Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-core/src/review-cache.ts

Snapshot at 34e3175

Containing directory

3,368 bytes · typescriptView raw
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