diff --git a/src/bindings/presentation-queries.ts b/src/bindings/presentation-queries.ts index ec793e7..32fcbba 100644 --- a/src/bindings/presentation-queries.ts +++ b/src/bindings/presentation-queries.ts @@ -41,15 +41,19 @@ export function presentationQueries(pkg: PackageRevision, interfaces: readonly I ]; }); 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!) { + // Retain full state for operations and hidden occurrences, independently + // from the identity projection used to prepare a rendering occurrence. + for (const identity of [false, true]) { + const prefix = `presentation:navigation:${id}:${identity ? "identity-" : ""}`; + const inputs = prefix + "inputs"; + for (const local of [false, true]) { + const name = `${stem}${identity ? "Identity" : ""}${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")} + entries { node { ${identity ? "" : fields.join("\n")} ... on PresentationState { _presentationOccurrence: occurrence _presentationSource: source {_camino {ref}} @@ -59,20 +63,21 @@ export function presentationQueries(pkg: PackageRevision, interfaces: readonly I } } } } }`, - ); - 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" }, - }); + ); + generated.push({ + id: local ? prefix + "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) diff --git a/src/execution-world/client-contracts.ts b/src/execution-world/client-contracts.ts index 11ebdd2..81df4bc 100644 --- a/src/execution-world/client-contracts.ts +++ b/src/execution-world/client-contracts.ts @@ -258,17 +258,7 @@ export function generatePackageComponentBindings( target.kind === "atom" ? `ManagedReference<"atom", ${JSON.stringify(target.atomId)}>` : `ManagedReference<"interface", ${JSON.stringify(target.interfaceRevisionId)}>`; - const fields = - target.kind === "interface" - ? find(target.interfaceRevisionId).members.flatMap((member) => { - if ((member.kind !== "value" && member.kind !== "relationship") || !member.queryRead) return []; - const getter = member.operations.find( - (operation) => operation.displayName === (member.kind === "value" ? "get" : "resolve"), - ); - return getter ? [`readonly ${JSON.stringify(member.displayName)}: ${tsType(getter.outputType)}`] : []; - }) - : []; - return `{readonly reference: ${reference}; readonly values: {${fields.join(";")}}}`; + return `{readonly reference: ${reference}}`; }) .join(",")}]`; const interfaces = generateInterfaceClientBindings(imports, "@quixos/camino-react", props); diff --git a/src/query/tree.ts b/src/query/tree.ts index e2c6e81..ba7e319 100644 --- a/src/query/tree.ts +++ b/src/query/tree.ts @@ -113,6 +113,13 @@ export function lowerQueryTree( if (!root || selection.some((field) => field !== root)) throw new QueryCompileError("QUERY_TREE_ROOT", "A Query Tree requires one unaliased root selection"); const plan: QueryTreePlan = { root: "root", nodes: [], projections: [], maxOccurrences: 0 }; + const collectionBound = (field: QuerySelection): number | undefined => { + const limit = field.arguments.first ?? field.arguments.all; + // Checked GraphQL Int literals retain their decimal spelling in the IR. + if (limit?.kind !== "literal" || !["string", "number"].includes(typeof limit.value)) return; + const value = Number(limit.value); + return Number.isSafeInteger(value) && value > 0 ? Math.min(value, declaration.budgets.rows) : undefined; + }; const project = (id: string, subject: InterfaceRevisionId, fields: QuerySelection[], shape: ValueType) => { if (plan.projections.length >= 256) throw new QueryCompileError("QUERY_TREE_LIMIT", "Query Tree exceeds 256 projections"); @@ -153,9 +160,15 @@ export function lowerQueryTree( for (const field of fields) { if (field.boundary || !containsBoundary(field.selection)) continue; const targets: { path: string[]; node: string }[] = []; - const prune = (fields: QuerySelection[], shape: ValueType, path: string[], bound: number): QuerySelection[] => { - if (shape.kind === "optional") return prune(fields, shape.value, path, bound); - if (shape.kind === "list") return prune(fields, shape.value, [...path, "[]"], bound * declaration.budgets.rows); + const prune = ( + fields: QuerySelection[], + shape: ValueType, + path: string[], + bound: number, + listLimit = declaration.budgets.rows, + ): QuerySelection[] => { + if (shape.kind === "optional") return prune(fields, shape.value, path, bound, listLimit); + if (shape.kind === "list") return prune(fields, shape.value, [...path, "[]"], bound * listLimit); const target = objectInterface(fields); if (target) { targets.push({ @@ -168,7 +181,13 @@ export function lowerQueryTree( .filter((field) => containsBoundary(field.selection)) .map((field) => ({ ...field, - selection: prune(field.selection, fieldType(shape, field.key), [...path, field.key], bound), + selection: prune( + field.selection, + fieldType(shape, field.key), + [...path, field.key], + bound, + collectionBound(field) ?? listLimit, + ), })); // Relationship occurrence identity is its entry key, including duplicate // target Objects. Never substitute list position or Object identity. @@ -177,7 +196,10 @@ export function lowerQueryTree( return minimal; }; const type = fieldType(shape, field.key); - const minimal = { ...field, selection: prune(field.selection, type, [field.key], occurrences) }; + const minimal = { + ...field, + selection: prune(field.selection, type, [field.key], occurrences, collectionBound(field)), + }; const selected = [...identity(fields), minimal]; current.bindings.push({ name: field.key, diff --git a/src/react-build/expansion.ts b/src/react-build/expansion.ts index 4776f4c..10ff32b 100644 --- a/src/react-build/expansion.ts +++ b/src/react-build/expansion.ts @@ -364,6 +364,9 @@ export async function expandPresentationQueries(workspace: WorkspaceRevision): P for (const pkg of workspace.packageImports) { for (const original of [...(pkg.checkedQueries ?? [])]) { if (!original.declaration.id.startsWith("presentation:navigation:")) continue; + // Identity must remain independent of every occurrence's data projection. + // Full retained acquisition may include those requirements together. + if (/:identity-(inputs|view)$/.test(original.declaration.id)) continue; const inputs = (stateInputs.get(original.declaration.root.interface) ?? []).map((input) => ({ ...input, query: diff --git a/test/query-compiler.test.ts b/test/query-compiler.test.ts index 6ccac88..e8c7a4b 100644 --- a/test/query-compiler.test.ts +++ b/test/query-compiler.test.ts @@ -520,7 +520,7 @@ test("React tooling generates checked owner queries from imported closed navigat 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); + assert.equal(documents.size, 4); const checked = await Promise.all( parsed.resource.revision.queries!.map((query) => compileQuery(query, contracts, async (name) => documents.get(name)!), @@ -528,10 +528,16 @@ test("React tooling generates checked owner queries from imported closed navigat ); assert.deepEqual( checked.map((query) => query.declaration.placement?.kind), - ["replica", "local"], + ["replica", "local", "replica", "local"], ); assert.match(JSON.stringify(checked[0]!.output), /"custom":\{"kind":"record","fields":\{"label"/); assert.match(JSON.stringify(checked[0]!.selection), /"kind":"as"/); + for (const identity of checked.slice(2)) { + assert.match(identity.declaration.id, /:identity-(inputs|view)$/); + assert.doesNotMatch(JSON.stringify(identity.output), /"custom"/); + assert.match(JSON.stringify(identity.output), /_presentationOccurrence/); + assert.match(JSON.stringify(identity.output), /_presentationSource/); + } assert( checked.every((query) => query.effects.some((effect) => effect.memberId === "mount" && effect.uses.includes("predicate")), diff --git a/test/query-tree.test.ts b/test/query-tree.test.ts index 6e915f5..f9f08b5 100644 --- a/test/query-tree.test.ts +++ b/test/query-tree.test.ts @@ -29,12 +29,15 @@ const navigation = iface( }`, [item], ); -function declaration(root: InterfaceRevision = item) { +function declaration(root: InterfaceRevision = item, dependencies: InterfaceRevision[] = []) { const result = compileCapabilityResourceSource( `import interface ${root.displayName}; package Views id "views" revision "views@1" { query Open id "open" root ${root.displayName} document "open.graphql" operation "Open" {replica; max rows 10;} }`, - { source, environment: { interfaces: new Map([[root.displayName, root]]), interfaceClosure: [root, item] } }, + { + source, + environment: { interfaces: new Map([[root.displayName, root]]), interfaceClosure: [root, item, ...dependencies] }, + }, ); assert.ok(result.ok, JSON.stringify(result.diagnostics)); if (result.resource.kind !== "package") throw Error("Expected Package"); @@ -103,6 +106,44 @@ test("bounded list bindings preserve entry identity without requesting details", assert(!JSON.stringify(projection.selection).includes('"name":"body"')); assert(tree.maxOccurrences <= 256); }); +test("literal collection windows bound handles independently of detail row budgets", async () => { + const source = { ...declaration(navigation), budgets: { ...declaration(navigation).budgets, rows: 20000 } }; + const query = (limit: string, variables = "") => `query Open${variables} {root { + items(first: ${limit}) {entries {node {...Body @boundary(name:"body")}}} + }} fragment Body on Item {body}`; + const checked = await compileQuery(source, [item, navigation], async () => query("3")); + assert.equal(checked.tree!.maxOccurrences, 7); + await assert.rejects( + compileQuery(source, [item, navigation], async () => query("200")), + /QUERY_TREE_LIMIT/, + ); + await assert.rejects( + compileQuery(source, [item, navigation], async () => query("$first", "($first:Int!)")), + /QUERY_TREE_LIMIT/, + ); + const group = iface( + `import interface Item; interface Group id "group" revision "group@1" { + queryable relation items id "group:items" : many interface Item ordered {resolve id "group:items:resolve" execution snapshot;} + }`, + [item], + ); + const groups = iface( + `import interface Group; interface Groups id "groups" revision "groups@1" { + queryable relation groups id "groups:groups" : many interface Group ordered {resolve id "groups:groups:resolve" execution snapshot;} + }`, + [item, group], + ); + const nested = { ...declaration(groups, [group]), budgets: source.budgets }; + const nestedQuery = (outer: number, inner: number) => `query Open {root { + groups(first:${outer}) {entries {node {items(first:${inner}) {entries {node {...Body @boundary(name:"body")}}}}}} + }} fragment Body on Item {body}`; + const bounded = await compileQuery(nested, [item, group, groups], async () => nestedQuery(2, 3)); + assert.equal(bounded.tree!.maxOccurrences, 15, "Nested windows multiply instead of reusing the outer bound"); + await assert.rejects( + compileQuery(nested, [item, group, groups], async () => nestedQuery(20, 20)), + /QUERY_TREE_LIMIT/, + ); +}); test("guards control boundary participation and invalid syntax fails checking", async () => { const checked = await compileQuery( declaration(),