diff --git a/flake.nix b/flake.nix index 7f2a1a8..efac4b1 100644 --- a/flake.nix +++ b/flake.nix @@ -41,6 +41,7 @@ patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin yarn build esbuild dist/src/execution-world/index.js --bundle --platform=node --target=node24 --format=esm --outfile=execution-world.mjs + esbuild dist/src/react-build/package-queries.js --bundle --platform=node --target=node24 --format=esm --outfile=react-query-generator.mjs esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated @@ -122,6 +123,7 @@ chmod +x "$out/bin/quixos-lock-check" mkdir -p "$out/libexec/quixos-protocol" install -m644 execution-world.mjs "$out/libexec/quixos-protocol/execution-world.mjs" + install -m644 react-query-generator.mjs "$out/libexec/quixos-protocol/react-query-generator.mjs" install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs" install -m644 react-platform.mjs "$out/libexec/quixos-protocol/react-platform.mjs" install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/" diff --git a/nix/checked-candidate.nix b/nix/checked-candidate.nix index 7e61b45..8fc6be4 100644 --- a/nix/checked-candidate.nix +++ b/nix/checked-candidate.nix @@ -65,6 +65,27 @@ let }) (flatten root) ) ); + # Frontends are an explicit build choice. The capability assembler itself + # neither discovers React contracts nor loads executable package code. + queryFrontends = pkgs.lib.unique ( + pkgs.lib.concatMap ( + node: + let + config = node.directory + "/quixos.check.json"; + in + if builtins.pathExists config then + (builtins.fromJSON (builtins.readFile config)).queryFrontends or [ ] + else + [ ] + ) (capabilityFlatten root) + ); + queryGeneratorFlags = + if queryFrontends == [ ] then + "" + else if queryFrontends == [ "react" ] then + "--query-generator ${protocol}/libexec/quixos-protocol/react-query-generator.mjs" + else + throw "Unsupported query frontend selection: ${builtins.toJSON queryFrontends}"; snapshots = pkgs.writeText "qx-nix-source-graph.json" ( builtins.toJSON { resources = map (node: { @@ -85,6 +106,7 @@ let if node.kind == "workspace" then '' ${protocol}/bin/quixos-workspace-compile --root ${node.directory} \ + ${queryGeneratorFlags} \ --source-root-commit ${pkgs.lib.escapeShellArg node.commit} \ --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \ --graph-out "$out/graph.json" --schemas-out "$out/package-schemas.json" \ @@ -93,6 +115,7 @@ let else '' ${protocol}/bin/quixos-resource-compile --root ${node.directory} \ + ${queryGeneratorFlags} \ --kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \ --commit ${pkgs.lib.escapeShellArg node.commit} \ --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} --snapshot-only true \ diff --git a/src/capability-language/assembly.ts b/src/capability-language/assembly.ts index 6399662..14c878c 100644 --- a/src/capability-language/assembly.ts +++ b/src/capability-language/assembly.ts @@ -2,7 +2,8 @@ import { readFile, realpath } from "node:fs/promises"; import path from "node:path"; import { loadQxSources, resolveQxSources } from "./source-loader.js"; import { compileQuery, compileRepositoryQuery } from "../query/compile.js"; -import { presentationQueries } from "../bindings/presentation-queries.js"; +import type { QueryDeclaration } from "../query/types.js"; +import { compileQueryComposition, type QueryComposition } from "../query/composition.js"; import { checkQueryTemplate } from "../query/templates.js"; import { readRepositorySource } from "./source-loader.js"; import { parseControllerCatalog } from "../capability-model/controllers.js"; @@ -42,6 +43,19 @@ export type CapabilityRepositoryResolver = ( kind: LockedResource["kind"], ) => Promise; +/** 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: { + readonly package: Readonly; + readonly interfaces: readonly InterfaceRevision[]; + readonly readSource: (name: string) => Promise; +}) => Promise<{ + readonly queries: readonly QueryDeclaration[]; + readonly documents: ReadonlyMap; + readonly compositions?: readonly QueryComposition[]; +}>; + export type ResolvedCapabilityResource = { key: string; kind: LockedResource["kind"]; @@ -198,7 +212,11 @@ const environmentFor = ( }; }; -const createResourceGraphResolver = (quixosCommit: string, resolveResource: CapabilityRepositoryResolver) => { +const createResourceGraphResolver = ( + quixosCommit: string, + resolveResource: CapabilityRepositoryResolver, + generateQueries?: PackageQueryGenerator, +) => { const resolved = new Map>(); const active: string[] = []; @@ -273,7 +291,22 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa ...(environment.interfaceClosure ?? []), ...(compiled.resource.specializations ?? []), ]; - const presentationDocuments = presentationQueries(compiled.resource.revision, queryInterfaces); + const generated = await generateQueries?.({ + package: structuredClone(compiled.resource.revision), + interfaces: structuredClone(queryInterfaces), + readSource: (name) => readRepositorySource(snapshot.directory, name), + }); + if (generated) { + const queries = [...(compiled.resource.revision.queries ?? [])]; + for (const query of generated.queries) { + if (queries.some((entry) => entry.id === query.id || entry.displayName === query.displayName)) + throw new Error(`Generated query conflicts with an authored query: ${query.id}`); + if (!generated.documents.has(query.document)) + throw new Error(`Generated query has no source: ${query.document}`); + queries.push(structuredClone(query)); + } + compiled.resource.revision.queries = queries; + } for (const template of compiled.resource.revision.queryTemplates ?? []) await checkQueryTemplate(template, queryInterfaces, (name) => readRepositorySource(snapshot.directory, name), @@ -290,29 +323,42 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa if (previous) return previous; const query = pkg.queries!.find((query) => query.id === id); if (!query) throw new Error(`Unknown fragment ${pkg.revisionId}/${id}`); - const generatedDocument = presentationDocuments.get(query.document); - const result = - generatedDocument !== undefined - ? await compileQuery(query, queryInterfaces, async () => generatedDocument) - : await compileRepositoryQuery( - snapshot.directory, - query, - queryInterfaces, - async (packageId, fragmentId) => { - if (packageId === pkg.revisionId) return compile(fragmentId, [...parents, id]); - const imported = environment.packageClosure - ?.find((other) => other.revisionId === packageId) - ?.checkedQueries?.find((query) => query.declaration.id === fragmentId); - if (!imported) throw new Error(`Unchecked imported fragment ${packageId}/${fragmentId}`); - return imported; - }, - ); + const resolveFragment = async (packageId: string, fragmentId: string) => { + if (packageId === pkg.revisionId) return compile(fragmentId, [...parents, id]); + const imported = environment.packageClosure + ?.find((other) => other.revisionId === packageId) + ?.checkedQueries?.find((query) => query.declaration.id === fragmentId); + if (!imported) throw new Error(`Unchecked imported fragment ${packageId}/${fragmentId}`); + return imported; + }; + const result = generated?.documents.has(query.document) + ? await compileQuery( + query, + queryInterfaces, + async (name) => + generated.documents.get(name) ?? (await readRepositorySource(snapshot.directory, name)), + resolveFragment, + ) + : await compileRepositoryQuery(snapshot.directory, query, queryInterfaces, resolveFragment); checked.set(id, result); return result; }; pkg.checkedQueries = []; for (const query of pkg.queries!) pkg.checkedQueries.push(await compile(query.id)); } + if (generated?.compositions?.length) { + const pkg = compiled.resource.revision; + const queries = new Map((pkg.checkedQueries ?? []).map((query) => [query.declaration.id, query])); + for (const dependency of environment.packageClosure ?? []) + for (const query of dependency.checkedQueries ?? []) + queries.set(JSON.stringify([dependency.revisionId, query.declaration.id]), query); + const ids = new Set(); + pkg.queryCompositions = generated.compositions.map((composition) => { + if (ids.has(composition.id)) throw new Error(`Duplicate query composition: ${composition.id}`); + ids.add(composition.id); + return compileQueryComposition(composition, queries); + }); + } let controllerText: string | undefined; try { controllerText = await readRepositorySource(snapshot.directory, "quixos.controllers.json"); @@ -377,12 +423,17 @@ export const compileCapabilityResourceRepository = async (options: { kind: LockedResource["kind"]; source: GitSource; resolveResource: CapabilityRepositoryResolver; + generateQueries?: PackageQueryGenerator; }): Promise => { const lockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock")); if (!lockResult.ok) { throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics)); } - const resolver = createResourceGraphResolver(lockResult.lock.quixos.commit, options.resolveResource); + const resolver = createResourceGraphResolver( + lockResult.lock.quixos.commit, + options.resolveResource, + options.generateQueries, + ); const root = await resolver.visit( { kind: options.kind, @@ -408,13 +459,18 @@ export const compileWorkspaceRepository = async (options: { sourceRootCommit?: string; /** An editor's proposed source snapshot; locks and dependency revisions remain exact. */ readSource?: (name: string) => Promise; + generateQueries?: PackageQueryGenerator; }): Promise => { const rootLockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock")); if (!rootLockResult.ok) { throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics)); } const rootLock = rootLockResult.lock; - const resolver = createResourceGraphResolver(rootLock.quixos.commit, options.resolveResource); + const resolver = createResourceGraphResolver( + rootLock.quixos.commit, + options.resolveResource, + options.generateQueries, + ); const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = []; for (const locked of rootLock.resources) { diff --git a/src/capability-language/component-scaffold.ts b/src/capability-language/component-scaffold.ts index 3b317eb..a5bacb9 100644 --- a/src/capability-language/component-scaffold.ts +++ b/src/capability-language/component-scaffold.ts @@ -55,12 +55,17 @@ export async function scaffoldComponent( const insertion = ` ${nixLiteral(spec.id)} = { entry = ${nixLiteral(`src/components/${spec.name}.tsx`)}; export = ${nixLiteral(spec.name)}; };\n`; const after = before.replace(/\n}(\s*)$/, (_match, trailing) => "\n" + insertion + "}" + trailing); const parentID = parent.children.find((node) => node.kind === "stringLiteral")!; + const configFile = prefix + "quixos.check.json"; + const configBefore = await fs.readFile(path.join(root, configFile), "utf8"); + const config = JSON.parse(configBefore); + config.queryFrontends = [...new Set([...(config.queryFrontends ?? []), "react"])]; return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files: [ + { file: configFile, expected: configBefore, replace: JSON.stringify(config, null, 2) + "\n" }, { file: prefix + "package.qx", edits: [ diff --git a/src/capability-language/query-generator.ts b/src/capability-language/query-generator.ts new file mode 100644 index 0000000..ab7ce45 --- /dev/null +++ b/src/capability-language/query-generator.ts @@ -0,0 +1,13 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { PackageQueryGenerator } from "./assembly.js"; + +/** The build invoker selects this executable explicitly. Never resolve a + * generator by a capability name or execute a module discovered in user QX. */ +export async function loadQueryGenerator(filename?: string): Promise { + if (!filename) return undefined; + const module = await import(pathToFileURL(path.resolve(filename)).href); + if (typeof module.generatePackageQueries !== "function") + throw new Error("Query generator must export generatePackageQueries"); + return module.generatePackageQueries; +} diff --git a/src/capability-language/resource-cli.ts b/src/capability-language/resource-cli.ts index ae67357..836419a 100644 --- a/src/capability-language/resource-cli.ts +++ b/src/capability-language/resource-cli.ts @@ -5,10 +5,12 @@ import process from "node:process"; import { bindingSchema } from "../bindings/index.js"; import { compileCapabilityResourceRepository, type ResolvedCapabilityResource } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; +import { loadQueryGenerator } from "./query-generator.js"; const usage = `usage: quixos-resource-compile --root DIRECTORY --kind interface|package|build --repository URL --commit GIT_REV --checkout-root DIRECTORY [--snapshot-map PATH] [--snapshot-only true] [--graph-out PATH] [--schema-out PATH] + [--query-generator BUILD_MODULE_PATH] Resolves a standalone resource's recursive dependency-lock graph, validates its interface.qx or package.qx manifest, and emits the checked resource as JSON.`; @@ -30,6 +32,7 @@ const parseArgs = (args: string[]) => { "--snapshot-only", "--graph-out", "--schema-out", + "--query-generator", ].includes(key) || values.has(key) ) @@ -65,6 +68,7 @@ const parseArgs = (args: string[]) => { snapshotOnly: values.get("--snapshot-only") === "true", graphOut: values.get("--graph-out"), schemaOut: values.get("--schema-out"), + queryGenerator: values.get("--query-generator"), } as const; }; @@ -106,6 +110,7 @@ const main = async () => { commit: options.commit, }, resolveResource, + generateQueries: await loadQueryGenerator(options.queryGenerator), }); if (options.graphOut) { await writeFile( diff --git a/src/capability-language/workspace-cli.ts b/src/capability-language/workspace-cli.ts index 82b4ac0..ffe57e4 100644 --- a/src/capability-language/workspace-cli.ts +++ b/src/capability-language/workspace-cli.ts @@ -4,6 +4,7 @@ import { readFile, writeFile } from "node:fs/promises"; import process from "node:process"; import { compileWorkspaceRepository } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; +import { loadQueryGenerator } from "./query-generator.js"; import { compileExecutionPlan } from "../execution-world/index.js"; import { bindingSchema, specializeBindingSchema } from "../bindings/index.js"; import { @@ -17,6 +18,7 @@ const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root [--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID] [--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH] [--reviews REVIEW_JSON] [--schemas-out PATH] [--execution-plan-out PATH] + [--query-generator BUILD_MODULE_PATH] Resolves a workspace's recursive resource-lock graph, clones every exact resource revision, validates standalone interface/package manifests, and emits @@ -46,6 +48,7 @@ const parseArgs = (args: string[]) => { baseline: values.get("--baseline"), evolutionOut: values.get("--evolution-out"), reviews: values.get("--reviews"), + queryGenerator: values.get("--query-generator"), }; }; @@ -65,6 +68,7 @@ const main = async () => { workspaceRevisionId: options.workspaceRevisionId, sourceRootCommit: options.sourceRootCommit, resolveResource, + generateQueries: await loadQueryGenerator(options.queryGenerator), }); if (options.graphOut) { diff --git a/src/capability-model/types.ts b/src/capability-model/types.ts index b3002af..6e8607e 100644 --- a/src/capability-model/types.ts +++ b/src/capability-model/types.ts @@ -330,6 +330,7 @@ export interface PackageRevision { controllers?: PackageController[]; queries?: import("../query/types.js").QueryDeclaration[]; checkedQueries?: import("../query/types.js").CheckedQuery[]; + queryCompositions?: import("../query/composition.js").CheckedQueryComposition[]; queryTemplates?: import("../query/types.js").QueryTemplate[]; genericExports?: import("./generic-packages.js").GenericPackageExport[]; argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[]; diff --git a/src/execution-world/queries.ts b/src/execution-world/queries.ts index fdf62d0..8ddf438 100644 --- a/src/execution-world/queries.ts +++ b/src/execution-world/queries.ts @@ -3,6 +3,7 @@ import { compileInspectionTemplates } from "./inspection.js"; import { portableValueSchema } from "../bindings/portable.js"; import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js"; import type { ValueType } from "../capability-model/types.js"; +import { compileQueryComposition } from "../query/composition.js"; const lowerTypes = (value: unknown): unknown => JSON.parse( @@ -25,9 +26,40 @@ export function compileQueryProgram(plan: ExecutionPlan): unknown { if (hexDigest(plan.definition) !== plan.digest) throw new ExecutionPlanError("INVALID_PLAN", "queries", "Plan digest mismatch"); const interfaces = plan.definition.workspace.interfaceImports; + const allQueries = new Map(plan.definition.queries.map(({ id, query }) => [id, query.checked])); + const compositions = plan.definition.workspace.packageImports.flatMap((pkg) => + (pkg.queryCompositions ?? []).map((composition) => { + const scoped = new Map(allQueries); + for (const query of pkg.checkedQueries ?? []) scoped.set(query.declaration.id, query); + const checked = compileQueryComposition( + { + id: composition.id, + input: composition.input, + steps: composition.steps, + maxQueries: composition.maxQueries, + }, + scoped, + ); + if (checked.definitionDigest !== composition.definitionDigest) + throw new ExecutionPlanError("INVALID_QUERY_COMPOSITION", composition.id, "Checked dependencies changed"); + return { + id: JSON.stringify([pkg.revisionId, composition.id]), + input: portableValueSchema(checked.input), + output: portableValueSchema(checked.output), + maxQueries: checked.maxQueries, + steps: checked.steps.map((step) => ({ + ...step, + query: (pkg.checkedQueries ?? []).some((query) => query.declaration.id === step.query) + ? JSON.stringify([pkg.revisionId, step.query]) + : step.query, + })), + }; + }), + ); return { codec: "camino-query-program-v1", plan: (compileKernelProgram(plan) as { plan: string }).plan, + ...(compositions.length ? { compositions } : {}), queries: [ ...compileInspectionTemplates(plan).queries, ...plan.definition.queries.map(({ id, query, admittedOperations }) => { diff --git a/src/query/composition.ts b/src/query/composition.ts new file mode 100644 index 0000000..bcfa0e5 --- /dev/null +++ b/src/query/composition.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import type { ValueType } from "../capability-model/types.js"; +import { canonicalJson } from "../capability-model/evolution.js"; +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 = + | { literal: string | boolean } + | { equal: [QueryBinding, QueryBinding] } + | { from: "input"; path: string[] } + | { from: "item"; path: string[] } + | { from: "result"; name: string; path: string[] } + | { record: Record }; + +export interface QueryCompositionStep { + name: string; + query: string; + root: QueryBinding | null; + arguments: QueryBinding; + /** Null roots skip this step and produce null, rather than querying an + * arbitrary object or treating unavailable query inputs as absence. */ + optional?: boolean; + when?: QueryBinding; + /** An optional object is unwrapped as item; absence skips the query. */ + with?: QueryBinding; + each?: { source: QueryBinding; limit: number }; +} + +export interface QueryComposition { + id: string; + input: ValueType; + steps: QueryCompositionStep[]; + maxQueries: number; +} + +export interface CheckedQueryComposition extends QueryComposition { + output: ValueType; + definitionDigest: string; + dependencies: { query: string; digest: string }[]; +} + +/** Checks a finite dependency graph. Query identities are resolved by the + * caller's package/workspace scope; no presentation concepts participate. */ +export function compileQueryComposition( + source: QueryComposition, + queries: ReadonlyMap, +): CheckedQueryComposition { + const fail = (message: string): never => { + throw new QueryCompileError("QUERY_COMPOSITION", `${source.id}: ${message}`); + }; + if (!Number.isSafeInteger(source.maxQueries) || source.maxQueries < 1) fail("maxQueries must be a positive integer"); + if (source.input.kind !== "record") fail("input must be a record"); + 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 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}`); + const query = queries.get(step.query); + if (!query) return fail(`unknown query ${step.query}`); + let item: ValueType | undefined; + if (step.with) { + if (step.each) fail(`${step.name} cannot combine with and each`); + const value = binding(step.with); + item = value.kind === "optional" ? value.value : value; + } + if (step.each) { + const collection = binding(step.each.source); + if (collection.kind !== "list") return fail(`${step.name}.each requires a list`); + if (!Number.isSafeInteger(step.each.limit) || step.each.limit < 1) + fail(`${step.name}.each.limit must be a positive integer`); + item = collection.value; + } + count += step.each?.limit ?? 1; + if (!Number.isSafeInteger(count) || count > source.maxQueries) fail("declared steps exceed maxQueries"); + if (step.when && !equal(binding(step.when, item), { kind: "scalar", name: "bool" })) + fail(`${step.name}.when requires a boolean`); + if (query.declaration.root.kind === "workspace-interface") { + if (step.root !== null) fail(`${step.name} workspace query takes no object root`); + } else { + if (step.root === null) return fail(`${step.name} requires an object root`); + let root = binding(step.root, item); + if (root.kind === "optional" && step.optional) root = root.value; + const expected: ValueType = { + kind: "object-ref", + expectation: { + kind: "interface", + interfaceRevisionId: query.declaration.root.interface, + }, + }; + if (!equal(root, expected)) fail(`${step.name} root must be a reference to ${query.declaration.root.interface}`); + } + if (!equal(binding(step.arguments, item), query.variables)) + fail(`${step.name} arguments do not match ${step.query}`); + let output = query.output; + if (step.optional || step.with || step.when) output = { kind: "optional", value: output }; + if (step.each) output = { kind: "list", value: output }; + outputs.set(step.name, output); + dependencies.set(step.query, query.definitionDigest); + } + const result = { + ...structuredClone(source), + output: { kind: "record" as const, fields: Object.fromEntries(outputs) }, + dependencies: [...dependencies].map(([query, digest]) => ({ query, digest })), + }; + return { ...result, definitionDigest: createHash("sha256").update(canonicalJson(result)).digest("hex") }; +} diff --git a/src/query/schema.ts b/src/query/schema.ts index afa307f..c663e8a 100644 --- a/src/query/schema.ts +++ b/src/query/schema.ts @@ -197,7 +197,12 @@ export function querySchema( const metadata = new GraphQLObjectType({ name: `QxMetadata_${name(id)}`, fields: { - ...(id === workspaceQueryRootId ? {} : { ref: { type: new GraphQLNonNull(relational.refs(id)) } }), + ...(id === workspaceQueryRootId + ? {} + : { + ref: { type: new GraphQLNonNull(relational.refs(id)) }, + atom: { type: new GraphQLNonNull(GraphQLString) }, + }), ...(helpers ? { relations: { type: new GraphQLNonNull(helpers) } } : {}), }, }); diff --git a/src/react-build/package-queries.ts b/src/react-build/package-queries.ts new file mode 100644 index 0000000..a205eef --- /dev/null +++ b/src/react-build/package-queries.ts @@ -0,0 +1,11 @@ +import type { PackageQueryGenerator } from "../capability-language/assembly.js"; +import { presentationQueries } from "../bindings/presentation-queries.js"; + +/** Selected explicitly by the React build. Its output is checked as ordinary + * Camino queries; no React identifiers are interpreted by package assembly. */ +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 }; +}; diff --git a/test/query-composition.test.ts b/test/query-composition.test.ts new file mode 100644 index 0000000..6f9d38b --- /dev/null +++ b/test/query-composition.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { compileCapabilityResourceSource } from "../src/capability-language/index.js"; +import type { InterfaceRevision } from "../src/capability-model/types.js"; +import { valueType } from "../src/capability-model/types.js"; +import { compileQuery } from "../src/query/compile.js"; +import { compileQueryComposition, type QueryComposition } from "../src/query/composition.js"; +import { defaultQueryBudgets } from "../src/query/types.js"; + +const iface = (text: string, dependencies: InterfaceRevision[] = []) => { + const result = compileCapabilityResourceSource(text, { + source: { repository: "https://example.test/devices", commit: "a".repeat(40) }, + environment: { + interfaces: new Map(dependencies.map((entry) => [entry.displayName, entry])), + interfaceClosure: dependencies, + }, + }); + assert.ok(result.ok, JSON.stringify(result.diagnostics)); + if (result.resource.kind !== "interface") throw Error("Expected interface"); + return result.resource.revision; +}; + +test("dependent query roots and arguments are checked without a presentation library", async () => { + const sensor = iface(`interface Sensor id "sensor" revision "sensor@1" { + queryable value reading id "reading" : int64 { get id "reading:get"; } + }`); + const control = iface( + `import interface Sensor; + interface Control id "control" revision "control@1" { + queryable value enabled id "enabled" : bool { get id "enabled:get"; } + queryable relation selected id "selected" : optional-one interface Sensor { resolve id "selected:get"; } + }`, + [sensor], + ); + const query = (id: string, root: InterfaceRevision["revisionId"], text: string) => + compileQuery( + { + id, + displayName: id, + document: `${id}.graphql`, + operation: id, + root: { kind: "object", interface: root }, + fragments: [], + views: [], + allowances: [], + budgets: { ...defaultQueryBudgets }, + watch: false, + placement: { kind: "replica" }, + }, + [control, sensor], + async () => text, + ); + const selection = await query( + "Selection", + control.revisionId, + "query Selection { root { enabled selected { _camino { ref atom } } } }", + ); + const reading = await query( + "Reading", + sensor.revisionId, + "query Reading($enabled: Boolean!) { root { reading @include(if: $enabled) } }", + ); + const queries = new Map([ + ["Selection", selection], + ["Reading", reading], + ]); + const source: QueryComposition = { + id: "device-display", + input: { kind: "record", fields: { controller: valueType.interfaceRef(control.revisionId) } }, + maxQueries: 2, + steps: [ + { + name: "selection", + query: "Selection", + root: { from: "input", path: ["controller"] }, + arguments: { record: {} }, + }, + { + name: "reading", + query: "Reading", + with: { from: "result", name: "selection", path: ["root", "selected"] }, + root: { from: "item", path: ["_camino", "ref"] }, + arguments: { record: { enabled: { from: "result", name: "selection", path: ["root", "enabled"] } } }, + }, + ], + }; + const checked = compileQueryComposition(source, queries); + assert.equal(checked.output.kind, "record"); + assert.equal(checked.dependencies.length, 2); + assert.ok(checked.definitionDigest); + const changed = new Map(queries); + changed.set("Reading", { ...reading, definitionDigest: "different-source" }); + assert.notEqual(compileQueryComposition(source, changed).definitionDigest, checked.definitionDigest); + assert.throws(() => compileQueryComposition({ ...source, maxQueries: 1 }, queries), /exceed maxQueries/); + assert.throws( + () => compileQueryComposition({ ...source, steps: [...source.steps].reverse() }, queries), + /earlier step/, + ); + const wrongArguments = structuredClone(source); + wrongArguments.steps[1]!.arguments = { record: { enabled: { from: "input", path: ["controller"] } } }; + assert.throws(() => compileQueryComposition(wrongArguments, queries), /arguments do not match/); + const wrongRoot = structuredClone(source); + wrongRoot.steps[1]!.root = { from: "input", path: ["controller"] }; + assert.throws(() => compileQueryComposition(wrongRoot, queries), /reference to sensor@1/); + const nullableTraversal = structuredClone(source); + nullableTraversal.steps[1]!.root = { + from: "result", + name: "selection", + path: ["root", "selected", "_camino", "ref"], + }; + assert.throws(() => compileQueryComposition(nullableTraversal, queries), /explicit boundary/); + const branch = structuredClone(source); + branch.steps[1]!.when = { equal: [{ from: "item", path: ["_camino", "atom"] }, { literal: "thermometer" }] }; + assert.ok(compileQueryComposition(branch, queries).definitionDigest); + branch.steps[1]!.when = { literal: "not a boolean" }; + assert.throws(() => compileQueryComposition(branch, queries), /when requires a boolean/); +}); diff --git a/test/query-source-generation.test.ts b/test/query-source-generation.test.ts new file mode 100644 index 0000000..b2b38dc --- /dev/null +++ b/test/query-source-generation.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + compileCapabilityResourceRepository, + type PackageQueryGenerator, +} from "../src/capability-language/assembly.js"; +import { defaultQueryBudgets } from "../src/query/types.js"; + +test("an explicit non-React frontend emits checked queries for its own selection model", async (context) => { + const directory = await mkdtemp(path.join(os.tmpdir(), "qx-query-generator-")); + context.after(() => rm(directory, { recursive: true, force: true })); + const commit = "a".repeat(40); + const source = (name: string) => ({ + resolver: "git" as const, + repository: `https://example.test/${name}.git`, + commit, + }); + const lock = (imports: string) => `quixos-lock version 1 { + quixos source { repository "https://example.test/quixos.git"; commit "${commit}"; } + ${imports} + }`; + const dependency = (name: string) => + `interface ${name} source { repository "${source(name).repository}"; commit "${commit}"; }`; + for (const name of ["Sensor", "DeviceSelection", "Device"]) await mkdir(path.join(directory, name)); + await writeFile(path.join(directory, "Sensor", "quixos.lock"), lock("")); + await writeFile( + path.join(directory, "Sensor", "interface.qx"), + ` + interface Sensor id "sensor" revision "sensor@1" { + queryable value reading id "reading" : int64 { get id "reading:get"; } + } + `, + ); + await writeFile(path.join(directory, "DeviceSelection", "quixos.lock"), lock(dependency("Sensor"))); + await writeFile( + path.join(directory, "DeviceSelection", "interface.qx"), + ` + import interface Sensor; + interface DeviceSelection id "device-selection" revision "device-selection@1" { + queryable relation selectedSensor id "selected-sensor" : optional-one interface Sensor { + resolve id "selected-sensor:resolve"; + } + } + `, + ); + await writeFile(path.join(directory, "Device", "quixos.lock"), lock(dependency("DeviceSelection"))); + await writeFile( + path.join(directory, "Device", "package.qx"), + ` + import interface DeviceSelection; + package Device id "device" revision "device@1" {} + `, + ); + await writeFile( + path.join(directory, "Device", "inputs.graphql"), + `query Selected { root { selectedSensor { reading } } }`, + ); + const options = { + rootDirectory: path.join(directory, "Device"), + kind: "package" as const, + source: source("Device"), + resolveResource: async (ref: { repository: string }) => ({ + directory: path.join(directory, new URL(ref.repository).pathname.slice(1, -4)), + }), + }; + const plain = await compileCapabilityResourceRepository(options); + assert.equal(plain.resource.kind, "package"); + if (plain.resource.kind !== "package") throw Error("Expected package"); + assert.equal(plain.resource.revision.queries?.length ?? 0, 0); + + const generateQueries: PackageQueryGenerator = async ({ interfaces, readSource }) => ({ + queries: [ + { + id: "selected-inputs", + displayName: "SelectedInputs", + root: { + kind: "object", + interface: interfaces.find((iface) => iface.displayName === "DeviceSelection")!.revisionId, + }, + document: "generated/selected.graphql", + operation: "Selected", + fragments: [], + views: [], + allowances: [], + budgets: { ...defaultQueryBudgets }, + watch: false, + placement: { kind: "replica" }, + }, + ], + documents: new Map([["generated/selected.graphql", await readSource("inputs.graphql")]]), + compositions: [ + { + id: "display", + maxQueries: 1, + input: { + kind: "record", + fields: { + device: { + kind: "object-ref", + expectation: { + kind: "interface", + interfaceRevisionId: interfaces.find((iface) => iface.displayName === "DeviceSelection")!.revisionId, + }, + }, + }, + }, + steps: [ + { + name: "selection", + query: "selected-inputs", + root: { from: "input", path: ["device"] }, + arguments: { record: {} }, + }, + ], + }, + ], + }); + const built = await compileCapabilityResourceRepository({ ...options, generateQueries }); + assert.equal(built.resource.kind, "package"); + if (built.resource.kind !== "package") throw Error("Expected package"); + assert.equal(built.resource.revision.checkedQueries?.length, 1); + const checked = built.resource.revision.checkedQueries![0]!; + assert.equal(checked.declaration.root.interface, "device-selection@1"); + assert.ok(checked.effects.some((effect) => effect.memberId === "reading")); + assert.ok(checked.effects.some((effect) => effect.memberId === "selected-sensor")); + assert.ok(checked.definitionDigest); + assert.equal(built.resource.revision.queryCompositions?.[0]?.dependencies[0]?.digest, checked.definitionDigest); + assert.equal( + built.resources.some((resource) => /React|PresentationMount/.test(resource.key)), + false, + ); + + await assert.rejects( + compileCapabilityResourceRepository({ + ...options, + generateQueries: async (input) => { + const result = await generateQueries(input); + return { + ...result, + documents: new Map([["generated/selected.graphql", "query Selected { root { invented } }"]]), + }; + }, + }), + /invented/, + ); + await assert.rejects( + compileCapabilityResourceRepository({ + ...options, + generateQueries: async (input) => { + const result = await generateQueries(input); + return { ...result, queries: [...result.queries, ...result.queries] }; + }, + }), + /Generated query conflicts/, + ); +});