Implement query execution, scoped RPC enrichment, live collections, and scaffold integration
This commit is contained in:
+68
-16
@@ -25,6 +25,7 @@ import {
|
||||
type DirectiveNode,
|
||||
} from "graphql";
|
||||
import { createHash } from "node:crypto";
|
||||
import { canonicalJson } from "../capability-model/evolution.js";
|
||||
import { readRepositorySource } from "../capability-language/source-loader.js";
|
||||
import {
|
||||
valueType,
|
||||
@@ -192,18 +193,28 @@ export async function compileQuery(
|
||||
if (member.kind !== "relationship" || (member.cardinality !== "many" && member.cardinality !== "many-unique"))
|
||||
return;
|
||||
const target = generated.target(member);
|
||||
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);
|
||||
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" &&
|
||||
["first", "all"].includes(argument.name.value) &&
|
||||
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);
|
||||
fail(
|
||||
"QUERY_ROW_LIMIT",
|
||||
`${argument.name.value} must be between 1 and ${declaration.budgets.rows}`,
|
||||
argument,
|
||||
);
|
||||
}
|
||||
},
|
||||
FragmentSpread(node) {
|
||||
@@ -228,26 +239,47 @@ export async function compileQuery(
|
||||
.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));
|
||||
// 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;
|
||||
};
|
||||
const required = (type: GraphQLType, selections?: SelectionSetNode, depth = 0): ValueType => {
|
||||
if (isListType(type)) return valueType.list(output(type.ofType, selections, depth));
|
||||
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));
|
||||
if (isObjectType(type)) {
|
||||
const fields: Record<string, ValueType> = {};
|
||||
const add = (set: SelectionSetNode) => {
|
||||
const add = (set: SelectionSetNode, conditional = false) => {
|
||||
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);
|
||||
add(fragments.get(selection.name.value)!.selectionSet, conditional || !!selection.directives?.length);
|
||||
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);
|
||||
const previous = fields[key];
|
||||
fields[key] = output(
|
||||
field.type,
|
||||
selection.selectionSet,
|
||||
depth + 1,
|
||||
conditional || !!selection.directives?.length,
|
||||
);
|
||||
if (selection.name.value === "_qx") {
|
||||
const contract = generated.byName.get(type.name)!;
|
||||
const metadataFields: Record<string, ValueType> = {};
|
||||
@@ -258,11 +290,12 @@ export async function compileQuery(
|
||||
}
|
||||
fields[key] = { kind: "record", fields: metadataFields };
|
||||
}
|
||||
if (selection.directives?.length && fields[key]!.kind !== "optional")
|
||||
if ((conditional || selection.directives?.length) && fields[key]!.kind !== "optional")
|
||||
fields[key] = valueType.optional(fields[key]!);
|
||||
if (previous) fields[key] = mergeOutput(previous, fields[key]!);
|
||||
}
|
||||
};
|
||||
if (selections) add(selections);
|
||||
if (selections) add(selections, conditional);
|
||||
return { kind: "record", fields };
|
||||
}
|
||||
return shapeScalar(getNamedType(type)!.name);
|
||||
@@ -354,7 +387,16 @@ export async function compileQuery(
|
||||
const normalized = print(document),
|
||||
schema = printSchema(generated.schema);
|
||||
const definitionDigest = createHash("sha256")
|
||||
.update(JSON.stringify({ semantics: 1, declaration, normalized, interfaces }))
|
||||
.update(
|
||||
canonicalJson({
|
||||
semantics: 1,
|
||||
declaration,
|
||||
normalized,
|
||||
interfaces: [...generated.byName.values()].sort((a, b) =>
|
||||
a.revisionId < b.revisionId ? -1 : a.revisionId > b.revisionId ? 1 : 0,
|
||||
),
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
return {
|
||||
declaration,
|
||||
@@ -374,8 +416,18 @@ export async function compileQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export const compileRepositoryQuery = (
|
||||
export const compileRepositoryQuery = async (
|
||||
root: string,
|
||||
declaration: QueryDeclaration,
|
||||
interfaces: readonly InterfaceRevision[],
|
||||
) => compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name));
|
||||
) => {
|
||||
const started = performance.now();
|
||||
try {
|
||||
return await compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name));
|
||||
} 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`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+13
-1
@@ -9,6 +9,7 @@ import type {
|
||||
} from "../capability-model/types.js";
|
||||
import { QueryCompileError, type CheckedQuery } from "./types.js";
|
||||
import { queryRuntimePlan } from "./proto.js";
|
||||
import { canonicalJson } from "../capability-model/evolution.js";
|
||||
|
||||
export interface LinkedQueryField {
|
||||
atomId: AtomId;
|
||||
@@ -128,7 +129,13 @@ export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
|
||||
}
|
||||
const storage = attachments.filter((entry) => needed.has(entry.id));
|
||||
const bindingDigest = createHash("sha256")
|
||||
.update(JSON.stringify({ definition: checked.definitionDigest, fields, storage }))
|
||||
.update(
|
||||
canonicalJson({
|
||||
definition: checked.definitionDigest,
|
||||
fields: [...fields].sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))),
|
||||
storage: [...storage].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
linked.push({
|
||||
id: `${pkg.revisionId}:${declaration.id}`,
|
||||
@@ -140,5 +147,10 @@ export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
|
||||
});
|
||||
}
|
||||
for (const entry of linked) entry.runtime = queryRuntimePlan(entry, workspace);
|
||||
if (new Set(linked.map((entry) => entry.id)).size !== linked.length)
|
||||
throw new QueryCompileError(
|
||||
"QUERY_ID_COLLISION",
|
||||
"Query export identities collide; choose distinct package/query IDs",
|
||||
);
|
||||
return linked;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export const queryRuntimePlan = (linked: LinkedQuery, workspace: WorkspaceRevisi
|
||||
memberId: field.memberId,
|
||||
getterOperationId: field.getter,
|
||||
fieldName: member.displayName,
|
||||
keyType: member.kind === "relationship" ? member.keyType : undefined,
|
||||
valueTypeJson: member.kind === "value" ? JSON.stringify(member.valueType) : "",
|
||||
cardinality:
|
||||
member.kind === "relationship"
|
||||
|
||||
+11
-1
@@ -203,6 +203,15 @@ export function querySchema(declaration: QueryDeclaration, interfaces: readonly
|
||||
name: `${name(id)}_${member.displayName}_Entry`,
|
||||
fields: {
|
||||
key: { type: new GraphQLNonNull(GraphQLString) },
|
||||
...(member.keyType
|
||||
? {
|
||||
mapKey: {
|
||||
type: new GraphQLNonNull(
|
||||
queryScalars[member.keyType === "boolean" ? "bool" : member.keyType],
|
||||
),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
cursor: { type: cursorScalar },
|
||||
node: { type: new GraphQLNonNull(node) },
|
||||
},
|
||||
@@ -218,7 +227,8 @@ export function querySchema(declaration: QueryDeclaration, interfaces: readonly
|
||||
fields[member.displayName] = {
|
||||
type: new GraphQLNonNull(connection),
|
||||
args: {
|
||||
first: { type: new GraphQLNonNull(GraphQLInt) },
|
||||
first: { type: GraphQLInt },
|
||||
all: { type: GraphQLInt },
|
||||
after: { type: cursorScalar },
|
||||
where: { type: filter(targetId) },
|
||||
...(ordering ? { orderBy: { type: new GraphQLList(new GraphQLNonNull(ordering)) } } : {}),
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { capabilityId, type InterfaceRevision, type InterfaceRevisionId } from "../capability-model/types.js";
|
||||
import {
|
||||
TypeSubstitution,
|
||||
bindTypeParameters,
|
||||
type ClosedTypeArgument,
|
||||
type GenericTypeEnvironment,
|
||||
} from "../capability-model/generics.js";
|
||||
import { GenericSourceTypes } from "../capability-language/generic-types.js";
|
||||
import { compileQuery } from "./compile.js";
|
||||
import { QueryCompileError, type QueryDeclaration, type QueryTemplate } from "./types.js";
|
||||
|
||||
export function specializeQueryTemplate(
|
||||
template: QueryTemplate,
|
||||
arguments_: ClosedTypeArgument[],
|
||||
environment: GenericTypeEnvironment,
|
||||
interfaces: () => readonly InterfaceRevision[],
|
||||
identity: { id: string; displayName: string },
|
||||
): QueryDeclaration {
|
||||
const obligations: NonNullable<QueryDeclaration["argumentRequirements"]> = [];
|
||||
const checked = {
|
||||
...environment,
|
||||
implementsInterface: (
|
||||
target: Parameters<GenericTypeEnvironment["implementsInterface"]>[0],
|
||||
required: InterfaceRevisionId,
|
||||
) => {
|
||||
obligations.push({ target, required });
|
||||
return environment.implementsInterface(target, required);
|
||||
},
|
||||
};
|
||||
const substitution = new TypeSubstitution({
|
||||
...checked,
|
||||
arguments: bindTypeParameters(template.parameters, arguments_, checked, template.declaration.displayName),
|
||||
});
|
||||
return {
|
||||
...structuredClone(template.declaration),
|
||||
...identity,
|
||||
root: substitution.application(template.root),
|
||||
views: template.views
|
||||
.map((view) => {
|
||||
const target = substitution.object(view.target);
|
||||
const interfaceRevisionId = substitution.application(view.interface);
|
||||
if (target.kind === "interface") {
|
||||
if (target.interfaceRevisionId !== interfaceRevisionId)
|
||||
throw new QueryCompileError(
|
||||
"QUERY_VIEW_REQUIRED",
|
||||
"An interface argument must use its exact selected query view",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return { atomId: target.atomId, interfaceRevisionId };
|
||||
})
|
||||
.filter((view): view is NonNullable<typeof view> => !!view),
|
||||
allowances: template.allowances.map((allowance) => {
|
||||
const interfaceRevisionId = substitution.application(allowance.interface);
|
||||
const member = interfaces()
|
||||
.find((entry) => entry.revisionId === interfaceRevisionId)
|
||||
?.members.find((entry) => entry.displayName === allowance.memberName);
|
||||
if (!member) throw new QueryCompileError("QUERY_ALLOWANCE", `Unknown template field ${allowance.memberName}`);
|
||||
return { interfaceRevisionId, memberId: member.id, uses: allowance.uses, reason: allowance.reason };
|
||||
}),
|
||||
application: { templateId: template.declaration.id, arguments: structuredClone(arguments_) },
|
||||
argumentRequirements: obligations,
|
||||
};
|
||||
}
|
||||
|
||||
/** Check a document with only its declared bounds available, even if no concrete
|
||||
* specialization is installed. No concrete atom fields can leak into this check. */
|
||||
export async function checkQueryTemplate(
|
||||
template: QueryTemplate,
|
||||
interfaces: readonly InterfaceRevision[],
|
||||
read: (name: string) => Promise<string>,
|
||||
) {
|
||||
const types = new GenericSourceTypes(new Map());
|
||||
for (const contract of interfaces) types.register(contract.displayName, contract);
|
||||
const arguments_: ClosedTypeArgument[] = template.parameters.map((parameter) => {
|
||||
if (parameter.kind !== "object")
|
||||
throw new QueryCompileError(
|
||||
"QUERY_TEMPLATE_PARAMETER",
|
||||
"Query templates currently require object parameters with explicit interface views; scalar/value polymorphism is not queryable",
|
||||
);
|
||||
return {
|
||||
kind: "object",
|
||||
target: { kind: "atom", atomId: capabilityId.atom(`query-bound:${template.declaration.id}:${parameter.id}`) },
|
||||
};
|
||||
});
|
||||
const bindings = new Map(template.parameters.map((parameter, index) => [parameter.id, arguments_[index]!]));
|
||||
const substitution = new TypeSubstitution({ ...types.environment(), arguments: bindings });
|
||||
const evidence = new Map(
|
||||
arguments_.map((argument, index) => {
|
||||
const parameter = template.parameters[index]!;
|
||||
const target = argument.kind === "object" && argument.target.kind === "atom" ? argument.target.atomId : "";
|
||||
return [
|
||||
target,
|
||||
new Set(
|
||||
parameter.kind === "object" ? parameter.implements.map((bound) => substitution.application(bound)) : [],
|
||||
),
|
||||
];
|
||||
}),
|
||||
);
|
||||
const implies = (offered: string, required: string, seen = new Set<string>()): boolean => {
|
||||
if (offered === required) return true;
|
||||
if (seen.has(offered)) return false;
|
||||
seen.add(offered);
|
||||
const contract = types.definitions.get(offered);
|
||||
return (contract?.requiredInterfaces ?? []).some((parent) => implies(parent, required, seen));
|
||||
};
|
||||
const environment = {
|
||||
...types.environment(),
|
||||
implementsInterface: (
|
||||
target: Parameters<GenericTypeEnvironment["implementsInterface"]>[0],
|
||||
required: InterfaceRevisionId,
|
||||
) =>
|
||||
target.kind === "atom" && [...(evidence.get(target.atomId) ?? [])].some((offered) => implies(offered, required)),
|
||||
};
|
||||
const declaration = specializeQueryTemplate(
|
||||
template,
|
||||
arguments_,
|
||||
environment,
|
||||
() => [...types.definitions.values()],
|
||||
template.declaration,
|
||||
);
|
||||
for (const view of declaration.views)
|
||||
if (!environment.implementsInterface({ kind: "atom", atomId: view.atomId }, view.interfaceRevisionId))
|
||||
throw new QueryCompileError(
|
||||
"QUERY_TEMPLATE_BOUND",
|
||||
`The declared bounds do not guarantee the selected view ${view.interfaceRevisionId}`,
|
||||
);
|
||||
for (const application of types.applications.values())
|
||||
for (const obligation of application.argumentRequirements ?? [])
|
||||
if (!environment.implementsInterface(obligation.target, obligation.required))
|
||||
throw new QueryCompileError(
|
||||
"QUERY_TEMPLATE_BOUND",
|
||||
`Template arguments do not guarantee ${obligation.required}`,
|
||||
);
|
||||
return compileQuery(declaration, [...types.definitions.values()], read);
|
||||
}
|
||||
+20
-1
@@ -1,4 +1,10 @@
|
||||
import type { AtomId, InterfaceRevisionId, MemberId, ValueType } from "../capability-model/types.js";
|
||||
import type {
|
||||
TypeParameter,
|
||||
InterfaceApplicationExpression,
|
||||
ObjectTypeExpression,
|
||||
ClosedTypeArgument,
|
||||
} from "../capability-model/generics.js";
|
||||
|
||||
export type QueryUse = "select" | "predicate" | "order";
|
||||
export interface QueryAllowance {
|
||||
@@ -37,6 +43,19 @@ export interface QueryDeclaration {
|
||||
budgets: QueryBudgets;
|
||||
watch: boolean;
|
||||
polling?: { intervalMs: number; reason: string };
|
||||
application?: { templateId: string; arguments: ClosedTypeArgument[] };
|
||||
argumentRequirements?: {
|
||||
target: import("../capability-model/types.js").ObjectExpectation;
|
||||
required: InterfaceRevisionId;
|
||||
}[];
|
||||
}
|
||||
/** Source-only template; neither parameters nor synthetic bound witnesses are installed. */
|
||||
export interface QueryTemplate {
|
||||
declaration: Omit<QueryDeclaration, "root" | "views" | "allowances">;
|
||||
parameters: TypeParameter[];
|
||||
root: InterfaceApplicationExpression;
|
||||
views: { target: ObjectTypeExpression; interface: InterfaceApplicationExpression }[];
|
||||
allowances: { interface: InterfaceApplicationExpression; memberName: string; uses: QueryUse[]; reason: string }[];
|
||||
}
|
||||
export interface QueryFieldEffect {
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
@@ -55,7 +74,7 @@ export class QueryCompileError extends Error {
|
||||
message: string,
|
||||
readonly location?: QuerySourceLocation,
|
||||
) {
|
||||
super(message);
|
||||
super(`${code}${location ? ` ${location.file}:${location.line}:${location.column}` : ""}: ${message}`);
|
||||
this.name = "QueryCompileError";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user