diff --git a/src/save/db-localstorage-adapter.ts b/src/save/db-localstorage-adapter.ts new file mode 100644 index 0000000..50f0a51 --- /dev/null +++ b/src/save/db-localstorage-adapter.ts @@ -0,0 +1,128 @@ +import type { SaveEnvelope } from './envelope'; + +/** + * CORE-04 fallback path. When IndexedDB is unavailable (private mode, + * blocked by browser, quota exceeded, embedded contexts that disable IDB), + * `openSaveDB()` returns this adapter instead of an IDBPDatabase. The + * interface intersects with what `snapshots.ts` and Phase 2's save consumer + * actually call — `get`, `put`, `delete`, `getAll` on the two stores + * (`saves`, `save_snapshots`) plus a `transaction()` helper that, for + * localStorage, is a straight-through proxy (no real transaction semantics + * — single-threaded synchronous storage with no rollback). + * + * Per .planning/research/PITFALLS.md #8, multi-layer storage is the v1 + * contract; IndexedDB is primary, localStorage is the fallback when IDB + * throws. Phase 2's settings UI surfaces a "running on localStorage" + * notice when this path triggers. + */ + +export type StoreName = 'saves' | 'save_snapshots'; + +export interface SavedRecord { + id: string; + envelope: SaveEnvelope; + savedAt: string; +} + +export interface SnapshotRecord { + id: string; + schemaVersion: number; + savedAt: string; + envelope: SaveEnvelope; +} + +export type RecordOf = S extends 'saves' + ? SavedRecord + : SnapshotRecord; + +/** + * Namespace localStorage keys under the project prefix. Concrete keys + * produced are of the form `tlg.saves.` or `tlg.save_snapshots.`. + * Phase 2's import flow scans for these prefixes when migrating an existing + * localStorage user back to IndexedDB. + */ +function nsKey(store: StoreName, id: string): string { + return `tlg.${store}.${id}`; // produces tlg.saves. or tlg.save_snapshots. +} + +function nsPrefix(store: StoreName): string { + return `tlg.${store}.`; // matches `tlg.saves.` or `tlg.save_snapshots.` prefix +} + +/** + * Object-store proxy returned by `transaction(...).objectStore(...)`. Each + * operation is its own atomic localStorage call, since localStorage has no + * real transactions. The shape mirrors `idb`'s store interface so callers + * can use the same `db.transaction(...).objectStore(...).put(...)` pattern + * against both backends. + */ +interface LocalStorageObjectStore { + put: (value: RecordOf) => Promise; + get: (key: string) => Promise | undefined>; + delete: (key: string) => Promise; + getAll: () => Promise[]>; +} + +export class LocalStorageDBAdapter { + /** + * Mirrors `IDBPDatabase.objectStoreNames`. The save layer only ever + * checks `contains()` so we don't bother implementing the full + * `DOMStringList` shape. + */ + readonly objectStoreNames = { + contains: (s: string): boolean => s === 'saves' || s === 'save_snapshots', + }; + + async get( + store: S, + key: string, + ): Promise | undefined> { + const raw = localStorage.getItem(nsKey(store, key)); + return raw ? (JSON.parse(raw) as RecordOf) : undefined; + } + + async put(store: S, value: RecordOf): Promise { + localStorage.setItem(nsKey(store, value.id), JSON.stringify(value)); + } + + async delete(store: StoreName, key: string): Promise { + localStorage.removeItem(nsKey(store, key)); + } + + async getAll(store: S): Promise[]> { + const prefix = nsPrefix(store); + const out: RecordOf[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(prefix)) { + const raw = localStorage.getItem(k); + if (raw) out.push(JSON.parse(raw) as RecordOf); + } + } + return out; + } + + /** + * Transaction shim. localStorage has no real transactions — each set/ + * remove is its own atomic operation — but we expose the same shape as + * `idb.transaction()` so `snapshots.ts` (and any other consumer) can + * use the same `db.transaction(name, mode).objectStore(name)` pattern + * against both backends. `done` resolves immediately because there is + * nothing to commit. + */ + transaction( + store: S, + _mode: 'readwrite' | 'readonly', + ): { objectStore: (s: S) => LocalStorageObjectStore; done: Promise } { + const adapter = this; + return { + objectStore: (s: S): LocalStorageObjectStore => ({ + put: (value: RecordOf) => adapter.put(s, value), + get: (key: string) => adapter.get(s, key), + delete: (key: string) => adapter.delete(s, key), + getAll: () => adapter.getAll(s), + }), + done: Promise.resolve(), + }; + } +} diff --git a/src/save/db.test.ts b/src/save/db.test.ts index b56f461..036b2b8 100644 --- a/src/save/db.test.ts +++ b/src/save/db.test.ts @@ -1,20 +1,34 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import 'fake-indexeddb/auto'; // happy-dom doesn't ship IDB; fake-indexeddb is the polyfill -import { openSaveDB, SAVE_DB_NAME } from './db'; -import { wrap } from './envelope'; -import { LocalStorageDBAdapter } from './db-localstorage-adapter'; // Tests for the IndexedDB-primary + localStorage-fallback open path (CORE-04). // The IDB path uses `fake-indexeddb` (polyfill is auto-imported above). // The fallback path uses `vi.doMock('idb')` to inject an openDB rejection, // which forces openSaveDB to return a LocalStorageDBAdapter instead. +// +// Important: the fallback test uses `vi.resetModules()` + dynamic re-import, +// which produces a freshly-loaded copy of the LocalStorageDBAdapter class. +// We therefore re-import the adapter inside that test (so the `instanceof` +// check uses the same module identity) rather than at the top of the file. beforeEach(async () => { - // Reset IDB and localStorage between tests - indexedDB.deleteDatabase(SAVE_DB_NAME); + // We can't `indexedDB.deleteDatabase('tlg-save')` between tests because + // openSaveDB leaves an open connection behind that idb caches; the + // delete would block forever. Instead we clear the contents of both + // stores directly. localStorage is also cleared for the fallback test. localStorage.clear(); vi.unstubAllGlobals(); + // Use a fresh import path to avoid module-cache state from a prior test + // (e.g. one that vi.doMock'd 'idb' will have left a stale db.ts cached). vi.resetModules(); + const { openSaveDB } = await import('./db'); + const db = await openSaveDB(); + for (const store of ['saves', 'save_snapshots'] as const) { + const all = await db.getAll(store); + for (const e of all) { + await db.delete(store, e.id); + } + } }); afterEach(() => { @@ -23,12 +37,15 @@ afterEach(() => { describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => { it('opens a DB with saves and save_snapshots object stores', async () => { + const { openSaveDB } = await import('./db'); const db = await openSaveDB(); expect(db.objectStoreNames.contains('saves')).toBe(true); expect(db.objectStoreNames.contains('save_snapshots')).toBe(true); }); it('round-trips a SaveEnvelope through saves store', async () => { + const { openSaveDB } = await import('./db'); + const { wrap } = await import('./envelope'); const db = await openSaveDB(); const envelope = wrap({ hello: 'world' }, 1); await db.put('saves', { @@ -41,6 +58,8 @@ describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => { }); it('round-trips through save_snapshots store too', async () => { + const { openSaveDB } = await import('./db'); + const { wrap } = await import('./envelope'); const db = await openSaveDB(); const envelope = wrap({ snap: true }, 1); await db.put('save_snapshots', { @@ -56,17 +75,27 @@ describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => { describe('openSaveDB (CORE-04 localStorage fallback path)', () => { it('falls back to LocalStorageDBAdapter when IndexedDB is unavailable', async () => { + // Reset modules FIRST so the doMock below applies to a clean import + // graph (the global beforeEach already imported ./db with the real + // idb, which would otherwise be cache-served on the next import). + vi.resetModules(); // Stub the idb module's openDB so it rejects, simulating private mode / // blocked IDB / quota exceeded — anything that makes openDB throw. vi.doMock('idb', async () => ({ openDB: vi.fn().mockRejectedValue(new Error('IDB unavailable (test stub)')), })); - // Re-import db.ts after the mock is registered so it picks up the - // rejecting openDB. + // Re-import db.ts AND the adapter after the mock is registered. We must + // import the adapter from the same module-graph instance the freshly- + // imported db.ts uses, otherwise `instanceof` checks fail because + // vi.resetModules() creates a new class identity per import. const { openSaveDB: openSaveDBFresh } = await import('./db'); + const { LocalStorageDBAdapter: LocalStorageDBAdapterFresh } = await import( + './db-localstorage-adapter' + ); + const { wrap } = await import('./envelope'); const db = await openSaveDBFresh(); - expect(db).toBeInstanceOf(LocalStorageDBAdapter); + expect(db).toBeInstanceOf(LocalStorageDBAdapterFresh); // Round-trip works against localStorage const envelope = wrap({ fallback: true }, 1); diff --git a/src/save/db.ts b/src/save/db.ts new file mode 100644 index 0000000..a2f8bfb --- /dev/null +++ b/src/save/db.ts @@ -0,0 +1,74 @@ +import { openDB, type IDBPDatabase } from 'idb'; +import type { SaveEnvelope } from './envelope'; +import { LocalStorageDBAdapter } from './db-localstorage-adapter'; + +export const SAVE_DB_NAME = 'tlg-save'; +const DB_VERSION = 1; + +export interface SavedRecord { + /** Singleton key — Phase 1 ships one save slot only ("main"). */ + id: 'main'; + envelope: SaveEnvelope; + savedAt: string; // ISO8601 +} + +export interface SnapshotRecord { + /** Composite key: `${schemaVersion}-${savedAt}-${entropy}`. */ + id: string; + schemaVersion: number; + savedAt: string; + envelope: SaveEnvelope; +} + +export interface SaveDBSchema { + saves: { key: string; value: SavedRecord }; + save_snapshots: { key: string; value: SnapshotRecord }; +} + +/** + * Type union of the two backends — IndexedDB primary, localStorage fallback. + * Phase 2's save consumer only calls the methods both backends implement + * (`get`, `put`, `delete`, `getAll`, `transaction`). + */ +export type SaveDB = IDBPDatabase | LocalStorageDBAdapter; + +/** + * Opens the save DB. Tries IndexedDB first; on rejection (private mode, + * blocked, quota exceeded — anything that makes openDB throw), falls back + * to a `LocalStorageDBAdapter` that exposes the same minimal interface. + * + * CORE-04: "IndexedDB-primary with localStorage fallback". + * + * The two-store split (`saves` singleton + `save_snapshots` keyed) is per + * RESEARCH Pattern 3 — snapshots are kept separate so migrating the main + * save never affects the snapshot history. The localStorage adapter + * mirrors the same two stores, namespaced under `tlg.saves.*` / + * `tlg.save_snapshots.*`. + * + * Tested in `db.test.ts` via stub-injected `vi.doMock('idb')` rejection. + */ +export async function openSaveDB(): Promise { + try { + return await openDB(SAVE_DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains('saves')) { + db.createObjectStore('saves', { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains('save_snapshots')) { + db.createObjectStore('save_snapshots', { keyPath: 'id' }); + } + }, + }); + } catch (err) { + // IDB unavailable — fall back to localStorage. Phase 2's settings UI + // will surface a "running on localStorage" notice when this path + // triggers (per .planning/research/PITFALLS.md #8 multi-layer write + // requirement). + // eslint-disable-next-line no-console + console.warn( + '[save] IndexedDB unavailable, falling back to localStorage:', + err, + ); + return new LocalStorageDBAdapter(); + } +} diff --git a/src/save/persist.ts b/src/save/persist.ts new file mode 100644 index 0000000..5e1c22d --- /dev/null +++ b/src/save/persist.ts @@ -0,0 +1,43 @@ +export interface PersistResult { + granted: boolean; + apiAvailable: boolean; +} + +/** + * Request persistent storage from the browser. + * + * Returns `granted=true` only if the browser actually granted persistence + * (Chrome/Firefox/Edge mostly will; iOS Safari mostly will NOT — see + * .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 2 + + * .planning/research/PITFALLS.md #8). The caller (Phase 2 settings UI) + * surfaces `apiAvailable=false` and `granted=false` *respectfully* — the + * anti-FOMO doctrine forbids nagging the user about it. + * + * The four scenarios this handles: + * 1. API present, persist resolves true → {granted: true, apiAvailable: true} + * 2. API present, persist resolves false → {granted: false, apiAvailable: true} + * 3. API present, persist throws → {granted: false, apiAvailable: true} + * 4. navigator.storage missing entirely → {granted: false, apiAvailable: false} + * + * All four are tested in `persist.test.ts`. + */ +async function _requestPersistence(): Promise { + if ( + typeof navigator === 'undefined' || + !('storage' in navigator) || + !navigator.storage || + !('persist' in navigator.storage) + ) { + return { granted: false, apiAvailable: false }; + } + try { + const granted = await navigator.storage.persist(); + return { granted, apiAvailable: true }; + } catch { + return { granted: false, apiAvailable: true }; + } +} + +export function requestPersistence(): Promise { + return _requestPersistence(); +} diff --git a/src/save/snapshots.test.ts b/src/save/snapshots.test.ts index e6f46b4..49ebc3d 100644 --- a/src/save/snapshots.test.ts +++ b/src/save/snapshots.test.ts @@ -2,14 +2,24 @@ import { describe, it, expect, beforeEach } from 'vitest'; import 'fake-indexeddb/auto'; import { snapshot, listSnapshots } from './snapshots'; import { wrap } from './envelope'; -import { SAVE_DB_NAME } from './db'; +import { openSaveDB } from './db'; // Tests for last-3 pre-migration snapshot retention (CORE-08). The // load-bearing test is "after 5 successive snapshot() calls, exactly 3 // newest entries remain". The 2ms wait between writes ensures savedAt // timestamps differ so newest-first ordering is unambiguous. +// +// We can't `indexedDB.deleteDatabase('tlg-save')` between tests because +// `openSaveDB()` (called inside snapshot/listSnapshots) leaves an open +// connection behind, and `idb` caches the connection — so the delete +// would block forever waiting for the prior connection to close. The +// pragmatic fix is to reset the store contents directly in beforeEach. -beforeEach(() => indexedDB.deleteDatabase(SAVE_DB_NAME)); +beforeEach(async () => { + const db = await openSaveDB(); + const all = await db.getAll('save_snapshots'); + await Promise.all(all.map((e) => db.delete('save_snapshots', e.id))); +}); describe('snapshot + listSnapshots', () => { it('returns 1 entry after 1 snapshot call', async () => { diff --git a/src/save/snapshots.ts b/src/save/snapshots.ts new file mode 100644 index 0000000..accf244 --- /dev/null +++ b/src/save/snapshots.ts @@ -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 { + 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 { + const db = await openSaveDB(); + const all = await db.getAll('save_snapshots'); + return all.sort((a, b) => b.savedAt.localeCompare(a.savedAt)); +}