import { contentDigest } from "./evolution.js"; import type { PersistentAttachment } from "./types.js"; /** Portable storage shape: local owner/slot/projection identities are supplied * by the consuming workspace's explicit bindings, never baked into this hash. */ export const migrationPortContract = (attachment: PersistentAttachment): unknown => { if (attachment.kind === "state") return {kind: "state", valueType: attachment.valueType, storagePolicy: attachment.storagePolicy, ...(attachment.defaultValue === undefined ? {} : {defaultValue: attachment.defaultValue})}; const endpoint = (value: typeof attachment.endpoints[number]) => ({constraint: value.constraint, cardinality: value.cardinality, ordered: value.ordered, onDelete: value.onDelete ?? "restrict", retainOther: value.retainOther ?? false, ...(value.keyType ? {keyType: value.keyType} : {}), ...(value.publicTraversal ? {publicTraversal: true} : {})}); return {kind: "edge", first: endpoint(attachment.endpoints[0]), second: endpoint(attachment.endpoints[1])}; }; export type MigrationDeclaration = { id: string; scopeId: string; from: string; to: string; implementation: { exportId: string; file: string; digest: string }; predecessors: string[]; ports: { name: string; view: "old" | "new"; access: ("read" | "write" | "create" | "edge")[]; contractDigest: string }[]; preservesOldReaders?: boolean; preservesOldWriters?: boolean; }; export type MigrationCatalog = { schemaVersion: 1; contracts: Record; migrations: MigrationDeclaration[]; }; export const validateMigrationCatalog = (value: unknown, exportIds?: ReadonlySet): MigrationCatalog => { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Migration catalog must be an object"); const catalog = value as MigrationCatalog; if (catalog.schemaVersion !== 1 || !catalog.contracts || Array.isArray(catalog.contracts) || !Array.isArray(catalog.migrations)) throw new Error("Unsupported migration catalog"); for (const [digest, contract] of Object.entries(catalog.contracts)) if (contentDigest(contract) !== digest) throw new Error(`Migration contract digest mismatch: ${digest}`); const ids = new Set(); for (const migration of catalog.migrations) { if (!migration.id || !migration.scopeId || ids.has(migration.id)) throw new Error("Migration IDs must be stable and unique"); ids.add(migration.id); if (!catalog.contracts[migration.from] || !catalog.contracts[migration.to] || migration.from === migration.to) throw new Error(`Migration ${migration.id} requires distinct retained source and target contracts`); if (!migration.implementation?.exportId || !/^sha256:[0-9a-f]{64}$/.test(migration.implementation.digest)) throw new Error(`Migration ${migration.id} requires an exact implementation digest`); if (!migration.implementation.file || migration.implementation.file.startsWith("/") || migration.implementation.file.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) throw new Error("Migration implementation must be a relative package file"); if (exportIds && !exportIds.has(migration.implementation.exportId)) throw new Error(`Migration ${migration.id} refers to an undeclared package export`); if (!Array.isArray(migration.predecessors) || !Array.isArray(migration.ports)) throw new Error(`Migration ${migration.id} requires predecessors and ports`); if (new Set(migration.predecessors).size !== migration.predecessors.length) throw new Error(`Duplicate predecessor in ${migration.id}`); for (const promise of [migration.preservesOldReaders, migration.preservesOldWriters]) if (promise !== undefined && typeof promise !== "boolean") throw new Error("Migration compatibility promises must be booleans"); const ports = new Set(); for (const port of migration.ports) { if (!port.name || ports.has(port.name) || !["old", "new"].includes(port.view) || !Array.isArray(port.access) || !port.access.length || port.access.some((access) => !["read", "write", "create", "edge"].includes(access)) || !catalog.contracts[port.contractDigest]) throw new Error(`Invalid migration port in ${migration.id}`); if (port.view === "old" && port.access.some((access) => access !== "read")) throw new Error("Old migration views are read-only"); ports.add(port.name); } } for (const migration of catalog.migrations) for (const predecessor of migration.predecessors) if (!ids.has(predecessor)) throw new Error(`Missing retained predecessor ${predecessor}`); // Catalogs are retained across releases. Reject impossible histories at // publication/check time, not only when someone tries to select a path. const remaining = new Map(catalog.migrations.map((entry) => [entry.id, new Set(entry.predecessors)])); const ready = [...remaining].filter(([, dependencies]) => dependencies.size === 0).map(([id]) => id); for (let index = 0; index < ready.length; index++) { remaining.delete(ready[index]); for (const [id, dependencies] of remaining) if (dependencies.delete(ready[index]) && dependencies.size === 0) ready.push(id); } if (remaining.size) throw new Error(`Cyclic migration predecessors: ${[...remaining.keys()].join(", ")}`); return catalog; }; export type MigrationSelection = { scopeId: string; from: string; to: string; path: string[]; bindings: Record; }; /** Explicit paths, not shortest-path guesses. Receipts identify code plus local scope mapping. */ export const selectMigrationPath = (catalog: MigrationCatalog, selection: MigrationSelection, previousReceipts: ReadonlyMap = new Map()) => { validateMigrationCatalog(catalog); let current = selection.from; const seen = new Set(); const transitions = []; for (const id of selection.path) { const declaration = catalog.migrations.find((entry) => entry.id === id); if (!declaration || declaration.scopeId !== selection.scopeId || declaration.from !== current || seen.has(id)) throw new Error(`Invalid selected migration transition ${id}`); for (const predecessor of declaration.predecessors) if (!seen.has(predecessor) && !previousReceipts.has(predecessor)) throw new Error(`Unsatisfied predecessor ${predecessor}`); const usedBindings: Record = {}; for (const port of declaration.ports) { if (!selection.bindings[port.name]) throw new Error(`Missing local migration binding ${port.name}`); usedBindings[port.name] = selection.bindings[port.name]; } const digest = contentDigest({ declaration, bindings: usedBindings }); const previous = previousReceipts.get(id); if (previous && previous !== digest) throw new Error(`Migration identity ${id} was previously used with different code or scope`); transitions.push({ declaration, bindings: usedBindings, digest, alreadyApplied: Boolean(previous) }); seen.add(id); current = declaration.to; } if (current !== selection.to) throw new Error("Selected migration path does not cover the target storage contract"); return transitions; };