Add exported query fragments and scoped coverage observations
This commit is contained in:
@@ -163,8 +163,10 @@ presentationDecl
|
||||
;
|
||||
|
||||
packageQuery
|
||||
: QUERY identifier typeParameters? ID stringLiteral ROOT WORKSPACE? interfaceType
|
||||
DOCUMENT stringLiteral OPERATION stringLiteral LBRACE queryClause* RBRACE
|
||||
: QUERY FRAGMENT? identifier typeParameters? ID stringLiteral ROOT WORKSPACE? interfaceType
|
||||
DOCUMENT stringLiteral
|
||||
(OPERATION stringLiteral LBRACE queryClause* RBRACE
|
||||
| FRAGMENT stringLiteral (OPERATION stringLiteral)? (LBRACE queryClause* RBRACE | SEMI))
|
||||
;
|
||||
|
||||
packageQuerySpecialization
|
||||
@@ -175,6 +177,7 @@ queryClause
|
||||
: REPLICA SEMI
|
||||
| LOCAL FROM identifier SEMI
|
||||
| FRAGMENTS stringLiteral SEMI
|
||||
| FRAGMENTS identifier DOT identifier (WITH LBRACE fragmentVariable* RBRACE)? SEMI
|
||||
| VIEW OBJECT? identifier AS interfaceType SEMI
|
||||
| MAX identifier INTEGER SEMI
|
||||
| ALLOW interfaceType DOT identifier identifier stringLiteral SEMI
|
||||
@@ -182,6 +185,8 @@ queryClause
|
||||
| POLL INTEGER stringLiteral SEMI
|
||||
;
|
||||
|
||||
fragmentVariable : identifier TO identifier SEMI;
|
||||
|
||||
packageOperationExport
|
||||
: OPERATION identifier typeParameters? ID stringLiteral COLON valueType ARROW valueType
|
||||
MODE operationMode executionClause? eventClause? RECEIVER receiverRequirement dependencyBlock? acceptanceClause? SEMI
|
||||
@@ -482,6 +487,7 @@ identifier
|
||||
| ROOT
|
||||
| DOCUMENT
|
||||
| FRAGMENTS
|
||||
| FRAGMENT
|
||||
| VIEW
|
||||
| MAX
|
||||
| ALLOW
|
||||
|
||||
@@ -276,12 +276,37 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa
|
||||
await checkQueryTemplate(template, queryInterfaces, (name) =>
|
||||
readRepositorySource(snapshot.directory, name),
|
||||
);
|
||||
if (compiled.resource.revision.queries?.length)
|
||||
compiled.resource.revision.checkedQueries = await Promise.all(
|
||||
compiled.resource.revision.queries.map((query) =>
|
||||
compileRepositoryQuery(snapshot.directory, query, queryInterfaces),
|
||||
),
|
||||
);
|
||||
if (compiled.resource.revision.queries?.length) {
|
||||
const pkg = compiled.resource.revision;
|
||||
const checked = new Map<string, import("../query/types.js").CheckedQuery>();
|
||||
const compile = async (
|
||||
id: string,
|
||||
parents: readonly string[] = [],
|
||||
): Promise<import("../query/types.js").CheckedQuery> => {
|
||||
if (parents.includes(id)) throw new Error(`Query fragment cycle: ${[...parents, id].join(" -> ")}`);
|
||||
const previous = checked.get(id);
|
||||
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;
|
||||
},
|
||||
);
|
||||
checked.set(id, result);
|
||||
return result;
|
||||
};
|
||||
pkg.checkedQueries = [];
|
||||
for (const query of pkg.queries!) pkg.checkedQueries.push(await compile(query.id));
|
||||
}
|
||||
let controllerText: string | undefined;
|
||||
try {
|
||||
controllerText = await readRepositorySource(snapshot.directory, "quixos.controllers.json");
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@ import { PresentationDeclContext } from "./QuixosCapabilityParser.js";
|
||||
import { PackageQueryContext } from "./QuixosCapabilityParser.js";
|
||||
import { PackageQuerySpecializationContext } from "./QuixosCapabilityParser.js";
|
||||
import { QueryClauseContext } from "./QuixosCapabilityParser.js";
|
||||
import { FragmentVariableContext } from "./QuixosCapabilityParser.js";
|
||||
import { PackageOperationExportContext } from "./QuixosCapabilityParser.js";
|
||||
import { PackageFunctionExportContext } from "./QuixosCapabilityParser.js";
|
||||
import { AcceptanceClauseContext } from "./QuixosCapabilityParser.js";
|
||||
@@ -291,6 +292,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
||||
* @return the visitor result
|
||||
*/
|
||||
visitQueryClause?: (ctx: QueryClauseContext) => Result;
|
||||
/**
|
||||
* Visit a parse tree produced by `QuixosCapabilityParser.fragmentVariable`.
|
||||
* @param ctx the parse tree
|
||||
* @return the visitor result
|
||||
*/
|
||||
visitFragmentVariable?: (ctx: FragmentVariableContext) => Result;
|
||||
/**
|
||||
* Visit a parse tree produced by `QuixosCapabilityParser.packageOperationExport`.
|
||||
* @param ctx the parse tree
|
||||
|
||||
@@ -1380,6 +1380,14 @@ const lowerPackage = (
|
||||
if (exportContext.packageQuerySpecialization()) return [];
|
||||
const query = exportContext.packageQuery();
|
||||
if (query) {
|
||||
const isFragment = query.FRAGMENT().length > 0;
|
||||
if (isFragment && (query.FRAGMENT().length !== 2 || query.typeParameters() || query.WORKSPACE()))
|
||||
loweringIssue(
|
||||
state,
|
||||
query,
|
||||
"invalid-query-fragment",
|
||||
"Export a fragment on an exact object interface; generic fragments and workspace fragment roots are not supported",
|
||||
);
|
||||
const previousParameters = state.types.parameters;
|
||||
state.types.parameters = new Map();
|
||||
try {
|
||||
@@ -1403,7 +1411,19 @@ const lowerPackage = (
|
||||
displayName: identifier(query.identifier()),
|
||||
root: { kind: query.WORKSPACE() ? "workspace-interface" : "object", interface: close(query.interfaceType()) },
|
||||
document: stringValue(query.stringLiteral(1)),
|
||||
operation: stringValue(query.stringLiteral(2)),
|
||||
operation: isFragment
|
||||
? query.stringLiteral(3)
|
||||
? stringValue(query.stringLiteral(3))
|
||||
: "QuixosFragment"
|
||||
: stringValue(query.stringLiteral(2)),
|
||||
...(isFragment
|
||||
? {
|
||||
fragment: {
|
||||
name: stringValue(query.stringLiteral(2)),
|
||||
...(query.stringLiteral(3) ? { operation: stringValue(query.stringLiteral(3)) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
fragments: [],
|
||||
views: [],
|
||||
allowances: [],
|
||||
@@ -1436,8 +1456,32 @@ const lowerPackage = (
|
||||
);
|
||||
if (sourceQueryId) declaration.placement = { kind: "local", sourceQueryId };
|
||||
}
|
||||
} else if (clause.FRAGMENTS()) declaration.fragments.push(stringValue(clause.stringLiteral()!));
|
||||
else if (clause.VIEW()) {
|
||||
} else if (clause.FRAGMENTS()) {
|
||||
if (clause.stringLiteral()) declaration.fragments.push(stringValue(clause.stringLiteral()!));
|
||||
else {
|
||||
const imported = lowerQueryReference(
|
||||
state,
|
||||
identifier(clause.identifier(0)),
|
||||
identifier(clause.identifier(1)),
|
||||
clause,
|
||||
);
|
||||
if (imported) {
|
||||
const variables: Record<string, string> = {};
|
||||
for (const mapping of clause.fragmentVariable()) {
|
||||
const name = identifier(mapping.identifier(0));
|
||||
if (Object.hasOwn(variables, name))
|
||||
loweringIssue(state, mapping, "duplicate-fragment-variable", `Repeated fragment variable ${name}`);
|
||||
variables[name] = identifier(mapping.identifier(1));
|
||||
}
|
||||
(declaration.importedFragments ??= []).push({
|
||||
packageRevisionId: imported.packageRevisionId,
|
||||
packageAlias: identifier(clause.identifier(0)),
|
||||
fragmentId: imported.queryId,
|
||||
variables,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (clause.VIEW()) {
|
||||
const name = identifier(clause.identifier(0));
|
||||
if (parameters.length) {
|
||||
const parameter = state.types.parameters.get(name);
|
||||
@@ -1548,6 +1592,23 @@ const lowerPackage = (
|
||||
views: templateViews,
|
||||
allowances: templateAllowances,
|
||||
});
|
||||
} else if (isFragment) {
|
||||
if (declaration.placement || declaration.polling || declaration.allowances.length)
|
||||
loweringIssue(
|
||||
state,
|
||||
query,
|
||||
"invalid-query-fragment",
|
||||
"Fragments are local projections with generated admission; remote allowances, polling and explicit placement are not supported",
|
||||
);
|
||||
const sourceQueryId = `${declaration.id}:fragment-inputs`;
|
||||
queries.push({
|
||||
...structuredClone(declaration),
|
||||
id: sourceQueryId,
|
||||
displayName: `${declaration.displayName}$inputs`,
|
||||
placement: { kind: "replica" },
|
||||
fragment: { ...declaration.fragment!, admission: true },
|
||||
});
|
||||
queries.push({ ...declaration, placement: { kind: "local", sourceQueryId } });
|
||||
} else queries.push(declaration);
|
||||
return [];
|
||||
} finally {
|
||||
|
||||
@@ -124,12 +124,64 @@ export function generateInterfaceClientBindings(
|
||||
export function generatePackageQueryClientBindings(
|
||||
pkg: PackageRevision,
|
||||
runtimeModule = "@quixos/camino-replica-core",
|
||||
packages: readonly PackageRevision[] = [pkg],
|
||||
): string {
|
||||
const queries = (pkg.checkedQueries ?? []).map(
|
||||
(query) =>
|
||||
`${JSON.stringify(query.declaration.displayName)}: definePackageQuery<${tsType(query.variables)}, ${tsType(query.output)}, ${JSON.stringify(publicQueryContract(pkg, query.declaration.id).placement)}>(${JSON.stringify(publicQueryContract(pkg, query.declaration.id))})`,
|
||||
);
|
||||
return `// Generated from checked package query contracts. Regenerate; do not edit.\nimport { definePackageQuery, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const queries = {${queries.join(",\n")}} as const;\n`;
|
||||
const queries = (pkg.checkedQueries ?? [])
|
||||
.filter((query) => !query.declaration.fragment)
|
||||
.map(
|
||||
(query) =>
|
||||
`${JSON.stringify(query.declaration.displayName)}: definePackageQuery<${tsType(query.variables)}, ${tsType(query.output)}, ${JSON.stringify(publicQueryContract(pkg, query.declaration.id).placement)}>(${JSON.stringify(publicQueryContract(pkg, query.declaration.id))})`,
|
||||
);
|
||||
const emitFragment = (provider: PackageRevision, id: string) => {
|
||||
const query = provider.checkedQueries?.find((query) => query.declaration.id === id);
|
||||
if (
|
||||
!query?.declaration.fragment ||
|
||||
query.declaration.fragment.admission ||
|
||||
query.declaration.placement?.kind !== "local" ||
|
||||
query.output.kind !== "record" ||
|
||||
!query.output.fields.root
|
||||
)
|
||||
throw new ExecutionPlanError("UNCHECKED_FRAGMENT", id, "Expected a checked local fragment export");
|
||||
const output = query.output.fields.root;
|
||||
const descriptor = {
|
||||
package: provider.revisionId,
|
||||
fragment: id,
|
||||
digest: query.definitionDigest,
|
||||
subject: query.declaration.root.interface,
|
||||
input: portableValueSchema(query.variables),
|
||||
output: portableValueSchema(output),
|
||||
admission: publicQueryContract(provider, query.declaration.placement.sourceQueryId),
|
||||
view: publicQueryContract(provider, id),
|
||||
};
|
||||
return `definePackageFragment<${tsType(query.variables)}, ${tsType(output)}, ${JSON.stringify(query.declaration.root.interface)}>(${JSON.stringify(descriptor)})`;
|
||||
};
|
||||
const fragments = (pkg.checkedQueries ?? [])
|
||||
.filter((query) => query.declaration.fragment && !query.declaration.fragment.admission)
|
||||
.map((query) => `${JSON.stringify(query.declaration.displayName)}: ${emitFragment(pkg, query.declaration.id)}`);
|
||||
const imports = new Map<string, Map<string, string>>();
|
||||
for (const query of pkg.queries ?? [])
|
||||
for (const imported of query.importedFragments ?? []) {
|
||||
if (imported.packageRevisionId === pkg.revisionId) continue;
|
||||
const provider = packages.find((provider) => provider.revisionId === imported.packageRevisionId);
|
||||
const name = provider?.queries?.find((query) => query.id === imported.fragmentId)?.displayName;
|
||||
if (!provider || !name)
|
||||
throw new ExecutionPlanError("UNCHECKED_FRAGMENT", imported.fragmentId, "Missing imported fragment provider");
|
||||
const alias = imported.packageAlias ?? provider.displayName;
|
||||
if (pkg.queries?.some((query) => query.fragment && query.displayName === alias))
|
||||
throw new ExecutionPlanError(
|
||||
"FRAGMENT_ALIAS_COLLISION",
|
||||
alias,
|
||||
"Imported fragment namespace conflicts with a local fragment export",
|
||||
);
|
||||
const entries = imports.get(alias) ?? new Map<string, string>();
|
||||
entries.set(name, emitFragment(provider, imported.fragmentId));
|
||||
imports.set(alias, entries);
|
||||
}
|
||||
for (const [alias, entries] of imports)
|
||||
fragments.push(
|
||||
`${JSON.stringify(alias)}: {${[...entries].map(([name, value]) => `${JSON.stringify(name)}: ${value}`).join(",")}}`,
|
||||
);
|
||||
return `// Generated from checked package query contracts. Regenerate; do not edit.\nimport { definePackageQuery, definePackageFragment, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const queries = {${queries.join(",\n")}} as const;\nexport const fragments = {${fragments.join(",\n")}} as const;\n`;
|
||||
}
|
||||
|
||||
/** Receipt-covered bindings expose the package's imports, component subjects and
|
||||
@@ -157,13 +209,17 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi
|
||||
.filter((line) => !line.startsWith("import "))
|
||||
.join("\n")
|
||||
.replace("export const interfaces =", "export const subjects =");
|
||||
const queries = generatePackageQueryClientBindings(pkg, "@quixos/camino-react")
|
||||
const queries = generatePackageQueryClientBindings(
|
||||
pkg,
|
||||
"@quixos/camino-react",
|
||||
plan.definition.workspace.packageImports,
|
||||
)
|
||||
.split("\n")
|
||||
.filter((line) => !line.startsWith("import "))
|
||||
.join("\n");
|
||||
return (
|
||||
interfaces +
|
||||
'\nimport {definePackageQuery,type ComponentType,type ComponentProps} from "@quixos/camino-react";\n' +
|
||||
'\nimport {definePackageQuery,definePackageFragment,type ComponentType,type ComponentProps} from "@quixos/camino-react";\n' +
|
||||
subjectBindings +
|
||||
"\n" +
|
||||
queries +
|
||||
|
||||
@@ -30,6 +30,15 @@ export function compileQueryProgram(plan: ExecutionPlan): unknown {
|
||||
const checked = query.checked;
|
||||
return {
|
||||
id,
|
||||
...(checked.declaration.fragment && !checked.declaration.fragment.admission
|
||||
? {
|
||||
fragment: {
|
||||
package: query.packageRevisionId,
|
||||
id: checked.declaration.id,
|
||||
digest: checked.definitionDigest,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
placement: checked.declaration.placement?.kind ?? "remote",
|
||||
source:
|
||||
checked.declaration.placement?.kind === "local"
|
||||
|
||||
+30
-7
@@ -37,6 +37,7 @@ import { querySchema } from "./schema.js";
|
||||
import { objectRow } from "./relational-schema.js";
|
||||
import { workspaceQueryRootId } from "./root.js";
|
||||
import { relationalCompiler } from "./relational-compile.js";
|
||||
import { fragmentSources, type FragmentResolver } from "./fragments.js";
|
||||
import {
|
||||
QueryCompileError,
|
||||
type QueryDeclaration,
|
||||
@@ -67,6 +68,7 @@ export async function compileQuery(
|
||||
declaration: QueryDeclaration,
|
||||
interfaces: readonly InterfaceRevision[],
|
||||
read: (name: string) => Promise<string>,
|
||||
resolveFragment?: FragmentResolver,
|
||||
): Promise<CheckedQuery> {
|
||||
const paths = [declaration.document, ...declaration.fragments];
|
||||
if (paths.length > 128 || new Set(paths).size !== paths.length)
|
||||
@@ -86,7 +88,10 @@ export async function compileQuery(
|
||||
const document = parse(new Source(text, path), { maxTokens: 10000 });
|
||||
definitions.push(...document.definitions);
|
||||
}
|
||||
const fragmentContracts = await fragmentSources(declaration, definitions, resolveFragment);
|
||||
const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
|
||||
if (definitions.length > 256 || Buffer.byteLength(print(document)) > 262144)
|
||||
fail("QUERY_SOURCE_LIMIT", "Expanded fragment sources exceed the query source budget");
|
||||
visit(document, {
|
||||
Directive(node) {
|
||||
if (node.name.value === "live")
|
||||
@@ -112,7 +117,7 @@ export async function compileQuery(
|
||||
if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node);
|
||||
},
|
||||
Directive(node) {
|
||||
if (!["include", "skip", "live"].includes(node.name.value))
|
||||
if (!["include", "skip"].includes(node.name.value))
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
|
||||
},
|
||||
InlineFragment(node) {
|
||||
@@ -400,11 +405,27 @@ export async function compileQuery(
|
||||
inherited: QuerySelection["conditions"] = [],
|
||||
): QuerySelection[] =>
|
||||
set.selections.flatMap((node): QuerySelection[] => {
|
||||
if (node.kind === "FragmentSpread")
|
||||
return selection(fragments.get(node.name.value)!.selectionSet, parent, [
|
||||
...inherited,
|
||||
...conditions(node.directives),
|
||||
]);
|
||||
if (node.kind === "FragmentSpread") {
|
||||
const inheritedConditions = [...inherited, ...conditions(node.directives)];
|
||||
const contract = fragmentContracts.get(node.name.value);
|
||||
if (contract && generated.byName.get(parent.name)?.revisionId !== contract.root)
|
||||
fail("FRAGMENT_ROOT_MISMATCH", "Imported fragment requires its exact nominal root interface", node);
|
||||
return [
|
||||
...(contract
|
||||
? [
|
||||
{
|
||||
name: "$fragment",
|
||||
key: "",
|
||||
selection: [],
|
||||
arguments: {},
|
||||
conditions: inheritedConditions,
|
||||
fragmentSpread: contract,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...selection(fragments.get(node.name.value)!.selectionSet, parent, inheritedConditions),
|
||||
];
|
||||
}
|
||||
if (node.kind !== "Field") return [];
|
||||
const contract = generated.byName.get(parent.name);
|
||||
const member = contract?.members.find((entry) => entry.displayName === node.name.value);
|
||||
@@ -471,6 +492,7 @@ export async function compileQuery(
|
||||
.map((entry) => [entry.variable.name.value, argument(entry.defaultValue!)]),
|
||||
),
|
||||
sourceFiles: paths,
|
||||
...(fragmentContracts.size ? { fragmentContracts: Object.fromEntries(fragmentContracts) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -478,10 +500,11 @@ export const compileRepositoryQuery = async (
|
||||
root: string,
|
||||
declaration: QueryDeclaration,
|
||||
interfaces: readonly InterfaceRevision[],
|
||||
resolveFragment?: FragmentResolver,
|
||||
) => {
|
||||
const started = performance.now();
|
||||
try {
|
||||
return await compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name));
|
||||
return await compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name), resolveFragment);
|
||||
} finally {
|
||||
// Timing belongs in check logs, never in immutable artifact identities.
|
||||
console.error(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { parse, print, visit, type DocumentNode, type FragmentDefinitionNode } from "graphql";
|
||||
import { QueryCompileError, type CheckedQuery, type QueryDeclaration, type QuerySelection } from "./types.js";
|
||||
|
||||
export type FragmentResolver = (packageRevisionId: string, fragmentId: string) => Promise<CheckedQuery>;
|
||||
/** Expand only explicitly imported exports. Each variable mapping is checked by
|
||||
* GraphQL against the consuming operation, rather than silently inheriting names. */
|
||||
export async function fragmentSources(
|
||||
declaration: QueryDeclaration,
|
||||
definitions: DocumentNode["definitions"][number][],
|
||||
resolve?: FragmentResolver,
|
||||
) {
|
||||
const markers = new Map<string, NonNullable<QuerySelection["fragmentSpread"]>>();
|
||||
for (const imported of declaration.importedFragments ?? []) {
|
||||
if (!resolve)
|
||||
throw new QueryCompileError(
|
||||
"FRAGMENT_RESOLVER_REQUIRED",
|
||||
"Resolve imported fragments through package dependencies",
|
||||
);
|
||||
const checked = await resolve(imported.packageRevisionId, imported.fragmentId);
|
||||
const exported = checked.declaration.fragment;
|
||||
if (!exported || exported.admission) throw new QueryCompileError("NOT_EXPORTED_FRAGMENT", imported.fragmentId);
|
||||
const variables = checked.variables.kind === "record" ? Object.keys(checked.variables.fields) : [];
|
||||
if (JSON.stringify(Object.keys(imported.variables).sort()) !== JSON.stringify(variables.sort()))
|
||||
throw new QueryCompileError(
|
||||
"FRAGMENT_VARIABLE_MAPPING",
|
||||
`Map every variable of ${checked.declaration.displayName} explicitly`,
|
||||
);
|
||||
const source = parse(checked.document);
|
||||
const sourceOperation = source.definitions.find(
|
||||
(node) => node.kind === "OperationDefinition" && node.name?.value === checked.declaration.operation,
|
||||
);
|
||||
const consumingOperation = definitions.find(
|
||||
(node) => node.kind === "OperationDefinition" && node.name?.value === declaration.operation,
|
||||
);
|
||||
for (const variable of sourceOperation?.kind === "OperationDefinition"
|
||||
? (sourceOperation.variableDefinitions ?? [])
|
||||
: []) {
|
||||
const name = imported.variables[variable.variable.name.value];
|
||||
const target =
|
||||
consumingOperation?.kind === "OperationDefinition"
|
||||
? consumingOperation.variableDefinitions?.find((v) => v.variable.name.value === name)
|
||||
: undefined;
|
||||
if (!target || print(target.type) !== print(variable.type))
|
||||
throw new QueryCompileError(
|
||||
"FRAGMENT_VARIABLE_TYPE",
|
||||
`Mapped variable ${name} must have type ${print(variable.type)}`,
|
||||
);
|
||||
}
|
||||
const fragments = source.definitions.filter(
|
||||
(node): node is FragmentDefinitionNode => node.kind === "FragmentDefinition",
|
||||
);
|
||||
if (definitions.some((node) => "name" in node && node.name?.value === exported.name))
|
||||
throw new QueryCompileError("FRAGMENT_NAME_COLLISION", exported.name);
|
||||
const privateNames = new Map(
|
||||
fragments.map((node) => [
|
||||
node.name.value,
|
||||
node.name.value === exported.name ? exported.name : `${exported.name}__${node.name.value}`,
|
||||
]),
|
||||
);
|
||||
for (const fragment of fragments) {
|
||||
definitions.push(
|
||||
visit(fragment, {
|
||||
FragmentDefinition(node) {
|
||||
return { ...node, name: { ...node.name, value: privateNames.get(node.name.value)! } };
|
||||
},
|
||||
FragmentSpread(node) {
|
||||
return { ...node, name: { ...node.name, value: privateNames.get(node.name.value) ?? node.name.value } };
|
||||
},
|
||||
Variable(node) {
|
||||
return { ...node, name: { ...node.name, value: imported.variables[node.name.value] ?? node.name.value } };
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
markers.set(exported.name, {
|
||||
package: imported.packageRevisionId,
|
||||
fragment: imported.fragmentId,
|
||||
digest: checked.definitionDigest,
|
||||
root: checked.declaration.root.interface,
|
||||
variables: imported.variables,
|
||||
});
|
||||
for (const [name, contract] of Object.entries(checked.fragmentContracts ?? {})) {
|
||||
markers.set(privateNames.get(name) ?? name, {
|
||||
...contract,
|
||||
variables: Object.fromEntries(
|
||||
Object.entries(contract.variables).map(([inner, outer]) => [inner, imported.variables[outer] ?? outer]),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (declaration.fragment) {
|
||||
const exported = declaration.fragment;
|
||||
if (!definitions.some((node) => node.kind === "FragmentDefinition" && node.name.value === exported.name))
|
||||
throw new QueryCompileError("FRAGMENT_MISSING", exported.name);
|
||||
const operations = definitions.filter((node) => node.kind === "OperationDefinition");
|
||||
if (!exported.operation) {
|
||||
if (operations.length)
|
||||
throw new QueryCompileError(
|
||||
"FRAGMENT_OPERATION_REQUIRED",
|
||||
"Name the standalone operation explicitly when a fragment document includes variables or an operation",
|
||||
);
|
||||
definitions.push(...parse(`query ${declaration.operation} { root { ...${exported.name} } }`).definitions);
|
||||
} else {
|
||||
const operation = operations.find(
|
||||
(node) => node.kind === "OperationDefinition" && node.name?.value === exported.operation,
|
||||
);
|
||||
if (!operation || operation.kind !== "OperationDefinition")
|
||||
throw new QueryCompileError("FRAGMENT_OPERATION_MISSING", exported.operation);
|
||||
// The standalone operation specifies variables only. Its result must be the
|
||||
// exact exported fragment, not additional fields or a different window.
|
||||
const expected = parse(`query ${declaration.operation} { root { ...${exported.name} } }`).definitions[0]!;
|
||||
if (expected.kind !== "OperationDefinition" || print(operation.selectionSet) !== print(expected.selectionSet))
|
||||
throw new QueryCompileError(
|
||||
"FRAGMENT_STANDALONE_SHAPE",
|
||||
"Standalone operation must select root { ...ExportedFragment }",
|
||||
);
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
@@ -35,6 +35,14 @@ export type QueryRoot<Interface = InterfaceRevisionId> =
|
||||
| { kind: "object"; interface: Interface }
|
||||
| { kind: "workspace-interface"; interface: Interface };
|
||||
export interface QueryDeclaration {
|
||||
/** Exported local projection; compilation also creates its direct admission. */
|
||||
fragment?: { name: string; operation?: string; admission?: boolean };
|
||||
importedFragments?: {
|
||||
packageRevisionId: string;
|
||||
packageAlias?: string;
|
||||
fragmentId: string;
|
||||
variables: Record<string, string>;
|
||||
}[];
|
||||
placement?: { kind: "replica" } | { kind: "local"; sourceQueryId: string };
|
||||
id: string;
|
||||
displayName: string;
|
||||
@@ -83,6 +91,7 @@ export class QueryCompileError extends Error {
|
||||
}
|
||||
}
|
||||
export interface CheckedQuery {
|
||||
fragmentContracts?: Record<string, NonNullable<QuerySelection["fragmentSpread"]>>;
|
||||
declaration: QueryDeclaration;
|
||||
definitionDigest: string;
|
||||
/** Fixed, validated document: never supplied by runtime callers. */
|
||||
@@ -102,6 +111,14 @@ export type QueryArgument =
|
||||
| { kind: "list"; values: QueryArgument[] }
|
||||
| { kind: "object"; fields: Record<string, QueryArgument> };
|
||||
export interface QuerySelection {
|
||||
/** A checked occurrence marker, not an output field or storage capability. */
|
||||
fragmentSpread?: {
|
||||
package: string;
|
||||
fragment: string;
|
||||
digest: string;
|
||||
root: InterfaceRevisionId;
|
||||
variables: Record<string, string>;
|
||||
};
|
||||
name: string;
|
||||
key: string;
|
||||
interfaceRevisionId?: InterfaceRevisionId;
|
||||
|
||||
@@ -218,3 +218,50 @@ package Runtime id "package:runtime" revision "package:runtime@1" { }
|
||||
/requires external interface Named \(interface:named@1\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("resource-owned fragment imports compile through the locked package closure", async (context) => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-fragment-assembly-"));
|
||||
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const dirs = Object.fromEntries(["item", "rows", "screen"].map((name) => [name, path.join(directory, name)]));
|
||||
await Promise.all(Object.values(dirs).map((dir) => mkdir(dir)));
|
||||
const dependency = (kind: string, name: string) =>
|
||||
`${kind} ${name} source {repository "https://example.test/${name.toLowerCase()}.git"; commit "${namedCommit}";}`;
|
||||
await writeFile(path.join(dirs.item!, "quixos.lock"), lock());
|
||||
await writeFile(
|
||||
path.join(dirs.item!, "interface.qx"),
|
||||
`interface Item id "item" revision "item@1" {queryable value title id "title" : string {get id "title:get" execution snapshot targets [browser,server];}}`,
|
||||
);
|
||||
await writeFile(path.join(dirs.rows!, "quixos.lock"), lock(dependency("interface", "Item")));
|
||||
await writeFile(
|
||||
path.join(dirs.rows!, "package.qx"),
|
||||
`import interface Item; package Rows id "rows" revision "rows@1" {query fragment Row id "row" root Item document "row.graphql" fragment "Row";}`,
|
||||
);
|
||||
await writeFile(path.join(dirs.rows!, "row.graphql"), "fragment Row on Item {_camino {ref} title}");
|
||||
await writeFile(
|
||||
path.join(dirs.screen!, "quixos.lock"),
|
||||
lock(dependency("interface", "Item") + dependency("package", "Rows")),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(dirs.screen!, "package.qx"),
|
||||
`import interface Item; import package Rows; package Screen id "screen" revision "screen@1" {
|
||||
query Inputs id "inputs" root Item document "screen.graphql" operation "Screen" {replica; fragments Rows.Row;}
|
||||
query View id "view" root Item document "screen.graphql" operation "Screen" {local from Inputs; fragments Rows.Row;}
|
||||
}`,
|
||||
);
|
||||
await writeFile(path.join(dirs.screen!, "screen.graphql"), "query Screen {root {...Row}}");
|
||||
const result = await compileCapabilityResourceRepository({
|
||||
rootDirectory: dirs.screen!,
|
||||
kind: "package",
|
||||
source: { resolver: "git", repository: "https://example.test/screen.git", commit: packageCommit },
|
||||
resolveResource: async (source) => {
|
||||
const name = new URL(source.repository).pathname.slice(1, -4);
|
||||
assert.ok(dirs[name], name);
|
||||
return { directory: dirs[name]! };
|
||||
},
|
||||
});
|
||||
assert.equal(result.resource.kind, "package");
|
||||
if (result.resource.kind !== "package") throw Error("expected package");
|
||||
const view = result.resource.revision.checkedQueries!.find((q) => q.declaration.id === "view")!;
|
||||
assert.equal(view.selection[0]!.selection[0]!.fragmentSpread?.package, "rows@1");
|
||||
assert.equal(result.resources.length, 3);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { compileCapabilityResourceSource } from "../src/capability-language/parser.js";
|
||||
import { compileQuery } from "../src/query/compile.js";
|
||||
import type { PackageRevision } from "../src/capability-model/types.js";
|
||||
const source = { repository: "https://example.test/fragments", commit: "a".repeat(40) };
|
||||
const compiled = compileCapabilityResourceSource(
|
||||
`interface Item id "item" revision "item@1" {
|
||||
queryable value title id "title" : string {get id "title:get" execution snapshot targets [browser,server];}
|
||||
queryable rpc value remote id "remote" : string {get id "remote:get";}
|
||||
}`,
|
||||
{ source },
|
||||
);
|
||||
if (!compiled.ok || compiled.resource.kind !== "interface") throw Error(JSON.stringify(compiled.diagnostics));
|
||||
const item = compiled.resource.revision;
|
||||
function pkg(body: string, imports: PackageRevision[] = [], name = "Views") {
|
||||
const result = compileCapabilityResourceSource(
|
||||
`import interface Item; ${imports.map((p) => `import package ${p.displayName};`).join(" ")} package ${name} id "${name}" revision "${name}@1" {${body}}`,
|
||||
{
|
||||
source,
|
||||
environment: {
|
||||
interfaces: new Map([["Item", item]]),
|
||||
interfaceClosure: [item],
|
||||
packages: new Map(imports.map((p) => [p.displayName, p])),
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
if (result.resource.kind !== "package") throw Error("package required");
|
||||
return result.resource.revision;
|
||||
}
|
||||
test("exported fragments generate local projection and direct admission", async () => {
|
||||
const p = pkg('query fragment Row id "row" root Item document "row.graphql" fragment "Row";');
|
||||
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}");
|
||||
assert.equal(checked.output.kind, "record");
|
||||
assert.match(checked.document, /query QuixosFragment/);
|
||||
await assert.rejects(
|
||||
compileQuery(local, [item], async () => "fragment Row on Item {remote}"),
|
||||
/QUERY_LOCAL_DEPENDENCY/,
|
||||
);
|
||||
});
|
||||
test("imported fragments retain checked occurrences and require exact roots", async () => {
|
||||
const provider = pkg('query fragment Row id "row" root Item document "row.graphql" fragment "Row";', [], "Provider");
|
||||
const checked = await compileQuery(
|
||||
provider.queries!.find((q) => !q.fragment?.admission)!,
|
||||
[item],
|
||||
async () => "fragment Row on Item {_camino {ref} title}",
|
||||
);
|
||||
const consumer = pkg(
|
||||
'query Screen id "screen" root Item document "screen.graphql" operation "Screen" {replica; fragments Provider.Row;}',
|
||||
[provider],
|
||||
);
|
||||
const result = await compileQuery(
|
||||
consumer.queries![0]!,
|
||||
[item],
|
||||
async () => "query Screen {root {...Row}}",
|
||||
async () => checked,
|
||||
);
|
||||
assert.equal(result.selection[0]!.selection[0]!.fragmentSpread?.digest, checked.definitionDigest);
|
||||
assert.equal(result.selection[0]!.selection[0]!.fragmentSpread?.root, item.revisionId);
|
||||
assert.ok(result.effects.some((e) => e.memberId === "title"));
|
||||
const wrong = {
|
||||
...checked,
|
||||
declaration: {
|
||||
...checked.declaration,
|
||||
root: { ...checked.declaration.root, interface: "different@1" as typeof item.revisionId },
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
compileQuery(
|
||||
consumer.queries![0]!,
|
||||
[item],
|
||||
async () => "query Screen {root {...Row}}",
|
||||
async () => wrong,
|
||||
),
|
||||
/FRAGMENT_ROOT_MISMATCH/,
|
||||
);
|
||||
});
|
||||
test("fragment variable mappings are explicit and checked", async () => {
|
||||
const provider = pkg(
|
||||
'query fragment Row id "row" root Item document "row.graphql" fragment "Row" operation "Inputs";',
|
||||
[],
|
||||
"Provider",
|
||||
);
|
||||
const checked = await compileQuery(
|
||||
provider.queries!.find((q) => !q.fragment?.admission)!,
|
||||
[item],
|
||||
async () =>
|
||||
"query Inputs($show: Boolean!) {root {...Row}} fragment Row on Item {_camino {ref} title @include(if:$show)}",
|
||||
);
|
||||
const build = (mapping: string) =>
|
||||
pkg(
|
||||
`query Screen id "screen" root Item document "screen.graphql" operation "Screen" {replica; fragments Provider.Row ${mapping};}`,
|
||||
[provider],
|
||||
).queries![0]!;
|
||||
const document = "query Screen($visible: Boolean!) {root {...Row}}";
|
||||
await assert.rejects(
|
||||
compileQuery(
|
||||
build(""),
|
||||
[item],
|
||||
async () => document,
|
||||
async () => checked,
|
||||
),
|
||||
/FRAGMENT_VARIABLE_MAPPING/,
|
||||
);
|
||||
const result = await compileQuery(
|
||||
build("with {show to visible;}"),
|
||||
[item],
|
||||
async () => document,
|
||||
async () => checked,
|
||||
);
|
||||
assert.deepEqual(result.selection[0]!.selection[0]!.fragmentSpread?.variables, { show: "visible" });
|
||||
await assert.rejects(
|
||||
compileQuery(
|
||||
build("with {show to visible;}"),
|
||||
[item],
|
||||
async () => document.replace("Boolean!", "Boolean"),
|
||||
async () => checked,
|
||||
),
|
||||
/FRAGMENT_VARIABLE_TYPE/,
|
||||
);
|
||||
});
|
||||
|
||||
test("independent fragment package survives execution lowering and typed client generation", async () => {
|
||||
const { loadReaderWorkspace, readReaderFixture } = await import("./reader-fixture.js");
|
||||
const { compileExecutionPlan } = await import("../src/execution-world/plan.js");
|
||||
const { compileQueryProgram } = await import("../src/execution-world/queries.js");
|
||||
const { generatePackageQueryClientBindings } = await import("../src/execution-world/client-contracts.js");
|
||||
const workspace = await loadReaderWorkspace();
|
||||
const comment = workspace.interfaceImports.find((i) => i.displayName === "CommentData")!;
|
||||
const parsed = compileCapabilityResourceSource(
|
||||
`import interface CommentData; package CommentRows id "comment-rows" revision "comment-rows@1" {
|
||||
query fragment Row id "row" root CommentData document "row.graphql" fragment "CommentRow";
|
||||
}`,
|
||||
{ source, environment: { interfaces: new Map([["CommentData", comment]]), interfaceClosure: [comment] } },
|
||||
);
|
||||
assert.ok(parsed.ok, JSON.stringify(parsed.diagnostics));
|
||||
if (parsed.resource.kind !== "package") throw Error("expected package");
|
||||
const provider = parsed.resource.revision;
|
||||
provider.checkedQueries = await Promise.all(
|
||||
provider.queries!.map((q) =>
|
||||
compileQuery(
|
||||
q,
|
||||
workspace.interfaceImports,
|
||||
async () => "fragment CommentRow on CommentData {_camino {ref} content {text tags}}",
|
||||
),
|
||||
),
|
||||
);
|
||||
workspace.packageImports.push(provider);
|
||||
const consumer = workspace.packageImports.find((p) => p.displayName === "ReaderRuntime")!;
|
||||
const declaration = consumer.queries!.find((q) => q.displayName === "ReaderScreen")!;
|
||||
declaration.importedFragments = [
|
||||
{ packageRevisionId: provider.revisionId, packageAlias: "CommentRows", fragmentId: "row", variables: {} },
|
||||
];
|
||||
const document = (await readReaderFixture("queries/reader-screen.graphql")).replace(
|
||||
"_camino { ref } content { text tags }",
|
||||
"...CommentRow",
|
||||
);
|
||||
const checked = await compileQuery(
|
||||
declaration,
|
||||
workspace.interfaceImports,
|
||||
async () => document,
|
||||
async () => provider.checkedQueries!.find((q) => q.declaration.id === "row")!,
|
||||
);
|
||||
consumer.checkedQueries = consumer.checkedQueries!.map((q) => (q.declaration.id === declaration.id ? checked : q));
|
||||
const plan = compileExecutionPlan(workspace);
|
||||
const program = compileQueryProgram(plan) as {
|
||||
queries: { id: string; fragment?: { package: string; id: string }; selection: unknown }[];
|
||||
};
|
||||
assert.deepEqual(program.queries.find((q) => q.fragment)?.fragment, {
|
||||
package: provider.revisionId,
|
||||
id: "row",
|
||||
digest: provider.checkedQueries!.find((q) => q.declaration.id === "row")!.definitionDigest,
|
||||
});
|
||||
const screen = program.queries.find((q) => q.id === JSON.stringify([consumer.revisionId, declaration.id]))!;
|
||||
assert.match(JSON.stringify(screen.selection), /fragmentSpread/);
|
||||
const generated = generatePackageQueryClientBindings(consumer, undefined, workspace.packageImports);
|
||||
assert.match(generated, /"CommentRows": \{"Row": definePackageFragment/);
|
||||
assert.ok(generated.includes(JSON.stringify(comment.revisionId)));
|
||||
assert.ok(!generated.includes('"Row$inputs": definePackageQuery'));
|
||||
});
|
||||
Reference in New Issue
Block a user