Implement typed module contracts and conformance bindings
This commit is contained in:
@@ -93,6 +93,11 @@ interfaceMember
|
||||
: valueMember
|
||||
| relationshipMember
|
||||
| operationMember
|
||||
| moduleMember
|
||||
;
|
||||
|
||||
moduleMember
|
||||
: MODULE identifier ID stringLiteral CONTRACT stringLiteral (SUBJECT targetConstraint)? SEMI
|
||||
;
|
||||
|
||||
operationMember
|
||||
@@ -146,11 +151,11 @@ packageExport
|
||||
| packageConstructorExport
|
||||
| packageQuery
|
||||
| packageQuerySpecialization
|
||||
| componentDecl
|
||||
| moduleExport
|
||||
;
|
||||
|
||||
componentDecl
|
||||
: COMPONENT identifier ID stringLiteral SUBJECT interfaceType RUNTIME identifier SEMI
|
||||
moduleExport
|
||||
: MODULE identifier ID stringLiteral IMPLEMENTS interfaceType DOT identifier SEMI
|
||||
;
|
||||
|
||||
presentationDecl
|
||||
@@ -296,6 +301,11 @@ conformanceItem
|
||||
| stateFieldBindingDecl
|
||||
| initializationDecl
|
||||
| relationshipMaterializationDecl
|
||||
| moduleBindingDecl
|
||||
;
|
||||
|
||||
moduleBindingDecl
|
||||
: BIND identifier TO identifier DOT identifier SEMI
|
||||
;
|
||||
|
||||
initializationDecl
|
||||
@@ -481,10 +491,8 @@ identifier
|
||||
| RESULT_BYTES
|
||||
| RPC_CALLS
|
||||
| DEADLINE_MS
|
||||
| COMPONENT
|
||||
| PRESENTATION
|
||||
| SUBJECT
|
||||
| RUNTIME
|
||||
| FOR
|
||||
;
|
||||
|
||||
@@ -493,10 +501,10 @@ stringLiteral
|
||||
;
|
||||
|
||||
EDIT: 'edit';
|
||||
COMPONENT: 'component';
|
||||
MODULE: 'module';
|
||||
CONTRACT: 'contract';
|
||||
PRESENTATION: 'presentation';
|
||||
SUBJECT: 'subject';
|
||||
RUNTIME: 'runtime';
|
||||
FOR: 'for';
|
||||
CREATION: 'creation';
|
||||
INITIALIZE: 'initialize';
|
||||
|
||||
@@ -99,6 +99,14 @@ let
|
||||
let
|
||||
compiled = compile node;
|
||||
candidate = builtins.fromJSON (builtins.readFile "${compiled}/candidate.json");
|
||||
componentEntries =
|
||||
if execution then
|
||||
(pkgs.lib.findFirst (
|
||||
entry: entry.revision == candidate.revision.revisionId
|
||||
) { } (builtins.fromJSON (builtins.readFile "${contract}/execution-plan.json")).definition.packages)
|
||||
.components or [ ]
|
||||
else
|
||||
[ ];
|
||||
package = builtins.getFlake (
|
||||
"git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}"
|
||||
);
|
||||
@@ -119,7 +127,7 @@ let
|
||||
typescript = runtimePackages.camino-replica-core.dependencies;
|
||||
packageRevisionId = candidate.revision.revisionId;
|
||||
}
|
||||
// pkgs.lib.optionalAttrs ((candidate.revision.components or [ ]) != [ ]) {
|
||||
// pkgs.lib.optionalAttrs (componentEntries != [ ]) {
|
||||
react = {
|
||||
sdk = runtimePackages.camino-react;
|
||||
runtime = runtimePackages.camino-react-runtime;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? []) {
|
||||
|
||||
@@ -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)}>>;`,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -25,11 +25,11 @@ test("checked component scaffolding composes with execution handlers without rep
|
||||
tools: { quixos: source, protocol: source, helpers: source, sdk: source },
|
||||
});
|
||||
const packageFile = path.join(root, "package.qx");
|
||||
await fs.writeFile(packageFile, "import interface Data;\n" + (await fs.readFile(packageFile, "utf8")));
|
||||
await fs.writeFile(packageFile, "import interface View;\n" + (await fs.readFile(packageFile, "utf8")));
|
||||
await apply("component", {
|
||||
name: "Card",
|
||||
id: "component:card",
|
||||
declaration: 'component Card id "component:card" subject Data runtime react;',
|
||||
declaration: 'module Card id "component:card" implements View.render;',
|
||||
});
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(path.join(root, "quixos.components.json"), "utf8")), {
|
||||
"component:card": { entry: "src/components/Card.tsx", export: "Card" },
|
||||
@@ -45,7 +45,7 @@ test("checked component scaffolding composes with execution handlers without rep
|
||||
await apply("component", {
|
||||
name: "Details",
|
||||
id: "component:details",
|
||||
declaration: 'component Details id "component:details" subject Data runtime react;',
|
||||
declaration: 'module Details id "component:details" implements View.render;',
|
||||
});
|
||||
assert.equal(await fs.readFile(card, "utf8"), retained);
|
||||
assert.match(await fs.readFile(path.join(root, "src/server.ts"), "utf8"), /echo/);
|
||||
@@ -54,7 +54,7 @@ test("checked component scaffolding composes with execution handlers without rep
|
||||
apply("component", {
|
||||
name: "Card",
|
||||
id: "component:other",
|
||||
declaration: 'component Card id "component:other" subject Data runtime react;',
|
||||
declaration: 'module Card id "component:other" implements View.render;',
|
||||
}),
|
||||
/unique/,
|
||||
);
|
||||
@@ -63,7 +63,7 @@ test("checked component scaffolding composes with execution handlers without rep
|
||||
name: "Injected",
|
||||
id: "component:inject",
|
||||
declaration:
|
||||
'component Injected id "component:inject" subject Data runtime react; function sneak id "sneak" : unit -> unit;',
|
||||
'module Injected id "component:inject" implements View.render; function sneak id "sneak" : unit -> unit;',
|
||||
}),
|
||||
/exactly one/,
|
||||
);
|
||||
|
||||
@@ -46,6 +46,10 @@ pkg.controllers = parseControllerCatalog(
|
||||
[iface, controllerInterface],
|
||||
pkg.checkedQueries,
|
||||
);
|
||||
const viewRole = compileCapabilityResourceSource(await read("DeviceView.qx"), { source, environment });
|
||||
assert.ok(viewRole.ok && viewRole.resource.kind === "interface", JSON.stringify(viewRole.diagnostics));
|
||||
environment.interfaces.set("DeviceView", viewRole.resource.revision);
|
||||
environment.interfaceClosure.push(viewRole.resource.revision);
|
||||
const viewer = compileCapabilityResourceSource(await read("Viewer.qx"), {
|
||||
source: { ...source, repository: "https://example.test/device-viewer.git" },
|
||||
environment,
|
||||
|
||||
+34
-7
@@ -45,10 +45,25 @@ assert.ok(data.ok && data.resource.kind === "interface", JSON.stringify(data.dia
|
||||
assert.ok(constructors.ok && constructors.resource.kind === "interface", JSON.stringify(constructors.diagnostics));
|
||||
const dataInterface = data.resource.revision,
|
||||
factoriesInterface = constructors.resource.revision;
|
||||
const ui = compileCapabilityResourceSource(
|
||||
'import interface Data; package UI id "package:ui" revision "package:ui@1" { component Card id "component:card" subject Data runtime react; }',
|
||||
const view = compileCapabilityResourceSource(
|
||||
'import interface Data; interface View id "interface:device-view" revision "interface:device-view@1" { module render id "member:device-view:render" contract "org.quixos.react.subject-only/1" subject interface Data; }',
|
||||
{ source, environment: { interfaces: new Map([["Data", dataInterface]]), interfaceClosure: [dataInterface] } },
|
||||
);
|
||||
assert.ok(view.ok && view.resource.kind === "interface", JSON.stringify(view.diagnostics));
|
||||
const viewInterface = view.resource.revision;
|
||||
const ui = compileCapabilityResourceSource(
|
||||
'import interface Data; import interface View; package UI id "package:ui" revision "package:ui@1" { module Card id "component:card" implements View.render; }',
|
||||
{
|
||||
source,
|
||||
environment: {
|
||||
interfaces: new Map([
|
||||
["Data", dataInterface],
|
||||
["View", viewInterface],
|
||||
]),
|
||||
interfaceClosure: [dataInterface, viewInterface],
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(ui.ok && ui.resource.kind === "package", JSON.stringify(ui.diagnostics));
|
||||
for (const variant of ["base", "added", "gateway", "gateway-next", "session"]) {
|
||||
const session = variant === "session",
|
||||
@@ -56,13 +71,13 @@ for (const variant of ["base", "added", "gateway", "gateway-next", "session"]) {
|
||||
commit = { base: "a", added: "b", gateway: "c", "gateway-next": "d", session: "e" }[variant]!.repeat(40);
|
||||
const pkg = compileCapabilityResourceSource(
|
||||
`${gateway ? 'external atom Entry id "atom:entry";' : ""}
|
||||
${session ? 'external atom Independent id "atom:independent"; import interface Data;' : ""}
|
||||
${session ? 'external atom Independent id "atom:independent"; import interface Data; import interface View;' : ""}
|
||||
package Owner id "package:owner" revision "package:owner@1" {
|
||||
function identity id "identity" : int32 -> record { pid: int32; child: int32; } execution remote;
|
||||
${gateway ? 'function create id "create" : unit -> atom-ref<Entry> execution transaction targets [server] requires { creation entry id "entry:birth" : Entry input unit; };' : ""}
|
||||
${
|
||||
session
|
||||
? `component Editor id "component:owner-editor" subject Data runtime react;
|
||||
? `module Editor id "component:owner-editor" implements View.render;
|
||||
function createOne id "create-one" : string -> atom-ref<Entry> execution transaction targets [browser, server] requires { creation entry id "one:birth" : Entry input unit; };
|
||||
function createTwo id "create-two" : unit -> atom-ref<Independent> execution transaction targets [browser, server] requires { creation entry id "two:birth" : Independent input unit; };
|
||||
operation revise id "revise" : record {body:change<string>; notes:change<string>; reject:bool;} -> unit mode call execution transaction targets [browser, server] receiver any requires {
|
||||
@@ -78,7 +93,15 @@ package Owner id "package:owner" revision "package:owner@1" {
|
||||
{
|
||||
source,
|
||||
...(session
|
||||
? { environment: { interfaces: new Map([["Data", dataInterface]]), interfaceClosure: [dataInterface] } }
|
||||
? {
|
||||
environment: {
|
||||
interfaces: new Map([
|
||||
["Data", dataInterface],
|
||||
["View", viewInterface],
|
||||
]),
|
||||
interfaceClosure: [dataInterface, viewInterface],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
@@ -107,7 +130,7 @@ package Owner id "package:owner" revision "package:owner@1" {
|
||||
${session ? "import package UI;" : ""}
|
||||
${
|
||||
session
|
||||
? `import interface Data; import interface Factories;
|
||||
? `import interface Data; import interface View; import interface Factories;
|
||||
conform Entry as Factories id "owner:factories" { bind createOne.call to package Owner.createOne with { entry to creation Entry with {}; }; bind createTwo.call to package Owner.createTwo with { entry to creation Independent with {}; }; }
|
||||
${["Entry", "Independent"].map((atom) => `conform ${atom} as Data id "owner:${atom}:data" { private state ${atom}Label id "label:${atom}" on ${atom} : string policy register default "${atom}"; bind label.get to state ${atom}Label.read; bind label.set to state ${atom}Label.write; private state ${atom}Body id "body:${atom}" on ${atom} : string policy crdt(string) default "hello 🌍"; private state ${atom}Notes id "notes:${atom}" on ${atom} : string policy crdt(string) default "notes"; bind body.get to state ${atom}Body.read; bind body.edit to state ${atom}Body.edit; bind notes.get to state ${atom}Notes.read; bind notes.edit to state ${atom}Notes.edit; bind revise.call to package Owner.revise with {body to state ${atom}Body; notes to state ${atom}Notes;}; }`).join("\n")}
|
||||
`
|
||||
@@ -137,11 +160,15 @@ package Owner id "package:owner" revision "package:owner@1" {
|
||||
...(session
|
||||
? ([
|
||||
["Data", dataInterface],
|
||||
["View", viewInterface],
|
||||
["Factories", factoriesInterface],
|
||||
] as const)
|
||||
: []),
|
||||
]),
|
||||
interfaceClosure: [factory.resource.revision, ...(session ? [dataInterface, factoriesInterface] : [])],
|
||||
interfaceClosure: [
|
||||
factory.resource.revision,
|
||||
...(session ? [dataInterface, viewInterface, factoriesInterface] : []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import interface Device;
|
||||
external atom Display id "device:display";
|
||||
interface DeviceView id "device-view" revision "device-view@1" {
|
||||
module render id "device-view:render" contract "org.quixos.react.subject-only/1" subject interface Device;
|
||||
}
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import interface DeviceView;
|
||||
import interface Device;
|
||||
external atom Display id "device:display";
|
||||
package DeviceViewer id "device-viewer" revision "device-viewer@1" {
|
||||
component Status id "component:device:status" subject Device runtime react;
|
||||
module Status id "component:device:status" implements DeviceView.render;
|
||||
query Inputs id "device-viewer:inputs" root Device document "viewer.graphql" operation "Status" {replica;}
|
||||
query View id "device-viewer:view" root Device document "viewer.graphql" operation "Status" {local from Inputs;watch;}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
compileCapabilityResourceSource,
|
||||
compileCapabilitySource,
|
||||
formatQx,
|
||||
} from "../src/capability-language/index.js";
|
||||
import {
|
||||
validateWorkspaceRevision,
|
||||
compileWorkspaceRevision,
|
||||
computeCapabilityClosure,
|
||||
capabilityId,
|
||||
type InterfaceRevision,
|
||||
} from "../src/capability-model/index.js";
|
||||
import { compileExecutionPlan, checkExecutionWorld } from "../src/execution-world/index.js";
|
||||
|
||||
const source = { repository: "https://example.test/modules.git", commit: "a".repeat(40) };
|
||||
const interfaces = new Map<string, InterfaceRevision>();
|
||||
function define(text: string) {
|
||||
const result = compileCapabilityResourceSource(text, {
|
||||
source,
|
||||
environment: { interfaces, interfaceClosure: [...interfaces.values()] },
|
||||
});
|
||||
assert.ok(result.ok && result.resource.kind === "interface", JSON.stringify(result.diagnostics));
|
||||
interfaces.set(result.resource.revision.displayName, result.resource.revision);
|
||||
}
|
||||
define('interface Data id "interface:data" revision "interface:data@1" {}');
|
||||
define('interface Other id "interface:other" revision "interface:other@1" {}');
|
||||
define(`import interface Data;
|
||||
interface Row id "interface:row" revision "interface:row@1" {
|
||||
module render id "member:row:render" contract "org.quixos.react.component/1" subject interface Data;
|
||||
}`);
|
||||
define(`interface Standalone<object Subject> id "interface:standalone" revision "interface:standalone@1" {
|
||||
module render id "member:standalone:render" contract "org.quixos.react.subject-only/1" subject object Subject;
|
||||
}`);
|
||||
const packageSource = `import interface Row; import interface Data; import interface Standalone;
|
||||
package UI id "package:ui" revision "package:ui@1" {
|
||||
module Row id "module:row" implements Row.render;
|
||||
module Editor id "module:editor" implements Standalone<interface Data>.render;
|
||||
}`;
|
||||
function compile(
|
||||
body = `conform Item as Data id "owner:data" {}
|
||||
conform Item as Row id "owner:row" {bind render to UI.Row;}
|
||||
conform Item as Standalone<interface Data> id "owner:standalone" {bind render to UI.Editor;}`,
|
||||
) {
|
||||
const pkg = compileCapabilityResourceSource(packageSource, {
|
||||
source,
|
||||
environment: { interfaces, interfaceClosure: [...interfaces.values()] },
|
||||
});
|
||||
assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics));
|
||||
return compileCapabilitySource(
|
||||
`workspace Demo id "workspace:demo" revision "workspace:demo@1" commit "${source.commit}" {
|
||||
import interface Data; import interface Other; import interface Row; import interface Standalone; import package UI;
|
||||
atom Item id "atom:item";
|
||||
${body}
|
||||
}`,
|
||||
"workspace.qx",
|
||||
{
|
||||
interfaces,
|
||||
interfaceClosure: [...interfaces.values()],
|
||||
packages: new Map([["UI", pkg.resource.revision]]),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("module members bind independent roles, including closed generic subjects, without operations or storage", () => {
|
||||
const result = compile();
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
const pkg = result.workspace.packageImports[0]!;
|
||||
assert.equal(pkg.exports.length, 0);
|
||||
assert.equal(pkg.modules?.length, 2);
|
||||
const roles = result.workspace.conformances.filter((entry) => entry.moduleBindings?.length);
|
||||
assert.equal(roles.length, 2);
|
||||
for (const role of roles) {
|
||||
assert.deepEqual(role.operationBindings, []);
|
||||
assert.deepEqual(role.privateAttachments, []);
|
||||
const iface: InterfaceRevision = result.workspace.interfaceImports.find(
|
||||
(entry) => entry.revisionId === role.interfaceRevisionId,
|
||||
)!;
|
||||
assert.equal(iface.members[0]!.kind, "module");
|
||||
assert.deepEqual(iface.members[0]!.operations, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("module bindings reject missing, duplicate and wrong nominal roles and unsatisfied subjects", () => {
|
||||
for (const body of [
|
||||
"conform Item as Data {} conform Item as Row {}",
|
||||
"conform Item as Data {} conform Item as Row {bind render to UI.Editor;}",
|
||||
"conform Item as Data {} conform Item as Row {bind render to UI.Row; bind render to UI.Row;}",
|
||||
"conform Item as Other {} conform Item as Row {bind render to UI.Row;}",
|
||||
"conform Item as Data {} conform Item as Row {bind render to UI.Missing;}",
|
||||
]) {
|
||||
const result = compile(body);
|
||||
assert.equal(result.ok, false, body);
|
||||
if (!result.ok) assert.match(JSON.stringify(result.diagnostics), /module/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("module declarations and bindings round-trip through the ordinary formatter", () => {
|
||||
const formatted = formatQx(packageSource);
|
||||
assert.equal(formatQx(formatted), formatted);
|
||||
const result = compileCapabilityResourceSource(formatted, {
|
||||
source,
|
||||
environment: { interfaces, interfaceClosure: [...interfaces.values()] },
|
||||
});
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
});
|
||||
|
||||
test("the old React-specific package declaration is no longer a public language path", () => {
|
||||
const result = compileCapabilityResourceSource(
|
||||
'import interface Data; package UI id "package:ui" revision "package:ui@1" { component Card id "card" subject Data runtime react; }',
|
||||
{ source, environment: { interfaces, interfaceClosure: [...interfaces.values()] } },
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.diagnostics.some((diagnostic) => diagnostic.phase === "syntax"));
|
||||
});
|
||||
|
||||
test("module closure retains the provider and subject but no synthetic state", () => {
|
||||
const result = compile();
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
const compiled = compileWorkspaceRevision(result.workspace);
|
||||
assert.ok(compiled.ok);
|
||||
const closure = computeCapabilityClosure(compiled.plan, [
|
||||
{
|
||||
atomId: capabilityId.atom("atom:item"),
|
||||
interfaceRevisionId: capabilityId.interfaceRevision("interface:row@1"),
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(closure.packageRevisionIds, ["package:ui@1"]);
|
||||
assert.deepEqual(closure.attachmentIds, []);
|
||||
assert.deepEqual(
|
||||
new Set(closure.conformances.map((entry) => entry.interfaceRevisionId)),
|
||||
new Set(["interface:row@1", "interface:data@1"]),
|
||||
);
|
||||
});
|
||||
|
||||
test("unknown namespaced module contracts remain metadata and malformed contracts reject", () => {
|
||||
const result = compile();
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
const workspace = structuredClone(result.workspace);
|
||||
const iface: InterfaceRevision = workspace.interfaceImports.find((entry) => entry.revisionId === "interface:row@1")!;
|
||||
const member = iface.members[0]!;
|
||||
assert.equal(member.kind, "module");
|
||||
if (member.kind !== "module") throw Error("Expected module");
|
||||
member.contract = "example.device.firmware/2";
|
||||
assert.deepEqual(validateWorkspaceRevision(workspace), []);
|
||||
member.contract = "react";
|
||||
assert.ok(validateWorkspaceRevision(workspace).some((issue) => issue.code === "invalid-module-contract"));
|
||||
});
|
||||
|
||||
test("execution plans retain nominal module contracts without synthesizing callable operations", async () => {
|
||||
const result = compile();
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
const plan = compileExecutionPlan(result.workspace);
|
||||
assert.deepEqual(plan.definition.operations, []);
|
||||
const pkg = plan.definition.packages[0]!;
|
||||
assert.equal(pkg.modules?.length, 2);
|
||||
assert.deepEqual(pkg.modules?.find((module) => module.id === "module:editor")?.subject, {
|
||||
kind: "interface",
|
||||
interfaceRevisionId: "interface:data@1",
|
||||
});
|
||||
// A language declaration cannot silently pass installation without an artifact consumer.
|
||||
await assert.rejects(
|
||||
checkExecutionWorld(
|
||||
plan,
|
||||
[
|
||||
{
|
||||
schemaVersion: 1,
|
||||
packageRevisionId: pkg.revision,
|
||||
bindingDigest: pkg.bindingDigest,
|
||||
sourceDigest: pkg.sourceDigest,
|
||||
versions: plan.definition.versions,
|
||||
implementations: [],
|
||||
},
|
||||
],
|
||||
async () => {
|
||||
throw Error("Must reject before reading files");
|
||||
},
|
||||
),
|
||||
/Missing component artifact/,
|
||||
);
|
||||
});
|
||||
|
||||
test("unrecognized module consumers fail before artifact execution, not during interface compilation", async () => {
|
||||
const result = compile();
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
const workspace = structuredClone(result.workspace);
|
||||
const member = workspace.interfaceImports.find((iface) => iface.revisionId === "interface:row@1")!.members[0]!;
|
||||
assert.equal(member.kind, "module");
|
||||
if (member.kind !== "module") throw Error("Expected module");
|
||||
member.contract = "example.device.firmware/2";
|
||||
const plan = compileExecutionPlan(workspace);
|
||||
const pkg = plan.definition.packages[0]!;
|
||||
await assert.rejects(
|
||||
checkExecutionWorld(
|
||||
plan,
|
||||
[
|
||||
{
|
||||
schemaVersion: 1,
|
||||
packageRevisionId: pkg.revision,
|
||||
bindingDigest: pkg.bindingDigest,
|
||||
sourceDigest: pkg.sourceDigest,
|
||||
versions: plan.definition.versions,
|
||||
implementations: [],
|
||||
},
|
||||
],
|
||||
async () => {
|
||||
throw Error("Must reject before reading files");
|
||||
},
|
||||
),
|
||||
/No artifact consumer.*example.device.firmware/,
|
||||
);
|
||||
});
|
||||
+31
-11
@@ -39,8 +39,17 @@ const interfaces = new Map([
|
||||
["Data", data],
|
||||
["Other", other],
|
||||
]);
|
||||
const componentSource = (body = 'component Card id "component:card" subject Data runtime react;') =>
|
||||
`import interface Data; package UI id "package:ui" revision "package:ui@1" { ${body} }`;
|
||||
const role = compileCapabilityResourceSource(
|
||||
`import interface Data;
|
||||
interface View id "interface:view" revision "interface:view@1" {
|
||||
module render id "member:view:render" contract "org.quixos.react.subject-only/1" subject interface Data;
|
||||
}`,
|
||||
{ source, environment: { interfaces, interfaceClosure: [...interfaces.values()] } },
|
||||
);
|
||||
assert.ok(role.ok && role.resource.kind === "interface", JSON.stringify(role.diagnostics));
|
||||
interfaces.set("View", role.resource.revision);
|
||||
const componentSource = (body = 'module Card id "component:card" implements View.render;') =>
|
||||
`import interface Data; import interface View; package UI id "package:ui" revision "package:ui@1" { ${body} }`;
|
||||
const compilePackage = (body?: string) =>
|
||||
compileCapabilityResourceSource(componentSource(body), {
|
||||
source,
|
||||
@@ -51,7 +60,7 @@ const workspace = (presentation = 'presentation Card id "presentation:card" for
|
||||
assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics));
|
||||
return compileCapabilitySource(
|
||||
`workspace Example id "workspace:example" revision "workspace:example@1" commit "${source.commit}" {
|
||||
import interface Data; import interface Other; import package UI;
|
||||
import interface Data; import interface Other; import interface View; import package UI;
|
||||
atom One id "atom:one"; atom Two id "atom:two";
|
||||
conform One as Data id "conformance:one:data" { }
|
||||
conform Two as Data id "conformance:two:data" { }
|
||||
@@ -88,21 +97,21 @@ test("component declarations survive formatting and syntax inspection", () => {
|
||||
const text = formatQx(componentSource());
|
||||
const parsed = parseQx(text);
|
||||
assert.deepEqual(parsed.diagnostics, []);
|
||||
assert.ok([...walkSyntax(parsed.root)].some((node) => node.kind === "componentDecl"));
|
||||
assert.ok([...walkSyntax(parsed.root)].some((node) => node.kind === "moduleExport"));
|
||||
assert.equal(formatQx(text), text);
|
||||
});
|
||||
|
||||
test("component and presentation contracts reject wrong subjects, duplicates and unavailable runtimes", () => {
|
||||
for (const [input, message] of [
|
||||
['component Card id "component:card" subject Missing runtime react;', /Missing/],
|
||||
['component Card id "component:card" subject Data runtime arduino;', /Unsupported component runtime/],
|
||||
['module Card id "component:card" implements Missing.render;', /Missing/],
|
||||
['module Card id "component:card" implements Data.missing;', /module member/],
|
||||
[
|
||||
'component Card id "component:card" subject Data runtime react; component Other id "component:card" subject Data runtime react;',
|
||||
/Duplicate component/,
|
||||
'module Card id "component:card" implements View.render; module Other id "component:card" implements View.render;',
|
||||
/Duplicate or empty module/,
|
||||
],
|
||||
[
|
||||
'component Card id "component:card" subject Data runtime react; component Card id "component:other" subject Data runtime react;',
|
||||
/Duplicate component/,
|
||||
'module Card id "component:card" implements View.render; module Card id "component:other" implements View.render;',
|
||||
/Duplicate or empty module/,
|
||||
],
|
||||
] as const) {
|
||||
const result = compilePackage(input);
|
||||
@@ -131,6 +140,17 @@ test("model validation rechecks presentation metadata instead of trusting the so
|
||||
assert.throws(() => compileExecutionPlan(result.workspace), /no installed package component/);
|
||||
});
|
||||
|
||||
test("standalone presentation rejects a general component contract that may require extra props", () => {
|
||||
const result = workspace();
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
const candidate = structuredClone(result.workspace);
|
||||
const member = candidate.interfaceImports.find((iface) => iface.revisionId === "interface:view@1")!.members[0]!;
|
||||
assert.equal(member.kind, "module");
|
||||
if (member.kind !== "module") throw Error("Expected module");
|
||||
member.contract = "org.quixos.react.component/1";
|
||||
assert.ok(validateWorkspaceRevision(candidate).some((issue) => /subject-only/.test(issue.message)));
|
||||
});
|
||||
|
||||
test("checked component-only artifacts install without a service and reject missing or substituted entries", async () => {
|
||||
const result = workspace();
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
@@ -150,7 +170,7 @@ test("checked component-only artifacts install without a service and reject miss
|
||||
const body: Omit<ComponentArtifact, "contentDigest"> = {
|
||||
id: "component:card",
|
||||
subject: "interface:Data@1",
|
||||
compatibility: "quixos-react-component-v1",
|
||||
compatibility: "org.quixos.react.esm/1",
|
||||
path: paths[0]!,
|
||||
exportName: "Card",
|
||||
files,
|
||||
|
||||
Reference in New Issue
Block a user