import { canonicalJson, fnv1a64 } from "../worker/replay.mjs"; export const JOURNAL_SCHEMA = "workspace-journal.v1"; export class MemoryStorage { constructor(initial = {}) { this.records = new Map(Object.entries(initial)); } get(key) { return this.records.get(key); } set(key, value) { this.records.set(key, value); } entries() { return [...this.records.entries()].sort(([a], [b]) => a.localeCompare(b)); } } function pathOf(value) { if (typeof value !== "string" || value.length === 0 || value.startsWith("/") || value.includes("\\")) throw new Error("workspace_path_invalid"); const parts = value.split("/").filter(Boolean); if (parts.some((part) => part === "." || part === "..")) throw new Error("workspace_path_invalid"); return parts.join("/"); } function dataHash(data) { if (typeof data !== "string") throw new Error("workspace_data_invalid"); return fnv1a64(data); } export class WorkspaceJournal { constructor(storage = new MemoryStorage(), { projectId = "default", schemaVersion = 1 } = {}) { this.storage = storage; this.projectId = projectId; this.schemaVersion = schemaVersion; } records() { return this.storage.entries().map(([, value]) => typeof value === "string" ? JSON.parse(value) : structuredClone(value)); } nextSequence() { return this.records().reduce((max, record) => Math.max(max, record.commit_seq ?? 0), 0) + 1; } prepare(operationId, changes) { if (typeof operationId !== "string" || operationId.length === 0) throw new Error("operation_id_invalid"); if (!Array.isArray(changes) || changes.length === 0) throw new Error("workspace_changes_empty"); const existing = this.records().find((record) => record.operation_id === operationId && record.kind === "commit"); if (existing) throw new Error("operation_already_committed"); const normalized = changes.map((change) => { const path = pathOf(change.path); const data = change.data; return { path, data, data_hash: dataHash(data) }; }).sort((left, right) => left.path.localeCompare(right.path)); const commitSeq = this.nextSequence(); const record = { schema: JOURNAL_SCHEMA, schema_version: this.schemaVersion, project_id: this.projectId, kind: "prepare", commit_seq: commitSeq, operation_id: operationId, changes: normalized, content_hash: fnv1a64({ commit_seq: commitSeq, operation_id: operationId, changes: normalized }), }; this.storage.set(`journal/${String(commitSeq).padStart(12, "0")}.prepare`, canonicalJson(record)); return record; } commit(operationId) { const records = this.records(); const prepare = records.find((record) => record.kind === "prepare" && record.operation_id === operationId); if (!prepare) throw new Error("prepare_missing"); if (records.some((record) => record.kind === "commit" && record.operation_id === operationId)) throw new Error("operation_already_committed"); const expectedHash = fnv1a64({ commit_seq: prepare.commit_seq, operation_id: prepare.operation_id, changes: prepare.changes }); if (prepare.content_hash !== expectedHash) throw new Error("prepare_hash_mismatch"); const record = { schema: JOURNAL_SCHEMA, schema_version: this.schemaVersion, project_id: this.projectId, kind: "commit", commit_seq: prepare.commit_seq, operation_id: operationId, prepare_hash: prepare.content_hash, }; this.storage.set(`journal/${String(prepare.commit_seq).padStart(12, "0")}.commit`, canonicalJson(record)); return record; } abort(operationId) { const prepare = this.records().find((record) => record.kind === "prepare" && record.operation_id === operationId); if (!prepare) throw new Error("prepare_missing"); const record = { schema: JOURNAL_SCHEMA, schema_version: this.schemaVersion, project_id: this.projectId, kind: "abort", commit_seq: prepare.commit_seq, operation_id: operationId }; this.storage.set(`journal/${String(prepare.commit_seq).padStart(12, "0")}.abort`, canonicalJson(record)); return record; } recover() { const records = this.records(); const incompatible = records.filter((record) => record.schema !== JOURNAL_SCHEMA || record.schema_version !== this.schemaVersion || record.project_id !== this.projectId); if (incompatible.length > 0) { return { schema: "workspace-recovery.v1", project_id: this.projectId, status: "blocked", files: {}, applied: [], half_commits: [], blocked: incompatible.map((record) => ({ commit_seq: record.commit_seq ?? null, reason: "schema_or_project_mismatch" })), state_hash: fnv1a64({}), }; } const prepares = new Map(records.filter((record) => record.kind === "prepare").map((record) => [record.commit_seq, record])); const commits = records.filter((record) => record.kind === "commit").sort((left, right) => left.commit_seq - right.commit_seq); const aborts = new Set(records.filter((record) => record.kind === "abort").map((record) => record.commit_seq)); const files = new Map(); const applied = []; const blocked = []; for (const commit of commits) { const prepare = prepares.get(commit.commit_seq); if (!prepare || aborts.has(commit.commit_seq)) { blocked.push({ commit_seq: commit.commit_seq, reason: "prepare_missing_or_aborted" }); continue; } const expectedPrepareHash = fnv1a64({ commit_seq: prepare.commit_seq, operation_id: prepare.operation_id, changes: prepare.changes }); if (prepare.content_hash !== expectedPrepareHash || commit.prepare_hash !== expectedPrepareHash) { blocked.push({ commit_seq: commit.commit_seq, reason: "hash_mismatch" }); continue; } for (const change of prepare.changes) { if (dataHash(change.data) !== change.data_hash) { blocked.push({ commit_seq: commit.commit_seq, reason: "data_hash_mismatch", path: change.path }); continue; } files.set(change.path, change.data); } applied.push({ commit_seq: commit.commit_seq, operation_id: commit.operation_id, content_hash: expectedPrepareHash }); } return { schema: "workspace-recovery.v1", project_id: this.projectId, status: blocked.length === 0 ? "recovered" : "blocked", files: Object.fromEntries([...files.entries()].sort(([a], [b]) => a.localeCompare(b))), applied, half_commits: [...prepares.values()].filter((prepare) => !commits.some((commit) => commit.commit_seq === prepare.commit_seq) && !aborts.has(prepare.commit_seq)).map((prepare) => prepare.commit_seq), blocked, state_hash: fnv1a64(Object.fromEntries([...files.entries()].sort(([a], [b]) => a.localeCompare(b)))), }; } } export { canonicalJson, dataHash, pathOf };