Standardize Camino query references and retire live field sidecars

This commit is contained in:
Timothy J. Aveni
2026-09-22 15:41:27 -07:00
parent 152f58a3c6
commit eba85ba158
28 changed files with 88 additions and 217 deletions
+5 -5
View File
@@ -34,16 +34,16 @@ test("controller discovery requires complete bounded accepted-state membership",
const check = async (document: string) =>
parseControllerCatalog(metadata, [iface], [await compileQuery(declaration, [iface], async () => document)]);
assert.equal(
(await check("query Enabled {root {objects(all:64,where:{enabled:{eq:true}}) {entries {node {_qx {ref}}}}}}"))
(await check("query Enabled {root {objects(all:64,where:{enabled:{eq:true}}) {entries {node {_camino {ref}}}}}}"))
.length,
1,
);
for (const document of [
"query Enabled {root {objects(first:64) {entries {node {_qx {ref}}}}}}",
"query Enabled {root {objects(all:65) {entries {node {_qx {ref}}}}}}",
"query Enabled {root {devices:objects(all:64) {entries {node {_qx {ref}}}}}}",
"query Enabled {root {objects(first:64) {entries {node {_camino {ref}}}}}}",
"query Enabled {root {objects(all:65) {entries {node {_camino {ref}}}}}}",
"query Enabled {root {devices:objects(all:64) {entries {node {_camino {ref}}}}}}",
"query Enabled {root {objects(all:64) {entries {node {enabled}}}}}",
"query Enabled($enabled:Boolean!) {root {objects(all:64,where:{enabled:{eq:$enabled}}) {entries {node {_qx {ref}}}}}}",
"query Enabled($enabled:Boolean!) {root {objects(all:64,where:{enabled:{eq:$enabled}}) {entries {node {_camino {ref}}}}}}",
])
await assert.rejects(check(document), /registry|Controller/);
assert.throws(() => parseControllerCatalog(metadata, [iface], []), /registry/);
+3 -3
View File
@@ -115,10 +115,10 @@ package Owner id "package:owner" revision "package:owner@1" {
async (name) =>
({
"inputs.graphql":
"query Inputs($label: String!) { root { objects(all: 100, where: {label: {eq: $label}}) { entries { key node { _qx {ref} label } } } } }",
"query Inputs($label: String!) { root { objects(all: 100, where: {label: {eq: $label}}) { entries { key node { _camino {ref} label } } } } }",
"view.graphql":
"query View { root { availableObjects(all: 100) { entries { key node { _qx {ref} label } } } } }",
"editor.graphql": "query Editor { root { _qx {ref} label body notes } }",
"query View { root { availableObjects(all: 100) { entries { key node { _camino {ref} label } } } } }",
"editor.graphql": "query Editor { root { _camino {ref} label body notes } }",
"editor-view.graphql": "query EditorView { root { label body notes } }",
})[name]!,
),
+1 -1
View File
@@ -302,7 +302,7 @@ test("a count-only local query still requires complete relationship admission",
const workspace = await loadReaderWorkspace((name, text) => {
if (name === "queries/replica-inputs.graphql") return source;
if (name === "queries/reader-screen.graphql")
return "query ReaderScreen { root { _qx { relations { highlights { aggregate { count } } } } } }";
return "query ReaderScreen { root { _camino { relations { highlights { aggregate { count } } } } } }";
return text;
});
assert.throws(() => compileExecutionPlan(workspace), /REPLICA_TRAVERSAL_MISSING|REPLICA_RELATION_INCOMPLETE/);
+1 -1
View File
@@ -1 +1 @@
query Enabled {root {objects(all:64, where:{enabled:{eq:true}}) {entries {node {_qx {ref}}}}}}
query Enabled {root {objects(all:64, where:{enabled:{eq:true}}) {entries {node {_camino {ref}}}}}}
@@ -1 +1 @@
query HighlightEditorInputs { root { _qx {ref} color range {sourceRevision start end} } }
query HighlightEditorInputs { root { _camino {ref} color range {sourceRevision start end} } }
@@ -1 +1 @@
query HighlightEditorView { root { _qx {ref} color @live range {sourceRevision start end} } }
query HighlightEditorView { root { _camino {ref} color range {sourceRevision start end} } }
@@ -1 +1 @@
query HighlightList($color: String!) { root { availableObjects(all: 200, where: {color: {eq: $color}}, orderBy: {range: {start: ASC}}) { entries { key node { _qx {ref} color @live range {sourceRevision start end} } } } } }
query HighlightList($color: String!) { root { availableObjects(all: 200, where: {color: {eq: $color}}, orderBy: {range: {start: ASC}}) { entries { key node { _camino {ref} color range {sourceRevision start end} } } } } }
@@ -1 +1 @@
query HighlightScope($color: String!) { root { objects(all: 200, where: {color: {eq: $color}}) { entries { key node { _qx {ref} color range {sourceRevision start end} } } } } }
query HighlightScope($color: String!) { root { objects(all: 200, where: {color: {eq: $color}}) { entries { key node { _camino {ref} color range {sourceRevision start end} } } } } }
@@ -1,7 +1,7 @@
query HighlightWindow($color: String!) {
root {
objects(first: 2, where: {color: {eq: $color}}, orderBy: {range: {start: ASC}}) {
entries { key node { _qx {ref} color range {sourceRevision start end} } }
entries { key node { _camino {ref} color range {sourceRevision start end} } }
pageInfo { hasNextPage endCursor }
}
}
@@ -6,16 +6,16 @@ query ReaderScreen($color: String!) {
entries {
key
node {
_qx { ref }
_camino { ref }
range { sourceRevision start end }
color @live
color
excerpt
comments(all: 200) {
entries { key node { _qx { ref } content @live { text tags } } }
entries { key node { _camino { ref } content { text tags } } }
}
}
}
}
_qx { relations { highlights { aggregate { count } } } }
_camino { relations { highlights { aggregate { count } } } }
}
}
@@ -6,12 +6,12 @@ query ReplicaInputs {
entries {
key
node {
_qx { ref }
_camino { ref }
range { sourceRevision start end }
color
excerpt
comments(all: 200) {
entries { key node { _qx { ref } content { text tags } } }
entries { key node { _camino { ref } content { text tags } } }
}
}
}
+1 -1
View File
@@ -67,5 +67,5 @@ export const compileAggregation = (body: string, variables = "") =>
compileQuery(
declaration,
interfaces,
async () => `query Report${variables} { root { _qx { relations { tasks { ${body} } } } } }`,
async () => `query Report${variables} { root { _camino { relations { tasks { ${body} } } } } }`,
);
+7 -10
View File
@@ -76,22 +76,19 @@ export async function queryWorkspaceFixture(options: { live?: boolean } = {}) {
const pkg: PackageRevision = pkgResult.resource.revision;
const allInterfaces = [...interfaces.values(), ...(pkgResult.resource.specializations ?? [])];
const documents: Record<string, string> = {
"extent.graphql": `query Extent($text: String! = "") {root {objects(first: 30, where: {title: {containsInsensitive: $text}}, orderBy: [{rank: ASC}]) {entries {node {_qx {ref} title rank}}}}}`,
"upcoming.graphql": `query Upcoming($first: Int!, $after: Cursor) {root {items(first: $first, after: $after, where: {done: {eq: false}}, orderBy: [{rank: ASC}]) {entries {key cursor node {_qx {ref} ...TaskRow}} pageInfo {hasNextPage endCursor}}}}`,
"extent.graphql": `query Extent($text: String! = "") {root {objects(first: 30, where: {title: {containsInsensitive: $text}}, orderBy: [{rank: ASC}]) {entries {node {_camino {ref} title rank}}}}}`,
"upcoming.graphql": `query Upcoming($first: Int!, $after: Cursor) {root {items(first: $first, after: $after, where: {done: {eq: false}}, orderBy: [{rank: ASC}]) {entries {key cursor node {_camino {ref} ...TaskRow}} pageInfo {hasNextPage endCursor}}}}`,
"row.graphql": `fragment TaskRow on TaskFacts {title rank done assignee {name}}`,
"enriched.graphql": `query Enriched {root {items(first: 3) {entries {key node {_qx {ref} title score}}}}}`,
"ranked.graphql": `query Ranked {root {items(first: 3, where: {score: {gt: 0}}, orderBy: [{score: DESC}]) {entries {key node {_qx {ref} title}}}}}`,
"totals.graphql": `query Totals {root {_qx {relations {items {aggregate {count sum {rank}} groups(by: {done: true}, first: 10) {entries {group {done} aggregate {count sum {rank}}}}}}}}}`,
"score-totals.graphql": `query ScoreTotals {root {_qx {relations {items {aggregate {count sum {score}}}}}}}`,
"enriched.graphql": `query Enriched {root {items(first: 3) {entries {key node {_camino {ref} title score}}}}}`,
"ranked.graphql": `query Ranked {root {items(first: 3, where: {score: {gt: 0}}, orderBy: [{score: DESC}]) {entries {key node {_camino {ref} title}}}}}`,
"totals.graphql": `query Totals {root {_camino {relations {items {aggregate {count sum {rank}} groups(by: {done: true}, first: 10) {entries {group {done} aggregate {count sum {rank}}}}}}}}}`,
"score-totals.graphql": `query ScoreTotals {root {_camino {relations {items {aggregate {count sum {score}}}}}}}`,
};
pkg.checkedQueries = await Promise.all(
pkg.queries!.map((query) =>
compileQuery(query, allInterfaces, async (name) =>
options.live
? documents[name]!.replace(/\btitle\b/g, "title @live").replace(
"title @live rank done",
"title @live rank @live done @live",
)
? documents[name]!.replace(/\btitle\b/g, "title").replace("title rank done", "title rank done")
: documents[name]!,
),
),
+1 -1
View File
@@ -81,7 +81,7 @@ test("bind chains forward native reads, writes and watches without changing stor
await compileQuery(
revision.queries![0]!,
[...interfaces.values()],
async () => `query Rows {root {objects(first: 10) {entries {node {title @live}}}}}`,
async () => `query Rows {root {objects(first: 10) {entries {node {title}}}}}`,
),
];
result.workspace.packageImports.push(revision);
+3 -8
View File
@@ -107,15 +107,10 @@ test("server projection descriptors are distinct from canonical inputs and have
assert.match(projections, /ConfirmedColor/);
assert.match(projections, /satisfies ServerProjection/);
assert.match(source.slice(source.indexOf("export const results")), /ConfirmedColor/);
const live = compileExecutionPlan(
await loadReaderWorkspace((name, text) =>
await assert.rejects(
loadReaderWorkspace((name, text) =>
name === "queries/confirmed-color.graphql" ? text.replace("color", "color @live") : text,
),
/@live is retired/,
);
const rejected = generatePortableClientBindings(live, { target: "browser" });
assert.doesNotMatch(
rejected.slice(rejected.indexOf("export const serverProjections"), rejected.indexOf("export const results")),
/ConfirmedColor/,
);
assert.match(rejected, /QUERY_NOT_SNAPSHOT_PROJECTION/);
});
+5 -5
View File
@@ -26,7 +26,7 @@ test("aggregate output uses exact count and sum types and nullable reductions",
test("explicit expansion and identity distinct retain checked grouped operands", async () => {
const checked = await compile(`expand { tags {
distinct(by: {source: {_qx: {ref: true}}, target: {color: true}}) {
distinct(by: {source: {_camino: {ref: true}}, target: {color: true}}) {
groups(by: {target: {color: true}}, first: 50,
having: {sum: {source: {estimatedHours: {gt: 0}}}},
orderBy: [{sum: {source: {estimatedHours: DESC}}}]) {
@@ -55,7 +55,7 @@ test("distinct and grouping reject fields not determined by their selected keys"
test("relationship existence predicates compile to typed plans", async () => {
const checked = await compile(
`filter(where: {_qx: {relations: {tags: {some: {color: {in: ["red","blue"]}}}}}}) {aggregate {count}}`,
`filter(where: {_camino: {relations: {tags: {some: {color: {in: ["red","blue"]}}}}}}) {aggregate {count}}`,
);
const plan = findPlan(checked.selection)!;
assert.equal(plan.stages[1]!.operation.kind, "filter");
@@ -88,19 +88,19 @@ test("RPC continuation rejection is local to the affected membership boundary",
);
const checked = await compileAggregationDocument(`query Report($after: Cursor) {root {
tasks(first: 2, after: $after) {entries {node {cost}}}
_qx {relations {tasks {aggregate {sum {score}}}}}
_camino {relations {tasks {aggregate {sum {score}}}}}
}}`);
assert.equal(findPlan(checked.selection)!.terminals[0]!.residual, true);
});
test("typed reference operands and scalar membership tests reject unbounded literal lists", async () => {
const checked = await compile(
`filter(where: {_qx: {ref: {in: $tasks}}}) {aggregate {count}}`,
`filter(where: {_camino: {ref: {in: $tasks}}}) {aggregate {count}}`,
"($tasks: [QxRef_Task!]!)",
);
assert.match(JSON.stringify(checked.variables), /object-ref/);
await assert.rejects(
compile(`filter(where: {_qx: {ref: {eq: "obj:made-up"}}}) {aggregate {count}}`),
compile(`filter(where: {_camino: {ref: {eq: "obj:made-up"}}}) {aggregate {count}}`),
/typed variables/,
);
await assert.rejects(
+14 -32
View File
@@ -4,7 +4,6 @@ import { compileCapabilityResourceSource } from "../src/capability-language/inde
import { compileQuery } from "../src/query/compile.js";
import { QueryCompileError } from "../src/query/types.js";
import { checkQueryTemplate } from "../src/query/templates.js";
import { queryPresentation } from "../src/query/presentation.js";
import { querySelectionToWire } from "../src/query/proto.js";
import type { InterfaceRevision } from "../src/capability-model/types.js";
@@ -149,9 +148,9 @@ test("workspace roots expose a bounded interface extent, not a root object or wr
async () => `query Upcoming {
root {
objects(first: 3, where: {done: {eq: false}}, orderBy: {title: ASC}) {
entries {key node {_qx {ref} title @live}} pageInfo {hasNextPage endCursor}
entries {key node {_camino {ref} title}} pageInfo {hasNextPage endCursor}
}
_qx {relations {objects {aggregate {count sum {due}}}}}
_camino {relations {objects {aggregate {count sum {due}}}}}
}
}`,
);
@@ -159,7 +158,7 @@ test("workspace roots expose a bounded interface extent, not a root object or wr
assert.ok(!checked.effects.some((effect) => effect.memberId.includes("workspace-objects")));
assert.ok(JSON.stringify(checked.selection).includes("qx-internal-workspace-objects"));
await assert.rejects(
compileQuery(declaration, [facts], async () => `query Upcoming {root {_qx {ref}}}`),
compileQuery(declaration, [facts], async () => `query Upcoming {root {_camino {ref}}}`),
/QUERY_VALIDATION/,
);
await assert.rejects(
@@ -169,51 +168,34 @@ test("workspace roots expose a bounded interface extent, not a root object or wr
});
const document = `query Upcoming($first: Int!, $before: Int64!) {
root { items(first: $first, where: {done: {eq: false}, due: {lt: $before}}, orderBy: [{due: ASC}]) {
entries { key node { _qx { ref } ...Row } } pageInfo { hasNextPage endCursor }
entries { key node { _camino { ref } ...Row } } pageInfo { hasNextPage endCursor }
} }
}`;
const compile = (query = document, row = "fragment Row on TaskFacts { title due }", clauses = "") =>
compileQuery(fixture(clauses), [collection, facts], async (name) => (name.endsWith("row.graphql") ? row : query));
test("live selections preserve aliases, nullable values, fragment conditions and wire identity", async () => {
test("ordinary selections preserve aliases, fragment conditions and wire identity", async () => {
const checked = await compile(
`query Upcoming($show: Boolean!) {root {items(first: 3) {entries {node {plain: title ...Row @include(if: $show)}}}}}`,
`fragment Row on TaskFacts {label: title @live due @live}`,
`fragment Row on TaskFacts {label: title due}`,
);
const fields = queryPresentation(checked, [collection, facts]);
const node = checked.selection[0]!.selection[0]!.selection[0]!.selection[0]!;
assert.deepEqual(
fields.map((f) => f.path),
[
["root", "items", "entries", "*", "node", "label"],
["root", "items", "entries", "*", "node", "due"],
],
node.selection.map((s) => s.key),
["plain", "label", "due"],
);
assert.equal(fields[0]!.getOperationId, "title:get");
assert.equal(fields[0]!.setOperationId, undefined);
assert.equal(fields[1]!.valueType.kind, "optional");
assert.equal(fields[0]!.conditions.length, 1);
assert.ok(JSON.stringify(checked.selection.map(querySelectionToWire)).includes(fields[0]!.selectionId));
const defaults = await compile(
`query Upcoming($show: Boolean! = true) {root {items(first: 3) {entries {node {...Row @include(if: $show)}}}}}`,
"fragment Row on TaskFacts {title @live}",
);
assert.equal(queryPresentation(defaults, [collection, facts])[0]!.conditions[0]!.defaultValue, true);
assert.equal(node.selection[1]!.conditions.length, 1);
assert.ok(JSON.stringify(checked.selection.map(querySelectionToWire)).includes("label"));
});
test("live selections reject RPC getters, synthetic selections and conflicting presentations", async () => {
for (const [selected, code] of [
["score @live", "QUERY_LIVE_UNSUPPORTED"],
["_qx @live {ref}", "QUERY_LIVE_UNSUPPORTED"],
["title @live title", "QUERY_LIVE_CONFLICT"],
["title @live(unchecked: true)", "QUERY_VALIDATION"],
])
test("retired live syntax fails with migration guidance", async () => {
for (const selected of ["score @live", "_camino @live {ref}", "title @live title", "title @live(unchecked: true)"])
await assert.rejects(
compile(
`query Upcoming {root {items(first: 3) {entries {node {...Row}}}}}`,
`fragment Row on TaskFacts {${selected}}`,
'allow TaskFacts.score select "bounded";',
),
(error: unknown) => error instanceof QueryCompileError && error.code === code,
(error: unknown) => error instanceof QueryCompileError && error.code === "QUERY_RETIRED_DIRECTIVE",
);
});
+1 -1
View File
@@ -39,7 +39,7 @@ test("reference-only workspace queries hash every eligible conformance, includin
const checked = await compileQuery(
query,
workspace.interfaceImports,
async () => `query ${query.operation} {root {objects(first: 10) {entries {node {_qx {ref}}}}}}`,
async () => `query ${query.operation} {root {objects(first: 10) {entries {node {_camino {ref}}}}}}`,
);
pkg.checkedQueries = pkg.checkedQueries!.map((q) => (q.declaration.id === query.id ? checked : q));
const linked = linkQueries(workspace).find((q) => q.id.endsWith(":upcoming"))!;
+5 -11
View File
@@ -33,7 +33,7 @@ test("React bindings preserve read-only, writable and nested reference contracts
await compileQuery(
pkg.resource.revision.queries![0]!,
[iface.resource.revision],
async () => "query Editor {root {title @live summary @live plain: title}}",
async () => "query Editor {root {title summary plain: title}}",
),
];
const schema = {
@@ -79,16 +79,10 @@ test("React bindings preserve read-only, writable and nested reference contracts
import {reactInterfaces} from "./react-props.gen.js";
import type {ReactResults, QueryResults} from "./react-props.gen.js";
declare const query: QueryResults["Editor"];
useLiveField(query.root.title).set("new");
useLiveField(detachField(query.root.title)).set("new");
// @ts-expect-error detachment preserves readonly access
useLiveField(detachField(query.root.summary)).set("no");
// @ts-expect-error query handles do not expose transport metadata
query.root.title.capability;
// @ts-expect-error live query fields retain exact setter types
useLiveField(query.root.title).set(123);
// @ts-expect-error readonly query fields do not acquire a setter
useLiveField(query.root.summary).set("no");
const queryTitle: string = query.root.title;
const querySummary: string = query.root.summary;
// @ts-expect-error query values are not field mutation handles
query.root.title.set("new");
const plain: string = query.root.plain;
async function lookup() {
const view = await tryConform("object", reactInterfaces.Fields);