chore: merge executor worktree (01-03 save-layer)

This commit is contained in:
2026-05-08 23:48:25 -04:00
18 changed files with 1568 additions and 0 deletions
View File
+35
View File
@@ -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]');
});
});
+38
View File
@@ -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;
});
}
+76
View File
@@ -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>;
}
+137
View File
@@ -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(),
};
}
}
+113
View File
@@ -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
View File
@@ -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;
}
}
+86
View File
@@ -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);
});
});
+73
View File
@@ -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;
}
+37
View File
@@ -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';
+64
View File
@@ -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;
}
});
});
+100
View File
@@ -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 };
}
+49
View File
@@ -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,
});
});
});
+43
View File
@@ -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();
}
+87
View File
@@ -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();
});
});
+64
View File
@@ -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);
});
});
+59
View File
@@ -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));
}