Implement typed module contracts and conformance bindings
This commit is contained in:
@@ -215,12 +215,17 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
||||
atomId: conformance.atomId,
|
||||
interfaceRevisionId: conformance.interfaceRevisionId,
|
||||
operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
|
||||
...(conformance.moduleBindings?.length
|
||||
? { modules: sorted(conformance.moduleBindings, (entry) => entry.memberId) }
|
||||
: {}),
|
||||
...(conformance.initialization
|
||||
? { initialization: sorted(conformance.initialization, (entry) => entry.input) }
|
||||
: {}),
|
||||
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
|
||||
});
|
||||
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
|
||||
for (const module of conformance.moduleBindings ?? [])
|
||||
parent.dependencies.add(`package:${module.packageRevisionId}`);
|
||||
const contract = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
|
||||
for (const required of contract?.requiredInterfaces ?? [])
|
||||
parent.dependencies.add(conformanceKey(conformance.atomId, required));
|
||||
|
||||
@@ -46,7 +46,7 @@ export function validateExecutionContracts(workspace: WorkspaceRevision): Capabi
|
||||
"Watch lifecycle operations are remote; portable reads use snapshots",
|
||||
);
|
||||
}
|
||||
if (member.kind !== "operation" && member.queryRead?.execution === "portable") {
|
||||
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");
|
||||
|
||||
@@ -86,6 +86,13 @@ export const instantiateInterface = (
|
||||
const members: InterfaceMember[] = template.members.map((member) => {
|
||||
const common = { id: member.id, displayName: member.displayName, operations: member.operations.map(operation) };
|
||||
switch (member.kind) {
|
||||
case "module":
|
||||
return {
|
||||
...common,
|
||||
kind: "module",
|
||||
contract: member.contract,
|
||||
...(member.subject ? { subject: substitution.object(member.subject, member.displayName) } : {}),
|
||||
};
|
||||
case "value":
|
||||
return {
|
||||
...common,
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./evolution.js";
|
||||
export * from "./migrations.js";
|
||||
export * from "./generics.js";
|
||||
export * from "./generic-packages.js";
|
||||
export * from "./modules.js";
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { InterfaceRevision, ModuleInterfaceMember, PackageModule, WorkspaceRevision } from "./types.js";
|
||||
import type { CapabilityValidationIssue } from "./validation.js";
|
||||
|
||||
/** Public nominal contract lookup; does not resolve an executable artifact or grant access. */
|
||||
export function moduleContract(
|
||||
interfaces: readonly InterfaceRevision[],
|
||||
module: PackageModule,
|
||||
): ModuleInterfaceMember | undefined {
|
||||
const iface = interfaces.find((entry) => entry.revisionId === module.interfaceRevisionId);
|
||||
if (!iface || iface.template) return undefined;
|
||||
const member = iface.members.find((entry) => entry.id === module.memberId);
|
||||
return member?.kind === "module" ? member : undefined;
|
||||
}
|
||||
|
||||
export function validateModules(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||
const issues: CapabilityValidationIssue[] = [];
|
||||
const fail = (path: string, message: string) => issues.push({ code: "invalid-module-contract", path, message });
|
||||
for (const iface of workspace.interfaceImports) {
|
||||
for (const member of iface.template?.members ?? iface.members) {
|
||||
if (member.kind !== "module") continue;
|
||||
const path = `${iface.revisionId}.${member.id}`;
|
||||
if (!/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+\/[1-9][0-9]*$/.test(member.contract))
|
||||
fail(path, "Module contracts require a namespaced, versioned identifier");
|
||||
if (member.operations.length) fail(path, "A module member cannot declare callable operations");
|
||||
}
|
||||
}
|
||||
for (const pkg of workspace.packageImports) {
|
||||
const ids = new Set<string>(pkg.exports.map((entry) => entry.id));
|
||||
const names = new Set([
|
||||
...pkg.exports.map((entry) => entry.displayName),
|
||||
...(pkg.genericExports ?? []).map((entry) => entry.displayName),
|
||||
...(pkg.queries ?? []).map((entry) => entry.displayName),
|
||||
...(pkg.queryTemplates ?? []).map((entry) => entry.declaration.displayName),
|
||||
]);
|
||||
for (const module of pkg.modules ?? []) {
|
||||
const path = `${pkg.revisionId}.${module.id}`;
|
||||
if (!module.id.trim() || !module.displayName.trim() || ids.has(module.id) || names.has(module.displayName))
|
||||
fail(path, "Duplicate or empty module identity or package member name");
|
||||
ids.add(module.id);
|
||||
names.add(module.displayName);
|
||||
if (!moduleContract(workspace.interfaceImports, module))
|
||||
fail(path, "Module export must implement a closed imported module member");
|
||||
}
|
||||
}
|
||||
for (const conformance of workspace.conformances) {
|
||||
const iface = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
|
||||
if (!iface) continue;
|
||||
const bound = new Set<string>();
|
||||
for (const binding of conformance.moduleBindings ?? []) {
|
||||
const path = `${conformance.id ?? conformance.atomId}.${binding.memberId}`;
|
||||
const member = iface.members.find((entry) => entry.id === binding.memberId);
|
||||
const pkg = workspace.packageImports.find((entry) => entry.revisionId === binding.packageRevisionId);
|
||||
const module = pkg?.modules?.find((entry) => entry.id === binding.moduleId);
|
||||
if (bound.has(binding.memberId)) fail(path, "Module member is bound more than once");
|
||||
bound.add(binding.memberId);
|
||||
if (
|
||||
member?.kind !== "module" ||
|
||||
!module ||
|
||||
module.interfaceRevisionId !== iface.revisionId ||
|
||||
module.memberId !== member.id
|
||||
) {
|
||||
fail(path, "Module binding must implement this exact nominal interface member");
|
||||
continue;
|
||||
}
|
||||
const subject = member.subject;
|
||||
if (
|
||||
subject &&
|
||||
!(subject.kind === "atom"
|
||||
? subject.atomId === conformance.atomId
|
||||
: workspace.conformances.some(
|
||||
(entry) =>
|
||||
entry.atomId === conformance.atomId && entry.interfaceRevisionId === subject.interfaceRevisionId,
|
||||
))
|
||||
)
|
||||
fail(path, "Implementing atom does not satisfy the module subject contract");
|
||||
}
|
||||
for (const member of iface.members)
|
||||
if (member.kind === "module" && !bound.has(member.id))
|
||||
fail(`${conformance.id ?? conformance.atomId}.${member.id}`, "Missing module member binding");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { WorkspaceRevision } from "./types.js";
|
||||
import type { CapabilityValidationIssue } from "./validation.js";
|
||||
import { moduleContract } from "./modules.js";
|
||||
|
||||
/** Presentation metadata has no graph authority and cannot name arbitrary module URLs. */
|
||||
export function validatePresentations(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||
@@ -19,24 +20,6 @@ export function validatePresentations(workspace: WorkspaceRevision): CapabilityV
|
||||
text(alias, `packageImports[${index}].clientInterfaces`);
|
||||
subject(id, `packageImports[${index}].clientInterfaces.${alias}`);
|
||||
}
|
||||
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>();
|
||||
@@ -49,12 +32,18 @@ export function validatePresentations(workspace: WorkspaceRevision): CapabilityV
|
||||
ids.add(presentation.id);
|
||||
names.add(presentation.displayName);
|
||||
subject(presentation.subject, `${path}.subject`);
|
||||
const component = packages
|
||||
const module = 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}`);
|
||||
?.modules?.find((entry) => entry.id === presentation.componentId);
|
||||
if (module) {
|
||||
const contract = moduleContract(workspace.interfaceImports, module);
|
||||
if (contract?.contract !== "org.quixos.react.subject-only/1")
|
||||
fail(`${path}.componentId`, "Standalone presentation requires a subject-only module contract");
|
||||
if (contract?.subject?.kind !== "interface" || contract.subject.interfaceRevisionId !== presentation.subject)
|
||||
fail(`${path}.subject`, "Component requires its exact declared subject interface");
|
||||
continue;
|
||||
}
|
||||
fail(`${path}.componentId`, "Presentation names no installed package component");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -163,10 +163,21 @@ export interface OperationInterfaceMember<Type = ValueType> extends InterfaceMem
|
||||
outputType: Type;
|
||||
}
|
||||
|
||||
/** A typed code artifact. Module bindings are neither calls nor storage fields. */
|
||||
export interface ModuleInterfaceMember<
|
||||
Type = ValueType,
|
||||
Target = EdgeEndpointConstraint,
|
||||
> extends InterfaceMemberBase<Type> {
|
||||
kind: "module";
|
||||
contract: string;
|
||||
subject?: Target;
|
||||
}
|
||||
|
||||
export type InterfaceMember<Type = ValueType, Target = EdgeEndpointConstraint> =
|
||||
| ValueInterfaceMember<Type>
|
||||
| RelationshipInterfaceMember<Type, Target>
|
||||
| OperationInterfaceMember<Type>;
|
||||
| OperationInterfaceMember<Type>
|
||||
| ModuleInterfaceMember<Type, Target>;
|
||||
|
||||
export interface InterfaceRevision {
|
||||
interfaceId: InterfaceId;
|
||||
@@ -288,12 +299,17 @@ 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 {
|
||||
export interface PackageModule {
|
||||
id: string;
|
||||
displayName: string;
|
||||
subject: InterfaceRevisionId;
|
||||
runtime: "react";
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
memberId: MemberId;
|
||||
}
|
||||
|
||||
export interface ModuleBinding {
|
||||
memberId: MemberId;
|
||||
packageRevisionId: PackageRevisionId;
|
||||
moduleId: string;
|
||||
}
|
||||
|
||||
export interface WorkspacePresentation {
|
||||
@@ -305,10 +321,10 @@ export interface WorkspacePresentation {
|
||||
}
|
||||
|
||||
export interface PackageRevision {
|
||||
modules?: PackageModule[];
|
||||
/** Closed interface imports exposed to this package's checked component bindings. */
|
||||
clientInterfaces?: Record<string, InterfaceRevisionId>;
|
||||
controllers?: PackageController[];
|
||||
components?: PackageComponent[];
|
||||
queries?: import("../query/types.js").QueryDeclaration[];
|
||||
checkedQueries?: import("../query/types.js").CheckedQuery[];
|
||||
queryTemplates?: import("../query/types.js").QueryTemplate[];
|
||||
@@ -427,6 +443,7 @@ export interface InitializationField {
|
||||
}
|
||||
|
||||
export interface Conformance {
|
||||
moduleBindings?: ModuleBinding[];
|
||||
initialization?: InitializationField[];
|
||||
/** Absent only for legacy assemblies awaiting explicit ownership enrollment. */
|
||||
id?: ConformanceId;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isStorableType } from "./generics.js";
|
||||
import { validateInitialization } from "./initialization.js";
|
||||
import { validatePresentations } from "./presentations.js";
|
||||
import { validateModules } from "./modules.js";
|
||||
import { validateWorkspaceControllers } from "./controllers.js";
|
||||
import { validateExecutionContracts } from "./execution.js";
|
||||
import { resolveForwardedBinding, type ConcreteBinding } from "./forwarding.js";
|
||||
@@ -48,6 +49,7 @@ import { isDeepStrictEqual } from "node:util";
|
||||
export type CapabilityValidationIssueCode =
|
||||
| "invalid-controller"
|
||||
| "invalid-presentation"
|
||||
| "invalid-module-contract"
|
||||
| "invalid-initialization"
|
||||
| "invalid-execution-contract"
|
||||
| "invalid-query-contract"
|
||||
@@ -543,7 +545,7 @@ const collectIdentityIndexes = (
|
||||
memberPath,
|
||||
"Keyed relationships require many cardinality and string, boolean or int64 keys",
|
||||
);
|
||||
if (member.kind !== "operation" && member.queryRead) {
|
||||
if (member.kind !== "operation" && member.kind !== "module" && member.queryRead) {
|
||||
const contract = member.queryRead.execution;
|
||||
const getter = member.kind === "value" ? "get" : "resolve";
|
||||
if (contract !== "native" && contract !== "rpc-permitted" && contract !== "portable")
|
||||
@@ -841,6 +843,8 @@ const validateInterfaces = (
|
||||
validateValueType(issues, member.valueType, `${memberPath}.valueType`, indexes);
|
||||
} else if (member.kind === "relationship") {
|
||||
validateConstraint(issues, member.target, `${memberPath}.target`, indexes);
|
||||
} else if (member.kind === "module") {
|
||||
if (member.subject) validateConstraint(issues, member.subject, `${memberPath}.subject`, indexes);
|
||||
} else {
|
||||
validateValueType(issues, member.inputType, `${memberPath}.inputType`, indexes);
|
||||
validateValueType(issues, member.outputType, `${memberPath}.outputType`, indexes);
|
||||
@@ -1671,6 +1675,7 @@ const validateConformances = (
|
||||
);
|
||||
if (
|
||||
member.kind !== "operation" &&
|
||||
member.kind !== "module" &&
|
||||
member.queryRead &&
|
||||
operation.displayName === (member.kind === "value" ? "get" : "resolve")
|
||||
) {
|
||||
@@ -2224,6 +2229,7 @@ export const validateWorkspaceRevision = (workspace: WorkspaceRevision): Capabil
|
||||
...validateExecutionContracts(workspace),
|
||||
...validateInitialization(workspace),
|
||||
...validatePresentations(workspace),
|
||||
...validateModules(workspace),
|
||||
...validateWorkspaceControllers(workspace),
|
||||
);
|
||||
return issues;
|
||||
@@ -2504,6 +2510,14 @@ export const computeCapabilityClosure = (
|
||||
for (const interfaceRevisionId of plan.interfaces.get(root.interfaceRevisionId)?.requiredInterfaces ?? []) {
|
||||
queued.push({ atomId: root.atomId, interfaceRevisionId });
|
||||
}
|
||||
for (const module of conformance.source.moduleBindings ?? []) {
|
||||
packages.add(module.packageRevisionId);
|
||||
const member = plan.interfaces
|
||||
.get(root.interfaceRevisionId)
|
||||
?.members.find((member) => member.id === module.memberId);
|
||||
if (member?.kind === "module" && member.subject?.kind === "interface")
|
||||
queued.push({ atomId: root.atomId, interfaceRevisionId: member.subject.interfaceRevisionId });
|
||||
}
|
||||
for (const binding of conformance.operationBindings.values()) {
|
||||
if (binding.kind === "state") {
|
||||
attachments.add(binding.slotId);
|
||||
|
||||
Reference in New Issue
Block a user