Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-core/src/review-cache.ts
Snapshot at 34e3175
1import type { ReviewReader } from "./review-detail";
2
3export type ReviewGitDetail = {
4 changes: Awaited<ReturnType<ReviewReader["changes"]>>["public"];
5} & Awaited<ReturnType<ReviewReader["history"]>>;
6type ReviewPatch = Awaited<ReturnType<ReviewReader["diff"]>>;
7interface CachedPair {
8 order: number;
9 detail?: ReviewGitDetail;
10 patches: ReviewPatch[];
11}
12
13// Version the representation independently from review records. UTF-8 JSON includes
14// bookkeeping and patches; leave ample room below the DO's per-value limit.
15export const REVIEW_CACHE_MAX_BYTES = 64 * 1024;
16export const REVIEW_CACHE_MAX_ENTRIES = 32;
17export const REVIEW_CACHE_PREFIX = "reviewGitCache:v1:pair:";
18const sequenceKey = "reviewGitCache:v1:sequence";
19const encoder = new TextEncoder();
20const keyFor = (base: string, head: string) => `${REVIEW_CACHE_PREFIX}${base}:${head}`;
21
22/** Project-local immutable Git data only. Call after authorization, within the
23 * Supervisor's serialization gate; never store an assembled review response. */
24export class ReviewCache {
25 constructor(private storage: DurableObjectStorage) {}
26
27 async detail(base: string, head: string, load: () => Promise<ReviewGitDetail>): Promise<ReviewGitDetail> {
28 const key = keyFor(base, head);
29 const cached = await this.storage.get<CachedPair>(key);
30 if (cached?.detail) return cached.detail;
31 // Await the whole read before storing anything. Throws and explicitly capped
32 // results leave the cache unchanged and retain the reader's public behavior.
33 const detail = await load();
34 if (!detail.changes.truncated && !detail.commitsTruncated)
35 await this.save(key, current => ({ ...current, detail }));
36 return detail;
37 }
38
39 async diff(base: string, head: string, path: string, load: () => Promise<{ patch: ReviewPatch; complete: boolean }>): Promise<ReviewPatch> {
40 const key = keyFor(base, head);
41 const cached = await this.storage.get<CachedPair>(key);
42 const patch = cached?.patches.find(candidate => candidate.path === path);
43 if (patch) return patch;
44 const { patch: result, complete } = await load();
45 if (complete && !result.truncated)
46 await this.save(key, current => ({ ...current,
47 patches: [...current.patches.filter(candidate => candidate.path !== path), result],
48 }));
49 return result;
50 }
51
52 private async save(key: string, update: (current: CachedPair) => CachedPair): Promise<void> {
53 await this.storage.transaction(async txn => {
54 const previous = await txn.get<CachedPair>(key);
55 const order = previous?.order ?? ((await txn.get<number>(sequenceKey)) ?? 0) + 1;
56 const value = update(previous ?? { order, patches: [] });
57 // A skipped update retains any smaller, complete values already cached.
58 if (encoder.encode(JSON.stringify(value)).length > REVIEW_CACHE_MAX_BYTES) return;
59 if (!previous) {
60 const entries = await txn.list<CachedPair>({ prefix: REVIEW_CACHE_PREFIX, limit: REVIEW_CACHE_MAX_ENTRIES + 1 });
61 const oldest = [...entries].toSorted((a, b) => a[1].order - b[1].order || a[0].localeCompare(b[0]));
62 const evict = oldest.slice(0, Math.max(0, entries.size - REVIEW_CACHE_MAX_ENTRIES + 1)).map(([entryKey]) => entryKey);
63 if (evict.length) await txn.delete(evict);
64 await txn.put(sequenceKey, order);
65 }
66 await txn.put(key, value);
67 });
68 }
69}
70