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:
@@ -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 StoreName> = S extends 'saves'
|
||||||
|
? SavedRecord
|
||||||
|
: SnapshotRecord;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Namespace localStorage keys under the project prefix. Concrete keys
|
||||||
|
* produced are of the form `tlg.saves.<id>` or `tlg.save_snapshots.<id>`.
|
||||||
|
* 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.<id> or tlg.save_snapshots.<id>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<S extends StoreName> {
|
||||||
|
put: (value: RecordOf<S>) => Promise<void>;
|
||||||
|
get: (key: string) => Promise<RecordOf<S> | undefined>;
|
||||||
|
delete: (key: string) => Promise<void>;
|
||||||
|
getAll: () => Promise<RecordOf<S>[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<S extends StoreName>(
|
||||||
|
store: S,
|
||||||
|
key: string,
|
||||||
|
): Promise<RecordOf<S> | undefined> {
|
||||||
|
const raw = localStorage.getItem(nsKey(store, key));
|
||||||
|
return raw ? (JSON.parse(raw) as RecordOf<S>) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<S extends StoreName>(store: S, value: RecordOf<S>): Promise<void> {
|
||||||
|
localStorage.setItem(nsKey(store, value.id), JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(store: StoreName, key: string): Promise<void> {
|
||||||
|
localStorage.removeItem(nsKey(store, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll<S extends StoreName>(store: S): Promise<RecordOf<S>[]> {
|
||||||
|
const prefix = nsPrefix(store);
|
||||||
|
const out: RecordOf<S>[] = [];
|
||||||
|
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<S>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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<S extends StoreName>(
|
||||||
|
store: S,
|
||||||
|
_mode: 'readwrite' | 'readonly',
|
||||||
|
): { objectStore: (s: S) => LocalStorageObjectStore<S>; done: Promise<void> } {
|
||||||
|
const adapter = this;
|
||||||
|
return {
|
||||||
|
objectStore: (s: S): LocalStorageObjectStore<S> => ({
|
||||||
|
put: (value: RecordOf<S>) => 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-8
@@ -1,20 +1,34 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
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 '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).
|
// Tests for the IndexedDB-primary + localStorage-fallback open path (CORE-04).
|
||||||
// The IDB path uses `fake-indexeddb` (polyfill is auto-imported above).
|
// The IDB path uses `fake-indexeddb` (polyfill is auto-imported above).
|
||||||
// The fallback path uses `vi.doMock('idb')` to inject an openDB rejection,
|
// The fallback path uses `vi.doMock('idb')` to inject an openDB rejection,
|
||||||
// which forces openSaveDB to return a LocalStorageDBAdapter instead.
|
// 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 () => {
|
beforeEach(async () => {
|
||||||
// Reset IDB and localStorage between tests
|
// We can't `indexedDB.deleteDatabase('tlg-save')` between tests because
|
||||||
indexedDB.deleteDatabase(SAVE_DB_NAME);
|
// 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();
|
localStorage.clear();
|
||||||
vi.unstubAllGlobals();
|
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();
|
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(() => {
|
afterEach(() => {
|
||||||
@@ -23,12 +37,15 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => {
|
describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => {
|
||||||
it('opens a DB with saves and save_snapshots object stores', async () => {
|
it('opens a DB with saves and save_snapshots object stores', async () => {
|
||||||
|
const { openSaveDB } = await import('./db');
|
||||||
const db = await openSaveDB();
|
const db = await openSaveDB();
|
||||||
expect(db.objectStoreNames.contains('saves')).toBe(true);
|
expect(db.objectStoreNames.contains('saves')).toBe(true);
|
||||||
expect(db.objectStoreNames.contains('save_snapshots')).toBe(true);
|
expect(db.objectStoreNames.contains('save_snapshots')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips a SaveEnvelope through saves store', async () => {
|
it('round-trips a SaveEnvelope through saves store', async () => {
|
||||||
|
const { openSaveDB } = await import('./db');
|
||||||
|
const { wrap } = await import('./envelope');
|
||||||
const db = await openSaveDB();
|
const db = await openSaveDB();
|
||||||
const envelope = wrap({ hello: 'world' }, 1);
|
const envelope = wrap({ hello: 'world' }, 1);
|
||||||
await db.put('saves', {
|
await db.put('saves', {
|
||||||
@@ -41,6 +58,8 @@ describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips through save_snapshots store too', async () => {
|
it('round-trips through save_snapshots store too', async () => {
|
||||||
|
const { openSaveDB } = await import('./db');
|
||||||
|
const { wrap } = await import('./envelope');
|
||||||
const db = await openSaveDB();
|
const db = await openSaveDB();
|
||||||
const envelope = wrap({ snap: true }, 1);
|
const envelope = wrap({ snap: true }, 1);
|
||||||
await db.put('save_snapshots', {
|
await db.put('save_snapshots', {
|
||||||
@@ -56,17 +75,27 @@ describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => {
|
|||||||
|
|
||||||
describe('openSaveDB (CORE-04 localStorage fallback path)', () => {
|
describe('openSaveDB (CORE-04 localStorage fallback path)', () => {
|
||||||
it('falls back to LocalStorageDBAdapter when IndexedDB is unavailable', async () => {
|
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 /
|
// Stub the idb module's openDB so it rejects, simulating private mode /
|
||||||
// blocked IDB / quota exceeded — anything that makes openDB throw.
|
// blocked IDB / quota exceeded — anything that makes openDB throw.
|
||||||
vi.doMock('idb', async () => ({
|
vi.doMock('idb', async () => ({
|
||||||
openDB: vi.fn().mockRejectedValue(new Error('IDB unavailable (test stub)')),
|
openDB: vi.fn().mockRejectedValue(new Error('IDB unavailable (test stub)')),
|
||||||
}));
|
}));
|
||||||
// Re-import db.ts after the mock is registered so it picks up the
|
// Re-import db.ts AND the adapter after the mock is registered. We must
|
||||||
// rejecting openDB.
|
// 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 { openSaveDB: openSaveDBFresh } = await import('./db');
|
||||||
|
const { LocalStorageDBAdapter: LocalStorageDBAdapterFresh } = await import(
|
||||||
|
'./db-localstorage-adapter'
|
||||||
|
);
|
||||||
|
const { wrap } = await import('./envelope');
|
||||||
|
|
||||||
const db = await openSaveDBFresh();
|
const db = await openSaveDBFresh();
|
||||||
expect(db).toBeInstanceOf(LocalStorageDBAdapter);
|
expect(db).toBeInstanceOf(LocalStorageDBAdapterFresh);
|
||||||
|
|
||||||
// Round-trip works against localStorage
|
// Round-trip works against localStorage
|
||||||
const envelope = wrap({ fallback: true }, 1);
|
const envelope = wrap({ fallback: true }, 1);
|
||||||
|
|||||||
@@ -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<SaveDBSchema> | 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<SaveDB> {
|
||||||
|
try {
|
||||||
|
return await openDB<SaveDBSchema>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PersistResult> {
|
||||||
|
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<PersistResult> {
|
||||||
|
return _requestPersistence();
|
||||||
|
}
|
||||||
@@ -2,14 +2,24 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|||||||
import 'fake-indexeddb/auto';
|
import 'fake-indexeddb/auto';
|
||||||
import { snapshot, listSnapshots } from './snapshots';
|
import { snapshot, listSnapshots } from './snapshots';
|
||||||
import { wrap } from './envelope';
|
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
|
// Tests for last-3 pre-migration snapshot retention (CORE-08). The
|
||||||
// load-bearing test is "after 5 successive snapshot() calls, exactly 3
|
// load-bearing test is "after 5 successive snapshot() calls, exactly 3
|
||||||
// newest entries remain". The 2ms wait between writes ensures savedAt
|
// newest entries remain". The 2ms wait between writes ensures savedAt
|
||||||
// timestamps differ so newest-first ordering is unambiguous.
|
// 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', () => {
|
describe('snapshot + listSnapshots', () => {
|
||||||
it('returns 1 entry after 1 snapshot call', async () => {
|
it('returns 1 entry after 1 snapshot call', async () => {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user