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);
|
atoms.set(atom.displayName, list);
|
||||||
}
|
}
|
||||||
const queries = new Map<string, string[]>();
|
const queries = new Map<string, string[]>();
|
||||||
|
const views = new Map<string, string[]>();
|
||||||
for (const query of plan.definition.queries) {
|
for (const query of plan.definition.queries) {
|
||||||
const declaration = query.query.checked.declaration;
|
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 pkg = workspace.packageImports.find((p) => p.revisionId === query.query.packageRevisionId)!;
|
||||||
const alias = JSON.stringify([pkg.displayName, declaration.displayName]);
|
const alias = JSON.stringify([pkg.displayName, declaration.displayName]);
|
||||||
if (seen.has(`query:${alias}`)) throw new PortableBindingError(alias, "Ambiguous query alias");
|
if (seen.has(`query:${alias}`)) throw new PortableBindingError(alias, "Ambiguous query alias");
|
||||||
seen.add(`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) ?? [];
|
const list = queries.get(pkg.displayName) ?? [];
|
||||||
list.push(
|
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)}>`,
|
`${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[]>) =>
|
const groups = (entries: Map<string, string[]>) =>
|
||||||
[...entries].map(([name, values]) => `${JSON.stringify(name)}: {${values.join(",\n")}}`).join(",\n");
|
[...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",
|
"Local and replica queries must use the same root contract",
|
||||||
);
|
);
|
||||||
const traversals = (linked: LinkedQuery) => {
|
const traversals = (linked: LinkedQuery) => {
|
||||||
const paths = new Set<string>();
|
const paths = new Map<string, boolean>();
|
||||||
const visit = (selections: LinkedQuery["checked"]["selection"], parents: string[]) => {
|
const visit = (selections: LinkedQuery["checked"]["selection"], parents: string[]) => {
|
||||||
for (const selection of selections) {
|
for (const selection of selections) {
|
||||||
const member = selection.interfaceRevisionId
|
const member = selection.interfaceRevisionId
|
||||||
? interfaces.get(selection.interfaceRevisionId)?.members.find((m) => m.id === selection.memberId)
|
? interfaces.get(selection.interfaceRevisionId)?.members.find((m) => m.id === selection.memberId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const path = member?.kind === "relationship" ? [...parents, member.id] : parents;
|
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);
|
visit(selection.selection, path);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -327,13 +332,20 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan {
|
|||||||
return paths;
|
return paths;
|
||||||
};
|
};
|
||||||
const admittedTraversals = traversals(source.query);
|
const admittedTraversals = traversals(source.query);
|
||||||
for (const traversal of traversals(query.query))
|
for (const traversal of traversals(query.query).keys()) {
|
||||||
if (!admittedTraversals.has(traversal))
|
if (!admittedTraversals.has(traversal))
|
||||||
throw new ExecutionPlanError(
|
throw new ExecutionPlanError(
|
||||||
"REPLICA_TRAVERSAL_MISSING",
|
"REPLICA_TRAVERSAL_MISSING",
|
||||||
query.id,
|
query.id,
|
||||||
`Replica source does not admit relationship path ${traversal}`,
|
`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.
|
// The admission executor follows these canonical dependencies on the admitted roots.
|
||||||
// They do not change source candidate membership or make local take/filters authoritative.
|
// They do not change source candidate membership or make local take/filters authoritative.
|
||||||
source.admittedOperations.push(...query.operations);
|
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/);
|
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 () => {
|
test("closed generic applications retain distinct identities and codec shapes", async () => {
|
||||||
const { compileCapabilityResourceSource, compileCapabilitySource } =
|
const { compileCapabilityResourceSource, compileCapabilitySource } =
|
||||||
await import("../src/capability-language/index.js");
|
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 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 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 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 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; }
|
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 { compileQuery } from "../src/query/compile.js";
|
||||||
import { linkQueries } from "../src/query/link.js";
|
import { linkQueries } from "../src/query/link.js";
|
||||||
import { loadReaderWorkspace as load, readReaderFixture as read } from "./reader-fixture.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();
|
const workspace = await load();
|
||||||
assert.equal(workspace.constructors.length, 0);
|
assert.equal(workspace.constructors.length, 0);
|
||||||
assert.equal(workspace.atoms.length, 3);
|
assert.equal(workspace.atoms.length, 3);
|
||||||
const linked = linkQueries(workspace);
|
const linked = linkQueries(workspace);
|
||||||
assert.equal(linked.length, 7);
|
assert.equal(linked.length, 8);
|
||||||
assert.equal(linked.filter((q) => q.checked.declaration.placement?.kind === "local").length, 3);
|
assert.equal(linked.filter((q) => q.checked.declaration.placement?.kind === "local").length, 4);
|
||||||
const screen = linked.find((q) => q.checked.declaration.displayName === "ReaderScreen")!;
|
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 === "highlight:excerpt" && f.binding.kind === "package"));
|
||||||
assert.ok(screen.fields.some((f) => f.memberId === "comment:content" && f.binding.kind === "state"));
|
assert.ok(screen.fields.some((f) => f.memberId === "comment:content" && f.binding.kind === "state"));
|
||||||
|
|||||||
Reference in New Issue
Block a user