254 lines
14 KiB
JavaScript
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 };
|