Add queryable capability contracts and checked GraphQL artifacts

This commit is contained in:
Timothy J. Aveni
2026-09-17 14:34:16 -07:00
parent e3501772c5
commit 61a410f98f
27 changed files with 4798 additions and 2333 deletions
+381
View File
@@ -0,0 +1,381 @@
import {
parse,
Source,
validate,
specifiedRules,
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";
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";
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,
);
};
const shapeScalar = (name: string): ValueType => {
switch (name) {
case "Boolean":
return valueType.bool;
case "Int":
return valueType.int32;
case "Int64":
return valueType.int64;
case "UInt32":
return valueType.uint32;
case "UInt64":
return valueType.uint64;
case "Float":
return valueType.double;
case "Bytes":
return valueType.bytes;
default:
return valueType.string;
}
};
/** Only immutable repository sources call this. Runtime requests never compile documents. */
export async function compileQuery(
declaration: QueryDeclaration,
interfaces: readonly InterfaceRevision[],
read: (name: string) => Promise<string>,
): 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);
}
const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
const generated = querySchema(declaration, interfaces);
const operations = definitions.filter((entry) => entry.kind === "OperationDefinition");
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) {
if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node);
},
Directive(node) {
if (!["include", "skip"].includes(node.name.value))
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
},
InlineFragment(node) {
fail("QUERY_UNSUPPORTED_FEATURE", "Use named fragments on the exact selected type", node);
},
});
const errors = validate(generated.schema, document, specifiedRules, { maxErrors: 20 });
if (errors.length) {
const error = errors[0]!;
fail("QUERY_VALIDATION", error.message, error.nodes?.[0]);
}
const effects = new Map<string, QueryFieldEffect>();
const mark = (id: InterfaceRevisionId, name: string, use: QueryUse, node: ASTNode) => {
const contract = generated.contracts.get(id)!;
const member = contract.members.find((entry) => entry.displayName === name);
if (!member || member.kind === "operation" || !member.queryRead)
return fail("QUERY_FIELD_NOT_QUERYABLE", `${contract.displayName}.${name} is not queryable`, node);
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,
);
};
const inputEffects = (id: InterfaceRevisionId, value: ValueNode, use: "predicate" | "order") => {
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) {
if (use === "predicate" && ["and", "or", "not"].includes(field.name.value)) inputEffects(id, field.value, use);
else mark(id, field.name.value, use, field);
}
};
const info = new TypeInfo(generated.schema);
let hasContinuation = false;
visit(
document,
visitWithTypeInfo(info, {
Field(node) {
const parent = info.getParentType();
const contract = parent && generated.byName.get(parent.name);
if (!contract || node.name.value === "_qx") return;
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);
for (const argument of node.arguments ?? []) {
if (argument.name.value === "where") inputEffects(target, argument.value, "predicate");
if (argument.name.value === "orderBy") inputEffects(target, argument.value, "order");
if (argument.name.value === "after") hasContinuation = true;
if (
argument.name.value === "first" &&
argument.value.kind !== "Variable" &&
(argument.value.kind !== "IntValue" ||
Number(argument.value.value) < 1 ||
Number(argument.value.value) > declaration.budgets.rows)
)
fail("QUERY_ROW_LIMIT", `first must be between 1 and ${declaration.budgets.rows}`, argument);
}
},
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);
},
}),
);
if (
hasContinuation &&
[...effects.values()].some(
(effect) => effect.execution === "rpc-permitted" && effect.uses.some((use) => use !== "select"),
)
)
fail("QUERY_UNSUPPORTED_FEATURE", "RPC predicates/order support bounded first windows, not continuation");
const fragments = new Map(
definitions
.filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition")
.map((entry) => [entry.name.value, entry]),
);
let expandedFields = 0;
const output = (type: GraphQLType, selections?: SelectionSetNode, depth = 0): 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);
return valueType.optional(required(type, selections, depth));
};
const required = (type: GraphQLType, selections?: SelectionSetNode, depth = 0): ValueType => {
if (isListType(type)) return valueType.list(output(type.ofType, selections, depth));
if (isObjectType(type)) {
const fields: Record<string, ValueType> = {};
const add = (set: SelectionSetNode) => {
for (const selection of set.selections) {
if (++expandedFields > 10000) fail("QUERY_WORK_LIMIT", "Expanded query exceeds 10000 fields", selection);
if (selection.kind === "FragmentSpread") {
add(fragments.get(selection.name.value)!.selectionSet);
continue;
}
if (selection.kind !== "Field") continue;
const key = selection.alias?.value ?? selection.name.value;
const field = type.getFields()[selection.name.value]!;
fields[key] = output(field.type, selection.selectionSet, depth + 1);
if (selection.name.value === "_qx") {
const contract = generated.byName.get(type.name)!;
const metadataFields: Record<string, ValueType> = {};
for (const metadata of selection.selectionSet?.selections ?? []) {
if (metadata.kind !== "Field" || metadata.name.value !== "ref")
fail("QUERY_UNSUPPORTED_FEATURE", "Select _qx.ref directly", metadata);
else metadataFields[metadata.alias?.value ?? "ref"] = valueType.interfaceRef(contract.revisionId);
}
fields[key] = { kind: "record", fields: metadataFields };
}
if (selection.directives?.length && fields[key]!.kind !== "optional")
fields[key] = valueType.optional(fields[key]!);
}
};
if (selections) add(selections);
return { kind: "record", fields };
}
return shapeScalar(getNamedType(type)!.name);
};
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])),
]),
),
};
}
if (isScalarType(type) || isEnumType(type)) return shapeScalar(type.name);
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[] = []) =>
directives.map((directive) => ({
include: directive.name.value === "include",
value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value),
}));
const selection = (
set: SelectionSetNode,
parent: import("graphql").GraphQLObjectType,
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 !== "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);
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) } : {}),
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) : [],
},
];
});
const normalized = print(document),
schema = printSchema(generated.schema);
const definitionDigest = createHash("sha256")
.update(JSON.stringify({ semantics: 1, declaration, normalized, interfaces }))
.digest("hex");
return {
declaration,
definitionDigest,
document: normalized,
schema,
variables: { kind: "record", fields: variables },
output: result,
effects: [...effects.values()],
selection: selection(operations[0]!.selectionSet, generated.schema.getQueryType()!),
variableDefaults: Object.fromEntries(
(operations[0]!.variableDefinitions ?? [])
.filter((entry) => entry.defaultValue)
.map((entry) => [entry.variable.name.value, argument(entry.defaultValue!)]),
),
sourceFiles: paths,
};
}
export const compileRepositoryQuery = (
root: string,
declaration: QueryDeclaration,
interfaces: readonly InterfaceRevision[],
) => compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name));
+144
View File
@@ -0,0 +1,144 @@
import { createHash } from "node:crypto";
import type {
WorkspaceRevision,
Binding,
AtomId,
InterfaceRevisionId,
MemberId,
PersistentAttachment,
} from "../capability-model/types.js";
import { QueryCompileError, type CheckedQuery } from "./types.js";
import { queryRuntimePlan } from "./proto.js";
export interface LinkedQueryField {
atomId: AtomId;
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
getter: string;
binding: Binding;
watch?: { start: string; stop: string; binding: Binding };
}
export interface LinkedQuery {
runtime?: import("@bufbuild/protobuf").JsonValue;
id: string;
packageRevisionId: string;
checked: CheckedQuery;
bindingDigest: string;
fields: LinkedQueryField[];
attachments: PersistentAttachment[];
}
/** Link every possible implementation, including atoms absent from today's data. */
export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
const interfaces = new Map(workspace.interfaceImports.map((entry) => [entry.revisionId, entry]));
const attachments = [
...workspace.sharedAttachments,
...workspace.conformances.flatMap((entry) => entry.privateAttachments),
];
const linked: LinkedQuery[] = [];
for (const pkg of workspace.packageImports)
for (const declaration of pkg.queries ?? []) {
const checked = pkg.checkedQueries?.find((query) => query.declaration.id === declaration.id);
if (!checked || JSON.stringify(checked.declaration) !== JSON.stringify(declaration))
throw new QueryCompileError(
"QUERY_ARTIFACT_MISSING",
`${pkg.displayName}.${declaration.displayName} has no checked source artifact`,
);
const fields: LinkedQueryField[] = [];
const needed = new Set<string>();
for (const view of declaration.views)
if (
!workspace.conformances.some(
(entry) => entry.atomId === view.atomId && entry.interfaceRevisionId === view.interfaceRevisionId,
)
)
throw new QueryCompileError(
"QUERY_VIEW_REQUIRED",
`${view.atomId} does not conform to declared query view ${view.interfaceRevisionId}`,
);
for (const effect of checked.effects) {
const contract = interfaces.get(effect.interfaceRevisionId);
const member = contract?.members.find((entry) => entry.id === effect.memberId);
if (!member || member.kind === "operation" || member.queryRead?.execution !== effect.execution)
throw new QueryCompileError(
"QUERY_STALE_CONTRACT",
`Query field ${effect.interfaceRevisionId}.${effect.memberId} changed`,
);
const getter = member.operations.find((op) => op.displayName === (member.kind === "value" ? "get" : "resolve"));
if (!getter) throw new QueryCompileError("QUERY_CONTRACT", `Missing getter ${effect.memberId}`);
for (const conformance of workspace.conformances.filter(
(entry) => entry.interfaceRevisionId === effect.interfaceRevisionId,
)) {
const provider = conformance.operationBindings.find((entry) => entry.operationId === getter.id);
if (!provider)
throw new QueryCompileError("QUERY_CONTRACT", `Missing binding ${conformance.atomId}.${getter.id}`);
const binding = provider.binding;
if (binding.kind === "package") {
if (effect.execution !== "rpc-permitted")
throw new QueryCompileError(
"QUERY_NATIVE_BINDING_REQUIRED",
`Native query field ${effect.memberId} binds package code`,
);
if (!provider.queryReason?.trim())
throw new QueryCompileError(
"QUERY_RPC_PROVIDER_REASON",
`Package query field ${effect.memberId} needs query-reason`,
);
} else if (binding.kind === "state" && binding.primitive === "read") needed.add(binding.slotId);
else if (binding.kind === "edge" && binding.primitive === "resolve") needed.add(binding.edgeTypeId);
else
throw new QueryCompileError(
"QUERY_NATIVE_BINDING_REQUIRED",
`Unsupported query read binding ${effect.memberId}`,
);
const start = member.operations.find((op) => op.displayName === "watch-start");
const stop = member.operations.find((op) => op.displayName === "watch-stop");
const watch = start && stop && conformance.operationBindings.find((entry) => entry.operationId === start.id);
if (
binding.kind === "state" &&
watch &&
(watch.binding.kind !== "state" ||
watch.binding.slotId !== binding.slotId ||
watch.binding.primitive !== "watch-start")
)
throw new QueryCompileError(
"QUERY_WATCH_CONTRACT",
`Native query field ${effect.memberId} watch disagrees with its getter`,
);
if (
binding.kind === "edge" &&
watch &&
(watch.binding.kind !== "edge" ||
watch.binding.edgeTypeId !== binding.edgeTypeId ||
watch.binding.projectionId !== binding.projectionId)
)
throw new QueryCompileError(
"QUERY_WATCH_CONTRACT",
`Native query relation ${effect.memberId} watch disagrees with its resolver`,
);
fields.push({
atomId: conformance.atomId,
interfaceRevisionId: effect.interfaceRevisionId,
memberId: effect.memberId,
getter: getter.id,
binding,
...(watch && start && stop ? { watch: { start: start.id, stop: stop.id, binding: watch.binding } } : {}),
});
}
}
const storage = attachments.filter((entry) => needed.has(entry.id));
const bindingDigest = createHash("sha256")
.update(JSON.stringify({ definition: checked.definitionDigest, fields, storage }))
.digest("hex");
linked.push({
id: `${pkg.revisionId}:${declaration.id}`,
packageRevisionId: pkg.revisionId,
checked,
bindingDigest,
fields,
attachments: storage,
});
}
for (const entry of linked) entry.runtime = queryRuntimePlan(entry, workspace);
return linked;
}
+88
View File
@@ -0,0 +1,88 @@
import { create, toJson } from "@bufbuild/protobuf";
import {
InstalledQuerySchema,
QueryArgumentSchema,
QuerySelectionSchema,
QueryReadBindingSchema,
Cardinality,
type QueryArgument as WireArgument,
} from "../gen/camino/schema_pb.js";
import type { LinkedQuery } from "./link.js";
import type { QueryArgument, QuerySelection } from "./types.js";
import type { WorkspaceRevision } from "../capability-model/types.js";
const argument = (entry: QueryArgument): WireArgument => {
switch (entry.kind) {
case "variable":
return create(QueryArgumentSchema, { value: { case: "variable", value: entry.name } });
case "literal":
return create(QueryArgumentSchema, { value: { case: "literalJson", value: JSON.stringify(entry.value) } });
case "list":
return create(QueryArgumentSchema, { value: { case: "list", value: { values: entry.values.map(argument) } } });
case "object":
return create(QueryArgumentSchema, {
value: {
case: "object",
value: { fields: Object.fromEntries(Object.entries(entry.fields).map(([k, v]) => [k, argument(v)])) },
},
});
}
};
const selection = (entry: QuerySelection): import("../gen/camino/schema_pb.js").QuerySelection =>
create(QuerySelectionSchema, {
...entry,
conditions: entry.conditions.map((condition) => ({ include: condition.include, value: argument(condition.value) })),
arguments: Object.fromEntries(Object.entries(entry.arguments).map(([k, v]) => [k, argument(v)])),
selection: entry.selection.map(selection),
});
export const queryRuntimePlan = (linked: LinkedQuery, workspace: WorkspaceRevision) =>
toJson(
InstalledQuerySchema,
create(InstalledQuerySchema, {
id: linked.id,
definitionDigest: linked.checked.definitionDigest,
bindingDigest: linked.bindingDigest,
rootInterfaceRevisionId: linked.checked.declaration.root,
selection: linked.checked.selection.map(selection),
budgets: linked.checked.declaration.budgets,
variablesTypeJson: JSON.stringify(linked.checked.variables),
outputTypeJson: JSON.stringify(linked.checked.output),
variableDefaults: Object.fromEntries(
Object.entries(linked.checked.variableDefaults).map(([k, v]) => [k, argument(v)]),
),
watch: linked.checked.declaration.watch,
pollingIntervalMs: linked.checked.declaration.polling?.intervalMs,
rpcPredicateOrOrder: linked.checked.effects.some(
(effect) => effect.execution === "rpc-permitted" && effect.uses.some((use) => use !== "select"),
),
bindings: linked.fields.map((field) => {
const member = workspace.interfaceImports
.find((entry) => entry.revisionId === field.interfaceRevisionId)!
.members.find((entry) => entry.id === field.memberId)!;
return create(QueryReadBindingSchema, {
atomId: field.atomId,
interfaceRevisionId: field.interfaceRevisionId,
memberId: field.memberId,
getterOperationId: field.getter,
fieldName: member.displayName,
valueTypeJson: member.kind === "value" ? JSON.stringify(member.valueType) : "",
cardinality:
member.kind === "relationship"
? {
"optional-one": Cardinality.OPTIONAL_ONE,
"exactly-one": Cardinality.EXACTLY_ONE,
many: Cardinality.MANY,
"many-unique": Cardinality.MANY_UNIQUE,
}[member.cardinality]
: undefined,
slotId: field.binding.kind === "state" ? field.binding.slotId : "",
edgeTypeId: field.binding.kind === "edge" ? field.binding.edgeTypeId : "",
projectionId: field.binding.kind === "edge" ? field.binding.projectionId : "",
rpc: field.binding.kind === "package",
watchStartOperationId: field.watch?.start,
watchStopOperationId: field.watch?.stop,
});
}),
}),
);
+243
View File
@@ -0,0 +1,243 @@
import {
GraphQLBoolean,
GraphQLString,
GraphQLInt,
GraphQLFloat,
GraphQLScalarType,
GraphQLObjectType,
GraphQLInputObjectType,
GraphQLEnumType,
GraphQLList,
GraphQLNonNull,
GraphQLSchema,
type GraphQLOutputType,
type GraphQLInputType,
type GraphQLFieldConfigMap,
type GraphQLInputFieldConfigMap,
} from "graphql";
import { createHash } from "node:crypto";
import type {
InterfaceRevision,
InterfaceRevisionId,
ValueType,
RelationshipInterfaceMember,
} from "../capability-model/types.js";
import { QueryCompileError, type QueryDeclaration } from "./types.js";
// Validate losslessly; callers carry decimal strings across JSON transports.
function integerParser(name: string, min: bigint, max: bigint, value: unknown): string {
if (
typeof value !== "string" &&
typeof value !== "bigint" &&
!(typeof value === "number" && Number.isSafeInteger(value))
)
throw new Error(`${name} requires a canonical integer`);
const text = String(value);
if (!/^-?(0|[1-9][0-9]*)$/.test(text) || text === "-0") throw new Error(`${name} requires a canonical integer`);
const n = BigInt(text);
if (n < min || n > max) throw new Error(`${name} out of range`);
return text;
}
const integerScalar = (name: string, min: bigint, max: bigint) => {
const parse = (v: unknown) => integerParser(name, min, max, v);
return new GraphQLScalarType({
name,
serialize: parse,
parseValue: parse,
parseLiteral(node) {
if (node.kind !== "IntValue" && node.kind !== "StringValue") throw new Error(`${name} requires an integer`);
return parse(node.value);
},
});
};
export const queryScalars = {
bool: GraphQLBoolean,
string: GraphQLString,
int32: GraphQLInt,
int64: integerScalar("Int64", -(1n << 63n), (1n << 63n) - 1n),
uint32: integerScalar("UInt32", 0n, (1n << 32n) - 1n),
uint64: integerScalar("UInt64", 0n, (1n << 64n) - 1n),
double: GraphQLFloat,
bytes: new GraphQLScalarType({ name: "Bytes" }),
};
export const cursorScalar = new GraphQLScalarType({
name: "Cursor",
parseValue(value) {
if (typeof value !== "string" || value.length > 4096) throw new Error("Invalid cursor");
return value;
},
});
export const referenceScalar = new GraphQLScalarType({ name: "ManagedReference" });
export function querySchema(declaration: QueryDeclaration, interfaces: readonly InterfaceRevision[]) {
const contracts = new Map(interfaces.map((entry) => [entry.revisionId, entry]));
const objects = new Map<InterfaceRevisionId, GraphQLObjectType>();
const filters = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
const byName = new Map<string, InterfaceRevision>();
const comparisons = new Map<string, GraphQLInputObjectType>();
const metadata = new GraphQLObjectType({
name: "QxMetadata",
fields: { ref: { type: new GraphQLNonNull(referenceScalar) } },
});
const pageInfo = new GraphQLObjectType({
name: "QxPageInfo",
fields: {
hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) },
endCursor: { type: cursorScalar },
},
});
const direction = new GraphQLEnumType({ name: "QxDirection", values: { ASC: {}, DESC: {} } });
const contract = (id: InterfaceRevisionId) => {
const found = contracts.get(id);
if (!found || found.template) throw new QueryCompileError("QUERY_CONTRACT", `Expected closed interface ${id}`);
return found;
};
const name = (id: InterfaceRevisionId) => {
const revision = contract(id);
const result =
revision.displayName +
(revision.application ? `_${createHash("sha256").update(id).digest("hex").slice(0, 12)}` : "");
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(result) || result.startsWith("__") || result.startsWith("Qx"))
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Unsupported query interface name ${result}`);
const existing = byName.get(result);
if (existing && existing.revisionId !== id)
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Conflicting query interface name ${result}`);
byName.set(result, revision);
return result;
};
const target = (member: RelationshipInterfaceMember): InterfaceRevisionId => {
if (member.target.kind === "interface") return member.target.interfaceRevisionId;
const atomId = member.target.atomId;
const views = declaration.views.filter((entry) => entry.atomId === atomId);
if (views.length !== 1)
throw new QueryCompileError(
"QUERY_VIEW_REQUIRED",
`Declare exactly one interface view for ${member.target.atomId}`,
);
return views[0]!.interfaceRevisionId;
};
const scalar = (type: ValueType): GraphQLOutputType & GraphQLInputType => {
if (type.kind === "optional") return scalar(type.value);
if (type.kind !== "scalar")
throw new QueryCompileError("QUERY_TYPE", "Only scalar/optional scalar query fields are supported");
return queryScalars[type.name];
};
const comparison = (type: ValueType) => {
const base = type.kind === "optional" ? type.value : type;
const value = scalar(base);
const key = String(value);
let result = comparisons.get(key);
if (!result) {
const fields: GraphQLInputFieldConfigMap = { eq: { type: value }, isNull: { type: GraphQLBoolean } };
if (base.kind === "scalar" && base.name !== "bool" && base.name !== "bytes")
for (const op of ["lt", "lte", "gt", "gte"]) fields[op] = { type: value };
result = new GraphQLInputObjectType({ name: `QxCompare${key}`, fields });
comparisons.set(key, result);
}
return result;
};
const filter = (id: InterfaceRevisionId): GraphQLInputObjectType => {
const existing = filters.get(id);
if (existing) return existing;
const result = new GraphQLInputObjectType({
name: `${name(id)}Where`,
fields: () => {
const fields: GraphQLInputFieldConfigMap = {
and: { type: new GraphQLList(new GraphQLNonNull(result)) },
or: { type: new GraphQLList(new GraphQLNonNull(result)) },
not: { type: result },
};
for (const member of contract(id).members)
if (member.kind === "value" && member.queryRead) {
if (member.displayName in fields)
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Reserved predicate name ${member.displayName}`);
fields[member.displayName] = { type: comparison(member.valueType) };
}
return fields;
},
});
filters.set(id, result);
return result;
};
const order = (id: InterfaceRevisionId) => {
let result = orders.get(id);
if (!result) {
const fields: GraphQLInputFieldConfigMap = {};
for (const member of contract(id).members) {
if (member.kind !== "value" || !member.queryRead) continue;
const t = member.valueType.kind === "optional" ? member.valueType.value : member.valueType;
if (t.kind === "scalar" && t.name !== "bytes") fields[member.displayName] = { type: direction };
}
if (Object.keys(fields).length === 0) return undefined;
result = new GraphQLInputObjectType({ name: `${name(id)}Order`, fields });
orders.set(id, result);
}
return result;
};
const object = (id: InterfaceRevisionId): GraphQLObjectType => {
const existing = objects.get(id);
if (existing) return existing;
const result = new GraphQLObjectType({
name: name(id),
fields: () => {
const fields: GraphQLFieldConfigMap<unknown, unknown> = { _qx: { type: new GraphQLNonNull(metadata) } };
for (const member of contract(id).members) {
if (member.kind === "operation" || !member.queryRead) continue;
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(member.displayName) || member.displayName.startsWith("_"))
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Unsupported query field name ${member.displayName}`);
if (member.kind === "value") {
const type = scalar(member.valueType);
fields[member.displayName] = {
type: member.valueType.kind === "optional" ? type : new GraphQLNonNull(type),
};
} else {
const targetId = target(member),
node = object(targetId);
if (member.cardinality === "exactly-one" || member.cardinality === "optional-one")
fields[member.displayName] = {
type: member.cardinality === "exactly-one" ? new GraphQLNonNull(node) : node,
};
else {
const entry = new GraphQLObjectType({
name: `${name(id)}_${member.displayName}_Entry`,
fields: {
key: { type: new GraphQLNonNull(GraphQLString) },
cursor: { type: cursorScalar },
node: { type: new GraphQLNonNull(node) },
},
});
const connection = new GraphQLObjectType({
name: `${name(id)}_${member.displayName}_Connection`,
fields: {
entries: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(entry))) },
pageInfo: { type: new GraphQLNonNull(pageInfo) },
},
});
const ordering = order(targetId);
fields[member.displayName] = {
type: new GraphQLNonNull(connection),
args: {
first: { type: new GraphQLNonNull(GraphQLInt) },
after: { type: cursorScalar },
where: { type: filter(targetId) },
...(ordering ? { orderBy: { type: new GraphQLList(new GraphQLNonNull(ordering)) } } : {}),
},
};
}
}
}
return fields;
},
});
objects.set(id, result);
return result;
};
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: "QxQuery",
fields: { root: { type: new GraphQLNonNull(object(declaration.root)) } },
}),
});
return { schema, byName, target, contracts };
}
+90
View File
@@ -0,0 +1,90 @@
import type { AtomId, InterfaceRevisionId, MemberId, ValueType } from "../capability-model/types.js";
export type QueryUse = "select" | "predicate" | "order";
export interface QueryAllowance {
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
uses: QueryUse[];
reason: string;
}
export interface QueryBudgets {
rows: number;
depth: number;
resultBytes: number;
candidates: number;
rpcCalls: number;
concurrency: number;
deadlineMs: number;
}
export const defaultQueryBudgets: Readonly<QueryBudgets> = Object.freeze({
rows: 100,
depth: 8,
resultBytes: 1048576,
candidates: 100,
rpcCalls: 200,
concurrency: 8,
deadlineMs: 10000,
});
export interface QueryDeclaration {
id: string;
displayName: string;
root: InterfaceRevisionId;
document: string;
operation: string;
fragments: string[];
views: { atomId: AtomId; interfaceRevisionId: InterfaceRevisionId }[];
allowances: QueryAllowance[];
budgets: QueryBudgets;
watch: boolean;
polling?: { intervalMs: number; reason: string };
}
export interface QueryFieldEffect {
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
uses: QueryUse[];
execution: "native" | "rpc-permitted";
}
export interface QuerySourceLocation {
file: string;
line: number;
column: number;
}
export class QueryCompileError extends Error {
constructor(
readonly code: string,
message: string,
readonly location?: QuerySourceLocation,
) {
super(message);
this.name = "QueryCompileError";
}
}
export interface CheckedQuery {
declaration: QueryDeclaration;
definitionDigest: string;
/** Fixed, validated document: never supplied by runtime callers. */
document: string;
schema: string;
variables: ValueType;
output: ValueType;
effects: QueryFieldEffect[];
selection: QuerySelection[];
variableDefaults: Record<string, QueryArgument>;
sourceFiles: string[];
}
export type QueryArgument =
| { kind: "variable"; name: string }
| { kind: "literal"; value: string | number | boolean | null }
| { kind: "list"; values: QueryArgument[] }
| { kind: "object"; fields: Record<string, QueryArgument> };
export interface QuerySelection {
name: string;
key: string;
interfaceRevisionId?: InterfaceRevisionId;
memberId?: MemberId;
targetInterfaceRevisionId?: InterfaceRevisionId;
conditions: { include: boolean; value: QueryArgument }[];
arguments: Record<string, QueryArgument>;
selection: QuerySelection[];
}