diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 00:02:32 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 00:02:32 +0900 |
| commit | 312ed5a9fe0d7f2660a66d61baf429b8e021ffb1 (patch) | |
| tree | dad46b8b88a6bc5629d50f18912ff944eaef4e95 /src/features/conversation-cache/logic.ts | |
| parent | a062045579de1bd0903f0f2c7f3e742162bcb7cc (diff) | |
| download | dispatch-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.ts | 77 |
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; +} |
