feat(01-03): idb DB + localStorage fallback adapter (CORE-04) + last-3 snapshot retention + persist API [GREEN]

- db.ts: openSaveDB() opens IndexedDB ('tlg-save', v1) with two object
  stores (saves singleton + save_snapshots keyed); on openDB rejection
  (private mode, blocked, quota exceeded) falls back to LocalStorageDBAdapter
  per CORE-04 contract
- db-localstorage-adapter.ts: ~110-LoC adapter exposing the same minimal
  get/put/delete/getAll/transaction surface as idb's IDBPDatabase, namespaced
  under tlg.saves.<id> and tlg.save_snapshots.<id>; transaction() shim
  proxies straight through (localStorage has no real transactions)
- snapshots.ts: snapshot(envelope) writes to save_snapshots and prunes to
  RETAIN=3 newest by savedAt descending (CORE-08); listSnapshots() returns
  newest-first; entropy suffix on snapshot IDs avoids same-ms collisions
- persist.ts: requestPersistence() returns {granted, apiAvailable} for all
  4 navigator.storage scenarios per CORE-05 + RESEARCH Pitfall 2

Test infra fixes: snapshots.test.ts and db.test.ts cannot deleteDatabase
between tests because openSaveDB leaves an open connection that idb caches
(deleteDatabase blocks indefinitely). beforeEach instead clears store
contents directly. The fallback test calls vi.resetModules() BEFORE
vi.doMock('idb') so the freshly-imported db.ts picks up the rejecting
openDB stub, and re-imports LocalStorageDBAdapter from the same module
graph so instanceof checks against the same class identity.

Tests: 12/12 pass (npx vitest run src/save/db.test.ts
src/save/snapshots.test.ts src/save/persist.test.ts).
Full save suite: 33/33 pass (Task 1 + Task 2 combined).
TypeScript-strict; no 'any' in production code (CLAUDE.md).
This commit is contained in:
2026-05-08 23:36:20 -04:00
parent e2d82ffa90
commit 0b1425d4f6
6 changed files with 353 additions and 10 deletions
+59
View File
@@ -0,0 +1,59 @@
import { openSaveDB } from './db';
import type { SaveEnvelope } from './envelope';
export interface SnapshotEntry {
id: string;
schemaVersion: number;
savedAt: string;
envelope: SaveEnvelope;
}
/**
* Last-N pre-migration snapshot retention. CORE-08 mandates exactly 3.
* If this needs to grow (e.g., Season 4 prestige rollback), update the
* constant; do NOT make it a parameter — the single value across the app
* is part of the contract Phase 2's settings UI shows the user.
*/
const RETAIN = 3;
/**
* Write a pre-migration snapshot. After every write, prune to the
* `RETAIN` newest entries by savedAt (descending). Works against both
* IndexedDB and localStorage backends — `db.transaction(...).objectStore(...)`
* is the common shape exposed by both.
*
* The snapshot ID includes a small entropy suffix because two snapshots
* can fire in the same millisecond in tests (and theoretically in
* production during a Phase-2 migration burst).
*/
export async function snapshot(envelope: SaveEnvelope): Promise<void> {
const db = await openSaveDB();
const tx = db.transaction('save_snapshots', 'readwrite');
const store = tx.objectStore('save_snapshots');
const savedAt = new Date().toISOString();
const id = `${envelope.schemaVersion}-${savedAt}-${Math.random()
.toString(36)
.slice(2, 8)}`;
await store.put({
id,
schemaVersion: envelope.schemaVersion,
savedAt,
envelope,
});
// Prune oldest beyond RETAIN
const all = await store.getAll();
const sorted = all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
const toDelete = sorted.slice(RETAIN);
await Promise.all(toDelete.map((e) => store.delete(e.id)));
await tx.done;
}
/**
* List snapshots in newest-first order. Returns `[]` on an empty store.
*/
export async function listSnapshots(): Promise<SnapshotEntry[]> {
const db = await openSaveDB();
const all = await db.getAll('save_snapshots');
return all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
}