const EVENT_SCHEMA = "event-log.v1"; const SNAPSHOT_SCHEMA = "snapshot.v1"; const REPLAY_SCHEMA = "replay-timeline.v1"; export function canonicalJson(value, path = "$") { if (value === null) return "null"; if (typeof value === "number") { if (!Number.isFinite(value)) throw new Error("canonical_nonfinite"); return JSON.stringify(value); } if (typeof value !== "object") { if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") throw new Error(`canonical_unsupported:${path}`); return JSON.stringify(value); } if (Array.isArray(value)) return `[${value.map((item, index) => canonicalJson(item, `${path}[${index}]`)).join(",")}]`; return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key], `${path}.${key}`)}`).join(",")}}`; } export function fnv1a64(value) { const bytes = new TextEncoder().encode(typeof value === "string" ? value : canonicalJson(value)); let hash = 1469598103934665603n; for (const byte of bytes) { hash ^= BigInt(byte); hash = BigInt.asUintN(64, hash * 1099511628211n); } return `fnv1a64:${hash.toString(16).padStart(16, "0")}`; } function requireInteger(value, name) { if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_${name}`); } function requireSequence(value, name) { requireInteger(value, name); if (value === 0) throw new Error(`invalid_${name}`); } function clone(value) { return value === undefined ? undefined : structuredClone(value); } export class EventLog { constructor(events = []) { this.events = []; for (const event of events) this.append(event.seq, event.tick, event.kind, event.payload, event.payload_hash); } append(seq, tick, kind, payload = {}, expectedHash = undefined) { requireSequence(seq, "seq"); requireInteger(tick, "tick"); if (seq !== this.cursor + 1) throw new Error("event_sequence_gap"); if (tick < this.tick) throw new Error("event_tick_regression"); if (typeof kind !== "string" || kind.length === 0 || kind.length > 64) throw new Error("event_kind_invalid"); if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("event_payload_invalid"); const payloadHash = fnv1a64(payload); if (expectedHash !== undefined && expectedHash !== payloadHash) throw new Error("event_payload_hash_mismatch"); const event = { seq, tick, kind, payload: clone(payload), payload_hash: payloadHash }; this.events.push(event); return clone(event); } get cursor() { return this.events.length === 0 ? 0 : this.events[this.events.length - 1].seq; } get tick() { return this.events.length === 0 ? 0 : this.events[this.events.length - 1].tick; } export() { return { schema: EVENT_SCHEMA, cursor: this.cursor, tick: this.tick, events: clone(this.events) }; } static import(value) { if (!value || value.schema !== EVENT_SCHEMA || !Array.isArray(value.events)) throw new Error("event_log_schema"); requireInteger(value.cursor, "event_cursor"); requireInteger(value.tick, "event_tick"); const log = new EventLog(value.events); if (log.cursor !== value.cursor || log.tick !== value.tick) throw new Error("event_log_cursor_mismatch"); return log; } } export class SnapshotStore { constructor(snapshots = []) { this.snapshots = new Map(); for (const snapshot of snapshots) this.importSnapshot(snapshot); } create(seq, tick, state, baseSnapshotId = null) { requireSequence(seq, "snapshot_seq"); requireInteger(tick, "snapshot_tick"); if (baseSnapshotId !== null && !this.snapshots.has(baseSnapshotId)) throw new Error("snapshot_base_missing"); if (state === undefined) throw new Error("snapshot_state_missing"); const stateHash = fnv1a64(state); const snapshot = { schema: SNAPSHOT_SCHEMA, snapshot_id: `snap:${seq}:${stateHash}`, base_snapshot_id: baseSnapshotId, seq, tick, state: clone(state), state_hash: stateHash, }; const existingAtSeq = [...this.snapshots.values()].find((candidate) => candidate.seq === seq); if (existingAtSeq && existingAtSeq.snapshot_id !== snapshot.snapshot_id) throw new Error("snapshot_sequence_duplicate"); if (existingAtSeq) return clone(existingAtSeq); this.snapshots.set(snapshot.snapshot_id, snapshot); return clone(snapshot); } importSnapshot(snapshot) { if (!snapshot || snapshot.schema !== SNAPSHOT_SCHEMA || typeof snapshot.snapshot_id !== "string") throw new Error("snapshot_schema"); requireSequence(snapshot.seq, "snapshot_seq"); requireInteger(snapshot.tick, "snapshot_tick"); if (snapshot.base_snapshot_id !== null && typeof snapshot.base_snapshot_id !== "string") throw new Error("snapshot_base_invalid"); if (snapshot.base_snapshot_id !== null && !this.snapshots.has(snapshot.base_snapshot_id)) throw new Error("snapshot_base_missing"); if (fnv1a64(snapshot.state) !== snapshot.state_hash) throw new Error("snapshot_hash_mismatch"); if (snapshot.snapshot_id !== `snap:${snapshot.seq}:${snapshot.state_hash}`) throw new Error("snapshot_id_mismatch"); if (this.snapshots.has(snapshot.snapshot_id)) throw new Error("snapshot_duplicate"); if ([...this.snapshots.values()].some((candidate) => candidate.seq === snapshot.seq)) throw new Error("snapshot_sequence_duplicate"); this.snapshots.set(snapshot.snapshot_id, clone(snapshot)); return clone(snapshot); } latest() { if (this.snapshots.size === 0) return null; return clone([...this.snapshots.values()].sort((left, right) => left.seq - right.seq || left.tick - right.tick || left.snapshot_id.localeCompare(right.snapshot_id)).at(-1)); } export() { return { schema: SNAPSHOT_SCHEMA, snapshots: clone([...this.snapshots.values()].sort((left, right) => left.seq - right.seq || left.tick - right.tick || left.snapshot_id.localeCompare(right.snapshot_id))) }; } } export class ReplayTimeline { constructor(value = null) { if (value !== null && (!value || value.schema !== REPLAY_SCHEMA || !value.events || !value.snapshots)) throw new Error("replay_schema"); this.events = value ? EventLog.import(value.events) : new EventLog(); this.snapshots = value ? new SnapshotStore(value.snapshots.snapshots) : new SnapshotStore(); } appendEvent(seq, tick, kind, payload = {}) { return this.events.append(seq, tick, kind, payload); } checkpoint(seq, tick, state, baseSnapshotId = null) { if (seq !== this.events.cursor || tick !== this.events.tick) throw new Error("checkpoint_cursor_mismatch"); return this.snapshots.create(seq, tick, state, baseSnapshotId); } replay(initialState, apply) { if (typeof apply !== "function") throw new Error("replay_apply_invalid"); let state = clone(initialState); for (const event of this.events.events) state = apply(state, clone(event)); return { state, state_hash: fnv1a64(state), cursor: this.events.cursor, tick: this.events.tick }; } export() { return { schema: REPLAY_SCHEMA, events: this.events.export(), snapshots: this.snapshots.export() }; } } export { EVENT_SCHEMA, SNAPSHOT_SCHEMA, REPLAY_SCHEMA };