The cache is a rebuildable index over gitea, never a source of truth (D4). Two tests lock that invariant where the durable cache actually lives: - packages/core: cache-purity-v0.test.ts — file-backed node:sqlite. Build the SQLite mirror from a gitea snapshot, capture every re-derived field, delete the .sqlite file, rebuild from the same snapshot, assert byte-identical. Plus a structural D4 guard: every issues-table column must map to a gitea field, so a future user-authored column can't silently break rebuild-ability. - apps/desktop: snapshot-store.test.ts — the shipped durable cache is the JSON snapshot-store. Delete the file → loadSnapshot returns null (degrades to no-cache, never throws), which is what forces the next getSnapshot to reconcile fresh from gitea. Corrupt/partial files are likewise treated as no-cache. Stands up vitest for the desktop main process (first unit tests there); electron is mocked, snapshot path is injected. No native better-sqlite3 shipped: the SQLite mirror has no consumer on any hot path yet, so wiring it into main (native module + asarUnpack + dmg re-verify) would add packaging risk for no runtime benefit. The purity invariant is proven at the seam for both caches; the native driver migration is deferred until SQLite becomes load-bearing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
53 lines
2.0 KiB
TypeScript
53 lines
2.0 KiB
TypeScript
/**
|
|
* Durable snapshot store — the reconcile cache, persisted to disk. On boot the
|
|
* app shows the last snapshot instantly (stale-while-revalidate) instead of a
|
|
* blank board while ~2N gitea calls run; if gitea is unreachable, reads fall
|
|
* back to it (offline). It's a rebuildable mirror — the durable truth stays in
|
|
* gitea (the purity split, D4). A plain JSON file: the whole snapshot fits in
|
|
* memory at this scale, so indexed SQL buys nothing yet (see the PR).
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
|
|
import { app } from 'electron'
|
|
|
|
/** The shape we persist — kept loose so a schema drift degrades to "no cache", not a crash. */
|
|
export interface PersistedSnapshot {
|
|
issues: unknown[]
|
|
milestones: unknown[]
|
|
deps: unknown[]
|
|
timelines: Record<number, unknown[]>
|
|
/** ISO time the snapshot was reconciled — shown as "cached since". */
|
|
savedAt: string
|
|
}
|
|
|
|
function snapshotPath(): string {
|
|
return join(app.getPath('userData'), 'commitea-snapshot.json')
|
|
}
|
|
|
|
/**
|
|
* Load the last persisted snapshot, or null if absent/corrupt. Never throws — a
|
|
* deleted or unreadable cache degrades to "no cache" (the purity guarantee, D4:
|
|
* the next reconcile rebuilds it from gitea). `path` is injectable for tests;
|
|
* production always uses the userData file.
|
|
*/
|
|
export function loadSnapshot(path: string = snapshotPath()): PersistedSnapshot | null {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(path, 'utf8')) as PersistedSnapshot
|
|
if (parsed && Array.isArray(parsed.issues)) return parsed
|
|
return null
|
|
} catch {
|
|
return null // missing file, bad JSON, or drift — treat as no cache
|
|
}
|
|
}
|
|
|
|
/** Persist a freshly reconciled snapshot. Best-effort — a write failure never breaks a reconcile. */
|
|
export function saveSnapshot(snap: Omit<PersistedSnapshot, 'savedAt'>, savedAt: string, path: string = snapshotPath()): void {
|
|
try {
|
|
writeFileSync(path, JSON.stringify({ ...snap, savedAt }), 'utf8')
|
|
} catch {
|
|
// disk full / permissions — the in-memory cache still works this session
|
|
}
|
|
}
|