Files
linuxcncWebWASM/web/workspace/migration.mjs

254 lines
14 KiB
JavaScript

import { ReplayTimeline, REPLAY_SCHEMA, canonicalJson, fnv1a64 } from "../worker/replay.mjs";
import { validateCheckpoint } from "./recovery.mjs";
// The package envelope is deliberately separate from the locked runtime schemas.
// Format 2 adds integrity metadata; event, snapshot, checkpoint and journal
// payloads remain byte-for-byte compatible with their existing v1 contracts.
export const PACKAGE_SCHEMA = "workspace-package.v1";
export const ROLLBACK_SCHEMA = "workspace-rollback.v1";
export const OFFLINE_SCHEMA = "offline-cache-package.v1";
export const PACKAGE_VERSIONS = Object.freeze([1, 2]);
function clone(value) {
return value === undefined ? undefined : structuredClone(value);
}
function deterministicHash(value) {
// Keep the envelope usable in a browser Worker without a host crypto module.
return fnv1a64(typeof value === "string" ? value : canonicalJson(value));
}
function requireString(value, name) {
if (typeof value !== "string" || value.length === 0) throw new Error(`${name}_invalid`);
}
function requireVersion(value) {
if (!PACKAGE_VERSIONS.includes(value)) throw new Error("package_version_unsupported");
}
function requireSequence(value, name) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name}_invalid`);
}
function packageUnsigned(value) {
const { package_hash: ignored, ...unsigned } = value;
return unsigned;
}
function contentUnsigned(value) {
return {
context_id: value.context_id,
committed_seq: value.committed_seq,
checkpoint: value.checkpoint ?? null,
journal: value.journal ?? [],
timeline: value.timeline,
};
}
function withHashes(value) {
const result = clone(value);
result.content_hash = deterministicHash(contentUnsigned(result));
result.package_hash = deterministicHash(packageUnsigned(result));
return result;
}
function validateJournal(journal) {
if (!Array.isArray(journal)) throw new Error("package_journal_invalid");
let previous = 0;
for (const record of journal) {
if (!record || typeof record !== "object" || Array.isArray(record)) throw new Error("package_journal_invalid");
if (record.commit_seq !== undefined) {
requireSequence(record.commit_seq, "journal_commit_seq");
if (record.commit_seq < previous) throw new Error("package_journal_order_invalid");
previous = record.commit_seq;
}
}
}
function normalizedJournal(journal) {
if (!Array.isArray(journal) || journal.some((record) => !record || typeof record !== "object" || Array.isArray(record))) throw new Error("package_journal_invalid");
const normalized = clone(journal).sort((left, right) => (left.commit_seq ?? 0) - (right.commit_seq ?? 0) || String(left.kind ?? "").localeCompare(String(right.kind ?? "")) || String(left.operation_id ?? "").localeCompare(String(right.operation_id ?? "")));
validateJournal(normalized);
return normalized;
}
function validateTimeline(timeline) {
if (!timeline || timeline.schema !== REPLAY_SCHEMA) throw new Error("package_timeline_schema");
return new ReplayTimeline(timeline);
}
function validatePackageShape(value, { contextId, expectedVersion } = {}) {
if (!value || value.schema !== PACKAGE_SCHEMA) throw new Error("package_schema_unknown");
requireVersion(value.format_version);
if (expectedVersion !== undefined && value.format_version !== expectedVersion) throw new Error("package_version_mismatch");
requireString(value.context_id, "package_context");
if (contextId !== undefined && value.context_id !== contextId) throw new Error("package_context_mismatch");
requireSequence(value.committed_seq, "package_committed_seq");
validateTimeline(value.timeline);
validateJournal(value.journal ?? []);
const committedJournalSequences = (value.journal ?? []).filter((record) => record.kind === "commit").map((record) => record.commit_seq);
if (committedJournalSequences.length > 0 && !committedJournalSequences.includes(value.committed_seq)) throw new Error("package_committed_seq_unverified");
if (value.checkpoint !== null && value.checkpoint !== undefined) {
validateCheckpoint(value.checkpoint, { contextId: value.context_id });
if (value.checkpoint.event_cursor > value.timeline.events.cursor) throw new Error("package_checkpoint_ahead");
}
if (typeof value.content_hash !== "string" || value.content_hash !== deterministicHash(contentUnsigned(value))) throw new Error("package_content_hash_mismatch");
if (typeof value.package_hash !== "string" || value.package_hash !== deterministicHash(packageUnsigned(value))) throw new Error("package_hash_mismatch");
if (value.format_version === 2) {
if (!value.integrity || value.integrity.timeline_hash !== deterministicHash(value.timeline) || value.integrity.journal_hash !== deterministicHash(value.journal ?? []) || value.integrity.checkpoint_hash !== (value.checkpoint ? deterministicHash(value.checkpoint) : null)) throw new Error("package_integrity_metadata_mismatch");
if (!value.migration || value.migration.from_version !== 1 || value.migration.to_version !== 2 || value.migration.lossless !== true || typeof value.migration.source_package_hash !== "string") throw new Error("package_migration_metadata_invalid");
}
return clone(value);
}
export function createWorkspacePackage({ contextId, timeline, checkpoint = null, journal = [], committedSeq = undefined } = {}) {
requireString(contextId, "package_context");
const normalizedTimeline = timeline instanceof ReplayTimeline ? timeline.export() : clone(timeline);
const imported = validateTimeline(normalizedTimeline);
if (checkpoint !== null) validateCheckpoint(checkpoint, { contextId });
const normalized = normalizedJournal(journal);
const committed_seq = committedSeq === undefined ? imported.events.cursor : committedSeq;
requireSequence(committed_seq, "package_committed_seq");
if (committed_seq > imported.events.cursor) throw new Error("package_committed_seq_ahead");
return withHashes({
schema: PACKAGE_SCHEMA,
format_version: 1,
context_id: contextId,
committed_seq,
checkpoint: clone(checkpoint),
timeline: normalizedTimeline,
journal: normalized,
});
}
export function verifyWorkspacePackage(value, options = {}) {
return validatePackageShape(value, options);
}
export function migrateWorkspacePackage(value, targetVersion) {
const source = validatePackageShape(value);
requireVersion(targetVersion);
if (source.format_version === targetVersion) return clone(source);
if (source.format_version === 1 && targetVersion === 2) {
return withHashes({
...source,
format_version: 2,
migration: {
from_version: 1,
to_version: 2,
source_package_hash: source.package_hash,
lossless: true,
},
integrity: {
timeline_hash: deterministicHash(source.timeline),
journal_hash: deterministicHash(source.journal),
checkpoint_hash: source.checkpoint ? deterministicHash(source.checkpoint) : null,
},
});
}
if (source.format_version === 2 && targetVersion === 1) {
if (!source.migration || source.migration.from_version !== 1 || source.migration.to_version !== 2 || source.migration.lossless !== true) throw new Error("package_downgrade_not_proven");
const downgraded = withHashes({
schema: PACKAGE_SCHEMA,
format_version: 1,
context_id: source.context_id,
committed_seq: source.committed_seq,
checkpoint: clone(source.checkpoint ?? null),
timeline: clone(source.timeline),
journal: clone(source.journal ?? []),
});
if (source.migration.source_package_hash !== downgraded.package_hash) throw new Error("package_downgrade_source_mismatch");
return downgraded;
}
throw new Error("package_migration_unsupported");
}
export function createRollbackPackage({ current, committed } = {}) {
const currentPackage = validatePackageShape(current);
const committedPackage = validatePackageShape(committed, { contextId: currentPackage.context_id });
if (committedPackage.committed_seq > currentPackage.committed_seq) throw new Error("rollback_target_not_committed");
const rollback = {
schema: ROLLBACK_SCHEMA,
format_version: 1,
context_id: currentPackage.context_id,
current_package_hash: currentPackage.package_hash,
target_package_hash: committedPackage.package_hash,
nearest_committed_seq: committedPackage.committed_seq,
target: committedPackage,
};
return { ...rollback, rollback_hash: deterministicHash(rollback) };
}
export function applyRollbackPackage(rollback, { currentPackageHash, failureInjection = null } = {}) {
if (!rollback || rollback.schema !== ROLLBACK_SCHEMA || rollback.format_version !== 1) throw new Error("rollback_schema_unknown");
const { rollback_hash: ignored, ...unsigned } = rollback;
if (typeof rollback.rollback_hash !== "string" || rollback.rollback_hash !== deterministicHash(unsigned)) throw new Error("rollback_hash_mismatch");
if (typeof currentPackageHash !== "string" || currentPackageHash !== rollback.current_package_hash) throw new Error("rollback_current_mismatch");
if (failureInjection === "before_commit") throw new Error("rollback_injected_failure");
const target = validatePackageShape(rollback.target, { contextId: rollback.context_id });
if (target.package_hash !== rollback.target_package_hash) throw new Error("rollback_target_hash_mismatch");
return clone(target);
}
function normalizeResources(resources, { maxResources = 256, maxBytes = 64 * 1024 * 1024 } = {}) {
if (!Array.isArray(resources) || resources.length === 0) throw new Error("offline_resources_empty");
if (resources.length > maxResources) throw new Error("offline_resource_capacity");
const seen = new Set();
let totalBytes = 0;
const normalized = resources.map((resource) => {
requireString(resource?.path, "offline_resource_path");
if (resource.path.startsWith("/") || resource.path.includes("\\") || resource.path.split("/").includes("..")) throw new Error("offline_resource_path");
if (seen.has(resource.path)) throw new Error("offline_resource_duplicate");
seen.add(resource.path);
if (typeof resource.data !== "string") throw new Error("offline_resource_data_invalid");
const bytes = new TextEncoder().encode(resource.data).byteLength;
totalBytes += bytes;
if (totalBytes > maxBytes) throw new Error("offline_resource_quota");
return { path: resource.path, bytes, data_hash: deterministicHash(resource.data) };
}).sort((left, right) => left.path.localeCompare(right.path));
return normalized;
}
export function createOfflinePackage({ workspacePackage, resources, maxResources = 256, maxBytes = 64 * 1024 * 1024 } = {}) {
const workspace = validatePackageShape(workspacePackage);
const normalized = normalizeResources(resources, { maxResources, maxBytes });
const bundle = {
schema: OFFLINE_SCHEMA,
format_version: 1,
context_id: workspace.context_id,
workspace_package_hash: workspace.package_hash,
resources: normalized,
required_paths: normalized.map((resource) => resource.path),
};
return { ...bundle, bundle_hash: deterministicHash(bundle) };
}
export function verifyOfflinePackage(bundle, { contextId, workspacePackage = null, available = [], online = false } = {}) {
if (!bundle || bundle.schema !== OFFLINE_SCHEMA || bundle.format_version !== 1) throw new Error("offline_schema_unknown");
requireString(bundle.context_id, "offline_context");
if (contextId !== undefined && bundle.context_id !== contextId) throw new Error("offline_context_mismatch");
if (workspacePackage !== null && verifyWorkspacePackage(workspacePackage, { contextId: bundle.context_id }).package_hash !== bundle.workspace_package_hash) throw new Error("offline_workspace_hash_mismatch");
if (!Array.isArray(bundle.resources) || !Array.isArray(bundle.required_paths) || bundle.resources.length > 256 || canonicalJson(bundle.required_paths) !== canonicalJson(bundle.resources.map((resource) => resource.path))) throw new Error("offline_manifest_invalid");
const resourcePaths = new Set();
let resourceBytes = 0;
for (const resource of bundle.resources) {
requireString(resource?.path, "offline_resource_path");
if (resource.path.startsWith("/") || resource.path.includes("\\") || resource.path.split("/").includes("..") || resourcePaths.has(resource.path) || !Number.isSafeInteger(resource.bytes) || resource.bytes < 0 || typeof resource.data_hash !== "string") throw new Error("offline_manifest_invalid");
resourcePaths.add(resource.path); resourceBytes += resource.bytes;
if (resourceBytes > 64 * 1024 * 1024) throw new Error("offline_manifest_quota");
}
if (typeof bundle.bundle_hash !== "string" || bundle.bundle_hash !== deterministicHash({ schema: bundle.schema, format_version: bundle.format_version, context_id: bundle.context_id, workspace_package_hash: bundle.workspace_package_hash, resources: bundle.resources, required_paths: bundle.required_paths })) throw new Error("offline_bundle_hash_mismatch");
if (!Array.isArray(available)) throw new Error("offline_available_invalid");
const availableMap = new Map(available.map((entry) => {
if (typeof entry === "string") return [entry, null];
if (entry && typeof entry.path === "string") return [entry.path, entry.data_hash ?? (typeof entry.data === "string" ? deterministicHash(entry.data) : null)];
throw new Error("offline_available_invalid");
}));
const missing = bundle.resources.filter((resource) => !availableMap.has(resource.path)).map((resource) => resource.path);
const corrupted = bundle.resources.filter((resource) => availableMap.has(resource.path) && availableMap.get(resource.path) !== null && availableMap.get(resource.path) !== resource.data_hash).map((resource) => resource.path);
if ((missing.length > 0 || corrupted.length > 0) && !online) throw new Error(`offline_resource_invalid:${[...missing, ...corrupted].join(",")}`);
return { status: missing.length === 0 && corrupted.length === 0 ? "ready" : "network_required", missing, corrupted, offline: !online };
}
export { canonicalJson, deterministicHash, fnv1a64 };