0b1425d4f6
- 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).
60 lines
2.0 KiB
TypeScript
60 lines
2.0 KiB
TypeScript
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));
|
|
}
|