157 lines
7.3 KiB
TypeScript
157 lines
7.3 KiB
TypeScript
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<string, unknown>;
|
|
migrations: MigrationDeclaration[];
|
|
};
|
|
export const validateMigrationCatalog = (value: unknown, exportIds?: ReadonlySet<string>): 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<string>();
|
|
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<string>();
|
|
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<string, string>;
|
|
};
|
|
/** Explicit paths, not shortest-path guesses. Receipts identify code plus local scope mapping. */
|
|
export const selectMigrationPath = (
|
|
catalog: MigrationCatalog,
|
|
selection: MigrationSelection,
|
|
previousReceipts: ReadonlyMap<string, string> = new Map(),
|
|
) => {
|
|
validateMigrationCatalog(catalog);
|
|
let current = selection.from;
|
|
const seen = new Set<string>();
|
|
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<string, string> = {};
|
|
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;
|
|
};
|