107 lines
4.2 KiB
JavaScript
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) };
|
|
}
|
|
}
|