chore: merge executor worktree (01-03 save-layer)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { crc32hex, canonicalJSON } from './checksum';
|
||||
|
||||
// Tests for the pure-function save core: deterministic CRC-32 + canonical JSON.
|
||||
// Both functions are load-bearing for envelope checksums (see envelope.test.ts).
|
||||
|
||||
describe('crc32hex', () => {
|
||||
it('is deterministic — same input always returns same output', () => {
|
||||
expect(crc32hex('hello')).toBe(crc32hex('hello'));
|
||||
});
|
||||
|
||||
it('returns 8-char lowercase hex', () => {
|
||||
expect(crc32hex('hello')).toMatch(/^[0-9a-f]{8}$/);
|
||||
});
|
||||
|
||||
it('differs for different inputs', () => {
|
||||
expect(crc32hex('hello')).not.toBe(crc32hex('world'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonicalJSON', () => {
|
||||
it('produces byte-identical output for objects with same keys in any order', () => {
|
||||
expect(canonicalJSON({ b: 1, a: 2 })).toBe(canonicalJSON({ a: 2, b: 1 }));
|
||||
});
|
||||
|
||||
it('sorts nested object keys recursively', () => {
|
||||
expect(canonicalJSON({ b: { z: 1, a: 2 }, a: 1 })).toBe(
|
||||
canonicalJSON({ a: 1, b: { a: 2, z: 1 } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT sort arrays — order is meaningful', () => {
|
||||
expect(canonicalJSON([3, 1, 2])).toBe('[3,1,2]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import CRC32 from 'crc-32';
|
||||
|
||||
/**
|
||||
* 8-char lowercase hex CRC-32 of the input string.
|
||||
* crc-32 returns a signed 32-bit integer; we mask to unsigned and pad.
|
||||
* Used by envelope.wrap/unwrap to detect save corruption (lossy storage,
|
||||
* partial writes, browser-eviction truncation).
|
||||
*/
|
||||
export function crc32hex(input: string): string {
|
||||
const signed = CRC32.str(input);
|
||||
const unsigned = signed >>> 0; // coerce to uint32
|
||||
return unsigned.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic JSON serialization with recursively-sorted object keys.
|
||||
* Required because checksum stability depends on stable key order across
|
||||
* V8 / SpiderMonkey / JavaScriptCore runs and across migration round-trips
|
||||
* (per .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 3).
|
||||
*
|
||||
* Arrays are NOT sorted — their order is meaningful (a garden tile list,
|
||||
* a timeline of harvested fragments). Only plain object keys are reordered.
|
||||
*
|
||||
* Hand-rolled rather than pulling in `json-stable-stringify` per RESEARCH
|
||||
* Open Question #1: ~10 LoC saves a dependency.
|
||||
*/
|
||||
export function canonicalJSON(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, val) => {
|
||||
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(val as Record<string, unknown>).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
);
|
||||
}
|
||||
return val;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import LZString from 'lz-string';
|
||||
import { SaveEnvelopeSchema, type SaveEnvelope } from './envelope';
|
||||
|
||||
/**
|
||||
* 50MB cap on Base64 import string length, per the Phase 1 threat model
|
||||
* (T-01-02 in the plan + .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
|
||||
* § Security Domain — malformed Base64 import / DoS via huge inflated
|
||||
* string).
|
||||
*
|
||||
* `lz-string.decompressFromBase64` has bounded output for bounded input,
|
||||
* but it is synchronous and would block the main thread on a pathological
|
||||
* input. We refuse oversized payloads at the boundary BEFORE invoking
|
||||
* decompression.
|
||||
*
|
||||
* 50MB is generous: real Phase-1 saves will be <10KB. The cap exists so a
|
||||
* malicious or accidental paste cannot freeze the tab.
|
||||
*/
|
||||
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Export a SaveEnvelope to a Base64 text blob suitable for the eventual
|
||||
* "Settings → Export" button. Phase 1 ships the function pair; Phase 2
|
||||
* wires the UI (CORE-09).
|
||||
*
|
||||
* Note: lz-string is synchronous. For the <10KB Phase-1 saves this is
|
||||
* fine. RESEARCH Pitfall 5 documents the eventual mitigation (Web Worker)
|
||||
* for when saves grow past ~1MB in Phase 8 perf work — do NOT add it now,
|
||||
* per CONTEXT D-09 minimum-viable directive.
|
||||
*/
|
||||
export function exportToBase64<T>(envelope: SaveEnvelope<T>): string {
|
||||
return LZString.compressToBase64(JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a SaveEnvelope from a Base64 text blob. Throws on:
|
||||
* - input larger than `MAX_IMPORT_BYTES` (DoS cap, T-01-02)
|
||||
* - lz-string decompression failure
|
||||
* - JSON parse failure
|
||||
* - `SaveEnvelopeSchema` validation failure (malformed envelope shape)
|
||||
*
|
||||
* Note: this does NOT verify the envelope's CRC checksum or run migrations.
|
||||
* The full pipeline is `importFromBase64 → migrate → unwrap`; see
|
||||
* `round-trip.test.ts` for the canonical example. Splitting these phases
|
||||
* lets the caller (Phase 2 settings UI) show different error states for
|
||||
* "malformed import" vs "checksum mismatch" vs "migration failure".
|
||||
*
|
||||
* Per threat-model T-01-03: this function detects corruption, NOT
|
||||
* adversarial editing. A player editing their own Base64 export and
|
||||
* re-importing is by-design acceptable in single-player.
|
||||
*/
|
||||
export function importFromBase64(base64: string): SaveEnvelope<unknown> {
|
||||
if (base64.length > MAX_IMPORT_BYTES) {
|
||||
throw new Error(
|
||||
`Import payload exceeds ${MAX_IMPORT_BYTES} bytes (got ${base64.length})`,
|
||||
);
|
||||
}
|
||||
const decompressed = LZString.decompressFromBase64(base64);
|
||||
if (!decompressed) {
|
||||
throw new Error('Failed to decompress Base64 import (malformed input)');
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(decompressed);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Imported blob is not valid JSON: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
const validated = SaveEnvelopeSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
throw new Error(
|
||||
`Imported envelope failed schema validation: ${validated.error.message}`,
|
||||
);
|
||||
}
|
||||
return validated.data as SaveEnvelope<unknown>;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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.
|
||||
*
|
||||
* The record-type definitions live HERE rather than in `db.ts` to avoid a
|
||||
* circular import (db.ts depends on this adapter). `db.ts` re-exports
|
||||
* them so Phase 2 consumers see a single canonical set of types.
|
||||
*/
|
||||
|
||||
export type StoreName = 'saves' | 'save_snapshots';
|
||||
|
||||
/** A persisted save (singleton — only one slot in Phase 1, id = "main"). */
|
||||
export interface SavedRecord {
|
||||
/** Singleton key — Phase 1 ships one save slot only ("main"). */
|
||||
id: 'main';
|
||||
envelope: SaveEnvelope;
|
||||
/** ISO8601 timestamp of the write. */
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
/** A pre-migration snapshot kept under save_snapshots (last-N retention). */
|
||||
export interface SnapshotRecord {
|
||||
/** Composite key: `${schemaVersion}-${savedAt}-${entropy}`. */
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto'; // happy-dom doesn't ship IDB; fake-indexeddb is the polyfill
|
||||
|
||||
// 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 () => {
|
||||
// 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(() => {
|
||||
vi.doUnmock('idb');
|
||||
});
|
||||
|
||||
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', {
|
||||
id: 'main',
|
||||
envelope,
|
||||
savedAt: new Date().toISOString(),
|
||||
});
|
||||
const retrieved = await db.get('saves', 'main');
|
||||
expect(retrieved?.envelope).toEqual(envelope);
|
||||
});
|
||||
|
||||
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', {
|
||||
id: 's-1',
|
||||
schemaVersion: 1,
|
||||
savedAt: new Date().toISOString(),
|
||||
envelope,
|
||||
});
|
||||
const retrieved = await db.get('save_snapshots', 's-1');
|
||||
expect(retrieved?.envelope).toEqual(envelope);
|
||||
});
|
||||
});
|
||||
|
||||
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 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(LocalStorageDBAdapterFresh);
|
||||
|
||||
// Round-trip works against localStorage
|
||||
const envelope = wrap({ fallback: true }, 1);
|
||||
await db.put('saves', {
|
||||
id: 'main',
|
||||
envelope,
|
||||
savedAt: new Date().toISOString(),
|
||||
});
|
||||
const retrieved = await db.get('saves', 'main');
|
||||
expect(retrieved?.envelope).toEqual(envelope);
|
||||
|
||||
// Verify it actually wrote to localStorage (not just memory)
|
||||
expect(localStorage.getItem('tlg.saves.main')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import {
|
||||
LocalStorageDBAdapter,
|
||||
type StoreName as SaveStoreName,
|
||||
type RecordOf,
|
||||
type SavedRecord,
|
||||
type SnapshotRecord,
|
||||
} from './db-localstorage-adapter';
|
||||
|
||||
export const SAVE_DB_NAME = 'tlg-save';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
// Re-export the record types so Phase 2 consumers can import them from
|
||||
// the canonical `./db` (or via index.ts) without reaching into the
|
||||
// adapter module.
|
||||
export type { SavedRecord, SnapshotRecord };
|
||||
export type { SaveStoreName };
|
||||
|
||||
export interface SaveDBSchema {
|
||||
saves: { key: string; value: SavedRecord };
|
||||
save_snapshots: { key: string; value: SnapshotRecord };
|
||||
}
|
||||
|
||||
/** What `db.transaction(...).objectStore(...)` exposes for one store. */
|
||||
export interface SaveObjectStore<S extends SaveStoreName> {
|
||||
put: (value: RecordOf<S>) => Promise<unknown>;
|
||||
get: (key: string) => Promise<RecordOf<S> | undefined>;
|
||||
delete: (key: string) => Promise<unknown>;
|
||||
getAll: () => Promise<RecordOf<S>[]>;
|
||||
}
|
||||
|
||||
export interface SaveTransaction<S extends SaveStoreName> {
|
||||
objectStore: (s: S) => SaveObjectStore<S>;
|
||||
done: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common contract that both backends (IndexedDB-primary and
|
||||
* localStorage-fallback) MUST satisfy. We define this as a single
|
||||
* interface (rather than a union of `IDBPDatabase | LocalStorageDBAdapter`)
|
||||
* because TypeScript cannot narrow method calls through a union when the
|
||||
* two branches have differently-shaped overloads — the result is a
|
||||
* "no compatible signature" type error on every `db.put(...)` call.
|
||||
*
|
||||
* Phase 2's save consumer should program against this interface, not
|
||||
* against either concrete backend.
|
||||
*/
|
||||
export interface SaveDB {
|
||||
objectStoreNames: { contains: (s: string) => boolean };
|
||||
get<S extends SaveStoreName>(
|
||||
store: S,
|
||||
key: string,
|
||||
): Promise<RecordOf<S> | undefined>;
|
||||
put<S extends SaveStoreName>(
|
||||
store: S,
|
||||
value: RecordOf<S>,
|
||||
): Promise<unknown>;
|
||||
delete(store: SaveStoreName, key: string): Promise<unknown>;
|
||||
getAll<S extends SaveStoreName>(store: S): Promise<RecordOf<S>[]>;
|
||||
transaction<S extends SaveStoreName>(
|
||||
store: S,
|
||||
mode: 'readwrite' | 'readonly',
|
||||
): SaveTransaction<S>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: the IDBPDatabase shape narrowed to our schema. We cast the
|
||||
* raw `idb`-returned value to `SaveDB` because IDBPDatabase exposes a
|
||||
* superset of methods with overloads that satisfy `SaveDB` at runtime
|
||||
* (idb returns the value for `put` keys, but the SaveDB.put we declared
|
||||
* also returns `Promise<unknown>` to absorb that).
|
||||
*/
|
||||
type IdbBackend = IDBPDatabase<SaveDBSchema>;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const idb: IdbBackend = 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' });
|
||||
}
|
||||
},
|
||||
});
|
||||
// idb's IDBPDatabase has overloaded methods that satisfy SaveDB at
|
||||
// runtime; the `as unknown as SaveDB` is the type-system bridge.
|
||||
return idb as unknown as SaveDB;
|
||||
} 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() as unknown as SaveDB;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
wrap,
|
||||
unwrap,
|
||||
SaveCorruptError,
|
||||
SaveEnvelopeSchema,
|
||||
type SaveEnvelope,
|
||||
} from './envelope';
|
||||
|
||||
// Tests for the SaveEnvelope wrap/unwrap pair. The envelope is the load-bearing
|
||||
// shape from CLAUDE.md: `{schemaVersion, payload, checksum}`. Tampering or
|
||||
// lossy-storage corruption is detected via CRC-32 mismatch on unwrap.
|
||||
|
||||
describe('wrap', () => {
|
||||
it('returns an envelope with schemaVersion, payload, and 8-char hex checksum', () => {
|
||||
const env = wrap({ foo: 'bar' }, 1);
|
||||
expect(env.schemaVersion).toBe(1);
|
||||
expect(env.payload).toEqual({ foo: 'bar' });
|
||||
expect(env.checksum).toMatch(/^[0-9a-f]{8}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrap', () => {
|
||||
it('round-trips several payload shapes', () => {
|
||||
const shapes: unknown[] = [
|
||||
{ foo: 'bar' },
|
||||
{ nested: { a: 1, b: { c: [1, 2, 3] } } },
|
||||
{ garden: { tiles: [{ id: 'tile-1' }] }, plants: [] },
|
||||
[1, 2, 3],
|
||||
{ empty: {} },
|
||||
];
|
||||
for (const p of shapes) {
|
||||
expect(unwrap(wrap(p, 1))).toEqual(p);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws SaveCorruptError when checksum is tampered', () => {
|
||||
const env = wrap({ x: 1 }, 1);
|
||||
const tampered: SaveEnvelope<unknown> = { ...env, checksum: 'deadbeef' };
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
unwrap(tampered);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SaveCorruptError);
|
||||
const err = caught as SaveCorruptError;
|
||||
expect(err.expected).toBe('deadbeef');
|
||||
expect(err.actual).toBe(env.checksum);
|
||||
});
|
||||
|
||||
it('throws SaveCorruptError when payload is tampered (checksum mismatch)', () => {
|
||||
const env = wrap({ x: 1 }, 1);
|
||||
const tampered: SaveEnvelope<unknown> = { ...env, payload: { x: 2 } };
|
||||
expect(() => unwrap(tampered)).toThrow(SaveCorruptError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SaveEnvelopeSchema', () => {
|
||||
it('accepts a valid envelope', () => {
|
||||
const env = wrap({ foo: 'bar' }, 1);
|
||||
expect(SaveEnvelopeSchema.safeParse(env).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts schemaVersion 0 (synthetic v0 per CONTEXT D-05)', () => {
|
||||
const env = { schemaVersion: 0, payload: {}, checksum: '00000000' };
|
||||
expect(SaveEnvelopeSchema.safeParse(env).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed envelopes (missing keys)', () => {
|
||||
const noChecksum = { schemaVersion: 1, payload: {} };
|
||||
const noVersion = { payload: {}, checksum: '00000000' };
|
||||
expect(SaveEnvelopeSchema.safeParse(noChecksum).success).toBe(false);
|
||||
expect(SaveEnvelopeSchema.safeParse(noVersion).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed envelopes (non-hex checksum)', () => {
|
||||
const bad = { schemaVersion: 1, payload: {}, checksum: 'NOT-HEX!' };
|
||||
expect(SaveEnvelopeSchema.safeParse(bad).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative schemaVersion', () => {
|
||||
const bad = { schemaVersion: -1, payload: {}, checksum: '00000000' };
|
||||
expect(SaveEnvelopeSchema.safeParse(bad).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { z } from 'zod';
|
||||
import { crc32hex, canonicalJSON } from './checksum';
|
||||
|
||||
/**
|
||||
* The save envelope shape, locked by CLAUDE.md "Code Style":
|
||||
* `{schemaVersion, payload, checksum}`
|
||||
*
|
||||
* `schemaVersion` is `nonnegative` (NOT `positive`) because CONTEXT D-05
|
||||
* declares the synthetic v0 era — see migrations.ts. RESEARCH Pattern 1's
|
||||
* example uses `positive` but that conflicts with D-05's requirement.
|
||||
*/
|
||||
export const SaveEnvelopeSchema = z.object({
|
||||
schemaVersion: z.number().int().nonnegative(),
|
||||
payload: z.unknown(),
|
||||
checksum: z.string().regex(/^[0-9a-f]{8}$/),
|
||||
});
|
||||
|
||||
export type SaveEnvelope<T = unknown> = {
|
||||
schemaVersion: number;
|
||||
payload: T;
|
||||
checksum: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown by `unwrap` when the envelope's stored checksum disagrees with
|
||||
* the recomputed checksum of the payload. Phase 2's settings UI surfaces
|
||||
* this with the recovery option (load from `save_snapshots` per CORE-08).
|
||||
*
|
||||
* NOT a cryptographic guarantee — see threat-model T-01-03 in the plan.
|
||||
* A player editing their own save is acceptable in single-player; this
|
||||
* detects lossy-storage corruption, not adversarial editing.
|
||||
*/
|
||||
export class SaveCorruptError extends Error {
|
||||
override readonly name = 'SaveCorruptError';
|
||||
constructor(
|
||||
public readonly expected: string,
|
||||
public readonly actual: string,
|
||||
) {
|
||||
super(`Save checksum mismatch: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a payload in an envelope at the given schema version. Computes the
|
||||
* checksum over the canonical-JSON serialization of the payload so that
|
||||
* key order does not affect the checksum (per RESEARCH Pitfall 3).
|
||||
*/
|
||||
export function wrap<T>(payload: T, schemaVersion: number): SaveEnvelope<T> {
|
||||
return {
|
||||
schemaVersion,
|
||||
payload,
|
||||
checksum: crc32hex(canonicalJSON(payload)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap an envelope, verifying the checksum. Throws `SaveCorruptError`
|
||||
* when the payload's recomputed checksum does not match the envelope's
|
||||
* stored checksum.
|
||||
*
|
||||
* The `expected` field on the error is the value the envelope ARRIVED with
|
||||
* (what the storage layer expected to be authoritative); `actual` is the
|
||||
* value computed from the payload as decoded. Phase 2's recovery UI shows
|
||||
* this delta so the user can choose between rolling back to a snapshot
|
||||
* or accepting the (presumably-tampered) payload as-is.
|
||||
*/
|
||||
export function unwrap<T>(env: SaveEnvelope<unknown>): T {
|
||||
const computed = crc32hex(canonicalJSON(env.payload));
|
||||
if (computed !== env.checksum) {
|
||||
throw new SaveCorruptError(env.checksum, computed);
|
||||
}
|
||||
return env.payload as T;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Public surface of the save layer. Phase 2's tick scheduler + Zustand
|
||||
* store are the first consumers — they should ONLY import from this
|
||||
* file, never from the individual modules underneath. The internal
|
||||
* shape is allowed to change between phases; this barrel is the
|
||||
* stability contract.
|
||||
*/
|
||||
|
||||
export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope';
|
||||
export type { SaveEnvelope } from './envelope';
|
||||
|
||||
export { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations';
|
||||
export type { V1Payload } from './migrations';
|
||||
|
||||
export { snapshot, listSnapshots } from './snapshots';
|
||||
export type { SnapshotEntry } from './snapshots';
|
||||
|
||||
export { requestPersistence } from './persist';
|
||||
export type { PersistResult } from './persist';
|
||||
|
||||
export { exportToBase64, importFromBase64, MAX_IMPORT_BYTES } from './codec';
|
||||
|
||||
export { openSaveDB, SAVE_DB_NAME } from './db';
|
||||
export type {
|
||||
SaveDB,
|
||||
SaveDBSchema,
|
||||
SavedRecord,
|
||||
SnapshotRecord,
|
||||
SaveStoreName,
|
||||
SaveObjectStore,
|
||||
SaveTransaction,
|
||||
} from './db';
|
||||
|
||||
export { LocalStorageDBAdapter } from './db-localstorage-adapter';
|
||||
export type { StoreName, RecordOf } from './db-localstorage-adapter';
|
||||
|
||||
export { crc32hex, canonicalJSON } from './checksum';
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations';
|
||||
|
||||
// Tests for the forward-only migration registry. The synthetic v0 → v1
|
||||
// migration (CONTEXT D-05) is the load-bearing one — Phase 4's real
|
||||
// migrate_v1_to_v2 will follow the exact same shape.
|
||||
|
||||
describe('CURRENT_SCHEMA_VERSION', () => {
|
||||
it('is 1 in Phase 1 (sanity)', () => {
|
||||
expect(CURRENT_SCHEMA_VERSION).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrate (synthetic v0 → v1 per CONTEXT D-04 + D-05)', () => {
|
||||
it('synthetic v0 payload migrates to v1 shape', () => {
|
||||
const v0 = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] };
|
||||
const result = migrate(v0, 0);
|
||||
expect(result.toVersion).toBe(1);
|
||||
expect(result.payload).toMatchObject({
|
||||
garden: { tiles: [{ id: 'tile-1' }, { id: 'tile-2' }] },
|
||||
plants: [],
|
||||
harvestedFragmentIds: [],
|
||||
lastTickAt: expect.any(Number),
|
||||
settings: {
|
||||
musicVolume: expect.any(Number),
|
||||
ambientVolume: expect.any(Number),
|
||||
sfxVolume: expect.any(Number),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('migrating from v1 is a no-op (returns payload unchanged at toVersion 1)', () => {
|
||||
const v1 = {
|
||||
garden: { tiles: [] },
|
||||
plants: [],
|
||||
harvestedFragmentIds: [],
|
||||
lastTickAt: 1234567890,
|
||||
settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8 },
|
||||
};
|
||||
const result = migrate(v1, 1);
|
||||
expect(result.toVersion).toBe(1);
|
||||
expect(result.payload).toEqual(v1);
|
||||
});
|
||||
|
||||
it('throws when fromVersion is in the future (no migration registered)', () => {
|
||||
expect(() => migrate({}, 99)).toThrow();
|
||||
});
|
||||
|
||||
it('throws when fromVersion is negative', () => {
|
||||
expect(() => migrate({}, -1)).toThrow();
|
||||
});
|
||||
|
||||
it('invokes migrations[1] exactly once when migrating v0 → v1', () => {
|
||||
const original = migrations[1];
|
||||
const spy = vi.fn(original);
|
||||
migrations[1] = spy;
|
||||
try {
|
||||
migrate({ garden: [] }, 0);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
migrations[1] = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Forward-only save migration registry.
|
||||
*
|
||||
* Each entry `migrations[N]` is the function that migrates payload from
|
||||
* schema version N-1 to schema version N. Phase 1 ships migrations[1]
|
||||
* (the synthetic v0 → v1 demo per CONTEXT D-05); Phase 4 will land
|
||||
* migrations[2] when prestige / Roothold state lands.
|
||||
*
|
||||
* The v1 shape (from CONTEXT D-04) is intentionally minimal: only what
|
||||
* Phase 2's first feature commit will write. Authoring it now lets us
|
||||
* prove the migration chain end-to-end without speculating about future
|
||||
* Season 5+ structures.
|
||||
*/
|
||||
|
||||
type Migration = (payload: unknown) => unknown;
|
||||
|
||||
export const CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
interface V0Payload {
|
||||
garden?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimal v1 save shape per CONTEXT D-04: garden tiles, plant growth
|
||||
* data placeholder, harvested fragment IDs, last tick timestamp, settings.
|
||||
* Phase 2 fleshes the contents; Phase 1 just locks the field set.
|
||||
*/
|
||||
export interface V1Payload {
|
||||
garden: { tiles: unknown[] };
|
||||
plants: unknown[];
|
||||
harvestedFragmentIds: string[];
|
||||
lastTickAt: number;
|
||||
settings: {
|
||||
musicVolume: number;
|
||||
ambientVolume: number;
|
||||
sfxVolume: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward-only migration chain. Keys are TARGET versions; the function
|
||||
* at key N migrates FROM N-1 TO N.
|
||||
*
|
||||
* - `migrations[1]` = v0 → v1 (synthetic demo per CONTEXT D-05).
|
||||
* - `migrations[2]` = v1 → v2 will be added in Phase 4 when Roothold /
|
||||
* prestige state lands.
|
||||
*/
|
||||
export const migrations: Record<number, Migration> = {
|
||||
1: (s: unknown): V1Payload => {
|
||||
const v0 = (s ?? {}) as V0Payload;
|
||||
return {
|
||||
garden: { tiles: v0.garden ?? [] },
|
||||
plants: [],
|
||||
harvestedFragmentIds: [],
|
||||
lastTickAt: Date.now(),
|
||||
settings: {
|
||||
musicVolume: 0.7,
|
||||
ambientVolume: 0.5,
|
||||
sfxVolume: 0.8,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate `payload` from `fromVersion` up to `CURRENT_SCHEMA_VERSION`,
|
||||
* applying each registered migration in order. Returns both the migrated
|
||||
* payload and the schema version it now matches.
|
||||
*
|
||||
* Throws when:
|
||||
* - `fromVersion` is negative (invalid input)
|
||||
* - `fromVersion` is greater than `CURRENT_SCHEMA_VERSION` (future save
|
||||
* from a newer build of the game — refuse to silently downgrade)
|
||||
* - any required migration function is missing
|
||||
*/
|
||||
export function migrate(
|
||||
payload: unknown,
|
||||
fromVersion: number,
|
||||
): { payload: unknown; toVersion: number } {
|
||||
if (fromVersion < 0) {
|
||||
throw new Error(`Cannot migrate from negative version ${fromVersion}`);
|
||||
}
|
||||
if (fromVersion > CURRENT_SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`Cannot migrate from future version ${fromVersion} (current: ${CURRENT_SCHEMA_VERSION})`,
|
||||
);
|
||||
}
|
||||
let current = payload;
|
||||
let v = fromVersion;
|
||||
while (v < CURRENT_SCHEMA_VERSION) {
|
||||
const next = v + 1;
|
||||
const fn = migrations[next];
|
||||
if (!fn) {
|
||||
throw new Error(`No migration registered for v${v} → v${next}`);
|
||||
}
|
||||
current = fn(current);
|
||||
v = next;
|
||||
}
|
||||
return { payload: current, toVersion: v };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { requestPersistence } from './persist';
|
||||
|
||||
// Tests for navigator.storage.persist() — must surface granted=false
|
||||
// respectfully without spamming the user (CORE-05 + RESEARCH Pitfall 2:
|
||||
// iOS Safari often returns false). Each test stubs `navigator` globally
|
||||
// to one of the four scenarios.
|
||||
|
||||
describe('requestPersistence (CORE-05)', () => {
|
||||
beforeEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns granted=true when navigator.storage.persist resolves true', async () => {
|
||||
vi.stubGlobal('navigator', { storage: { persist: async () => true } });
|
||||
expect(await requestPersistence()).toEqual({
|
||||
granted: true,
|
||||
apiAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns granted=false when navigator.storage.persist resolves false', async () => {
|
||||
vi.stubGlobal('navigator', { storage: { persist: async () => false } });
|
||||
expect(await requestPersistence()).toEqual({
|
||||
granted: false,
|
||||
apiAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns granted=false when persist throws', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
storage: {
|
||||
persist: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await requestPersistence()).toEqual({
|
||||
granted: false,
|
||||
apiAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns apiAvailable=false when navigator.storage is missing', async () => {
|
||||
vi.stubGlobal('navigator', {});
|
||||
expect(await requestPersistence()).toEqual({
|
||||
granted: false,
|
||||
apiAvailable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { wrap, unwrap } from './envelope';
|
||||
import { migrate, CURRENT_SCHEMA_VERSION } from './migrations';
|
||||
import { exportToBase64, importFromBase64 } from './codec';
|
||||
import { openSaveDB } from './db';
|
||||
|
||||
// CORE-09 + CORE-04 + CORE-06 + CORE-07: full save round-trip exercising
|
||||
// every save layer file end-to-end. This is the load-bearing integration
|
||||
// test for Phase 1 — if this passes, Phase 2 can reasonably trust that
|
||||
// the save subsystem is wired correctly.
|
||||
|
||||
beforeEach(async () => {
|
||||
// Same store-contents reset pattern as the unit tests — see db.test.ts
|
||||
// and snapshots.test.ts for why we don't deleteDatabase.
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('CORE-09 + CORE-04 + CORE-06 + CORE-07: full save round-trip', () => {
|
||||
it('synthetic v0 envelope migrates, round-trips through Base64, validates, persists', async () => {
|
||||
// Pretend a player had an old v0 save lying around (CONTEXT D-05 synthetic v0).
|
||||
const v0Payload = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] };
|
||||
// v0 envelope: schemaVersion 0, with a placeholder checksum that we won't
|
||||
// verify (the v0 era didn't have our checksum scheme, but the schema
|
||||
// accepts it because checksum just has to be 8 hex chars).
|
||||
const v0Envelope = {
|
||||
schemaVersion: 0,
|
||||
payload: v0Payload,
|
||||
checksum: '00000000', // 8-char hex placeholder
|
||||
};
|
||||
|
||||
// EXPORT through Base64 codec
|
||||
const exported = exportToBase64(v0Envelope);
|
||||
expect(exported.length).toBeGreaterThan(0);
|
||||
|
||||
// IMPORT (simulating a fresh browser) — note: import returns a parsed
|
||||
// envelope that PASSES our SaveEnvelopeSchema (schemaVersion 0 is allowed
|
||||
// since z.number().nonnegative()).
|
||||
const imported = importFromBase64(exported);
|
||||
expect(imported.schemaVersion).toBe(0);
|
||||
|
||||
// MIGRATE the imported payload
|
||||
const { payload, toVersion } = migrate(imported.payload, imported.schemaVersion);
|
||||
expect(toVersion).toBe(CURRENT_SCHEMA_VERSION);
|
||||
expect(payload).toMatchObject({
|
||||
garden: { tiles: [{ id: 'tile-1' }, { id: 'tile-2' }] },
|
||||
plants: [],
|
||||
harvestedFragmentIds: [],
|
||||
lastTickAt: expect.any(Number),
|
||||
settings: {
|
||||
musicVolume: expect.any(Number),
|
||||
ambientVolume: expect.any(Number),
|
||||
sfxVolume: expect.any(Number),
|
||||
},
|
||||
});
|
||||
|
||||
// WRAP with current version and a valid checksum, UNWRAP to verify
|
||||
const v1Envelope = wrap(payload, toVersion);
|
||||
expect(unwrap(v1Envelope)).toEqual(payload);
|
||||
|
||||
// PERSIST to IDB and read back (CORE-04)
|
||||
const db = await openSaveDB();
|
||||
await db.put('saves', {
|
||||
id: 'main',
|
||||
envelope: v1Envelope,
|
||||
savedAt: new Date().toISOString(),
|
||||
});
|
||||
const retrieved = await db.get('saves', 'main');
|
||||
expect(retrieved?.envelope).toEqual(v1Envelope);
|
||||
expect(unwrap(retrieved!.envelope)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('rejects oversized Base64 import (DoS cap)', () => {
|
||||
const huge = 'A'.repeat(50 * 1024 * 1024 + 1);
|
||||
expect(() => importFromBase64(huge)).toThrow(/exceeds/);
|
||||
});
|
||||
|
||||
it('rejects malformed Base64', () => {
|
||||
expect(() => importFromBase64('not-valid-base64-)(*&^%$')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { snapshot, listSnapshots } from './snapshots';
|
||||
import { wrap } from './envelope';
|
||||
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(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 () => {
|
||||
await snapshot(wrap({ generation: 0 }, 1));
|
||||
const list = await listSnapshots();
|
||||
expect(list).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns [] from listSnapshots on empty store', async () => {
|
||||
const list = await listSnapshots();
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CORE-08: last-3 snapshot retention', () => {
|
||||
it('retains exactly 3 newest entries after 5 successive snapshot calls', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await snapshot(wrap({ generation: i }, 1));
|
||||
await new Promise((r) => setTimeout(r, 2)); // ensure savedAt timestamps differ
|
||||
}
|
||||
const list = await listSnapshots();
|
||||
expect(list).toHaveLength(3);
|
||||
// Newest first: payloads should be {generation:4}, {generation:3}, {generation:2}
|
||||
expect(
|
||||
list.map((e) => (e.envelope.payload as { generation: number }).generation),
|
||||
).toEqual([4, 3, 2]);
|
||||
});
|
||||
|
||||
it('pruned entries are the oldest by savedAt', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await snapshot(wrap({ generation: i }, 1));
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
const list = await listSnapshots();
|
||||
// The two oldest (generations 0 and 1) should NOT appear in the retained list.
|
||||
const generations = list.map(
|
||||
(e) => (e.envelope.payload as { generation: number }).generation,
|
||||
);
|
||||
expect(generations).not.toContain(0);
|
||||
expect(generations).not.toContain(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { openSaveDB } from './db';
|
||||
import type { SnapshotRecord } from './db';
|
||||
import type { SaveEnvelope } from './envelope';
|
||||
|
||||
/**
|
||||
* Public type for what listSnapshots returns. Structurally identical to
|
||||
* SnapshotRecord but exposed under a friendlier name for Phase 2's UI.
|
||||
*/
|
||||
export type SnapshotEntry = SnapshotRecord;
|
||||
|
||||
/**
|
||||
* 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