diff --git a/src/capability-language/parser.ts b/src/capability-language/parser.ts index 8550a6d..66fca08 100644 --- a/src/capability-language/parser.ts +++ b/src/capability-language/parser.ts @@ -2799,6 +2799,13 @@ const compileCapabilityResourceSourceInternal = ( revision: lowerPackage(state, context, options.source), }; if (resource.revision.modules?.length) { + resource.revision.clientPackages = Object.fromEntries( + imports.flatMap((entry) => { + if (entry.kind !== "package") return []; + const imported = options.environment?.packages?.get(entry.binding); + return imported ? [[entry.binding, imported.revisionId]] : []; + }), + ); resource.revision.clientInterfaces = Object.fromEntries( imports.flatMap((entry) => { if (entry.kind !== "interface") return []; diff --git a/src/capability-model/types.ts b/src/capability-model/types.ts index c23f68f..58d4690 100644 --- a/src/capability-model/types.ts +++ b/src/capability-model/types.ts @@ -324,6 +324,8 @@ export interface PackageRevision { modules?: PackageModule[]; /** Closed interface imports exposed to this package's checked component bindings. */ clientInterfaces?: Record; + /** Explicit package imports supplying module selections, never the whole workspace. */ + clientPackages?: Record; controllers?: PackageController[]; queries?: import("../query/types.js").QueryDeclaration[]; checkedQueries?: import("../query/types.js").CheckedQuery[]; diff --git a/src/execution-world/client-contracts.ts b/src/execution-world/client-contracts.ts index 3dceb32..bbcbb67 100644 --- a/src/execution-world/client-contracts.ts +++ b/src/execution-world/client-contracts.ts @@ -99,6 +99,7 @@ export function linkPackageQuery(plan: ExecutionPlan, contract: PublicQueryContr export function generateInterfaceClientBindings( imports: Readonly>, runtimeModule = "@quixos/camino-replica-core", + moduleProps?: (interfaceId: string, memberId: string) => string, ): string { const entries = Object.entries(imports).map(([alias, iface]) => { if (iface.template) @@ -108,6 +109,10 @@ export function generateInterfaceClientBindings( "Specialize the interface before generating a client", ); const members = iface.members.map((member) => { + if (member.kind === "module" && member.subject?.kind === "interface") { + const subject = member.subject.interfaceRevisionId; + return `${JSON.stringify(member.displayName)}: defineModuleMember<${JSON.stringify(subject)}, ${moduleProps?.(iface.revisionId, member.id) ?? "{}"}>(${JSON.stringify({ interface: iface.revisionId, member: member.id, subject, contract: member.contract })})`; + } const operations = member.operations .filter((op) => op.mode === "call") .map( @@ -118,7 +123,7 @@ export function generateInterfaceClientBindings( }); return `${JSON.stringify(alias)}: {${members.join(",\n")}}`; }); - return `// Generated from imported interface contracts. Regenerate; do not edit.\nimport { defineInterfaceOperation, type PreparedChange, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const interfaces = {${entries.join(",\n")}} as const;\n`; + return `// Generated from imported interface contracts. Regenerate; do not edit.\nimport { defineModuleMember, defineInterfaceOperation, type PreparedChange, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const interfaces = {${entries.join(",\n")}} as const;\n`; } export function generatePackageQueryClientBindings( @@ -203,8 +208,10 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi Object.entries(pkg.clientInterfaces ?? {}).map(([alias, id]) => [alias, find(id)]), ); 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") + const props = (iface: string, member: string) => + `ExtraModuleProps<${JSON.stringify(JSON.stringify([iface, member]))}>`; + const interfaces = generateInterfaceClientBindings(imports, "@quixos/camino-react", props); + const subjectBindings = generateInterfaceClientBindings(subjects, "@quixos/camino-react", props) .split("\n") .filter((line) => !line.startsWith("import ")) .join("\n") @@ -219,10 +226,20 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi .join("\n"); return ( interfaces + - '\nimport {definePackageQuery,definePackageFragment,type ComponentType,type ComponentProps} from "@quixos/camino-react";\n' + + '\nimport {createObjectRenderer,definePackageQuery,definePackageFragment,type ComponentType,type ComponentProps} from "@quixos/camino-react";\n' + subjectBindings + "\n" + queries + + `\nexport const providers = {${Object.entries(pkg.clientPackages ?? {}) + .map(([alias, id]) => { + const imported = plan.definition.workspace.packageImports.find((entry) => entry.revisionId === id); + if (!imported) throw new ExecutionPlanError("UNINSTALLED_PACKAGE", id, "Missing imported module provider"); + return `${JSON.stringify(alias)}:{${(imported.modules ?? []).map((module) => `${JSON.stringify(module.displayName)}:${JSON.stringify({ packageRevision: id, id: module.id })}`).join(",")}}`; + }) + .join(",")}} as const;\n` + + `\nexport const renderers = {${Object.entries(imports) + .map(([name, iface]) => `${JSON.stringify(name)}:createObjectRenderer(${JSON.stringify(iface.revisionId)})`) + .join(",\n")}} as const;\n` + "\nexport interface ModuleProps {}\n" + "export type ExtraModuleProps = K extends keyof ModuleProps ? ModuleProps[K] : {};\n" + "export interface Modules {\n" + diff --git a/src/execution-world/installation.ts b/src/execution-world/installation.ts index 116d330..0adc7f5 100644 --- a/src/execution-world/installation.ts +++ b/src/execution-world/installation.ts @@ -29,6 +29,13 @@ export interface ClientContractCatalog { readonly queries: readonly PublicQueryContract[]; readonly components: readonly InstalledComponent[]; readonly presentations: readonly { id: string; subject: string; packageRevision: string; component: string }[]; + readonly moduleBindings: readonly { + atom: string; + interface: string; + member: string; + packageRevision: string; + id: string; + }[]; } /** Explicit projection prevents accidental spreading of filesystem roots or private bindings. */ export function clientContractCatalog( @@ -68,6 +75,17 @@ export function clientContractCatalog( (pkg.checkedQueries ?? []).map((q) => publicQueryContract(pkg, q.declaration.id)), ), components: installation.components.filter((c) => requestedPackages.includes(c.packageRevision)), + moduleBindings: plan.definition.workspace.conformances.flatMap((c) => + (c.moduleBindings ?? []) + .filter((b) => requestedPackages.includes(b.packageRevisionId)) + .map((b) => ({ + atom: c.atomId, + interface: c.interfaceRevisionId, + member: b.memberId, + packageRevision: b.packageRevisionId, + id: b.moduleId, + })), + ), presentations: (plan.definition.presentations ?? []) .filter((p) => requestedPackages.includes(p.packageRevisionId)) .map((p) => ({ id: p.id, subject: p.subject, packageRevision: p.packageRevisionId, component: p.componentId })), @@ -99,6 +117,12 @@ export interface ExecutionInstallation { } /** Safe to expose in a presentation catalog: no source paths, state slots, or host configuration. */ export interface InstalledComponent { + readonly role: { + readonly interface: string; + readonly member: string; + readonly subject: string; + readonly contract: string; + }; readonly packageRevision: string; readonly id: string; readonly subject: string; @@ -176,6 +200,17 @@ function assembleExecutionInstallation( routes[url] ??= { path: path.join(root, file.path), mediaType: file.mediaType }; } components.push({ + role: (() => { + const module = plan.definition.packages + .find((p) => p.revision === revision)! + .modules!.find((m) => m.id === component.id)!; + return { + interface: module.interfaceRevisionId, + member: module.memberId, + subject: component.subject, + contract: module.contract, + }; + })(), packageRevision: revision, id: component.id, subject: component.subject,