diff --git a/src/execution-world/index.ts b/src/execution-world/index.ts index e393d65..1f1af96 100644 --- a/src/execution-world/index.ts +++ b/src/execution-world/index.ts @@ -14,6 +14,7 @@ export { generatePortableTypeScriptBindings, generateRemoteTypeScriptBindings } export * from "./kernel.js"; export * from "./projections.js"; export * from "./queries.js"; +export * from "./query-document.js"; export { generatePortableClientBindings } from "../bindings/portable-client.js"; export * from "./candidate.js"; diff --git a/src/execution-world/queries.ts b/src/execution-world/queries.ts index 8ddf438..26cb3c3 100644 --- a/src/execution-world/queries.ts +++ b/src/execution-world/queries.ts @@ -25,7 +25,6 @@ const lowerTypes = (value: unknown): unknown => export function compileQueryProgram(plan: ExecutionPlan): unknown { if (hexDigest(plan.definition) !== plan.digest) throw new ExecutionPlanError("INVALID_PLAN", "queries", "Plan digest mismatch"); - const interfaces = plan.definition.workspace.interfaceImports; const allQueries = new Map(plan.definition.queries.map(({ id, query }) => [id, query.checked])); const compositions = plan.definition.workspace.packageImports.flatMap((pkg) => (pkg.queryCompositions ?? []).map((composition) => { @@ -62,45 +61,50 @@ export function compileQueryProgram(plan: ExecutionPlan): unknown { ...(compositions.length ? { compositions } : {}), queries: [ ...compileInspectionTemplates(plan).queries, - ...plan.definition.queries.map(({ id, query, admittedOperations }) => { - const checked = query.checked; - return { - id, - ...(checked.declaration.fragment && !checked.declaration.fragment.admission - ? { - fragment: { - package: query.packageRevisionId, - id: checked.declaration.id, - digest: checked.definitionDigest, - }, - } - : {}), - placement: checked.declaration.placement?.kind ?? "remote", - source: - checked.declaration.placement?.kind === "local" - ? JSON.stringify([query.packageRevisionId, checked.declaration.placement.sourceQueryId]) - : null, - root: checked.declaration.root, - atoms: query.rootAtoms, - variables: portableValueSchema(checked.variables), - defaults: checked.variableDefaults, - selection: lowerTypes(checked.selection), - budgets: checked.declaration.budgets, - fields: query.fields.map((field) => { - const member = interfaces - .find((i) => i.revisionId === field.interfaceRevisionId)! - .members.find((m) => m.id === field.memberId)!; - return { - atom: field.atomId, - interface: field.interfaceRevisionId, - member: field.memberId, - name: member.displayName, - operation: JSON.stringify([field.atomId, field.interfaceRevisionId, field.getter]), - }; - }), - admittedOperations, - }; - }), + ...plan.definition.queries.map((entry) => queryProgramEntry(plan, entry)), ], }; } + +export function queryProgramEntry(plan: ExecutionPlan, entry: import("./plan.js").PlannedQuery) { + const { id, query, admittedOperations } = entry; + const interfaces = plan.definition.workspace.interfaceImports; + + const checked = query.checked; + return { + id, + ...(checked.declaration.fragment && !checked.declaration.fragment.admission + ? { + fragment: { + package: query.packageRevisionId, + id: checked.declaration.id, + digest: checked.definitionDigest, + }, + } + : {}), + placement: checked.declaration.placement?.kind ?? "remote", + source: + checked.declaration.placement?.kind === "local" + ? JSON.stringify([query.packageRevisionId, checked.declaration.placement.sourceQueryId]) + : null, + root: checked.declaration.root, + atoms: query.rootAtoms, + variables: portableValueSchema(checked.variables), + defaults: checked.variableDefaults, + selection: lowerTypes(checked.selection), + budgets: checked.declaration.budgets, + fields: query.fields.map((field) => { + const member = interfaces + .find((i) => i.revisionId === field.interfaceRevisionId)! + .members.find((m) => m.id === field.memberId)!; + return { + atom: field.atomId, + interface: field.interfaceRevisionId, + member: field.memberId, + name: member.displayName, + operation: JSON.stringify([field.atomId, field.interfaceRevisionId, field.getter]), + }; + }), + admittedOperations, + }; +} diff --git a/src/execution-world/query-document.ts b/src/execution-world/query-document.ts new file mode 100644 index 0000000..08509d1 --- /dev/null +++ b/src/execution-world/query-document.ts @@ -0,0 +1,77 @@ +import { compileQuery } from "../query/compile.js"; +import { linkQueries } from "../query/link.js"; +import { capabilityId } from "../capability-model/types.js"; +import { portableValueSchema } from "../bindings/portable.js"; +import { hexDigest, type ExecutionPlan, type PlannedQuery } from "./plan.js"; +import { compileKernelProgram } from "./kernel.js"; +import { queryProgramEntry } from "./queries.js"; +import { unsupportedQueryOperator } from "./query-operators.js"; + +export interface QueryDocument { + readonly interface: string; + readonly document: string; + readonly operation: string; + readonly objectRoot: boolean; +} +/** Runtime query documents use the same language, nominal contracts, linking + * and interpreter as packaged queries. They introduce no resources or code. */ +export async function compilePublicQuery(plan: ExecutionPlan, request: QueryDocument) { + if (hexDigest(plan.definition) !== plan.digest) throw Error("INVALID_PLAN"); + if ( + typeof request.document !== "string" || + Buffer.byteLength(request.document) > 32768 || + typeof request.operation !== "string" || + !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(request.operation) || + typeof request.interface !== "string" || + request.interface.length > 2048 + ) + throw Error("QUERY_DOCUMENT_LIMIT"); + const id = "public-query:" + hexDigest(request); + const checked = await compileQuery( + { + id, + displayName: request.operation, + root: { + kind: request.objectRoot ? "object" : "workspace-interface", + interface: capabilityId.interfaceRevision(request.interface), + }, + document: "request.graphql", + operation: request.operation, + fragments: [], + views: [], + allowances: [], + watch: false, + budgets: { + rows: 512, + depth: 8, + resultBytes: 256 * 1024, + candidates: 10000, + rpcCalls: 128, + concurrency: 8, + deadlineMs: 10000, + }, + }, + plan.definition.workspace.interfaceImports, + async () => request.document, + ); + const unsupported = unsupportedQueryOperator(checked); + if (unsupported) throw Error(`QUERY_OPERATOR_UNSUPPORTED: ${unsupported}`); + const linked = linkQueries(plan.definition.workspace, [ + { packageRevisionId: "org.quixos.public-query/1", checked }, + ]).find((q) => q.checked === checked)!; + const kernel = compileKernelProgram(plan) as { + operations: { key: string; effect: string; unsupported: string | null; binding: { kind: string } }[]; + }; + for (const field of linked.fields) { + const key = JSON.stringify([field.atomId, field.interfaceRevisionId, field.getter]); + const operation = kernel.operations.find((op) => op.key === key); + const member = plan.definition.workspace.interfaceImports + .find((i) => i.revisionId === field.interfaceRevisionId)! + .members.find((m) => m.id === field.memberId)!; + if (!operation || operation.unsupported || operation.effect !== "snapshot") + throw Error("QUERY_DOCUMENT_REQUIRES_SNAPSHOT: use an installed query for remote-effect enrichment"); + if (member.kind === "relationship" && operation.binding.kind !== "edge") throw Error("QUERY_NON_NATIVE_RELATION"); + } + const entry: PlannedQuery = { id, query: linked, operations: [], admittedOperations: [] }; + return { id, program: queryProgramEntry(plan, entry), output: portableValueSchema(checked.output) }; +} diff --git a/src/query/compile.ts b/src/query/compile.ts index d9e0d49..6711e72 100644 --- a/src/query/compile.ts +++ b/src/query/compile.ts @@ -66,7 +66,8 @@ const fail = (code: string, message: string, node?: ASTNode): never => { ); }; -/** Only immutable repository sources call this. Runtime requests never compile documents. */ +/** Shared checker for authored queries and bounded public query documents. + * Runtime execution receives its checked IR, never an unchecked document. */ export async function compileQuery( declaration: QueryDeclaration, interfaces: readonly InterfaceRevision[], diff --git a/src/query/link.ts b/src/query/link.ts index 9731000..f6684ca 100644 --- a/src/query/link.ts +++ b/src/query/link.ts @@ -32,14 +32,28 @@ export interface LinkedQuery { } /** Link every possible implementation, including atoms absent from today's data. */ -export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] { +export function linkQueries( + workspace: WorkspaceRevision, + additional: readonly { + packageRevisionId: string; + checked: CheckedQuery; + }[] = [], +): 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 pkg of [ + ...workspace.packageImports, + ...additional.map(({ packageRevisionId, checked }) => ({ + revisionId: packageRevisionId, + displayName: "Public query", + queries: [checked.declaration], + checkedQueries: [checked], + })), + ]) 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)) diff --git a/src/query/types.ts b/src/query/types.ts index 2afa27e..cfa096c 100644 --- a/src/query/types.ts +++ b/src/query/types.ts @@ -97,7 +97,8 @@ export interface CheckedQuery { fragmentContracts?: Record>; declaration: QueryDeclaration; definitionDigest: string; - /** Fixed, validated document: never supplied by runtime callers. */ + /** Fixed, validated document. Execution always uses checked IR; public runtime + * documents pass through this same checker before they are executed. */ document: string; schema: string; variables: ValueType;