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
+2 -2
View File
@@ -147,7 +147,7 @@ packageExport
; ;
packageQuery packageQuery
: QUERY identifier typeParameters? ID stringLiteral ROOT interfaceType : QUERY identifier typeParameters? ID stringLiteral ROOT WORKSPACE? interfaceType
DOCUMENT stringLiteral OPERATION stringLiteral LBRACE queryClause* RBRACE DOCUMENT stringLiteral OPERATION stringLiteral LBRACE queryClause* RBRACE
; ;
@@ -328,7 +328,7 @@ dependencyBinding
| identifier TO EDGE identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI | identifier TO EDGE identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
| identifier TO INTERFACE identifier typeArguments? (VIA EDGE identifier DOT identifier)? SEMI | identifier TO INTERFACE identifier typeArguments? (VIA EDGE identifier DOT identifier)? SEMI
| identifier TO CONSTRUCTOR identifier SEMI | identifier TO CONSTRUCTOR identifier SEMI
| identifier TO QUERY identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI | identifier TO WORKSPACE? QUERY identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
; ;
constructorBindingDecl constructorBindingDecl
+6
View File
@@ -241,7 +241,10 @@ message CaminoOp {
message QueryRequest { message QueryRequest {
string query_id = 1; string query_id = 1;
oneof root {
string object_id = 2; string object_id = 2;
quixos.WorkspaceQueryScope workspace_scope = 5;
}
map<string, Value> variables = 3; map<string, Value> variables = 3;
// Typed callers require this exact checked definition. Administrative/raw // Typed callers require this exact checked definition. Administrative/raw
// callers may omit it to explicitly select the currently installed definition. // callers may omit it to explicitly select the currently installed definition.
@@ -351,7 +354,10 @@ message QueryFieldFailure {
} }
message QueryChangesRequest { message QueryChangesRequest {
string query_id = 1; string query_id = 1;
oneof root {
string object_id = 2; string object_id = 2;
quixos.WorkspaceQueryScope workspace_scope = 4;
}
string data_version = 3; string data_version = 3;
} }
message QueryChangesResponse { message QueryChangesResponse {
+15 -1
View File
@@ -286,6 +286,15 @@ message QueryReadBinding {
Cardinality cardinality = 13; Cardinality cardinality = 13;
string key_type = 14; string key_type = 14;
} }
// Checked schema survives even when no atom currently implements an interface.
message QueryFieldContract {
string interface_revision_id = 1;
string member_id = 2;
string field_name = 3;
string value_type_json = 4;
Cardinality cardinality = 5;
string key_type = 6;
}
message QueryBudgets { message QueryBudgets {
uint32 rows = 1; uint32 rows = 1;
uint32 depth = 2; uint32 depth = 2;
@@ -299,7 +308,10 @@ message InstalledQuery {
string id = 1; string id = 1;
string definition_digest = 2; string definition_digest = 2;
string binding_digest = 3; string binding_digest = 3;
string root_interface_revision_id = 4; oneof root {
string object_interface = 4;
string workspace_interface = 14;
}
repeated QuerySelection selection = 5; repeated QuerySelection selection = 5;
repeated QueryReadBinding bindings = 6; repeated QueryReadBinding bindings = 6;
QueryBudgets budgets = 7; QueryBudgets budgets = 7;
@@ -309,4 +321,6 @@ message InstalledQuery {
bool watch = 11; bool watch = 11;
uint32 polling_interval_ms = 12; uint32 polling_interval_ms = 12;
bool rpc_predicate_or_order = 13; bool rpc_predicate_or_order = 13;
repeated string root_atom_ids = 15;
repeated QueryFieldContract fields = 16;
} }
+1
View File
@@ -145,6 +145,7 @@ message QueryDescription {
repeated string source_files = 6; repeated string source_files = 6;
string effects_json = 7; string effects_json = 7;
camino.InstalledQuery plan = 8; camino.InstalledQuery plan = 8;
WorkspaceQueryScope workspace_scope = 9;
} }
message ClassCapability { message ClassCapability {
string conformance_id = 1; string conformance_id = 1;
+15
View File
@@ -44,12 +44,27 @@ message InjectedDependency {
string interface_revision_id = 4; string interface_revision_id = 4;
string constructor_atom_id = 5; string constructor_atom_id = 5;
string query_id = 7; string query_id = 7;
WorkspaceQueryDependency workspace_query = 8;
} }
// Defaults to the invocation receiver. A checked dependency traversal can // Defaults to the invocation receiver. A checked dependency traversal can
// select a related object explicitly before the package runtime starts. // select a related object explicitly before the package runtime starts.
string object_id = 6; string object_id = 6;
} }
// Scope evidence is not authority by itself. The caller must also hold the
// matching workspace-query grant (or the authenticated workspace user grant).
message WorkspaceQueryScope {
string workspace_id = 1;
string workspace_revision_id = 2;
string workspace_epoch = 3;
string interface_revision_id = 4;
string binding_digest = 5;
}
message WorkspaceQueryDependency {
string query_id = 1;
WorkspaceQueryScope scope = 2;
}
message EdgeDependency { message EdgeDependency {
string edge_type_id = 1; string edge_type_id = 1;
string projection_id = 2; string projection_id = 2;
+1 -1
View File
@@ -388,7 +388,7 @@ export const generateTypeScriptBindings = (
`declare const appliedType: unique symbol;\ntype QxApplied<Definition extends string, Arguments extends readonly unknown[]> = string & {readonly [appliedType]: (value: [Definition, Arguments]) => [Definition, Arguments]};\n` + `declare const appliedType: unique symbol;\ntype QxApplied<Definition extends string, Arguments extends readonly unknown[]> = string & {readonly [appliedType]: (value: [Definition, Arguments]) => [Definition, Arguments]};\n` +
`export type Contexts = ${object(contexts)};\nexport type Results = ${object(results)};\nexport type Implementation = ${object(handlers)};\n` + `export type Contexts = ${object(contexts)};\nexport type Results = ${object(results)};\nexport type Implementation = ${object(handlers)};\n` +
`export type QueryVariables = ${queryVariables};\nexport type QueryResults = ${queryResults};\n` + `export type QueryVariables = ${queryVariables};\nexport type QueryResults = ${queryResults};\n` +
`export const queries: ${queryDescriptors} = ${JSON.stringify(Object.fromEntries((pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, { id: `${pkg.revisionId}:${query.declaration.id}`, definitionDigest: query.definitionDigest, rootInterfaceRevisionId: query.declaration.root, variables: query.variables, output: query.output, watch: query.declaration.watch }])), null, 2)} as const;\n` + `export const queries: ${queryDescriptors} = ${JSON.stringify(Object.fromEntries((pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, { id: `${pkg.revisionId}:${query.declaration.id}`, definitionDigest: query.definitionDigest, root: query.declaration.root, variables: query.variables, output: query.output, watch: query.declaration.watch }])), null, 2)} as const;\n` +
`export const contracts = {${contracts.map((entry) => `${q(entry.iface.revisionId)}: ${entry.code}`).join(",\n")}};\n` + `export const contracts = {${contracts.map((entry) => `${q(entry.iface.revisionId)}: ${entry.code}`).join(",\n")}};\n` +
`export const interfaces = {${contracts `export const interfaces = {${contracts
.filter((entry) => contracts.filter((other) => other.iface.displayName === entry.iface.displayName).length === 1) .filter((entry) => contracts.filter((other) => other.iface.displayName === entry.iface.displayName).length === 1)
+5 -2
View File
@@ -30,11 +30,14 @@ declare module "@quixos/web-studio-react-runtime" {
export type QueryValueType = {kind: "builtin" | "scalar"; name: string} | {kind: "record"; fields: Record<string, QueryValueType>} | {kind: "optional" | "list"; value: QueryValueType} | {kind: "object-ref"; expectation: {kind: "atom"; atomId: string} | {kind: "interface"; interfaceRevisionId: string}}; export type QueryValueType = {kind: "builtin" | "scalar"; name: string} | {kind: "record"; fields: Record<string, QueryValueType>} | {kind: "optional" | "list"; value: QueryValueType} | {kind: "object-ref"; expectation: {kind: "atom"; atomId: string} | {kind: "interface"; interfaceRevisionId: string}};
export type QueryReference<Contract extends string = string> = {readonly $quixosRef: string; readonly queryContract: Contract}; export type QueryReference<Contract extends string = string> = {readonly $quixosRef: string; readonly queryContract: Contract};
export type QueryLiveProjection = {path: readonly string[]; selectionId: string; interfaceRevisionId: string; getOperationId: string; setOperationId?: string; watchOperationId?: string; inputKind: "fields" | "value"; valueType: QueryValueType; conditions: {include: boolean; defaultValue?: boolean; value: {kind: "variable"; name: string} | {kind: "literal"; value: unknown}}[]}; export type QueryLiveProjection = {path: readonly string[]; selectionId: string; interfaceRevisionId: string; getOperationId: string; setOperationId?: string; watchOperationId?: string; inputKind: "fields" | "value"; valueType: QueryValueType; conditions: {include: boolean; defaultValue?: boolean; value: {kind: "variable"; name: string} | {kind: "literal"; value: unknown}}[]};
export interface QueryDescriptor<Variables, Result> {readonly id: string; readonly definitionDigest: string; readonly rootInterfaceRevisionId: string; readonly variables: QueryValueType; readonly output: QueryValueType; readonly watch: boolean; readonly liveFields?: readonly QueryLiveProjection[]; readonly $types?: (variables: Variables, result: Result) => [Variables, Result]} export interface QueryDescriptor<Variables, Result, Kind extends "object" | "workspace-interface" = "object" | "workspace-interface"> {readonly id: string; readonly definitionDigest: string; readonly root: {readonly kind: Kind; readonly interface: string}; readonly variables: QueryValueType; readonly output: QueryValueType; readonly watch: boolean; readonly liveFields?: readonly QueryLiveProjection[]; readonly $types?: (variables: Variables, result: Result) => [Variables, Result]}
export type QueryPartial<T> = T extends {readonly $quixosRef: string} | ReadableField<unknown> ? T : T extends readonly (infer Item)[] ? QueryPartial<Item>[] : T extends object ? {[Key in keyof T]?: QueryPartial<T[Key]>} : T; export type QueryPartial<T> = T extends {readonly $quixosRef: string} | ReadableField<unknown> ? T : T extends readonly (infer Item)[] ? QueryPartial<Item>[] : T extends object ? {[Key in keyof T]?: QueryPartial<T[Key]>} : T;
export type QueryFieldState = {path: readonly (string | number)[]; status: "pending" | "error"; error?: string}; export type QueryFieldState = {path: readonly (string | number)[]; status: "pending" | "error"; error?: string};
export type QueryState<T> = {refreshing: boolean; fields: QueryFieldState[]; runId?: string; sequence?: bigint; consistency?: string} & ({status: "loading"; data?: undefined; error?: undefined} | {status: "ready"; data: T; error?: undefined} | {status: "partial"; data: QueryPartial<T>; error?: undefined} | {status: "error"; data?: T | QueryPartial<T>; error: Error}); export type QueryState<T> = {refreshing: boolean; fields: QueryFieldState[]; runId?: string; sequence?: bigint; consistency?: string} & ({status: "loading"; data?: undefined; error?: undefined} | {status: "ready"; data: T; error?: undefined} | {status: "partial"; data: QueryPartial<T>; error?: undefined} | {status: "error"; data?: T | QueryPartial<T>; error: Error});
export function useQuery<Variables, Result>(descriptor: QueryDescriptor<Variables, Result>, options: {root: string | {readonly $quixosRef: string}; variables: Variables}): QueryState<Result> & {refresh(): void}; export type WorkspaceQueryScope = {readonly $workspaceQueryScope: true};
export function getWorkspaceQueryScope<Variables, Result>(descriptor: QueryDescriptor<Variables, Result, "workspace-interface">): Promise<WorkspaceQueryScope>;
export function useQuery<Variables, Result>(descriptor: QueryDescriptor<Variables, Result, "object">, options: {root: string | {readonly $quixosRef: string}; variables: Variables}): QueryState<Result> & {refresh(): void};
export function useQuery<Variables, Result>(descriptor: QueryDescriptor<Variables, Result, "workspace-interface">, options: {scope: WorkspaceQueryScope; variables: Variables}): QueryState<Result> & {refresh(): void};
export interface QueryConnection<Row> {entries: {key: string; node: Row; cursor?: string | null}[]; pageInfo: {hasNextPage: boolean; endCursor?: string | null}} export interface QueryConnection<Row> {entries: {key: string; node: Row; cursor?: string | null}[]; pageInfo: {hasNextPage: boolean; endCursor?: string | null}}
export function CollectionView<Result, Row>(props: {result: QueryState<Result> & {refresh(): void}; connection(data: QueryPartial<Result>): QueryPartial<QueryConnection<Row>> | undefined; renderItem(row: QueryPartial<Row>, entryKey: string): React.ReactNode; onCreate?: () => void; onLoadMore?: (cursor: string | null) => void; onReset?: () => void; loadingMore?: boolean; empty?: React.ReactNode; prefetch?: boolean}): React.ReactElement; export function CollectionView<Result, Row>(props: {result: QueryState<Result> & {refresh(): void}; connection(data: QueryPartial<Result>): QueryPartial<QueryConnection<Row>> | undefined; renderItem(row: QueryPartial<Row>, entryKey: string): React.ReactNode; onCreate?: () => void; onLoadMore?: (cursor: string | null) => void; onReset?: () => void; loadingMore?: boolean; empty?: React.ReactNode; prefetch?: boolean}): React.ReactElement;
export type ReactComponentHostProps<Action> = { export type ReactComponentHostProps<Action> = {
+2 -2
View File
@@ -148,14 +148,14 @@ export function generateReactBindings(
const queryCode = const queryCode =
`export type QueryVariables = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.variables)}`).join(";")}};\n` + `export type QueryVariables = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.variables)}`).join(";")}};\n` +
`export type QueryResults = {${queries.map((query) => `${q(query.declaration.displayName)}: ${presentedType(query.output, queryPresentation(query, schema.interfaces))}`).join(";")}};\n` + `export type QueryResults = {${queries.map((query) => `${q(query.declaration.displayName)}: ${presentedType(query.output, queryPresentation(query, schema.interfaces))}`).join(";")}};\n` +
`export const queries: {${queries.map((query) => `${q(query.declaration.displayName)}: QueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}]>`).join(";")}} = ${JSON.stringify( `export const queries: {${queries.map((query) => `${q(query.declaration.displayName)}: QueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}], ${q(query.declaration.root.kind)}>`).join(";")}} = ${JSON.stringify(
Object.fromEntries( Object.fromEntries(
queries.map((query) => [ queries.map((query) => [
query.declaration.displayName, query.declaration.displayName,
{ {
id: `${pkg.revisionId}:${query.declaration.id}`, id: `${pkg.revisionId}:${query.declaration.id}`,
definitionDigest: query.definitionDigest, definitionDigest: query.definitionDigest,
rootInterfaceRevisionId: query.declaration.root, root: query.declaration.root,
variables: query.variables, variables: query.variables,
output: query.output, output: query.output,
liveFields: queryPresentation(query, schema.interfaces), liveFields: queryPresentation(query, schema.interfaces),
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -1244,7 +1244,7 @@ const lowerPackage = (
const declaration: QueryDeclaration = { const declaration: QueryDeclaration = {
id: stringValue(query.stringLiteral(0)), id: stringValue(query.stringLiteral(0)),
displayName: identifier(query.identifier()), displayName: identifier(query.identifier()),
root: close(query.interfaceType()), root: { kind: query.WORKSPACE() ? "workspace-interface" : "object", interface: close(query.interfaceType()) },
document: stringValue(query.stringLiteral(1)), document: stringValue(query.stringLiteral(1)),
operation: stringValue(query.stringLiteral(2)), operation: stringValue(query.stringLiteral(2)),
fragments: [], fragments: [],
@@ -1372,7 +1372,7 @@ const lowerPackage = (
queryTemplates.push({ queryTemplates.push({
declaration: common, declaration: common,
parameters, parameters,
root: expressions.get(root)!, root: { kind: root.kind, interface: expressions.get(root.interface)! },
views: templateViews, views: templateViews,
allowances: templateAllowances, allowances: templateAllowances,
}); });
@@ -1579,7 +1579,17 @@ const lowerBoundDependencies = (
const query = lowerQueryReference(state, identifier(entry.identifier(1)), identifier(entry.identifier(2)), entry); const query = lowerQueryReference(state, identifier(entry.identifier(1)), identifier(entry.identifier(2)), entry);
const via = entry.VIA() ? traversal(identifier(entry.identifier(3)), identifier(entry.identifier(4))) : undefined; const via = entry.VIA() ? traversal(identifier(entry.identifier(3)), identifier(entry.identifier(4))) : undefined;
return query && (!entry.VIA() || via) return query && (!entry.VIA() || via)
? [{ portId, binding: { kind: "query", ...query, ...(via ? { via } : {}) } }] ? [
{
portId,
binding: {
kind: "query",
...query,
scope: entry.WORKSPACE() ? "workspace" : "object",
...(via ? { via } : {}),
},
},
]
: []; : [];
} }
if (entry.STATE()) { if (entry.STATE()) {
+7 -1
View File
@@ -292,7 +292,13 @@ export interface PackageRevision {
} }
export type DependencyBinding = export type DependencyBinding =
| { kind: "query"; packageRevisionId: PackageRevisionId; queryId: string; via?: EdgeTraversal } | {
kind: "query";
packageRevisionId: PackageRevisionId;
queryId: string;
scope: "object" | "workspace";
via?: EdgeTraversal;
}
| { kind: "state"; slotId: SlotId; via?: EdgeTraversal } | { kind: "state"; slotId: SlotId; via?: EdgeTraversal }
| { | {
kind: "edge"; kind: "edge";
+21 -2
View File
@@ -1173,6 +1173,20 @@ const validateBoundDependencies = (
issue(issues, "invalid-dependency-binding", path, "Query port must bind its exact checked query export"); issue(issues, "invalid-dependency-binding", path, "Query port must bind its exact checked query export");
break; break;
} }
if (query.root.kind === "workspace-interface") {
if (selected.scope !== "workspace" || selected.via)
issue(
issues,
"invalid-dependency-binding",
path,
"Workspace-interface queries require an explicit workspace scope without an object traversal",
);
break;
}
if (selected.scope !== "object") {
issue(issues, "invalid-dependency-binding", path, "Object-root queries require an object dependency binding");
break;
}
const traversal = selected.via const traversal = selected.via
? validateTraversal( ? validateTraversal(
issues, issues,
@@ -1191,8 +1205,13 @@ const validateBoundDependencies = (
: [] : []
: [params.atomId]; : [params.atomId];
for (const atom of targets) for (const atom of targets)
if (!params.indexes.conformances.has(conformanceKey(atom as AtomId, query.root))) if (!params.indexes.conformances.has(conformanceKey(atom as AtomId, query.root.interface)))
issue(issues, "unsatisfied-interface", path, `Query root ${atom} does not conform to ${query.root}`); issue(
issues,
"unsatisfied-interface",
path,
`Query root ${atom} does not conform to ${query.root.interface}`,
);
break; break;
} }
case "state": { case "state": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+70 -2
View File
@@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file quixos/refs.proto. * Describes the file quixos/refs.proto.
*/ */
export const file_quixos_refs: GenFile = /*@__PURE__*/ export const file_quixos_refs: GenFile = /*@__PURE__*/
fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zInUKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCRIvCgtjb25mb3JtYW5jZRgDIAEoCzIaLnF1aXhvcy5Db25mb3JtYW5jZVdpdG5lc3Mi0QEKDEZpZWxkRWRpdGluZxIbChNnZXR0ZXJfb3BlcmF0aW9uX2lkGAEgASgJEhsKE3NldHRlcl9vcGVyYXRpb25faWQYAiABKAkSFQoNZG9jdW1lbnRfdHlwZRgDIAEoCRIWCg5iaW5kaW5nX2RpZ2VzdBgEIAEoCRInCgRtb2RlGAUgASgOMhkucXVpeG9zLkZpZWxkRWRpdGluZy5Nb2RlIi8KBE1vZGUSDwoLVU5TUEVDSUZJRUQQABIMCghSRUdJU1RFUhABEggKBENSRFQQAiKWAQoSQ29uZm9ybWFuY2VXaXRuZXNzEhEKCW9iamVjdF9pZBgBIAEoCRIdChVpbnRlcmZhY2VfcmV2aXNpb25faWQYAiABKAkSFgoOY29uZm9ybWFuY2VfaWQYAyABKAkSHQoVd29ya3NwYWNlX3JldmlzaW9uX2lkGAQgASgJEhcKD3dvcmtzcGFjZV9lcG9jaBgFIAEoCSJCChBQYWNrYWdlRXhwb3J0UmVmEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSEQoJZXhwb3J0X2lkGAIgASgJItgBChJJbmplY3RlZERlcGVuZGVuY3kSDwoHcG9ydF9pZBgBIAEoCRIXCg1zdGF0ZV9zbG90X2lkGAIgASgJSAASJgoEZWRnZRgDIAEoCzIWLnF1aXhvcy5FZGdlRGVwZW5kZW5jeUgAEh8KFWludGVyZmFjZV9yZXZpc2lvbl9pZBgEIAEoCUgAEh0KE2NvbnN0cnVjdG9yX2F0b21faWQYBSABKAlIABISCghxdWVyeV9pZBgHIAEoCUgAEhEKCW9iamVjdF9pZBgGIAEoCUIJCgdiaW5kaW5nIj0KDkVkZ2VEZXBlbmRlbmN5EhQKDGVkZ2VfdHlwZV9pZBgBIAEoCRIVCg1wcm9qZWN0aW9uX2lkGAIgASgJYgZwcm90bzM"); fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zInUKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCRIvCgtjb25mb3JtYW5jZRgDIAEoCzIaLnF1aXhvcy5Db25mb3JtYW5jZVdpdG5lc3Mi0QEKDEZpZWxkRWRpdGluZxIbChNnZXR0ZXJfb3BlcmF0aW9uX2lkGAEgASgJEhsKE3NldHRlcl9vcGVyYXRpb25faWQYAiABKAkSFQoNZG9jdW1lbnRfdHlwZRgDIAEoCRIWCg5iaW5kaW5nX2RpZ2VzdBgEIAEoCRInCgRtb2RlGAUgASgOMhkucXVpeG9zLkZpZWxkRWRpdGluZy5Nb2RlIi8KBE1vZGUSDwoLVU5TUEVDSUZJRUQQABIMCghSRUdJU1RFUhABEggKBENSRFQQAiKWAQoSQ29uZm9ybWFuY2VXaXRuZXNzEhEKCW9iamVjdF9pZBgBIAEoCRIdChVpbnRlcmZhY2VfcmV2aXNpb25faWQYAiABKAkSFgoOY29uZm9ybWFuY2VfaWQYAyABKAkSHQoVd29ya3NwYWNlX3JldmlzaW9uX2lkGAQgASgJEhcKD3dvcmtzcGFjZV9lcG9jaBgFIAEoCSJCChBQYWNrYWdlRXhwb3J0UmVmEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSEQoJZXhwb3J0X2lkGAIgASgJIpUCChJJbmplY3RlZERlcGVuZGVuY3kSDwoHcG9ydF9pZBgBIAEoCRIXCg1zdGF0ZV9zbG90X2lkGAIgASgJSAASJgoEZWRnZRgDIAEoCzIWLnF1aXhvcy5FZGdlRGVwZW5kZW5jeUgAEh8KFWludGVyZmFjZV9yZXZpc2lvbl9pZBgEIAEoCUgAEh0KE2NvbnN0cnVjdG9yX2F0b21faWQYBSABKAlIABISCghxdWVyeV9pZBgHIAEoCUgAEjsKD3dvcmtzcGFjZV9xdWVyeRgIIAEoCzIgLnF1aXhvcy5Xb3Jrc3BhY2VRdWVyeURlcGVuZGVuY3lIABIRCglvYmplY3RfaWQYBiABKAlCCQoHYmluZGluZyKaAQoTV29ya3NwYWNlUXVlcnlTY29wZRIUCgx3b3Jrc3BhY2VfaWQYASABKAkSHQoVd29ya3NwYWNlX3JldmlzaW9uX2lkGAIgASgJEhcKD3dvcmtzcGFjZV9lcG9jaBgDIAEoCRIdChVpbnRlcmZhY2VfcmV2aXNpb25faWQYBCABKAkSFgoOYmluZGluZ19kaWdlc3QYBSABKAkiWAoYV29ya3NwYWNlUXVlcnlEZXBlbmRlbmN5EhAKCHF1ZXJ5X2lkGAEgASgJEioKBXNjb3BlGAIgASgLMhsucXVpeG9zLldvcmtzcGFjZVF1ZXJ5U2NvcGUiPQoORWRnZURlcGVuZGVuY3kSFAoMZWRnZV90eXBlX2lkGAEgASgJEhUKDXByb2plY3Rpb25faWQYAiABKAliBnByb3RvMw");
/** /**
* @generated from message quixos.CapabilityRef * @generated from message quixos.CapabilityRef
@@ -207,6 +207,12 @@ export type InjectedDependency = Message<"quixos.InjectedDependency"> & {
*/ */
value: string; value: string;
case: "queryId"; case: "queryId";
} | {
/**
* @generated from field: quixos.WorkspaceQueryDependency workspace_query = 8;
*/
value: WorkspaceQueryDependency;
case: "workspaceQuery";
} | { case: undefined; value?: undefined }; } | { case: undefined; value?: undefined };
/** /**
@@ -225,6 +231,68 @@ export type InjectedDependency = Message<"quixos.InjectedDependency"> & {
export const InjectedDependencySchema: GenMessage<InjectedDependency> = /*@__PURE__*/ export const InjectedDependencySchema: GenMessage<InjectedDependency> = /*@__PURE__*/
messageDesc(file_quixos_refs, 4); messageDesc(file_quixos_refs, 4);
/**
* Scope evidence is not authority by itself. The caller must also hold the
* matching workspace-query grant (or the authenticated workspace user grant).
*
* @generated from message quixos.WorkspaceQueryScope
*/
export type WorkspaceQueryScope = Message<"quixos.WorkspaceQueryScope"> & {
/**
* @generated from field: string workspace_id = 1;
*/
workspaceId: string;
/**
* @generated from field: string workspace_revision_id = 2;
*/
workspaceRevisionId: string;
/**
* @generated from field: string workspace_epoch = 3;
*/
workspaceEpoch: string;
/**
* @generated from field: string interface_revision_id = 4;
*/
interfaceRevisionId: string;
/**
* @generated from field: string binding_digest = 5;
*/
bindingDigest: string;
};
/**
* Describes the message quixos.WorkspaceQueryScope.
* Use `create(WorkspaceQueryScopeSchema)` to create a new message.
*/
export const WorkspaceQueryScopeSchema: GenMessage<WorkspaceQueryScope> = /*@__PURE__*/
messageDesc(file_quixos_refs, 5);
/**
* @generated from message quixos.WorkspaceQueryDependency
*/
export type WorkspaceQueryDependency = Message<"quixos.WorkspaceQueryDependency"> & {
/**
* @generated from field: string query_id = 1;
*/
queryId: string;
/**
* @generated from field: quixos.WorkspaceQueryScope scope = 2;
*/
scope?: WorkspaceQueryScope | undefined;
};
/**
* Describes the message quixos.WorkspaceQueryDependency.
* Use `create(WorkspaceQueryDependencySchema)` to create a new message.
*/
export const WorkspaceQueryDependencySchema: GenMessage<WorkspaceQueryDependency> = /*@__PURE__*/
messageDesc(file_quixos_refs, 6);
/** /**
* @generated from message quixos.EdgeDependency * @generated from message quixos.EdgeDependency
*/ */
@@ -245,5 +313,5 @@ export type EdgeDependency = Message<"quixos.EdgeDependency"> & {
* Use `create(EdgeDependencySchema)` to create a new message. * Use `create(EdgeDependencySchema)` to create a new message.
*/ */
export const EdgeDependencySchema: GenMessage<EdgeDependency> = /*@__PURE__*/ export const EdgeDependencySchema: GenMessage<EdgeDependency> = /*@__PURE__*/
messageDesc(file_quixos_refs, 5); messageDesc(file_quixos_refs, 7);
+4
View File
@@ -35,6 +35,7 @@ import {
} from "../capability-model/types.js"; } from "../capability-model/types.js";
import { querySchema } from "./schema.js"; import { querySchema } from "./schema.js";
import { objectRow } from "./relational-schema.js"; import { objectRow } from "./relational-schema.js";
import { workspaceQueryRootId } from "./root.js";
import { relationalCompiler } from "./relational-compile.js"; import { relationalCompiler } from "./relational-compile.js";
import { import {
QueryCompileError, QueryCompileError,
@@ -120,6 +121,9 @@ export async function compileQuery(
.map((entry) => [entry.name.value, entry]), .map((entry) => [entry.name.value, entry]),
); );
const mark = (id: InterfaceRevisionId, name: string, use: QueryUse, node: ASTNode) => { 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 contract = generated.contracts.get(id)!;
const member = contract.members.find((entry) => entry.displayName === name); const member = contract.members.find((entry) => entry.displayName === name);
if (!member || member.kind === "operation" || !member.queryRead) if (!member || member.kind === "operation" || !member.queryRead)
+9
View File
@@ -27,6 +27,7 @@ export interface LinkedQuery {
bindingDigest: string; bindingDigest: string;
fields: LinkedQueryField[]; fields: LinkedQueryField[];
attachments: PersistentAttachment[]; attachments: PersistentAttachment[];
rootAtoms: AtomId[];
} }
/** Link every possible implementation, including atoms absent from today's data. */ /** 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 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") const bindingDigest = createHash("sha256")
.update( .update(
canonicalJson({ canonicalJson({
definition: checked.definitionDigest, 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))), fields: [...fields].sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))),
storage: [...storage].sort((a, b) => a.id.localeCompare(b.id)), storage: [...storage].sort((a, b) => a.id.localeCompare(b.id)),
}), }),
@@ -144,6 +152,7 @@ export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
bindingDigest, bindingDigest,
fields, fields,
attachments: storage, attachments: storage,
rootAtoms,
}); });
} }
for (const entry of linked) entry.runtime = queryRuntimePlan(entry, workspace); 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, id: linked.id,
definitionDigest: linked.checked.definitionDigest, definitionDigest: linked.checked.definitionDigest,
bindingDigest: linked.bindingDigest, 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), selection: linked.checked.selection.map(selection),
budgets: linked.checked.declaration.budgets, budgets: linked.checked.declaration.budgets,
variablesTypeJson: JSON.stringify(linked.checked.variables), 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"; } from "../capability-model/types.js";
import { QueryCompileError, type QueryDeclaration } from "./types.js"; import { QueryCompileError, type QueryDeclaration } from "./types.js";
import { relationalSchema, objectRow } from "./relational-schema.js"; import { relationalSchema, objectRow } from "./relational-schema.js";
import { queryRootSchema, workspaceQueryRootId } from "./root.js";
// Validate losslessly; callers carry decimal strings across JSON transports. // Validate losslessly; callers carry decimal strings across JSON transports.
function integerParser(name: string, min: bigint, max: bigint, value: unknown): string { function integerParser(name: string, min: bigint, max: bigint, value: unknown): string {
@@ -78,7 +79,8 @@ export function querySchema(
interfaces: readonly InterfaceRevision[], interfaces: readonly InterfaceRevision[],
document?: DocumentNode, 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 objects = new Map<InterfaceRevisionId, GraphQLObjectType>();
const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>(); const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
const byName = new Map<string, InterfaceRevision>(); const byName = new Map<string, InterfaceRevision>();
@@ -150,7 +152,7 @@ export function querySchema(
{ contracts, target, name, scalar, comparison, scalarTypes, direction, cursor: cursorScalar, pageInfo }, { contracts, target, name, scalar, comparison, scalarTypes, direction, cursor: cursorScalar, pageInfo },
document, document,
); );
relational.discover(declaration.root); relational.discover(root.interface);
const filter = (id: InterfaceRevisionId) => relational.where(objectRow(id)); const filter = (id: InterfaceRevisionId) => relational.where(objectRow(id));
const order = (id: InterfaceRevisionId) => { const order = (id: InterfaceRevisionId) => {
let result = orders.get(id); let result = orders.get(id);
@@ -177,7 +179,7 @@ export function querySchema(
const metadata = new GraphQLObjectType({ const metadata = new GraphQLObjectType({
name: `QxMetadata_${name(id)}`, name: `QxMetadata_${name(id)}`,
fields: { fields: {
ref: { type: new GraphQLNonNull(relational.refs(id)) }, ...(id === workspaceQueryRootId ? {} : { ref: { type: new GraphQLNonNull(relational.refs(id)) } }),
...(helpers ? { relations: { type: new GraphQLNonNull(helpers) } } : {}), ...(helpers ? { relations: { type: new GraphQLNonNull(helpers) } } : {}),
}, },
}); });
@@ -247,7 +249,7 @@ export function querySchema(
directives: [...specifiedDirectives, new GraphQLDirective({ name: "live", locations: [DirectiveLocation.FIELD] })], directives: [...specifiedDirectives, new GraphQLDirective({ name: "live", locations: [DirectiveLocation.FIELD] })],
query: new GraphQLObjectType({ query: new GraphQLObjectType({
name: "QxQuery", 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 }; return { schema, byName, target, contracts, relational, scalarTypes };
+1 -1
View File
@@ -34,7 +34,7 @@ export function specializeQueryTemplate(
return { return {
...structuredClone(template.declaration), ...structuredClone(template.declaration),
...identity, ...identity,
root: substitution.application(template.root), root: { kind: template.root.kind, interface: substitution.application(template.root.interface) },
views: template.views views: template.views
.map((view) => { .map((view) => {
const target = substitution.object(view.target); const target = substitution.object(view.target);
+5 -2
View File
@@ -31,10 +31,13 @@ export const defaultQueryBudgets: Readonly<QueryBudgets> = Object.freeze({
concurrency: 8, concurrency: 8,
deadlineMs: 10000, deadlineMs: 10000,
}); });
export type QueryRoot<Interface = InterfaceRevisionId> =
| { kind: "object"; interface: Interface }
| { kind: "workspace-interface"; interface: Interface };
export interface QueryDeclaration { export interface QueryDeclaration {
id: string; id: string;
displayName: string; displayName: string;
root: InterfaceRevisionId; root: QueryRoot;
document: string; document: string;
operation: string; operation: string;
fragments: string[]; fragments: string[];
@@ -53,7 +56,7 @@ export interface QueryDeclaration {
export interface QueryTemplate { export interface QueryTemplate {
declaration: Omit<QueryDeclaration, "root" | "views" | "allowances">; declaration: Omit<QueryDeclaration, "root" | "views" | "allowances">;
parameters: TypeParameter[]; parameters: TypeParameter[];
root: InterfaceApplicationExpression; root: QueryRoot<InterfaceApplicationExpression>;
views: { target: ObjectTypeExpression; interface: InterfaceApplicationExpression }[]; views: { target: ObjectTypeExpression; interface: InterfaceApplicationExpression }[];
allowances: { interface: InterfaceApplicationExpression; memberName: string; uses: QueryUse[]; reason: string }[]; allowances: { interface: InterfaceApplicationExpression; memberName: string; uses: QueryUse[]; reason: string }[];
} }
+4
View File
@@ -44,6 +44,9 @@ export async function queryWorkspaceFixture(options: { live?: boolean } = {}) {
operation titleSet id "title-set" : string -> unit mode call receiver any operation titleSet id "title-set" : string -> unit mode call receiver any
requires {state title id "title-port" : string [write];}; requires {state title id "title-port" : string [write];};
operation score id "score" : unit -> int32 mode call receiver any; operation score id "score" : unit -> int32 mode call receiver any;
query Extent id "extent" root workspace TaskFacts document "extent.graphql" operation "Extent" {
max rows 30; watch;
}
query Upcoming id "upcoming" root Collection<interface TaskFacts> document "upcoming.graphql" operation "Upcoming" { query Upcoming id "upcoming" root Collection<interface TaskFacts> document "upcoming.graphql" operation "Upcoming" {
fragments "row.graphql"; max rows 30; watch; fragments "row.graphql"; max rows 30; watch;
} }
@@ -73,6 +76,7 @@ export async function queryWorkspaceFixture(options: { live?: boolean } = {}) {
const pkg: PackageRevision = pkgResult.resource.revision; const pkg: PackageRevision = pkgResult.resource.revision;
const allInterfaces = [...interfaces.values(), ...(pkgResult.resource.specializations ?? [])]; const allInterfaces = [...interfaces.values(), ...(pkgResult.resource.specializations ?? [])];
const documents: Record<string, string> = { const documents: Record<string, string> = {
"extent.graphql": `query Extent {root {objects(first: 30, orderBy: [{rank: ASC}]) {entries {node {_qx {ref} title rank}}}}}`,
"upcoming.graphql": `query Upcoming($first: Int!, $after: Cursor) {root {items(first: $first, after: $after, where: {done: {eq: false}}, orderBy: [{rank: ASC}]) {entries {key cursor node {_qx {ref} ...TaskRow}} pageInfo {hasNextPage endCursor}}}}`, "upcoming.graphql": `query Upcoming($first: Int!, $after: Cursor) {root {items(first: $first, after: $after, where: {done: {eq: false}}, orderBy: [{rank: ASC}]) {entries {key cursor node {_qx {ref} ...TaskRow}} pageInfo {hasNextPage endCursor}}}}`,
"row.graphql": `fragment TaskRow on TaskFacts {title rank done assignee {name}}`, "row.graphql": `fragment TaskRow on TaskFacts {title rank done assignee {name}}`,
"enriched.graphql": `query Enriched {root {items(first: 3) {entries {key node {_qx {ref} title score}}}}}`, "enriched.graphql": `query Enriched {root {items(first: 3) {entries {key node {_qx {ref} title score}}}}}`,
+52 -4
View File
@@ -45,11 +45,11 @@ test("generic query templates check declared bounds before closed specialization
}`, }`,
[facts], [facts],
); );
const build = (bound: string) => const build = (bound: string, workspace = false) =>
compileCapabilityResourceSource( compileCapabilityResourceSource(
`import interface Collection; import interface TaskFacts; `import interface Collection; import interface TaskFacts;
package GenericQueries id "generic-queries" revision "generic-queries@1" { package GenericQueries id "generic-queries" revision "generic-queries@1" {
query Rows<object Item ${bound}> id "rows-template" root Collection<Item> query Rows<object Item ${bound}> id "rows-template" root ${workspace ? "workspace " : ""}Collection<Item>
document "rows.graphql" operation "Rows" { view object Item as TaskFacts; max rows 30; } document "rows.graphql" operation "Rows" { view object Item as TaskFacts; max rows 30; }
query TaskRows id "task-rows" specialize Rows<interface TaskFacts>; query TaskRows id "task-rows" specialize Rows<interface TaskFacts>;
}`, }`,
@@ -93,12 +93,33 @@ test("generic query templates check declared bounds before closed specialization
checkQueryTemplate(invalid.resource.revision.queryTemplates![0]!, [generic, facts], read), checkQueryTemplate(invalid.resource.revision.queryTemplates![0]!, [generic, facts], read),
/bound|guarantee|implement/i, /bound|guarantee|implement/i,
); );
const extent = build("implements TaskFacts", true);
assert.ok(extent.ok, JSON.stringify(extent.diagnostics));
if (extent.resource.kind !== "package") throw new Error("package expected");
const extentPackage = extent.resource.revision;
const readExtent = async () =>
`query Rows {root {objects(first: 2) {entries {node {items(first: 3) {entries {node {title}}}}}}}}`;
await checkQueryTemplate(extentPackage.queryTemplates![0]!, [generic, facts], readExtent);
const closed = await compileQuery(
extentPackage.queries![0]!,
[generic, facts, ...(extent.resource.specializations ?? [])],
readExtent,
);
assert.equal(closed.declaration.root.kind, "workspace-interface");
assert.equal(closed.declaration.root.interface, specialized.declaration.root.interface);
const invalidExtent = build("", true);
assert.ok(invalidExtent.ok, JSON.stringify(invalidExtent.diagnostics));
if (invalidExtent.resource.kind !== "package") throw new Error("package expected");
await assert.rejects(
checkQueryTemplate(invalidExtent.resource.revision.queryTemplates![0]!, [generic, facts], readExtent),
/bound|guarantee|implement/i,
);
}); });
function fixture(clauses = "") { function fixture(clauses = "", root = "Tasks") {
const result = compileCapabilityResourceSource( const result = compileCapabilityResourceSource(
`import interface Tasks; import interface TaskFacts; `import interface Tasks; import interface TaskFacts;
package Queries id "queries" revision "queries@1" { package Queries id "queries" revision "queries@1" {
query Upcoming id "upcoming" root Tasks document "queries/upcoming.graphql" operation "Upcoming" { query Upcoming id "upcoming" root ${root} document "queries/upcoming.graphql" operation "Upcoming" {
fragments "queries/row.graphql"; max rows 30; ${clauses} fragments "queries/row.graphql"; max rows 30; ${clauses}
} }
}`, }`,
@@ -119,6 +140,33 @@ package Queries id "queries" revision "queries@1" {
assert.equal(result.resource.revision.exports.length, 0); assert.equal(result.resource.revision.exports.length, 0);
return result.resource.revision.queries![0]!; return result.resource.revision.queries![0]!;
} }
test("workspace roots expose a bounded interface extent, not a root object or writable relationship", async () => {
const declaration = { ...fixture("watch;", "workspace TaskFacts"), fragments: [] };
assert.deepEqual(declaration.root, { kind: "workspace-interface", interface: "facts@1" });
const checked = await compileQuery(
declaration,
[facts, collection],
async () => `query Upcoming {
root {
objects(first: 3, where: {done: {eq: false}}, orderBy: {title: ASC}) {
entries {key node {_qx {ref} title @live}} pageInfo {hasNextPage endCursor}
}
_qx {relations {objects {aggregate {count sum {due}}}}}
}
}`,
);
assert.deepEqual(new Set(checked.effects.map((effect) => effect.memberId)), new Set(["done", "title", "due"]));
assert.ok(!checked.effects.some((effect) => effect.memberId.includes("workspace-objects")));
assert.ok(JSON.stringify(checked.selection).includes("qx-internal-workspace-objects"));
await assert.rejects(
compileQuery(declaration, [facts], async () => `query Upcoming {root {_qx {ref}}}`),
/QUERY_VALIDATION/,
);
await assert.rejects(
compileQuery(declaration, [facts], async () => `query Upcoming {root {objects {entries {key}}}}`),
/QUERY_ROW_LIMIT/,
);
});
const document = `query Upcoming($first: Int!, $before: Int64!) { const document = `query Upcoming($first: Int!, $before: Int64!) {
root { items(first: $first, where: {done: {eq: false}, due: {lt: $before}}, orderBy: [{due: ASC}]) { root { items(first: $first, where: {done: {eq: false}, due: {lt: $before}}, orderBy: [{due: ASC}]) {
entries { key node { _qx { ref } ...Row } } pageInfo { hasNextPage endCursor } entries { key node { _qx { ref } ...Row } } pageInfo { hasNextPage endCursor }
+25
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { queryWorkspaceFixture } from "./fixtures/query-workspace.js"; import { queryWorkspaceFixture } from "./fixtures/query-workspace.js";
import { linkQueries } from "../src/query/link.js"; import { linkQueries } from "../src/query/link.js";
import { compileQuery } from "../src/query/compile.js";
test("query fixture links generic collections, both implementations, and native getter/RPC setter", async () => { test("query fixture links generic collections, both implementations, and native getter/RPC setter", async () => {
const { workspace } = await queryWorkspaceFixture(); const { workspace } = await queryWorkspaceFixture();
@@ -27,3 +28,27 @@ test("query fixture links generic collections, both implementations, and native
const totals = workspace.linkedQueries.find((q) => q.id.endsWith(":totals"))!; const totals = workspace.linkedQueries.find((q) => q.id.endsWith(":totals"))!;
assert.equal(totals.fields.filter((f) => f.memberId === "rank").length, 2); assert.equal(totals.fields.filter((f) => f.memberId === "rank").length, 2);
}); });
test("reference-only workspace queries hash every eligible conformance, including empty atoms", async () => {
const { workspace } = await queryWorkspaceFixture();
const pkg = workspace.packageImports.find((pkg) => pkg.queries?.some((q) => q.id === "upcoming"))!;
const query = pkg.queries!.find((q) => q.id === "upcoming")!;
const contract = workspace.interfaceImports.find((iface) => iface.displayName === "TaskFacts")!;
query.root = { kind: "workspace-interface", interface: contract.revisionId };
query.fragments = [];
const checked = await compileQuery(
query,
workspace.interfaceImports,
async () => `query ${query.operation} {root {objects(first: 10) {entries {node {_qx {ref}}}}}}`,
);
pkg.checkedQueries = pkg.checkedQueries!.map((q) => (q.declaration.id === query.id ? checked : q));
const linked = linkQueries(workspace).find((q) => q.id.endsWith(":upcoming"))!;
assert.equal(linked.fields.length, 0);
assert.equal(linked.rootAtoms.length, 2);
workspace.conformances = workspace.conformances.filter(
(c) => !(c.atomId === linked.rootAtoms[0] && c.interfaceRevisionId === contract.revisionId),
);
const changed = linkQueries(workspace).find((q) => q.id === linked.id)!;
assert.equal(changed.rootAtoms.length, 1);
assert.notEqual(linked.bindingDigest, changed.bindingDigest);
});