Add exported query fragments and scoped coverage observations
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user