diff --git a/src/bindings/presentation-queries.ts b/src/bindings/presentation-queries.ts new file mode 100644 index 0000000..ec793e7 --- /dev/null +++ b/src/bindings/presentation-queries.ts @@ -0,0 +1,83 @@ +import { createHash } from "node:crypto"; +import type { InterfaceRevision, PackageRevision, ValueType } from "../capability-model/types.js"; +import { defaultQueryBudgets, type QueryDeclaration } from "../query/types.js"; + +/** React contract tooling generates ordinary queries before normal checking. + * Neither the query language nor its execution planner knows presentation IDs. */ +export function presentationQueries(pkg: PackageRevision, interfaces: readonly InterfaceRevision[]) { + const documents = new Map(); + const generated: QueryDeclaration[] = []; + const contracts = new Map(interfaces.map((contract) => [contract.revisionId, contract])); + const navigation = new Set(); + for (const module of pkg.modules ?? []) { + const contract = contracts.get(module.interfaceRevisionId); + const member = contract?.members.find((member) => member.id === module.memberId); + if (member?.kind !== "module" || member.contract !== "org.quixos.react.component/2") continue; + const argument = contract?.application?.arguments[1]; + if (argument?.kind !== "object" || argument.target.kind !== "interface") + throw Error("Stateful React presentations require a closed navigation interface"); + navigation.add(argument.target.interfaceRevisionId); + } + const valueSelection = (type: ValueType): string => { + if (type.kind === "optional" || type.kind === "list") return valueSelection(type.value); + return type.kind === "record" + ? `{${Object.entries(type.fields) + .map(([name, type]) => `${name} ${valueSelection(type)}`) + .join(" ")}}` + : ""; + }; + for (const id of [...navigation].sort()) { + const contract = contracts.get(id as InterfaceRevision["revisionId"]); + if (!contract || contract.template) throw Error(`Missing closed presentation navigation ${id}`); + const fields = contract.members.flatMap((member) => { + if ((member.kind !== "value" && member.kind !== "relationship") || !member.queryRead) return []; + if (member.kind === "value") return [`${member.displayName} ${valueSelection(member.valueType)}`]; + if (member.target.kind !== "interface") + throw Error(`Navigation relationship ${member.displayName} must declare an interface view`); + return [ + member.cardinality === "many" || member.cardinality === "many-unique" + ? `${member.displayName}(all:512) {entries {node {_camino {ref}}}}` + : `${member.displayName} {_camino {ref}}`, + ]; + }); + const stem = `PresentationNavigation${createHash("sha256").update(id).digest("hex").slice(0, 12)}`; + const inputs = `presentation:navigation:${id}:inputs`; + for (const local of [false, true]) { + const name = `${stem}${local ? "View" : "Inputs"}`; + const document = `generated/${name}.graphql`; + documents.set( + document, + `query ${name}($presentationMount: QxRef_PresentationMount!) { + root { ${local ? "availableObjects" : "objects"}(all:512, where:{_camino:{as:{PresentationState:{presentationMount:{eq:$presentationMount}}}}}) { + entries { node { ${fields.join("\n")} + ... on PresentationState { + _presentationOccurrence: occurrence + _presentationSource: source {_camino {ref}} + _presentationParent: parent {_camino {ref}} + _presentationMount: presentationMount {_camino {ref}} + } + } } + } } + }`, + ); + generated.push({ + id: local ? `presentation:navigation:${id}:view` : inputs, + displayName: name, + root: { kind: "workspace-interface", interface: contract.revisionId }, + document, + operation: name, + fragments: [], + views: [], + allowances: [], + budgets: { ...defaultQueryBudgets, rows: 5000, candidates: 5000, resultBytes: 8388608 }, + watch: local, + placement: local ? { kind: "local", sourceQueryId: inputs } : { kind: "replica" }, + }); + } + } + for (const query of generated) + if (pkg.queries?.some((existing) => existing.id === query.id || existing.displayName === query.displayName)) + throw Error(`Presentation query conflicts with an authored query: ${query.id}`); + pkg.queries = [...(pkg.queries ?? []), ...generated]; + return documents; +} diff --git a/src/capability-language/assembly.ts b/src/capability-language/assembly.ts index 43a4982..6399662 100644 --- a/src/capability-language/assembly.ts +++ b/src/capability-language/assembly.ts @@ -1,7 +1,8 @@ import { readFile, realpath } from "node:fs/promises"; import path from "node:path"; import { loadQxSources, resolveQxSources } from "./source-loader.js"; -import { compileRepositoryQuery } from "../query/compile.js"; +import { compileQuery, compileRepositoryQuery } from "../query/compile.js"; +import { presentationQueries } from "../bindings/presentation-queries.js"; import { checkQueryTemplate } from "../query/templates.js"; import { readRepositorySource } from "./source-loader.js"; import { parseControllerCatalog } from "../capability-model/controllers.js"; @@ -272,6 +273,7 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa ...(environment.interfaceClosure ?? []), ...(compiled.resource.specializations ?? []), ]; + const presentationDocuments = presentationQueries(compiled.resource.revision, queryInterfaces); for (const template of compiled.resource.revision.queryTemplates ?? []) await checkQueryTemplate(template, queryInterfaces, (name) => readRepositorySource(snapshot.directory, name), @@ -288,19 +290,23 @@ 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 result = 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 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; + }, + ); checked.set(id, result); return result; }; diff --git a/src/query/compile.ts b/src/query/compile.ts index bf754a1..29e83d5 100644 --- a/src/query/compile.ts +++ b/src/query/compile.ts @@ -171,6 +171,12 @@ export async function compileQuery( visit( document, visitWithTypeInfo(projectionTypes, { + Field(node) { + const parent = projectionTypes.getParentType(); + const member = + parent && generated.byName.get(parent.name)?.members.find((member) => member.displayName === node.name.value); + if (member?.kind === "relationship") generated.target(member); + }, InlineFragment(node) { if (!node.typeCondition) return; const from = projectionTypes.getParentType(); diff --git a/src/query/relational-schema.ts b/src/query/relational-schema.ts index 7b0f41e..8cc0435 100644 --- a/src/query/relational-schema.ts +++ b/src/query/relational-schema.ts @@ -44,6 +44,7 @@ export type SchemaRowField = { kind: "leaf"; type: ValueType } | { kind: "row"; export interface RelationalSchemaEnvironment { contracts: Map; target(member: RelationshipInterfaceMember): InterfaceRevisionId; + viewable(member: RelationshipInterfaceMember): boolean; name(id: InterfaceRevisionId): string; scalar(type: ValueType): GraphQLInputType & GraphQLOutputType; comparison(type: ValueType): GraphQLInputObjectType; @@ -83,7 +84,8 @@ export function relationalSchema(env: RelationalSchemaEnvironment, document?: Do ? env.contracts .get(row.interfaceRevisionId)! .members.filter( - (member): member is RelationshipInterfaceMember => member.kind === "relationship" && !!member.queryRead, + (member): member is RelationshipInterfaceMember => + member.kind === "relationship" && !!member.queryRead && env.viewable(member), ) : []; const fields = (row: QueryRow): Map => { @@ -96,6 +98,7 @@ export function relationalSchema(env: RelationalSchemaEnvironment, document?: Do env.contracts.get(row.interfaceRevisionId)!.members.flatMap((member): [string, SchemaRowField][] => { if (member.kind === "operation" || member.kind === "module" || !member.queryRead) return []; if (member.kind === "value") return [[member.displayName, { kind: "leaf", type: member.valueType }]]; + if (!env.viewable(member)) return []; if (many(member)) return []; return [ [ diff --git a/src/query/schema.ts b/src/query/schema.ts index 948ab25..afa307f 100644 --- a/src/query/schema.ts +++ b/src/query/schema.ts @@ -150,11 +150,15 @@ export function querySchema( return result; }; const structured = structuredQueryTypes(scalar, scalarComparison, direction); + const viewable = (member: RelationshipInterfaceMember) => + member.target.kind === "interface" || + declaration.views.some((view) => member.target.kind === "atom" && view.atomId === member.target.atomId); const comparison = (type: ValueType) => structured.input(type) as GraphQLInputObjectType; const relational = relationalSchema( { contracts, target, + viewable, name, scalar, comparison, @@ -205,6 +209,9 @@ export function querySchema( if (member.kind === "value") { fields[member.displayName] = { type: structured.output(member.valueType) }; } else { + // Reference-only projections need no views for unselected outgoing + // atom relationships. Selecting one still requires its explicit view. + if (!viewable(member)) continue; const targetId = target(member), node = object(targetId); if (member.cardinality === "exactly-one" || member.cardinality === "optional-one") diff --git a/test/query-compiler.test.ts b/test/query-compiler.test.ts index 9304918..649d15b 100644 --- a/test/query-compiler.test.ts +++ b/test/query-compiler.test.ts @@ -226,18 +226,33 @@ test("retired live syntax fails with migration guidance", async () => { test("implicit references reject aliases which occupy the canonical metadata path", async () => { for (const invalid of ["_camino: title", "_camino {ref: __typename}"]) - await assert.rejects(compile(`query Upcoming {root {items(first: 3) {entries {node {...Row}}}}}`, `fragment Row on TaskFacts {${invalid}}`), /QUERY_VALIDATION|Introspection/); + await assert.rejects( + compile( + `query Upcoming {root {items(first: 3) {entries {node {...Row}}}}}`, + `fragment Row on TaskFacts {${invalid}}`, + ), + /QUERY_VALIDATION|Introspection/, + ); }); test("implicit reference merges with conditional aggregate metadata", async () => { - const checked = await compileQuery({ ...fixture(""), fragments: [] }, [collection, facts], async () => - `query Upcoming($show: Boolean!) {root {_camino @include(if: $show) {relations {items {aggregate {count}}}}}}`); + const checked = await compileQuery( + { ...fixture(""), fragments: [] }, + [collection, facts], + async () => + `query Upcoming($show: Boolean!) {root {_camino @include(if: $show) {relations {items {aggregate {count}}}}}}`, + ); const metadata = checked.selection[0]!.selection.filter((field) => field.key === "_camino"); assert.equal(metadata.length, 1); assert.deepEqual(metadata[0]!.conditions, []); assert.equal(metadata[0]!.selection.find((field) => field.name === "relations")!.conditions.length, 1); assert.deepEqual(metadata[0]!.selection.find((field) => field.name === "ref")!.conditions, []); - assert.equal(metadata[0]!.selection.find((field) => field.name === "relations")!.selection[0]!.selection[0]!.selection.some((field) => field.name === "_camino"), false); + assert.equal( + metadata[0]!.selection + .find((field) => field.name === "relations")! + .selection[0]!.selection[0]!.selection.some((field) => field.name === "_camino"), + false, + ); }); test("qualified interface predicates and guaranteed inline projections retain nominal reads", async () => { @@ -280,6 +295,21 @@ test("qualified interface predicates and guaranteed inline projections retain no const node = objects.selection[0]!.selection[0]!; assert.equal(node.selection.find((s) => s.key === "ownerLabel")!.interfaceRevisionId, owned.revisionId); assert.equal(node.selection.filter((s) => s.key === "_camino").length, 1); + await assert.rejects( + compileQuery(declaration, [item, owned, owner], async () => + document.replace("ownerLabel: label", "ownerLabel: label _camino {ref}"), + ), + /QUERY_VALIDATION/, + ); + const explicitProjectionRef = await compileQuery(declaration, [item, owned, owner], async () => + document.replace("ownerLabel: label", "ownerLabel: label ownedIdentity: _camino {ref}"), + ); + const projectedNode = explicitProjectionRef.selection[0]!.selection[0]!.selection[0]!.selection[0]!; + assert.equal( + projectedNode.selection.find((field) => field.key === "_camino")!.selection.find((field) => field.key === "ref")! + .targetInterfaceRevisionId, + item.revisionId, + ); await assert.rejects( compileQuery(declaration, [item, owned, owner], async () => document.replace("ownerLabel: label", "label")), /QUERY_PROJECTION_CONFLICT|QUERY_VALIDATION/, @@ -369,3 +399,98 @@ test("query allowlist and two-sided RPC effects fail during compilation", async await rejects(compile(document.replace("...Row", "__typename ...Row")), "QUERY_UNSUPPORTED_FEATURE"); await rejects(compile(document, "fragment Row on TaskFacts { ...Row }"), "QUERY_VALIDATION"); }); + +test("unselected atom relationships do not require views on reference-only targets", async () => { + const target = iface(`external atom Detail id "detail"; + interface Referenced id "referenced" revision "referenced@1" { + queryable relation details id "details" : many atom Detail {resolve id "details:resolve";} + }`); + const subject = iface( + `external atom Detail id "detail"; import interface Referenced; + interface Subject id "subject" revision "subject@1" { + queryable relation selected id "selected" : optional-one interface Referenced {resolve id "selected:resolve";} + }`, + [target], + ); + const declaration = { + ...fixture(""), + root: { kind: "object" as const, interface: subject.revisionId }, + fragments: [], + }; + const document = "query Upcoming {root {selected {_camino {ref}}}}"; + const checked = await compileQuery(declaration, [subject, target], async () => document); + assert.match(JSON.stringify(checked.output), /referenced@1/); + await assert.rejects( + compileQuery( + declaration, + [subject, target], + async () => "query Upcoming {root {selected {details(all: 3) {entries {node {_camino {ref}}}}}}}", + ), + /QUERY_VIEW_REQUIRED/, + ); +}); + +test("React tooling generates checked owner queries from imported closed navigation fields", async () => { + const { presentationQueries } = await import("../src/bindings/presentation-queries.js"); + const mount = iface('interface PresentationMount id "mount" revision "mount@1" {}'); + const subject = iface('interface Subject id "subject" revision "subject@1" {}'); + const state = iface( + `import interface PresentationMount; import interface Subject; + interface PresentationState id "state" revision "state@1" { + queryable value occurrence id "occurrence" : string {get id "occurrence:get" execution snapshot targets [browser,server];} + queryable relation presentationMount id "mount" : optional-one interface PresentationMount {resolve id "mount:resolve" execution snapshot targets [browser,server];} + queryable relation parent id "parent" : optional-one interface PresentationState {resolve id "parent:resolve" execution snapshot targets [browser,server];} + queryable relation source id "source" : exactly-one interface Subject {resolve id "source:resolve" execution snapshot targets [browser,server];} + }`, + [mount, subject], + ); + const navigation = iface( + `import interface PresentationState; + interface Navigation id "navigation" revision "navigation@1" requires PresentationState { + queryable value custom id "custom" : T {get id "custom:get" execution snapshot targets [browser,server];} + }`, + [mount, subject, state], + ); + const react = iface( + `import interface PresentationState; + interface ReactComponent id "react" revision "react@1" { + module render id "render" contract "org.quixos.react.component/2" subject object Subject; + }`, + [mount, subject, state], + ); + const dependencies = [mount, subject, state, navigation, react]; + const parsed = compileCapabilityResourceSource( + `import interface ReactComponent; import interface Navigation; import interface Subject; + package Example id "example" revision "example@1" { + module Screen id "screen" implements ReactComponent>.render; + }`, + { + source, + environment: { + interfaces: new Map(dependencies.map((entry) => [entry.displayName, entry])), + interfaceClosure: dependencies, + }, + }, + ); + assert.ok(parsed.ok, JSON.stringify(parsed.diagnostics)); + if (parsed.resource.kind !== "package") throw Error("package required"); + const contracts = [...dependencies, ...(parsed.resource.specializations ?? [])]; + const documents = presentationQueries(parsed.resource.revision, contracts); + assert.equal(documents.size, 2); + const checked = await Promise.all( + parsed.resource.revision.queries!.map((query) => + compileQuery(query, contracts, async (name) => documents.get(name)!), + ), + ); + assert.deepEqual( + checked.map((query) => query.declaration.placement?.kind), + ["replica", "local"], + ); + assert.match(JSON.stringify(checked[0]!.output), /"custom":\{"kind":"record","fields":\{"label"/); + assert.match(JSON.stringify(checked[0]!.selection), /"kind":"as"/); + assert( + checked.every((query) => + query.effects.some((effect) => effect.memberId === "mount" && effect.uses.includes("predicate")), + ), + ); +}); diff --git a/test/query-fragments.test.ts b/test/query-fragments.test.ts index c202af9..880f0e9 100644 --- a/test/query-fragments.test.ts +++ b/test/query-fragments.test.ts @@ -34,7 +34,7 @@ test("exported fragments generate local projection and direct admission", async assert.equal(p.queries!.length, 2); const local = p.queries!.find((q) => !q.fragment?.admission)!; assert.equal(local.placement?.kind, "local"); - const checked = await compileQuery(local, [item], async () => "fragment Row on Item {_camino {ref} title}"); + const checked = await compileQuery(local, [item], async () => "fragment Row on Item {title}"); assert.equal(checked.output.kind, "record"); assert.match(checked.document, /query QuixosFragment/); await assert.rejects( @@ -47,7 +47,7 @@ test("imported fragments retain checked occurrences and require exact roots", as const checked = await compileQuery( provider.queries!.find((q) => !q.fragment?.admission)!, [item], - async () => "fragment Row on Item {_camino {ref} title}", + async () => "fragment Row on Item {title}", ); const consumer = pkg( 'query Screen id "screen" root Item document "screen.graphql" operation "Screen" {replica; fragments Provider.Row;}', @@ -145,7 +145,7 @@ test("independent fragment package survives execution lowering and typed client compileQuery( q, workspace.interfaceImports, - async () => "fragment CommentRow on CommentData {_camino {ref} content {text tags}}", + async () => "fragment CommentRow on CommentData {content {text tags}}", ), ), ); diff --git a/test/query-structured.test.ts b/test/query-structured.test.ts index 7b3697f..a53c735 100644 --- a/test/query-structured.test.ts +++ b/test/query-structured.test.ts @@ -70,8 +70,14 @@ test("record/list query values preserve nested projections and leaf predicates", } }; assertValuesOnly(node.selection.filter((field) => field.key !== "_camino")); - assert.equal(checked.selection[0]!.selection.some((field) => field.key === "_camino"), false); - assert.equal(extent.selection.some((field) => field.key === "_camino"), false); + assert.equal( + checked.selection[0]!.selection.some((field) => field.key === "_camino"), + false, + ); + assert.equal( + extent.selection.some((field) => field.key === "_camino"), + false, + ); assert.match(raw, /"begin"/); assert.match(raw, /"tags":\{"kind":"list"/); assert.equal(checked.effects.filter((e) => e.memberId === "range").length, 1);