Implement typed module contracts and conformance bindings

This commit is contained in:
Timothy J. Aveni
2026-09-22 13:51:32 -07:00
parent 60b44a6637
commit 7173751f37
33 changed files with 2963 additions and 2261 deletions
@@ -14,7 +14,7 @@ export async function scaffoldComponent(
throw Error("Component scaffold requires a PascalCase name, ID and QX declaration");
const source = `package Draft id "draft" revision "draft@1" { ${spec.declaration} }`;
const syntax = parseQx(source);
const declarations = [...walkSyntax(syntax.root)].filter((node) => node.kind === "componentDecl");
const declarations = [...walkSyntax(syntax.root)].filter((node) => node.kind === "moduleExport");
const pkg = syntax.root.children.find((node) => node.kind === "packageResourceDecl");
if (
syntax.diagnostics.length ||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
EDIT=1
COMPONENT=2
PRESENTATION=3
SUBJECT=4
RUNTIME=5
MODULE=2
CONTRACT=3
PRESENTATION=4
SUBJECT=5
FOR=6
CREATION=7
INITIALIZE=8
@@ -152,10 +152,10 @@ LINE_COMMENT=151
BLOCK_COMMENT=152
WS=153
'edit'=1
'component'=2
'presentation'=3
'subject'=4
'runtime'=5
'module'=2
'contract'=3
'presentation'=4
'subject'=5
'for'=6
'creation'=7
'initialize'=8
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
EDIT=1
COMPONENT=2
PRESENTATION=3
SUBJECT=4
RUNTIME=5
MODULE=2
CONTRACT=3
PRESENTATION=4
SUBJECT=5
FOR=6
CREATION=7
INITIALIZE=8
@@ -152,10 +152,10 @@ LINE_COMMENT=151
BLOCK_COMMENT=152
WS=153
'edit'=1
'component'=2
'presentation'=3
'subject'=4
'runtime'=5
'module'=2
'contract'=3
'presentation'=4
'subject'=5
'for'=6
'creation'=7
'initialize'=8
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,7 @@ import { TypeArgumentsContext } from "./QuixosCapabilityParser.js";
import { TypeArgumentContext } from "./QuixosCapabilityParser.js";
import { TypeAliasDeclContext } from "./QuixosCapabilityParser.js";
import { InterfaceMemberContext } from "./QuixosCapabilityParser.js";
import { ModuleMemberContext } from "./QuixosCapabilityParser.js";
import { OperationMemberContext } from "./QuixosCapabilityParser.js";
import { ValueMemberContext } from "./QuixosCapabilityParser.js";
import { ValueMemberOperationContext } from "./QuixosCapabilityParser.js";
@@ -29,7 +30,7 @@ import { RelationshipOperationContext } from "./QuixosCapabilityParser.js";
import { TargetConstraintContext } from "./QuixosCapabilityParser.js";
import { PackageResourceDeclContext } from "./QuixosCapabilityParser.js";
import { PackageExportContext } from "./QuixosCapabilityParser.js";
import { ComponentDeclContext } from "./QuixosCapabilityParser.js";
import { ModuleExportContext } from "./QuixosCapabilityParser.js";
import { PresentationDeclContext } from "./QuixosCapabilityParser.js";
import { PackageQueryContext } from "./QuixosCapabilityParser.js";
import { PackageQuerySpecializationContext } from "./QuixosCapabilityParser.js";
@@ -56,6 +57,7 @@ import { EdgeDeclContext } from "./QuixosCapabilityParser.js";
import { EdgeEndpointContext } from "./QuixosCapabilityParser.js";
import { ConformanceDeclContext } from "./QuixosCapabilityParser.js";
import { ConformanceItemContext } from "./QuixosCapabilityParser.js";
import { ModuleBindingDeclContext } from "./QuixosCapabilityParser.js";
import { InitializationDeclContext } from "./QuixosCapabilityParser.js";
import { InitializationFieldContext } from "./QuixosCapabilityParser.js";
import { CreationOwnerMappingContext } from "./QuixosCapabilityParser.js";
@@ -199,6 +201,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor result
*/
visitInterfaceMember?: (ctx: InterfaceMemberContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.moduleMember`.
* @param ctx the parse tree
* @return the visitor result
*/
visitModuleMember?: (ctx: ModuleMemberContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.operationMember`.
* @param ctx the parse tree
@@ -254,11 +262,11 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
*/
visitPackageExport?: (ctx: PackageExportContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.componentDecl`.
* Visit a parse tree produced by `QuixosCapabilityParser.moduleExport`.
* @param ctx the parse tree
* @return the visitor result
*/
visitComponentDecl?: (ctx: ComponentDeclContext) => Result;
visitModuleExport?: (ctx: ModuleExportContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.presentationDecl`.
* @param ctx the parse tree
@@ -415,6 +423,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor result
*/
visitConformanceItem?: (ctx: ConformanceItemContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.moduleBindingDecl`.
* @param ctx the parse tree
* @return the visitor result
*/
visitModuleBindingDecl?: (ctx: ModuleBindingDeclContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.initializationDecl`.
* @param ctx the parse tree
+63 -18
View File
@@ -642,7 +642,19 @@ const lowerInterface = (
source: SourceRevision,
): InterfaceRevision => {
const alias = identifier(context.identifier());
const members = context.interfaceMember().flatMap((entry) => {
const members = context.interfaceMember().flatMap<InterfaceMember>((entry) => {
const module = entry.moduleMember();
if (module)
return [
{
kind: "module" as const,
id: capabilityId.member(stringValue(module.stringLiteral(0))),
displayName: identifier(module.identifier()),
contract: stringValue(module.stringLiteral(1)),
...(module.targetConstraint() ? { subject: state.types.target(module.targetConstraint()!) } : {}),
operations: [],
},
];
const value = entry.valueMember();
if (value) {
return [lowerValueMember(state, value)];
@@ -745,6 +757,16 @@ const lowerInterfaceTemplate = (
const members: InterfaceMember<ValueTypeExpression, ObjectTypeExpression>[] = context
.interfaceMember()
.map((entry) => {
const module = entry.moduleMember();
if (module)
return {
kind: "module" as const,
id: capabilityId.member(stringValue(module.stringLiteral(0))),
displayName: identifier(module.identifier()),
contract: stringValue(module.stringLiteral(1)),
...(module.targetConstraint() ? { subject: types.targetExpression(module.targetConstraint()!) } : {}),
operations: [],
};
const value = entry.valueMember();
if (value) {
const type = types.expression(value.valueType());
@@ -1333,23 +1355,26 @@ const lowerPackage = (
}),
);
const genericExports: GenericPackageExport[] = [];
const components: NonNullable<PackageRevision["components"]> = [];
const modules: NonNullable<PackageRevision["modules"]> = [];
const queries: QueryDeclaration[] = [];
const queryTemplates: QueryTemplate[] = [];
const exports = context.packageExport().flatMap((exportContext): PackageExport[] => {
const component = exportContext.componentDecl();
if (component) {
const runtime = identifier(component.identifier(1));
if (runtime !== "react")
loweringIssue(state, component, "unsupported-component-runtime", `Unsupported component runtime ${runtime}`);
components.push({
id: stringValue(component.stringLiteral()),
displayName: identifier(component.identifier(0)),
subject: new TypeSubstitution(state.types.environment()).application(
state.types.interface(component.interfaceType()),
),
runtime: "react",
});
const module = exportContext.moduleExport();
if (module) {
const interfaceRevisionId = new TypeSubstitution(state.types.environment()).application(
state.types.interface(module.interfaceType()),
);
const contract = state.types.definitions.get(interfaceRevisionId);
const member = contract?.members.find((m) => m.displayName === identifier(module.identifier(1)));
if (member?.kind !== "module")
loweringIssue(state, module, "invalid-module-export", "Module export must implement an imported module member");
else
modules.push({
id: stringValue(module.stringLiteral()),
displayName: identifier(module.identifier(0)),
interfaceRevisionId,
memberId: member.id,
});
return [];
}
if (exportContext.packageQuerySpecialization()) return [];
@@ -1611,7 +1636,7 @@ const lowerPackage = (
displayName: alias,
source,
exports,
...(components.length ? { components } : {}),
...(modules.length ? { modules } : {}),
...(queries.length ? { queries } : {}),
...(queryTemplates.length ? { queryTemplates } : {}),
...(queries.some((query) => query.argumentRequirements?.length)
@@ -1931,6 +1956,25 @@ const lowerConformance = (
state.types.self = previousSelf;
}
}
const moduleBindings: NonNullable<WorkspaceRevision["conformances"][number]["moduleBindings"]> = [];
for (const item of context.conformanceItem()) {
const binding = item.moduleBindingDecl();
if (!binding) continue;
const memberName = identifier(binding.identifier(0));
const member = interfaceSymbol.definition?.members.find((m) => m.displayName === memberName);
const pkg = requireSymbol(state, state.packages, identifier(binding.identifier(1)), binding, "package");
const module = pkg?.definition?.modules?.find((m) => m.displayName === identifier(binding.identifier(2)));
if (member?.kind !== "module" || !module || !pkg) {
loweringIssue(
state,
binding,
"invalid-module-binding",
"Expected a module member bound to an imported module export",
);
continue;
}
moduleBindings.push({ memberId: member.id, packageRevisionId: pkg.revisionId, moduleId: module.id });
}
const operationBindings = context
.conformanceItem()
.flatMap<WorkspaceRevision["conformances"][number]["operationBindings"][number]>((item) => {
@@ -2182,6 +2226,7 @@ const lowerConformance = (
...(context.INTEGER() ? { semanticMajor: Number(context.INTEGER()!.getText()) } : {}),
privateAttachments,
operationBindings,
...(moduleBindings.length ? { moduleBindings } : {}),
relationshipMaterializations,
};
};
@@ -2420,7 +2465,7 @@ const lowerWorkspace = (
const packageName = identifier(presentation.identifier(1));
const componentName = identifier(presentation.identifier(2));
const pkg = requireSymbol(state, state.packages, packageName, presentation, "package");
const component = pkg?.definition?.components?.find((entry) => entry.displayName === componentName);
const component = pkg?.definition?.modules?.find((entry) => entry.displayName === componentName);
if (!component) {
loweringIssue(state, presentation, "unknown-component", `Unknown component ${packageName}.${componentName}`);
return [];
@@ -2692,7 +2737,7 @@ const compileCapabilityResourceSourceInternal = (
externalInterfaces,
revision: lowerPackage(state, context, options.source),
};
if (resource.revision.components?.length) {
if (resource.revision.modules?.length) {
resource.revision.clientInterfaces = Object.fromEntries(
imports.flatMap((entry) => {
if (entry.kind !== "interface") return [];
+5
View File
@@ -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));
+1 -1
View File
@@ -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");
+7
View File
@@ -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,
+1
View File
@@ -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";
+82
View File
@@ -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;
}
+12 -23
View File
@@ -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;
}
+23 -6
View File
@@ -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;
+15 -1
View File
@@ -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);
+6 -3
View File
@@ -39,7 +39,7 @@ export const componentSharedModules = [
export interface ComponentArtifact {
id: string;
subject: string;
compatibility: "quixos-react-component-v1";
compatibility: "org.quixos.react.esm/1";
path: string;
exportName: string;
sharedModules: Record<(typeof componentSharedModules)[number], string>;
@@ -171,7 +171,7 @@ function parseComponent(value: unknown, index: number): ComponentArtifact {
[],
path,
);
if (entry.compatibility !== "quixos-react-component-v1") fail(path, "Unsupported component ABI");
if (entry.compatibility !== "org.quixos.react.esm/1") fail(path, "Unsupported component ABI");
const files = parseArtifactFiles(entry.files, `${path}.files`);
const shared = object(entry.sharedModules, `${path}.sharedModules`);
keys(shared, [...componentSharedModules], [], `${path}.sharedModules`);
@@ -187,7 +187,7 @@ function parseComponent(value: unknown, index: number): ComponentArtifact {
const component: ComponentArtifact = {
id: text(entry.id, path),
subject: text(entry.subject, path),
compatibility: "quixos-react-component-v1",
compatibility: "org.quixos.react.esm/1",
path: entryPath,
exportName,
sharedModules,
@@ -272,6 +272,9 @@ export function parsePackageArtifacts(value: unknown): PackageArtifacts {
};
}
function checkCoverage(expected: PlanPackage, manifest: PackageArtifacts): void {
for (const module of expected.modules ?? [])
if (!expected.components?.some((component) => component.id === module.id))
fail(module.id, `No artifact consumer for module contract ${module.contract} and its subject`);
if (expected.bindingDigest !== manifest.bindingDigest || expected.sourceDigest !== manifest.sourceDigest)
fail(expected.revision, "Substituted binding schema or source revision");
for (const component of manifest.components ?? []) {
+4 -5
View File
@@ -136,7 +136,8 @@ export function generatePackageQueryClientBindings(
* declared queries, never the workspace's complete atom/interface inventory. */
export function generateComponentClientBindings(plan: ExecutionPlan, packageRevision: string): string {
const pkg = plan.definition.workspace.packageImports.find((pkg) => pkg.revisionId === packageRevision);
if (!pkg || !pkg.components?.length)
const components = plan.definition.packages.find((pkg) => pkg.revision === packageRevision)?.components;
if (!pkg || !components?.length)
throw new ExecutionPlanError("NO_COMPONENT_CONTRACT", packageRevision, "Package declares no component entries");
const byId = new Map(plan.definition.workspace.interfaceImports.map((iface) => [iface.revisionId as string, iface]));
const find = (id: string) => {
@@ -148,9 +149,7 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi
const imports = Object.fromEntries(
Object.entries(pkg.clientInterfaces ?? {}).map(([alias, id]) => [alias, find(id)]),
);
const subjects = Object.fromEntries(
pkg.components.map((component) => [component.displayName, find(component.subject)]),
);
const subjects = Object.fromEntries(components.map((component) => [component.displayName, find(component.subject)]));
const interfaces = generateInterfaceClientBindings(imports, "@quixos/camino-react");
const subjectBindings = generateInterfaceClientBindings(subjects, "@quixos/camino-react")
.split("\n")
@@ -168,7 +167,7 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi
"\n" +
queries +
"\nexport interface Components {\n" +
pkg.components
components
.map(
(component) =>
`${JSON.stringify(component.displayName)}: ComponentType<ComponentProps<${JSON.stringify(component.subject)}>>;`,
+44 -2
View File
@@ -10,6 +10,7 @@ import type {
OperationId,
} from "../capability-model/types.js";
import { validateWorkspaceRevision } from "../capability-model/validation.js";
import { moduleContract } from "../capability-model/modules.js";
import { canonicalJson, contentDigest } from "../capability-model/evolution.js";
import { resolveForwardedBinding } from "../capability-model/forwarding.js";
import { linkQueries, type LinkedQuery } from "../query/link.js";
@@ -58,14 +59,41 @@ export interface PlannedQuery {
admittedOperations: string[];
}
export interface PlanPackage {
modules?: (NonNullable<WorkspaceRevision["packageImports"][number]["modules"]>[number] & {
contract: string;
subject?: import("../capability-model/types.js").ObjectExpectation;
})[];
controllers?: NonNullable<WorkspaceRevision["packageImports"][number]["controllers"]>;
components?: NonNullable<WorkspaceRevision["packageImports"][number]["components"]>;
components?: { id: string; displayName: string; subject: InterfaceRevisionId; runtime: "react" }[];
revision: string;
source: WorkspaceRevision["packageImports"][number]["source"];
sourceDigest: string;
bindingDigest: string;
exports: { id: string; execution: ExecutionContract }[];
}
/** React consumes these module contracts; other contracts remain metadata until
* an artifact consumer exists. This does not make modules callable operations. */
function componentDeclarations(workspace: WorkspaceRevision, pkg: WorkspaceRevision["packageImports"][number]) {
return [
...(pkg.modules ?? []).flatMap((module) => {
const contract = moduleContract(workspace.interfaceImports, module)!;
if (
!["org.quixos.react.component/1", "org.quixos.react.subject-only/1"].includes(contract.contract) ||
contract.subject?.kind !== "interface"
)
return [];
return [
{
id: module.id,
displayName: module.displayName,
subject: contract.subject.interfaceRevisionId,
runtime: "react" as const,
},
];
}),
];
}
export interface ExecutionPlanDefinition {
presentations?: NonNullable<WorkspaceRevision["presentations"]>;
codec: "camino-execution-plan-v1";
@@ -430,8 +458,22 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan {
source: p.source,
sourceDigest: hexDigest({ revision: p.revisionId, source: p.source }),
bindingDigest: hexDigest(normalizedSchema),
...(p.modules?.length
? {
modules: sorted(p.modules, (module) => module.id).map((module) => {
const contract = moduleContract(workspace.interfaceImports, module)!;
return {
...module,
contract: contract.contract,
...(contract.subject ? { subject: contract.subject } : {}),
};
}),
}
: {}),
...(p.controllers?.length ? { controllers: sorted(p.controllers, (controller) => controller.id) } : {}),
...(p.components?.length ? { components: sorted(p.components, (component) => component.id) } : {}),
...(componentDeclarations(workspace, p).length
? { components: sorted(componentDeclarations(workspace, p), (component) => component.id) }
: {}),
exports: sorted(p.exports, (e) => e.id).map((e) => ({ id: e.id, execution: e.execution ?? { kind: "remote" } })),
})),
owners: sorted(workspace.conformances, (c) => c.id ?? "").map((c) => {
+1 -1
View File
@@ -126,7 +126,7 @@ export async function compileQuery(
if (id === workspaceQueryRootId) return;
const contract = generated.contracts.get(id)!;
const member = contract.members.find((entry) => entry.displayName === name);
if (!member || member.kind === "operation" || !member.queryRead)
if (!member || member.kind === "operation" || member.kind === "module" || !member.queryRead)
return fail("QUERY_FIELD_NOT_QUERYABLE", `${contract.displayName}.${name} is not queryable`, node);
if (declaration.placement?.kind === "local") {
const getter = member.operations.find(
+6 -1
View File
@@ -62,7 +62,12 @@ export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
for (const effect of checked.effects) {
const contract = interfaces.get(effect.interfaceRevisionId);
const member = contract?.members.find((entry) => entry.id === effect.memberId);
if (!member || member.kind === "operation" || member.queryRead?.execution !== effect.execution)
if (
!member ||
member.kind === "operation" ||
member.kind === "module" ||
member.queryRead?.execution !== effect.execution
)
throw new QueryCompileError(
"QUERY_STALE_CONTRACT",
`Query field ${effect.interfaceRevisionId}.${effect.memberId} changed`,
+1 -1
View File
@@ -83,7 +83,7 @@ export function relationalSchema(env: RelationalSchemaEnvironment, document?: Do
]);
return new Map(
env.contracts.get(row.interfaceRevisionId)!.members.flatMap((member): [string, SchemaRowField][] => {
if (member.kind === "operation" || !member.queryRead) return [];
if (member.kind === "operation" || member.kind === "module" || !member.queryRead) return [];
if (member.kind === "value") return [[member.displayName, { kind: "leaf", type: member.valueType }]];
if (many(member)) return [];
return [
+1 -1
View File
@@ -200,7 +200,7 @@ export function querySchema(
});
const fields: GraphQLFieldConfigMap<unknown, unknown> = { _qx: { type: new GraphQLNonNull(metadata) } };
for (const member of contract(id).members) {
if (member.kind === "operation" || !member.queryRead) continue;
if (member.kind === "operation" || member.kind === "module" || !member.queryRead) continue;
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(member.displayName) || member.displayName.startsWith("_"))
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Unsupported query field name ${member.displayName}`);
if (member.kind === "value") {