Evaluate coherent local queries over explicit replica scopes
This commit is contained in:
@@ -49,13 +49,22 @@ export function generatePortableClientBindings(
|
||||
atoms.set(atom.displayName, list);
|
||||
}
|
||||
const queries = new Map<string, string[]>();
|
||||
const views = new Map<string, string[]>();
|
||||
for (const query of plan.definition.queries) {
|
||||
const declaration = query.query.checked.declaration;
|
||||
if (declaration.placement?.kind !== "replica") continue;
|
||||
if (!declaration.placement) continue;
|
||||
const pkg = workspace.packageImports.find((p) => p.revisionId === query.query.packageRevisionId)!;
|
||||
const alias = JSON.stringify([pkg.displayName, declaration.displayName]);
|
||||
if (seen.has(`query:${alias}`)) throw new PortableBindingError(alias, "Ambiguous query alias");
|
||||
seen.add(`query:${alias}`);
|
||||
if (declaration.placement.kind === "local") {
|
||||
const list = views.get(pkg.displayName) ?? [];
|
||||
list.push(
|
||||
`${JSON.stringify(declaration.displayName)}: ${JSON.stringify({ id: query.id, plan: plan.digest, source: JSON.stringify([query.query.packageRevisionId, declaration.placement.sourceQueryId]) })} as const satisfies LocalQueryDescriptor`,
|
||||
);
|
||||
views.set(pkg.displayName, list);
|
||||
continue;
|
||||
}
|
||||
const list = queries.get(pkg.displayName) ?? [];
|
||||
list.push(
|
||||
`${JSON.stringify(declaration.displayName)}: { id: ${JSON.stringify(query.id)}, plan: ${JSON.stringify(plan.digest)}, root: ${JSON.stringify(declaration.root.interface)}, rootKind: ${JSON.stringify(declaration.root.kind)} } as const satisfies ReplicaProjection<${JSON.stringify(declaration.root.interface)}, ${JSON.stringify(declaration.root.kind)}>`,
|
||||
@@ -64,5 +73,5 @@ export function generatePortableClientBindings(
|
||||
}
|
||||
const groups = (entries: Map<string, string[]>) =>
|
||||
[...entries].map(([name, values]) => `${JSON.stringify(name)}: {${values.join(",\n")}}`).join(",\n");
|
||||
return `// Generated application descriptors. Regenerate; do not edit.\nimport { defineOperationDescriptor, type ManagedReference, type ReplicaProjection } from ${JSON.stringify(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const planDigest = ${JSON.stringify(plan.digest)};\nexport const operations = {${groups(atoms)}} as const;\nexport const queries = {${groups(queries)}} as const;\nexport const unavailable = ${JSON.stringify(unavailable)} as const;\n`;
|
||||
return `// Generated application descriptors. Regenerate; do not edit.\nimport { defineOperationDescriptor, type ManagedReference, type ReplicaProjection, type LocalQueryDescriptor } from ${JSON.stringify(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const planDigest = ${JSON.stringify(plan.digest)};\nexport const operations = {${groups(atoms)}} as const;\nexport const queries = {${groups(queries)}} as const;\nexport const views = {${groups(views)}} as const;\nexport const unavailable = ${JSON.stringify(unavailable)} as const;\n`;
|
||||
}
|
||||
|
||||
@@ -312,14 +312,19 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan {
|
||||
"Local and replica queries must use the same root contract",
|
||||
);
|
||||
const traversals = (linked: LinkedQuery) => {
|
||||
const paths = new Set<string>();
|
||||
const paths = new Map<string, boolean>();
|
||||
const visit = (selections: LinkedQuery["checked"]["selection"], parents: string[]) => {
|
||||
for (const selection of selections) {
|
||||
const member = selection.interfaceRevisionId
|
||||
? interfaces.get(selection.interfaceRevisionId)?.members.find((m) => m.id === selection.memberId)
|
||||
: undefined;
|
||||
const path = member?.kind === "relationship" ? [...parents, member.id] : parents;
|
||||
if (member?.kind === "relationship") paths.add(JSON.stringify(path));
|
||||
if (member?.kind === "relationship") {
|
||||
const many = member.cardinality === "many" || member.cardinality === "many-unique";
|
||||
const complete = !selection.predicate && (!many || !!selection.arguments.all);
|
||||
const key = JSON.stringify(path);
|
||||
paths.set(key, complete || paths.get(key) === true);
|
||||
}
|
||||
visit(selection.selection, path);
|
||||
}
|
||||
};
|
||||
@@ -327,13 +332,20 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan {
|
||||
return paths;
|
||||
};
|
||||
const admittedTraversals = traversals(source.query);
|
||||
for (const traversal of traversals(query.query))
|
||||
for (const traversal of traversals(query.query).keys()) {
|
||||
if (!admittedTraversals.has(traversal))
|
||||
throw new ExecutionPlanError(
|
||||
"REPLICA_TRAVERSAL_MISSING",
|
||||
query.id,
|
||||
`Replica source does not admit relationship path ${traversal}`,
|
||||
);
|
||||
if (!admittedTraversals.get(traversal))
|
||||
throw new ExecutionPlanError(
|
||||
"REPLICA_RELATION_INCOMPLETE",
|
||||
query.id,
|
||||
`Local relationship ${traversal} needs an unfiltered bounded all selection in its replica source; a filtered/windowed source cannot supply unseen matches`,
|
||||
);
|
||||
}
|
||||
// The admission executor follows these canonical dependencies on the admitted roots.
|
||||
// They do not change source candidate membership or make local take/filters authoritative.
|
||||
source.admittedOperations.push(...query.operations);
|
||||
|
||||
@@ -173,6 +173,15 @@ test("local queries cannot silently expand the replica relationship traversal",
|
||||
assert.throws(() => compileExecutionPlan(workspace), /REPLICA_TRAVERSAL_MISSING/);
|
||||
});
|
||||
|
||||
test("local relationships require complete source candidates even when a filter initially matches", async () => {
|
||||
for (const selection of ["highlights(first: 10)", 'highlights(all: 1000, where: {color: {eq: "yellow"}})']) {
|
||||
const workspace = await loadReaderWorkspace((name, text) =>
|
||||
name === "queries/replica-inputs.graphql" ? text.replace("highlights(all: 1000)", selection) : text,
|
||||
);
|
||||
assert.throws(() => compileExecutionPlan(workspace), /REPLICA_RELATION_INCOMPLETE/);
|
||||
}
|
||||
});
|
||||
|
||||
test("closed generic applications retain distinct identities and codec shapes", async () => {
|
||||
const { compileCapabilityResourceSource, compileCapabilitySource } =
|
||||
await import("../src/capability-language/index.js");
|
||||
|
||||
@@ -79,6 +79,7 @@ revision "package:reader-runtime@1" {
|
||||
query HighlightScope id "reader:highlight-scope" root workspace HighlightData document "queries/highlight-scope.graphql" operation "HighlightScope" { view Comment as CommentData; replica; max rows 200; }
|
||||
query HighlightWindow id "reader:highlight-window" root workspace HighlightData document "queries/highlight-window.graphql" operation "HighlightWindow" { view Comment as CommentData; replica; max rows 200; }
|
||||
query HighlightList id "reader:highlight-list" root workspace HighlightData document "queries/highlight-list.graphql" operation "HighlightList" { view Comment as CommentData; local from HighlightScope; max rows 200; watch; }
|
||||
query HighlightWindowList id "reader:highlight-window-list" root workspace HighlightData document "queries/highlight-list.graphql" operation "HighlightList" { view Comment as CommentData; local from HighlightWindow; max rows 200; watch; }
|
||||
query HighlightEditorInputs id "reader:editor-inputs" root HighlightData document "queries/editor-inputs.graphql" operation "HighlightEditorInputs" { view Comment as CommentData; replica; max rows 1; }
|
||||
query HighlightEditorView id "reader:editor-view" root HighlightData document "queries/editor-view.graphql" operation "HighlightEditorView" { view Comment as CommentData; local from HighlightEditorInputs; max rows 1; watch; }
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import { test } from "node:test";
|
||||
import { compileQuery } from "../src/query/compile.js";
|
||||
import { linkQueries } from "../src/query/link.js";
|
||||
import { loadReaderWorkspace as load, readReaderFixture as read } from "./reader-fixture.js";
|
||||
test("complete reader contracts compose factories, portable selectors, CRDT edits, and six query scopes", async () => {
|
||||
test("complete reader contracts compose factories, portable selectors, CRDT edits, and bounded query scopes", async () => {
|
||||
const workspace = await load();
|
||||
assert.equal(workspace.constructors.length, 0);
|
||||
assert.equal(workspace.atoms.length, 3);
|
||||
const linked = linkQueries(workspace);
|
||||
assert.equal(linked.length, 7);
|
||||
assert.equal(linked.filter((q) => q.checked.declaration.placement?.kind === "local").length, 3);
|
||||
assert.equal(linked.length, 8);
|
||||
assert.equal(linked.filter((q) => q.checked.declaration.placement?.kind === "local").length, 4);
|
||||
const screen = linked.find((q) => q.checked.declaration.displayName === "ReaderScreen")!;
|
||||
assert.ok(screen.fields.some((f) => f.memberId === "highlight:excerpt" && f.binding.kind === "package"));
|
||||
assert.ok(screen.fields.some((f) => f.memberId === "comment:content" && f.binding.kind === "state"));
|
||||
|
||||
Reference in New Issue
Block a user