Implement checked workspace integration contracts (WI-01)
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import type { InterfaceRevision, PackageController, WorkspaceRevision } from "./types.js";
|
||||
import type { CapabilityValidationIssue } from "./validation.js";
|
||||
import { canonicalJson } from "./evolution.js";
|
||||
|
||||
export function validateControllers(
|
||||
controllers: readonly PackageController[],
|
||||
interfaces: readonly InterfaceRevision[],
|
||||
prefix: string,
|
||||
): CapabilityValidationIssue[] {
|
||||
const issues: CapabilityValidationIssue[] = [],
|
||||
ids = new Set<string>();
|
||||
const fail = (path: string, message: string) => issues.push({ code: "invalid-controller" as const, path, message });
|
||||
for (const [index, controller] of controllers.entries()) {
|
||||
const path = `${prefix}[${index}]`;
|
||||
if (!controller.id.trim() || ids.has(controller.id))
|
||||
fail(`${path}.id`, "Controller identities must be nonempty and unique within the package");
|
||||
ids.add(controller.id);
|
||||
const iface = interfaces.find((i) => i.revisionId === controller.subject && !i.template);
|
||||
if (!iface) {
|
||||
fail(`${path}.subject`, "Controller subject must be an imported closed interface");
|
||||
continue;
|
||||
}
|
||||
const operations = iface.members.flatMap((m) => m.operations);
|
||||
const snapshot = operations.find((o) => o.id === controller.snapshot),
|
||||
remote = operations.find((o) => o.id === controller.remote);
|
||||
const unit = (value: unknown) => canonicalJson(value) === canonicalJson({ kind: "builtin", name: "unit" });
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.scope === "class" ||
|
||||
snapshot.mode !== "call" ||
|
||||
snapshot.execution?.kind !== "snapshot" ||
|
||||
!snapshot.execution.targets.includes("server") ||
|
||||
!unit(snapshot.inputType)
|
||||
)
|
||||
fail(`${path}.snapshot`, "Controller snapshot must be a server snapshot object call with unit input");
|
||||
if (
|
||||
!remote ||
|
||||
remote.scope === "class" ||
|
||||
remote.mode !== "call" ||
|
||||
(remote.execution?.kind ?? "remote") !== "remote"
|
||||
)
|
||||
fail(`${path}.remote`, "Controller target must be a remote object call");
|
||||
if (controller.input !== "unit" && controller.input !== "value")
|
||||
fail(`${path}.input`, "Controller input must be unit or value");
|
||||
else if (
|
||||
remote &&
|
||||
(controller.input === "unit"
|
||||
? !unit(remote.inputType)
|
||||
: snapshot && canonicalJson(snapshot.outputType) !== canonicalJson(remote.inputType))
|
||||
)
|
||||
fail(`${path}.input`, "Controller input mapping does not match the remote operation input");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
export function validateWorkspaceControllers(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||
return workspace.packageImports.flatMap((pkg, index) =>
|
||||
validateControllers(pkg.controllers ?? [], workspace.interfaceImports, `packageImports[${index}].controllers`),
|
||||
);
|
||||
}
|
||||
/** The file is declarative metadata, not an executable registration callback. */
|
||||
export function parseControllerCatalog(text: string, interfaces: readonly InterfaceRevision[]): PackageController[] {
|
||||
if (Buffer.byteLength(text) > 1048576) throw new Error("Controller catalog exceeds 1 MiB");
|
||||
const value: unknown = JSON.parse(text);
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === "object" && !Array.isArray(value);
|
||||
if (
|
||||
!record(value) ||
|
||||
value.schemaVersion !== 1 ||
|
||||
Object.keys(value).some((k) => !["schemaVersion", "controllers"].includes(k)) ||
|
||||
!Array.isArray(value.controllers) ||
|
||||
value.controllers.length > 1000
|
||||
)
|
||||
throw new Error("Invalid controller catalog: expected schemaVersion 1 and at most 1000 controllers");
|
||||
const fields = ["id", "subject", "snapshot", "remote", "input"];
|
||||
for (const entry of value.controllers)
|
||||
if (
|
||||
!record(entry) ||
|
||||
Object.keys(entry).length !== fields.length ||
|
||||
fields.some((key) => typeof entry[key] !== "string")
|
||||
)
|
||||
throw new Error("Invalid controller entry: expected exactly id, subject, snapshot, remote, input strings");
|
||||
const controllers = value.controllers as PackageController[];
|
||||
const issues = validateControllers(controllers, interfaces, "controllers");
|
||||
if (issues.length) throw new Error(issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
|
||||
return controllers;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { WorkspaceRevision } from "./types.js";
|
||||
import type { CapabilityValidationIssue } from "./validation.js";
|
||||
|
||||
/** Presentation metadata has no graph authority and cannot name arbitrary module URLs. */
|
||||
export function validatePresentations(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||
const issues: CapabilityValidationIssue[] = [];
|
||||
const fail = (path: string, message: string) => issues.push({ code: "invalid-presentation", path, message });
|
||||
const interfaces = new Map(workspace.interfaceImports.map((entry) => [entry.revisionId, entry]));
|
||||
const packages = new Map(workspace.packageImports.map((entry) => [entry.revisionId, entry]));
|
||||
const text = (value: string, path: string) => {
|
||||
if (!value.trim()) fail(path, "Presentation identities and names must be nonempty");
|
||||
};
|
||||
const subject = (id: string, path: string) => {
|
||||
const contract = interfaces.get(id as WorkspaceRevision["interfaceImports"][number]["revisionId"]);
|
||||
if (!contract || contract.template) fail(path, `Expected an imported closed subject interface: ${id}`);
|
||||
};
|
||||
for (const [index, pkg] of workspace.packageImports.entries()) {
|
||||
const ids = new Set(pkg.exports.map((entry) => entry.id as string));
|
||||
const names = new Set([
|
||||
...pkg.exports.map((entry) => entry.displayName),
|
||||
...(pkg.queries ?? []).map((entry) => entry.displayName),
|
||||
...(pkg.queryTemplates ?? []).map((entry) => entry.declaration.displayName),
|
||||
...(pkg.genericExports ?? []).map((entry) => entry.displayName),
|
||||
]);
|
||||
for (const [componentIndex, component] of (pkg.components ?? []).entries()) {
|
||||
const path = `packageImports[${index}].components[${componentIndex}]`;
|
||||
text(component.id, `${path}.id`);
|
||||
text(component.displayName, `${path}.displayName`);
|
||||
if (ids.has(component.id) || names.has(component.displayName))
|
||||
fail(path, "Duplicate component identity or package member name");
|
||||
ids.add(component.id);
|
||||
names.add(component.displayName);
|
||||
subject(component.subject, `${path}.subject`);
|
||||
if (component.runtime !== "react") fail(`${path}.runtime`, "Unsupported component runtime");
|
||||
}
|
||||
}
|
||||
const ids = new Set<string>(),
|
||||
names = new Set<string>();
|
||||
for (const [index, presentation] of (workspace.presentations ?? []).entries()) {
|
||||
const path = `presentations[${index}]`;
|
||||
text(presentation.id, `${path}.id`);
|
||||
text(presentation.displayName, `${path}.displayName`);
|
||||
if (ids.has(presentation.id) || names.has(presentation.displayName))
|
||||
fail(path, "Duplicate presentation identity or name");
|
||||
ids.add(presentation.id);
|
||||
names.add(presentation.displayName);
|
||||
subject(presentation.subject, `${path}.subject`);
|
||||
const component = packages
|
||||
.get(presentation.packageRevisionId)
|
||||
?.components?.find((entry) => entry.id === presentation.componentId);
|
||||
if (!component) fail(`${path}.componentId`, "Presentation names no installed package component");
|
||||
else if (component.subject !== presentation.subject)
|
||||
fail(`${path}.subject`, `Component requires ${component.subject}, not ${presentation.subject}`);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -288,7 +288,25 @@ export interface PackageConstructorExport extends PackageExportBase {
|
||||
|
||||
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
|
||||
|
||||
/** A window presentation entry, never a graph operation or service export. */
|
||||
export interface PackageComponent {
|
||||
id: string;
|
||||
displayName: string;
|
||||
subject: InterfaceRevisionId;
|
||||
runtime: "react";
|
||||
}
|
||||
|
||||
export interface WorkspacePresentation {
|
||||
id: string;
|
||||
displayName: string;
|
||||
subject: InterfaceRevisionId;
|
||||
packageRevisionId: PackageRevisionId;
|
||||
componentId: string;
|
||||
}
|
||||
|
||||
export interface PackageRevision {
|
||||
controllers?: PackageController[];
|
||||
components?: PackageComponent[];
|
||||
queries?: import("../query/types.js").QueryDeclaration[];
|
||||
checkedQueries?: import("../query/types.js").CheckedQuery[];
|
||||
queryTemplates?: import("../query/types.js").QueryTemplate[];
|
||||
@@ -304,6 +322,15 @@ export interface PackageRevision {
|
||||
semanticMajor?: number;
|
||||
}
|
||||
|
||||
/** Checked adapter registration. Implementations remain ordinary object capabilities. */
|
||||
export interface PackageController {
|
||||
id: string;
|
||||
subject: InterfaceRevisionId;
|
||||
snapshot: OperationId;
|
||||
remote: OperationId;
|
||||
input: "unit" | "value";
|
||||
}
|
||||
|
||||
export type DependencyBinding =
|
||||
| { kind: "reflection"; selectors: string[]; via?: never }
|
||||
| {
|
||||
@@ -415,6 +442,7 @@ export interface AtomConstructorBinding {
|
||||
}
|
||||
|
||||
export interface WorkspaceRevision {
|
||||
presentations?: WorkspacePresentation[];
|
||||
linkedQueries?: import("../query/link.js").LinkedQuery[];
|
||||
id: WorkspaceRevisionId;
|
||||
workspaceId: WorkspaceId;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isStorableType } from "./generics.js";
|
||||
import { validateInitialization } from "./initialization.js";
|
||||
import { validatePresentations } from "./presentations.js";
|
||||
import { validateWorkspaceControllers } from "./controllers.js";
|
||||
import { validateExecutionContracts } from "./execution.js";
|
||||
import { resolveForwardedBinding, type ConcreteBinding } from "./forwarding.js";
|
||||
import type {
|
||||
@@ -44,6 +46,8 @@ import { specializePackageExport } from "./generic-packages.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
export type CapabilityValidationIssueCode =
|
||||
| "invalid-controller"
|
||||
| "invalid-presentation"
|
||||
| "invalid-initialization"
|
||||
| "invalid-execution-contract"
|
||||
| "invalid-query-contract"
|
||||
@@ -2213,7 +2217,12 @@ export const validateWorkspaceRevision = (workspace: WorkspaceRevision): Capabil
|
||||
validatePackages(workspace, issues, indexes);
|
||||
validateConformances(workspace, issues, indexes);
|
||||
validateConstructors(workspace, issues, indexes);
|
||||
issues.push(...validateExecutionContracts(workspace), ...validateInitialization(workspace));
|
||||
issues.push(
|
||||
...validateExecutionContracts(workspace),
|
||||
...validateInitialization(workspace),
|
||||
...validatePresentations(workspace),
|
||||
...validateWorkspaceControllers(workspace),
|
||||
);
|
||||
return issues;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user