39563f6934
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>
1034 lines
58 KiB
Markdown
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/ && 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>
|