feat: persist the reconcile cache to disk — instant boot + offline reads
Extends the in-memory cache into a durable mirror. The reconcile snapshot is written to disk on every successful reconcile; on boot the app shows it instantly (stale-while-revalidate) instead of a blank board, and if gitea is unreachable, reads fall back to it (offline). Rebuildable — the durable truth stays in gitea. - snapshot-store.ts: load/save the snapshot as JSON in app userData (never throws; corrupt/absent → "no cache"). At this scale (~34 issues, 37KB) the whole snapshot fits in memory, so a JSON file beats indexed SQL — no query benefit yet, no native-module (better-sqlite3/electron-rebuild) or WASM dependency. That's the next step if the mirror ever needs indexed queries over larger data. - gitea.ts: getSnapshot persists on a fresh pull; bootSnapshot() returns the persisted snapshot (without seeding the cache — agents still reconcile fresh); gitea:boot serves it; gitea:reconcile falls back to it on failure (stale:true). - useBacklog: stale-while-revalidate — boot instantly, then a fresh reconcile supersedes; a reconcile error keeps the shown snapshot instead of erroring. Verified: desktop typecheck clean, 14 fixture e2e green. Live: the snapshot persists (34 issues / 44 deps / 34 timelines / 5 milestones written to disk); a second launch with gitea unreachable renders the full real board — NOW/NEXT/LATER + the Monte Carlo cone — entirely from the cache (new live-persistence e2e). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
47
apps/desktop/src/main/snapshot-store.ts
Normal file
47
apps/desktop/src/main/snapshot-store.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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. */
|
||||
export function loadSnapshot(): PersistedSnapshot | null {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(snapshotPath(), '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): void {
|
||||
try {
|
||||
writeFileSync(snapshotPath(), JSON.stringify({ ...snap, savedAt }), 'utf8')
|
||||
} catch {
|
||||
// disk full / permissions — the in-memory cache still works this session
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user