2026-09-17 14:34:16 -07:00
|
|
|
import {
|
|
|
|
|
parse,
|
|
|
|
|
Source,
|
|
|
|
|
validate,
|
|
|
|
|
specifiedRules,
|
2026-09-26 11:16:46 -07:00
|
|
|
PossibleFragmentSpreadsRule,
|
2026-09-27 19:21:05 -07:00
|
|
|
NoUnusedVariablesRule,
|
|
|
|
|
GraphQLError,
|
2026-09-17 14:34:16 -07:00
|
|
|
print,
|
|
|
|
|
printSchema,
|
|
|
|
|
visit,
|
|
|
|
|
TypeInfo,
|
|
|
|
|
visitWithTypeInfo,
|
|
|
|
|
getNamedType,
|
|
|
|
|
isNonNullType,
|
|
|
|
|
isListType,
|
|
|
|
|
isObjectType,
|
|
|
|
|
isInputObjectType,
|
|
|
|
|
isScalarType,
|
|
|
|
|
isEnumType,
|
|
|
|
|
typeFromAST,
|
|
|
|
|
type GraphQLType,
|
|
|
|
|
type SelectionSetNode,
|
|
|
|
|
type FragmentDefinitionNode,
|
|
|
|
|
type ValueNode,
|
|
|
|
|
type ASTNode,
|
|
|
|
|
type DocumentNode,
|
|
|
|
|
type DirectiveNode,
|
|
|
|
|
} from "graphql";
|
|
|
|
|
import { createHash } from "node:crypto";
|
2026-09-17 14:34:16 -07:00
|
|
|
import { canonicalJson } from "../capability-model/evolution.js";
|
2026-09-17 14:34:16 -07:00
|
|
|
import { readRepositorySource } from "../capability-language/source-loader.js";
|
|
|
|
|
import {
|
|
|
|
|
valueType,
|
|
|
|
|
type InterfaceRevision,
|
|
|
|
|
type ValueType,
|
|
|
|
|
type InterfaceRevisionId,
|
|
|
|
|
} from "../capability-model/types.js";
|
|
|
|
|
import { querySchema } from "./schema.js";
|
2026-09-17 18:23:16 -07:00
|
|
|
import { objectRow } from "./relational-schema.js";
|
2026-09-18 14:55:12 -07:00
|
|
|
import { workspaceQueryRootId } from "./root.js";
|
2026-09-17 18:23:16 -07:00
|
|
|
import { relationalCompiler } from "./relational-compile.js";
|
2026-09-22 15:41:27 -07:00
|
|
|
import { fragmentSources, type FragmentResolver } from "./fragments.js";
|
2026-10-09 20:57:55 -07:00
|
|
|
import { lowerQueryTree, stripQueryBoundaries } from "./tree.js";
|
2026-09-17 14:34:16 -07:00
|
|
|
import {
|
|
|
|
|
QueryCompileError,
|
|
|
|
|
type QueryDeclaration,
|
|
|
|
|
type CheckedQuery,
|
|
|
|
|
type QueryFieldEffect,
|
|
|
|
|
type QueryUse,
|
|
|
|
|
type QueryArgument,
|
|
|
|
|
type QuerySelection,
|
|
|
|
|
} from "./types.js";
|
|
|
|
|
|
|
|
|
|
const fail = (code: string, message: string, node?: ASTNode): never => {
|
|
|
|
|
const token = node?.loc?.startToken;
|
|
|
|
|
throw new QueryCompileError(
|
|
|
|
|
code,
|
|
|
|
|
message,
|
|
|
|
|
token
|
|
|
|
|
? {
|
|
|
|
|
file: node!.loc!.source.name,
|
|
|
|
|
line: token.line,
|
|
|
|
|
column: token.column,
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-01 11:28:57 -07:00
|
|
|
/** Shared checker for authored queries and bounded public query documents.
|
|
|
|
|
* Runtime execution receives its checked IR, never an unchecked document. */
|
2026-09-17 14:34:16 -07:00
|
|
|
export async function compileQuery(
|
|
|
|
|
declaration: QueryDeclaration,
|
|
|
|
|
interfaces: readonly InterfaceRevision[],
|
|
|
|
|
read: (name: string) => Promise<string>,
|
2026-09-22 15:41:27 -07:00
|
|
|
resolveFragment?: FragmentResolver,
|
2026-09-17 14:34:16 -07:00
|
|
|
): Promise<CheckedQuery> {
|
|
|
|
|
const paths = [declaration.document, ...declaration.fragments];
|
|
|
|
|
if (paths.length > 128 || new Set(paths).size !== paths.length)
|
|
|
|
|
fail("QUERY_SOURCE_LIMIT", "Query files must be distinct and bounded to 128");
|
|
|
|
|
const definitions: DocumentNode["definitions"][number][] = [];
|
|
|
|
|
let totalBytes = 0;
|
|
|
|
|
for (const path of paths) {
|
|
|
|
|
if (
|
|
|
|
|
!path.endsWith(".graphql") ||
|
|
|
|
|
path.startsWith("/") ||
|
|
|
|
|
path.split(/[\\/]/).some((part) => !part || part === "." || part === "..")
|
|
|
|
|
)
|
|
|
|
|
fail("QUERY_SOURCE_PATH", `Invalid query source path ${path}`);
|
|
|
|
|
const text = await read(path);
|
|
|
|
|
totalBytes += Buffer.byteLength(text);
|
|
|
|
|
if (totalBytes > 262144) fail("QUERY_SOURCE_LIMIT", "Query sources exceed 256 KiB");
|
|
|
|
|
const document = parse(new Source(text, path), { maxTokens: 10000 });
|
|
|
|
|
definitions.push(...document.definitions);
|
|
|
|
|
}
|
2026-09-22 15:41:27 -07:00
|
|
|
const fragmentContracts = await fragmentSources(declaration, definitions, resolveFragment);
|
2026-09-26 11:16:46 -07:00
|
|
|
let document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
|
2026-09-22 15:41:27 -07:00
|
|
|
if (definitions.length > 256 || Buffer.byteLength(print(document)) > 262144)
|
|
|
|
|
fail("QUERY_SOURCE_LIMIT", "Expanded fragment sources exceed the query source budget");
|
2026-09-22 15:31:42 -07:00
|
|
|
visit(document, {
|
|
|
|
|
Directive(node) {
|
|
|
|
|
if (node.name.value === "live")
|
|
|
|
|
fail(
|
|
|
|
|
"QUERY_RETIRED_DIRECTIVE",
|
|
|
|
|
"@live is retired. Observations watch ordinary query values; use checked operations for mutations.",
|
|
|
|
|
node,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-09-17 18:23:16 -07:00
|
|
|
const generated = querySchema(declaration, interfaces, document);
|
2026-09-26 11:16:46 -07:00
|
|
|
// Identity belongs to the checked object result, including fragment results.
|
|
|
|
|
// Schema membership deliberately excludes structured values and query wrappers.
|
|
|
|
|
const identity = parse("query Identity { _camino { ref } }", { noLocation: true }).definitions[0]!;
|
|
|
|
|
if (identity.kind !== "OperationDefinition") throw new Error("Invalid identity selection");
|
|
|
|
|
const identitySelection = identity.selectionSet.selections[0]!;
|
|
|
|
|
const identityTypes = new TypeInfo(generated.schema);
|
|
|
|
|
document = visit(
|
|
|
|
|
document,
|
|
|
|
|
visitWithTypeInfo(identityTypes, {
|
|
|
|
|
SelectionSet: {
|
|
|
|
|
leave(node, _key, parentNode) {
|
|
|
|
|
// An inline projection is part of its containing object. Its identity
|
|
|
|
|
// remains that object's base interface, not the projected interface.
|
|
|
|
|
if (parentNode && !Array.isArray(parentNode) && "kind" in parentNode && parentNode.kind === "InlineFragment")
|
|
|
|
|
return;
|
|
|
|
|
const parent = identityTypes.getParentType();
|
|
|
|
|
const contract = parent && generated.byName.get(parent.name);
|
|
|
|
|
if (!contract || contract.revisionId === workspaceQueryRootId) return;
|
|
|
|
|
const hasIdentity = node.selections.some(
|
|
|
|
|
(field) =>
|
|
|
|
|
field.kind === "Field" &&
|
|
|
|
|
field.name.value === "_camino" &&
|
|
|
|
|
!field.alias &&
|
|
|
|
|
!field.directives?.length &&
|
|
|
|
|
field.selectionSet?.selections.some(
|
|
|
|
|
(child) =>
|
|
|
|
|
child.kind === "Field" && child.name.value === "ref" && !child.alias && !child.directives?.length,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
// GraphQL validation caches comparisons by AST identity. Each nominal
|
|
|
|
|
// object must get its own metadata subtree, even though the spelling is
|
|
|
|
|
// identical (its reference scalar can be different).
|
|
|
|
|
if (!hasIdentity) return { ...node, selections: [...node.selections, structuredClone(identitySelection)] };
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const operations = document.definitions.filter((entry) => entry.kind === "OperationDefinition");
|
2026-09-17 14:34:16 -07:00
|
|
|
if (
|
|
|
|
|
operations.length !== 1 ||
|
|
|
|
|
operations[0]!.operation !== "query" ||
|
|
|
|
|
operations[0]!.name?.value !== declaration.operation
|
|
|
|
|
)
|
|
|
|
|
fail("QUERY_UNSUPPORTED_FEATURE", "Exactly one named query matching the declaration is required");
|
|
|
|
|
visit(document, {
|
|
|
|
|
Field(node) {
|
2026-09-17 18:23:16 -07:00
|
|
|
if (node.name.value === "_unavailable")
|
|
|
|
|
fail("QUERY_AGGREGATE_TYPE", "No supported field exists at this operator path", node);
|
2026-09-17 14:34:16 -07:00
|
|
|
if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node);
|
|
|
|
|
},
|
|
|
|
|
Directive(node) {
|
2026-10-09 20:57:55 -07:00
|
|
|
if (!["include", "skip", "boundary"].includes(node.name.value))
|
2026-09-17 14:34:16 -07:00
|
|
|
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-09-26 11:16:46 -07:00
|
|
|
// Capability interfaces are nominal views of one graph object, not mutually
|
|
|
|
|
// exclusive GraphQL object variants. Check overlap using declared requirements.
|
|
|
|
|
const projectionTypes = new TypeInfo(generated.schema);
|
|
|
|
|
const requires = (from: InterfaceRevisionId, to: InterfaceRevisionId, seen = new Set<string>()): boolean => {
|
|
|
|
|
if (from === to) return true;
|
|
|
|
|
if (seen.has(from)) return false;
|
|
|
|
|
seen.add(from);
|
|
|
|
|
return generated.contracts.get(from)?.requiredInterfaces?.some((id) => requires(id, to, seen)) ?? false;
|
|
|
|
|
};
|
|
|
|
|
visit(
|
|
|
|
|
document,
|
|
|
|
|
visitWithTypeInfo(projectionTypes, {
|
2026-09-26 11:44:50 -07:00
|
|
|
Field(node) {
|
|
|
|
|
const parent = projectionTypes.getParentType();
|
|
|
|
|
const member =
|
|
|
|
|
parent && generated.byName.get(parent.name)?.members.find((member) => member.displayName === node.name.value);
|
|
|
|
|
if (member?.kind === "relationship") generated.target(member);
|
|
|
|
|
},
|
2026-09-26 11:16:46 -07:00
|
|
|
InlineFragment(node) {
|
|
|
|
|
if (!node.typeCondition) return;
|
|
|
|
|
const from = projectionTypes.getParentType();
|
|
|
|
|
const a = from && generated.byName.get(from.name);
|
|
|
|
|
const b = generated.byName.get(node.typeCondition.name.value);
|
|
|
|
|
if (!a || !b || !requires(a.revisionId, b.revisionId))
|
|
|
|
|
fail(
|
|
|
|
|
"QUERY_UNSUPPORTED_FEATURE",
|
|
|
|
|
"Inline interface projections currently require a declared interface requirement",
|
|
|
|
|
node,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
FragmentSpread(node) {
|
|
|
|
|
const fragment = document.definitions.find(
|
|
|
|
|
(entry) => entry.kind === "FragmentDefinition" && entry.name.value === node.name.value,
|
|
|
|
|
);
|
|
|
|
|
if (
|
|
|
|
|
fragment?.kind === "FragmentDefinition" &&
|
|
|
|
|
fragment.typeCondition.name.value !== projectionTypes.getParentType()?.name
|
|
|
|
|
)
|
|
|
|
|
fail("QUERY_UNSUPPORTED_FEATURE", "Named fragments currently require the exact nominal root interface", node);
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-09-27 19:21:05 -07:00
|
|
|
const bindingVariables = new Set(
|
|
|
|
|
[...fragmentContracts.values()]
|
|
|
|
|
.filter((entry) => entry.mode === "reference")
|
|
|
|
|
.flatMap((entry) => Object.values(entry.variables)),
|
|
|
|
|
);
|
2026-09-26 11:16:46 -07:00
|
|
|
const errors = validate(
|
|
|
|
|
generated.schema,
|
|
|
|
|
document,
|
2026-09-27 19:21:05 -07:00
|
|
|
[
|
|
|
|
|
...specifiedRules.filter((rule) => rule !== PossibleFragmentSpreadsRule && rule !== NoUnusedVariablesRule),
|
|
|
|
|
(context) => ({
|
|
|
|
|
OperationDefinition: {
|
|
|
|
|
leave(operation) {
|
|
|
|
|
const used = new Set(context.getRecursiveVariableUsages(operation).map(({ node }) => node.name.value));
|
|
|
|
|
for (const variable of operation.variableDefinitions ?? []) {
|
|
|
|
|
const name = variable.variable.name.value;
|
|
|
|
|
if (!used.has(name) && !bindingVariables.has(name))
|
|
|
|
|
context.reportError(new GraphQLError(`Variable "$${name}" is never used.`, { nodes: variable }));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
],
|
2026-09-26 11:16:46 -07:00
|
|
|
{ maxErrors: 20 },
|
|
|
|
|
);
|
2026-09-17 14:34:16 -07:00
|
|
|
if (errors.length) {
|
|
|
|
|
const error = errors[0]!;
|
|
|
|
|
fail("QUERY_VALIDATION", error.message, error.nodes?.[0]);
|
|
|
|
|
}
|
|
|
|
|
const effects = new Map<string, QueryFieldEffect>();
|
2026-09-17 18:23:16 -07:00
|
|
|
const fragments = new Map(
|
2026-09-26 11:16:46 -07:00
|
|
|
document.definitions
|
2026-09-17 18:23:16 -07:00
|
|
|
.filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition")
|
|
|
|
|
.map((entry) => [entry.name.value, entry]),
|
|
|
|
|
);
|
2026-09-17 14:34:16 -07:00
|
|
|
const mark = (id: InterfaceRevisionId, name: string, use: QueryUse, node: ASTNode) => {
|
2026-09-18 14:55:12 -07:00
|
|
|
// The virtual extent has no provider/getter effect. Its membership is
|
|
|
|
|
// linked separately, including when the query only selects references.
|
|
|
|
|
if (id === workspaceQueryRootId) return;
|
2026-09-17 14:34:16 -07:00
|
|
|
const contract = generated.contracts.get(id)!;
|
|
|
|
|
const member = contract.members.find((entry) => entry.displayName === name);
|
2026-09-22 13:16:23 -07:00
|
|
|
if (!member || member.kind === "operation" || member.kind === "module" || !member.queryRead)
|
2026-09-17 14:34:16 -07:00
|
|
|
return fail("QUERY_FIELD_NOT_QUERYABLE", `${contract.displayName}.${name} is not queryable`, node);
|
2026-09-19 21:52:37 -07:00
|
|
|
if (declaration.placement?.kind === "local") {
|
|
|
|
|
const getter = member.operations.find(
|
|
|
|
|
(operation) => operation.displayName === (member.kind === "value" ? "get" : "resolve"),
|
|
|
|
|
);
|
2026-09-28 20:29:21 -07:00
|
|
|
if (getter?.execution?.kind !== "snapshot")
|
2026-09-19 21:52:37 -07:00
|
|
|
fail(
|
|
|
|
|
"QUERY_LOCAL_DEPENDENCY",
|
2026-09-28 20:29:21 -07:00
|
|
|
`Local query requires a graph snapshot getter for ${contract.displayName}.${name}; split the remote field or move the query to the server`,
|
2026-09-19 21:52:37 -07:00
|
|
|
node,
|
|
|
|
|
);
|
|
|
|
|
if (member.queryRead.execution === "rpc-permitted")
|
|
|
|
|
fail(
|
|
|
|
|
"QUERY_LOCAL_DEPENDENCY",
|
|
|
|
|
`Local query cannot include remote-compute field ${contract.displayName}.${name}`,
|
|
|
|
|
node,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-17 14:34:16 -07:00
|
|
|
const key = `${id}\0${member.id}`;
|
|
|
|
|
const effect = effects.get(key) ?? {
|
|
|
|
|
interfaceRevisionId: id,
|
|
|
|
|
memberId: member.id,
|
|
|
|
|
uses: [],
|
|
|
|
|
execution: member.queryRead.execution,
|
|
|
|
|
};
|
|
|
|
|
if (!effect.uses.includes(use)) effect.uses.push(use);
|
|
|
|
|
effects.set(key, effect);
|
|
|
|
|
if (
|
|
|
|
|
effect.execution === "rpc-permitted" &&
|
|
|
|
|
!declaration.allowances.some(
|
|
|
|
|
(allow) =>
|
|
|
|
|
allow.interfaceRevisionId === id &&
|
|
|
|
|
allow.memberId === member.id &&
|
|
|
|
|
allow.uses.includes(use) &&
|
|
|
|
|
allow.reason.trim(),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
fail("QUERY_RPC_CONSUMER_REASON", `${contract.displayName}.${name} needs an allowance for ${use}`, node);
|
|
|
|
|
if (
|
|
|
|
|
declaration.watch &&
|
|
|
|
|
effect.execution === "rpc-permitted" &&
|
|
|
|
|
!declaration.polling &&
|
|
|
|
|
!member.operations.some((op) => op.displayName === "watch-start")
|
|
|
|
|
)
|
|
|
|
|
fail(
|
|
|
|
|
"QUERY_WATCH_UNSUPPORTED",
|
|
|
|
|
`${contract.displayName}.${name} has no watch; explicitly acknowledge polling or use a one-shot query`,
|
|
|
|
|
node,
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-09-17 18:23:16 -07:00
|
|
|
const relational = relationalCompiler(generated, declaration, mark, fragments);
|
|
|
|
|
const compiledPredicates = new WeakMap<ASTNode, import("./relational.js").QueryPredicate>();
|
2026-09-17 14:34:16 -07:00
|
|
|
const inputEffects = (id: InterfaceRevisionId, value: ValueNode, use: "predicate" | "order") => {
|
2026-09-17 18:23:16 -07:00
|
|
|
if (use === "predicate") {
|
|
|
|
|
relational.predicate(objectRow(id), value);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-17 14:34:16 -07:00
|
|
|
if (value.kind === "Variable") {
|
|
|
|
|
// A dynamic filter may name any field in its declared input type.
|
|
|
|
|
for (const member of generated.contracts.get(id)!.members)
|
|
|
|
|
if (member.kind === "value" && member.queryRead) mark(id, member.displayName, use, value);
|
|
|
|
|
} else if (value.kind === "ListValue") value.values.forEach((child) => inputEffects(id, child, use));
|
|
|
|
|
else if (value.kind === "ObjectValue")
|
|
|
|
|
for (const field of value.fields) {
|
2026-09-17 18:23:16 -07:00
|
|
|
mark(id, field.name.value, use, field);
|
2026-09-17 14:34:16 -07:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const info = new TypeInfo(generated.schema);
|
|
|
|
|
visit(
|
|
|
|
|
document,
|
|
|
|
|
visitWithTypeInfo(info, {
|
|
|
|
|
Field(node) {
|
|
|
|
|
const parent = info.getParentType();
|
|
|
|
|
const contract = parent && generated.byName.get(parent.name);
|
2026-09-22 15:31:42 -07:00
|
|
|
if (!contract || node.name.value === "_camino") return;
|
2026-09-17 14:34:16 -07:00
|
|
|
mark(contract.revisionId, node.name.value, "select", node);
|
|
|
|
|
const member = contract.members.find((entry) => entry.displayName === node.name.value)!;
|
|
|
|
|
if (member.kind !== "relationship" || (member.cardinality !== "many" && member.cardinality !== "many-unique"))
|
|
|
|
|
return;
|
|
|
|
|
const target = generated.target(member);
|
2026-09-17 14:34:16 -07:00
|
|
|
const bounds = (node.arguments ?? []).filter(
|
|
|
|
|
(argument) => argument.name.value === "first" || argument.name.value === "all",
|
|
|
|
|
);
|
|
|
|
|
if (bounds.length !== 1) fail("QUERY_ROW_LIMIT", "Specify exactly one of first or all", node);
|
|
|
|
|
if (bounds[0]!.name.value === "all" && node.arguments?.some((argument) => argument.name.value === "after"))
|
|
|
|
|
fail("QUERY_UNSUPPORTED_FEATURE", "Bounded-all does not accept a continuation", node);
|
2026-09-17 14:34:16 -07:00
|
|
|
for (const argument of node.arguments ?? []) {
|
2026-09-17 18:23:16 -07:00
|
|
|
if (argument.name.value === "where")
|
2026-09-25 11:37:27 -07:00
|
|
|
compiledPredicates.set(
|
|
|
|
|
node,
|
|
|
|
|
relational.predicate(
|
|
|
|
|
objectRow(target, member.keyType ? { keyType: member.keyType } : undefined),
|
|
|
|
|
argument.value,
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-09-17 14:34:16 -07:00
|
|
|
if (argument.name.value === "orderBy") inputEffects(target, argument.value, "order");
|
|
|
|
|
if (
|
2026-09-17 14:34:16 -07:00
|
|
|
["first", "all"].includes(argument.name.value) &&
|
2026-09-17 14:34:16 -07:00
|
|
|
argument.value.kind !== "Variable" &&
|
|
|
|
|
(argument.value.kind !== "IntValue" ||
|
|
|
|
|
Number(argument.value.value) < 1 ||
|
|
|
|
|
Number(argument.value.value) > declaration.budgets.rows)
|
|
|
|
|
)
|
2026-09-17 14:34:16 -07:00
|
|
|
fail(
|
|
|
|
|
"QUERY_ROW_LIMIT",
|
|
|
|
|
`${argument.name.value} must be between 1 and ${declaration.budgets.rows}`,
|
|
|
|
|
argument,
|
|
|
|
|
);
|
2026-09-17 14:34:16 -07:00
|
|
|
}
|
2026-09-17 18:23:16 -07:00
|
|
|
if (node.arguments?.some((a) => a.name.value === "after")) {
|
|
|
|
|
const rpcOrder = (value: ValueNode): boolean =>
|
|
|
|
|
value.kind === "Variable"
|
|
|
|
|
? generated.contracts
|
|
|
|
|
.get(target)!
|
|
|
|
|
.members.some((m) => m.kind === "value" && m.queryRead?.execution === "rpc-permitted")
|
|
|
|
|
: value.kind === "ListValue"
|
|
|
|
|
? value.values.some(rpcOrder)
|
|
|
|
|
: value.kind === "ObjectValue" &&
|
|
|
|
|
value.fields.some((f) =>
|
|
|
|
|
generated.contracts
|
|
|
|
|
.get(target)!
|
|
|
|
|
.members.some(
|
|
|
|
|
(m) =>
|
|
|
|
|
m.displayName === f.name.value &&
|
|
|
|
|
m.kind === "value" &&
|
|
|
|
|
m.queryRead?.execution === "rpc-permitted",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
const order = node.arguments.find((a) => a.name.value === "orderBy");
|
|
|
|
|
if (relational.needsRpc(compiledPredicates.get(node)) || (order && rpcOrder(order.value)))
|
|
|
|
|
fail(
|
|
|
|
|
"QUERY_UNSUPPORTED_FEATURE",
|
|
|
|
|
"RPC predicates/order require a bounded first/all window without continuation",
|
|
|
|
|
node,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-17 14:34:16 -07:00
|
|
|
},
|
|
|
|
|
FragmentSpread(node) {
|
|
|
|
|
const fragment = definitions.find(
|
|
|
|
|
(entry) => entry.kind === "FragmentDefinition" && entry.name.value === node.name.value,
|
|
|
|
|
) as FragmentDefinitionNode;
|
|
|
|
|
if (fragment.typeCondition.name.value !== info.getParentType()?.name)
|
|
|
|
|
fail("QUERY_UNSUPPORTED_FEATURE", "Fragments must select the exact current type", node);
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
let expandedFields = 0;
|
2026-09-17 14:34:16 -07:00
|
|
|
// GraphQL merges repeated response keys. In particular, fragments may each
|
|
|
|
|
// contribute different children of one relationship; last-write-wins would
|
|
|
|
|
// silently remove fields from both the generated type and the wire codec.
|
|
|
|
|
const mergeOutput = (left: ValueType, right: ValueType): ValueType => {
|
|
|
|
|
const a = left.kind === "optional" ? left.value : left;
|
|
|
|
|
const b = right.kind === "optional" ? right.value : right;
|
|
|
|
|
let result = a;
|
|
|
|
|
if (a.kind === "record" && b.kind === "record") {
|
|
|
|
|
const fields = { ...a.fields };
|
|
|
|
|
for (const [key, type] of Object.entries(b.fields))
|
|
|
|
|
fields[key] = fields[key] ? mergeOutput(fields[key], type) : type;
|
|
|
|
|
result = { kind: "record", fields };
|
|
|
|
|
} else if (a.kind === "list" && b.kind === "list") result = valueType.list(mergeOutput(a.value, b.value));
|
|
|
|
|
return left.kind === "optional" && right.kind === "optional" ? valueType.optional(result) : result;
|
2026-09-17 14:34:16 -07:00
|
|
|
};
|
2026-09-17 14:34:16 -07:00
|
|
|
const output = (type: GraphQLType, selections?: SelectionSetNode, depth = 0, conditional = false): ValueType => {
|
|
|
|
|
if (depth > declaration.budgets.depth * 4 + 4) fail("QUERY_DEPTH_LIMIT", "Expanded query exceeds its depth budget");
|
|
|
|
|
if (isNonNullType(type)) return required(type.ofType, selections, depth, conditional);
|
|
|
|
|
return valueType.optional(required(type, selections, depth, conditional));
|
|
|
|
|
};
|
|
|
|
|
const required = (type: GraphQLType, selections?: SelectionSetNode, depth = 0, conditional = false): ValueType => {
|
|
|
|
|
if (isListType(type)) return valueType.list(output(type.ofType, selections, depth, conditional));
|
2026-09-17 14:34:16 -07:00
|
|
|
if (isObjectType(type)) {
|
|
|
|
|
const fields: Record<string, ValueType> = {};
|
2026-09-26 11:16:46 -07:00
|
|
|
const add = (set: SelectionSetNode, conditional = false, selectedType = type) => {
|
2026-09-17 14:34:16 -07:00
|
|
|
for (const selection of set.selections) {
|
|
|
|
|
if (++expandedFields > 10000) fail("QUERY_WORK_LIMIT", "Expanded query exceeds 10000 fields", selection);
|
|
|
|
|
if (selection.kind === "FragmentSpread") {
|
2026-10-09 20:57:55 -07:00
|
|
|
if (selection.directives?.some((directive) => directive.name.value === "boundary")) continue;
|
2026-09-27 19:21:05 -07:00
|
|
|
if (fragmentContracts.get(selection.name.value)?.mode === "reference") continue;
|
2026-09-26 11:16:46 -07:00
|
|
|
add(
|
|
|
|
|
fragments.get(selection.name.value)!.selectionSet,
|
|
|
|
|
conditional || !!selection.directives?.length,
|
|
|
|
|
selectedType,
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (selection.kind === "InlineFragment") {
|
|
|
|
|
const projected = selection.typeCondition
|
|
|
|
|
? generated.schema.getType(selection.typeCondition.name.value)
|
|
|
|
|
: selectedType;
|
|
|
|
|
if (!isObjectType(projected)) return fail("QUERY_TYPE", "Expected an object projection", selection);
|
|
|
|
|
add(selection.selectionSet, conditional || !!selection.directives?.length, projected);
|
2026-09-17 14:34:16 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (selection.kind !== "Field") continue;
|
|
|
|
|
const key = selection.alias?.value ?? selection.name.value;
|
2026-09-26 11:16:46 -07:00
|
|
|
const field = selectedType.getFields()[selection.name.value]!;
|
2026-09-17 14:34:16 -07:00
|
|
|
const previous = fields[key];
|
|
|
|
|
fields[key] = output(
|
|
|
|
|
field.type,
|
|
|
|
|
selection.selectionSet,
|
|
|
|
|
depth + 1,
|
2026-09-18 01:10:48 -07:00
|
|
|
conditional || !!selection.directives?.some((entry) => entry.name.value !== "live"),
|
2026-09-17 14:34:16 -07:00
|
|
|
);
|
2026-09-18 01:10:48 -07:00
|
|
|
if (
|
|
|
|
|
(conditional || selection.directives?.some((entry) => entry.name.value !== "live")) &&
|
|
|
|
|
fields[key]!.kind !== "optional"
|
|
|
|
|
)
|
2026-09-17 14:34:16 -07:00
|
|
|
fields[key] = valueType.optional(fields[key]!);
|
2026-09-17 14:34:16 -07:00
|
|
|
if (previous) fields[key] = mergeOutput(previous, fields[key]!);
|
2026-09-17 14:34:16 -07:00
|
|
|
}
|
|
|
|
|
};
|
2026-09-26 11:16:46 -07:00
|
|
|
// A condition on this object makes the object optional, not every child
|
|
|
|
|
// of the object when it is present. Fragment conditions are handled by add.
|
|
|
|
|
if (selections) add(selections);
|
2026-09-17 14:34:16 -07:00
|
|
|
return { kind: "record", fields };
|
|
|
|
|
}
|
2026-09-17 18:23:16 -07:00
|
|
|
return (
|
|
|
|
|
generated.scalarTypes.get(getNamedType(type)!.name) ??
|
|
|
|
|
fail("QUERY_TYPE", `Unsupported query scalar ${getNamedType(type)!.name}`)
|
|
|
|
|
);
|
2026-09-17 14:34:16 -07:00
|
|
|
};
|
|
|
|
|
const variables: Record<string, ValueType> = {};
|
|
|
|
|
const inputShape = (type: GraphQLType, seen = new Set<string>()): ValueType => {
|
|
|
|
|
if (isNonNullType(type)) return inputRequired(type.ofType, seen);
|
|
|
|
|
return valueType.optional(inputRequired(type, seen));
|
|
|
|
|
};
|
|
|
|
|
const inputRequired = (type: GraphQLType, seen: Set<string>): ValueType => {
|
|
|
|
|
if (isListType(type)) return valueType.list(inputShape(type.ofType, seen));
|
|
|
|
|
if (isInputObjectType(type)) {
|
|
|
|
|
// Recursive boolean filter inputs need generated recursive TS shapes; never degrade to any.
|
|
|
|
|
if (seen.has(type.name))
|
|
|
|
|
fail(
|
|
|
|
|
"QUERY_UNSUPPORTED_FEATURE",
|
|
|
|
|
"Pass scalar variables inside fixed filter expressions instead of a whole recursive filter",
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
kind: "record",
|
|
|
|
|
fields: Object.fromEntries(
|
|
|
|
|
Object.entries(type.getFields()).map(([key, field]) => [
|
|
|
|
|
key,
|
|
|
|
|
inputShape(field.type, new Set([...seen, type.name])),
|
|
|
|
|
]),
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-09-17 18:23:16 -07:00
|
|
|
if (isEnumType(type)) return valueType.string;
|
|
|
|
|
if (isScalarType(type))
|
|
|
|
|
return generated.scalarTypes.get(type.name) ?? fail("QUERY_TYPE", `Unsupported query scalar ${type.name}`);
|
2026-09-17 14:34:16 -07:00
|
|
|
return fail("QUERY_VARIABLE_TYPE", "Unsupported variable type");
|
|
|
|
|
};
|
|
|
|
|
for (const variable of operations[0]!.variableDefinitions ?? [])
|
|
|
|
|
variables[variable.variable.name.value] = inputShape(typeFromAST(generated.schema, variable.type)!);
|
|
|
|
|
const result = required(generated.schema.getQueryType()!, operations[0]!.selectionSet);
|
|
|
|
|
const argument = (node: ValueNode): QueryArgument => {
|
|
|
|
|
switch (node.kind) {
|
|
|
|
|
case "Variable":
|
|
|
|
|
return { kind: "variable", name: node.name.value };
|
|
|
|
|
case "ListValue":
|
|
|
|
|
return { kind: "list", values: node.values.map(argument) };
|
|
|
|
|
case "ObjectValue":
|
|
|
|
|
return {
|
|
|
|
|
kind: "object",
|
|
|
|
|
fields: Object.fromEntries(node.fields.map((field) => [field.name.value, argument(field.value)])),
|
|
|
|
|
};
|
|
|
|
|
case "NullValue":
|
|
|
|
|
return { kind: "literal", value: null };
|
|
|
|
|
case "BooleanValue":
|
|
|
|
|
return { kind: "literal", value: node.value };
|
|
|
|
|
// Int literals stay decimal until their checked field codec interprets them.
|
|
|
|
|
default:
|
|
|
|
|
return { kind: "literal", value: node.value };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const conditions = (directives: readonly DirectiveNode[] = []) =>
|
2026-09-18 01:10:48 -07:00
|
|
|
directives
|
2026-10-09 20:57:55 -07:00
|
|
|
.filter((directive) => ["include", "skip"].includes(directive.name.value))
|
2026-09-18 01:10:48 -07:00
|
|
|
.map((directive) => ({
|
|
|
|
|
include: directive.name.value === "include",
|
|
|
|
|
value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value),
|
|
|
|
|
}));
|
2026-09-17 14:34:16 -07:00
|
|
|
const selection = (
|
|
|
|
|
set: SelectionSetNode,
|
|
|
|
|
parent: import("graphql").GraphQLObjectType,
|
|
|
|
|
inherited: QuerySelection["conditions"] = [],
|
2026-09-26 11:16:46 -07:00
|
|
|
): QuerySelection[] => {
|
|
|
|
|
const selections = set.selections.flatMap((node): QuerySelection[] => {
|
|
|
|
|
if (node.kind === "InlineFragment") {
|
|
|
|
|
const projected = node.typeCondition ? generated.schema.getType(node.typeCondition.name.value) : parent;
|
|
|
|
|
if (!isObjectType(projected)) return fail("QUERY_TYPE", "Expected an object projection", node);
|
|
|
|
|
return selection(node.selectionSet, projected, [...inherited, ...conditions(node.directives)]);
|
|
|
|
|
}
|
2026-09-22 15:41:27 -07:00
|
|
|
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);
|
2026-10-09 20:57:55 -07:00
|
|
|
const directive = node.directives?.find((directive) => directive.name.value === "boundary");
|
|
|
|
|
if (directive) {
|
|
|
|
|
const name = directive.arguments?.find((argument) => argument.name.value === "name")?.value;
|
|
|
|
|
if (name?.kind !== "StringValue" || !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(name.value))
|
|
|
|
|
return fail(
|
|
|
|
|
"QUERY_BOUNDARY_NAME",
|
|
|
|
|
"A boundary needs a literal identifier of at most 64 characters",
|
|
|
|
|
directive,
|
|
|
|
|
);
|
|
|
|
|
const root = generated.byName.get(parent.name)?.revisionId;
|
|
|
|
|
if (!root || root === workspaceQueryRootId)
|
|
|
|
|
return fail("QUERY_BOUNDARY_ROOT", "A boundary must select a nominal Graph Object", node);
|
|
|
|
|
if (contract?.mode === "reference")
|
|
|
|
|
fail("QUERY_BOUNDARY_REFERENCE", "Use an ordinary checked Fragment spread for a boundary", node);
|
|
|
|
|
const fragment = fragments.get(node.name.value)!;
|
|
|
|
|
const token = node.loc?.startToken;
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
name: "$boundary",
|
|
|
|
|
key: "",
|
|
|
|
|
arguments: {},
|
|
|
|
|
conditions: inheritedConditions,
|
|
|
|
|
boundary: {
|
|
|
|
|
name: name.value,
|
|
|
|
|
root,
|
|
|
|
|
output: required(parent, fragment.selectionSet),
|
|
|
|
|
...(token ? { location: { file: node.loc!.source.name, line: token.line, column: token.column } } : {}),
|
|
|
|
|
},
|
|
|
|
|
selection: [
|
|
|
|
|
...(contract
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
name: "$fragment",
|
|
|
|
|
key: "",
|
|
|
|
|
selection: [],
|
|
|
|
|
arguments: {},
|
|
|
|
|
conditions: [],
|
|
|
|
|
fragmentSpread: contract,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: []),
|
|
|
|
|
...selection(fragment.selectionSet, parent),
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|
2026-09-22 15:41:27 -07:00
|
|
|
return [
|
|
|
|
|
...(contract
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
name: "$fragment",
|
|
|
|
|
key: "",
|
|
|
|
|
selection: [],
|
|
|
|
|
arguments: {},
|
|
|
|
|
conditions: inheritedConditions,
|
|
|
|
|
fragmentSpread: contract,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: []),
|
2026-09-27 19:21:05 -07:00
|
|
|
...(contract?.mode === "reference"
|
|
|
|
|
? []
|
|
|
|
|
: selection(fragments.get(node.name.value)!.selectionSet, parent, inheritedConditions)),
|
2026-09-22 15:41:27 -07:00
|
|
|
];
|
|
|
|
|
}
|
2026-09-17 14:34:16 -07:00
|
|
|
if (node.kind !== "Field") return [];
|
|
|
|
|
const contract = generated.byName.get(parent.name);
|
|
|
|
|
const member = contract?.members.find((entry) => entry.displayName === node.name.value);
|
|
|
|
|
const type = getNamedType(parent.getFields()[node.name.value]!.type);
|
2026-09-26 11:16:46 -07:00
|
|
|
const referenceType = type && generated.scalarTypes.get(type.name);
|
2026-09-17 18:23:16 -07:00
|
|
|
const role = generated.relational.roles.get(parent.name);
|
|
|
|
|
const relationship =
|
|
|
|
|
role?.kind === "relations"
|
|
|
|
|
? generated.relational.relations(role.row).find((m) => m.displayName === node.name.value)
|
|
|
|
|
: undefined;
|
|
|
|
|
const plan =
|
|
|
|
|
relationship && role?.row.kind === "object" && node.selectionSet
|
|
|
|
|
? relational.plan(role.row.interfaceRevisionId, relationship, node.selectionSet, (set, name) => {
|
|
|
|
|
const t = generated.schema.getType(name);
|
|
|
|
|
if (!t || !isObjectType(t)) return fail("QUERY_TYPE", `Missing generated output type ${name}`, node);
|
|
|
|
|
return selection(set, t);
|
|
|
|
|
})
|
|
|
|
|
: undefined;
|
2026-09-17 14:34:16 -07:00
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
name: node.name.value,
|
|
|
|
|
key: node.alias?.value ?? node.name.value,
|
|
|
|
|
...(member && contract ? { interfaceRevisionId: contract.revisionId, memberId: member.id } : {}),
|
|
|
|
|
...(member?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}),
|
2026-09-26 11:16:46 -07:00
|
|
|
...(node.name.value === "ref" &&
|
|
|
|
|
referenceType?.kind === "object-ref" &&
|
|
|
|
|
referenceType.expectation.kind === "interface"
|
|
|
|
|
? { targetInterfaceRevisionId: referenceType.expectation.interfaceRevisionId }
|
|
|
|
|
: {}),
|
2026-09-17 14:34:16 -07:00
|
|
|
conditions: [...inherited, ...conditions(node.directives)],
|
|
|
|
|
arguments: Object.fromEntries(
|
|
|
|
|
(node.arguments ?? []).map((entry) => [entry.name.value, argument(entry.value)]),
|
|
|
|
|
),
|
|
|
|
|
selection: node.selectionSet && isObjectType(type) ? selection(node.selectionSet, type) : [],
|
2026-09-17 18:23:16 -07:00
|
|
|
...(plan ? { relational: plan } : {}),
|
|
|
|
|
...(compiledPredicates.has(node) ? { predicate: compiledPredicates.get(node)! } : {}),
|
2026-09-17 14:34:16 -07:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
});
|
2026-09-26 11:16:46 -07:00
|
|
|
const contract = generated.byName.get(parent.name);
|
2026-10-09 20:57:55 -07:00
|
|
|
const names = new Set<string>();
|
|
|
|
|
for (const field of selections)
|
|
|
|
|
if (field.boundary) {
|
|
|
|
|
if (names.has(field.boundary.name))
|
|
|
|
|
fail("QUERY_BOUNDARY_DUPLICATE", `Duplicate boundary ${field.boundary.name}`);
|
|
|
|
|
names.add(field.boundary.name);
|
|
|
|
|
}
|
2026-09-26 11:16:46 -07:00
|
|
|
if (!contract || contract.revisionId === workspaceQueryRootId) return selections;
|
|
|
|
|
// Fragment expansion may repeat metadata. Combine its children so a later
|
|
|
|
|
// implicit reference cannot overwrite an explicitly selected aggregate.
|
|
|
|
|
const metadata = selections.filter((field) => field.key === "_camino" && field.name === "_camino");
|
|
|
|
|
const responseFields = new Map<string, QuerySelection>();
|
|
|
|
|
for (const field of selections.filter((field) => field.memberId)) {
|
|
|
|
|
const previous = responseFields.get(field.key);
|
|
|
|
|
if (
|
|
|
|
|
previous &&
|
|
|
|
|
(previous.interfaceRevisionId !== field.interfaceRevisionId || previous.memberId !== field.memberId)
|
|
|
|
|
)
|
|
|
|
|
fail("QUERY_PROJECTION_CONFLICT", `Alias distinct interface fields selected as ${field.key}`);
|
|
|
|
|
responseFields.set(field.key, field);
|
|
|
|
|
}
|
|
|
|
|
if (!metadata.length) return selections;
|
|
|
|
|
const children = metadata.flatMap((field) =>
|
|
|
|
|
field.selection.map((child) => ({
|
|
|
|
|
...child,
|
|
|
|
|
conditions: [...field.conditions, ...child.conditions],
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
const ref = children.find((child) => child.key === "ref" && child.name === "ref")!;
|
|
|
|
|
return [
|
|
|
|
|
...selections.filter((field) => !metadata.includes(field)),
|
|
|
|
|
{
|
|
|
|
|
...metadata[0]!,
|
|
|
|
|
conditions: [],
|
|
|
|
|
selection: [...children.filter((child) => child.key !== "ref"), { ...ref, conditions: [] }],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
};
|
2026-09-17 14:34:16 -07:00
|
|
|
const normalized = print(document),
|
|
|
|
|
schema = printSchema(generated.schema);
|
2026-09-17 18:23:16 -07:00
|
|
|
const selections = selection(operations[0]!.selectionSet, generated.schema.getQueryType()!);
|
2026-10-09 20:57:55 -07:00
|
|
|
const tree = lowerQueryTree(declaration, selections, result);
|
2026-09-17 14:34:16 -07:00
|
|
|
const definitionDigest = createHash("sha256")
|
2026-09-17 14:34:16 -07:00
|
|
|
.update(
|
|
|
|
|
canonicalJson({
|
2026-10-09 20:57:55 -07:00
|
|
|
semantics: tree ? 7 : 6,
|
2026-09-17 14:34:16 -07:00
|
|
|
declaration,
|
|
|
|
|
normalized,
|
2026-09-27 19:21:05 -07:00
|
|
|
fragmentContracts: Object.fromEntries(fragmentContracts),
|
2026-09-21 16:59:35 -07:00
|
|
|
// Source provenance belongs to the checked installation. Identical public
|
|
|
|
|
// contracts keep their query identity when retained in another repository.
|
2026-10-01 09:21:57 -07:00
|
|
|
// Closed generic identity remains explicit in revisionId/application;
|
|
|
|
|
// its template's retention source is provenance too. byName also holds
|
|
|
|
|
// names probed while discovering nominal projections, so only include
|
|
|
|
|
// interfaces actually present in the generated query schema.
|
|
|
|
|
interfaces: Object.values(generated.schema.getTypeMap())
|
|
|
|
|
.filter(isObjectType)
|
|
|
|
|
.flatMap((type) => {
|
|
|
|
|
const revision = generated.byName.get(type.name);
|
|
|
|
|
if (!revision) return [];
|
|
|
|
|
const { source: _source, application, ...contract } = revision;
|
|
|
|
|
if (!application) return [contract];
|
|
|
|
|
const { source: _templateSource, ...identity } = application;
|
|
|
|
|
return [{ ...contract, application: identity }];
|
|
|
|
|
})
|
2026-09-21 16:59:35 -07:00
|
|
|
.sort((a, b) => (a.revisionId < b.revisionId ? -1 : a.revisionId > b.revisionId ? 1 : 0)),
|
2026-09-17 14:34:16 -07:00
|
|
|
}),
|
|
|
|
|
)
|
2026-09-17 14:34:16 -07:00
|
|
|
.digest("hex");
|
|
|
|
|
return {
|
|
|
|
|
declaration,
|
|
|
|
|
definitionDigest,
|
|
|
|
|
document: normalized,
|
|
|
|
|
schema,
|
|
|
|
|
variables: { kind: "record", fields: variables },
|
|
|
|
|
output: result,
|
|
|
|
|
effects: [...effects.values()],
|
2026-10-09 20:57:55 -07:00
|
|
|
selection: tree ? stripQueryBoundaries(selections) : selections,
|
|
|
|
|
...(tree ? { tree } : {}),
|
2026-09-17 14:34:16 -07:00
|
|
|
variableDefaults: Object.fromEntries(
|
|
|
|
|
(operations[0]!.variableDefinitions ?? [])
|
|
|
|
|
.filter((entry) => entry.defaultValue)
|
|
|
|
|
.map((entry) => [entry.variable.name.value, argument(entry.defaultValue!)]),
|
|
|
|
|
),
|
|
|
|
|
sourceFiles: paths,
|
2026-09-22 15:41:27 -07:00
|
|
|
...(fragmentContracts.size ? { fragmentContracts: Object.fromEntries(fragmentContracts) } : {}),
|
2026-09-17 14:34:16 -07:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 14:34:16 -07:00
|
|
|
export const compileRepositoryQuery = async (
|
2026-09-17 14:34:16 -07:00
|
|
|
root: string,
|
|
|
|
|
declaration: QueryDeclaration,
|
|
|
|
|
interfaces: readonly InterfaceRevision[],
|
2026-09-22 15:41:27 -07:00
|
|
|
resolveFragment?: FragmentResolver,
|
2026-09-17 14:34:16 -07:00
|
|
|
) => {
|
|
|
|
|
const started = performance.now();
|
|
|
|
|
try {
|
2026-09-22 15:41:27 -07:00
|
|
|
return await compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name), resolveFragment);
|
2026-09-17 14:34:16 -07:00
|
|
|
} finally {
|
|
|
|
|
// Timing belongs in check logs, never in immutable artifact identities.
|
|
|
|
|
console.error(
|
|
|
|
|
`[query-check] ${JSON.stringify(declaration.displayName)}: ${(performance.now() - started).toFixed(1)}ms`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|