Format authored monorepo code with pinned language formatters
This commit is contained in:
@@ -3,50 +3,75 @@ import type { Binding, Conformance, DependencyBinding, PersistentAttachment, Wor
|
||||
import { validateWorkspaceRevision } from "./validation.js";
|
||||
|
||||
/** Content hashing is independent of JSON object insertion order, not array order. */
|
||||
const compareText = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
|
||||
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
export const canonicalJson = (value: unknown): string => {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
||||
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("Expected a plain JSON object");
|
||||
return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => compareText(a, b))
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
|
||||
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
||||
throw new Error("Expected a plain JSON object");
|
||||
return `{${Object.entries(value)
|
||||
.filter(([, entry]) => entry !== undefined)
|
||||
.sort(([a], [b]) => compareText(a, b))
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
throw new Error(`Cannot hash non-JSON value: ${typeof value}`);
|
||||
};
|
||||
export const contentDigest = (value: unknown) => `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
||||
export const contentDigest = (value: unknown) =>
|
||||
`sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
||||
const semantic = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(semantic);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
|
||||
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
|
||||
// These are authored data/maps, not schema nodes. A user field literally
|
||||
// named displayName or documentation is semantic and must stay in the hash.
|
||||
.map(([key, entry]) => [key, key === "defaultValue" || key === "fields" ? entry : semantic(entry)]));
|
||||
if (value && typeof value === "object")
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
|
||||
// These are authored data/maps, not schema nodes. A user field literally
|
||||
// named displayName or documentation is semantic and must stay in the hash.
|
||||
.map(([key, entry]) => [key, key === "defaultValue" || key === "fields" ? entry : semantic(entry)]),
|
||||
);
|
||||
return value;
|
||||
};
|
||||
const sorted = <T>(entries: readonly T[], key: (entry: T) => string) => [...entries].sort((a, b) => compareText(key(a), key(b)));
|
||||
export const conformanceIdentity = (entry: Conformance): string => entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`;
|
||||
const sorted = <T>(entries: readonly T[], key: (entry: T) => string) =>
|
||||
[...entries].sort((a, b) => compareText(key(a), key(b)));
|
||||
export const conformanceIdentity = (entry: Conformance): string =>
|
||||
entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`;
|
||||
|
||||
export type StorageContract = { id: string; ownerId: string; kind: "state" | "edge"; digest: string; definition: unknown };
|
||||
export type StorageContract = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
kind: "state" | "edge";
|
||||
digest: string;
|
||||
definition: unknown;
|
||||
};
|
||||
/** Automatic evolution preserves values; it never interprets migration code or
|
||||
* guesses that a new nominal message descriptor means the same representation. */
|
||||
export const storageChangeRequiresMigration = (previous: StorageContract | undefined, next: StorageContract | undefined,
|
||||
oldAtomIds: ReadonlySet<string>): boolean => {
|
||||
export const storageChangeRequiresMigration = (
|
||||
previous: StorageContract | undefined,
|
||||
next: StorageContract | undefined,
|
||||
oldAtomIds: ReadonlySet<string>,
|
||||
): boolean => {
|
||||
if (!next) return true;
|
||||
const after = next.definition as PersistentAttachment;
|
||||
if (!previous) {
|
||||
if (after.kind === "state") return oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional";
|
||||
return after.endpoints.some(endpoint => endpoint.cardinality === "exactly-one" &&
|
||||
(endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId)));
|
||||
if (after.kind === "state")
|
||||
return (
|
||||
oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional"
|
||||
);
|
||||
return after.endpoints.some(
|
||||
(endpoint) =>
|
||||
endpoint.cardinality === "exactly-one" &&
|
||||
(endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId)),
|
||||
);
|
||||
}
|
||||
if (previous.ownerId !== next.ownerId || previous.kind !== next.kind) return true;
|
||||
const before = previous.definition as PersistentAttachment;
|
||||
if (before.kind === "state" && after.kind === "state") {
|
||||
// Capture materializes old defaults, so changing a default affects only
|
||||
// newly constructed objects, not existing sparse state.
|
||||
const {defaultValue: _beforeDefault, ...beforeStorage} = before;
|
||||
const {defaultValue: _afterDefault, ...afterStorage} = after;
|
||||
const { defaultValue: _beforeDefault, ...beforeStorage } = before;
|
||||
const { defaultValue: _afterDefault, ...afterStorage } = after;
|
||||
return canonicalJson(beforeStorage) !== canonicalJson(afterStorage);
|
||||
}
|
||||
return canonicalJson(before) !== canonicalJson(after);
|
||||
@@ -55,15 +80,29 @@ export const storageContracts = (workspace: WorkspaceRevision): StorageContract[
|
||||
const result: StorageContract[] = [];
|
||||
const add = (attachment: PersistentAttachment, ownerId: string) => {
|
||||
const definition = semantic(attachment);
|
||||
result.push({ id: attachment.id, ownerId, kind: attachment.kind, definition, digest: contentDigest({ ownerId, definition }) });
|
||||
result.push({
|
||||
id: attachment.id,
|
||||
ownerId,
|
||||
kind: attachment.kind,
|
||||
definition,
|
||||
digest: contentDigest({ ownerId, definition }),
|
||||
});
|
||||
};
|
||||
for (const attachment of workspace.sharedAttachments) add(attachment, "legacy:workspace");
|
||||
for (const conformance of workspace.conformances) for (const attachment of conformance.privateAttachments) add(attachment, conformanceIdentity(conformance));
|
||||
for (const conformance of workspace.conformances)
|
||||
for (const attachment of conformance.privateAttachments) add(attachment, conformanceIdentity(conformance));
|
||||
return sorted(result, (entry) => entry.id);
|
||||
};
|
||||
|
||||
type GraphNode = { value: unknown; dependencies: Set<string>; reviewProviders: Set<string> };
|
||||
export type RuntimeContract = { groupId: string; packageId: string; packageRevisionId: string; digest: string; reviewProviders: string[]; dependencies: Array<{ id: string; digest: string }> };
|
||||
export type RuntimeContract = {
|
||||
groupId: string;
|
||||
packageId: string;
|
||||
packageRevisionId: string;
|
||||
digest: string;
|
||||
reviewProviders: string[];
|
||||
dependencies: Array<{ id: string; digest: string }>;
|
||||
};
|
||||
|
||||
/** Build only outbound execution dependencies. Incoming callers never retain or invalidate a provider. */
|
||||
export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[] => {
|
||||
@@ -75,13 +114,23 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
||||
};
|
||||
const conformanceKey = (atom: string, iface: string) => `conformance:${atom}:${iface}`;
|
||||
for (const storage of storageContracts(workspace)) node(`attachment:${storage.id}`, storage);
|
||||
for (const iface of workspace.interfaceImports) node(`interface:${iface.revisionId}`, {
|
||||
...iface, members: sorted(iface.members, (entry) => entry.id).map((entry) => ({ ...entry, operations: sorted(entry.operations, (operation) => operation.id) })),
|
||||
});
|
||||
for (const pkg of workspace.packageImports) node(`package:${pkg.revisionId}`, {
|
||||
...pkg, semanticMajor: pkg.semanticMajor ?? 1,
|
||||
exports: sorted(pkg.exports, (entry) => entry.id).map((entry) => ({ ...entry, dependencyPorts: sorted(entry.dependencyPorts, (port) => port.id) })),
|
||||
});
|
||||
for (const iface of workspace.interfaceImports)
|
||||
node(`interface:${iface.revisionId}`, {
|
||||
...iface,
|
||||
members: sorted(iface.members, (entry) => entry.id).map((entry) => ({
|
||||
...entry,
|
||||
operations: sorted(entry.operations, (operation) => operation.id),
|
||||
})),
|
||||
});
|
||||
for (const pkg of workspace.packageImports)
|
||||
node(`package:${pkg.revisionId}`, {
|
||||
...pkg,
|
||||
semanticMajor: pkg.semanticMajor ?? 1,
|
||||
exports: sorted(pkg.exports, (entry) => entry.id).map((entry) => ({
|
||||
...entry,
|
||||
dependencyPorts: sorted(entry.dependencyPorts, (port) => port.id),
|
||||
})),
|
||||
});
|
||||
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
|
||||
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
|
||||
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
|
||||
@@ -96,7 +145,10 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
||||
parent.dependencies.add(`interface:${binding.interfaceRevisionId}`);
|
||||
// An edge traversal may select any matching target. Conservatively include every possible witness.
|
||||
for (const conformance of workspace.conformances) {
|
||||
if (conformance.interfaceRevisionId === binding.interfaceRevisionId && (binding.via || conformance.atomId === atomId)) {
|
||||
if (
|
||||
conformance.interfaceRevisionId === binding.interfaceRevisionId &&
|
||||
(binding.via || conformance.atomId === atomId)
|
||||
) {
|
||||
parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
||||
reviews?.add(conformanceIdentity(conformance));
|
||||
for (const operation of conformance.operationBindings) {
|
||||
@@ -110,7 +162,10 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
||||
}
|
||||
};
|
||||
const binding = (parent: GraphNode, value: Binding, atomId: string, context: unknown) => {
|
||||
if (value.kind !== "package") { dependency(parent, value, atomId); return; }
|
||||
if (value.kind !== "package") {
|
||||
dependency(parent, value, atomId);
|
||||
return;
|
||||
}
|
||||
const packageNode = nodes.get(`package:${value.packageRevisionId}`)!;
|
||||
parent.dependencies.add(`package:${value.packageRevisionId}`);
|
||||
const normalized = { ...value, dependencies: sorted(value.dependencies, (entry) => entry.portId) };
|
||||
@@ -121,16 +176,21 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
||||
};
|
||||
for (const conformance of workspace.conformances) {
|
||||
const parent = node(conformanceKey(conformance.atomId, conformance.interfaceRevisionId), {
|
||||
id: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1,
|
||||
atomId: conformance.atomId, interfaceRevisionId: conformance.interfaceRevisionId,
|
||||
id: conformanceIdentity(conformance),
|
||||
semanticMajor: conformance.semanticMajor ?? 1,
|
||||
atomId: conformance.atomId,
|
||||
interfaceRevisionId: conformance.interfaceRevisionId,
|
||||
operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
|
||||
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
|
||||
});
|
||||
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
|
||||
for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`);
|
||||
for (const operation of conformance.operationBindings) binding(parent, operation.binding, conformance.atomId, {
|
||||
conformanceId: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1, operationId: operation.operationId,
|
||||
});
|
||||
for (const operation of conformance.operationBindings)
|
||||
binding(parent, operation.binding, conformance.atomId, {
|
||||
conformanceId: conformanceIdentity(conformance),
|
||||
semanticMajor: conformance.semanticMajor ?? 1,
|
||||
operationId: operation.operationId,
|
||||
});
|
||||
for (const materialization of conformance.relationshipMaterializations) {
|
||||
parent.dependencies.add(`constructor:${materialization.constructorAtomId}`);
|
||||
parent.dependencies.add(`attachment:${materialization.edgeTypeId}`);
|
||||
@@ -142,91 +202,195 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
||||
}
|
||||
const counts = new Map<string, number>();
|
||||
for (const pkg of workspace.packageImports) counts.set(pkg.packageId, (counts.get(pkg.packageId) ?? 0) + 1);
|
||||
return sorted(workspace.packageImports.map((pkg): RuntimeContract => {
|
||||
const visited = new Set<string>();
|
||||
const walk = (key: string) => {
|
||||
if (visited.has(key)) return;
|
||||
const entry = nodes.get(key);
|
||||
if (!entry) throw new Error(`Unresolved execution dependency ${key}`);
|
||||
visited.add(key);
|
||||
for (const target of entry.dependencies) walk(target);
|
||||
};
|
||||
walk(`package:${pkg.revisionId}`);
|
||||
const dependencies = [...visited].sort().map((id) => ({ id, digest: contentDigest(nodes.get(id)!.value) }));
|
||||
return { groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`,
|
||||
packageId: pkg.packageId, packageRevisionId: pkg.revisionId, digest: contentDigest(dependencies),
|
||||
reviewProviders: [...nodes.get(`package:${pkg.revisionId}`)!.reviewProviders].sort(), dependencies };
|
||||
}), (entry) => entry.groupId);
|
||||
return sorted(
|
||||
workspace.packageImports.map((pkg): RuntimeContract => {
|
||||
const visited = new Set<string>();
|
||||
const walk = (key: string) => {
|
||||
if (visited.has(key)) return;
|
||||
const entry = nodes.get(key);
|
||||
if (!entry) throw new Error(`Unresolved execution dependency ${key}`);
|
||||
visited.add(key);
|
||||
for (const target of entry.dependencies) walk(target);
|
||||
};
|
||||
walk(`package:${pkg.revisionId}`);
|
||||
const dependencies = [...visited].sort().map((id) => ({ id, digest: contentDigest(nodes.get(id)!.value) }));
|
||||
return {
|
||||
groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`,
|
||||
packageId: pkg.packageId,
|
||||
packageRevisionId: pkg.revisionId,
|
||||
digest: contentDigest(dependencies),
|
||||
reviewProviders: [...nodes.get(`package:${pkg.revisionId}`)!.reviewProviders].sort(),
|
||||
dependencies,
|
||||
};
|
||||
}),
|
||||
(entry) => entry.groupId,
|
||||
);
|
||||
};
|
||||
|
||||
export type EvolutionReview = { requirementDigest: string; decision: "changed" | "accepted-unchanged"; rationale: string; agentId: string };
|
||||
export type ReviewRequirement = { consumerId: string; providerId: string; oldMajor: number; newMajor: number; requirementDigest: string };
|
||||
export type RuntimeAction = { groupId: string; action: "keep" | "start" | "replace" | "retire"; previous?: RuntimeContract; candidate?: RuntimeContract; reasons: string[] };
|
||||
export type EvolutionReview = {
|
||||
requirementDigest: string;
|
||||
decision: "changed" | "accepted-unchanged";
|
||||
rationale: string;
|
||||
agentId: string;
|
||||
};
|
||||
export type ReviewRequirement = {
|
||||
consumerId: string;
|
||||
providerId: string;
|
||||
oldMajor: number;
|
||||
newMajor: number;
|
||||
requirementDigest: string;
|
||||
};
|
||||
export type RuntimeAction = {
|
||||
groupId: string;
|
||||
action: "keep" | "start" | "replace" | "retire";
|
||||
previous?: RuntimeContract;
|
||||
candidate?: RuntimeContract;
|
||||
reasons: string[];
|
||||
};
|
||||
export type EvolutionReport = {
|
||||
schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string;
|
||||
schemaVersion: 1;
|
||||
baselineDigest: string | null;
|
||||
candidateDigest: string;
|
||||
checkerVersion: string;
|
||||
runtimeActions: RuntimeAction[];
|
||||
storageChanges: Array<{ id: string; kind: "add" | "remove" | "change"; requiresMigration: boolean; previous?: StorageContract; candidate?: StorageContract }>;
|
||||
storageChanges: Array<{
|
||||
id: string;
|
||||
kind: "add" | "remove" | "change";
|
||||
requiresMigration: boolean;
|
||||
previous?: StorageContract;
|
||||
candidate?: StorageContract;
|
||||
}>;
|
||||
migrationRequired: string[];
|
||||
reviews: Array<ReviewRequirement & { accepted: boolean }>;
|
||||
packageChecks: Array<{ groupId: string; contractDigest: string }>;
|
||||
blockers: string[];
|
||||
};
|
||||
|
||||
export const planEvolution = (baseline: WorkspaceRevision | null, candidate: WorkspaceRevision,
|
||||
options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {}): EvolutionReport => {
|
||||
export const planEvolution = (
|
||||
baseline: WorkspaceRevision | null,
|
||||
candidate: WorkspaceRevision,
|
||||
options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {},
|
||||
): EvolutionReport => {
|
||||
const issues = validateWorkspaceRevision(candidate);
|
||||
if (issues.length) throw new Error(`Invalid candidate workspace:\n${issues.map((entry) => `${entry.path}: ${entry.message}`).join("\n")}`);
|
||||
if (baseline && baseline.workspaceId !== candidate.workspaceId) throw new Error("Cannot evolve a different workspace");
|
||||
if (issues.length)
|
||||
throw new Error(
|
||||
`Invalid candidate workspace:\n${issues.map((entry) => `${entry.path}: ${entry.message}`).join("\n")}`,
|
||||
);
|
||||
if (baseline && baseline.workspaceId !== candidate.workspaceId)
|
||||
throw new Error("Cannot evolve a different workspace");
|
||||
const candidateDigest = contentDigest(candidate);
|
||||
const checkerVersion = "quixos-evolution-v1";
|
||||
const blockers: string[] = [];
|
||||
if (!options.allowLegacy) {
|
||||
if (candidate.sharedAttachments.length) blockers.push("Assign legacy workspace-shared attachments to explicit conformance owners");
|
||||
for (const entry of candidate.conformances) if (!entry.id) blockers.push(`Conformance ${entry.atomId} as ${entry.interfaceRevisionId} requires an authored ID`);
|
||||
if (candidate.sharedAttachments.length)
|
||||
blockers.push("Assign legacy workspace-shared attachments to explicit conformance owners");
|
||||
for (const entry of candidate.conformances)
|
||||
if (!entry.id)
|
||||
blockers.push(`Conformance ${entry.atomId} as ${entry.interfaceRevisionId} requires an authored ID`);
|
||||
}
|
||||
const previousRuntimes = new Map((baseline ? runtimeContracts(baseline) : []).map((entry) => [entry.groupId, entry]));
|
||||
const nextRuntimes = new Map(runtimeContracts(candidate).map((entry) => [entry.groupId, entry]));
|
||||
const runtimeActions: RuntimeAction[] = [...new Set([...previousRuntimes.keys(), ...nextRuntimes.keys()])].sort().map((groupId) => {
|
||||
const previous = previousRuntimes.get(groupId), next = nextRuntimes.get(groupId);
|
||||
const before = new Map(previous?.dependencies.map((entry) => [entry.id, entry.digest]));
|
||||
const after = new Map(next?.dependencies.map((entry) => [entry.id, entry.digest]));
|
||||
const reasons = [...new Set([...before.keys(), ...after.keys()])].sort().filter((id) => before.get(id) !== after.get(id));
|
||||
return { groupId, action: !previous ? "start" : !next ? "retire" : previous.digest === next.digest ? "keep" : "replace",
|
||||
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}), reasons };
|
||||
});
|
||||
const runtimeActions: RuntimeAction[] = [...new Set([...previousRuntimes.keys(), ...nextRuntimes.keys()])]
|
||||
.sort()
|
||||
.map((groupId) => {
|
||||
const previous = previousRuntimes.get(groupId),
|
||||
next = nextRuntimes.get(groupId);
|
||||
const before = new Map(previous?.dependencies.map((entry) => [entry.id, entry.digest]));
|
||||
const after = new Map(next?.dependencies.map((entry) => [entry.id, entry.digest]));
|
||||
const reasons = [...new Set([...before.keys(), ...after.keys()])]
|
||||
.sort()
|
||||
.filter((id) => before.get(id) !== after.get(id));
|
||||
return {
|
||||
groupId,
|
||||
action: !previous ? "start" : !next ? "retire" : previous.digest === next.digest ? "keep" : "replace",
|
||||
...(previous ? { previous } : {}),
|
||||
...(next ? { candidate: next } : {}),
|
||||
reasons,
|
||||
};
|
||||
});
|
||||
const beforeStorage = new Map((baseline ? storageContracts(baseline) : []).map((entry) => [entry.id, entry]));
|
||||
const afterStorage = new Map(storageContracts(candidate).map((entry) => [entry.id, entry]));
|
||||
const storageChanges: EvolutionReport["storageChanges"] = [];
|
||||
for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) {
|
||||
const previous = beforeStorage.get(id), next = afterStorage.get(id);
|
||||
if (previous?.digest !== next?.digest) storageChanges.push({ id, kind: !previous ? "add" : !next ? "remove" : "change",
|
||||
requiresMigration: storageChangeRequiresMigration(previous, next, new Set(baseline?.atoms.map(atom => atom.id) ?? [])),
|
||||
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}) });
|
||||
const previous = beforeStorage.get(id),
|
||||
next = afterStorage.get(id);
|
||||
if (previous?.digest !== next?.digest)
|
||||
storageChanges.push({
|
||||
id,
|
||||
kind: !previous ? "add" : !next ? "remove" : "change",
|
||||
requiresMigration: storageChangeRequiresMigration(
|
||||
previous,
|
||||
next,
|
||||
new Set(baseline?.atoms.map((atom) => atom.id) ?? []),
|
||||
),
|
||||
...(previous ? { previous } : {}),
|
||||
...(next ? { candidate: next } : {}),
|
||||
});
|
||||
}
|
||||
const providers = (workspace: WorkspaceRevision) => [
|
||||
...workspace.packageImports.map((entry) => ({ id: entry.packageId as string, revision: entry.revisionId as string, major: entry.semanticMajor ?? 1,
|
||||
node: `package:${entry.revisionId}`, digest: contentDigest(entry) })),
|
||||
...workspace.conformances.map((entry) => ({ id: conformanceIdentity(entry), revision: contentDigest(entry), major: entry.semanticMajor ?? 1,
|
||||
node: `conformance:${entry.atomId}:${entry.interfaceRevisionId}`, digest: contentDigest(entry) })),
|
||||
...workspace.packageImports.map((entry) => ({
|
||||
id: entry.packageId as string,
|
||||
revision: entry.revisionId as string,
|
||||
major: entry.semanticMajor ?? 1,
|
||||
node: `package:${entry.revisionId}`,
|
||||
digest: contentDigest(entry),
|
||||
})),
|
||||
...workspace.conformances.map((entry) => ({
|
||||
id: conformanceIdentity(entry),
|
||||
revision: contentDigest(entry),
|
||||
major: entry.semanticMajor ?? 1,
|
||||
node: `conformance:${entry.atomId}:${entry.interfaceRevisionId}`,
|
||||
digest: contentDigest(entry),
|
||||
})),
|
||||
];
|
||||
const oldProviders = baseline ? providers(baseline) : [];
|
||||
const reviews: EvolutionReport["reviews"] = [];
|
||||
for (const provider of providers(candidate)) {
|
||||
const old = oldProviders.filter((entry) => entry.id === provider.id);
|
||||
if (old.length > 1) { blockers.push(`Ambiguous semantic-major lineage for ${provider.id}`); continue; }
|
||||
if (old.length > 1) {
|
||||
blockers.push(`Ambiguous semantic-major lineage for ${provider.id}`);
|
||||
continue;
|
||||
}
|
||||
if (!old[0] || old[0].major === provider.major) continue;
|
||||
if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`);
|
||||
for (const consumer of nextRuntimes.values()) {
|
||||
if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue;
|
||||
const requirement = { consumerId: consumer.groupId, providerId: provider.id, oldMajor: old[0].major, newMajor: provider.major };
|
||||
const requirementDigest = contentDigest({ ...requirement, oldProvider: old[0].digest, newProvider: provider.digest, consumer: consumer.digest, checkerVersion });
|
||||
const accepted = (options.reviews ?? []).some((entry) => entry.requirementDigest === requirementDigest &&
|
||||
["changed", "accepted-unchanged"].includes(entry.decision) && entry.rationale.trim() && entry.agentId.trim());
|
||||
const requirement = {
|
||||
consumerId: consumer.groupId,
|
||||
providerId: provider.id,
|
||||
oldMajor: old[0].major,
|
||||
newMajor: provider.major,
|
||||
};
|
||||
const requirementDigest = contentDigest({
|
||||
...requirement,
|
||||
oldProvider: old[0].digest,
|
||||
newProvider: provider.digest,
|
||||
consumer: consumer.digest,
|
||||
checkerVersion,
|
||||
});
|
||||
const accepted = (options.reviews ?? []).some(
|
||||
(entry) =>
|
||||
entry.requirementDigest === requirementDigest &&
|
||||
["changed", "accepted-unchanged"].includes(entry.decision) &&
|
||||
entry.rationale.trim() &&
|
||||
entry.agentId.trim(),
|
||||
);
|
||||
reviews.push({ ...requirement, requirementDigest, accepted });
|
||||
if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`);
|
||||
}
|
||||
}
|
||||
return { schemaVersion: 1, baselineDigest: baseline ? contentDigest(baseline) : null, candidateDigest, checkerVersion,
|
||||
runtimeActions, storageChanges, migrationRequired: storageChanges.filter(entry => entry.requiresMigration).map(entry => entry.id), reviews, packageChecks: runtimeActions.filter((entry) => entry.candidate && entry.action !== "keep")
|
||||
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), blockers };
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
baselineDigest: baseline ? contentDigest(baseline) : null,
|
||||
candidateDigest,
|
||||
checkerVersion,
|
||||
runtimeActions,
|
||||
storageChanges,
|
||||
migrationRequired: storageChanges.filter((entry) => entry.requiresMigration).map((entry) => entry.id),
|
||||
reviews,
|
||||
packageChecks: runtimeActions
|
||||
.filter((entry) => entry.candidate && entry.action !== "keep")
|
||||
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })),
|
||||
blockers,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,12 +4,23 @@ 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])};
|
||||
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 = {
|
||||
@@ -19,7 +30,12 @@ export type MigrationDeclaration = {
|
||||
to: string;
|
||||
implementation: { exportId: string; file: string; digest: string };
|
||||
predecessors: string[];
|
||||
ports: { name: string; view: "old" | "new"; access: ("read" | "write" | "create" | "edge")[]; contractDigest: string }[];
|
||||
ports: {
|
||||
name: string;
|
||||
view: "old" | "new";
|
||||
access: ("read" | "write" | "create" | "edge")[];
|
||||
contractDigest: string;
|
||||
}[];
|
||||
preservesOldReaders?: boolean;
|
||||
preservesOldWriters?: boolean;
|
||||
};
|
||||
@@ -29,56 +45,99 @@ export type MigrationCatalog = {
|
||||
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");
|
||||
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}`);
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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}`);
|
||||
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);
|
||||
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[];
|
||||
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()) => {
|
||||
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}`);
|
||||
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}`);
|
||||
@@ -86,7 +145,8 @@ export const selectMigrationPath = (catalog: MigrationCatalog, selection: Migrat
|
||||
}
|
||||
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`);
|
||||
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;
|
||||
|
||||
@@ -29,13 +29,11 @@ const opaque = <Kind extends string>(value: string) => value as OpaqueId<Kind>;
|
||||
*/
|
||||
export const capabilityId = {
|
||||
workspace: (value: string) => opaque<"WorkspaceId">(value),
|
||||
workspaceRevision: (value: string) =>
|
||||
opaque<"WorkspaceRevisionId">(value),
|
||||
workspaceRevision: (value: string) => opaque<"WorkspaceRevisionId">(value),
|
||||
atom: (value: string) => opaque<"AtomId">(value),
|
||||
conformance: (value: string) => opaque<"ConformanceId">(value),
|
||||
interface: (value: string) => opaque<"InterfaceId">(value),
|
||||
interfaceRevision: (value: string) =>
|
||||
opaque<"InterfaceRevisionId">(value),
|
||||
interfaceRevision: (value: string) => opaque<"InterfaceRevisionId">(value),
|
||||
member: (value: string) => opaque<"MemberId">(value),
|
||||
operation: (value: string) => opaque<"OperationId">(value),
|
||||
slot: (value: string) => opaque<"SlotId">(value),
|
||||
@@ -53,15 +51,7 @@ export interface SourceRevision {
|
||||
commit: string;
|
||||
}
|
||||
|
||||
export type ScalarValueTypeName =
|
||||
| "bool"
|
||||
| "bytes"
|
||||
| "double"
|
||||
| "int32"
|
||||
| "int64"
|
||||
| "string"
|
||||
| "uint32"
|
||||
| "uint64";
|
||||
export type ScalarValueTypeName = "bool" | "bytes" | "double" | "int32" | "int64" | "string" | "uint32" | "uint64";
|
||||
|
||||
export type ObjectExpectation =
|
||||
| { kind: "atom"; atomId: AtomId }
|
||||
@@ -112,12 +102,7 @@ export interface AtomDefinition {
|
||||
documentation?: string;
|
||||
}
|
||||
|
||||
export type InterfaceOperationMode =
|
||||
| "call"
|
||||
| "watch-start"
|
||||
| "watch-stop"
|
||||
| "subscribe"
|
||||
| "unsubscribe";
|
||||
export type InterfaceOperationMode = "call" | "watch-start" | "watch-stop" | "subscribe" | "unsubscribe";
|
||||
|
||||
export interface InterfaceOperation {
|
||||
id: OperationId;
|
||||
@@ -140,11 +125,7 @@ export interface ValueInterfaceMember extends InterfaceMemberBase {
|
||||
valueType: ValueType;
|
||||
}
|
||||
|
||||
export type EdgeCardinality =
|
||||
| "optional-one"
|
||||
| "exactly-one"
|
||||
| "many"
|
||||
| "many-unique";
|
||||
export type EdgeCardinality = "optional-one" | "exactly-one" | "many" | "many-unique";
|
||||
|
||||
export type EdgeEndpointConstraint =
|
||||
| { kind: "atom"; atomId: AtomId }
|
||||
@@ -167,10 +148,7 @@ export interface OperationInterfaceMember extends InterfaceMemberBase {
|
||||
outputType: ValueType;
|
||||
}
|
||||
|
||||
export type InterfaceMember =
|
||||
| ValueInterfaceMember
|
||||
| RelationshipInterfaceMember
|
||||
| OperationInterfaceMember;
|
||||
export type InterfaceMember = ValueInterfaceMember | RelationshipInterfaceMember | OperationInterfaceMember;
|
||||
|
||||
export interface InterfaceRevision {
|
||||
interfaceId: InterfaceId;
|
||||
@@ -180,9 +158,7 @@ export interface InterfaceRevision {
|
||||
members: InterfaceMember[];
|
||||
}
|
||||
|
||||
export type StoragePolicy =
|
||||
| { kind: "optimistic-register" }
|
||||
| { kind: "crdt-document"; updateType: ValueType };
|
||||
export type StoragePolicy = { kind: "optimistic-register" } | { kind: "crdt-document"; updateType: ValueType };
|
||||
|
||||
export interface StateSlotDefinition {
|
||||
kind: "state";
|
||||
@@ -215,18 +191,9 @@ export interface EdgeDefinition {
|
||||
|
||||
export type PersistentAttachment = StateSlotDefinition | EdgeDefinition;
|
||||
|
||||
export type StatePrimitive =
|
||||
| "read"
|
||||
| "write"
|
||||
| "watch-start"
|
||||
| "watch-stop";
|
||||
export type StatePrimitive = "read" | "write" | "watch-start" | "watch-stop";
|
||||
|
||||
export type EdgePrimitive =
|
||||
| "resolve"
|
||||
| "connect"
|
||||
| "disconnect"
|
||||
| "watch-start"
|
||||
| "watch-stop";
|
||||
export type EdgePrimitive = "resolve" | "connect" | "disconnect" | "watch-start" | "watch-stop";
|
||||
|
||||
export type PackageReceiverRequirement =
|
||||
| { kind: "any-object" }
|
||||
@@ -284,10 +251,7 @@ export interface PackageConstructorExport extends PackageExportBase {
|
||||
constructsAtom: AtomId;
|
||||
}
|
||||
|
||||
export type PackageExport =
|
||||
| PackageOperationExport
|
||||
| PackageFunctionExport
|
||||
| PackageConstructorExport;
|
||||
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
|
||||
|
||||
export interface PackageRevision {
|
||||
migrationCatalog?: import("./migrations.js").MigrationCatalog;
|
||||
|
||||
+212
-386
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user