summaryrefslogtreecommitdiffhomepage
path: root/src/features/conversation-cache/logic.ts
blob: 4a4479ea4505552c1cafdf976b4f319029267dd8 (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
72
73
74
75
76
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;
}