import type { HsonNode } from "../../core/types.js"; import type { DocumentLiveMapMode, LiveMapGraphCommit, LiveMapGraphOp, LiveMapGraphReplaceRootOp, LiveMapCommitObserverApi, } from "../../types/livemap.types.js"; import { clone_live_root } from "./livemap.editor.js"; import { LiveMapDocumentMutationError, LiveMapDocumentStagingError, LiveMapReplayInputError, LiveMapRevError, } from "./livemap.error.js"; import { canonical_graph_equal, prepare_document_install, type PreparedDocumentInstall, } from "./livemap.document.install.js"; import { prepare_document_graph_operation, } from "./livemap.document.mutation.js"; import { livemap_document_identity_quids, replace_livemap_document_identity_overlay_effects, type LiveMapDocumentIdentityEffect, } from "./livemap.document.identity.js"; import { preflight_livemap_document_identity_replay } from "./livemap.document.registration.js"; import type { LiveMapIdentityEpochController, LiveMapIssuedQuidLedger } from "./livemap.identity-epoch.js"; import { stage_livemap_identity_epoch } from "./livemap.identity-epoch.js"; import { LiveMapIdentityEpochError } from "./livemap.identity-epoch.js"; export type PreparedDocumentReplay = Readonly<{ root: HsonNode; overlay: PreparedDocumentInstall["overlay"]; commit: LiveMapGraphCommit; identityEffects: readonly LiveMapDocumentIdentityEffect[]; issuedLedger: LiveMapIssuedQuidLedger; }>; export type LiveMapDocumentReplayController = Readonly<{ mode: DocumentLiveMapMode; rev: () => number; root: () => HsonNode; overlay: () => PreparedDocumentInstall["overlay"]; identityEpoch: LiveMapIdentityEpochController; applyReplay: (candidate: PreparedDocumentReplay) => LiveMapGraphCommit; commits: LiveMapCommitObserverApi; }>; /** Validate all graph operations against a detached candidate before one swap. */ export function replay_livemap_document_commit( controller: LiveMapDocumentReplayController, input: LiveMapGraphCommit, ): LiveMapGraphCommit { const envelope = must_graph_commit_envelope(input); if (envelope.prevRev !== controller.rev()) { throw new LiveMapRevError(envelope.prevRev, controller.rev()); } let root = clone_live_root(controller.root()); let overlay: PreparedDocumentInstall["overlay"] = controller.overlay(); let issuedLedger = controller.identityEpoch.issued(); const operations: LiveMapGraphOp[] = []; const identityEffects: LiveMapDocumentIdentityEffect[] = []; for (const [index, rawOperation] of envelope.ops.entries()) { if (is_replace_root_operation(rawOperation)) { if (envelope.ops.length !== 1) { throw new LiveMapReplayInputError( "replace-root must be the only graph operation", index, "ROOT_OPERATION_COMPOSITION", ); } const prepared = prepare_document_install({ kind: "hson-document", mode: rawOperation.mode, rev: envelope.rev, root: rawOperation.root, }, controller.mode); root = prepared.root; identityEffects.push(...replace_livemap_document_identity_overlay_effects(overlay, prepared.overlay)); overlay = prepared.overlay; operations.push(Object.freeze({ domain: "graph", op: "replace-root", mode: prepared.mode, root: clone_live_root(prepared.root), })); continue; } let prepared; try { prepared = prepare_document_graph_operation(root, controller.mode, rawOperation, overlay); } catch (cause) { if (cause instanceof LiveMapDocumentMutationError) { throw new LiveMapDocumentStagingError(index, cause); } throw cause; } if (canonical_graph_equal(root, prepared.root)) { throw new LiveMapReplayInputError( "changed graph commit contains an unchanged operation", index, "UNCHANGED_STAGED_OPERATION", ); } root = prepared.root; try { issuedLedger = stage_livemap_identity_epoch( issuedLedger, livemap_document_identity_quids(overlay), livemap_document_identity_quids(prepared.overlay), ); } catch (cause) { if (cause instanceof LiveMapIdentityEpochError && cause.code === "SAME_EPOCH_QUID_REUSE") { throw new LiveMapDocumentStagingError(index, new LiveMapDocumentMutationError( "DOCUMENT_IDENTITY_REUSE", prepared.operation.op, "replay cannot reuse a retired QUID in the same owner epoch", { cause }, )); } throw cause; } overlay = prepared.overlay; operations.push(prepared.operation); identityEffects.push(...prepared.identityEffects); } if (operations.length === 0) { throw new LiveMapReplayInputError("graph commit contains no operations", undefined, "EMPTY_GRAPH_COMMIT"); } const commit: LiveMapGraphCommit = Object.freeze({ changed: true, prevRev: envelope.prevRev, rev: envelope.rev, ops: Object.freeze(operations), }); const reservation = preflight_livemap_document_identity_replay(controller, commit); try { return controller.applyReplay({ root, overlay, commit, identityEffects: Object.freeze(identityEffects), issuedLedger, }); } finally { reservation?.release(); } } function must_graph_commit_envelope(input: unknown): LiveMapGraphCommit { if (!is_plain_record(input)) throw new LiveMapReplayInputError("graph commit is not an object"); const keys = Object.keys(input); if (keys.length !== 4 || !keys.every((key) => ["changed", "prevRev", "rev", "ops"].includes(key))) { throw new LiveMapReplayInputError("graph commit contains missing or unknown fields"); } if (input.changed !== true) throw new LiveMapReplayInputError("graph replay requires a changed commit"); if (!is_revision(input.prevRev) || !is_revision(input.rev) || input.rev !== input.prevRev + 1) { throw new LiveMapReplayInputError("graph commit revisions must be consecutive non-negative integers"); } if (!Array.isArray(input.ops) || input.ops.length === 0) { throw new LiveMapReplayInputError("graph commit ops must be a non-empty array"); } return Object.freeze({ changed: true, prevRev: input.prevRev, rev: input.rev, ops: Object.freeze([...input.ops]), }); } function is_replace_root_operation(input: unknown): input is LiveMapGraphReplaceRootOp { if (!is_plain_record(input) || input.domain !== "graph" || input.op !== "replace-root") return false; const keys = Object.keys(input); if (keys.length !== 4 || !keys.every((key) => ["domain", "op", "mode", "root"].includes(key))) { throw new LiveMapReplayInputError("replace-root contains missing or unknown fields"); } return true; } function is_revision(input: unknown): input is number { return typeof input === "number" && Number.isInteger(input) && input >= 0; } function is_plain_record(input: unknown): input is Readonly> { if (typeof input !== "object" || input === null || Array.isArray(input)) return false; const prototype = Object.getPrototypeOf(input); return prototype === Object.prototype || prototype === null; }