Files
linuxcncWebWASM/web/worker/operation-ledger.mjs

107 lines
4.2 KiB
JavaScript

import { fnv1a64 } from "./replay.mjs";
export const LEDGER_SCHEMA = "operation-ledger.v1";
export const LEDGER_PHASES = Object.freeze(["prepared", "executing", "verified", "committed", "aborted", "failed"]);
function clone(value) { return structuredClone(value); }
function text(value) { return typeof value === "string" && value.length > 0 ? value : null; }
export class OperationLedger {
constructor(entries = []) {
this.entries = new Map();
for (const entry of entries) this.importEntry(entry);
}
intent(input) {
return fnv1a64({ kind: input.kind, payload: input.payload ?? {}, context_id: input.context_id ?? null, session_id: input.session_id ?? null });
}
prepare(input) {
if (!text(input?.operation_id)) return { status: "untracked", replay: false, entry: null };
const intentHash = this.intent(input);
const existing = this.entries.get(input.operation_id);
if (existing) {
if (existing.kind !== input.kind) throw new Error("ledger_operation_conflict");
return { status: existing.status, replay: existing.phase === "committed" || existing.status === "verified", entry: clone(existing) };
}
const entry = {
operation_id: input.operation_id,
kind: input.kind,
seq: input.seq,
intent_hash: intentHash,
status: "prepared",
phase: "prepared",
side_effect_count: 0,
result_hash: null,
commit_seq: null,
response: null,
};
this.entries.set(entry.operation_id, entry);
return { status: entry.status, replay: false, entry: clone(entry) };
}
execute(operationId) {
const entry = this.require(operationId);
if (entry.phase === "committed" || entry.phase === "aborted") return clone(entry);
if (entry.phase !== "prepared") throw new Error("ledger_execute_phase");
entry.phase = "executing";
entry.status = "executing";
return clone(entry);
}
verify(operationId, response) {
const entry = this.require(operationId);
if (entry.phase === "committed") return clone(entry);
if (entry.phase !== "executing" && entry.phase !== "prepared") throw new Error("ledger_verify_phase");
entry.phase = "verified";
entry.status = response?.payload?.status === "error" || response?.kind === "ERROR" ? "failed" : "verified";
entry.result_hash = fnv1a64(response?.payload ?? {});
entry.response = clone(response);
return clone(entry);
}
commit(operationId, seq) {
const entry = this.require(operationId);
if (entry.phase === "committed") return clone(entry);
if (entry.phase !== "verified") throw new Error("ledger_commit_phase");
if (entry.status === "failed") { entry.phase = "failed"; return clone(entry); }
entry.phase = "committed";
entry.status = "verified";
entry.commit_seq = seq;
entry.side_effect_count = 1;
return clone(entry);
}
abort(operationId, reason = "abort_requested") {
const entry = this.require(operationId);
if (entry.phase === "committed") return { status: "too_late", entry: clone(entry) };
if (entry.phase === "aborted") return { status: "aborted", entry: clone(entry) };
entry.phase = "aborted";
entry.status = "aborted";
entry.abort_reason = reason;
return { status: "aborted", entry: clone(entry) };
}
reconcile(operationId) {
if (!text(operationId)) throw new Error("ledger_operation_required");
const entry = this.entries.get(operationId);
return entry ? { status: entry.phase === "committed" ? "committed" : entry.phase, entry: clone(entry) } : { status: "missing", entry: null };
}
require(operationId) {
const entry = this.entries.get(operationId);
if (!entry) throw new Error("ledger_operation_missing");
return entry;
}
importEntry(entry) {
if (!entry || typeof entry !== "object" || !text(entry.operation_id) || !text(entry.kind) || !LEDGER_PHASES.includes(entry.phase) || !["prepared", "executing", "verified", "aborted", "failed"].includes(entry.status)) throw new Error("ledger_entry_invalid");
if (this.entries.has(entry.operation_id)) throw new Error("ledger_operation_duplicate");
this.entries.set(entry.operation_id, clone(entry));
}
export() {
return { schema: LEDGER_SCHEMA, entries: [...this.entries.values()].sort((a, b) => a.operation_id.localeCompare(b.operation_id)).map(clone) };
}
}