Add queryable capability contracts and checked GraphQL artifacts
This commit is contained in:
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user