Files
linuxcncWebWASM/web/workspace/journal.mjs

141 lines
6.7 KiB
JavaScript

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 };