Purity / rebuild guarantee test (#30)

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>
This commit is contained in:
Croissant Le Doux
2026-07-09 18:02:50 -04:00
parent b07f07c14d
commit 73643efcbc
5 changed files with 302 additions and 5 deletions

View File

@@ -26,10 +26,15 @@ function snapshotPath(): string {
return join(app.getPath('userData'), 'commitea-snapshot.json')
}
/** Load the last persisted snapshot, or null if absent/corrupt. Never throws. */
export function loadSnapshot(): PersistedSnapshot | null {
/**
* 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(snapshotPath(), 'utf8')) as PersistedSnapshot
const parsed = JSON.parse(readFileSync(path, 'utf8')) as PersistedSnapshot
if (parsed && Array.isArray(parsed.issues)) return parsed
return null
} catch {
@@ -38,9 +43,9 @@ export function loadSnapshot(): PersistedSnapshot | null {
}
/** Persist a freshly reconciled snapshot. Best-effort — a write failure never breaks a reconcile. */
export function saveSnapshot(snap: Omit<PersistedSnapshot, 'savedAt'>, savedAt: string): void {
export function saveSnapshot(snap: Omit<PersistedSnapshot, 'savedAt'>, savedAt: string, path: string = snapshotPath()): void {
try {
writeFileSync(snapshotPath(), JSON.stringify({ ...snap, savedAt }), 'utf8')
writeFileSync(path, JSON.stringify({ ...snap, savedAt }), 'utf8')
} catch {
// disk full / permissions — the in-memory cache still works this session
}