Files
TheLastGarden/.planning/phases/01-foundations-and-doctrine/01-03-save-layer-PLAN.md
T
josh 39563f6934 docs(01): plan phase 1 — 7 plans across 3 waves, verified after 1 revision
Wave 1: Plan 01 (scaffold + test infra)
Wave 2: Plans 02 (eslint firewall), 03 (save layer), 04 (content pipeline),
        05 (asset provenance — autonomous:false human-curate checkpoint),
        06 (doctrine docs)
Wave 3: Plan 07 (CI workflow)

All 16 Phase-1 REQ-IDs covered. Plan-checker found 4 blockers + 6 warnings
on first pass; revision iteration 1 landed all 10 fixes; iteration 2
returned VERIFICATION PASSED. Two orchestrator judgment calls during
revision: (1) implement CORE-04 localStorage fallback in Phase 1 (the
literal requirement and ROADMAP success criterion #2 both call for it),
(2) reclassify STRY-09 as vacuously satisfied in Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:09:08 -04:00

1034 lines
58 KiB
Markdown

---
phase: 01
plan: 03
type: execute
wave: 2
depends_on: [01-01]
files_modified:
- src/save/checksum.ts
- src/save/checksum.test.ts
- src/save/envelope.ts
- src/save/envelope.test.ts
- src/save/migrations.ts
- src/save/migrations.test.ts
- src/save/db.ts
- src/save/db-localstorage-adapter.ts
- src/save/db.test.ts
- src/save/snapshots.ts
- src/save/snapshots.test.ts
- src/save/persist.ts
- src/save/persist.test.ts
- src/save/codec.ts
- src/save/round-trip.test.ts
- src/save/index.ts
autonomous: true
requirements: [CORE-04, CORE-05, CORE-06, CORE-07, CORE-08, CORE-09]
must_haves:
truths:
- "A save envelope `{schemaVersion, payload, checksum}` can be wrapped from a payload + version, unwrapped back, and `unwrap` throws on checksum mismatch (CORE-06)"
- "Canonical-JSON serialization sorts object keys recursively so the same payload always produces the same checksum across runs"
- "A synthetic v0 payload `{garden: []}` migrates to the v1 shape (garden tiles, plants, harvestedFragmentIds, lastTickAt, settings) via the migration registry (CORE-07, per CONTEXT D-04 + D-05)"
- "After 5 successive `snapshot()` calls, exactly 3 newest entries remain in the `save_snapshots` IndexedDB store (CORE-08)"
- "A v0 envelope can be Base64-exported via lz-string, Base64-imported into a fresh DB, migrated through the chain, and unwrapped to the original payload (CORE-09)"
- "`requestPersistence()` returns `{granted: boolean, apiAvailable: boolean}` and handles missing `navigator.storage.persist` gracefully (CORE-05)"
- "An IndexedDB save round-trips: `openSaveDB() → put(envelope) → get() → equals original` (CORE-04)"
- "CORE-04 IndexedDB-primary + localStorage-fallback both round-trip via Vitest: when `openDB` rejects, the same `get`/`put`/`delete` interface is served by `LocalStorageDBAdapter`, and a stub-injected IDB failure exercises the fallback path"
artifacts:
- path: src/save/checksum.ts
provides: "crc32hex(string) → 8-char lowercase hex CRC-32; canonicalJSON(value) → recursively-key-sorted JSON string"
exports: ["crc32hex", "canonicalJSON"]
- path: src/save/envelope.ts
provides: "wrap<T>(payload, schemaVersion), unwrap<T>(env), SaveEnvelope type, SaveCorruptError class, SaveEnvelopeSchema (Zod)"
exports: ["wrap", "unwrap", "SaveEnvelope", "SaveCorruptError", "SaveEnvelopeSchema"]
- path: src/save/migrations.ts
provides: "migrate(payload, fromVersion) → {payload, toVersion}; CURRENT_SCHEMA_VERSION constant; migrations registry with v0→v1 synthetic demo"
exports: ["migrate", "CURRENT_SCHEMA_VERSION", "migrations"]
- path: src/save/db.ts
provides: "openSaveDB() → SaveDB (IDBPDatabase or LocalStorageDBAdapter); SAVE_DB_NAME constant; two object stores: 'saves' (singleton) + 'save_snapshots' (keyed by id); falls back to LocalStorageDBAdapter on IDB failure (CORE-04)"
exports: ["openSaveDB", "SAVE_DB_NAME"]
- path: src/save/db-localstorage-adapter.ts
provides: "LocalStorageDBAdapter — thin localStorage-backed implementation of the same minimal interface as the IDB DB (get/put/delete on saves + save_snapshots), keyed under tlg.saves.* / tlg.save_snapshots.* (CORE-04 fallback path)"
exports: ["LocalStorageDBAdapter"]
- path: src/save/snapshots.ts
provides: "snapshot(envelope), listSnapshots() — last-3 retention; SnapshotEntry type"
exports: ["snapshot", "listSnapshots", "SnapshotEntry"]
- path: src/save/persist.ts
provides: "requestPersistence() → Promise<{granted, apiAvailable}>"
exports: ["requestPersistence", "PersistResult"]
- path: src/save/codec.ts
provides: "exportToBase64<T>(envelope), importFromBase64(base64) — lz-string round-trip with 50MB DoS cap"
exports: ["exportToBase64", "importFromBase64", "MAX_IMPORT_BYTES"]
- path: src/save/index.ts
provides: "Public re-exports for Phase 2 consumption"
key_links:
- from: src/save/envelope.ts
to: src/save/checksum.ts
via: "import { crc32hex, canonicalJSON } from './checksum'"
pattern: "import \\{ crc32hex, canonicalJSON \\} from './checksum'"
- from: src/save/migrations.ts
to: "synthetic v0 payload {garden: []}"
via: "migrations[1] receives {garden: any[]} and produces v1 shape per CONTEXT D-04"
pattern: "garden:\\s*\\{\\s*tiles:"
- from: src/save/snapshots.ts
to: src/save/db.ts
via: "openSaveDB() — uses 'save_snapshots' object store"
pattern: "save_snapshots"
- from: src/save/db.ts
to: src/save/db-localstorage-adapter.ts
via: "openSaveDB wraps openDB() in try/catch and returns LocalStorageDBAdapter when IDB rejects (CORE-04 fallback)"
pattern: "LocalStorageDBAdapter"
- from: src/save/round-trip.test.ts
to: "src/save/codec.ts + envelope.ts + migrations.ts"
via: "Full pipeline: wrap → exportToBase64 → importFromBase64 → migrate → unwrap"
pattern: "exportToBase64.*importFromBase64.*migrate.*unwrap"
---
<note>
**Plan 03 modifies 16 files across 3 tasks — at the upper edge of the per-plan budget.** Recommend `/clear` between tasks if executor context fills (after the Task 1 commit and after the Task 2 commit). Tasks are independently committable; the Wave 2 frontmatter has no other plan depending on intermediate state from this plan, so a context reset between tasks is safe.
</note>
<objective>
Build the load-bearing save layer for the entire game: envelope `{schemaVersion, payload, checksum}` with CRC-32 over canonical JSON, a forward-only migration chain seeded with a synthetic v0→v1 demo migration, an `idb`-wrapped IndexedDB DB with two object stores (`saves` + `save_snapshots`) **plus a thin localStorage fallback adapter for CORE-04 when IndexedDB is unavailable** (private mode, blocked by browser, quota exceeded), last-3 pre-migration snapshot retention, `navigator.storage.persist()` with respectful surfacing of `false`, and Base64 export/import via lz-string with a 50MB DoS cap on import. Every behavior is covered by a Vitest unit test, plus a single round-trip test that exercises the full pipeline end-to-end.
Purpose: Phase 2's first feature commit will write the first real save — and Phase 4 will ship the first real `migrate_v1_to_v2`. If the framework here is wrong, every subsequent Season's save migration is broken. CONTEXT D-04 + D-05 + D-06 lock the shape: minimal v1 payload (only what Phase 2 will write), synthetic v0→v1 demo migration to prove the chain, envelope shape locked from CLAUDE.md. RESEARCH § Patterns 1, 2, 3 + Pitfalls 3 (canonical JSON), 5 (lz-string sync caveat), 7 (real migration registry test) provide concrete code. REQUIREMENTS.md CORE-04 ("with localStorage fallback") + ROADMAP success criterion #2 ("with localStorage fallback and `navigator.storage.persist()`") require the fallback to ship in Phase 1; this plan satisfies that with a ~30-LoC adapter + one stub-injected Vitest test.
Output: A complete save subsystem under `src/save/` with one entry point (`src/save/index.ts`), 7 implementation files + 6 test files + 1 codec round-trip test, all Vitest tests passing in the happy-dom environment.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
@.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
@.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md
@CLAUDE.md
@.planning/research/ARCHITECTURE.md
@.planning/research/PITFALLS.md
<interfaces>
<!-- Key types this plan creates that downstream phases will consume.
Plan 04 (content), Plan 05 (assets), Plan 06 (doctrine) do NOT depend on these.
Phase 2's tick scheduler + Zustand store will be the first consumer. -->
From src/save/envelope.ts (this plan creates):
```typescript
export interface SaveEnvelope<T = unknown> {
schemaVersion: number;
payload: T;
checksum: string; // 8-char lowercase hex CRC-32 over canonicalJSON(payload)
}
export class SaveCorruptError extends Error { /* expected, actual */ }
export function wrap<T>(payload: T, schemaVersion: number): SaveEnvelope<T>;
export function unwrap<T>(env: SaveEnvelope<unknown>): T; // throws SaveCorruptError on mismatch
```
From src/save/migrations.ts:
```typescript
export const CURRENT_SCHEMA_VERSION = 1;
export const migrations: Record<number, (payload: unknown) => unknown>;
export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number };
```
From src/save/codec.ts:
```typescript
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; // 50MB DoS cap per Phase 1 threat model
export function exportToBase64<T>(env: SaveEnvelope<T>): string;
export function importFromBase64(base64: string): SaveEnvelope<unknown>; // throws on >MAX or invalid
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Checksum + envelope + migrations (the pure-function core)</name>
<files>
src/save/checksum.ts,
src/save/checksum.test.ts,
src/save/envelope.ts,
src/save/envelope.test.ts,
src/save/migrations.ts,
src/save/migrations.test.ts
</files>
<read_first>
- .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 1: Save Envelope" (verbatim code), § "Pattern 2: Migration Registry" (verbatim code), § "Common Pitfalls — Pitfall 3: JSON key ordering breaks checksums across runs" (canonical-JSON requirement), § "Common Pitfalls — Pitfall 7: Synthetic v0→v1 migration test that doesn't actually exercise the registry" (the 5 required Vitest assertions for CORE-07)
- .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-04 — minimal v1 payload shape: garden tiles, plant growth data, harvested fragment IDs, lastTickAt, basic settings; D-05 — synthetic v0→v1 demo migration; D-06 — envelope shape locked, checksum/registry Claude's discretion)
- CLAUDE.md "Code Style" — TypeScript strict, no `any` in production; `BigQty` is Phase 2 (do NOT pre-create)
</read_first>
<behavior>
- **checksum.ts:**
- Test 1: `crc32hex('hello')` returns the same 8-char lowercase hex string on every call (deterministic).
- Test 2: `crc32hex('hello')` and `crc32hex('world')` differ.
- Test 3: `canonicalJSON({b:1, a:2})` and `canonicalJSON({a:2, b:1})` are byte-identical.
- Test 4: `canonicalJSON` recursively sorts nested object keys.
- Test 5: `canonicalJSON` preserves array order (arrays are NOT sorted).
- **envelope.ts:**
- Test 1: `wrap({foo: 'bar'}, 1)` returns `{schemaVersion: 1, payload: {foo: 'bar'}, checksum: <8-char hex>}`.
- Test 2: `unwrap(wrap(p, 1))` deep-equals `p` for several payload shapes.
- Test 3: `unwrap` with a tampered checksum throws `SaveCorruptError` with `expected` and `actual` fields.
- Test 4: `unwrap` with a tampered payload (checksum mismatched) throws `SaveCorruptError`.
- Test 5: `SaveEnvelopeSchema.safeParse` rejects malformed envelopes (missing keys, non-hex checksum).
- **migrations.ts:**
- Test 1 (the load-bearing one per Pitfall 7): `migrate({garden: [{id: 'tile-1'}]}, 0)` returns `{payload: {garden: {tiles: [{id: 'tile-1'}]}, plants: [], harvestedFragmentIds: [], lastTickAt: <number>, settings: {...}}, toVersion: 1}`.
- Test 2: `migrate(<any v1 payload>, 1)` is a no-op (returns `{payload, toVersion: 1}` unchanged).
- Test 3: `migrate(<anything>, 99)` throws (no migration to a future version).
- Test 4: `migrate(<anything>, -1)` throws (no migration registered).
- Test 5: `migrations[1]` is invoked exactly once when migrating from v0 to v1 (use a spy/mock or count by replacing `migrations[1]` and asserting call count).
- Test 6: `CURRENT_SCHEMA_VERSION === 1` (sanity).
</behavior>
<action>
Write each file using the Write tool, copying the patterns from RESEARCH.md verbatim where they exist. Pure-function core — no I/O, no async.
**Step 1 — `src/save/checksum.ts`** (per RESEARCH Pattern 1 + Pitfall 3):
```typescript
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.
*/
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).
*/
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;
});
}
```
Per RESEARCH Open Question #1, hand-rolled sorted-key recursion is recommended (no `json-stable-stringify` dep). The `>>> 0` coercion converts crc-32's signed return to unsigned per the SheetJS docs.
**Step 2 — `src/save/checksum.test.ts`** with all 5 behaviors above. Concrete shape:
```typescript
import { describe, it, expect } from 'vitest';
import { crc32hex, canonicalJSON } from './checksum';
describe('crc32hex', () => {
it('is deterministic', () => {
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', () => {
expect(canonicalJSON([3, 1, 2])).toBe('[3,1,2]');
});
});
```
**Step 3 — `src/save/envelope.ts`** (per RESEARCH Pattern 1 verbatim, with Zod schema added):
```typescript
import { z } from 'zod';
import { crc32hex, canonicalJSON } from './checksum';
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> = z.infer<typeof SaveEnvelopeSchema> & { payload: T };
export class SaveCorruptError extends Error {
readonly name = 'SaveCorruptError';
constructor(public readonly expected: string, public readonly actual: string) {
super(`Save checksum mismatch: expected ${expected}, got ${actual}`);
}
}
export function wrap<T>(payload: T, schemaVersion: number): SaveEnvelope<T> {
return {
schemaVersion,
payload,
checksum: crc32hex(canonicalJSON(payload)),
};
}
export function unwrap<T>(env: SaveEnvelope<unknown>): T {
const expected = crc32hex(canonicalJSON(env.payload));
if (expected !== env.checksum) {
throw new SaveCorruptError(env.checksum, expected);
}
return env.payload as T;
}
```
Per CONTEXT D-04: zero (the synthetic v0) is a valid `schemaVersion`, so the Zod refinement uses `nonnegative` not `positive`. RESEARCH Pattern 1's example uses `positive` but that conflicts with D-05's synthetic-v0 requirement — use `nonnegative`.
**Step 4 — `src/save/envelope.test.ts`** with all 5 behaviors above.
**Step 5 — `src/save/migrations.ts`** (per RESEARCH Pattern 2 verbatim):
```typescript
type Migration = (payload: unknown) => unknown;
export const CURRENT_SCHEMA_VERSION = 1;
/**
* Forward-only migration chain. Each entry migrates FROM (key-1) TO key.
* - 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.
*
* v0 was a hypothetical prior shape `{garden: []}`; v1 is the minimal Phase-2 shape per CONTEXT D-04:
* garden tiles, plants, harvested fragment IDs, lastTickAt, settings.
*/
export const migrations: Record<number, Migration> = {
1: (s: unknown) => {
const v0 = (s ?? {}) as { garden?: unknown[] };
return {
garden: { tiles: v0.garden ?? [] },
plants: [],
harvestedFragmentIds: [],
lastTickAt: Date.now(),
settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8 },
};
},
};
export function migrate(
payload: unknown,
fromVersion: number,
): { payload: unknown; toVersion: number } {
if (fromVersion < 0) {
throw new Error(`Cannot migrate from negative version ${fromVersion}`);
}
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;
}
if (v > CURRENT_SCHEMA_VERSION) {
throw new Error(`Cannot migrate from future version ${fromVersion} (current: ${CURRENT_SCHEMA_VERSION})`);
}
return { payload: current, toVersion: v };
}
```
Note: the future-version throw protects against `migrate(p, 99)` per RESEARCH Pitfall 7 assertion #3.
**Step 6 — `src/save/migrations.test.ts`** with all 6 behaviors above. Particularly:
```typescript
it('synthetic v0 payload migrates to v1 shape (CONTEXT D-04 + D-05)', () => {
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) },
});
});
```
**Step 7 — Run `npm test` and confirm all 16 tests in this task pass (5+5+6).**
**Step 8 — Commit `feat(01-03): save envelope + canonical-JSON CRC32 + synthetic v0→v1 migration`.**
</action>
<verify>
<automated>npx vitest run src/save/checksum.test.ts src/save/envelope.test.ts src/save/migrations.test.ts</automated>
</verify>
<acceptance_criteria>
- All 6 files exist: `for f in checksum envelope migrations; do test -f src/save/$f.ts && test -f src/save/$f.test.ts; done`.
- `src/save/checksum.ts` exports both `crc32hex` and `canonicalJSON` — verify with `grep -E "^export function (crc32hex|canonicalJSON)" src/save/checksum.ts | wc -l` returns 2.
- `src/save/envelope.ts` exports `wrap`, `unwrap`, `SaveCorruptError`, `SaveEnvelopeSchema` — verify with `grep -cE "^export (function|class|const) (wrap|unwrap|SaveCorruptError|SaveEnvelopeSchema)" src/save/envelope.ts` returns 4.
- `src/save/migrations.ts` exports `migrate`, `CURRENT_SCHEMA_VERSION`, `migrations` — verify with `grep -cE "^export (function|const) (migrate|CURRENT_SCHEMA_VERSION|migrations)" src/save/migrations.ts` returns 3.
- `migrations.ts` v0→v1 migration produces the v1 shape from CONTEXT D-04 — verify with `grep -E "tiles:|plants:|harvestedFragmentIds:|lastTickAt:|settings:" src/save/migrations.ts | grep -v '^#' | wc -l` returns at least 5.
- `npm test` for these 3 files passes 16 tests total — verify with `npx vitest run src/save/checksum.test.ts src/save/envelope.test.ts src/save/migrations.test.ts 2>&1 | grep -E "16 passed|Tests *16"`.
- No `any` types in production code (excluding test files) — verify with `grep -nE ': any\\b' src/save/checksum.ts src/save/envelope.ts src/save/migrations.ts`; expect zero matches (CLAUDE.md TypeScript-strict rule).
</acceptance_criteria>
<done>
Pure-function save core (checksum, envelope, migrations) implemented per RESEARCH Patterns 1 + 2; 16 Vitest tests covering all RESEARCH Pitfall 7 assertions plus canonical-JSON determinism plus checksum-mismatch throw; no `any` in production; commit landed.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: idb DB + localStorage fallback adapter (CORE-04) + snapshots (last-3 retention) + persist API</name>
<files>
src/save/db.ts,
src/save/db-localstorage-adapter.ts,
src/save/db.test.ts,
src/save/snapshots.ts,
src/save/snapshots.test.ts,
src/save/persist.ts,
src/save/persist.test.ts
</files>
<read_first>
- c:/Users/josh1/Documents/Code/TheLastGarden/package.json (Plan 01 Task 1 already installed `fake-indexeddb@^6` as a devDependency — confirm `grep -q '"fake-indexeddb"' package.json` exits 0 before writing the IDB tests)
- .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 3: Last-3 Pre-Migration Snapshots" (verbatim code), § "Code Examples — Persist API call with respectful surfacing" (verbatim code)
- .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Common Pitfalls — Pitfall 2: navigator.storage.persist() returns false on iOS Safari most of the time"
- .planning/research/PITFALLS.md #8 (storage eviction; multi-layer write requirement)
- REQUIREMENTS.md CORE-04 ("with localStorage fallback") + ROADMAP success criterion #2 (the orchestrator authorized implementing the fallback in Phase 1; this task ships ~30 LoC + 1 test)
- src/save/envelope.ts (read the SaveEnvelope type from Task 1 — snapshots.ts and db.ts need it)
- idb 8.0.3 README: openDB upgrade callback shape, transaction API
</read_first>
<behavior>
- **db.ts:**
- Test 1: `openSaveDB()` returns an IDBPDatabase with two object stores: `saves` and `save_snapshots`.
- Test 2: `saves` store uses keyPath `'id'` (singleton; only one save per slot).
- Test 3: `save_snapshots` store uses keyPath `'id'`.
- Test 4: `put` + `get` round-trips a SaveEnvelope without modification.
- **Test 5 (CORE-04 fallback): when `openDB` rejects (stub-injected), `openSaveDB()` returns a `LocalStorageDBAdapter` and the same `put`/`get` round-trip succeeds against `localStorage`.**
- **db-localstorage-adapter.ts:**
- Implements the minimal interface used by the rest of the save layer (`get(store, key)`, `put(store, value)`, `delete(store, key)`, `getAll(store)`, plus a `transaction()` helper that proxies to direct localStorage operations — snapshots.ts uses `db.transaction('save_snapshots', 'readwrite').objectStore(...)`).
- Namespaces keys under `tlg.saves.<id>` and `tlg.save_snapshots.<id>`.
- JSON-encodes values; throws on missing.
- **snapshots.ts:**
- Test 1: After 1 `snapshot()` call, `listSnapshots()` returns 1 entry.
- Test 2 (the load-bearing one for CORE-08 per Pitfall 7 #5): After 5 successive `snapshot()` calls, `listSnapshots()` returns exactly 3 entries, in newest-first order.
- Test 3: Each pruned (deleted) entry has the oldest `savedAt` timestamps.
- Test 4: `listSnapshots()` on an empty store returns `[]`.
- **persist.ts:**
- Test 1: When `navigator.storage.persist` exists and resolves true, returns `{granted: true, apiAvailable: true}`.
- Test 2: When `navigator.storage.persist` exists and resolves false, returns `{granted: false, apiAvailable: true}`.
- Test 3: When `navigator.storage.persist` throws, returns `{granted: false, apiAvailable: true}`.
- Test 4: When `navigator.storage` is missing entirely, returns `{granted: false, apiAvailable: false}`.
</behavior>
<action>
**Step 1 — `src/save/db-localstorage-adapter.ts`** (~30-40 LoC). The adapter implements the minimal interface that `snapshots.ts` and Phase 2's save consumer will call. Use Write tool:
```typescript
import type { SaveEnvelope } from './envelope';
/**
* CORE-04 fallback path. When IndexedDB is unavailable (private mode, blocked
* by browser, quota exceeded), `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 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).
*
* Per .planning/research/PITFALLS.md #8, multi-layer storage is the v1 contract;
* IndexedDB is primary, localStorage is the fallback when IDB throws.
*/
type StoreName = 'saves' | 'save_snapshots';
interface SavedRecord {
id: string;
envelope: SaveEnvelope;
savedAt: string;
}
interface SnapshotRecord {
id: string;
schemaVersion: number;
savedAt: string;
envelope: SaveEnvelope;
}
type RecordOf<S extends StoreName> = S extends 'saves' ? SavedRecord : SnapshotRecord;
function nsKey(store: StoreName, id: string): string {
return `tlg.${store}.${id}`;
}
function nsPrefix(store: StoreName): string {
return `tlg.${store}.`;
}
export class LocalStorageDBAdapter {
readonly objectStoreNames = {
contains: (s: string) => 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. We expose the
* same shape as idb's transaction() so snapshots.ts's `db.transaction(...).objectStore(...)`
* pattern works against both backends. `done` resolves immediately because
* each set/remove is its own atomic operation.
*/
transaction(store: StoreName, _mode: 'readwrite' | 'readonly') {
const adapter = this;
return {
objectStore: (s: StoreName) => ({
put: (value: RecordOf<typeof 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(),
};
}
}
```
**Step 2 — `src/save/db.ts`:**
```typescript
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 {
id: 'main'; // singleton key — Phase 1 ships one save slot only
envelope: SaveEnvelope;
savedAt: string; // ISO8601
}
export interface SnapshotRecord {
id: string; // `${schemaVersion}-${savedAt}`
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".
* Tested in db.test.ts via stub-injected openDB 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).
console.warn('[save] IndexedDB unavailable, falling back to localStorage:', err);
return new LocalStorageDBAdapter();
}
}
```
Per RESEARCH § "Don't Hand-Roll", `idb` is the right wrapper. The two-store split (`saves` + `save_snapshots`) is per RESEARCH Pattern 3 — snapshots are kept separate so migrating the main save never affects the snapshot history. The localStorage fallback adapter mirrors the same two stores, namespaced under `tlg.saves.*` / `tlg.save_snapshots.*`.
**Step 3 — `src/save/db.test.ts`:** Plan 01 Task 1 already installed `fake-indexeddb@^6` (verify with `grep -q '"fake-indexeddb"' package.json` before authoring); import it directly:
```typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
import 'fake-indexeddb/auto'; // happy-dom doesn't ship IDB; fake-indexeddb is the polyfill (installed by Plan 01)
import { openSaveDB, SAVE_DB_NAME } from './db';
import { wrap } from './envelope';
import { LocalStorageDBAdapter } from './db-localstorage-adapter';
beforeEach(async () => {
// Reset IDB and localStorage between tests
indexedDB.deleteDatabase(SAVE_DB_NAME);
localStorage.clear();
vi.unstubAllGlobals();
vi.resetModules();
});
describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => {
it('opens a DB with saves and save_snapshots object stores', async () => {
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 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);
});
});
describe('openSaveDB (CORE-04 localStorage fallback path)', () => {
it('falls back to LocalStorageDBAdapter when IndexedDB is unavailable', async () => {
// Stub the idb module's openDB so it rejects, simulating private mode / blocked IDB.
vi.doMock('idb', async () => ({
openDB: vi.fn().mockRejectedValue(new Error('IDB unavailable (test stub)')),
}));
// Re-import db.ts after the mock is registered so it picks up the rejecting openDB.
const { openSaveDB: openSaveDBFresh } = await import('./db');
const db = await openSaveDBFresh();
expect(db).toBeInstanceOf(LocalStorageDBAdapter);
// 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();
vi.doUnmock('idb');
});
});
```
**Step 4 — `src/save/snapshots.ts`** (per RESEARCH Pattern 3 verbatim, but consuming the union `SaveDB` type so it works against both backends):
```typescript
import { openSaveDB } from './db';
import type { SaveEnvelope } from './envelope';
export interface SnapshotEntry {
id: string;
schemaVersion: number;
savedAt: string;
envelope: SaveEnvelope;
}
const RETAIN = 3;
/**
* Write a pre-migration snapshot. After every write, prune to the 3 newest
* entries by savedAt (descending). Works against both IDB and localStorage backends.
*/
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();
// Make ID unique even if two snapshots fire in the same ms (rare in tests)
const id = `${envelope.schemaVersion}-${savedAt}-${Math.random().toString(36).slice(2, 8)}`;
await store.put({ id, schemaVersion: envelope.schemaVersion, savedAt, envelope });
// Prune
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;
}
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));
}
```
**Step 5 — `src/save/snapshots.test.ts`** with the 4 behaviors above. The CORE-08 test:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import 'fake-indexeddb/auto';
import { snapshot, listSnapshots } from './snapshots';
import { wrap } from './envelope';
import { SAVE_DB_NAME } from './db';
beforeEach(() => indexedDB.deleteDatabase(SAVE_DB_NAME));
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 any).generation)).toEqual([4, 3, 2]);
});
});
```
**Step 6 — `src/save/persist.ts`** (per RESEARCH § "Code Examples — Persist API call with respectful surfacing" verbatim):
```typescript
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). Caller (Phase 2 settings UI)
* surfaces apiAvailable=false / granted=false respectfully.
*/
export 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 };
}
}
```
**Step 7 — `src/save/persist.test.ts`** with the 4 behaviors. Use Vitest's `vi.stubGlobal` to mock `navigator.storage.persist` per case:
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { requestPersistence } from './persist';
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 });
});
});
```
**Step 8 — Verify all tests pass: `npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts`.**
**Step 9 — Commit `feat(01-03): idb DB + localStorage fallback adapter (CORE-04) + last-3 snapshot retention + navigator.storage.persist API`.**
</action>
<verify>
<automated>npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts</automated>
</verify>
<acceptance_criteria>
- Precondition: `fake-indexeddb` is listed in devDependencies (installed by Plan 01 Task 1) — verify with `grep -q '"fake-indexeddb"' package.json` (this is a precondition assertion, not a new install).
- `src/save/db-localstorage-adapter.ts` exists and exports `LocalStorageDBAdapter` — verify with `grep -q "^export class LocalStorageDBAdapter" src/save/db-localstorage-adapter.ts`.
- `LocalStorageDBAdapter` namespaces under `tlg.saves.*` and `tlg.save_snapshots.*` — verify with `grep -E "tlg\\.(saves|save_snapshots)\\." src/save/db-localstorage-adapter.ts | wc -l` returns at least 2.
- `src/save/db.ts` opens a DB with stores `saves` AND `save_snapshots` — verify with `grep -E "createObjectStore\\('(saves|save_snapshots)'" src/save/db.ts | wc -l` returns 2.
- `src/save/db.ts` wraps `openDB` in try/catch and returns `LocalStorageDBAdapter` on failure (CORE-04 fallback) — verify with `grep -q "LocalStorageDBAdapter" src/save/db.ts && grep -E "catch\\b" src/save/db.ts`.
- `src/save/db.test.ts` includes a test that stub-injects an IDB failure and verifies the fallback path round-trips — verify with `grep -q "vi.doMock" src/save/db.test.ts && grep -q "LocalStorageDBAdapter" src/save/db.test.ts && grep -q "tlg.saves.main" src/save/db.test.ts`.
- `src/save/snapshots.ts` exports `snapshot` and `listSnapshots` — verify with `grep -cE "^export (async )?function (snapshot|listSnapshots)" src/save/snapshots.ts` returns 2.
- `RETAIN` constant in snapshots.ts is exactly 3 — verify with `grep -E "RETAIN\\s*=\\s*3" src/save/snapshots.ts`.
- `src/save/persist.ts` exports `requestPersistence` and `PersistResult` — verify with `grep -cE "^export (function|interface|type) (requestPersistence|PersistResult)" src/save/persist.ts` returns 2.
- The CORE-08 test asserts `toHaveLength(3)` after 5 writes — verify with `grep -q "toHaveLength(3)" src/save/snapshots.test.ts`.
- All 4 persist.test.ts cases stub `navigator.storage` — verify with `grep -cE "vi.stubGlobal" src/save/persist.test.ts` returns at least 4.
- All tests pass: `npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts 2>&1 | grep -E "passed"`.
- No `any` types in production files (test files may use `as any` for stub typing only) — verify with `grep -nE ': any\\b' src/save/db.ts src/save/db-localstorage-adapter.ts src/save/snapshots.ts src/save/persist.ts`; zero matches.
</acceptance_criteria>
<done>
`idb`-wrapped IndexedDB with both stores; `LocalStorageDBAdapter` (~30-40 LoC) implementing the same minimal interface for the CORE-04 fallback path; `openSaveDB()` returns the IDB DB on success and the adapter on rejection; one Vitest test stub-injects an IDB failure and exercises the localStorage round-trip end-to-end (verifies `tlg.saves.main` key is written); last-3 snapshot retention with the CORE-08 5-then-3 invariant test; persist API with all 4 navigator.storage scenarios covered; commit landed.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Base64 codec + round-trip integration test + index re-exports</name>
<files>
src/save/codec.ts,
src/save/round-trip.test.ts,
src/save/index.ts
</files>
<read_first>
- .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Code Examples — Save round-trip (Phase 1's load-bearing test)" (verbatim test) and § "Common Pitfalls — Pitfall 5: Synchronous lz-string compression of huge saves blocks the main thread"
- .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Security Domain" (Phase 1 threat: malformed Base64 import — DoS via huge inflated string; cap payload size at 50MB before decompression)
- src/save/envelope.ts (this plan's Task 1 — wrap/unwrap signatures)
- src/save/migrations.ts (this plan's Task 1 — migrate signature)
- lz-string 1.5.0 README: compressToBase64 / decompressFromBase64 semantics
</read_first>
<behavior>
- **codec.ts:**
- Test 1: `exportToBase64(env)` returns a non-empty string.
- Test 2: `importFromBase64(exportToBase64(env))` deep-equals the original envelope.
- Test 3: `importFromBase64('not-valid-base64-junk')` throws (malformed import detection).
- Test 4: `importFromBase64(<base64 string longer than MAX_IMPORT_BYTES>)` throws BEFORE decompression (DoS cap).
- **round-trip.test.ts (the load-bearing CORE-09 test):**
- The full pipeline per RESEARCH § "Save round-trip" verbatim: synthesize a v0 envelope, export to Base64, simulate a "fresh browser" by importing back from Base64, migrate v0 → v1, wrap in a v1 envelope with valid checksum, unwrap, assert original payload returned.
- Bonus: write the migrated v1 envelope to IDB, read it back, unwrap, assert equality (proves IDB + envelope + migration + codec all integrate).
- **index.ts:**
- Re-exports the public surface for Phase 2 consumption.
</behavior>
<action>
**Step 1 — `src/save/codec.ts`:**
```typescript
import LZString from 'lz-string';
import { SaveEnvelopeSchema, type SaveEnvelope } from './envelope';
/**
* 50MB cap on Base64 import string length, per Phase 1 threat model
* (.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;
* still, refuse pathologically large inputs at the boundary.
*/
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
/**
* Export a SaveEnvelope to a Base64 text blob suitable for "Settings → Export".
* Phase 1 ships the function pair; Phase 2 wires the UI button (CORE-09).
*/
export function exportToBase64<T>(envelope: SaveEnvelope<T>): string {
return LZString.compressToBase64(JSON.stringify(envelope));
}
/**
* Import from a Base64 text blob. Throws on:
* - input larger than MAX_IMPORT_BYTES (DoS cap)
* - lz-string decompression failure
* - JSON parse failure
* - SaveEnvelopeSchema validation failure (malformed envelope shape)
*
* Note: this does NOT verify checksum or run migrations — the caller chains
* importFromBase64 → migrate → unwrap. See round-trip.test.ts for the full pipeline.
*/
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)');
}
const parsed = JSON.parse(decompressed);
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>;
}
```
Per RESEARCH Pitfall 5: lz-string is synchronous; for Phase 1 saves (<10KB) this is fine. Document the eventual mitigation as a code comment so Phase 8 perf work knows where to look. Do NOT build the Web Worker now (premature per CONTEXT D-09 minimum-viable directive).
**Step 2 — `src/save/round-trip.test.ts`** (per RESEARCH § "Code Examples — Save round-trip" verbatim, extended with IDB integration):
```typescript
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, SAVE_DB_NAME } from './db';
beforeEach(() => indexedDB.deleteDatabase(SAVE_DB_NAME));
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).
const v0Envelope = {
schemaVersion: 0,
payload: v0Payload,
checksum: '00000000', // 8-char hex placeholder
};
// Export through Base64 codec
const exported = exportToBase64(v0Envelope as any);
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) },
});
// Re-wrap with current version and a valid checksum
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();
});
});
```
**Step 3 — `src/save/index.ts`** — public re-exports for Phase 2:
```typescript
/**
* Public surface of the save layer. Phase 2's tick scheduler + Zustand store
* are the first consumers.
*/
export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope';
export type { SaveEnvelope } from './envelope';
export { migrate, CURRENT_SCHEMA_VERSION, migrations } 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 } from './db';
export { LocalStorageDBAdapter } from './db-localstorage-adapter';
export { crc32hex, canonicalJSON } from './checksum';
```
**Step 4 — Run the full save test suite: `npx vitest run src/save/`.** Expect all tests across 8 files (checksum, envelope, migrations, db, snapshots, persist, round-trip; LocalStorageDBAdapter is exercised by db.test.ts) to pass.
**Step 5 — Run `npm test`** to confirm the entire test suite (sentinel + lint-firewall from Plan 02 + all save tests) passes.
**Step 6 — Run `npm run build`** to confirm the save layer compiles cleanly with TypeScript strict.
**Step 7 — Commit `feat(01-03): Base64 codec + DoS-capped import + full save round-trip integration test + index re-exports`.**
</action>
<verify>
<automated>npx vitest run src/save/ &amp;&amp; npm run build</automated>
</verify>
<acceptance_criteria>
- `src/save/codec.ts` exports `exportToBase64`, `importFromBase64`, `MAX_IMPORT_BYTES` — verify with `grep -cE "^export (function|const) (exportToBase64|importFromBase64|MAX_IMPORT_BYTES)" src/save/codec.ts` returns 3.
- `MAX_IMPORT_BYTES` is exactly 50MB — verify with `grep -E "MAX_IMPORT_BYTES\\s*=\\s*50\\s*\\*\\s*1024\\s*\\*\\s*1024" src/save/codec.ts`.
- `importFromBase64` validates against `SaveEnvelopeSchema` — verify with `grep -q "SaveEnvelopeSchema.safeParse" src/save/codec.ts`.
- `src/save/index.ts` exports the full public surface including `LocalStorageDBAdapter` — verify with `grep -cE "^export " src/save/index.ts` returns at least 11 (wrap, unwrap, SaveCorruptError, migrate, snapshot, requestPersistence, exportToBase64, importFromBase64, openSaveDB, LocalStorageDBAdapter, crc32hex, etc).
- The round-trip test asserts on the v0→v1 migration shape from CONTEXT D-04 — verify with `grep -E "tiles:|plants:|harvestedFragmentIds:|lastTickAt:|settings:" src/save/round-trip.test.ts | grep -v '^#' | wc -l` returns at least 5.
- The round-trip test exercises EXPORT → IMPORT → MIGRATE → WRAP → UNWRAP → IDB PUT → IDB GET — verify with `grep -E "exportToBase64|importFromBase64|migrate|wrap|unwrap|openSaveDB|db\\.put|db\\.get" src/save/round-trip.test.ts | wc -l` returns at least 7 (one per stage).
- The DoS cap is tested — verify with `grep -q "50 \\* 1024 \\* 1024 + 1" src/save/round-trip.test.ts`.
- `npx vitest run src/save/` passes ALL tests — verify exit 0; expect roughly 16+5+4+2+3 = ~30 tests across 7 test files (db.test.ts now has 1 extra fallback test).
- `npm run build` exits 0 — TypeScript strict compilation passes including the full save layer.
- No `any` types in production code (codec.ts, index.ts) — verify with `grep -nE ': any\\b' src/save/codec.ts src/save/index.ts`; zero matches.
</acceptance_criteria>
<done>
Base64 export/import codec with 50MB DoS cap; full round-trip test exercising every save layer file (CORE-04 + 06 + 07 + 09 in one go); public re-exports (including `LocalStorageDBAdapter` and `SaveDB` union type) indexed for Phase 2; entire save test suite green under `npm test`; `npm run build` succeeds under TypeScript strict; commit landed.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Disk → IndexedDB | Saved envelopes loaded back from storage; data may have been corrupted in lossy storage (Chrome eviction, partial write, browser crash). Mitigated by CRC-32 envelope checksum. |
| User → Base64 import | A pasted Base64 string from "Settings → Import" (Phase 2 wires the UI; Phase 1 ships the function). User is single-player but the input is still untrusted bytes. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-01 | Tampering | `unwrap()` of save envelope | mitigate | CRC-32 checksum over canonical JSON detects corruption (lossy storage, partial writes); throws `SaveCorruptError` on mismatch. **Not** a cryptographic guarantee — a player editing their own save is by-design acceptable in a single-player contemplative game per RESEARCH § Security Domain. Phase 2's UI surfaces the recovery option (last-3 snapshots from CORE-08) when this throws. |
| T-01-02 | Denial of Service | `importFromBase64()` | mitigate | Cap input length at `MAX_IMPORT_BYTES = 50 * 1024 * 1024` BEFORE invoking `lz-string.decompressFromBase64` (which is synchronous and would block the main thread on huge inputs per RESEARCH Pitfall 5 + § Security Domain). Throws an Error with `/exceeds/` message when input exceeds cap. Tested in round-trip.test.ts. |
| T-01-03 | Tampering | Save authentication (player edits Base64 export and reimports) | accept | Single-player game; no leaderboards, no monetization gates in Phase 1; player tampering with their own save is the player's prerogative. Documented explicitly in `codec.ts` and RESEARCH § "Phase 1 explicit security non-goals". CRC-32 detects corruption, NOT adversarial editing — by design. |
| T-01-04 | Information Disclosure | Save contents | accept | Phase 1 saves contain no PII (no Keeper name per STRY-07; no auth, no sessions). Garden state, plant data, harvested fragment IDs are non-sensitive. |
| T-01-05 | Spoofing | Cross-origin import via URL params (future risk if save-via-link is added) | accept (out of scope) | Phase 1 has no URL import mechanism. Flagged here for Phase 4+ when Settings UI is wired: import flow MUST require explicit user confirmation, NEVER auto-load from URL. |
</threat_model>
<verification>
- `npx vitest run src/save/` passes every test (target: ≥30 tests across 7 test files; db.test.ts now includes the CORE-04 localStorage fallback test).
- `npm run build` exits 0 under TypeScript strict (no `any` in production code).
- `npm run lint` exits 0 (the save layer respects the firewall — no `import` from `src/render/` or `src/ui/`; this would also fire the Plan 02 boundary rule).
- All 6 CORE requirements (CORE-04 through CORE-09) have at least one Vitest assertion explicitly named or commented as covering them. CORE-04 specifically covers BOTH the IDB-primary path AND the localStorage-fallback path.
- The CRC-32 envelope and the 50MB DoS cap satisfy Phase 1's two STRIDE-mitigate threats.
</verification>
<success_criteria>
- Save envelope `{schemaVersion, payload, checksum}` with CRC-32 over canonical JSON, exported via wrap/unwrap and tested for round-trip + tamper-detection.
- Migration chain with CURRENT_SCHEMA_VERSION = 1 and one synthetic v0 → v1 demo migration that produces the v1 shape from CONTEXT D-04.
- IDB DB with two object stores: `saves` (singleton) + `save_snapshots` (last-3 retention).
- `LocalStorageDBAdapter` implementing the same minimal interface as the IDB DB; `openSaveDB()` falls back to the adapter when IDB is unavailable (CORE-04 IndexedDB-primary + localStorage-fallback contract).
- `requestPersistence()` covers all four navigator.storage scenarios.
- Base64 export/import via lz-string with a 50MB DoS cap.
- Full round-trip test covers every component end-to-end.
- All 6 Phase-1 CORE save requirements automated and green.
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundations-and-doctrine/01-03-SUMMARY.md` documenting:
- Final test count (`npx vitest run src/save/ 2>&1 | tail -5`).
- The exact `CURRENT_SCHEMA_VERSION` (must be 1) and what the v1 shape contains (so Phase 4's `migrate_v1_to_v2` author has the contract).
- **CORE-04 fallback note:** the localStorage fallback is shipped in Phase 1 per the orchestrator's revision-iteration-1 decision (REQUIREMENTS.md CORE-04 + ROADMAP success criterion #2 both require it). The fallback is a thin (~30-40 LoC) `LocalStorageDBAdapter` exposing the same minimal interface as the IDB DB; `openSaveDB()` wraps `openDB()` in try/catch and returns the adapter on rejection. A single Vitest test (`db.test.ts` "falls back to LocalStorageDBAdapter when IndexedDB is unavailable") stub-injects an IDB failure via `vi.doMock('idb')` and asserts the round-trip succeeds with `tlg.saves.main` written to localStorage.
- Confirmation that the public surface in `src/save/index.ts` is the only entry point Phase 2 should import from.
</output>
</output>