export const SAVE_ATTEMPT_STAGES = ["SERIALIZE", "OPFS_STAGE", "SCENE_COMMIT", "METADATA_COMMIT"] as const; export type SaveAttemptStage = typeof SAVE_ATTEMPT_STAGES[number]; export interface SaveCommitIdentity { revision: number; sha256: string | null; } export interface SaveTransactionState { status: "IDLE" | "RUNNING" | "SUCCEEDED" | "FAILED"; stage: SaveAttemptStage | null; committed: SaveCommitIdentity; candidate: SaveCommitIdentity | null; errorCode: string | null; } export type SaveTransactionResult = | { ok: true; state: SaveTransactionState } | { ok: false; state: SaveTransactionState; errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" | "SAVE_TRANSACTION_COMMIT_MISMATCH" }; const SHA256 = /^[a-f0-9]{64}$/; function validIdentity(identity: SaveCommitIdentity, allowNullHash: boolean): boolean { return Number.isSafeInteger(identity.revision) && identity.revision >= 0 && (allowNullHash ? identity.sha256 === null || SHA256.test(identity.sha256) : typeof identity.sha256 === "string" && SHA256.test(identity.sha256)); } export function createSaveTransactionState(committed: SaveCommitIdentity): SaveTransactionState { if (!validIdentity(committed, true)) throw new Error("SAVE_TRANSACTION_COMMIT_INVALID"); return { status: "IDLE", stage: null, committed, candidate: null, errorCode: null }; } export function beginSaveTransaction(state: SaveTransactionState, targetRevision: number): SaveTransactionResult { if (state.status === "RUNNING" || !Number.isSafeInteger(targetRevision) || targetRevision < state.committed.revision) { return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" }; } return { ok: true, state: { status: "RUNNING", stage: "SERIALIZE", committed: state.committed, candidate: { revision: targetRevision, sha256: null }, errorCode: null, }, }; } export function advanceSaveTransaction(state: SaveTransactionState, stage: SaveAttemptStage, candidateSha256?: string): SaveTransactionResult { if (state.status !== "RUNNING" || !state.stage || !state.candidate) { return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" }; } const currentIndex = SAVE_ATTEMPT_STAGES.indexOf(state.stage); const nextIndex = SAVE_ATTEMPT_STAGES.indexOf(stage); if (nextIndex !== currentIndex + 1 || (stage === "OPFS_STAGE" && (!candidateSha256 || !SHA256.test(candidateSha256)))) { return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" }; } return { ok: true, state: { ...state, stage, candidate: stage === "OPFS_STAGE" ? { ...state.candidate, sha256: candidateSha256! } : state.candidate, }, }; } export function failSaveTransaction(state: SaveTransactionState, errorCode: string): SaveTransactionState { if (state.status !== "RUNNING" || !/^[A-Z][A-Z0-9_]*$/.test(errorCode)) return state; return { ...state, status: "FAILED", errorCode }; } export function commitSaveTransaction(state: SaveTransactionState, persisted: SaveCommitIdentity): SaveTransactionResult { if (state.status !== "RUNNING" || state.stage !== "METADATA_COMMIT" || !state.candidate || !validIdentity(persisted, false)) { return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" }; } if (persisted.revision !== state.candidate.revision || persisted.sha256 !== state.candidate.sha256) { return { ok: false, state, errorCode: "SAVE_TRANSACTION_COMMIT_MISMATCH" }; } return { ok: true, state: { status: "SUCCEEDED", stage: "METADATA_COMMIT", committed: persisted, candidate: persisted, errorCode: null } }; }