summaryrefslogtreecommitdiffhomepage
path: root/src/features/conversation-cache/logic.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 00:02:32 +0900
committerAdam Malczewski <[email protected]>2026-06-07 00:02:32 +0900
commit312ed5a9fe0d7f2660a66d61baf429b8e021ffb1 (patch)
treedad46b8b88a6bc5629d50f18912ff944eaef4e95 /src/features/conversation-cache/logic.ts
parenta062045579de1bd0903f0f2c7f3e742162bcb7cc (diff)
downloaddispatch-web-312ed5a9fe0d7f2660a66d61baf429b8e021ffb1.tar.gz
dispatch-web-312ed5a9fe0d7f2660a66d61baf429b8e021ffb1.zip
Slice 2 wave 1: transcript reducer, wire conformance, ws chat, cache core
- core/chunks: the one pure transcript reducer (foldEvent live deltas + applyHistory seq-keyed reconcile + selectChunks/selectMessages); 27 tests - core/wire: FE-side contract-conformance exhaustiveness guards + drift smoke tests over wire/transport-contract unions (ยง2.9 drift signal); 10 tests - adapters/ws: additively multiplex chat.send/chat.delta/chat.error on the existing surface socket (onChat + widened send); surface API unchanged - features/conversation-cache: pure reconcileCache/nextSinceSeq/selectEvictions + ConversationChunkStore port + injected createConversationCache; 26 tests Verified green: svelte-check 0/0, vitest 169, biome clean, build ok.
Diffstat (limited to 'src/features/conversation-cache/logic.ts')
-rw-r--r--src/features/conversation-cache/logic.ts77
1 files changed, 77 insertions, 0 deletions
diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts
new file mode 100644
index 0000000..4a4479e
--- /dev/null
+++ b/src/features/conversation-cache/logic.ts
@@ -0,0 +1,77 @@
+import type { StoredChunk } from "@dispatch/wire";
+import type { ConversationCacheIndexEntry, ReconcileResult } from "./types";
+
+/**
+ * Merge authoritative seq-keyed chunks with cached chunks.
+ *
+ * Deduplicates by `seq`, produces seq-monotonic order.
+ * `toAppend` = the incoming chunks whose `seq` is not already in `cached`
+ * (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`.
+ */
+export function reconcileCache(
+ cached: readonly StoredChunk[],
+ incoming: readonly StoredChunk[],
+): ReconcileResult {
+ const seen = new Set<number>();
+ for (const chunk of cached) {
+ seen.add(chunk.seq);
+ }
+
+ const toAppend: StoredChunk[] = [];
+ for (const chunk of incoming) {
+ if (!seen.has(chunk.seq)) {
+ toAppend.push(chunk);
+ seen.add(chunk.seq);
+ }
+ }
+
+ const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq);
+ return { merged, toAppend };
+}
+
+/**
+ * Return the max committed `seq`, or `0` if empty.
+ * This is the `?sinceSeq=` cursor for the next incremental sync.
+ */
+export function nextSinceSeq(cached: readonly StoredChunk[]): number {
+ if (cached.length === 0) return 0;
+ let max = 0;
+ for (const chunk of cached) {
+ if (chunk.seq > max) max = chunk.seq;
+ }
+ return max;
+}
+
+/**
+ * Choose conversationIds to evict to get total cached chunks under `maxChunks`.
+ *
+ * LRU eviction: oldest `lastAccess` first, tie-break smaller `maxSeq`.
+ * NEVER evicts the `activeConversationId`.
+ * Returns [] when under budget.
+ */
+export function selectEvictions(
+ index: readonly ConversationCacheIndexEntry[],
+ opts: { maxChunks: number; activeConversationId: string | null },
+): readonly string[] {
+ const totalChunks = index.reduce((sum, entry) => sum + entry.chunkCount, 0);
+ if (totalChunks <= opts.maxChunks) return [];
+
+ const candidates = index
+ .filter((entry) => entry.conversationId !== opts.activeConversationId)
+ .sort((a, b) => {
+ const aAccess = a.lastAccess ?? 0;
+ const bAccess = b.lastAccess ?? 0;
+ if (aAccess !== bAccess) return aAccess - bAccess;
+ return a.maxSeq - b.maxSeq;
+ });
+
+ let remaining = totalChunks;
+ const evictions: string[] = [];
+ for (const entry of candidates) {
+ if (remaining <= opts.maxChunks) break;
+ evictions.push(entry.conversationId);
+ remaining -= entry.chunkCount;
+ }
+
+ return evictions;
+}