Files
linuxcncWebWASM/web/worker/replay.mjs

126 lines
4.8 KiB
JavaScript

const EVENT_SCHEMA = "event-log.v1";
const SNAPSHOT_SCHEMA = "snapshot.v1";
export function canonicalJson(value) {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[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}`);
}
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) {
requireInteger(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");
const payloadHash = fnv1a64(payload);
if (expectedHash !== undefined && expectedHash !== payloadHash) throw new Error("event_payload_hash_mismatch");
const event = { seq, tick, kind, payload, payload_hash: payloadHash };
this.events.push(event);
return 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: this.events.map((event) => ({ ...event })) };
}
static import(value) {
if (!value || value.schema !== EVENT_SCHEMA || !Array.isArray(value.events)) throw new Error("event_log_schema");
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) {
requireInteger(seq, "snapshot_seq");
requireInteger(tick, "snapshot_tick");
if (baseSnapshotId !== null && !this.snapshots.has(baseSnapshotId)) throw new Error("snapshot_base_missing");
const stateHash = fnv1a64(state);
const snapshot = {
schema: SNAPSHOT_SCHEMA,
snapshot_id: `snap:${seq}:${stateHash}`,
base_snapshot_id: baseSnapshotId,
seq,
tick,
state,
state_hash: stateHash,
};
this.snapshots.set(snapshot.snapshot_id, snapshot);
return snapshot;
}
importSnapshot(snapshot) {
if (!snapshot || snapshot.schema !== SNAPSHOT_SCHEMA || typeof snapshot.snapshot_id !== "string") throw new Error("snapshot_schema");
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");
this.snapshots.set(snapshot.snapshot_id, { ...snapshot });
return snapshot;
}
latest() {
return this.snapshots.size === 0 ? null : [...this.snapshots.values()][this.snapshots.size - 1];
}
export() {
return { schema: SNAPSHOT_SCHEMA, snapshots: [...this.snapshots.values()].map((snapshot) => ({ ...snapshot })) };
}
}
export class ReplayTimeline {
constructor(value = null) {
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) {
return this.snapshots.create(seq, tick, state, baseSnapshotId);
}
replay(initialState, apply) {
let state = initialState;
for (const event of this.events.events) state = apply(state, event);
return { state, state_hash: fnv1a64(state), cursor: this.events.cursor, tick: this.events.tick };
}
export() {
return { events: this.events.export(), snapshots: this.snapshots.export() };
}
}
export { EVENT_SCHEMA, SNAPSHOT_SCHEMA };