Add checked workspace-interface query roots

Implement scoped interface extents across QX, generated bindings, SDK,
Orch, Camino and browser queries. Preserve native projection, aggregation,
pagination and watch invalidation without synthetic persisted objects.
Validate exact query grants and scope freshness on execution and replay.
Add local PostgreSQL, transport, browser and code-generation coverage.

C1 implementation slice only: full-text indexes and Studio search adoption
(C2/C3) remain tracked in the implementation handoff. No publication or deployment.
This commit is contained in:
Timothy J. Aveni
2026-09-19 10:18:47 -07:00
parent 0810775c07
commit 5634d3ee78
27 changed files with 1371 additions and 945 deletions
+4
View File
@@ -35,6 +35,7 @@ import {
} from "../capability-model/types.js";
import { querySchema } from "./schema.js";
import { objectRow } from "./relational-schema.js";
import { workspaceQueryRootId } from "./root.js";
import { relationalCompiler } from "./relational-compile.js";
import {
QueryCompileError,
@@ -120,6 +121,9 @@ export async function compileQuery(
.map((entry) => [entry.name.value, entry]),
);
const mark = (id: InterfaceRevisionId, name: string, use: QueryUse, node: ASTNode) => {
// The virtual extent has no provider/getter effect. Its membership is
// linked separately, including when the query only selects references.
if (id === workspaceQueryRootId) return;
const contract = generated.contracts.get(id)!;
const member = contract.members.find((entry) => entry.displayName === name);
if (!member || member.kind === "operation" || !member.queryRead)
+9
View File
@@ -27,6 +27,7 @@ export interface LinkedQuery {
bindingDigest: string;
fields: LinkedQueryField[];
attachments: PersistentAttachment[];
rootAtoms: AtomId[];
}
/** Link every possible implementation, including atoms absent from today's data. */
@@ -128,10 +129,17 @@ export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
}
}
const storage = attachments.filter((entry) => needed.has(entry.id));
const rootConformances = workspace.conformances.filter(
(entry) => entry.interfaceRevisionId === declaration.root.interface,
);
const rootAtoms = [...new Set(rootConformances.map((entry) => entry.atomId))].sort();
const bindingDigest = createHash("sha256")
.update(
canonicalJson({
definition: checked.definitionDigest,
rootConformances: rootConformances
.map((entry) => ({ atomId: entry.atomId, id: entry.id, interfaceRevisionId: entry.interfaceRevisionId }))
.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))),
fields: [...fields].sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))),
storage: [...storage].sort((a, b) => a.id.localeCompare(b.id)),
}),
@@ -144,6 +152,7 @@ export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
bindingDigest,
fields,
attachments: storage,
rootAtoms,
});
}
for (const entry of linked) entry.runtime = queryRuntimePlan(entry, workspace);
+26 -1
View File
@@ -48,7 +48,32 @@ export const queryRuntimePlan = (linked: LinkedQuery, workspace: WorkspaceRevisi
id: linked.id,
definitionDigest: linked.checked.definitionDigest,
bindingDigest: linked.bindingDigest,
rootInterfaceRevisionId: linked.checked.declaration.root,
root: {
case: linked.checked.declaration.root.kind === "object" ? "objectInterface" : "workspaceInterface",
value: linked.checked.declaration.root.interface,
},
rootAtomIds: linked.rootAtoms,
fields: linked.checked.effects.map((effect) => {
const member = workspace.interfaceImports
.find((entry) => entry.revisionId === effect.interfaceRevisionId)!
.members.find((entry) => entry.id === effect.memberId)!;
return {
interfaceRevisionId: effect.interfaceRevisionId,
memberId: effect.memberId,
fieldName: member.displayName,
valueTypeJson: member.kind === "value" ? JSON.stringify(member.valueType) : "",
keyType: member.kind === "relationship" ? member.keyType : undefined,
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,
};
}),
selection: linked.checked.selection.map(selection),
budgets: linked.checked.declaration.budgets,
variablesTypeJson: JSON.stringify(linked.checked.variables),
+40
View File
@@ -0,0 +1,40 @@
import {
capabilityId,
type InterfaceRevision,
type InterfaceRevisionId,
type MemberId,
} from "../capability-model/types.js";
import { QueryCompileError, type QueryRoot } from "./types.js";
/** Compiler-only row-source schema; never installed as an atom, edge or capability. */
export const workspaceQueryRootId: InterfaceRevisionId = capabilityId.interfaceRevision(
"qx-internal-workspace-query-root",
);
export const workspaceObjectsMemberId: MemberId = capabilityId.member("qx-internal-workspace-objects");
export function queryRootSchema(root: QueryRoot, interfaces: readonly InterfaceRevision[]) {
if (interfaces.some((i) => i.revisionId === workspaceQueryRootId))
throw new QueryCompileError("QUERY_RESERVED_ID", "The workspace query root identity is compiler-owned");
if (root.kind === "object") return { interface: root.interface, interfaces };
const contract = interfaces.find((i) => i.revisionId === root.interface);
if (!contract || contract.template)
throw new QueryCompileError("QUERY_CONTRACT", `Expected closed interface ${root.interface}`);
const synthetic: InterfaceRevision = {
interfaceId: capabilityId.interface("qx-internal-workspace-query-root"),
revisionId: workspaceQueryRootId,
displayName: "WorkspaceQueryRoot",
source: contract.source,
members: [
{
kind: "relationship",
id: workspaceObjectsMemberId,
displayName: "objects",
target: { kind: "interface", interfaceRevisionId: root.interface },
cardinality: "many-unique",
ordered: false,
operations: [],
queryRead: { execution: "native" },
},
],
};
return { interface: synthetic.revisionId, interfaces: [...interfaces, synthetic] };
}
+6 -4
View File
@@ -28,6 +28,7 @@ import type {
} from "../capability-model/types.js";
import { QueryCompileError, type QueryDeclaration } from "./types.js";
import { relationalSchema, objectRow } from "./relational-schema.js";
import { queryRootSchema, workspaceQueryRootId } from "./root.js";
// Validate losslessly; callers carry decimal strings across JSON transports.
function integerParser(name: string, min: bigint, max: bigint, value: unknown): string {
@@ -78,7 +79,8 @@ export function querySchema(
interfaces: readonly InterfaceRevision[],
document?: DocumentNode,
) {
const contracts = new Map(interfaces.map((entry) => [entry.revisionId, entry]));
const root = queryRootSchema(declaration.root, interfaces);
const contracts = new Map(root.interfaces.map((entry) => [entry.revisionId, entry]));
const objects = new Map<InterfaceRevisionId, GraphQLObjectType>();
const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
const byName = new Map<string, InterfaceRevision>();
@@ -150,7 +152,7 @@ export function querySchema(
{ contracts, target, name, scalar, comparison, scalarTypes, direction, cursor: cursorScalar, pageInfo },
document,
);
relational.discover(declaration.root);
relational.discover(root.interface);
const filter = (id: InterfaceRevisionId) => relational.where(objectRow(id));
const order = (id: InterfaceRevisionId) => {
let result = orders.get(id);
@@ -177,7 +179,7 @@ export function querySchema(
const metadata = new GraphQLObjectType({
name: `QxMetadata_${name(id)}`,
fields: {
ref: { type: new GraphQLNonNull(relational.refs(id)) },
...(id === workspaceQueryRootId ? {} : { ref: { type: new GraphQLNonNull(relational.refs(id)) } }),
...(helpers ? { relations: { type: new GraphQLNonNull(helpers) } } : {}),
},
});
@@ -247,7 +249,7 @@ export function querySchema(
directives: [...specifiedDirectives, new GraphQLDirective({ name: "live", locations: [DirectiveLocation.FIELD] })],
query: new GraphQLObjectType({
name: "QxQuery",
fields: { root: { type: new GraphQLNonNull(object(declaration.root)) } },
fields: { root: { type: new GraphQLNonNull(object(root.interface)) } },
}),
});
return { schema, byName, target, contracts, relational, scalarTypes };
+1 -1
View File
@@ -34,7 +34,7 @@ export function specializeQueryTemplate(
return {
...structuredClone(template.declaration),
...identity,
root: substitution.application(template.root),
root: { kind: template.root.kind, interface: substitution.application(template.root.interface) },
views: template.views
.map((view) => {
const target = substitution.object(view.target);
+5 -2
View File
@@ -31,10 +31,13 @@ export const defaultQueryBudgets: Readonly<QueryBudgets> = Object.freeze({
concurrency: 8,
deadlineMs: 10000,
});
export type QueryRoot<Interface = InterfaceRevisionId> =
| { kind: "object"; interface: Interface }
| { kind: "workspace-interface"; interface: Interface };
export interface QueryDeclaration {
id: string;
displayName: string;
root: InterfaceRevisionId;
root: QueryRoot;
document: string;
operation: string;
fragments: string[];
@@ -53,7 +56,7 @@ export interface QueryDeclaration {
export interface QueryTemplate {
declaration: Omit<QueryDeclaration, "root" | "views" | "allowances">;
parameters: TypeParameter[];
root: InterfaceApplicationExpression;
root: QueryRoot<InterfaceApplicationExpression>;
views: { target: ObjectTypeExpression; interface: InterfaceApplicationExpression }[];
allowances: { interface: InterfaceApplicationExpression; memberName: string; uses: QueryUse[]; reason: string }[];
}