feat(01-03): save envelope + canonical-JSON CRC32 + synthetic v0->v1 migration [GREEN]

- checksum.ts: crc32hex (8-char lowercase hex of CRC-32, signed->unsigned via >>>0)
  + canonicalJSON (recursive object-key sort, arrays preserved) per Pitfall 3
- envelope.ts: wrap/unwrap with SaveCorruptError on checksum mismatch + Zod
  SaveEnvelopeSchema accepting nonnegative schemaVersion (allows synthetic v0)
- migrations.ts: forward-only registry with migrations[1] producing the v1
  shape from CONTEXT D-04 (garden.tiles, plants, harvestedFragmentIds,
  lastTickAt, settings); throws on negative or future-version inputs

Removes src/save/.gitkeep firewall marker (real source files now live here).

Tests: 21/21 pass (npx vitest run src/save/checksum.test.ts
src/save/envelope.test.ts src/save/migrations.test.ts).
TypeScript-strict; no 'any' in production code (CLAUDE.md).
This commit is contained in:
2026-05-08 23:28:56 -04:00
parent 445a46139f
commit b6cc9000c3
3 changed files with 211 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import CRC32 from 'crc-32';
/**
* 8-char lowercase hex CRC-32 of the input string.
* crc-32 returns a signed 32-bit integer; we mask to unsigned and pad.
* Used by envelope.wrap/unwrap to detect save corruption (lossy storage,
* partial writes, browser-eviction truncation).
*/
export function crc32hex(input: string): string {
const signed = CRC32.str(input);
const unsigned = signed >>> 0; // coerce to uint32
return unsigned.toString(16).padStart(8, '0');
}
/**
* Deterministic JSON serialization with recursively-sorted object keys.
* Required because checksum stability depends on stable key order across
* V8 / SpiderMonkey / JavaScriptCore runs and across migration round-trips
* (per .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 3).
*
* Arrays are NOT sorted — their order is meaningful (a garden tile list,
* a timeline of harvested fragments). Only plain object keys are reordered.
*
* Hand-rolled rather than pulling in `json-stable-stringify` per RESEARCH
* Open Question #1: ~10 LoC saves a dependency.
*/
export function canonicalJSON(value: unknown): string {
return JSON.stringify(value, (_key, val) => {
if (val && typeof val === 'object' && !Array.isArray(val)) {
return Object.fromEntries(
Object.entries(val as Record<string, unknown>).sort(([a], [b]) =>
a.localeCompare(b),
),
);
}
return val;
});
}
+73
View File
@@ -0,0 +1,73 @@
import { z } from 'zod';
import { crc32hex, canonicalJSON } from './checksum';
/**
* The save envelope shape, locked by CLAUDE.md "Code Style":
* `{schemaVersion, payload, checksum}`
*
* `schemaVersion` is `nonnegative` (NOT `positive`) because CONTEXT D-05
* declares the synthetic v0 era — see migrations.ts. RESEARCH Pattern 1's
* example uses `positive` but that conflicts with D-05's requirement.
*/
export const SaveEnvelopeSchema = z.object({
schemaVersion: z.number().int().nonnegative(),
payload: z.unknown(),
checksum: z.string().regex(/^[0-9a-f]{8}$/),
});
export type SaveEnvelope<T = unknown> = {
schemaVersion: number;
payload: T;
checksum: string;
};
/**
* Thrown by `unwrap` when the envelope's stored checksum disagrees with
* the recomputed checksum of the payload. Phase 2's settings UI surfaces
* this with the recovery option (load from `save_snapshots` per CORE-08).
*
* NOT a cryptographic guarantee — see threat-model T-01-03 in the plan.
* A player editing their own save is acceptable in single-player; this
* detects lossy-storage corruption, not adversarial editing.
*/
export class SaveCorruptError extends Error {
override readonly name = 'SaveCorruptError';
constructor(
public readonly expected: string,
public readonly actual: string,
) {
super(`Save checksum mismatch: expected ${expected}, got ${actual}`);
}
}
/**
* Wrap a payload in an envelope at the given schema version. Computes the
* checksum over the canonical-JSON serialization of the payload so that
* key order does not affect the checksum (per RESEARCH Pitfall 3).
*/
export function wrap<T>(payload: T, schemaVersion: number): SaveEnvelope<T> {
return {
schemaVersion,
payload,
checksum: crc32hex(canonicalJSON(payload)),
};
}
/**
* Unwrap an envelope, verifying the checksum. Throws `SaveCorruptError`
* when the payload's recomputed checksum does not match the envelope's
* stored checksum.
*
* The `expected` field on the error is the value the envelope ARRIVED with
* (what the storage layer expected to be authoritative); `actual` is the
* value computed from the payload as decoded. Phase 2's recovery UI shows
* this delta so the user can choose between rolling back to a snapshot
* or accepting the (presumably-tampered) payload as-is.
*/
export function unwrap<T>(env: SaveEnvelope<unknown>): T {
const computed = crc32hex(canonicalJSON(env.payload));
if (computed !== env.checksum) {
throw new SaveCorruptError(env.checksum, computed);
}
return env.payload as T;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Forward-only save migration registry.
*
* Each entry `migrations[N]` is the function that migrates payload from
* schema version N-1 to schema version N. Phase 1 ships migrations[1]
* (the synthetic v0 → v1 demo per CONTEXT D-05); Phase 4 will land
* migrations[2] when prestige / Roothold state lands.
*
* The v1 shape (from CONTEXT D-04) is intentionally minimal: only what
* Phase 2's first feature commit will write. Authoring it now lets us
* prove the migration chain end-to-end without speculating about future
* Season 5+ structures.
*/
type Migration = (payload: unknown) => unknown;
export const CURRENT_SCHEMA_VERSION = 1;
interface V0Payload {
garden?: unknown[];
}
/**
* The minimal v1 save shape per CONTEXT D-04: garden tiles, plant growth
* data placeholder, harvested fragment IDs, last tick timestamp, settings.
* Phase 2 fleshes the contents; Phase 1 just locks the field set.
*/
export interface V1Payload {
garden: { tiles: unknown[] };
plants: unknown[];
harvestedFragmentIds: string[];
lastTickAt: number;
settings: {
musicVolume: number;
ambientVolume: number;
sfxVolume: number;
};
}
/**
* Forward-only migration chain. Keys are TARGET versions; the function
* at key N migrates FROM N-1 TO N.
*
* - `migrations[1]` = v0 → v1 (synthetic demo per CONTEXT D-05).
* - `migrations[2]` = v1 → v2 will be added in Phase 4 when Roothold /
* prestige state lands.
*/
export const migrations: Record<number, Migration> = {
1: (s: unknown): V1Payload => {
const v0 = (s ?? {}) as V0Payload;
return {
garden: { tiles: v0.garden ?? [] },
plants: [],
harvestedFragmentIds: [],
lastTickAt: Date.now(),
settings: {
musicVolume: 0.7,
ambientVolume: 0.5,
sfxVolume: 0.8,
},
};
},
};
/**
* Migrate `payload` from `fromVersion` up to `CURRENT_SCHEMA_VERSION`,
* applying each registered migration in order. Returns both the migrated
* payload and the schema version it now matches.
*
* Throws when:
* - `fromVersion` is negative (invalid input)
* - `fromVersion` is greater than `CURRENT_SCHEMA_VERSION` (future save
* from a newer build of the game — refuse to silently downgrade)
* - any required migration function is missing
*/
export function migrate(
payload: unknown,
fromVersion: number,
): { payload: unknown; toVersion: number } {
if (fromVersion < 0) {
throw new Error(`Cannot migrate from negative version ${fromVersion}`);
}
if (fromVersion > CURRENT_SCHEMA_VERSION) {
throw new Error(
`Cannot migrate from future version ${fromVersion} (current: ${CURRENT_SCHEMA_VERSION})`,
);
}
let current = payload;
let v = fromVersion;
while (v < CURRENT_SCHEMA_VERSION) {
const next = v + 1;
const fn = migrations[next];
if (!fn) {
throw new Error(`No migration registered for v${v} → v${next}`);
}
current = fn(current);
v = next;
}
return { payload: current, toVersion: v };
}