summaryrefslogtreecommitdiffhomepage
path: root/src/features/conversation-cache
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/conversation-cache')
-rw-r--r--src/features/conversation-cache/cache.test.ts326
-rw-r--r--src/features/conversation-cache/cache.ts98
-rw-r--r--src/features/conversation-cache/index.ts10
-rw-r--r--src/features/conversation-cache/logic.test.ts248
-rw-r--r--src/features/conversation-cache/logic.ts82
-rw-r--r--src/features/conversation-cache/types.ts38
6 files changed, 401 insertions, 401 deletions
diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts
index 89e81b8..ce1c60c 100644
--- a/src/features/conversation-cache/cache.test.ts
+++ b/src/features/conversation-cache/cache.test.ts
@@ -4,9 +4,9 @@ import { createConversationCache } from "./cache";
import type { ConversationCacheIndexEntry, ConversationChunkStore } from "./types";
const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({
- seq,
- role,
- chunk: { type: "text", text: `chunk-${seq}` },
+ seq,
+ role,
+ chunk: { type: "text", text: `chunk-${seq}` },
});
/**
@@ -14,184 +14,184 @@ const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk =>
* An outermost edge: simulates the storage port without any real I/O.
*/
function createFakeStore(): ConversationChunkStore {
- const store = new Map<string, StoredChunk[]>();
-
- return {
- async load(conversationId) {
- return store.get(conversationId) ?? [];
- },
-
- async append(conversationId, chunks) {
- const existing = store.get(conversationId) ?? [];
- const existingSeqs = new Set(existing.map((c) => c.seq));
- const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq));
- store.set(
- conversationId,
- [...existing, ...toAdd].sort((a, b) => a.seq - b.seq),
- );
- },
-
- async delete(conversationId) {
- store.delete(conversationId);
- },
-
- async index() {
- const entries: ConversationCacheIndexEntry[] = [];
- for (const [id, chunks] of store) {
- if (chunks.length === 0) continue;
- let maxSeq = 0;
- for (const c of chunks) {
- if (c.seq > maxSeq) maxSeq = c.seq;
- }
- entries.push({
- conversationId: id,
- chunkCount: chunks.length,
- maxSeq,
- });
- }
- return entries;
- },
- };
+ const store = new Map<string, StoredChunk[]>();
+
+ return {
+ async load(conversationId) {
+ return store.get(conversationId) ?? [];
+ },
+
+ async append(conversationId, chunks) {
+ const existing = store.get(conversationId) ?? [];
+ const existingSeqs = new Set(existing.map((c) => c.seq));
+ const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq));
+ store.set(
+ conversationId,
+ [...existing, ...toAdd].sort((a, b) => a.seq - b.seq),
+ );
+ },
+
+ async delete(conversationId) {
+ store.delete(conversationId);
+ },
+
+ async index() {
+ const entries: ConversationCacheIndexEntry[] = [];
+ for (const [id, chunks] of store) {
+ if (chunks.length === 0) continue;
+ let maxSeq = 0;
+ for (const c of chunks) {
+ if (c.seq > maxSeq) maxSeq = c.seq;
+ }
+ entries.push({
+ conversationId: id,
+ chunkCount: chunks.length,
+ maxSeq,
+ });
+ }
+ return entries;
+ },
+ };
}
describe("cache.load", () => {
- it("returns stored chunks", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
- await store.append("conv-1", [chunk(1), chunk(2)]);
- const result = await cache.load("conv-1");
- expect(result).toEqual([chunk(1), chunk(2)]);
- });
-
- it("returns empty array for absent conversation", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
- const result = await cache.load("nonexistent");
- expect(result).toEqual([]);
- });
+ it("returns stored chunks", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("conv-1", [chunk(1), chunk(2)]);
+ const result = await cache.load("conv-1");
+ expect(result).toEqual([chunk(1), chunk(2)]);
+ });
+
+ it("returns empty array for absent conversation", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ const result = await cache.load("nonexistent");
+ expect(result).toEqual([]);
+ });
});
describe("cache.commit", () => {
- it("appends only new chunks", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
- await store.append("conv-1", [chunk(1), chunk(2)]);
-
- const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]);
- expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]);
-
- // Verify store has all chunks
- const stored = await store.load("conv-1");
- expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]);
- });
-
- it("returns full merged result", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
-
- const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]);
- expect(merged).toEqual([chunk(1), chunk(3)]);
- });
-
- it("is idempotent — re-committing same chunks is a no-op", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
-
- await cache.commit("conv-1", [chunk(1), chunk(2)]);
- const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]);
- expect(merged).toEqual([chunk(1), chunk(2)]);
-
- const stored = await store.load("conv-1");
- expect(stored).toEqual([chunk(1), chunk(2)]);
- });
+ it("appends only new chunks", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("conv-1", [chunk(1), chunk(2)]);
+
+ const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]);
+ expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]);
+
+ // Verify store has all chunks
+ const stored = await store.load("conv-1");
+ expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]);
+ });
+
+ it("returns full merged result", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+
+ const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]);
+ expect(merged).toEqual([chunk(1), chunk(3)]);
+ });
+
+ it("is idempotent — re-committing same chunks is a no-op", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+
+ await cache.commit("conv-1", [chunk(1), chunk(2)]);
+ const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]);
+ expect(merged).toEqual([chunk(1), chunk(2)]);
+
+ const stored = await store.load("conv-1");
+ expect(stored).toEqual([chunk(1), chunk(2)]);
+ });
});
describe("cache.sinceSeq", () => {
- it("returns max seq from cache", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
- await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]);
- expect(await cache.sinceSeq("conv-1")).toBe(5);
- });
-
- it("returns 0 for empty conversation", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
- expect(await cache.sinceSeq("conv-1")).toBe(0);
- });
+ it("returns max seq from cache", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]);
+ expect(await cache.sinceSeq("conv-1")).toBe(5);
+ });
+
+ it("returns 0 for empty conversation", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ expect(await cache.sinceSeq("conv-1")).toBe(0);
+ });
});
describe("cache.evictIfOverBudget", () => {
- it("deletes selected conversations", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store, { maxChunks: 5 });
-
- await store.append("a", [chunk(1), chunk(2)]);
- await store.append("b", [chunk(1), chunk(2)]);
- await store.append("c", [chunk(1)]);
-
- // Total = 5, max = 5, under budget
- const evicted = await cache.evictIfOverBudget(null);
- expect(evicted).toEqual([]);
-
- // Add more to go over budget
- await store.append("d", [chunk(1), chunk(2), chunk(3)]);
- // Total = 8, max = 5, need to evict 3+ chunks
-
- const evicted2 = await cache.evictIfOverBudget(null);
- expect(evicted2.length).toBeGreaterThan(0);
-
- // Verify evicted conversations are deleted
- for (const id of evicted2) {
- expect(await store.load(id)).toEqual([]);
- }
- });
-
- it("never evicts the active conversation", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store, { maxChunks: 3 });
-
- await store.append("active", [chunk(1), chunk(2), chunk(3)]);
- await store.append("other", [chunk(1), chunk(2)]);
-
- // Total = 5, max = 3, need to evict 2+ chunks
- const evicted = await cache.evictIfOverBudget("active");
- expect(evicted).not.toContain("active");
- expect(evicted).toContain("other");
- });
-
- it("returns empty when under budget", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store, { maxChunks: 100 });
-
- await store.append("a", [chunk(1)]);
- await store.append("b", [chunk(1)]);
-
- const evicted = await cache.evictIfOverBudget(null);
- expect(evicted).toEqual([]);
- });
+ it("deletes selected conversations", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store, { maxChunks: 5 });
+
+ await store.append("a", [chunk(1), chunk(2)]);
+ await store.append("b", [chunk(1), chunk(2)]);
+ await store.append("c", [chunk(1)]);
+
+ // Total = 5, max = 5, under budget
+ const evicted = await cache.evictIfOverBudget(null);
+ expect(evicted).toEqual([]);
+
+ // Add more to go over budget
+ await store.append("d", [chunk(1), chunk(2), chunk(3)]);
+ // Total = 8, max = 5, need to evict 3+ chunks
+
+ const evicted2 = await cache.evictIfOverBudget(null);
+ expect(evicted2.length).toBeGreaterThan(0);
+
+ // Verify evicted conversations are deleted
+ for (const id of evicted2) {
+ expect(await store.load(id)).toEqual([]);
+ }
+ });
+
+ it("never evicts the active conversation", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store, { maxChunks: 3 });
+
+ await store.append("active", [chunk(1), chunk(2), chunk(3)]);
+ await store.append("other", [chunk(1), chunk(2)]);
+
+ // Total = 5, max = 3, need to evict 2+ chunks
+ const evicted = await cache.evictIfOverBudget("active");
+ expect(evicted).not.toContain("active");
+ expect(evicted).toContain("other");
+ });
+
+ it("returns empty when under budget", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store, { maxChunks: 100 });
+
+ await store.append("a", [chunk(1)]);
+ await store.append("b", [chunk(1)]);
+
+ const evicted = await cache.evictIfOverBudget(null);
+ expect(evicted).toEqual([]);
+ });
});
describe("cache.delete", () => {
- it("removes the conversation from the store", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
+ it("removes the conversation from the store", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
- await store.append("conv-1", [chunk(1), chunk(2)]);
- await cache.delete("conv-1");
+ await store.append("conv-1", [chunk(1), chunk(2)]);
+ await cache.delete("conv-1");
- const stored = await store.load("conv-1");
- expect(stored).toEqual([]);
- });
+ const stored = await store.load("conv-1");
+ expect(stored).toEqual([]);
+ });
- it("then load returns []", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
+ it("then load returns []", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
- await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]);
- await cache.delete("conv-1");
+ await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]);
+ await cache.delete("conv-1");
- const result = await cache.load("conv-1");
- expect(result).toEqual([]);
- });
+ const result = await cache.load("conv-1");
+ expect(result).toEqual([]);
+ });
});
diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts
index 3d5743a..4953944 100644
--- a/src/features/conversation-cache/cache.ts
+++ b/src/features/conversation-cache/cache.ts
@@ -3,31 +3,31 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic";
import type { ConversationChunkStore } from "./types";
export interface ConversationCache {
- /** Load all cached chunks for a conversation. */
- load(conversationId: string): Promise<readonly StoredChunk[]>;
+ /** Load all cached chunks for a conversation. */
+ load(conversationId: string): Promise<readonly StoredChunk[]>;
- /**
- * Load + reconcile + append new chunks.
- * Returns the merged cache (the new authoritative cache for this conversation).
- */
- commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>;
+ /**
+ * Load + reconcile + append new chunks.
+ * Returns the merged cache (the new authoritative cache for this conversation).
+ */
+ commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>;
- /** Return the `?sinceSeq=` cursor for the next incremental sync. */
- sinceSeq(conversationId: string): Promise<number>;
+ /** Return the `?sinceSeq=` cursor for the next incremental sync. */
+ sinceSeq(conversationId: string): Promise<number>;
- /**
- * Evict conversations over budget.
- * Returns the evicted conversationIds.
- */
- evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>;
+ /**
+ * Evict conversations over budget.
+ * Returns the evicted conversationIds.
+ */
+ evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>;
- /** Delete all cached data for a single conversation (local forget). */
- delete(conversationId: string): Promise<void>;
+ /** Delete all cached data for a single conversation (local forget). */
+ delete(conversationId: string): Promise<void>;
}
export interface ConversationCacheOptions {
- /** Maximum total chunks across all conversations before eviction triggers. */
- readonly maxChunks?: number;
+ /** Maximum total chunks across all conversations before eviction triggers. */
+ readonly maxChunks?: number;
}
const DEFAULT_MAX_CHUNKS = 10_000;
@@ -38,41 +38,41 @@ const DEFAULT_MAX_CHUNKS = 10_000;
* The ONLY impurity is the injected `store`; all logic delegates to pure functions.
*/
export function createConversationCache(
- store: ConversationChunkStore,
- opts?: ConversationCacheOptions,
+ store: ConversationChunkStore,
+ opts?: ConversationCacheOptions,
): ConversationCache {
- const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS;
+ const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS;
- return {
- async load(conversationId) {
- return store.load(conversationId);
- },
+ return {
+ async load(conversationId) {
+ return store.load(conversationId);
+ },
- async commit(conversationId, incoming) {
- const cached = await store.load(conversationId);
- const { merged, toAppend } = reconcileCache(cached, incoming);
- if (toAppend.length > 0) {
- await store.append(conversationId, toAppend);
- }
- return merged;
- },
+ async commit(conversationId, incoming) {
+ const cached = await store.load(conversationId);
+ const { merged, toAppend } = reconcileCache(cached, incoming);
+ if (toAppend.length > 0) {
+ await store.append(conversationId, toAppend);
+ }
+ return merged;
+ },
- async sinceSeq(conversationId) {
- const cached = await store.load(conversationId);
- return nextSinceSeq(cached);
- },
+ async sinceSeq(conversationId) {
+ const cached = await store.load(conversationId);
+ return nextSinceSeq(cached);
+ },
- async evictIfOverBudget(activeConversationId) {
- const idx = await store.index();
- const toEvict = selectEvictions(idx, { maxChunks, activeConversationId });
- for (const id of toEvict) {
- await store.delete(id);
- }
- return toEvict;
- },
+ async evictIfOverBudget(activeConversationId) {
+ const idx = await store.index();
+ const toEvict = selectEvictions(idx, { maxChunks, activeConversationId });
+ for (const id of toEvict) {
+ await store.delete(id);
+ }
+ return toEvict;
+ },
- async delete(conversationId) {
- await store.delete(conversationId);
- },
- };
+ async delete(conversationId) {
+ await store.delete(conversationId);
+ },
+ };
}
diff --git a/src/features/conversation-cache/index.ts b/src/features/conversation-cache/index.ts
index 32e32d9..25e2af7 100644
--- a/src/features/conversation-cache/index.ts
+++ b/src/features/conversation-cache/index.ts
@@ -2,13 +2,13 @@ export type { ConversationCache, ConversationCacheOptions } from "./cache";
export { createConversationCache } from "./cache";
export { nextSinceSeq, reconcileCache, selectEvictions } from "./logic";
export type {
- ConversationCacheIndexEntry,
- ConversationChunkStore,
- ReconcileResult,
+ ConversationCacheIndexEntry,
+ ConversationChunkStore,
+ ReconcileResult,
} from "./types";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "conversation-cache",
- description: "IndexedDB-backed chunk cache with reconciliation",
+ name: "conversation-cache",
+ description: "IndexedDB-backed chunk cache with reconciliation",
} as const;
diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts
index 858460a..880c32e 100644
--- a/src/features/conversation-cache/logic.test.ts
+++ b/src/features/conversation-cache/logic.test.ts
@@ -4,137 +4,137 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic";
import type { ConversationCacheIndexEntry } from "./types";
const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({
- seq,
- role,
- chunk: { type: "text", text: `chunk-${seq}` },
+ seq,
+ role,
+ chunk: { type: "text", text: `chunk-${seq}` },
});
describe("reconcileCache", () => {
- it("merges and dedupes by seq", () => {
- const cached = [chunk(1), chunk(2)];
- const incoming = [chunk(2), chunk(3)];
- const result = reconcileCache(cached, incoming);
- expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]);
- });
-
- it("toAppend excludes already-cached seqs", () => {
- const cached = [chunk(1), chunk(2)];
- const incoming = [chunk(2), chunk(3)];
- const result = reconcileCache(cached, incoming);
- expect(result.toAppend).toEqual([chunk(3)]);
- });
-
- it("tolerates out-of-order incoming", () => {
- const cached = [chunk(1)];
- const incoming = [chunk(5), chunk(3), chunk(2)];
- const result = reconcileCache(cached, incoming);
- expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]);
- expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]);
- });
-
- it("returns empty merged and toAppend when both inputs are empty", () => {
- const result = reconcileCache([], []);
- expect(result.merged).toEqual([]);
- expect(result.toAppend).toEqual([]);
- });
-
- it("handles empty cached with incoming", () => {
- const incoming = [chunk(3), chunk(1)];
- const result = reconcileCache([], incoming);
- expect(result.merged).toEqual([chunk(1), chunk(3)]);
- expect(result.toAppend).toEqual([chunk(3), chunk(1)]);
- });
-
- it("handles cached with empty incoming", () => {
- const cached = [chunk(1), chunk(2)];
- const result = reconcileCache(cached, []);
- expect(result.merged).toEqual([chunk(1), chunk(2)]);
- expect(result.toAppend).toEqual([]);
- });
-
- it("is idempotent — re-reconciling same incoming produces same result", () => {
- const cached = [chunk(1)];
- const incoming = [chunk(2), chunk(3)];
- const first = reconcileCache(cached, incoming);
- const second = reconcileCache(first.merged, incoming);
- expect(second.merged).toEqual(first.merged);
- expect(second.toAppend).toEqual([]);
- });
+ it("merges and dedupes by seq", () => {
+ const cached = [chunk(1), chunk(2)];
+ const incoming = [chunk(2), chunk(3)];
+ const result = reconcileCache(cached, incoming);
+ expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]);
+ });
+
+ it("toAppend excludes already-cached seqs", () => {
+ const cached = [chunk(1), chunk(2)];
+ const incoming = [chunk(2), chunk(3)];
+ const result = reconcileCache(cached, incoming);
+ expect(result.toAppend).toEqual([chunk(3)]);
+ });
+
+ it("tolerates out-of-order incoming", () => {
+ const cached = [chunk(1)];
+ const incoming = [chunk(5), chunk(3), chunk(2)];
+ const result = reconcileCache(cached, incoming);
+ expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]);
+ expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]);
+ });
+
+ it("returns empty merged and toAppend when both inputs are empty", () => {
+ const result = reconcileCache([], []);
+ expect(result.merged).toEqual([]);
+ expect(result.toAppend).toEqual([]);
+ });
+
+ it("handles empty cached with incoming", () => {
+ const incoming = [chunk(3), chunk(1)];
+ const result = reconcileCache([], incoming);
+ expect(result.merged).toEqual([chunk(1), chunk(3)]);
+ expect(result.toAppend).toEqual([chunk(3), chunk(1)]);
+ });
+
+ it("handles cached with empty incoming", () => {
+ const cached = [chunk(1), chunk(2)];
+ const result = reconcileCache(cached, []);
+ expect(result.merged).toEqual([chunk(1), chunk(2)]);
+ expect(result.toAppend).toEqual([]);
+ });
+
+ it("is idempotent — re-reconciling same incoming produces same result", () => {
+ const cached = [chunk(1)];
+ const incoming = [chunk(2), chunk(3)];
+ const first = reconcileCache(cached, incoming);
+ const second = reconcileCache(first.merged, incoming);
+ expect(second.merged).toEqual(first.merged);
+ expect(second.toAppend).toEqual([]);
+ });
});
describe("nextSinceSeq", () => {
- it("returns max seq", () => {
- const cached = [chunk(1), chunk(5), chunk(3)];
- expect(nextSinceSeq(cached)).toBe(5);
- });
-
- it("returns 0 when empty", () => {
- expect(nextSinceSeq([])).toBe(0);
- });
-
- it("returns single seq for single chunk", () => {
- expect(nextSinceSeq([chunk(42)])).toBe(42);
- });
+ it("returns max seq", () => {
+ const cached = [chunk(1), chunk(5), chunk(3)];
+ expect(nextSinceSeq(cached)).toBe(5);
+ });
+
+ it("returns 0 when empty", () => {
+ expect(nextSinceSeq([])).toBe(0);
+ });
+
+ it("returns single seq for single chunk", () => {
+ expect(nextSinceSeq([chunk(42)])).toBe(42);
+ });
});
describe("selectEvictions", () => {
- it("never evicts the active conversation", () => {
- const index: ConversationCacheIndexEntry[] = [
- { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 },
- { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 },
- ];
- const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" });
- expect(result).not.toContain("active");
- expect(result).toContain("other");
- });
-
- it("evicts LRU until under budget", () => {
- const index: ConversationCacheIndexEntry[] = [
- { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 },
- { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 },
- { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 },
- { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 },
- ];
- // Total = 120, max = 60, need to evict 60+ chunks
- // LRU order: d(10), b(50), a(100), c(200)
- const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null });
- expect(result).toEqual(["d", "b"]);
- });
-
- it("is a no-op under budget", () => {
- const index: ConversationCacheIndexEntry[] = [
- { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 },
- { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 },
- ];
- const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null });
- expect(result).toEqual([]);
- });
-
- it("returns empty for empty index", () => {
- const result = selectEvictions([], { maxChunks: 100, activeConversationId: null });
- expect(result).toEqual([]);
- });
-
- it("tie-breaks by smaller maxSeq when lastAccess is equal", () => {
- const index: ConversationCacheIndexEntry[] = [
- { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 },
- { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 },
- { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 },
- ];
- // Total = 90, max = 60, need to evict 30+ chunks
- // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200)
- const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null });
- expect(result).toEqual(["b"]);
- });
-
- it("handles missing lastAccess (treated as 0)", () => {
- const index: ConversationCacheIndexEntry[] = [
- { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 },
- { conversationId: "b", chunkCount: 30, maxSeq: 30 },
- ];
- // Total = 60, max = 30, need to evict 30+ chunks
- // b has no lastAccess (0), a has 100
- const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null });
- expect(result).toEqual(["b"]);
- });
+ it("never evicts the active conversation", () => {
+ const index: ConversationCacheIndexEntry[] = [
+ { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 },
+ { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 },
+ ];
+ const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" });
+ expect(result).not.toContain("active");
+ expect(result).toContain("other");
+ });
+
+ it("evicts LRU until under budget", () => {
+ const index: ConversationCacheIndexEntry[] = [
+ { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 },
+ { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 },
+ { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 },
+ { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 },
+ ];
+ // Total = 120, max = 60, need to evict 60+ chunks
+ // LRU order: d(10), b(50), a(100), c(200)
+ const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null });
+ expect(result).toEqual(["d", "b"]);
+ });
+
+ it("is a no-op under budget", () => {
+ const index: ConversationCacheIndexEntry[] = [
+ { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 },
+ { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 },
+ ];
+ const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null });
+ expect(result).toEqual([]);
+ });
+
+ it("returns empty for empty index", () => {
+ const result = selectEvictions([], { maxChunks: 100, activeConversationId: null });
+ expect(result).toEqual([]);
+ });
+
+ it("tie-breaks by smaller maxSeq when lastAccess is equal", () => {
+ const index: ConversationCacheIndexEntry[] = [
+ { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 },
+ { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 },
+ { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 },
+ ];
+ // Total = 90, max = 60, need to evict 30+ chunks
+ // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200)
+ const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null });
+ expect(result).toEqual(["b"]);
+ });
+
+ it("handles missing lastAccess (treated as 0)", () => {
+ const index: ConversationCacheIndexEntry[] = [
+ { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 },
+ { conversationId: "b", chunkCount: 30, maxSeq: 30 },
+ ];
+ // Total = 60, max = 30, need to evict 30+ chunks
+ // b has no lastAccess (0), a has 100
+ const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null });
+ expect(result).toEqual(["b"]);
+ });
});
diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts
index 4a4479e..3cb23e2 100644
--- a/src/features/conversation-cache/logic.ts
+++ b/src/features/conversation-cache/logic.ts
@@ -9,24 +9,24 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types";
* (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`.
*/
export function reconcileCache(
- cached: readonly StoredChunk[],
- incoming: readonly StoredChunk[],
+ cached: readonly StoredChunk[],
+ incoming: readonly StoredChunk[],
): ReconcileResult {
- const seen = new Set<number>();
- for (const chunk of cached) {
- seen.add(chunk.seq);
- }
+ 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 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 };
+ const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq);
+ return { merged, toAppend };
}
/**
@@ -34,12 +34,12 @@ export function reconcileCache(
* 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;
+ if (cached.length === 0) return 0;
+ let max = 0;
+ for (const chunk of cached) {
+ if (chunk.seq > max) max = chunk.seq;
+ }
+ return max;
}
/**
@@ -50,28 +50,28 @@ export function nextSinceSeq(cached: readonly StoredChunk[]): number {
* Returns [] when under budget.
*/
export function selectEvictions(
- index: readonly ConversationCacheIndexEntry[],
- opts: { maxChunks: number; activeConversationId: string | null },
+ 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 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;
- });
+ 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;
- }
+ 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;
+ return evictions;
}
diff --git a/src/features/conversation-cache/types.ts b/src/features/conversation-cache/types.ts
index 2a349cc..345ab71 100644
--- a/src/features/conversation-cache/types.ts
+++ b/src/features/conversation-cache/types.ts
@@ -2,10 +2,10 @@ import type { StoredChunk } from "@dispatch/wire";
/** Metadata entry for a cached conversation, used by eviction logic. */
export interface ConversationCacheIndexEntry {
- readonly conversationId: string;
- readonly chunkCount: number;
- readonly maxSeq: number;
- readonly lastAccess?: number;
+ readonly conversationId: string;
+ readonly chunkCount: number;
+ readonly maxSeq: number;
+ readonly lastAccess?: number;
}
/**
@@ -17,26 +17,26 @@ export interface ConversationCacheIndexEntry {
* All methods MUST be idempotent on `seq`: re-appending an existing seq is a no-op.
*/
export interface ConversationChunkStore {
- /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */
- load(conversationId: string): Promise<readonly StoredChunk[]>;
+ /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */
+ load(conversationId: string): Promise<readonly StoredChunk[]>;
- /**
- * Append committed chunks to a conversation's cache.
- * MUST be idempotent on `seq`: re-appending an existing seq is a no-op.
- */
- append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void>;
+ /**
+ * Append committed chunks to a conversation's cache.
+ * MUST be idempotent on `seq`: re-appending an existing seq is a no-op.
+ */
+ append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void>;
- /** Delete all cached data for a conversation. */
- delete(conversationId: string): Promise<void>;
+ /** Delete all cached data for a conversation. */
+ delete(conversationId: string): Promise<void>;
- /** Return metadata for all cached conversations (for eviction). */
- index(): Promise<readonly ConversationCacheIndexEntry[]>;
+ /** Return metadata for all cached conversations (for eviction). */
+ index(): Promise<readonly ConversationCacheIndexEntry[]>;
}
/** Result of reconciling cached chunks with incoming authoritative chunks. */
export interface ReconcileResult {
- /** The merged, deduplicated, seq-ordered chunk list. */
- readonly merged: readonly StoredChunk[];
- /** The subset of incoming chunks that need to be appended (not already cached). */
- readonly toAppend: readonly StoredChunk[];
+ /** The merged, deduplicated, seq-ordered chunk list. */
+ readonly merged: readonly StoredChunk[];
+ /** The subset of incoming chunks that need to be appended (not already cached). */
+ readonly toAppend: readonly StoredChunk[];
}