diff --git a/src/capability-language/assembly.ts b/src/capability-language/assembly.ts index 14c878c..332bca0 100644 --- a/src/capability-language/assembly.ts +++ b/src/capability-language/assembly.ts @@ -46,7 +46,7 @@ export type CapabilityRepositoryResolver = ( /** An explicitly selected build frontend supplies ordinary query sources. The * assembler never chooses a frontend by inspecting an interface/contract ID. * Generated sources go through the same query and imported-fragment checks. */ -export type PackageQueryGenerator = (input: { +export type PackageQueryGenerator = ((input: { readonly package: Readonly; readonly interfaces: readonly InterfaceRevision[]; readonly readSource: (name: string) => Promise; @@ -54,7 +54,12 @@ export type PackageQueryGenerator = (input: { readonly queries: readonly QueryDeclaration[]; readonly documents: ReadonlyMap; readonly compositions?: readonly QueryComposition[]; -}>; + readonly artifacts?: Readonly>; +}>) & { + /** Optional explicit frontend stage after workspace binding resolution. + * Its output still passes normal workspace/query validation. */ + completeWorkspace?: (workspace: WorkspaceRevision) => Promise; +}; export type ResolvedCapabilityResource = { key: string; @@ -297,6 +302,7 @@ const createResourceGraphResolver = ( readSource: (name) => readRepositorySource(snapshot.directory, name), }); if (generated) { + if (generated.artifacts) compiled.resource.revision.buildArtifacts = structuredClone(generated.artifacts); const queries = [...(compiled.resource.revision.queries ?? [])]; for (const query of generated.queries) { if (queries.some((entry) => entry.id === query.id || entry.displayName === query.displayName)) @@ -534,6 +540,7 @@ export const compileWorkspaceRepository = async (options: { : {}), ...(options.sourceRootCommit ? { sourceRootCommit: options.sourceRootCommit } : {}), }; + await options.generateQueries?.completeWorkspace?.(workspace); const checked = compileWorkspaceRevision(workspace); if (!checked.ok) { throw new Error( diff --git a/src/capability-language/component-scaffold.ts b/src/capability-language/component-scaffold.ts index a5bacb9..7d1723b 100644 --- a/src/capability-language/component-scaffold.ts +++ b/src/capability-language/component-scaffold.ts @@ -59,12 +59,26 @@ export async function scaffoldComponent( const configBefore = await fs.readFile(path.join(root, configFile), "utf8"); const config = JSON.parse(configBefore); config.queryFrontends = [...new Set([...(config.queryFrontends ?? []), "react"])]; + const preparationFile = prefix + "quixos.react.json"; + let preparationBefore: string | undefined; + try { + preparationBefore = await fs.readFile(path.join(root, preparationFile), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const preparation = preparationBefore ? JSON.parse(preparationBefore) : { modules: {} }; + if (Object.hasOwn(preparation.modules, spec.name)) throw Error("Duplicate presentation preparation"); + preparation.modules[spec.name] = { inputs: [] }; + const preparationText = JSON.stringify(preparation, null, 2) + "\n"; return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files: [ + preparationBefore === undefined + ? { file: preparationFile, create: preparationText } + : { file: preparationFile, expected: preparationBefore, replace: preparationText }, { file: configFile, expected: configBefore, replace: JSON.stringify(config, null, 2) + "\n" }, { file: prefix + "package.qx", @@ -79,7 +93,7 @@ export async function scaffoldComponent( { file: prefix + filename, expected: before, replace: after }, { file: prefix + `src/components/${spec.name}.tsx`, - create: `import type { Modules } from "../gen/client.js";\n\n// Add explicit query admission and named interface operations from the generated client.\nexport const ${spec.name}: Modules[${JSON.stringify(spec.name)}] = ({subject}) =>

{subject.object}

;\n`, + create: `import type { Modules } from "../gen/client.js";\n\n// Declare initial query inputs in quixos.react.json; use interfaces for actions.\nexport const ${spec.name}: Modules[${JSON.stringify(spec.name)}] = ({subject}) =>

{subject.object}

;\n`, }, ], }; diff --git a/src/capability-language/query-generator.ts b/src/capability-language/query-generator.ts index ab7ce45..25d57fe 100644 --- a/src/capability-language/query-generator.ts +++ b/src/capability-language/query-generator.ts @@ -9,5 +9,10 @@ export async function loadQueryGenerator(filename?: string): Promise; modules?: PackageModule[]; /** Closed interface imports exposed to this package's checked component bindings. */ clientInterfaces?: Record; diff --git a/src/execution-world/client-contracts.ts b/src/execution-world/client-contracts.ts index 5f7cae6..cd0b676 100644 --- a/src/execution-world/client-contracts.ts +++ b/src/execution-world/client-contracts.ts @@ -251,7 +251,7 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi .join("\n"); return ( interfaces + - '\nimport {createObjectRenderer,definePackageQuery,definePackageFragment,type ComponentType,type ComponentProps} from "@quixos/camino-react";\n' + + '\nimport {createObjectRenderer,definePackageQuery,definePackageFragment,type ComponentType,type ComponentProps,type PreparedPresentation} from "@quixos/camino-react";\n' + subjectBindings + "\n" + queries + @@ -272,7 +272,27 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi .map((component) => { const module = modules.find((module) => module.id === component.id)!; const key = JSON.stringify([module.interfaceRevisionId, module.memberId]); - return `${JSON.stringify(component.displayName)}: ComponentType & ExtraModuleProps<${JSON.stringify(key)}>>;`; + const artifact = pkg.buildArtifacts?.["org.quixos.react.preparation/1"] as + | import("../react-build/declarations.js").ReactPreparationArtifact + | undefined; + const preparation = artifact?.modules.find((entry) => entry.module === module.id); + const composition = pkg.queryCompositions?.find((entry) => entry.id === preparation?.composition); + if (!preparation || !composition) + throw new ExecutionPlanError("MISSING_PRESENTATION_INPUTS", module.id, "Declare inputs in quixos.react.json"); + const children = preparation.children.map((child) => { + let extra = "{}"; + if (child.role) { + const iface = find(child.role.interface); + const member = iface.members.find( + (entry) => entry.id === child.role!.member || entry.displayName === child.role!.member, + )!; + extra = props(iface.revisionId, member.id); + } + const handle = `PreparedPresentation<${JSON.stringify(child.subject)}, ${extra}>`; + return `readonly ${JSON.stringify(child.name)}: ${child.each ? `readonly ${handle}[]` : child.with || child.when ? `${handle} | null` : handle}`; + }); + const input = `${tsType(composition.output)} & {readonly children: {${children.join(";")}}}`; + return `${JSON.stringify(component.displayName)}: ComponentType & ExtraModuleProps<${JSON.stringify(key)}>>;`; }) .join("\n") + "\n}\n" diff --git a/src/execution-world/installation.ts b/src/execution-world/installation.ts index 34d9f5e..4787af7 100644 --- a/src/execution-world/installation.ts +++ b/src/execution-world/installation.ts @@ -138,6 +138,8 @@ export interface InstalledComponent { readonly exportName: string; readonly styles: readonly string[]; readonly compatibility: ComponentArtifact["compatibility"]; + /** Owned and interpreted by the React artifact consumer. */ + readonly preparation?: unknown; readonly sharedModules: Readonly>; } /** Build-time validation uses the compiler selected by the Nix derivation. */ @@ -208,6 +210,14 @@ function assembleExecutionInstallation( routes[url] ??= { path: path.join(root, file.path), mediaType: file.mediaType }; } components.push({ + ...(() => { + const pkg = plan.definition.workspace.packageImports.find((entry) => entry.revisionId === revision); + const artifact = pkg?.buildArtifacts?.["org.quixos.react.preparation/1"] as + | { modules: { module: string }[] } + | undefined; + const preparation = artifact?.modules.find((entry) => entry.module === component.id); + return preparation ? { preparation } : {}; + })(), role: (() => { const module = plan.definition.packages .find((p) => p.revision === revision)! diff --git a/src/query/composition.ts b/src/query/composition.ts index bcfa0e5..5c472a7 100644 --- a/src/query/composition.ts +++ b/src/query/composition.ts @@ -6,6 +6,7 @@ import { QueryCompileError, type CheckedQuery } from "./types.js"; /** Bindings refer to checked values, never executable callbacks. A path cannot * silently cross a nullable value or expand an unbounded collection. */ export type QueryBinding = + | { flatMap: { source: QueryBinding; path: string[] } } | { literal: string | boolean } | { equal: [QueryBinding, QueryBinding] } | { from: "input"; path: string[] } @@ -54,37 +55,8 @@ export function compileQueryComposition( const outputs = new Map(); const dependencies = new Map(); let count = 0; - function binding(expression: QueryBinding, item?: ValueType): ValueType { - if ("literal" in expression) - return { kind: "scalar", name: typeof expression.literal === "string" ? "string" : "bool" }; - if ("equal" in expression) { - const left = binding(expression.equal[0], item); - const right = binding(expression.equal[1], item); - if (left.kind !== "scalar" || !equal(left, right)) fail("equal requires matching scalar operands"); - return { kind: "scalar", name: "bool" }; - } - if ("record" in expression) - return { - kind: "record", - fields: Object.fromEntries( - Object.entries(expression.record).map(([key, value]) => [key, binding(value, item)]), - ), - }; - let type = - expression.from === "input" ? source.input : expression.from === "item" ? item : outputs.get(expression.name); - if (!type) - return fail( - expression.from === "result" - ? `result ${expression.name} must name an earlier step (forward references and cycles are unsupported)` - : "item is only available within a bounded each step", - ); - for (const key of expression.path) { - if (type.kind !== "record" || !Object.hasOwn(type.fields, key)) - return fail(`cannot select ${key} from ${type.kind}; nulls and collections need an explicit boundary`); - type = type.fields[key]!; - } - return type; - } + const binding = (expression: QueryBinding, item?: ValueType) => + queryBindingType(expression, source.input, outputs, item); const equal = (a: ValueType, b: ValueType) => canonicalJson(a) === canonicalJson(b); for (const step of source.steps) { if (!step.name || outputs.has(step.name)) fail(`duplicate or empty step name ${step.name}`); @@ -137,3 +109,49 @@ export function compileQueryComposition( }; return { ...result, definitionDigest: createHash("sha256").update(canonicalJson(result)).digest("hex") }; } + +export function queryBindingType( + expression: QueryBinding, + input: ValueType, + outputs: ReadonlyMap, + item?: ValueType, +): ValueType { + const fail = (message: string): never => { + throw new QueryCompileError("QUERY_COMPOSITION", message); + }; + const binding = (expression: QueryBinding, item?: ValueType) => queryBindingType(expression, input, outputs, item); + const equal = (a: ValueType, b: ValueType) => canonicalJson(a) === canonicalJson(b); + if ("flatMap" in expression) { + const rows = binding(expression.flatMap.source, item); + if (rows.kind !== "list") return fail("flatMap requires a list"); + const children = binding({ from: "item", path: expression.flatMap.path }, rows.value); + if (children.kind !== "list") return fail("flatMap path requires a list"); + return children; + } + if ("literal" in expression) + return { kind: "scalar", name: typeof expression.literal === "string" ? "string" : "bool" }; + if ("equal" in expression) { + const left = binding(expression.equal[0], item); + const right = binding(expression.equal[1], item); + if (left.kind !== "scalar" || !equal(left, right)) fail("equal requires matching scalar operands"); + return { kind: "scalar", name: "bool" }; + } + if ("record" in expression) + return { + kind: "record", + fields: Object.fromEntries(Object.entries(expression.record).map(([key, value]) => [key, binding(value, item)])), + }; + let type = expression.from === "input" ? input : expression.from === "item" ? item : outputs.get(expression.name); + if (!type) + return fail( + expression.from === "result" + ? `result ${expression.name} must name an earlier step (forward references and cycles are unsupported)` + : "item is only available within a bounded each step", + ); + for (const key of expression.path) { + if (type.kind !== "record" || !Object.hasOwn(type.fields, key)) + return fail(`cannot select ${key} from ${type.kind}; nulls and collections need an explicit boundary`); + type = type.fields[key]!; + } + return type; +} diff --git a/src/react-build/declarations.ts b/src/react-build/declarations.ts new file mode 100644 index 0000000..42fd8ab --- /dev/null +++ b/src/react-build/declarations.ts @@ -0,0 +1,127 @@ +import type { InterfaceRevision, PackageRevision, ValueType } from "../capability-model/types.js"; +import type { QueryBinding, QueryComposition, QueryCompositionStep } from "../query/composition.js"; + +/** This is an artifact of the explicitly selected React frontend, not QX IR. */ +export const preparationArtifact = "org.quixos.react.preparation/1"; +export interface ReactChildDeclaration { + name: string; + subject: string; + root: QueryBinding; + slot: string; + with?: QueryBinding; + each?: { source: QueryBinding; limit: number }; + when?: QueryBinding; + role?: { interface: string; member: string }; + provider?: { package: string; module: string }; +} +export interface ReactModulePreparation { + module: string; + composition: string; + discoveryComposition?: string; + children: ReactChildDeclaration[]; +} +export interface ReactPreparationArtifact { + modules: ReactModulePreparation[]; +} + +/** Names are package imports/module declarations. All query bindings are passed + * to Camino's ordinary composition checker after generated queries are checked. */ +export function presentationDeclarations( + text: string, + pkg: PackageRevision, + interfaces: readonly InterfaceRevision[], +): { compositions: QueryComposition[]; artifact: ReactPreparationArtifact } { + const source: unknown = JSON.parse(text); + const object = (value: unknown, label: string): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw Error(`${label}: expected an object`); + return value as Record; + }; + const top = object(source, "quixos.react.json"); + if (Object.keys(top).some((key) => key !== "modules")) throw Error("quixos.react.json: unknown field"); + const modules = object(top.modules, "modules"); + const compositions: QueryComposition[] = []; + const artifact: ReactPreparationArtifact = { modules: [] }; + const imported = (name: string) => { + const id = pkg.clientInterfaces?.[name]; + if (!id) throw Error(`React declaration: unknown imported interface ${name}`); + return id; + }; + for (const [name, value] of Object.entries(modules)) { + const declaration = object(value, `module ${name}`); + if (Object.keys(declaration).some((key) => !["inputs", "children"].includes(key))) + throw Error(`${name}: unknown preparation field`); + const module = pkg.modules?.find((entry) => entry.displayName === name); + if (!module) throw Error(`React declaration: unknown module ${name}`); + const contract = interfaces.find((entry) => entry.revisionId === module.interfaceRevisionId); + const member = contract?.members.find((entry) => entry.id === module.memberId); + if ( + member?.kind !== "module" || + member.subject?.kind !== "interface" || + !["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes( + member.contract, + ) + ) + throw Error(`${name}: preparation requires a React module contract`); + const fields: Record = { + subject: { + kind: "object-ref", + expectation: { kind: "interface", interfaceRevisionId: member.subject.interfaceRevisionId }, + }, + }; + if (member.contract === "org.quixos.react.component/2") { + const state = contract?.application?.arguments[1]; + if (state?.kind !== "object" || state.target.kind !== "interface") + throw Error(`${name}: expected a closed state interface`); + fields.navigation = { + kind: "object-ref", + expectation: { kind: "interface", interfaceRevisionId: state.target.interfaceRevisionId }, + }; + } + if (!Array.isArray(declaration.inputs)) throw Error(`${name}: inputs must be an array of query bindings`); + const steps = declaration.inputs.map((value) => { + const step = object(value, `${name} input`) as unknown as QueryCompositionStep; + if (step.name === "children") throw Error(`${name}: children is reserved for prepared child inputs`); + const query = pkg.queries?.find((entry) => entry.displayName === step.query); + if (!query) throw Error(`${name}.${step.name}: unknown query ${step.query}`); + if (query.placement?.kind !== "local" && !query.fragment) + throw Error(`${name}.${step.name}: requires a local projection or fragment`); + return { ...step, query: query.id }; + }); + const composition = `react:inputs:${module.id}`; + compositions.push({ + id: composition, + input: { kind: "record", fields }, + steps, + maxQueries: steps.reduce((count, step) => count + (step.each?.limit ?? 1), 0) || 1, + }); + const children = declaration.children ?? []; + if (!Array.isArray(children)) throw Error(`${name}: children must be an array`); + const names = new Set(); + const parsed = children.map((value) => { + const child = object(value, `${name} child`) as unknown as ReactChildDeclaration; + if (!child.name || names.has(child.name) || !child.slot || !child.root) + throw Error(`${name}: child requires a unique name, slot and root`); + names.add(child.name); + if (child.each && child.with) throw Error(`${name}.${child.name}: cannot combine each and with`); + if (child.each && (!Number.isSafeInteger(child.each.limit) || child.each.limit < 1)) + throw Error(`${name}.${child.name}: finite positive collection limit required`); + return { + ...child, + subject: imported(child.subject), + ...(child.role ? { role: { ...child.role, interface: imported(child.role.interface) } } : {}), + }; + }); + artifact.modules.push({ module: module.id, composition, children: parsed }); + } + for (const module of pkg.modules ?? []) { + const contract = interfaces.find((entry) => entry.revisionId === module.interfaceRevisionId); + const member = contract?.members.find((entry) => entry.id === module.memberId); + if ( + member?.kind === "module" && + member.contract.startsWith("org.quixos.react.") && + !artifact.modules.some((entry) => entry.module === module.id) + ) + throw Error(`React module ${module.displayName} has no preparation declaration`); + } + return { compositions, artifact }; +} diff --git a/src/react-build/expansion.ts b/src/react-build/expansion.ts new file mode 100644 index 0000000..823b74b --- /dev/null +++ b/src/react-build/expansion.ts @@ -0,0 +1,420 @@ +import { createHash } from "node:crypto"; +import { + parse, + print, + visit, + Kind, + type SelectionSetNode, + type FragmentDefinitionNode, + type SelectionNode, + type DocumentNode, +} from "graphql"; +import type { PackageRevision, WorkspaceRevision } from "../capability-model/types.js"; +import { compileQuery } from "../query/compile.js"; +import { compileQueryComposition, type QueryBinding } from "../query/composition.js"; +import type { CheckedQuery, QueryDeclaration } from "../query/types.js"; +import { preparationArtifact, type ReactModulePreparation, type ReactPreparationArtifact } from "./declarations.js"; + +interface Branch { + packageRevision: string; + id: string; +} +type Child = ReactModulePreparation["children"][number] & { branches: Branch[] }; +interface Module { + pkg: PackageRevision; + preparation: ReactModulePreparation; +} +const identity = (pkg: string, id: string) => JSON.stringify([pkg, id]); +const suffix = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 20); + +/** Lower bounded, statically bound subject projections into ordinary fragments. + * The generated query still passes the normal GraphQL/QX checker. Stateful + * lookups remain independent ordinary root-scoped queries owned by the adapter. */ +export async function expandPresentationQueries(workspace: WorkspaceRevision): Promise { + const modules = new Map(); + for (const pkg of workspace.packageImports) { + const artifact = pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined; + for (const preparation of artifact?.modules ?? []) + modules.set(identity(pkg.revisionId, preparation.module), { pkg, preparation }); + } + const query = (pkg: string, id: string) => { + const result = workspace.packageImports + .find((entry) => entry.revisionId === pkg) + ?.checkedQueries?.find((entry) => entry.declaration.id === id); + if (!result) throw Error(`Missing checked preparation query ${pkg}/${id}`); + return result; + }; + const resolver = async (pkg: string, id: string) => query(pkg, id); + const install = async (pkg: PackageRevision, declaration: QueryDeclaration, document: string) => { + const views = new Map(declaration.views.map((view) => [view.atomId, view])); + const allowances = [...declaration.allowances]; + for (const imported of declaration.importedFragments ?? []) { + const dependency = query(imported.packageRevisionId, imported.fragmentId).declaration; + for (const view of dependency.views) { + const previous = views.get(view.atomId); + if (previous && previous.interfaceRevisionId !== view.interfaceRevisionId) + throw Error(`${declaration.displayName}: conflicting interface views for ${view.atomId}`); + views.set(view.atomId, view); + } + for (const allowance of dependency.allowances) + if (!allowances.some((entry) => JSON.stringify(entry) === JSON.stringify(allowance))) + allowances.push(allowance); + } + declaration = { ...declaration, views: [...views.values()], allowances }; + const checked = await compileQuery(declaration, workspace.interfaceImports, async () => document, resolver); + pkg.queries = [...(pkg.queries ?? []).filter((entry) => entry.id !== declaration.id), declaration]; + pkg.checkedQueries = [ + ...(pkg.checkedQueries ?? []).filter((entry) => entry.declaration.id !== declaration.id), + checked, + ]; + return checked; + }; + function expanded(source: CheckedQuery): SelectionSetNode { + const document = parse(source.document); + const fragments = new Map( + document.definitions + .filter((entry): entry is FragmentDefinitionNode => entry.kind === Kind.FRAGMENT_DEFINITION) + .map((entry) => [entry.name.value, entry]), + ); + const operation = document.definitions.find((entry) => entry.kind === Kind.OPERATION_DEFINITION); + if (!operation || operation.kind !== Kind.OPERATION_DEFINITION) + throw Error(`Missing query operation ${source.declaration.id}`); + const expand = (selection: SelectionSetNode, parents: string[] = []): SelectionSetNode => ({ + ...selection, + selections: selection.selections.flatMap((entry): SelectionNode[] => { + if (entry.kind === Kind.FRAGMENT_SPREAD) { + if (parents.includes(entry.name.value)) throw Error("Cyclic query fragment"); + const fragment = fragments.get(entry.name.value); + if (!fragment) throw Error(`Missing query fragment ${entry.name.value}`); + return [ + { + kind: Kind.INLINE_FRAGMENT, + typeCondition: fragment.typeCondition, + directives: entry.directives ?? [], + selectionSet: expand(fragment.selectionSet, [...parents, entry.name.value]), + }, + ]; + } + return [{ ...entry, ...(entry.selectionSet ? { selectionSet: expand(entry.selectionSet, parents) } : {}) }]; + }), + }); + return expand(operation.selectionSet); + } + function insert(selection: SelectionSetNode, path: readonly string[], spread: string): SelectionSetNode { + if (!path.length) + return { + ...selection, + selections: [...selection.selections, { kind: Kind.FRAGMENT_SPREAD, name: { kind: Kind.NAME, value: spread } }], + }; + let found = false; + const selections = selection.selections.map((entry): SelectionNode => { + if (entry.kind === Kind.INLINE_FRAGMENT) { + try { + const nested = insert(entry.selectionSet, path, spread); + found = true; + return { ...entry, selectionSet: nested }; + } catch (error) { + if (!(error instanceof Error) || error.message !== "REACT_SELECTION_PATH") throw error; + } + } + if (entry.kind === Kind.FIELD && (entry.alias?.value ?? entry.name.value) === path[0] && entry.selectionSet) { + found = true; + return { ...entry, selectionSet: insert(entry.selectionSet, path.slice(1), spread) }; + } + return entry; + }); + if (!found) throw Error("REACT_SELECTION_PATH"); + return { ...selection, selections }; + } + function location(binding: QueryBinding): { name: string; path: string[] } | undefined { + if ("from" in binding && binding.from === "result") return { name: binding.name, path: [...binding.path] }; + if ("flatMap" in binding) { + const root = location(binding.flatMap.source); + return root ? { name: root.name, path: [...root.path, ...binding.flatMap.path] } : undefined; + } + return undefined; + } + const done = new Set(); + const eager = new Map(); + const admissionFragments = new Map(); + const stateInputs = new Map(); + async function expandModule(key: string): Promise { + if (done.has(key)) return; + const module = modules.get(key); + if (!module) throw Error(`Missing preparation module ${key}`); + const { pkg, preparation } = module; + const children = preparation.children as Child[]; + for (const child of children) + for (const branch of child.branches) await expandModule(identity(branch.packageRevision, branch.id)); + const composition = pkg.queryCompositions!.find((entry) => entry.id === preparation.composition)!; + const discovery = { ...composition, id: composition.id + ":discovery" }; + preparation.discoveryComposition = discovery.id; + pkg.queryCompositions!.push(discovery); + const steps = structuredClone(composition.steps); + const exported: { step: string; query: CheckedQuery }[] = []; + for (const step of steps) { + const original = query(pkg.revisionId, step.query); + // Variable roots/arguments remain checked dependent steps. Constants can + // be substituted without requesting new runtime parameters from a parent. + if (!("record" in step.arguments) || Object.values(step.arguments.record).some((value) => !("literal" in value))) + continue; + const constants = step.arguments.record; + let selection = expanded(original); + if (original.declaration.placement?.kind !== "local") + throw Error(key + "." + step.name + ": expected checked local input"); + const source = query(pkg.revisionId, original.declaration.placement.sourceQueryId); + let admissionSelection = expanded(source); + const admissionImports: NonNullable = []; + const imports: NonNullable = []; + for (const child of children) { + const selected = child.with + ? location(child.with) + : child.each + ? location(child.each.source) + : location(child.root); + if (!selected || selected.name !== step.name) continue; + const rootPath = "from" in child.root && child.root.from === "item" ? child.root.path : []; + const path = [...selected.path, ...rootPath]; + if (path.slice(-2).join(".") !== "_camino.ref") { + if (!rootPath.length && (child.with || child.each)) + throw Error(`${key}.${child.name}: child root must select the item's reference`); + } else path.splice(-2); + for (const branch of child.branches) { + for (const input of eager.get(identity(branch.packageRevision, branch.id)) ?? []) { + const declaration = input.query.declaration; + if ( + !imports.some( + (entry) => entry.packageRevisionId === branch.packageRevision && entry.fragmentId === declaration.id, + ) + ) { + imports.push({ packageRevisionId: branch.packageRevision, fragmentId: declaration.id, variables: {} }); + } + selection = insert(selection, path, declaration.fragment!.name); + const admission = admissionFragments.get(identity(branch.packageRevision, declaration.id))!; + if ( + !admissionImports.some( + (entry) => + entry.packageRevisionId === branch.packageRevision && entry.fragmentId === admission.declaration.id, + ) + ) + admissionImports.push({ + packageRevisionId: branch.packageRevision, + fragmentId: admission.declaration.id, + variables: {}, + }); + admissionSelection = insert(admissionSelection, path, admission.declaration.fragment!.name); + } + } + } + const token = suffix(identity(pkg.revisionId, `${preparation.module}/${step.name}`)); + const name = `ReactInput${token}`, + id = `react:input:${preparation.module}:${step.name}`; + const base: QueryDeclaration = { + ...original.declaration, + id, + displayName: name, + document: `generated/react/${name}.graphql`, + operation: name, + fragments: [], + importedFragments: imports, + watch: true, + budgets: { + ...original.declaration.budgets, + depth: Math.max(original.declaration.budgets.depth, 32), + rows: Math.max(original.declaration.budgets.rows, 5000), + }, + }; + delete base.fragment; + function documentFor(selection: SelectionSetNode, fragmentName: string): DocumentNode { + const substituted = visit(selection, { + Variable(node) { + const constant = constants[node.name.value]; + if (!constant || !("literal" in constant)) + throw Error(key + "." + step.name + ": unbound query variable " + node.name.value); + return typeof constant.literal === "boolean" + ? { kind: Kind.BOOLEAN, value: constant.literal } + : { kind: Kind.STRING, value: constant.literal }; + }, + }); + if (original.declaration.root.kind === "object") { + const root = substituted.selections.find((entry) => entry.kind === Kind.FIELD && entry.name.value === "root"); + if (!root || root.kind !== Kind.FIELD || !root.selectionSet) + throw Error(key + ": expected object-rooted query"); + const contract = workspace.interfaceImports.find( + (entry) => entry.revisionId === original.declaration.root.interface, + )!; + return { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.FRAGMENT_DEFINITION, + name: { kind: Kind.NAME, value: fragmentName }, + typeCondition: { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: contract.displayName } }, + selectionSet: root.selectionSet, + }, + ], + }; + } + return { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + operation: "query" as import("graphql").OperationTypeNode, + name: { kind: Kind.NAME, value: name }, + selectionSet: substituted, + }, + ], + }; + } + if (original.declaration.root.kind === "object") base.fragment = { name }; + const sourceId = id + ":fragment-inputs"; + const admissionName = name + "Admission"; + const admissionBase: QueryDeclaration = { + ...base, + views: source.declaration.views, + allowances: source.declaration.allowances, + importedFragments: admissionImports, + ...(base.fragment ? { fragment: { name: admissionName } } : {}), + }; + const admissionDocument = documentFor(admissionSelection, admissionName); + await install( + pkg, + { + ...admissionBase, + id: sourceId, + displayName: name + "$inputs", + placement: { kind: "replica" }, + watch: false, + ...(base.fragment ? { fragment: { name: admissionName, admission: true } } : {}), + }, + print(admissionDocument), + ); + const admissionFragment = await install( + pkg, + { + ...admissionBase, + id: id + ":admission-projection", + displayName: name + "$admission", + placement: { kind: "local", sourceQueryId: sourceId }, + }, + print(admissionDocument), + ); + const local = await install( + pkg, + { ...base, placement: { kind: "local", sourceQueryId: sourceId } }, + print(documentFor(selection, name)), + ); + admissionFragments.set(identity(pkg.revisionId, local.declaration.id), admissionFragment); + step.query = local.declaration.id; + step.arguments = { record: {} }; + if ( + local.declaration.fragment && + step.root && + "from" in step.root && + step.root.from === "input" && + step.root.path.join(".") === "subject" + ) + exported.push({ step: step.name, query: local }); + if ( + local.declaration.fragment && + step.root && + "from" in step.root && + step.root.from === "input" && + step.root.path.join(".") === "navigation" + ) { + const iface = local.declaration.root.interface; + stateInputs.set(iface, [...(stateInputs.get(iface) ?? []), { pkg, query: local }]); + } + } + const available = new Map( + workspace.packageImports.flatMap((pkg) => + (pkg.checkedQueries ?? []).map((query) => [identity(pkg.revisionId, query.declaration.id), query] as const), + ), + ); + for (const query of pkg.checkedQueries ?? []) available.set(query.declaration.id, query); + const checked = compileQueryComposition( + { id: composition.id, input: composition.input, steps, maxQueries: composition.maxQueries }, + available, + ); + const checkedDiscovery = compileQueryComposition( + { id: discovery.id, input: discovery.input, steps: discovery.steps, maxQueries: discovery.maxQueries }, + available, + ); + pkg.queryCompositions = pkg.queryCompositions!.map((entry) => + entry.id === composition.id ? checked : entry.id === discovery.id ? checkedDiscovery : entry, + ); + eager.set(key, exported); + done.add(key); + } + for (const key of modules.keys()) await expandModule(key); + // Retention queries are ordinary checked queries too. They carry the declared + // state projections, including any subject fragments reached through state. + // This is specific to this frontend; no navigation operator enters Camino. + for (const pkg of workspace.packageImports) { + for (const original of [...(pkg.checkedQueries ?? [])]) { + if (!original.declaration.id.startsWith("presentation:navigation:")) continue; + const inputs = (stateInputs.get(original.declaration.root.interface) ?? []).map((input) => ({ + ...input, + query: + original.declaration.placement?.kind === "replica" + ? admissionFragments.get(identity(input.pkg.revisionId, input.query.declaration.id))! + : input.query, + })); + if (!inputs.length) continue; + const document = parse(original.document); + const collection = original.declaration.placement?.kind === "local" ? "availableObjects" : "objects"; + const definitions = document.definitions.map((definition) => { + if (definition.kind !== Kind.OPERATION_DEFINITION) return definition; + return visit(definition, { + Field(node) { + if (node.name.value !== "root" || !node.selectionSet) return; + const source = node.selectionSet.selections.find( + (entry) => entry.kind === Kind.FIELD && entry.name.value === collection, + ); + if (!source || source.kind !== Kind.FIELD) throw Error("Missing navigation collection"); + return { + ...node, + selectionSet: { + ...node.selectionSet, + selections: [ + ...node.selectionSet.selections, + ...inputs.map((input) => ({ + ...source, + alias: { + kind: Kind.NAME as const, + value: `prepared${suffix(identity(input.pkg.revisionId, input.query.declaration.id))}`, + }, + selectionSet: parse( + `{ entries { node { ...${input.query.declaration.fragment!.name} } } }`, + ).definitions.flatMap((entry) => + entry.kind === Kind.OPERATION_DEFINITION ? [entry.selectionSet] : [], + )[0]!, + })), + ], + }, + }; + }, + }); + }); + await install( + pkg, + { + ...original.declaration, + ...(original.declaration.placement?.kind === "local" + ? { + id: original.declaration.id.replace(/:view$/, ":prepared-view"), + displayName: original.declaration.displayName + "Prepared", + } + : {}), + importedFragments: inputs.map((input) => ({ + packageRevisionId: input.pkg.revisionId, + fragmentId: input.query.declaration.id, + variables: {}, + })), + }, + print({ ...document, definitions }), + ); + } + } +} diff --git a/src/react-build/package-queries.ts b/src/react-build/package-queries.ts index a205eef..3442230 100644 --- a/src/react-build/package-queries.ts +++ b/src/react-build/package-queries.ts @@ -1,5 +1,7 @@ import type { PackageQueryGenerator } from "../capability-language/assembly.js"; import { presentationQueries } from "../bindings/presentation-queries.js"; +import { presentationDeclarations, preparationArtifact } from "./declarations.js"; +export { completeWorkspace } from "./workspace.js"; /** Selected explicitly by the React build. Its output is checked as ordinary * Camino queries; no React identifiers are interpreted by package assembly. */ @@ -7,5 +9,22 @@ export const generatePackageQueries: PackageQueryGenerator = async (input) => { const pkg = structuredClone(input.package); const before = new Set(pkg.queries?.map((query) => query.id)); const documents = presentationQueries(pkg, input.interfaces); - return { queries: (pkg.queries ?? []).filter((query) => !before.has(query.id)), documents }; + const reactModules = (pkg.modules ?? []).filter((module) => + input.interfaces + .find((entry) => entry.revisionId === module.interfaceRevisionId) + ?.members.some( + (entry) => + entry.id === module.memberId && entry.kind === "module" && entry.contract.startsWith("org.quixos.react."), + ), + ); + const declarations = reactModules.length + ? presentationDeclarations(await input.readSource("quixos.react.json"), pkg, input.interfaces) + : undefined; + return { + queries: (pkg.queries ?? []).filter((query) => !before.has(query.id)), + documents, + ...(declarations + ? { compositions: declarations.compositions, artifacts: { [preparationArtifact]: declarations.artifact } } + : {}), + }; }; diff --git a/src/react-build/workspace.ts b/src/react-build/workspace.ts new file mode 100644 index 0000000..f0baba0 --- /dev/null +++ b/src/react-build/workspace.ts @@ -0,0 +1,150 @@ +import type { WorkspaceRevision, ValueType } from "../capability-model/types.js"; +import { moduleContract } from "../capability-model/modules.js"; +import { canonicalJson } from "../capability-model/evolution.js"; +import { queryBindingType } from "../query/composition.js"; +import { moduleTypeContext } from "../execution-world/module-contracts.js"; +import { preparationArtifact, type ReactPreparationArtifact } from "./declarations.js"; +import { expandPresentationQueries } from "./expansion.js"; + +/** Explicit frontend stage. Resolve finite provider branches after workspace + * bindings exist; the generic workspace compiler does not invoke this itself. */ +export async function completeWorkspace(workspace: WorkspaceRevision): Promise { + const modules = workspace.packageImports.flatMap((pkg) => { + const artifact = pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined; + return (artifact?.modules ?? []).map((preparation) => { + const module = pkg.modules?.find((entry) => entry.id === preparation.module); + if (!module) throw Error(`React preparation has no module ${preparation.module}`); + const contract = moduleContract(workspace.interfaceImports, module)!; + return { pkg, module, contract, preparation }; + }); + }); + const graph = new Map(); + const key = (pkg: string, id: string) => JSON.stringify([pkg, id]); + for (const parent of modules) { + const composition = parent.pkg.queryCompositions?.find((entry) => entry.id === parent.preparation.composition); + if (!composition || composition.output.kind !== "record") + throw Error(`Missing checked inputs for ${parent.module.displayName}`); + const outputs = new Map(Object.entries(composition.output.fields)); + const dependencies: string[] = []; + const children = parent.preparation.children.map((child) => { + const at = `${parent.module.displayName}.${child.name}`; + let item: ValueType | undefined; + if (child.with) { + const type = queryBindingType(child.with, composition.input, outputs); + item = type.kind === "optional" ? type.value : type; + } + if (child.each) { + const type = queryBindingType(child.each.source, composition.input, outputs); + if (type.kind !== "list") throw Error(`${at}: each requires a list`); + item = type.value; + } + const root = queryBindingType(child.root, composition.input, outputs, item); + if ( + root.kind !== "object-ref" || + root.expectation.kind !== "interface" || + root.expectation.interfaceRevisionId !== child.subject + ) + throw Error(`${at}: child root must be a ${child.subject} reference; unwrap nulls and lists explicitly`); + if ( + child.when && + canonicalJson(queryBindingType(child.when, composition.input, outputs, item)) !== + canonicalJson({ kind: "scalar", name: "bool" }) + ) + throw Error(`${at}: when requires a boolean`); + const atoms = workspace.atoms.filter((atom) => + workspace.conformances.some((entry) => entry.atomId === atom.id && entry.interfaceRevisionId === child.subject), + ); + if (!atoms.length) throw Error(`${at}: no installed subject atoms`); + const branches = atoms.map((atom) => { + let candidates = modules.filter( + (entry) => + entry.contract.subject?.kind === "interface" && + entry.contract.subject.interfaceRevisionId === child.subject, + ); + if (child.role) { + const iface = workspace.interfaceImports.find((entry) => entry.revisionId === child.role!.interface); + const member = iface?.members.find( + (entry) => entry.displayName === child.role!.member || entry.id === child.role!.member, + ); + if (member?.kind !== "module") throw Error(`${at}: unknown rendering role`); + if ( + !workspace.conformances.some( + (entry) => entry.atomId === atom.id && entry.interfaceRevisionId === iface!.revisionId, + ) + ) + throw Error(`${at}: ${atom.id} does not implement the requested role`); + candidates = candidates.filter( + (entry) => entry.module.interfaceRevisionId === iface!.revisionId && entry.module.memberId === member.id, + ); + } else { + candidates = candidates.filter((entry) => + ["org.quixos.react.subject-only/1", "org.quixos.react.component/2"].includes(entry.contract.contract), + ); + } + if (child.provider) { + const pkg = parent.pkg.clientPackages?.[child.provider.package]; + if (!pkg) throw Error(`${at}: provider package is not imported`); + candidates = candidates.filter( + (entry) => entry.pkg.revisionId === pkg && entry.module.displayName === child.provider!.module, + ); + } else { + const bound = candidates.filter((entry) => + workspace.conformances.some( + (conformance) => + conformance.atomId === atom.id && + conformance.interfaceRevisionId === entry.module.interfaceRevisionId && + conformance.moduleBindings?.some( + (binding) => + binding.memberId === entry.module.memberId && + binding.packageRevisionId === entry.pkg.revisionId && + binding.moduleId === entry.module.id, + ), + ), + ); + candidates = bound.length + ? bound + : child.role + ? [] + : candidates.filter((entry) => + workspace.presentations?.some( + (presentation) => + presentation.subject === child.subject && + presentation.packageRevisionId === entry.pkg.revisionId && + presentation.componentId === entry.module.id, + ), + ); + } + if (candidates.length !== 1) + throw Error(`${at}: ${atom.id} has ${candidates.length} compatible providers; select one explicitly`); + const selected = candidates[0]!; + dependencies.push(key(selected.pkg.revisionId, selected.module.id)); + return { + atom: atom.id, + packageRevision: selected.pkg.revisionId, + id: selected.module.id, + role: { + interface: selected.module.interfaceRevisionId, + member: selected.module.memberId, + subject: child.subject, + contract: selected.contract.contract, + ...moduleTypeContext( + workspace.interfaceImports.find((entry) => entry.revisionId === selected.module.interfaceRevisionId)!, + ), + }, + }; + }); + return { ...child, branches }; + }); + graph.set(key(parent.pkg.revisionId, parent.module.id), dependencies); + Object.assign(parent.preparation, { children }); + } + const visited = new Set(); + const visit = (node: string, ancestors: string[]) => { + if (ancestors.includes(node)) throw Error(`Eager React preparation cycle: ${[...ancestors, node].join(" -> ")}`); + if (visited.has(node)) return; + for (const child of graph.get(node) ?? []) visit(child, [...ancestors, node]); + visited.add(node); + }; + for (const node of graph.keys()) visit(node, []); + await expandPresentationQueries(workspace); +} diff --git a/test/prepared-presentations.test.ts b/test/prepared-presentations.test.ts new file mode 100644 index 0000000..ae901cc --- /dev/null +++ b/test/prepared-presentations.test.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/index.js"; +import type { InterfaceRevision, PackageRevision } from "../src/capability-model/types.js"; +import { compileQuery } from "../src/query/compile.js"; +import { compileQueryComposition } from "../src/query/composition.js"; +import { defaultQueryBudgets, type CheckedQuery } from "../src/query/types.js"; +import { + presentationDeclarations, + preparationArtifact, + type ReactPreparationArtifact, +} from "../src/react-build/declarations.js"; +import { completeWorkspace } from "../src/react-build/workspace.js"; + +const source = { repository: "https://example.test/prepared", commit: "a".repeat(40) }; +async function fixture(replacement = "First", change?: (artifact: ReactPreparationArtifact) => void) { + const interfaces = new Map(); + const resource = (text: string) => { + const result = compileCapabilityResourceSource(text, { + source, + environment: { interfaces, interfaceClosure: [...interfaces.values()] }, + }); + assert.ok(result.ok, JSON.stringify(result.diagnostics)); + return result.resource; + }; + for (const text of [ + 'interface Row id "row" revision "row@1" { queryable value title id "title" : string {get id "title:get" execution snapshot targets [browser, server];} queryable value detail id "detail" : string {get id "detail:get" execution snapshot targets [browser, server];} }', + 'import interface Row; interface Library id "library" revision "library@1" { queryable relation selected id "selected" : optional-one interface Row {resolve id "selected:get" execution snapshot targets [browser, server];} }', + 'import interface Row; interface RowRole id "row-role" revision "row-role@1" { module render id "row-render" contract "org.quixos.react.subject-only/1" subject interface Row; }', + 'import interface Library; interface LibraryRole id "library-role" revision "library-role@1" { module render id "library-render" contract "org.quixos.react.subject-only/1" subject interface Library; }', + ]) { + const result = resource(text); + assert.equal(result.kind, "interface"); + if (result.kind === "interface") interfaces.set(result.revision.displayName, result.revision); + } + const result = resource( + 'import interface Row; import interface Library; import interface RowRole; import interface LibraryRole; package UI id "ui" revision "ui@1" { module LibraryCard id "library-card" implements LibraryRole.render; module First id "first" implements RowRole.render; module Second id "second" implements RowRole.render; }', + ); + assert.equal(result.kind, "package"); + if (result.kind !== "package") throw Error("package expected"); + const pkg: PackageRevision = result.revision; + const checked: CheckedQuery[] = []; + for (const [name, iface, fields] of [ + ["LibraryData", "library@1", "selected {_camino {ref atom}}"], + ["Title", "row@1", "title"], + ["Detail", "row@1", "detail"], + ]) { + for (const local of [false, true]) { + const id = name + (local ? "" : "Inputs"); + checked.push( + await compileQuery( + { + id, + displayName: id, + root: { + kind: "object", + interface: [...interfaces.values()].find((entry) => entry.revisionId === iface)!.revisionId, + }, + document: id + ".graphql", + operation: id, + fragments: [], + views: [], + allowances: [], + budgets: { ...defaultQueryBudgets }, + watch: local, + placement: local ? { kind: "local", sourceQueryId: name + "Inputs" } : { kind: "replica" }, + }, + [...interfaces.values()], + async () => `query ${id} {root {${fields}}}`, + ), + ); + } + } + pkg.checkedQueries = checked; + pkg.queries = checked.map((query) => query.declaration); + const input = (query: string) => ({ + name: "data", + query, + root: { from: "input", path: ["subject"] }, + arguments: { record: {} }, + }); + const authored = { + modules: { + LibraryCard: { + inputs: [input("LibraryData")], + children: [ + { + name: "selected", + subject: "Row", + slot: "selected", + with: { from: "result", name: "data", path: ["root", "selected"] }, + root: { from: "item", path: ["_camino", "ref"] }, + role: { interface: "RowRole", member: "render" }, + }, + ], + }, + First: { inputs: [input("Title")] }, + Second: { inputs: [input("Detail")] }, + }, + }; + const declarations = presentationDeclarations(JSON.stringify(authored), pkg, [...interfaces.values()]); + change?.(declarations.artifact); + pkg.buildArtifacts = { [preparationArtifact]: declarations.artifact }; + pkg.queryCompositions = declarations.compositions.map((composition) => + compileQueryComposition(composition, new Map(checked.map((query) => [query.declaration.id, query]))), + ); + const workspace = compileCapabilitySource( + `workspace Example id "example" revision "example@1" commit "${source.commit}" { + import interface Row; import interface Library; import interface RowRole; import interface LibraryRole; import package UI; + atom A id "a"; atom B id "b"; + conform A as Row id "a-row" {private state TitleA id "title-A" on A : string policy register default "title"; private state DetailA id "detail-A" on A : string policy register default "detail"; bind title.get to state TitleA.read; bind detail.get to state DetailA.read;} + conform B as Row id "b-row" {private state TitleB id "title-B" on B : string policy register default "title"; private state DetailB id "detail-B" on B : string policy register default "detail"; bind title.get to state TitleB.read; bind detail.get to state DetailB.read;} + conform A as RowRole id "a-role" {bind render to UI.${replacement};} + conform B as RowRole id "b-role" {bind render to UI.Second;} + }`, + "workspace.qx", + { interfaces, interfaceClosure: [...interfaces.values()], packages: new Map([["UI", pkg]]) }, + ); + assert.ok(workspace.ok, JSON.stringify(workspace.diagnostics)); + await completeWorkspace(workspace.workspace); + return { workspace: workspace.workspace, pkg: workspace.workspace.packageImports[0]!, authored, interfaces }; +} + +test("workspace expands distinct providers' private inputs without parent field declarations", async () => { + const { pkg } = await fixture(); + const parent = pkg.checkedQueries!.find((query) => query.declaration.id === "react:input:library-card:data")!; + assert.match(parent.document, /title/); + assert.match(parent.document, /detail/); + const plan = pkg.queryCompositions!.find((composition) => composition.id === "react:inputs:library-card")!; + assert.equal(plan.steps[0]!.query, parent.declaration.id); + assert.equal(Object.keys(parent.fragmentContracts ?? {}).length, 2); + const discovery = pkg.queryCompositions!.find( + (composition) => composition.id === "react:inputs:library-card:discovery", + )!; + assert.equal( + discovery.steps[0]!.query, + "LibraryData", + "Cold child data cannot block discovering its selected reference", + ); +}); + +test("eager cycles and wrongly typed child bindings fail before runtime", async () => { + await assert.rejects( + fixture("First", (artifact) => { + artifact.modules + .find((module) => module.module === "first")! + .children.push({ + name: "again", + subject: "row@1", + slot: "again", + root: { from: "input", path: ["subject"] }, + role: { interface: "row-role@1", member: "render" }, + }); + }), + /Eager React preparation cycle/, + ); + await assert.rejects( + fixture("First", (artifact) => { + artifact.modules.find((module) => module.module === "library-card")!.children[0]!.root = { + literal: "not a reference", + }; + }), + /child root must be/, + ); +}); + +test("workspace replacement changes acquisition requirements without changing the parent", async () => { + const { pkg } = await fixture("Second"); + const parent = pkg.checkedQueries!.find((query) => query.declaration.id === "react:input:library-card:data")!; + assert.doesNotMatch(parent.document, /\btitle\b/); + assert.match(parent.document, /detail/); + assert.equal(Object.keys(parent.fragmentContracts ?? {}).length, 1); +}); diff --git a/test/presentations.test.ts b/test/presentations.test.ts index ab69e70..c908779 100644 --- a/test/presentations.test.ts +++ b/test/presentations.test.ts @@ -23,6 +23,8 @@ import { type PackageArtifacts, } from "../src/execution-world/index.js"; import { validateWorkspaceRevision } from "../src/capability-model/index.js"; +import { presentationDeclarations, preparationArtifact } from "../src/react-build/declarations.js"; +import { compileQueryComposition } from "../src/query/composition.js"; const source = { repository: "https://example.test/components.git", commit: "a".repeat(40) }; const contract = (name: string) => { @@ -58,6 +60,13 @@ const compilePackage = (body?: string) => const workspace = (presentation = 'presentation Card id "presentation:card" for Data using UI.Card;') => { const pkg = compilePackage(); assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics)); + const preparation = presentationDeclarations('{"modules":{"Card":{"inputs":[]}}}', pkg.resource.revision, [ + ...interfaces.values(), + ]); + pkg.resource.revision.buildArtifacts = { [preparationArtifact]: preparation.artifact }; + pkg.resource.revision.queryCompositions = preparation.compositions.map((entry) => + compileQueryComposition(entry, new Map()), + ); return compileCapabilitySource( `workspace Example id "workspace:example" revision "workspace:example@1" commit "${source.commit}" { import interface Data; import interface Other; import interface View; import package UI;