179 lines
9.4 KiB
TypeScript
179 lines
9.4 KiB
TypeScript
import type { ExecutionContract, InterfaceOperation, WorkspaceRevision } from "./types.js";
|
|
import type { CapabilityValidationIssue } from "./validation.js";
|
|
import { resolveForwardedBinding } from "./forwarding.js";
|
|
|
|
/** Validate declared effects and targets; this is not a JavaScript purity proof. */
|
|
export function validateExecutionContracts(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
|
const issues: CapabilityValidationIssue[] = [];
|
|
const fail = (path: string, message: string) => issues.push({ code: "invalid-execution-contract", path, message });
|
|
const interfaces = new Map(workspace.interfaceImports.map((i) => [i.revisionId, i]));
|
|
const packages = new Map(workspace.packageImports.map((p) => [p.revisionId, p]));
|
|
const operations = (id: InterfaceOperation["id"], revision: WorkspaceRevision["interfaceImports"][number]) =>
|
|
(revision.template?.members ?? revision.members).flatMap((m) => m.operations).find((o) => o.id === id);
|
|
const check = (contract: ExecutionContract | undefined, path: string) => {
|
|
if (!contract || contract.kind === "remote") return;
|
|
if (
|
|
!["snapshot", "transaction"].includes(contract.kind) ||
|
|
!Array.isArray(contract.targets) ||
|
|
!contract.targets.length ||
|
|
contract.targets.some((t) => typeof t !== "string" || !t.trim()) ||
|
|
new Set(contract.targets).size !== contract.targets.length
|
|
)
|
|
fail(path, "Portable execution requires a nonempty set of distinct target names");
|
|
};
|
|
const supports = (
|
|
requested: ExecutionContract | undefined,
|
|
provided: ExecutionContract | undefined,
|
|
path: string,
|
|
) => {
|
|
if (!requested || requested.kind === "remote") return;
|
|
if (!provided || provided.kind === "remote") {
|
|
fail(path, "Portable execution depends on a remote-only operation");
|
|
return;
|
|
}
|
|
if (requested.kind === "snapshot" && provided.kind !== "snapshot")
|
|
fail(path, "Snapshot execution cannot perform a transaction");
|
|
};
|
|
for (const revision of workspace.interfaceImports)
|
|
for (const member of revision.template?.members ?? revision.members) {
|
|
for (const operation of member.operations) {
|
|
check(operation.execution, `${revision.revisionId}/${operation.id}`);
|
|
if (operation.execution && operation.execution.kind !== "remote" && operation.mode !== "call")
|
|
fail(
|
|
`${revision.revisionId}/${operation.id}`,
|
|
"Watch lifecycle operations are remote; portable reads use snapshots",
|
|
);
|
|
}
|
|
if (member.kind !== "operation" && member.kind !== "module" && member.queryRead?.execution === "portable") {
|
|
const getter = member.operations.find((o) => o.displayName === (member.kind === "value" ? "get" : "resolve"));
|
|
if (getter?.execution?.kind !== "snapshot")
|
|
fail(`${revision.revisionId}/${member.id}`, "Queryable portable members require a snapshot getter");
|
|
}
|
|
}
|
|
for (const revision of workspace.packageImports) {
|
|
for (const entry of [...revision.exports, ...(revision.genericExports ?? [])]) {
|
|
const path = `${revision.revisionId}/${entry.id}`;
|
|
check(entry.execution, path);
|
|
for (const port of entry.dependencyPorts) {
|
|
const requirement = port.requirement;
|
|
const portPath = `${path}/${port.id}`;
|
|
if (requirement.kind === "interface") {
|
|
const contract = interfaces.get(
|
|
"interfaceRevisionId" in requirement
|
|
? requirement.interfaceRevisionId
|
|
: requirement.application.definitionId,
|
|
);
|
|
const selected = requirement.operations;
|
|
if (requirement.receiver === "input") {
|
|
if (!entry.execution || entry.execution.kind === "remote" || !selected?.length)
|
|
fail(portPath, "Reference-selected calls require portable execution and an explicit selection");
|
|
for (const id of selected ?? []) {
|
|
const operation = contract && operations(id, contract);
|
|
if (!operation?.execution || operation.execution.kind === "remote" || operation.scope === "class")
|
|
fail(portPath, "Reference-selected dependencies require portable object operations");
|
|
}
|
|
}
|
|
if (selected && (!selected.length || new Set(selected).size !== selected.length))
|
|
fail(portPath, "Selected interface operations must be nonempty and distinct");
|
|
for (const id of selected ?? [])
|
|
if (contract && !operations(id, contract)) fail(portPath, `Unknown selected operation ${id}`);
|
|
if (entry.execution && entry.execution.kind !== "remote") {
|
|
if (!selected) fail(portPath, "Portable interface ports must select their required operations");
|
|
for (const id of selected ?? [])
|
|
if (contract) supports(entry.execution, operations(id, contract)?.execution, portPath);
|
|
}
|
|
}
|
|
if (!entry.execution || entry.execution.kind === "remote") continue;
|
|
if (requirement.kind === "state" || requirement.kind === "edge") {
|
|
const read = requirement.kind === "state" ? "read" : "resolve";
|
|
for (const primitive of requirement.primitives) {
|
|
if (primitive.startsWith("watch"))
|
|
fail(portPath, "Portable operations cannot acquire watch lifecycle primitives");
|
|
else if (entry.execution.kind === "snapshot" && primitive !== read)
|
|
fail(portPath, "Snapshot operations cannot acquire write primitives");
|
|
}
|
|
} else if (requirement.kind === "query") {
|
|
const query = packages.get(requirement.packageRevisionId)?.queries?.find((q) => q.id === requirement.queryId);
|
|
if (query?.placement?.kind !== "local")
|
|
fail(portPath, "Portable operations require a local query dependency");
|
|
} else if (requirement.kind === "creation") {
|
|
if (entry.execution.kind !== "transaction") fail(portPath, "Object creation requires transaction execution");
|
|
} else if (requirement.kind === "reflection") {
|
|
if (requirement.selectors.some((selector) => selector !== "presentations"))
|
|
fail(portPath, "Only checked presentation metadata is portable");
|
|
} else if (requirement.kind !== "interface")
|
|
fail(portPath, `Portable execution does not support ${requirement.kind} dependencies`);
|
|
}
|
|
}
|
|
for (const query of revision.queries ?? [])
|
|
if (query.placement?.kind === "local") {
|
|
const sourceQueryId = query.placement.sourceQueryId;
|
|
const source = revision.queries?.find((q) => q.id === sourceQueryId);
|
|
if (source?.placement?.kind !== "replica")
|
|
fail(`${revision.revisionId}/${query.id}`, "Local queries must name a replica query in the same package");
|
|
}
|
|
}
|
|
for (const conformance of workspace.conformances) {
|
|
const revision = interfaces.get(conformance.interfaceRevisionId);
|
|
if (!revision) continue;
|
|
for (const entry of conformance.operationBindings) {
|
|
const operation = operations(entry.operationId, revision);
|
|
if (!operation) continue;
|
|
const path = `${conformance.atomId}/${revision.revisionId}/${entry.operationId}`;
|
|
let binding;
|
|
try {
|
|
binding = resolveForwardedBinding(
|
|
workspace,
|
|
conformance.atomId,
|
|
revision.revisionId,
|
|
entry.operationId,
|
|
).binding;
|
|
} catch {
|
|
continue;
|
|
} // Ordinary binding validation reports unresolved references and cycles.
|
|
if (entry.binding.kind === "interface") {
|
|
const target = interfaces.get(entry.binding.interfaceRevisionId);
|
|
if (target) supports(operation.execution, operations(entry.binding.operationId, target)?.execution, path);
|
|
}
|
|
if (binding.kind === "lifecycle") {
|
|
if (
|
|
operation.execution?.kind !== "transaction" ||
|
|
operation.execution.targets.length !== 1 ||
|
|
operation.execution.targets[0] !== "server"
|
|
)
|
|
fail(
|
|
path,
|
|
"Deletion requires an explicitly server-only transaction; partial replicas cannot enumerate incoming references",
|
|
);
|
|
continue;
|
|
}
|
|
if (!operation.execution || operation.execution.kind === "remote") continue;
|
|
if (binding.kind === "journal") {
|
|
const expected = binding.primitive === "publish" ? "transaction" : "snapshot";
|
|
if (operation.execution.kind !== expected) fail(path, "Native journal operation requires the matching effect");
|
|
continue;
|
|
}
|
|
if (binding.kind === "subscription") {
|
|
const expected = binding.primitive === "status" ? "snapshot" : "transaction";
|
|
if (
|
|
operation.execution.kind !== expected ||
|
|
(binding.primitive !== "status" && operation.execution.targets.some((target) => target !== "server"))
|
|
)
|
|
fail(path, "Subscription controls require authoritative execution; status is a portable snapshot");
|
|
continue;
|
|
}
|
|
if (binding.kind === "package") {
|
|
const provider = packages.get(binding.packageRevisionId)?.exports.find((e) => e.id === binding.exportId);
|
|
supports(operation.execution, provider?.execution, path);
|
|
} else {
|
|
const read = binding.kind === "state" ? "read" : "resolve";
|
|
if (binding.primitive.startsWith("watch"))
|
|
fail(path, "Portable operations cannot bind watch lifecycle primitives");
|
|
else if (operation.execution.kind === "snapshot" && binding.primitive !== read)
|
|
fail(path, "Snapshot operations cannot bind write primitives");
|
|
}
|
|
}
|
|
}
|
|
return issues;
|
|
}
|