Extend simulation runtime coverage and kinematics support
This commit is contained in:
@@ -1,8 +1,17 @@
|
||||
const EVENT_SCHEMA = "event-log.v1";
|
||||
const SNAPSHOT_SCHEMA = "snapshot.v1";
|
||||
const REPLAY_SCHEMA = "replay-timeline.v1";
|
||||
|
||||
export function canonicalJson(value) {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
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");
|
||||
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(",")}}`;
|
||||
}
|
||||
@@ -21,6 +30,15 @@ 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 = [];
|
||||
@@ -28,27 +46,30 @@ export class EventLog {
|
||||
}
|
||||
|
||||
append(seq, tick, kind, payload = {}, expectedHash = undefined) {
|
||||
requireInteger(seq, "seq");
|
||||
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, payload_hash: payloadHash };
|
||||
const event = { seq, tick, kind, payload: clone(payload), payload_hash: payloadHash };
|
||||
this.events.push(event);
|
||||
return 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: this.events.map((event) => ({ ...event })) };
|
||||
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;
|
||||
@@ -62,9 +83,10 @@ export class SnapshotStore {
|
||||
}
|
||||
|
||||
create(seq, tick, state, baseSnapshotId = null) {
|
||||
requireInteger(seq, "snapshot_seq");
|
||||
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,
|
||||
@@ -72,33 +94,43 @@ export class SnapshotStore {
|
||||
base_snapshot_id: baseSnapshotId,
|
||||
seq,
|
||||
tick,
|
||||
state,
|
||||
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 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");
|
||||
this.snapshots.set(snapshot.snapshot_id, { ...snapshot });
|
||||
return snapshot;
|
||||
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() {
|
||||
return this.snapshots.size === 0 ? null : [...this.snapshots.values()][this.snapshots.size - 1];
|
||||
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: [...this.snapshots.values()].map((snapshot) => ({ ...snapshot })) };
|
||||
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();
|
||||
}
|
||||
@@ -108,18 +140,20 @@ export class ReplayTimeline {
|
||||
}
|
||||
|
||||
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) {
|
||||
let state = initialState;
|
||||
for (const event of this.events.events) state = apply(state, event);
|
||||
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 { events: this.events.export(), snapshots: this.snapshots.export() };
|
||||
return { schema: REPLAY_SCHEMA, events: this.events.export(), snapshots: this.snapshots.export() };
|
||||
}
|
||||
}
|
||||
|
||||
export { EVENT_SCHEMA, SNAPSHOT_SCHEMA };
|
||||
export { EVENT_SCHEMA, SNAPSHOT_SCHEMA, REPLAY_SCHEMA };
|
||||
|
||||
Reference in New Issue
Block a user