summaryrefslogtreecommitdiffhomepage
path: root/src/features/conversation-cache/cache.ts
blob: 4aab487ac8dcb990a4dc229926980ee8a207a745 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import type { StoredChunk } from "@dispatch/wire";
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 + 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>;

	/**
	 * Evict conversations over budget.
	 * Returns the evicted conversationIds.
	 */
	evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>;
}

export interface ConversationCacheOptions {
	/** Maximum total chunks across all conversations before eviction triggers. */
	readonly maxChunks?: number;
}

const DEFAULT_MAX_CHUNKS = 10_000;

/**
 * Create a conversation cache backed by the injected storage port.
 *
 * The ONLY impurity is the injected `store`; all logic delegates to pure functions.
 */
export function createConversationCache(
	store: ConversationChunkStore,
	opts?: ConversationCacheOptions,
): ConversationCache {
	const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS;

	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 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;
		},
	};
}