Compare commits

..

8 Commits

Author SHA1 Message Date
Quixos Subtree Publisher b0d31ba51c Publish quixos-protocol from c87835bff85794bc232dbaaddc1eb3b593c2aab8 2026-09-18 09:19:49 +00:00
Timothy J. Aveni 2dffdbcd9f Implement shared live fields and checked query hydration
Add native register acknowledgments and idempotent mutation replay, shared optimistic field controllers, overlapping custom setters, non-suspending hooks and explicit Suspense. Carry checked @live provenance through batched queries and hydrate shared browser fields with coverage leases. Update tracker/scaffolds/guides and verify compiler, PostgreSQL, browser and immutable workspace paths.
2026-09-18 02:19:49 -07:00
Quixos Subtree Publisher c9265e7903 Publish quixos-protocol from 4c88728f144add3457b4273d4b9a04ee53c53be3 2026-09-18 06:40:14 +00:00
Timothy J. Aveni a05f58f7fe Build query-backed task tracker default template
Add shared colored tags, native tag/status filters, cursor-paged task and
completion views, estimated-hour aggregates and repeat-after-completion actions.
Keep task views independently renderable and isolate component styling.

Cover immutable fresh-template/additive checks, real PostgreSQL/orch package
actions and query watches, and wide/narrow browser layouts. Accept documented
hyphenated query budget names in the capability lexer.

Template publication/default selection pending installation AWS SSO renewal;
no live workspace data or deployment configuration changed.
2026-09-17 23:40:14 -07:00
Quixos Subtree Publisher 93c8cae1e6 Publish quixos-protocol from d6b0ff39db819c339a08e2203eff401c86fa9b73 2026-09-18 02:52:55 +00:00
Timothy J. Aveni 5a611bbc9b Enforce aggregation contract budgets and expose residual timing 2026-09-17 19:52:55 -07:00
Quixos Subtree Publisher aadc35581d Publish quixos-protocol from 1c2dbc6ca2c3917292f158cbbaef531836eaacca 2026-09-18 02:43:46 +00:00
Timothy J. Aveni e61b0a36ac Implement checked aggregation plans, native SQL, and bounded RPC capture 2026-09-17 19:43:46 -07:00
34 changed files with 5160 additions and 1829 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"version": 1, "version": 1,
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
"sourceCommit": "53b9a56e8cb120c34d6b5c85b52b18080dd1d9a3", "sourceCommit": "c87835bff85794bc232dbaaddc1eb3b593c2aab8",
"sourcePath": "quixos-protocol", "sourcePath": "quixos-protocol",
"exportName": "quixos-protocol", "exportName": "quixos-protocol",
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
+6
View File
@@ -408,6 +408,9 @@ identifier
| POLL | POLL
| RPC | RPC
| QUERYABLE | QUERYABLE
| RESULT_BYTES
| RPC_CALLS
| DEADLINE_MS
; ;
stringLiteral stringLiteral
@@ -415,6 +418,9 @@ stringLiteral
; ;
WORKSPACE: 'workspace'; WORKSPACE: 'workspace';
RESULT_BYTES: 'result-bytes';
RPC_CALLS: 'rpc-calls';
DEADLINE_MS: 'deadline-ms';
QUERY: 'query'; QUERY: 'query';
SPECIALIZE: 'specialize'; SPECIALIZE: 'specialize';
ROOT: 'root'; ROOT: 'root';
+60
View File
@@ -3,6 +3,7 @@ syntax = "proto3";
package camino; package camino;
import "camino/schema.proto"; import "camino/schema.proto";
import "quixos/refs.proto";
service CaminoService { service CaminoService {
rpc ExecuteQuery(QueryRequest) returns (QueryResponse); rpc ExecuteQuery(QueryRequest) returns (QueryResponse);
@@ -69,6 +70,27 @@ message Value {
CrdtValue crdt_value = 10; CrdtValue crdt_value = 10;
} }
ValueSource source = 11; ValueSource source = 11;
// Trusted query coordinator annotation. Travels with values through residual
// row selection, then is removed in favor of final-path hydration metadata.
QueryFieldOrigin query_origin = 12;
}
message QueryFieldOrigin {
string selection_id = 1;
string atom_id = 2;
string interface_revision_id = 3;
string member_id = 4;
StateValueSource source = 5;
}
message QueryLiveField {
repeated QueryPathPart path = 1;
string selection_id = 2;
string object_id = 3;
quixos.CapabilityRef capability = 4;
string watch_operation_id = 5;
string setter_operation_id = 6;
quixos.FieldEditing editing = 7;
StateValueSource source = 8;
} }
message InstallPersistencePlanRequest { message InstallPersistencePlanRequest {
@@ -242,6 +264,8 @@ message QueryPendingField {
optional uint32 residual_window = 7; optional uint32 residual_window = 7;
uint32 residual_row = 8; uint32 residual_row = 8;
string residual_field = 9; string residual_field = 9;
optional uint32 relational_capture = 10;
uint32 captured_object = 11;
} }
message QueryStats { message QueryStats {
uint32 sql_count = 1; uint32 sql_count = 1;
@@ -251,6 +275,10 @@ message QueryStats {
uint32 result_bytes = 5; uint32 result_bytes = 5;
double preparation_ms = 6; double preparation_ms = 6;
double total_ms = 7; double total_ms = 7;
uint32 relational_stages = 8;
uint32 captured_candidates = 9;
double relational_ms = 10;
double residual_ms = 11;
} }
message QueryResponse { message QueryResponse {
Value value = 1; Value value = 1;
@@ -264,6 +292,36 @@ message QueryResponse {
repeated QueryResidualWindow residual_windows = 8; repeated QueryResidualWindow residual_windows = 8;
// Native reads share one database snapshot; package enrichment does not. // Native reads share one database snapshot; package enrichment does not.
string consistency = 9; // native-snapshot | mixed string consistency = 9; // native-snapshot | mixed
repeated QueryRelationalCapture relational_captures = 10;
repeated QueryLiveField live_fields = 11;
}
// Private coordinator input, removed before publishing a result. Memberships
// and native facts share one snapshot; package reads are sampled afterwards.
message QueryCapturedMember {
string object_id = 1;
string entry_id = 2;
Value map_key = 3;
}
message QueryCapturedMembers {
repeated QueryCapturedMember entries = 1;
}
message QueryCapturedObject {
string object_id = 1;
map<string, Value> fields = 2;
map<string, string> field_types = 3;
map<string, QueryCapturedMembers> relationships = 4;
}
message QueryRelationalCapture {
repeated QueryPathPart path = 1;
QueryRelationalPlan plan = 2;
string root_object_id = 3;
repeated QueryCapturedObject objects = 4;
string variables_json = 5;
uint32 row_limit = 6;
uint32 candidate_limit = 7;
optional uint32 residual_window = 8;
repeated string result_path = 9;
} }
message QueryResidualRow { message QueryResidualRow {
@@ -283,6 +341,8 @@ message QueryResidualWindow {
bool bounded_all = 6; bool bounded_all = 6;
repeated QuerySelection selection = 7; repeated QuerySelection selection = 7;
map<string, string> field_types = 8; map<string, string> field_types = 8;
bool relational = 9;
repeated string matched_entries = 10;
} }
message QueryFieldFailure { message QueryFieldFailure {
+167
View File
@@ -102,6 +102,173 @@ message QuerySelection {
repeated QueryCondition conditions = 6; repeated QueryCondition conditions = 6;
map<string, QueryArgument> arguments = 7; map<string, QueryArgument> arguments = 7;
repeated QuerySelection selection = 8; repeated QuerySelection selection = 8;
QueryRelationalPlan relational = 9;
QueryPredicate predicate = 10;
// Stable checked selection identity; empty for ordinary value projections.
string live_selection_id = 11;
}
// Resolved IDs, not GraphQL names, determine execution. Response selections
// remain separate so aliases and fragments cannot change relational semantics.
message QueryPathStep {
oneof step {
bool source = 1;
bool target = 2;
QueryRelationPath relation = 3;
}
}
message QueryRelationPath {
string interface_revision_id = 1;
string member_id = 2;
string target_interface_revision_id = 3;
bool optional = 4;
}
message QueryFieldOperand {
string interface_revision_id = 1;
string member_id = 2;
}
message QueryExpression {
repeated QueryPathStep path = 1;
oneof leaf {
QueryFieldOperand field = 2;
string ref = 3;
bool entry = 4;
bool map_key = 5;
}
string value_type_json = 6;
}
enum QueryAggregateOperator {
QUERY_AGGREGATE_OPERATOR_UNSPECIFIED = 0;
QUERY_AGGREGATE_OPERATOR_COUNT = 1;
QUERY_AGGREGATE_OPERATOR_COUNT_PRESENT = 2;
QUERY_AGGREGATE_OPERATOR_SUM = 3;
QUERY_AGGREGATE_OPERATOR_AVG = 4;
QUERY_AGGREGATE_OPERATOR_MIN = 5;
QUERY_AGGREGATE_OPERATOR_MAX = 6;
}
message QueryReduction {
QueryAggregateOperator operator = 1;
QueryExpression operand = 2;
string value_type_json = 3;
}
message QueryOperand {
oneof operand {
QueryExpression expression = 1;
QueryReduction reduction = 2;
}
}
enum QueryComparisonOperator {
QUERY_COMPARISON_OPERATOR_UNSPECIFIED = 0;
QUERY_COMPARISON_OPERATOR_EQ = 1;
QUERY_COMPARISON_OPERATOR_IN = 2;
QUERY_COMPARISON_OPERATOR_IS_NULL = 3;
QUERY_COMPARISON_OPERATOR_LT = 4;
QUERY_COMPARISON_OPERATOR_LTE = 5;
QUERY_COMPARISON_OPERATOR_GT = 6;
QUERY_COMPARISON_OPERATOR_GTE = 7;
}
message QueryComparison {
QueryOperand operand = 1;
QueryComparisonOperator operator = 2;
QueryArgument value = 3;
}
message QueryPredicateList {
repeated QueryPredicate children = 1;
}
enum QueryRelationPredicateOperator {
QUERY_RELATION_PREDICATE_OPERATOR_UNSPECIFIED = 0;
QUERY_RELATION_PREDICATE_OPERATOR_SOME = 1;
QUERY_RELATION_PREDICATE_OPERATOR_NONE = 2;
QUERY_RELATION_PREDICATE_OPERATOR_IS = 3;
QUERY_RELATION_PREDICATE_OPERATOR_IS_NULL = 4;
}
message QueryRelationPredicate {
repeated QueryPathStep path = 1;
QueryRelationPath relation = 2;
QueryRelationPredicateOperator operator = 3;
QueryPredicate predicate = 4;
QueryArgument value = 5;
}
message QueryReductionPredicate {
repeated QueryPathStep path = 1;
QueryRelationPath relation = 2;
QueryPredicate where = 3;
QueryPredicate having = 4;
}
message QueryPredicate {
oneof predicate {
QueryPredicateList and = 1;
QueryPredicateList or = 2;
QueryPredicate not = 3;
QueryComparison compare = 4;
QueryRelationPredicate relation = 5;
QueryReductionPredicate reduce = 6;
}
}
message QueryRow {
oneof row {
QueryObjectRow object = 1;
QueryPairRow pair = 2;
}
}
message QueryObjectRow {
string interface_revision_id = 1;
bool membership = 2;
string key_type = 3;
}
message QueryPairRow {
QueryRow source = 1;
QueryRow target = 2;
}
message QueryKey {
repeated string path = 1;
QueryExpression expression = 2;
}
message QueryDistinct {
repeated QueryKey keys = 1;
}
message QueryExpansion {
repeated QueryPathStep path = 1;
QueryRelationPath relation = 2;
}
message QueryRelationalStage {
uint32 id = 1;
optional uint32 input = 2;
QueryRow row = 3;
oneof operation {
QueryRelationPath source = 4;
QueryPredicate filter = 5;
QueryExpansion expand = 6;
QueryDistinct distinct = 7;
}
}
message QueryReductionProjection {
repeated string path = 1;
QueryReduction reduction = 2;
}
message QueryAggregateOrder {
QueryOperand operand = 1;
bool descending = 2;
}
message QueryRelationalTerminal {
uint32 stage = 1;
repeated string path = 2;
bool groups = 3;
repeated QueryKey keys = 4;
repeated QueryReductionProjection reductions = 5;
QueryPredicate where = 6;
QueryPredicate having = 7;
repeated QueryAggregateOrder order = 8;
QueryArgument first = 9;
QueryArgument all = 10;
QueryArgument after = 11;
repeated QuerySelection selection = 12;
bool residual = 13;
// Internal ordinary-relationship residual: yields ordered membership IDs.
bool rows = 14;
}
message QueryRelationalPlan {
repeated QueryRelationalStage stages = 1;
repeated QueryRelationalTerminal terminals = 2;
} }
message QueryReadBinding { message QueryReadBinding {
string atom_id = 1; string atom_id = 1;
+3 -7
View File
@@ -87,20 +87,16 @@ message InvokeCapabilityResponse {
FieldEditing field_editing = 7; FieldEditing field_editing = 7;
} }
// Resolved from the checked native getter/setter binding, not Value.source.
message FieldEditing {
string getter_operation_id = 1;
string setter_operation_id = 2;
string document_type = 3;
string binding_digest = 4;
}
message EditCapabilityFieldRequest { message EditCapabilityFieldRequest {
// The public getter; setter must belong to the same value member. // The public getter; setter must belong to the same value member.
quixos.CapabilityRef capability = 1; quixos.CapabilityRef capability = 1;
string object_id = 2; string object_id = 2;
string setter_operation_id = 3; string setter_operation_id = 3;
string binding_digest = 4; string binding_digest = 4;
oneof edit {
camino.CrdtValue update = 5; camino.CrdtValue update = 5;
camino.Value replacement = 7;
}
string client_mutation_id = 6; string client_mutation_id = 6;
} }
+14
View File
@@ -9,6 +9,20 @@ message CapabilityRef {
ConformanceWitness conformance = 3; ConformanceWitness conformance = 3;
} }
// Resolved native editing semantics. A source snapshot alone grants no writer.
message FieldEditing {
string getter_operation_id = 1;
string setter_operation_id = 2;
string document_type = 3;
string binding_digest = 4;
enum Mode {
UNSPECIFIED = 0;
REGISTER = 1;
CRDT = 2;
}
Mode mode = 5;
}
message ConformanceWitness { message ConformanceWitness {
string object_id = 1; string object_id = 1;
string interface_revision_id = 2; string interface_revision_id = 2;
+12 -5
View File
@@ -5,6 +5,8 @@ declare module "@quixos/web-studio-react-runtime" {
import type * as React from "react"; import type * as React from "react";
export function useComponentOverlayContainer(): HTMLElement; export function useComponentOverlayContainer(): HTMLElement;
export function useComponentStyleRoot(): ShadowRoot; export function useComponentStyleRoot(): ShadowRoot;
/** Aggregate transport counters only; contains no object identities or values. */
export function getFieldTransportMetrics(): Partial<Record<"get" | "watch" | "register" | "crdt" | "setter", Readonly<{started: number; active: number; completed: number; failed: number; totalMs: number}>>>;
export type ObjectRef<AtomId extends string> = string & { export type ObjectRef<AtomId extends string> = string & {
readonly $quixosAtom: AtomId; readonly $quixosAtom: AtomId;
}; };
@@ -22,8 +24,9 @@ declare module "@quixos/web-studio-react-runtime" {
export function useTryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>): ConformanceResult<View>; export function useTryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>): ConformanceResult<View>;
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 interface QueryDescriptor<Variables, Result> {readonly id: string; readonly definitionDigest: string; readonly rootInterfaceRevisionId: string; readonly variables: QueryValueType; readonly output: QueryValueType; readonly watch: boolean; readonly $types?: (variables: Variables, result: Result) => [Variables, Result]} 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 QueryPartial<T> = T extends QueryReference ? T : T extends readonly (infer Item)[] ? QueryPartial<Item>[] : T extends object ? {[Key in keyof T]?: QueryPartial<T[Key]>} : T; 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 type QueryPartial<T> = T extends {readonly $quixosRef: string} | {readonly capability: {readonly getOperationId: string}} ? 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 function useQuery<Variables, Result>(descriptor: QueryDescriptor<Variables, Result>, options: {root: string | {readonly $quixosRef: string}; variables: Variables}): QueryState<Result> & {refresh(): void};
@@ -55,8 +58,12 @@ declare module "@quixos/web-studio-react-runtime" {
options?: {clientMutationId?: string; signal?: AbortSignal}, options?: {clientMutationId?: string; signal?: AbortSignal},
) => Promise<Result>; ) => Promise<Result>;
export const h: typeof React.createElement; export const h: typeof React.createElement;
export function useLiveField<T>(field: ReadableField<T>, options: {write: (value: T) => Promise<void>}): readonly [T, (value: T) => Promise<void>]; export type LiveFieldState<T> = {refreshing: boolean; stale: boolean; pendingWrites: number; writeError?: Error; refresh(): Promise<void>; discard(): void} & ({status: "loading"; value?: undefined; error?: undefined} | {status: "ready"; value: T; error?: Error} | {status: "error"; value?: undefined; error: Error});
export function useLiveField<T>(field: WritableField<T>): readonly [T, (value: T) => Promise<void>]; export type WritableLiveFieldState<T> = LiveFieldState<T> & {set(value: T): Promise<void>; retry(): Promise<void>};
export function useLiveField<T>(field: ReadableField<T>): readonly [T]; export function useLiveField<T>(field: ReadableField<T>, options: {id: string; write: (value: T) => Promise<void>}): WritableLiveFieldState<T>;
export function useLiveField<T>(field: WritableField<T>): WritableLiveFieldState<T>;
export function useLiveField<T>(field: ReadableField<T>): LiveFieldState<T>;
export function useSuspenseLiveField<T>(field: WritableField<T>): WritableLiveFieldState<T> & {status: "ready"; value: T};
export function useSuspenseLiveField<T>(field: ReadableField<T>): LiveFieldState<T> & {status: "ready"; value: T};
} }
`; `;
+19 -1
View File
@@ -1,5 +1,6 @@
import type { BindingSchema } from "./index.js"; import type { BindingSchema } from "./index.js";
import type { ValueType } from "../capability-model/types.js"; import type { ValueType } from "../capability-model/types.js";
import { queryPresentation } from "../query/presentation.js";
/** Browser projection of the SAME checked RPC types, not a second props schema. */ /** Browser projection of the SAME checked RPC types, not a second props schema. */
export function generateReactBindings( export function generateReactBindings(
@@ -128,9 +129,25 @@ export function generateReactBindings(
return type(value); return type(value);
}; };
const queries = pkg.checkedQueries ?? []; const queries = pkg.checkedQueries ?? [];
const presentedType = (
value: ValueType,
fields: ReturnType<typeof queryPresentation>,
path: string[] = [],
): string => {
const field = fields.find((field) => JSON.stringify(field.path) === JSON.stringify(path));
if (field)
return `${field.conditions.length ? "(" : ""}${field.setOperationId ? "WritableField" : "ReadableField"}<${type(field.valueType)}>${field.conditions.length ? " | null)" : ""}`;
if (value.kind === "optional") return `(${presentedType(value.value, fields, path)} | null)`;
if (value.kind === "list") return `Array<${presentedType(value.value, fields, [...path, "*"])}>`;
if (value.kind === "record")
return `{${Object.entries(value.fields)
.map(([key, child]) => `${q(key)}: ${presentedType(child, fields, [...path, key])}`)
.join(";")}}`;
return queryType(value);
};
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)}: ${queryType(query.output)}`).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)}]>`).join(";")}} = ${JSON.stringify(
Object.fromEntries( Object.fromEntries(
queries.map((query) => [ queries.map((query) => [
@@ -141,6 +158,7 @@ export function generateReactBindings(
rootInterfaceRevisionId: query.declaration.root, rootInterfaceRevisionId: query.declaration.root,
variables: query.variables, variables: query.variables,
output: query.output, output: query.output,
liveFields: queryPresentation(query, schema.interfaces),
watch: query.declaration.watch, watch: query.declaration.watch,
}, },
]), ]),
File diff suppressed because one or more lines are too long
@@ -1,253 +1,259 @@
WORKSPACE=1 WORKSPACE=1
QUERY=2 RESULT_BYTES=2
SPECIALIZE=3 RPC_CALLS=3
ROOT=4 DEADLINE_MS=4
DOCUMENT=5 QUERY=5
FRAGMENTS=6 SPECIALIZE=6
VIEW=7 ROOT=7
MAX=8 DOCUMENT=8
ALLOW=9 FRAGMENTS=9
POLL=10 VIEW=10
QUERYABLE=11 MAX=11
RPC=12 ALLOW=12
QUERY_REASON=13 POLL=13
TYPE=14 QUERYABLE=14
OBJECT=15 RPC=15
STORABLE=16 QUERY_REASON=16
IMPLEMENTS=17 TYPE=17
REF=18 OBJECT=18
FRAGMENT=19 STORABLE=19
IMPORT=20 IMPLEMENTS=20
EXTERNAL=21 REF=21
ATOM=22 FRAGMENT=22
INTERFACE=23 IMPORT=23
INTERFACES=24 EXTERNAL=24
PACKAGE=25 ATOM=25
VALUE=26 INTERFACE=26
RELATION=27 INTERFACES=27
OPERATION=28 PACKAGE=28
FUNCTION=29 VALUE=29
CONSTRUCTOR=30 RELATION=30
CONSTRUCTS=31 OPERATION=31
INPUT=32 FUNCTION=32
CONFORM=33 CONSTRUCTOR=33
AS=34 CONSTRUCTS=34
BIND=35 INPUT=35
STATIC=36 CONFORM=36
TO=37 AS=37
PRIVATE=38 BIND=38
SHARED=39 STATIC=39
STATE=40 TO=40
EDGE=41 PRIVATE=41
PROJECTION=42 SHARED=42
WITH=43 STATE=43
USING=44 EDGE=44
VIA=45 PROJECTION=45
MATERIALIZE=46 WITH=46
IF=47 USING=47
ABSENT=48 VIA=48
ON=49 MATERIALIZE=49
POLICY=50 IF=50
DEFAULT=51 ABSENT=51
SOURCE=52 ON=52
REPOSITORY=53 POLICY=53
COMMIT=54 DEFAULT=54
REVISION=55 SOURCE=55
SEMANTIC_MAJOR=56 REPOSITORY=56
ON_DELETE=57 COMMIT=57
RETAIN_OTHER=58 REVISION=58
KEYED=59 SEMANTIC_MAJOR=59
PUBLIC_TRAVERSAL=60 ON_DELETE=60
ID=61 RETAIN_OTHER=61
DOC=62 KEYED=62
MODE=63 PUBLIC_TRAVERSAL=63
EMITS=64 ID=64
RECEIVER=65 DOC=65
REQUIRES=66 MODE=66
ANY=67 EMITS=67
GET=68 RECEIVER=68
SET=69 REQUIRES=69
WATCH=70 ANY=70
START=71 GET=71
STOP=72 SET=72
READ=73 WATCH=73
WRITE=74 START=74
RESOLVE=75 STOP=75
CONNECT=76 READ=76
DISCONNECT=77 WRITE=77
CALL=78 RESOLVE=78
WATCH_START=79 CONNECT=79
WATCH_STOP=80 DISCONNECT=80
SUBSCRIBE=81 CALL=81
UNSUBSCRIBE=82 WATCH_START=82
OPTIMISTIC_REGISTER=83 WATCH_STOP=83
CRDT=84 SUBSCRIBE=84
OPTIONAL_ONE=85 UNSUBSCRIBE=85
EXACTLY_ONE=86 OPTIMISTIC_REGISTER=86
MANY_UNIQUE=87 CRDT=87
MANY=88 OPTIONAL_ONE=88
ORDERED=89 EXACTLY_ONE=89
UNIT=90 MANY_UNIQUE=90
WATCH_HANDLE=91 MANY=91
MESSAGE=92 ORDERED=92
ATOM_REF=93 UNIT=93
INTERFACE_REF=94 WATCH_HANDLE=94
OPTIONAL=95 MESSAGE=95
LIST=96 ATOM_REF=96
RECORD=97 INTERFACE_REF=97
BOOL=98 OPTIONAL=98
BYTES=99 LIST=99
DOUBLE=100 RECORD=100
INT32=101 BOOL=101
INT64=102 BYTES=102
STRING=103 DOUBLE=103
UINT32=104 INT32=104
UINT64=105 INT64=105
TRUE=106 STRING=106
FALSE=107 UINT32=107
NULL=108 UINT64=108
ARROW=109 TRUE=109
COLON=110 FALSE=110
SEMI=111 NULL=111
COMMA=112 ARROW=112
DOT=113 COLON=113
LBRACE=114 SEMI=114
RBRACE=115 COMMA=115
LBRACK=116 DOT=116
RBRACK=117 LBRACE=117
LPAREN=118 RBRACE=118
RPAREN=119 LBRACK=119
LT=120 RBRACK=120
GT=121 LPAREN=121
AMP=122 RPAREN=122
EQUAL=123 LT=123
INTEGER=124 GT=124
JSON_NUMBER=125 AMP=125
IDENTIFIER=126 EQUAL=126
STRING_LITERAL=127 INTEGER=127
LINE_COMMENT=128 JSON_NUMBER=128
BLOCK_COMMENT=129 IDENTIFIER=129
WS=130 STRING_LITERAL=130
LINE_COMMENT=131
BLOCK_COMMENT=132
WS=133
'workspace'=1 'workspace'=1
'query'=2 'result-bytes'=2
'specialize'=3 'rpc-calls'=3
'root'=4 'deadline-ms'=4
'document'=5 'query'=5
'fragments'=6 'specialize'=6
'view'=7 'root'=7
'max'=8 'document'=8
'allow'=9 'fragments'=9
'poll'=10 'view'=10
'queryable'=11 'max'=11
'rpc'=12 'allow'=12
'query-reason'=13 'poll'=13
'type'=14 'queryable'=14
'object'=15 'rpc'=15
'storable'=16 'query-reason'=16
'implements'=17 'type'=17
'ref'=18 'object'=18
'fragment'=19 'storable'=19
'import'=20 'implements'=20
'external'=21 'ref'=21
'atom'=22 'fragment'=22
'interface'=23 'import'=23
'interfaces'=24 'external'=24
'package'=25 'atom'=25
'value'=26 'interface'=26
'relation'=27 'interfaces'=27
'operation'=28 'package'=28
'function'=29 'value'=29
'constructor'=30 'relation'=30
'constructs'=31 'operation'=31
'input'=32 'function'=32
'conform'=33 'constructor'=33
'as'=34 'constructs'=34
'bind'=35 'input'=35
'static'=36 'conform'=36
'to'=37 'as'=37
'private'=38 'bind'=38
'shared'=39 'static'=39
'state'=40 'to'=40
'edge'=41 'private'=41
'projection'=42 'shared'=42
'with'=43 'state'=43
'using'=44 'edge'=44
'via'=45 'projection'=45
'materialize'=46 'with'=46
'if'=47 'using'=47
'absent'=48 'via'=48
'on'=49 'materialize'=49
'policy'=50 'if'=50
'default'=51 'absent'=51
'source'=52 'on'=52
'repository'=53 'policy'=53
'commit'=54 'default'=54
'revision'=55 'source'=55
'semantic-major'=56 'repository'=56
'on-delete'=57 'commit'=57
'retain-other'=58 'revision'=58
'keyed'=59 'semantic-major'=59
'public-traversal'=60 'on-delete'=60
'id'=61 'retain-other'=61
'doc'=62 'keyed'=62
'mode'=63 'public-traversal'=63
'emits'=64 'id'=64
'receiver'=65 'doc'=65
'requires'=66 'mode'=66
'any'=67 'emits'=67
'get'=68 'receiver'=68
'set'=69 'requires'=69
'watch'=70 'any'=70
'start'=71 'get'=71
'stop'=72 'set'=72
'read'=73 'watch'=73
'write'=74 'start'=74
'resolve'=75 'stop'=75
'connect'=76 'read'=76
'disconnect'=77 'write'=77
'call'=78 'resolve'=78
'watch-start'=79 'connect'=79
'watch-stop'=80 'disconnect'=80
'subscribe'=81 'call'=81
'unsubscribe'=82 'watch-start'=82
'optimistic-register'=83 'watch-stop'=83
'crdt'=84 'subscribe'=84
'optional-one'=85 'unsubscribe'=85
'exactly-one'=86 'optimistic-register'=86
'many-unique'=87 'crdt'=87
'many'=88 'optional-one'=88
'ordered'=89 'exactly-one'=89
'unit'=90 'many-unique'=90
'watch-handle'=91 'many'=91
'message'=92 'ordered'=92
'atom-ref'=93 'unit'=93
'interface-ref'=94 'watch-handle'=94
'optional'=95 'message'=95
'list'=96 'atom-ref'=96
'record'=97 'interface-ref'=97
'bool'=98 'optional'=98
'bytes'=99 'list'=99
'double'=100 'record'=100
'int32'=101 'bool'=101
'int64'=102 'bytes'=102
'string'=103 'double'=103
'uint32'=104 'int32'=104
'uint64'=105 'int64'=105
'true'=106 'string'=106
'false'=107 'uint32'=107
'null'=108 'uint64'=108
'->'=109 'true'=109
':'=110 'false'=110
';'=111 'null'=111
','=112 '->'=112
'.'=113 ':'=113
'{'=114 ';'=114
'}'=115 ','=115
'['=116 '.'=116
']'=117 '{'=117
'('=118 '}'=118
')'=119 '['=119
'<'=120 ']'=120
'>'=121 '('=121
'&'=122 ')'=122
'='=123 '<'=123
'>'=124
'&'=125
'='=126
File diff suppressed because one or more lines are too long
@@ -1,253 +1,259 @@
WORKSPACE=1 WORKSPACE=1
QUERY=2 RESULT_BYTES=2
SPECIALIZE=3 RPC_CALLS=3
ROOT=4 DEADLINE_MS=4
DOCUMENT=5 QUERY=5
FRAGMENTS=6 SPECIALIZE=6
VIEW=7 ROOT=7
MAX=8 DOCUMENT=8
ALLOW=9 FRAGMENTS=9
POLL=10 VIEW=10
QUERYABLE=11 MAX=11
RPC=12 ALLOW=12
QUERY_REASON=13 POLL=13
TYPE=14 QUERYABLE=14
OBJECT=15 RPC=15
STORABLE=16 QUERY_REASON=16
IMPLEMENTS=17 TYPE=17
REF=18 OBJECT=18
FRAGMENT=19 STORABLE=19
IMPORT=20 IMPLEMENTS=20
EXTERNAL=21 REF=21
ATOM=22 FRAGMENT=22
INTERFACE=23 IMPORT=23
INTERFACES=24 EXTERNAL=24
PACKAGE=25 ATOM=25
VALUE=26 INTERFACE=26
RELATION=27 INTERFACES=27
OPERATION=28 PACKAGE=28
FUNCTION=29 VALUE=29
CONSTRUCTOR=30 RELATION=30
CONSTRUCTS=31 OPERATION=31
INPUT=32 FUNCTION=32
CONFORM=33 CONSTRUCTOR=33
AS=34 CONSTRUCTS=34
BIND=35 INPUT=35
STATIC=36 CONFORM=36
TO=37 AS=37
PRIVATE=38 BIND=38
SHARED=39 STATIC=39
STATE=40 TO=40
EDGE=41 PRIVATE=41
PROJECTION=42 SHARED=42
WITH=43 STATE=43
USING=44 EDGE=44
VIA=45 PROJECTION=45
MATERIALIZE=46 WITH=46
IF=47 USING=47
ABSENT=48 VIA=48
ON=49 MATERIALIZE=49
POLICY=50 IF=50
DEFAULT=51 ABSENT=51
SOURCE=52 ON=52
REPOSITORY=53 POLICY=53
COMMIT=54 DEFAULT=54
REVISION=55 SOURCE=55
SEMANTIC_MAJOR=56 REPOSITORY=56
ON_DELETE=57 COMMIT=57
RETAIN_OTHER=58 REVISION=58
KEYED=59 SEMANTIC_MAJOR=59
PUBLIC_TRAVERSAL=60 ON_DELETE=60
ID=61 RETAIN_OTHER=61
DOC=62 KEYED=62
MODE=63 PUBLIC_TRAVERSAL=63
EMITS=64 ID=64
RECEIVER=65 DOC=65
REQUIRES=66 MODE=66
ANY=67 EMITS=67
GET=68 RECEIVER=68
SET=69 REQUIRES=69
WATCH=70 ANY=70
START=71 GET=71
STOP=72 SET=72
READ=73 WATCH=73
WRITE=74 START=74
RESOLVE=75 STOP=75
CONNECT=76 READ=76
DISCONNECT=77 WRITE=77
CALL=78 RESOLVE=78
WATCH_START=79 CONNECT=79
WATCH_STOP=80 DISCONNECT=80
SUBSCRIBE=81 CALL=81
UNSUBSCRIBE=82 WATCH_START=82
OPTIMISTIC_REGISTER=83 WATCH_STOP=83
CRDT=84 SUBSCRIBE=84
OPTIONAL_ONE=85 UNSUBSCRIBE=85
EXACTLY_ONE=86 OPTIMISTIC_REGISTER=86
MANY_UNIQUE=87 CRDT=87
MANY=88 OPTIONAL_ONE=88
ORDERED=89 EXACTLY_ONE=89
UNIT=90 MANY_UNIQUE=90
WATCH_HANDLE=91 MANY=91
MESSAGE=92 ORDERED=92
ATOM_REF=93 UNIT=93
INTERFACE_REF=94 WATCH_HANDLE=94
OPTIONAL=95 MESSAGE=95
LIST=96 ATOM_REF=96
RECORD=97 INTERFACE_REF=97
BOOL=98 OPTIONAL=98
BYTES=99 LIST=99
DOUBLE=100 RECORD=100
INT32=101 BOOL=101
INT64=102 BYTES=102
STRING=103 DOUBLE=103
UINT32=104 INT32=104
UINT64=105 INT64=105
TRUE=106 STRING=106
FALSE=107 UINT32=107
NULL=108 UINT64=108
ARROW=109 TRUE=109
COLON=110 FALSE=110
SEMI=111 NULL=111
COMMA=112 ARROW=112
DOT=113 COLON=113
LBRACE=114 SEMI=114
RBRACE=115 COMMA=115
LBRACK=116 DOT=116
RBRACK=117 LBRACE=117
LPAREN=118 RBRACE=118
RPAREN=119 LBRACK=119
LT=120 RBRACK=120
GT=121 LPAREN=121
AMP=122 RPAREN=122
EQUAL=123 LT=123
INTEGER=124 GT=124
JSON_NUMBER=125 AMP=125
IDENTIFIER=126 EQUAL=126
STRING_LITERAL=127 INTEGER=127
LINE_COMMENT=128 JSON_NUMBER=128
BLOCK_COMMENT=129 IDENTIFIER=129
WS=130 STRING_LITERAL=130
LINE_COMMENT=131
BLOCK_COMMENT=132
WS=133
'workspace'=1 'workspace'=1
'query'=2 'result-bytes'=2
'specialize'=3 'rpc-calls'=3
'root'=4 'deadline-ms'=4
'document'=5 'query'=5
'fragments'=6 'specialize'=6
'view'=7 'root'=7
'max'=8 'document'=8
'allow'=9 'fragments'=9
'poll'=10 'view'=10
'queryable'=11 'max'=11
'rpc'=12 'allow'=12
'query-reason'=13 'poll'=13
'type'=14 'queryable'=14
'object'=15 'rpc'=15
'storable'=16 'query-reason'=16
'implements'=17 'type'=17
'ref'=18 'object'=18
'fragment'=19 'storable'=19
'import'=20 'implements'=20
'external'=21 'ref'=21
'atom'=22 'fragment'=22
'interface'=23 'import'=23
'interfaces'=24 'external'=24
'package'=25 'atom'=25
'value'=26 'interface'=26
'relation'=27 'interfaces'=27
'operation'=28 'package'=28
'function'=29 'value'=29
'constructor'=30 'relation'=30
'constructs'=31 'operation'=31
'input'=32 'function'=32
'conform'=33 'constructor'=33
'as'=34 'constructs'=34
'bind'=35 'input'=35
'static'=36 'conform'=36
'to'=37 'as'=37
'private'=38 'bind'=38
'shared'=39 'static'=39
'state'=40 'to'=40
'edge'=41 'private'=41
'projection'=42 'shared'=42
'with'=43 'state'=43
'using'=44 'edge'=44
'via'=45 'projection'=45
'materialize'=46 'with'=46
'if'=47 'using'=47
'absent'=48 'via'=48
'on'=49 'materialize'=49
'policy'=50 'if'=50
'default'=51 'absent'=51
'source'=52 'on'=52
'repository'=53 'policy'=53
'commit'=54 'default'=54
'revision'=55 'source'=55
'semantic-major'=56 'repository'=56
'on-delete'=57 'commit'=57
'retain-other'=58 'revision'=58
'keyed'=59 'semantic-major'=59
'public-traversal'=60 'on-delete'=60
'id'=61 'retain-other'=61
'doc'=62 'keyed'=62
'mode'=63 'public-traversal'=63
'emits'=64 'id'=64
'receiver'=65 'doc'=65
'requires'=66 'mode'=66
'any'=67 'emits'=67
'get'=68 'receiver'=68
'set'=69 'requires'=69
'watch'=70 'any'=70
'start'=71 'get'=71
'stop'=72 'set'=72
'read'=73 'watch'=73
'write'=74 'start'=74
'resolve'=75 'stop'=75
'connect'=76 'read'=76
'disconnect'=77 'write'=77
'call'=78 'resolve'=78
'watch-start'=79 'connect'=79
'watch-stop'=80 'disconnect'=80
'subscribe'=81 'call'=81
'unsubscribe'=82 'watch-start'=82
'optimistic-register'=83 'watch-stop'=83
'crdt'=84 'subscribe'=84
'optional-one'=85 'unsubscribe'=85
'exactly-one'=86 'optimistic-register'=86
'many-unique'=87 'crdt'=87
'many'=88 'optional-one'=88
'ordered'=89 'exactly-one'=89
'unit'=90 'many-unique'=90
'watch-handle'=91 'many'=91
'message'=92 'ordered'=92
'atom-ref'=93 'unit'=93
'interface-ref'=94 'watch-handle'=94
'optional'=95 'message'=95
'list'=96 'atom-ref'=96
'record'=97 'interface-ref'=97
'bool'=98 'optional'=98
'bytes'=99 'list'=99
'double'=100 'record'=100
'int32'=101 'bool'=101
'int64'=102 'bytes'=102
'string'=103 'double'=103
'uint32'=104 'int32'=104
'uint64'=105 'int64'=105
'true'=106 'string'=106
'false'=107 'uint32'=107
'null'=108 'uint64'=108
'->'=109 'true'=109
':'=110 'false'=110
';'=111 'null'=111
','=112 '->'=112
'.'=113 ':'=113
'{'=114 ';'=114
'}'=115 ','=115
'['=116 '.'=116
']'=117 '{'=117
'('=118 '}'=118
')'=119 '['=119
'<'=120 ']'=120
'>'=121 '('=121
'&'=122 ')'=122
'='=123 '<'=123
'>'=124
'&'=125
'='=126
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1307,12 +1307,12 @@ const lowerPackage = (
if (parameters.length) { if (parameters.length) {
const use = identifier(clause.identifier(1)); const use = identifier(clause.identifier(1));
const reason = stringValue(clause.stringLiteral()!); const reason = stringValue(clause.stringLiteral()!);
if (!["select", "predicate", "order"].includes(use) || !reason.trim()) if (!["select", "predicate", "order", "aggregate", "group", "distinct"].includes(use) || !reason.trim())
loweringIssue( loweringIssue(
state, state,
clause, clause,
"invalid-query-allowance", "invalid-query-allowance",
"Expected select/predicate/order and a nonempty reason", "Expected select/predicate/order/aggregate/group/distinct and a nonempty reason",
); );
templateAllowances.push({ templateAllowances.push({
interface: state.types.interface(clause.interfaceType()!), interface: state.types.interface(clause.interfaceType()!),
@@ -1329,12 +1329,16 @@ const lowerPackage = (
const member = contract?.members.find((entry) => entry.displayName === identifier(clause.identifier(0))); const member = contract?.members.find((entry) => entry.displayName === identifier(clause.identifier(0)));
const use = identifier(clause.identifier(1)); const use = identifier(clause.identifier(1));
const reason = stringValue(clause.stringLiteral()!); const reason = stringValue(clause.stringLiteral()!);
if (!member || !["select", "predicate", "order"].includes(use) || !reason.trim()) if (
!member ||
!["select", "predicate", "order", "aggregate", "group", "distinct"].includes(use) ||
!reason.trim()
)
loweringIssue( loweringIssue(
state, state,
clause, clause,
"invalid-query-allowance", "invalid-query-allowance",
"Query allowances name an exact member, use (select/predicate/order), and nonempty reason", "Query allowances name an exact member, use (select/predicate/order/aggregate/group/distinct), and nonempty reason",
); );
else else
declaration.allowances.push({ declaration.allowances.push({
+328 -43
View File
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
+72 -7
View File
@@ -2,15 +2,15 @@
// @generated from file quixos/refs.proto (package quixos, syntax proto3) // @generated from file quixos/refs.proto (package quixos, syntax proto3)
/* eslint-disable */ /* eslint-disable */
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf"; 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("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zInUKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCRIvCgtjb25mb3JtYW5jZRgDIAEoCzIaLnF1aXhvcy5Db25mb3JtYW5jZVdpdG5lc3MilgEKEkNvbmZvcm1hbmNlV2l0bmVzcxIRCglvYmplY3RfaWQYASABKAkSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAIgASgJEhYKDmNvbmZvcm1hbmNlX2lkGAMgASgJEh0KFXdvcmtzcGFjZV9yZXZpc2lvbl9pZBgEIAEoCRIXCg93b3Jrc3BhY2VfZXBvY2gYBSABKAkiQgoQUGFja2FnZUV4cG9ydFJlZhIbChNwYWNrYWdlX3JldmlzaW9uX2lkGAEgASgJEhEKCWV4cG9ydF9pZBgCIAEoCSLYAQoSSW5qZWN0ZWREZXBlbmRlbmN5Eg8KB3BvcnRfaWQYASABKAkSFwoNc3RhdGVfc2xvdF9pZBgCIAEoCUgAEiYKBGVkZ2UYAyABKAsyFi5xdWl4b3MuRWRnZURlcGVuZGVuY3lIABIfChVpbnRlcmZhY2VfcmV2aXNpb25faWQYBCABKAlIABIdChNjb25zdHJ1Y3Rvcl9hdG9tX2lkGAUgASgJSAASEgoIcXVlcnlfaWQYByABKAlIABIRCglvYmplY3RfaWQYBiABKAlCCQoHYmluZGluZyI9Cg5FZGdlRGVwZW5kZW5jeRIUCgxlZGdlX3R5cGVfaWQYASABKAkSFQoNcHJvamVjdGlvbl9pZBgCIAEoCWIGcHJvdG8z"); fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zInUKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCRIvCgtjb25mb3JtYW5jZRgDIAEoCzIaLnF1aXhvcy5Db25mb3JtYW5jZVdpdG5lc3Mi0QEKDEZpZWxkRWRpdGluZxIbChNnZXR0ZXJfb3BlcmF0aW9uX2lkGAEgASgJEhsKE3NldHRlcl9vcGVyYXRpb25faWQYAiABKAkSFQoNZG9jdW1lbnRfdHlwZRgDIAEoCRIWCg5iaW5kaW5nX2RpZ2VzdBgEIAEoCRInCgRtb2RlGAUgASgOMhkucXVpeG9zLkZpZWxkRWRpdGluZy5Nb2RlIi8KBE1vZGUSDwoLVU5TUEVDSUZJRUQQABIMCghSRUdJU1RFUhABEggKBENSRFQQAiKWAQoSQ29uZm9ybWFuY2VXaXRuZXNzEhEKCW9iamVjdF9pZBgBIAEoCRIdChVpbnRlcmZhY2VfcmV2aXNpb25faWQYAiABKAkSFgoOY29uZm9ybWFuY2VfaWQYAyABKAkSHQoVd29ya3NwYWNlX3JldmlzaW9uX2lkGAQgASgJEhcKD3dvcmtzcGFjZV9lcG9jaBgFIAEoCSJCChBQYWNrYWdlRXhwb3J0UmVmEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSEQoJZXhwb3J0X2lkGAIgASgJItgBChJJbmplY3RlZERlcGVuZGVuY3kSDwoHcG9ydF9pZBgBIAEoCRIXCg1zdGF0ZV9zbG90X2lkGAIgASgJSAASJgoEZWRnZRgDIAEoCzIWLnF1aXhvcy5FZGdlRGVwZW5kZW5jeUgAEh8KFWludGVyZmFjZV9yZXZpc2lvbl9pZBgEIAEoCUgAEh0KE2NvbnN0cnVjdG9yX2F0b21faWQYBSABKAlIABISCghxdWVyeV9pZBgHIAEoCUgAEhEKCW9iamVjdF9pZBgGIAEoCUIJCgdiaW5kaW5nIj0KDkVkZ2VEZXBlbmRlbmN5EhQKDGVkZ2VfdHlwZV9pZBgBIAEoCRIVCg1wcm9qZWN0aW9uX2lkGAIgASgJYgZwcm90bzM");
/** /**
* @generated from message quixos.CapabilityRef * @generated from message quixos.CapabilityRef
@@ -41,6 +41,71 @@ export type CapabilityRef = Message<"quixos.CapabilityRef"> & {
export const CapabilityRefSchema: GenMessage<CapabilityRef> = /*@__PURE__*/ export const CapabilityRefSchema: GenMessage<CapabilityRef> = /*@__PURE__*/
messageDesc(file_quixos_refs, 0); messageDesc(file_quixos_refs, 0);
/**
* Resolved native editing semantics. A source snapshot alone grants no writer.
*
* @generated from message quixos.FieldEditing
*/
export type FieldEditing = Message<"quixos.FieldEditing"> & {
/**
* @generated from field: string getter_operation_id = 1;
*/
getterOperationId: string;
/**
* @generated from field: string setter_operation_id = 2;
*/
setterOperationId: string;
/**
* @generated from field: string document_type = 3;
*/
documentType: string;
/**
* @generated from field: string binding_digest = 4;
*/
bindingDigest: string;
/**
* @generated from field: quixos.FieldEditing.Mode mode = 5;
*/
mode: FieldEditing_Mode;
};
/**
* Describes the message quixos.FieldEditing.
* Use `create(FieldEditingSchema)` to create a new message.
*/
export const FieldEditingSchema: GenMessage<FieldEditing> = /*@__PURE__*/
messageDesc(file_quixos_refs, 1);
/**
* @generated from enum quixos.FieldEditing.Mode
*/
export enum FieldEditing_Mode {
/**
* @generated from enum value: UNSPECIFIED = 0;
*/
UNSPECIFIED = 0,
/**
* @generated from enum value: REGISTER = 1;
*/
REGISTER = 1,
/**
* @generated from enum value: CRDT = 2;
*/
CRDT = 2,
}
/**
* Describes the enum quixos.FieldEditing.Mode.
*/
export const FieldEditing_ModeSchema: GenEnum<FieldEditing_Mode> = /*@__PURE__*/
enumDesc(file_quixos_refs, 1, 0);
/** /**
* @generated from message quixos.ConformanceWitness * @generated from message quixos.ConformanceWitness
*/ */
@@ -76,7 +141,7 @@ export type ConformanceWitness = Message<"quixos.ConformanceWitness"> & {
* Use `create(ConformanceWitnessSchema)` to create a new message. * Use `create(ConformanceWitnessSchema)` to create a new message.
*/ */
export const ConformanceWitnessSchema: GenMessage<ConformanceWitness> = /*@__PURE__*/ export const ConformanceWitnessSchema: GenMessage<ConformanceWitness> = /*@__PURE__*/
messageDesc(file_quixos_refs, 1); messageDesc(file_quixos_refs, 2);
/** /**
* @generated from message quixos.PackageExportRef * @generated from message quixos.PackageExportRef
@@ -98,7 +163,7 @@ export type PackageExportRef = Message<"quixos.PackageExportRef"> & {
* Use `create(PackageExportRefSchema)` to create a new message. * Use `create(PackageExportRefSchema)` to create a new message.
*/ */
export const PackageExportRefSchema: GenMessage<PackageExportRef> = /*@__PURE__*/ export const PackageExportRefSchema: GenMessage<PackageExportRef> = /*@__PURE__*/
messageDesc(file_quixos_refs, 2); messageDesc(file_quixos_refs, 3);
/** /**
* @generated from message quixos.InjectedDependency * @generated from message quixos.InjectedDependency
@@ -158,7 +223,7 @@ export type InjectedDependency = Message<"quixos.InjectedDependency"> & {
* Use `create(InjectedDependencySchema)` to create a new message. * Use `create(InjectedDependencySchema)` to create a new message.
*/ */
export const InjectedDependencySchema: GenMessage<InjectedDependency> = /*@__PURE__*/ export const InjectedDependencySchema: GenMessage<InjectedDependency> = /*@__PURE__*/
messageDesc(file_quixos_refs, 3); messageDesc(file_quixos_refs, 4);
/** /**
* @generated from message quixos.EdgeDependency * @generated from message quixos.EdgeDependency
@@ -180,5 +245,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, 4); messageDesc(file_quixos_refs, 5);
+108 -56
View File
@@ -34,6 +34,8 @@ import {
type InterfaceRevisionId, type InterfaceRevisionId,
} 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 { relationalCompiler } from "./relational-compile.js";
import { import {
QueryCompileError, QueryCompileError,
type QueryDeclaration, type QueryDeclaration,
@@ -58,26 +60,6 @@ const fail = (code: string, message: string, node?: ASTNode): never => {
: undefined, : 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. */ /** Only immutable repository sources call this. Runtime requests never compile documents. */
export async function compileQuery( export async function compileQuery(
@@ -104,7 +86,7 @@ export async function compileQuery(
definitions.push(...document.definitions); definitions.push(...document.definitions);
} }
const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions }; const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
const generated = querySchema(declaration, interfaces); const generated = querySchema(declaration, interfaces, document);
const operations = definitions.filter((entry) => entry.kind === "OperationDefinition"); const operations = definitions.filter((entry) => entry.kind === "OperationDefinition");
if ( if (
operations.length !== 1 || operations.length !== 1 ||
@@ -114,10 +96,12 @@ export async function compileQuery(
fail("QUERY_UNSUPPORTED_FEATURE", "Exactly one named query matching the declaration is required"); fail("QUERY_UNSUPPORTED_FEATURE", "Exactly one named query matching the declaration is required");
visit(document, { visit(document, {
Field(node) { Field(node) {
if (node.name.value === "_unavailable")
fail("QUERY_AGGREGATE_TYPE", "No supported field exists at this operator path", node);
if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node); if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node);
}, },
Directive(node) { Directive(node) {
if (!["include", "skip"].includes(node.name.value)) if (!["include", "skip", "live"].includes(node.name.value))
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node); fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
}, },
InlineFragment(node) { InlineFragment(node) {
@@ -130,6 +114,11 @@ export async function compileQuery(
fail("QUERY_VALIDATION", error.message, error.nodes?.[0]); fail("QUERY_VALIDATION", error.message, error.nodes?.[0]);
} }
const effects = new Map<string, QueryFieldEffect>(); const effects = new Map<string, QueryFieldEffect>();
const fragments = new Map(
definitions
.filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition")
.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) => {
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);
@@ -167,7 +156,13 @@ export async function compileQuery(
node, node,
); );
}; };
const relational = relationalCompiler(generated, declaration, mark, fragments);
const compiledPredicates = new WeakMap<ASTNode, import("./relational.js").QueryPredicate>();
const inputEffects = (id: InterfaceRevisionId, value: ValueNode, use: "predicate" | "order") => { const inputEffects = (id: InterfaceRevisionId, value: ValueNode, use: "predicate" | "order") => {
if (use === "predicate") {
relational.predicate(objectRow(id), value);
return;
}
if (value.kind === "Variable") { if (value.kind === "Variable") {
// A dynamic filter may name any field in its declared input type. // A dynamic filter may name any field in its declared input type.
for (const member of generated.contracts.get(id)!.members) for (const member of generated.contracts.get(id)!.members)
@@ -175,18 +170,25 @@ export async function compileQuery(
} else if (value.kind === "ListValue") value.values.forEach((child) => inputEffects(id, child, use)); } else if (value.kind === "ListValue") value.values.forEach((child) => inputEffects(id, child, use));
else if (value.kind === "ObjectValue") else if (value.kind === "ObjectValue")
for (const field of value.fields) { for (const field of value.fields) {
if (use === "predicate" && ["and", "or", "not"].includes(field.name.value)) inputEffects(id, field.value, use); mark(id, field.name.value, use, field);
else mark(id, field.name.value, use, field);
} }
}; };
const info = new TypeInfo(generated.schema); const info = new TypeInfo(generated.schema);
let hasContinuation = false;
visit( visit(
document, document,
visitWithTypeInfo(info, { visitWithTypeInfo(info, {
Field(node) { Field(node) {
const parent = info.getParentType(); const parent = info.getParentType();
const contract = parent && generated.byName.get(parent.name); const contract = parent && generated.byName.get(parent.name);
if (node.directives?.some((directive) => directive.name.value === "live")) {
const member = contract?.members.find((entry) => entry.displayName === node.name.value);
if (!member || member.kind !== "value" || member.queryRead?.execution !== "native")
fail(
"QUERY_LIVE_UNSUPPORTED",
"@live requires a direct native-queryable value field, not RPC enrichment or a synthetic result",
node,
);
}
if (!contract || node.name.value === "_qx") return; if (!contract || node.name.value === "_qx") return;
mark(contract.revisionId, node.name.value, "select", node); mark(contract.revisionId, node.name.value, "select", node);
const member = contract.members.find((entry) => entry.displayName === node.name.value)!; const member = contract.members.find((entry) => entry.displayName === node.name.value)!;
@@ -200,9 +202,9 @@ export async function compileQuery(
if (bounds[0]!.name.value === "all" && node.arguments?.some((argument) => argument.name.value === "after")) 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); fail("QUERY_UNSUPPORTED_FEATURE", "Bounded-all does not accept a continuation", node);
for (const argument of node.arguments ?? []) { for (const argument of node.arguments ?? []) {
if (argument.name.value === "where") inputEffects(target, argument.value, "predicate"); if (argument.name.value === "where")
compiledPredicates.set(node, relational.predicate(objectRow(target), argument.value));
if (argument.name.value === "orderBy") inputEffects(target, argument.value, "order"); if (argument.name.value === "orderBy") inputEffects(target, argument.value, "order");
if (argument.name.value === "after") hasContinuation = true;
if ( if (
["first", "all"].includes(argument.name.value) && ["first", "all"].includes(argument.name.value) &&
argument.value.kind !== "Variable" && argument.value.kind !== "Variable" &&
@@ -216,6 +218,33 @@ export async function compileQuery(
argument, argument,
); );
} }
if (node.arguments?.some((a) => a.name.value === "after")) {
const rpcOrder = (value: ValueNode): boolean =>
value.kind === "Variable"
? generated.contracts
.get(target)!
.members.some((m) => m.kind === "value" && m.queryRead?.execution === "rpc-permitted")
: value.kind === "ListValue"
? value.values.some(rpcOrder)
: value.kind === "ObjectValue" &&
value.fields.some((f) =>
generated.contracts
.get(target)!
.members.some(
(m) =>
m.displayName === f.name.value &&
m.kind === "value" &&
m.queryRead?.execution === "rpc-permitted",
),
);
const order = node.arguments.find((a) => a.name.value === "orderBy");
if (relational.needsRpc(compiledPredicates.get(node)) || (order && rpcOrder(order.value)))
fail(
"QUERY_UNSUPPORTED_FEATURE",
"RPC predicates/order require a bounded first/all window without continuation",
node,
);
}
}, },
FragmentSpread(node) { FragmentSpread(node) {
const fragment = definitions.find( const fragment = definitions.find(
@@ -226,18 +255,6 @@ export async function compileQuery(
}, },
}), }),
); );
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; let expandedFields = 0;
// GraphQL merges repeated response keys. In particular, fragments may each // GraphQL merges repeated response keys. In particular, fragments may each
// contribute different children of one relationship; last-write-wins would // contribute different children of one relationship; last-write-wins would
@@ -278,19 +295,12 @@ export async function compileQuery(
field.type, field.type,
selection.selectionSet, selection.selectionSet,
depth + 1, depth + 1,
conditional || !!selection.directives?.length, conditional || !!selection.directives?.some((entry) => entry.name.value !== "live"),
); );
if (selection.name.value === "_qx") { if (
const contract = generated.byName.get(type.name)!; (conditional || selection.directives?.some((entry) => entry.name.value !== "live")) &&
const metadataFields: Record<string, ValueType> = {}; fields[key]!.kind !== "optional"
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 ((conditional || selection.directives?.length) && fields[key]!.kind !== "optional")
fields[key] = valueType.optional(fields[key]!); fields[key] = valueType.optional(fields[key]!);
if (previous) fields[key] = mergeOutput(previous, fields[key]!); if (previous) fields[key] = mergeOutput(previous, fields[key]!);
} }
@@ -298,7 +308,10 @@ export async function compileQuery(
if (selections) add(selections, conditional); if (selections) add(selections, conditional);
return { kind: "record", fields }; return { kind: "record", fields };
} }
return shapeScalar(getNamedType(type)!.name); return (
generated.scalarTypes.get(getNamedType(type)!.name) ??
fail("QUERY_TYPE", `Unsupported query scalar ${getNamedType(type)!.name}`)
);
}; };
const variables: Record<string, ValueType> = {}; const variables: Record<string, ValueType> = {};
const inputShape = (type: GraphQLType, seen = new Set<string>()): ValueType => { const inputShape = (type: GraphQLType, seen = new Set<string>()): ValueType => {
@@ -324,7 +337,9 @@ export async function compileQuery(
), ),
}; };
} }
if (isScalarType(type) || isEnumType(type)) return shapeScalar(type.name); if (isEnumType(type)) return valueType.string;
if (isScalarType(type))
return generated.scalarTypes.get(type.name) ?? fail("QUERY_TYPE", `Unsupported query scalar ${type.name}`);
return fail("QUERY_VARIABLE_TYPE", "Unsupported variable type"); return fail("QUERY_VARIABLE_TYPE", "Unsupported variable type");
}; };
for (const variable of operations[0]!.variableDefinitions ?? []) for (const variable of operations[0]!.variableDefinitions ?? [])
@@ -351,7 +366,9 @@ export async function compileQuery(
} }
}; };
const conditions = (directives: readonly DirectiveNode[] = []) => const conditions = (directives: readonly DirectiveNode[] = []) =>
directives.map((directive) => ({ directives
.filter((directive) => directive.name.value !== "live")
.map((directive) => ({
include: directive.name.value === "include", include: directive.name.value === "include",
value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value), value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value),
})); }));
@@ -370,10 +387,30 @@ export async function compileQuery(
const contract = generated.byName.get(parent.name); const contract = generated.byName.get(parent.name);
const member = contract?.members.find((entry) => entry.displayName === node.name.value); const member = contract?.members.find((entry) => entry.displayName === node.name.value);
const type = getNamedType(parent.getFields()[node.name.value]!.type); const type = getNamedType(parent.getFields()[node.name.value]!.type);
const role = generated.relational.roles.get(parent.name);
const relationship =
role?.kind === "relations"
? generated.relational.relations(role.row).find((m) => m.displayName === node.name.value)
: undefined;
const plan =
relationship && role?.row.kind === "object" && node.selectionSet
? relational.plan(role.row.interfaceRevisionId, relationship, node.selectionSet, (set, name) => {
const t = generated.schema.getType(name);
if (!t || !isObjectType(t)) return fail("QUERY_TYPE", `Missing generated output type ${name}`, node);
return selection(set, t);
})
: undefined;
return [ return [
{ {
name: node.name.value, name: node.name.value,
key: node.alias?.value ?? node.name.value, key: node.alias?.value ?? node.name.value,
...(node.directives?.some((directive) => directive.name.value === "live")
? {
liveSelectionId: createHash("sha256")
.update(JSON.stringify([node.loc?.source.name, node.loc?.start, contract?.revisionId, member?.id]))
.digest("hex"),
}
: {}),
...(member && contract ? { interfaceRevisionId: contract.revisionId, memberId: member.id } : {}), ...(member && contract ? { interfaceRevisionId: contract.revisionId, memberId: member.id } : {}),
...(member?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}), ...(member?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}),
conditions: [...inherited, ...conditions(node.directives)], conditions: [...inherited, ...conditions(node.directives)],
@@ -381,15 +418,30 @@ export async function compileQuery(
(node.arguments ?? []).map((entry) => [entry.name.value, argument(entry.value)]), (node.arguments ?? []).map((entry) => [entry.name.value, argument(entry.value)]),
), ),
selection: node.selectionSet && isObjectType(type) ? selection(node.selectionSet, type) : [], selection: node.selectionSet && isObjectType(type) ? selection(node.selectionSet, type) : [],
...(plan ? { relational: plan } : {}),
...(compiledPredicates.has(node) ? { predicate: compiledPredicates.get(node)! } : {}),
}, },
]; ];
}); });
const normalized = print(document), const normalized = print(document),
schema = printSchema(generated.schema); schema = printSchema(generated.schema);
const selections = selection(operations[0]!.selectionSet, generated.schema.getQueryType()!);
const checkPresentation = (entries: QuerySelection[]) => {
for (const key of new Set(entries.map((entry) => entry.key))) {
const siblings = entries.filter((entry) => entry.key === key);
if (siblings.some((entry) => entry.liveSelectionId) && siblings.some((entry) => !entry.liveSelectionId))
throw new QueryCompileError(
"QUERY_LIVE_CONFLICT",
`${key} mixes live and plain selections; use separate aliases`,
);
checkPresentation(siblings.flatMap((entry) => entry.selection));
}
};
checkPresentation(selections);
const definitionDigest = createHash("sha256") const definitionDigest = createHash("sha256")
.update( .update(
canonicalJson({ canonicalJson({
semantics: 1, semantics: 2,
declaration, declaration,
normalized, normalized,
interfaces: [...generated.byName.values()].sort((a, b) => interfaces: [...generated.byName.values()].sort((a, b) =>
@@ -406,7 +458,7 @@ export async function compileQuery(
variables: { kind: "record", fields: variables }, variables: { kind: "record", fields: variables },
output: result, output: result,
effects: [...effects.values()], effects: [...effects.values()],
selection: selection(operations[0]!.selectionSet, generated.schema.getQueryType()!), selection: selections,
variableDefaults: Object.fromEntries( variableDefaults: Object.fromEntries(
(operations[0]!.variableDefinitions ?? []) (operations[0]!.variableDefinitions ?? [])
.filter((entry) => entry.defaultValue) .filter((entry) => entry.defaultValue)
+58
View File
@@ -0,0 +1,58 @@
import type { InterfaceRevision, ValueType } from "../capability-model/types.js";
import type { CheckedQuery, QuerySelection } from "./types.js";
/** Browser presentation is separate from the portable query value contract. */
export function queryPresentation(query: CheckedQuery, interfaces: readonly InterfaceRevision[]) {
const result: {
path: string[];
selectionId: string;
interfaceRevisionId: string;
getOperationId: string;
setOperationId?: string;
watchOperationId?: string;
inputKind: "value" | "fields";
valueType: ValueType;
conditions: (QuerySelection["conditions"][number] & { defaultValue?: boolean })[];
}[] = [];
const visit = (type: ValueType, selections: QuerySelection[], path: string[]) => {
if (type.kind === "optional") return visit(type.value, selections, path);
if (type.kind === "list") return visit(type.value, selections, [...path, "*"]);
if (type.kind !== "record") return;
for (const [name, field] of Object.entries(type.fields)) {
const matches = selections.filter((entry) => entry.key === name);
if (matches.some((entry) => entry.liveSelectionId) && matches.some((entry) => !entry.liveSelectionId))
throw new Error(`QUERY_LIVE_CONFLICT: ${[...path, name].join(".")} mixes live and plain selections`);
for (const entry of matches) {
if (entry.liveSelectionId) {
const iface = interfaces.find((iface) => iface.revisionId === entry.interfaceRevisionId)!;
const member = iface.members.find((member) => member.id === entry.memberId)!;
if (member.kind !== "value") throw new Error("QUERY_LIVE_UNSUPPORTED");
const get = member.operations.find((op) => op.displayName === "get")!;
const set = member.operations.find((op) => op.displayName === "set");
result.push({
path: [...path, name],
selectionId: entry.liveSelectionId,
interfaceRevisionId: iface.revisionId,
getOperationId: get.id,
setOperationId: set?.id,
watchOperationId: member.operations.find((op) => op.displayName === "watch-start")?.id,
inputKind: set?.inputType.kind === "record" ? "fields" : "value",
valueType: get.outputType,
conditions: entry.conditions.map((condition) => {
const fallback =
condition.value.kind === "variable" ? query.variableDefaults[condition.value.name] : undefined;
return {
...condition,
...(fallback?.kind === "literal" && typeof fallback.value === "boolean"
? { defaultValue: fallback.value }
: {}),
};
}),
});
} else visit(field, entry.selection, [...path, name]);
}
}
};
visit(query.output, query.selection, []);
return result;
}
+5
View File
@@ -10,6 +10,7 @@ import {
import type { LinkedQuery } from "./link.js"; import type { LinkedQuery } from "./link.js";
import type { QueryArgument, QuerySelection } from "./types.js"; import type { QueryArgument, QuerySelection } from "./types.js";
import type { WorkspaceRevision } from "../capability-model/types.js"; import type { WorkspaceRevision } from "../capability-model/types.js";
import { relationalWire } from "./relational-proto.js";
const argument = (entry: QueryArgument): WireArgument => { const argument = (entry: QueryArgument): WireArgument => {
switch (entry.kind) { switch (entry.kind) {
@@ -34,8 +35,12 @@ const selection = (entry: QuerySelection): import("../gen/camino/schema_pb.js").
conditions: entry.conditions.map((condition) => ({ include: condition.include, value: argument(condition.value) })), 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)])), arguments: Object.fromEntries(Object.entries(entry.arguments).map(([k, v]) => [k, argument(v)])),
selection: entry.selection.map(selection), selection: entry.selection.map(selection),
relational: entry.relational && relationalWire(argument, selection).plan(entry.relational),
predicate: entry.predicate && relationalWire(argument, selection).predicate(entry.predicate),
}); });
export const querySelectionToWire = selection;
export const queryRuntimePlan = (linked: LinkedQuery, workspace: WorkspaceRevision) => export const queryRuntimePlan = (linked: LinkedQuery, workspace: WorkspaceRevision) =>
toJson( toJson(
InstalledQuerySchema, InstalledQuerySchema,
+605
View File
@@ -0,0 +1,605 @@
import type { ASTNode, FieldNode, FragmentDefinitionNode, SelectionSetNode, ValueNode } from "graphql";
import {
valueType,
type InterfaceRevisionId,
type RelationshipInterfaceMember,
type ValueType,
} from "../capability-model/types.js";
import {
QueryCompileError,
type QueryArgument,
type QueryDeclaration,
type QuerySelection,
type QueryUse,
} from "./types.js";
import {
aggregateType,
queryKeyType,
type AggregateOperator,
type QueryAggregatePredicate,
type QueryEffectRecorder,
type QueryExpression,
type QueryKey,
type QueryPathStep,
type QueryPredicate,
type QueryReduction,
type QueryRelationalPlan,
type QueryRelationalTerminal,
type QueryRow,
} from "./relational.js";
import { objectRow } from "./relational-schema.js";
export function queryArgument(node: ValueNode): QueryArgument {
if (node.kind === "Variable") return { kind: "variable", name: node.name.value };
if (node.kind === "ListValue") return { kind: "list", values: node.values.map(queryArgument) };
if (node.kind === "ObjectValue")
return {
kind: "object",
fields: Object.fromEntries(node.fields.map((f) => [f.name.value, queryArgument(f.value)])),
};
return { kind: "literal", value: node.kind === "NullValue" ? null : node.value };
}
type Environment = ReturnType<typeof import("./schema.js").querySchema>;
type Availability = readonly QueryKey[] | undefined;
function 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 pairs = (node: ValueNode): readonly import("graphql").ObjectFieldNode[] =>
node.kind === "ObjectValue"
? node.fields
: fail("QUERY_UNSUPPORTED_FEATURE", "Query structure must be a literal object", node);
const ops = new Set(["eq", "in", "isNull", "lt", "lte", "gt", "gte"]);
const prefix = (a: readonly string[], b: readonly string[]) => a.every((part, i) => part === b[i]);
export function relationalCompiler(
env: Environment,
declaration: QueryDeclaration,
mark: QueryEffectRecorder,
fragments: Map<string, FragmentDefinitionNode>,
) {
const rowFields = env.relational.fields;
const relationships = env.relational.relations;
const needsRpc = (value: unknown): boolean => {
if (!value || typeof value !== "object") return false;
if ("leaf" in value) {
const leaf = (value as QueryExpression).leaf;
if (leaf.kind === "field")
return (
env.contracts
.get(leaf.interfaceRevisionId)
?.members.some(
(m) => m.id === leaf.memberId && m.kind === "value" && m.queryRead?.execution === "rpc-permitted",
) ?? false
);
}
return Object.values(value).some(needsRpc);
};
let stageCount = 0;
const fields = (set: SelectionSetNode): FieldNode[] =>
set.selections.flatMap((n) =>
n.kind === "Field" ? [n] : n.kind === "FragmentSpread" ? fields(fragments.get(n.name.value)!.selectionSet) : [],
);
const requireAvailable = (path: string[], available: Availability, node: ASTNode) => {
if (!available) return;
const ok = available.some((key) => {
if (key.path.join(".") === path.join(".")) return true;
const last = key.expression.leaf.kind;
if (last !== "ref" && last !== "entry") return false;
const base = key.path.slice(0, -2);
if (!prefix(base, path)) return false;
// Object identity does not determine which incoming membership carried it.
return (
last === "entry" || !(path[base.length] === "_qx" && ["entry", "mapKey"].includes(path[base.length + 1] ?? ""))
);
});
if (!ok)
fail(
"QUERY_DISTINCT_FIELD_UNAVAILABLE",
`${path.join(".")} was dropped by distinct; retain its identity or key explicitly`,
node,
);
};
const walk = (
root: QueryRow,
names: string[],
uses: QueryUse[],
node: ASTNode,
): { row: QueryRow; path: QueryPathStep[]; nullable: boolean } => {
let row = root,
nullable = false;
const path: QueryPathStep[] = [];
for (const name of names) {
if (row.kind === "pair") {
if (name !== "source" && name !== "target")
return fail("QUERY_FIELD_NOT_QUERYABLE", `Unknown row binding ${name}`, node);
path.push({ kind: name });
row = row[name];
continue;
}
const relation = relationships(row).find((m) => m.displayName === name);
if (!relation) return fail("QUERY_FIELD_NOT_QUERYABLE", `Expected queryable relationship ${name}`, node);
if (relation.cardinality === "many" || relation.cardinality === "many-unique")
return fail(
"QUERY_EXPANSION_REQUIRED",
`${name} is to-many; explicitly expand it, quantify it or aggregate it`,
node,
);
for (const use of uses) mark(row.interfaceRevisionId, name, use, node);
const target = env.target(relation),
optional = relation.cardinality === "optional-one";
path.push({
kind: "relation",
interfaceRevisionId: row.interfaceRevisionId,
memberId: relation.id,
targetInterfaceRevisionId: target,
optional,
});
nullable ||= optional;
row = objectRow(target);
}
if (path.length > declaration.budgets.depth) fail("QUERY_DEPTH_LIMIT", "Expression path exceeds query depth", node);
return { row, path, nullable };
};
const expression = (
root: QueryRow,
names: string[],
uses: QueryUse[],
node: ASTNode,
available?: Availability,
): QueryExpression => {
requireAvailable(names, available, node);
const meta = names.at(-2) === "_qx";
const { row, path, nullable } = walk(root, names.slice(0, meta ? -2 : -1), uses, node);
const name = names.at(-1)!;
let type: ValueType, leaf: QueryExpression["leaf"];
if (meta) {
if (name === "ref" && row.kind === "object") {
type = valueType.interfaceRef(row.interfaceRevisionId);
leaf = { kind: "ref", interfaceRevisionId: row.interfaceRevisionId };
} else if (name === "entry" && (row.kind === "pair" || row.membership)) {
type = valueType.string;
leaf = { kind: "entry" };
} else if (name === "mapKey" && row.kind === "object" && row.membership?.keyType) {
type =
row.membership.keyType === "boolean"
? valueType.bool
: row.membership.keyType === "int64"
? valueType.int64
: valueType.string;
leaf = { kind: "mapKey" };
} else return fail("QUERY_KEY_INVALID", `Unavailable metadata ${names.join(".")}`, node);
} else {
if (row.kind !== "object")
return fail("QUERY_FIELD_NOT_QUERYABLE", `Expected source or target, not ${name}`, node);
const field = rowFields(row).get(name);
if (!field || field.kind !== "leaf") {
if (relationships(row).some((m) => m.displayName === name))
fail("QUERY_EXPANSION_REQUIRED", `${name} is not a scalar operand`, node);
return fail("QUERY_FIELD_NOT_QUERYABLE", `Unknown queryable scalar ${name}`, node);
}
for (const use of uses) mark(row.interfaceRevisionId, name, use, node);
const member = env.contracts.get(row.interfaceRevisionId)!.members.find((m) => m.displayName === name)!;
type = field.type;
leaf = { kind: "field", interfaceRevisionId: row.interfaceRevisionId, memberId: member.id };
}
return { path, leaf, type: nullable && type.kind !== "optional" ? valueType.optional(type) : type };
};
const keyList = (row: QueryRow, node: ValueNode, use: "group" | "distinct", available?: Availability): QueryKey[] => {
const result: QueryKey[] = [];
const visit = (node: ValueNode, path: string[]) => {
if (node.kind === "BooleanValue") {
if (!node.value) fail("QUERY_KEY_INVALID", "Key selector leaves must be literal true", node);
const expr = expression(row, path, [use], node, available);
if (!queryKeyType(expr.type)) fail("QUERY_KEY_INVALID", `Unsupported key ${path.join(".")}`, node);
result.push({ path, expression: expr });
return;
}
for (const field of pairs(node)) visit(field.value, [...path, field.name.value]);
};
visit(node, []);
if (!result.length) fail("QUERY_KEY_INVALID", "Key selectors must not be empty", node);
return result.sort((a, b) => JSON.stringify(a.expression).localeCompare(JSON.stringify(b.expression)));
};
const comparisons = (
node: ValueNode,
make: (
operator: Extract<QueryPredicate, { kind: "compare" }>["operator"],
value: QueryArgument,
) => QueryPredicate | QueryAggregatePredicate,
) =>
pairs(node).map((field) => {
if (!ops.has(field.name.value))
return fail("QUERY_PREDICATE_INVALID", `Unsupported comparison ${field.name.value}`, field);
if (field.name.value === "in" && field.value.kind === "ListValue" && field.value.values.length > 1000)
fail("QUERY_WORK_LIMIT", "in supports at most 1000 operands", field);
return make(
field.name.value as Extract<QueryPredicate, { kind: "compare" }>["operator"],
queryArgument(field.value),
);
});
const reduction = (
row: QueryRow,
op: AggregateOperator,
path: string[],
node: ASTNode,
uses: QueryUse[],
available?: Availability,
): QueryReduction => {
if (op === "count") return { operator: op, type: aggregateType(op) };
const operand = expression(row, path, ["aggregate", ...uses], node, available);
return { operator: op, operand, type: aggregateType(op, operand.type) };
};
const aggregatePredicate = (
row: QueryRow,
node: ValueNode,
keys: QueryKey[] = [],
available?: Availability,
): QueryAggregatePredicate => {
const children: QueryAggregatePredicate[] = [];
for (const field of pairs(node)) {
const name = field.name.value;
if (name === "and" || name === "or") {
if (field.value.kind !== "ListValue")
fail("QUERY_UNSUPPORTED_FEATURE", "Boolean predicate structure is fixed in the document", field);
children.push({
kind: name,
children: field.value.values.map((v) => aggregatePredicate(row, v, keys, available)),
});
} else if (name === "not")
children.push({ kind: "not", child: aggregatePredicate(row, field.value, keys, available) });
else if (name === "count")
children.push(
...(comparisons(field.value, (operator, value) => ({
kind: "compare",
expression: reduction(row, "count", [], field, []),
operator,
value,
})) as QueryAggregatePredicate[]),
);
else {
const visit = (n: ValueNode, path: string[]) => {
const entries = pairs(n);
if (entries.some((f) => ops.has(f.name.value))) {
if (name === "group" && !keys.some((k) => k.path.join(".") === path.join(".")))
fail("QUERY_GROUP_FIELD_UNAVAILABLE", `${path.join(".")} is not a grouping key`, n);
const expr =
name === "group"
? expression(row, path, ["predicate"], n, available)
: reduction(row, name as AggregateOperator, path, n, ["predicate"], available);
children.push(
...(comparisons(n, (operator, value) => ({
kind: "compare",
expression: expr,
operator,
value,
})) as QueryAggregatePredicate[]),
);
} else for (const entry of entries) visit(entry.value, [...path, entry.name.value]);
};
visit(field.value, []);
}
}
return { kind: "and", children };
};
const predicate = (row: QueryRow, node: ValueNode, available?: Availability): QueryPredicate => {
const children: QueryPredicate[] = [];
const visit = (current: QueryRow, n: ValueNode, base: string[]) => {
for (const field of pairs(n)) {
const name = field.name.value;
if (name === "and" || name === "or") {
if (field.value.kind !== "ListValue")
fail("QUERY_UNSUPPORTED_FEATURE", "Boolean predicate structure is fixed in the document", field);
const branches = field.value.values.map((v) => {
const before = children.length;
visit(current, v, base);
return { kind: "and", children: children.splice(before) } as QueryPredicate;
});
children.push({ kind: name, children: branches });
} else if (name === "not") {
const before = children.length;
visit(current, field.value, base);
children.push({ kind: "not", child: { kind: "and", children: children.splice(before) } });
} else if (current.kind === "pair" && (name === "source" || name === "target"))
visit(current[name], field.value, [...base, name]);
else if (name === "_qx") {
for (const meta of pairs(field.value)) {
if (meta.name.value !== "relations") {
const expr = expression(row, [...base, "_qx", meta.name.value], ["predicate"], meta, available);
children.push(
...(comparisons(meta.value, (operator, value) => ({
kind: "compare",
expression: expr,
operator,
value,
})) as QueryPredicate[]),
);
continue;
}
for (const edge of pairs(meta.value)) {
if (current.kind !== "object")
fail("QUERY_CONTRACT", "A pair is not an object with graph relationships", edge);
const member = relationships(current).find((m) => m.displayName === edge.name.value)!;
requireAvailable([...base, member.displayName], available, edge);
mark(current.interfaceRevisionId, member.displayName, "predicate", edge);
const common = {
path: walk(row, base, ["predicate"], edge).path,
interfaceRevisionId: current.interfaceRevisionId,
memberId: member.id,
targetInterfaceRevisionId: env.target(member),
};
for (const op of pairs(edge.value)) {
if (op.name.value === "aggregate") {
const config = pairs(op.value),
where = config.find((f) => f.name.value === "where"),
having = config.find((f) => f.name.value === "having")!;
children.push({
kind: "reduce",
...common,
where: where ? predicate(env.relational.relationRow(member), where.value) : undefined,
having: aggregatePredicate(env.relational.relationRow(member), having.value),
});
} else if (op.name.value === "isNull")
children.push({ kind: "relation", ...common, operator: "isNull", value: queryArgument(op.value) });
else
children.push({
kind: "relation",
...common,
operator: op.name.value as "some" | "none" | "is",
predicate: predicate(env.relational.relationRow(member), op.value),
});
}
}
}
} else {
const expr = expression(row, [...base, name], ["predicate"], field, available);
children.push(
...(comparisons(field.value, (operator, value) => ({
kind: "compare",
expression: expr,
operator,
value,
})) as QueryPredicate[]),
);
}
}
};
visit(row, node, []);
return { kind: "and", children };
};
const plan = (
owner: InterfaceRevisionId,
member: RelationshipInterfaceMember,
set: SelectionSetNode,
selection: (set: SelectionSetNode, typeName: string) => QuerySelection[],
): QueryRelationalPlan => {
const result: QueryRelationalPlan = { stages: [], terminals: [] };
const add = (row: QueryRow, operation: QueryRelationalPlan["stages"][number]["operation"], input?: number) => {
if (++stageCount > 256) throw new QueryCompileError("QUERY_WORK_LIMIT", "Query exceeds 256 relational stages");
const id = result.stages.length;
result.stages.push({ id, row, operation, input });
return id;
};
const root = env.relational.relationRow(member);
const initial = add(root, {
kind: "source",
interfaceRevisionId: owner,
memberId: member.id,
targetInterfaceRevisionId: env.target(member),
});
mark(owner, member.displayName, "select", set);
const bound = (node: FieldNode) => {
const args = node.arguments ?? [],
bounds = args.filter((a) => a.name.value === "first" || a.name.value === "all");
if (bounds.length !== 1) fail("QUERY_ROW_LIMIT", "Specify exactly one first or all for groups", node);
if (bounds[0]!.name.value === "all" && args.some((a) => a.name.value === "after"))
fail("QUERY_UNSUPPORTED_FEATURE", "Bounded-all cannot continue", node);
for (const b of bounds)
if (
b.value.kind !== "Variable" &&
(b.value.kind !== "IntValue" || Number(b.value.value) < 1 || Number(b.value.value) > declaration.budgets.rows)
)
fail("QUERY_ROW_LIMIT", "Group page exceeds row budget", b);
};
const reductions = (
row: QueryRow,
set: SelectionSetNode,
available: Availability,
): QueryRelationalTerminal["reductions"] => {
const output: QueryRelationalTerminal["reductions"] = [];
const visit = (set: SelectionSetNode, path: string[], response: string[], op: AggregateOperator) => {
for (const n of fields(set)) {
const key = n.alias?.value ?? n.name.value,
next = [...path, n.name.value],
out = [...response, key];
if (n.selectionSet) visit(n.selectionSet, next, out, op);
else output.push({ path: out, reduction: reduction(row, op, next, n, [], available) });
}
};
for (const n of fields(set)) {
const op = n.name.value as AggregateOperator,
key = n.alias?.value ?? n.name.value;
if (op === "count") output.push({ path: [key], reduction: reduction(row, op, [], n, []) });
else if (n.selectionSet) visit(n.selectionSet, [], [key], op);
}
return output;
};
const groupsProjection = (row: QueryRow, set: SelectionSetNode, keys: QueryKey[], available: Availability) => {
let values: QueryRelationalTerminal["reductions"] = [];
for (const entries of fields(set))
if (entries.name.value === "entries" && entries.selectionSet)
for (const n of fields(entries.selectionSet)) {
if (n.name.value === "aggregate" && n.selectionSet)
values.push(
...reductions(row, n.selectionSet, available).map((v) => ({
...v,
path: [entries.alias?.value ?? entries.name.value, n.alias?.value ?? n.name.value, ...v.path],
})),
);
if (n.name.value === "group" && n.selectionSet) {
const visit = (set: SelectionSetNode, path: string[]) => {
for (const f of fields(set)) {
const next = [...path, f.name.value];
if (f.selectionSet) visit(f.selectionSet, next);
else if (!keys.some((k) => k.path.join(".") === next.join(".")))
fail("QUERY_GROUP_FIELD_UNAVAILABLE", `${next.join(".")} is not a selected group key`, f);
}
};
visit(n.selectionSet, []);
}
}
return values;
};
const run = (row: QueryRow, stage: number, set: SelectionSetNode, path: string[], available?: Availability) => {
for (const node of fields(set)) {
if (!node.selectionSet) continue;
if (node.directives?.length)
fail("QUERY_UNSUPPORTED_FEATURE", "Relational stages cannot be conditionally selected", node);
const name = node.name.value,
outputPath = [...path, node.alias?.value ?? name];
const args = new Map((node.arguments ?? []).map((a) => [a.name.value, a.value]));
if (name === "filter")
run(
row,
add(row, { kind: "filter", predicate: predicate(row, args.get("where")!, available) }, stage),
node.selectionSet,
outputPath,
available,
);
else if (name === "distinct") {
const keys = keyList(row, args.get("by")!, "distinct", available);
run(row, add(row, { kind: "distinct", keys }, stage), node.selectionSet, outputPath, keys);
} else if (name === "expand") {
const expand = (current: QueryRow, set: SelectionSetNode, memberPath: string[], output: string[]) => {
for (const n of fields(set)) {
if (!n.selectionSet) continue;
const member = relationships(current).find((m) => m.displayName === n.name.value);
const next = [...memberPath, n.name.value],
out = [...output, n.alias?.value ?? n.name.value];
if (member && (member.cardinality === "many" || member.cardinality === "many-unique")) {
requireAvailable(next, available, n);
if (current.kind !== "object") fail("QUERY_CONTRACT", "Expected object expansion source", n);
const walked = walk(row, memberPath, ["select"], n);
mark(current.interfaceRevisionId, member.displayName, "select", n);
const target = env.relational.relationRow(member),
pair: QueryRow = { kind: "pair", source: row, target };
const newAvailable = available
? [
...available.map((k) => ({
...k,
path: ["source", ...k.path],
expression: { ...k.expression, path: [{ kind: "source" as const }, ...k.expression.path] },
})),
{
path: ["target", "_qx", "entry"],
expression: {
path: [{ kind: "target" as const }],
leaf: { kind: "entry" as const },
type: valueType.string,
},
},
]
: undefined;
run(
pair,
add(
pair,
{
kind: "expand",
path: walked.path,
interfaceRevisionId: current.interfaceRevisionId,
memberId: member.id,
targetInterfaceRevisionId: env.target(member),
},
stage,
),
n.selectionSet,
out,
newAvailable,
);
} else {
const f = rowFields(current).get(n.name.value);
if (f?.kind === "row") expand(f.row, n.selectionSet, next, out);
else fail("QUERY_EXPANSION_REQUIRED", "Expected a declared expansion path", n);
}
}
};
expand(row, node.selectionSet, [], outputPath);
} else if (name === "aggregate" || name === "groups") {
if (name === "groups") bound(node);
const keys = name === "groups" ? keyList(row, args.get("by")!, "group", available) : [];
const terminal: QueryRelationalTerminal = {
stage,
path: outputPath,
kind: name,
keys,
reductions:
name === "aggregate"
? reductions(row, node.selectionSet, available)
: groupsProjection(row, node.selectionSet, keys, available),
order: [],
residual: false,
where: args.has("where") ? predicate(row, args.get("where")!, available) : undefined,
having: args.has("having") ? aggregatePredicate(row, args.get("having")!, keys, available) : undefined,
first: args.has("first") ? queryArgument(args.get("first")!) : undefined,
all: args.has("all") ? queryArgument(args.get("all")!) : undefined,
after: args.has("after") ? queryArgument(args.get("after")!) : undefined,
selection: selection(
node.selectionSet,
name === "aggregate"
? env.relational.rowset(row).getFields().aggregate!.type.toString().replace(/!$/u, "")
: env.relational.rowset(row).getFields().groups!.type.toString().replace(/!$/u, ""),
),
};
const order = args.get("orderBy");
if (order) {
if (order.kind !== "ListValue")
fail("QUERY_UNSUPPORTED_FEATURE", "Ordering is a fixed list of field directions", order);
for (const item of order.values) {
const start = terminal.order.length;
const visit = (n: ValueNode, path: string[]) => {
if (n.kind === "EnumValue") {
const [op, ...operand] = path;
let expr: QueryExpression | QueryReduction;
if (op === "group") {
if (!keys.some((k) => k.path.join(".") === operand.join(".")))
fail("QUERY_GROUP_FIELD_UNAVAILABLE", "Sort key was not grouped", n);
expr = expression(row, operand, ["order"], n, available);
} else expr = reduction(row, op as AggregateOperator, operand, n, ["order"], available);
terminal.order.push({ expression: expr, descending: n.value === "DESC" });
} else for (const f of pairs(n)) visit(f.value, [...path, f.name.value]);
};
visit(item, []);
if (terminal.order.length !== start + 1)
fail("QUERY_ORDER_INVALID", "Each orderBy element must name exactly one field", item);
}
}
result.terminals.push(terminal);
} else fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported relational stage ${name}`, node);
}
};
run(root, initial, set, []);
for (const terminal of result.terminals) {
let stage: QueryRelationalPlan["stages"][number] | undefined = result.stages[terminal.stage];
let rpc = needsRpc(terminal);
while (stage) {
rpc ||= needsRpc(stage.operation);
stage = stage.input === undefined ? undefined : result.stages[stage.input];
}
terminal.residual = rpc;
if (rpc && terminal.after)
fail(
"QUERY_UNSUPPORTED_FEATURE",
"RPC relational inputs require a bounded first/all window without continuation",
set,
);
}
return result;
};
return { expression, predicate, aggregatePredicate, keyList, plan, needsRpc };
}
+164
View File
@@ -0,0 +1,164 @@
import { create } from "@bufbuild/protobuf";
import * as wire from "../gen/camino/schema_pb.js";
import type { QueryArgument, QuerySelection } from "./types.js";
import type {
QueryAggregatePredicate,
QueryExpression,
QueryPathStep,
QueryPredicate,
QueryReduction,
QueryRelationalPlan,
QueryRow,
} from "./relational.js";
export function relationalWire(
argument: (v: QueryArgument) => wire.QueryArgument,
selection: (v: QuerySelection) => wire.QuerySelection,
) {
const path = (p: QueryPathStep): wire.QueryPathStep =>
create(wire.QueryPathStepSchema, {
step: p.kind === "relation" ? { case: "relation", value: p } : { case: p.kind, value: true },
});
const expression = (e: QueryExpression) =>
create(wire.QueryExpressionSchema, {
path: e.path.map(path),
valueTypeJson: JSON.stringify(e.type),
leaf:
e.leaf.kind === "field"
? { case: "field", value: e.leaf }
: e.leaf.kind === "ref"
? { case: "ref", value: e.leaf.interfaceRevisionId }
: { case: e.leaf.kind, value: true },
});
const reduction = (r: QueryReduction) =>
create(wire.QueryReductionSchema, {
operator: {
count: wire.QueryAggregateOperator.COUNT,
countPresent: wire.QueryAggregateOperator.COUNT_PRESENT,
sum: wire.QueryAggregateOperator.SUM,
avg: wire.QueryAggregateOperator.AVG,
min: wire.QueryAggregateOperator.MIN,
max: wire.QueryAggregateOperator.MAX,
}[r.operator],
operand: r.operand && expression(r.operand),
valueTypeJson: JSON.stringify(r.type),
});
const operand = (v: QueryExpression | QueryReduction) =>
create(wire.QueryOperandSchema, {
operand:
"operator" in v ? { case: "reduction", value: reduction(v) } : { case: "expression", value: expression(v) },
});
const predicate = (p: QueryPredicate | QueryAggregatePredicate): wire.QueryPredicate => {
switch (p.kind) {
case "and":
case "or":
return create(wire.QueryPredicateSchema, {
predicate: { case: p.kind, value: { children: p.children.map(predicate) } },
});
case "not":
return create(wire.QueryPredicateSchema, { predicate: { case: "not", value: predicate(p.child) } });
case "compare":
return create(wire.QueryPredicateSchema, {
predicate: {
case: "compare",
value: {
operand: operand(p.expression),
value: argument(p.value),
operator: {
eq: wire.QueryComparisonOperator.EQ,
in: wire.QueryComparisonOperator.IN,
isNull: wire.QueryComparisonOperator.IS_NULL,
lt: wire.QueryComparisonOperator.LT,
lte: wire.QueryComparisonOperator.LTE,
gt: wire.QueryComparisonOperator.GT,
gte: wire.QueryComparisonOperator.GTE,
}[p.operator],
},
},
});
case "relation":
return create(wire.QueryPredicateSchema, {
predicate: {
case: "relation",
value: {
path: p.path.map(path),
relation: p,
operator: {
some: wire.QueryRelationPredicateOperator.SOME,
none: wire.QueryRelationPredicateOperator.NONE,
is: wire.QueryRelationPredicateOperator.IS,
isNull: wire.QueryRelationPredicateOperator.IS_NULL,
}[p.operator],
predicate: p.predicate && predicate(p.predicate),
value: p.value && argument(p.value),
},
},
});
case "reduce":
return create(wire.QueryPredicateSchema, {
predicate: {
case: "reduce",
value: {
path: p.path.map(path),
relation: p,
where: p.where && predicate(p.where),
having: predicate(p.having),
},
},
});
}
};
const row = (r: QueryRow): wire.QueryRow =>
create(wire.QueryRowSchema, {
row:
r.kind === "object"
? {
case: "object",
value: {
interfaceRevisionId: r.interfaceRevisionId,
membership: !!r.membership,
keyType: r.membership?.keyType,
},
}
: { case: "pair", value: { source: row(r.source), target: row(r.target) } },
});
const plan = (p: QueryRelationalPlan) =>
create(wire.QueryRelationalPlanSchema, {
stages: p.stages.map((s) => {
const op = s.operation;
const operation: wire.QueryRelationalStage["operation"] =
op.kind === "source"
? { case: "source", value: create(wire.QueryRelationPathSchema, op) }
: op.kind === "filter"
? { case: "filter", value: predicate(op.predicate) }
: op.kind === "expand"
? {
case: "expand",
value: create(wire.QueryExpansionSchema, { path: op.path.map(path), relation: op }),
}
: {
case: "distinct",
value: create(wire.QueryDistinctSchema, {
keys: op.keys.map((k) => ({ ...k, expression: expression(k.expression) })),
}),
};
return create(wire.QueryRelationalStageSchema, { id: s.id, input: s.input, row: row(s.row), operation });
}),
terminals: p.terminals.map((t) => ({
stage: t.stage,
path: t.path,
groups: t.kind === "groups",
keys: t.keys.map((k) => ({ ...k, expression: expression(k.expression) })),
reductions: t.reductions.map((r) => ({ ...r, reduction: reduction(r.reduction) })),
where: t.where && predicate(t.where),
having: t.having && predicate(t.having),
order: t.order.map((o) => ({ operand: operand(o.expression), descending: o.descending })),
first: t.first && argument(t.first),
all: t.all && argument(t.all),
after: t.after && argument(t.after),
selection: t.selection.map(selection),
residual: t.residual,
})),
});
return { predicate, plan };
}
+497
View File
@@ -0,0 +1,497 @@
import {
GraphQLBoolean,
GraphQLString,
GraphQLInt,
GraphQLScalarType,
GraphQLObjectType,
GraphQLInputObjectType,
GraphQLList,
GraphQLNonNull,
type GraphQLInputType,
type GraphQLOutputType,
type GraphQLInputFieldConfigMap,
type GraphQLFieldConfigMap,
type GraphQLEnumType,
type DocumentNode,
type SelectionSetNode,
type FragmentDefinitionNode,
} from "graphql";
import { createHash } from "node:crypto";
import {
valueType,
type ValueType,
type InterfaceRevision,
type InterfaceRevisionId,
type RelationshipInterfaceMember,
} from "../capability-model/types.js";
import {
aggregateType,
aggregateOperators,
queryKeyType,
type QueryRow,
type AggregateOperator,
} from "./relational.js";
import { QueryCompileError } from "./types.js";
export const rowKey = (row: QueryRow): string => JSON.stringify(row);
export const objectRow = (
interfaceRevisionId: InterfaceRevisionId,
membership?: { keyType?: "string" | "boolean" | "int64" },
): QueryRow => ({ kind: "object", interfaceRevisionId, ...(membership ? { membership } : {}) });
export type SchemaRowField = { kind: "leaf"; type: ValueType } | { kind: "row"; row: QueryRow; optional: boolean };
export interface RelationalSchemaEnvironment {
contracts: Map<InterfaceRevisionId, InterfaceRevision>;
target(member: RelationshipInterfaceMember): InterfaceRevisionId;
name(id: InterfaceRevisionId): string;
scalar(type: ValueType): GraphQLInputType & GraphQLOutputType;
comparison(type: ValueType): GraphQLInputObjectType;
scalarTypes: Map<string, ValueType>;
direction: GraphQLEnumType;
cursor: GraphQLScalarType;
pageInfo: GraphQLObjectType;
}
export function relationalSchema(env: RelationalSchemaEnvironment, document?: DocumentNode) {
const outputCache = new Map<string, GraphQLObjectType>();
const inputCache = new Map<string, GraphQLInputObjectType>();
const referenceCache = new Map<string, GraphQLScalarType>();
const rowsets = new Map<string, QueryRow>();
const expansionPaths = new Map<string, Map<string, string[]>>();
const roles = new Map<
string,
{ kind: "rowset" | "aggregate" | "groups" | "keys" | "expand" | "relations"; row: QueryRow }
>();
const many = (member: RelationshipInterfaceMember) =>
member.cardinality === "many" || member.cardinality === "many-unique";
const relationRow = (member: RelationshipInterfaceMember) =>
objectRow(env.target(member), { keyType: member.keyType });
const relations = (row: QueryRow) =>
row.kind === "object"
? env.contracts
.get(row.interfaceRevisionId)!
.members.filter(
(member): member is RelationshipInterfaceMember => member.kind === "relationship" && !!member.queryRead,
)
: [];
const fields = (row: QueryRow): Map<string, SchemaRowField> => {
if (row.kind === "pair")
return new Map([
["source", { kind: "row", row: row.source, optional: false }],
["target", { kind: "row", row: row.target, optional: false }],
]);
return new Map(
env.contracts.get(row.interfaceRevisionId)!.members.flatMap((member): [string, SchemaRowField][] => {
if (member.kind === "operation" || !member.queryRead) return [];
if (member.kind === "value") return [[member.displayName, { kind: "leaf", type: member.valueType }]];
if (many(member)) return [];
return [
[
member.displayName,
{ kind: "row", row: objectRow(env.target(member)), optional: member.cardinality === "optional-one" },
],
];
}),
);
};
const refs = (id: InterfaceRevisionId) => {
let result = referenceCache.get(id);
if (!result) {
result = new GraphQLScalarType({
name: `QxRef_${env.name(id)}`,
parseValue: (value) => value,
parseLiteral() {
throw new Error("Managed references must be supplied as typed variables");
},
});
referenceCache.set(id, result);
env.scalarTypes.set(result.name, valueType.interfaceRef(id));
}
return result;
};
const scalar = (type: ValueType): GraphQLInputType & GraphQLOutputType => {
if (type.kind === "optional") return scalar(type.value);
return type.kind === "object-ref" && type.expectation.kind === "interface"
? refs(type.expectation.interfaceRevisionId)
: env.scalar(type);
};
const leafFields = (row: QueryRow) => {
const result = new Map<string, ValueType>();
if (row.kind === "object") result.set("ref", valueType.interfaceRef(row.interfaceRevisionId));
if (row.kind === "pair" || row.membership) result.set("entry", valueType.string);
if (row.kind === "object" && row.membership?.keyType)
result.set(
"mapKey",
row.membership.keyType === "boolean"
? valueType.bool
: row.membership.keyType === "int64"
? valueType.int64
: valueType.string,
);
return result;
};
const typeName = (role: string, row: QueryRow, suffix = "") =>
`Qx${role}_${createHash("sha256")
.update(rowKey(row) + suffix)
.digest("hex")
.slice(0, 16)}`;
const out = (role: string, row: QueryRow, make: () => GraphQLFieldConfigMap<unknown, unknown>, suffix = "") => {
const key = typeName(role, row, suffix);
let result = outputCache.get(key);
if (!result) {
result = new GraphQLObjectType({ name: key, fields: make });
outputCache.set(key, result);
}
return result;
};
const inp = (role: string, row: QueryRow, make: () => GraphQLInputFieldConfigMap, suffix = "") => {
const key = typeName(role, row, suffix);
let result = inputCache.get(key);
if (!result) {
result = new GraphQLInputObjectType({ name: key, fields: make });
inputCache.set(key, result);
}
return result;
};
const compare = (type: ValueType) => {
const base = type.kind === "optional" ? type.value : type;
if (base.kind !== "object-ref") return env.comparison(type);
return inp(
"RefCompare",
objectRow(
base.expectation.kind === "interface"
? base.expectation.interfaceRevisionId
: (() => {
throw new Error("Expected interface reference");
})(),
),
() => ({ eq: { type: scalar(type) }, in: { type: new GraphQLList(new GraphQLNonNull(scalar(type))) } }),
);
};
// Recursive interface graphs are interned. Synthetic pair graphs are finite and document-driven.
const columns = (
row: QueryRow,
operator?: Exclude<AggregateOperator, "count">,
nullable = false,
): GraphQLObjectType =>
out(
"Columns",
row,
() => {
const result: GraphQLFieldConfigMap<unknown, unknown> = {};
for (const [name, field] of fields(row)) {
if (field.kind === "row")
result[name] = { type: new GraphQLNonNull(columns(field.row, operator, nullable || field.optional)) };
else {
let type = field.type;
if (operator) {
try {
type = aggregateType(operator, type);
} catch {
continue;
}
} else if (!queryKeyType(type)) continue;
result[name] = {
type:
type.kind === "optional" || (!operator && nullable) ? scalar(type) : new GraphQLNonNull(scalar(type)),
};
}
}
const metadata: GraphQLFieldConfigMap<unknown, unknown> = {};
for (const [name, type] of leafFields(row)) {
if (operator && operator !== "countPresent") continue;
const t = scalar(operator ? aggregateType(operator, type) : type);
metadata[name] = { type: !operator && nullable ? t : new GraphQLNonNull(t) };
}
if (Object.keys(metadata).length)
result._qx = { type: new GraphQLNonNull(out("ColumnsMeta", row, () => metadata, `${operator}:${nullable}`)) };
// A type may have no matching scalar at this node; a private schema sentinel
// keeps it valid while compiler validation forbids selecting that sentinel.
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
return result;
},
`${operator}:${nullable}`,
);
const aggregate = (row: QueryRow): GraphQLObjectType => {
const result = out("Aggregate", row, () =>
Object.fromEntries(
aggregateOperators.map((op) => [
op,
{ type: new GraphQLNonNull(op === "count" ? env.scalar(valueType.uint64) : columns(row, op)) },
]),
),
);
roles.set(result.name, { kind: "aggregate", row });
return result;
};
const keys = (row: QueryRow): GraphQLInputObjectType =>
inp("Keys", row, () => {
const result: GraphQLInputFieldConfigMap = {};
for (const [name, field] of fields(row)) {
if (field.kind === "row") result[name] = { type: keys(field.row) };
else if (queryKeyType(field.type)) result[name] = { type: GraphQLBoolean };
}
const meta = Object.fromEntries([...leafFields(row)].map(([name]) => [name, { type: GraphQLBoolean }]));
if (Object.keys(meta).length) result._qx = { type: inp("KeyMeta", row, () => meta) };
return result;
});
const comparisons = (
row: QueryRow,
operator?: Exclude<AggregateOperator, "count">,
ordering = false,
): GraphQLInputObjectType =>
inp(
"Expressions",
row,
() => {
const result: GraphQLInputFieldConfigMap = {};
for (const [name, field] of fields(row)) {
if (field.kind === "row") result[name] = { type: comparisons(field.row, operator, ordering) };
else {
let type = field.type;
if (operator) {
try {
type = aggregateType(operator, type);
} catch {
continue;
}
}
result[name] = { type: ordering ? env.direction : compare(type) };
}
}
const meta: GraphQLInputFieldConfigMap = {};
for (const [name, type] of leafFields(row))
if (!operator || operator === "countPresent")
meta[name] = { type: ordering ? env.direction : compare(operator ? aggregateType(operator, type) : type) };
if (Object.keys(meta).length)
result._qx = { type: inp("ExpressionMeta", row, () => meta, `${operator}:${ordering}`) };
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
return result;
},
`${operator}:${ordering}`,
);
const having = (row: QueryRow, ordering = false): GraphQLInputObjectType =>
inp(
"Having",
row,
() => {
const result: GraphQLInputFieldConfigMap = { group: { type: comparisons(row, undefined, ordering) } };
for (const op of aggregateOperators)
result[op] = {
type:
op === "count" ? (ordering ? env.direction : compare(valueType.uint64)) : comparisons(row, op, ordering),
};
if (!ordering)
Object.assign(result, {
and: { type: new GraphQLList(new GraphQLNonNull(having(row))) },
or: { type: new GraphQLList(new GraphQLNonNull(having(row))) },
not: { type: having(row) },
});
return result;
},
String(ordering),
);
const where = (row: QueryRow): GraphQLInputObjectType =>
inp("Where", row, () => {
const result: GraphQLInputFieldConfigMap = {
and: { type: new GraphQLList(new GraphQLNonNull(where(row))) },
or: { type: new GraphQLList(new GraphQLNonNull(where(row))) },
not: { type: where(row) },
};
for (const [name, field] of fields(row)) {
if (row.kind === "object" && field.kind === "row") continue;
if (name in result) throw new QueryCompileError("QUERY_SCHEMA_NAME", `Reserved predicate name ${name}`);
result[name] = { type: field.kind === "row" ? where(field.row) : compare(field.type) };
}
result._qx = {
type: inp("WhereMeta", row, () => {
const meta: GraphQLInputFieldConfigMap = {};
for (const [name, type] of leafFields(row)) meta[name] = { type: compare(type) };
const edges = relations(row);
if (edges.length)
meta.relations = {
type: inp("WhereRelations", row, () =>
Object.fromEntries(
edges.map((member) => {
const target = relationRow(member);
return [
member.displayName,
{
type: inp(
"RelationPredicate",
target,
(): GraphQLInputFieldConfigMap =>
many(member)
? {
some: { type: where(target) },
none: { type: where(target) },
aggregate: {
type: inp("ReductionPredicate", target, () => ({
where: { type: where(target) },
having: { type: new GraphQLNonNull(having(target)) },
})),
},
}
: { is: { type: where(target) }, isNull: { type: GraphQLBoolean } },
rowKey(row) + member.id,
),
},
];
}),
),
),
};
return meta;
}),
};
return result;
});
const fragments = new Map(
(document?.definitions ?? [])
.filter((d): d is FragmentDefinitionNode => d.kind === "FragmentDefinition")
.map((d) => [d.name.value, d]),
);
let visited = 0;
const selections = (set: SelectionSetNode, active: ReadonlySet<string> = new Set()): import("graphql").FieldNode[] =>
set.selections.flatMap((node) => {
if (++visited > 10000)
throw new QueryCompileError("QUERY_WORK_LIMIT", "Query schema discovery exceeds 10000 nodes");
if (node.kind === "Field") return [node];
if (node.kind === "FragmentSpread") {
if (active.has(node.name.value) || active.size >= 128)
throw new QueryCompileError("QUERY_VALIDATION", "Cyclic or excessively nested fragments");
const fragment = fragments.get(node.name.value);
return fragment ? selections(fragment.selectionSet, new Set([...active, node.name.value])) : [];
}
return [];
});
const discoverExpansion = (root: QueryRow, current: QueryRow, set: SelectionSetNode, path: string[] = []) => {
for (const node of selections(set)) {
if (!node.selectionSet) continue;
const member = relations(current).find((m) => m.displayName === node.name.value);
const next = [...path, node.name.value];
if (member && many(member)) {
const map = expansionPaths.get(rowKey(root)) ?? new Map();
map.set(next.join("."), next);
expansionPaths.set(rowKey(root), map);
discoverRows({ kind: "pair", source: root, target: relationRow(member) }, node.selectionSet);
} else {
const field = fields(current).get(node.name.value);
if (field?.kind === "row") discoverExpansion(root, field.row, node.selectionSet, next);
}
}
};
const discoverRows = (row: QueryRow, set: SelectionSetNode) => {
rowsets.set(rowKey(row), row);
if (rowsets.size > 256) throw new QueryCompileError("QUERY_WORK_LIMIT", "Query exceeds 256 row shapes");
for (const node of selections(set))
if (node.selectionSet) {
if (node.name.value === "expand") discoverExpansion(row, row, node.selectionSet);
else if (node.name.value === "filter" || node.name.value === "distinct") discoverRows(row, node.selectionSet);
}
};
const discoverObject = (row: QueryRow, set: SelectionSetNode) => {
for (const node of selections(set))
if (node.selectionSet) {
if (node.name.value === "_qx") {
for (const meta of selections(node.selectionSet))
if (meta.name.value === "relations" && meta.selectionSet)
for (const edge of selections(meta.selectionSet)) {
const member = relations(row).find((m) => m.displayName === edge.name.value);
if (member && many(member) && edge.selectionSet) discoverRows(relationRow(member), edge.selectionSet);
}
} else {
const member = relations(row).find((m) => m.displayName === node.name.value);
if (member) {
if (!many(member)) discoverObject(objectRow(env.target(member)), node.selectionSet);
else
for (const entries of selections(node.selectionSet))
if (entries.name.value === "entries" && entries.selectionSet)
for (const child of selections(entries.selectionSet))
if (child.name.value === "node" && child.selectionSet)
discoverObject(objectRow(env.target(member)), child.selectionSet);
}
}
}
};
const discover = (root: InterfaceRevisionId) => {
for (const operation of document?.definitions ?? [])
if (operation.kind === "OperationDefinition")
for (const node of selections(operation.selectionSet))
if (node.name.value === "root" && node.selectionSet) discoverObject(objectRow(root), node.selectionSet);
};
const expand = (root: QueryRow, current: QueryRow, path: string[] = []): GraphQLObjectType =>
out(
"Expand",
current,
() => {
const result: GraphQLFieldConfigMap<unknown, unknown> = {};
const paths = [...(expansionPaths.get(rowKey(root))?.values() ?? [])].filter((p) =>
path.every((part, i) => p[i] === part),
);
for (const name of new Set(paths.map((p) => p[path.length]).filter((v): v is string => !!v))) {
const member = relations(current).find((m) => m.displayName === name);
if (member && many(member))
result[name] = {
type: new GraphQLNonNull(rowset({ kind: "pair", source: root, target: relationRow(member) })),
};
else {
const field = fields(current).get(name);
if (field?.kind === "row")
result[name] = { type: new GraphQLNonNull(expand(root, field.row, [...path, name])) };
}
}
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
return result;
},
rowKey(root) + JSON.stringify(path),
);
const rowset = (row: QueryRow): GraphQLObjectType => {
const result = out("Rows", row, () => {
const entry = out("GroupEntry", row, () => ({
key: { type: new GraphQLNonNull(GraphQLString) },
cursor: { type: env.cursor },
group: { type: new GraphQLNonNull(columns(row)) },
aggregate: { type: new GraphQLNonNull(aggregate(row)) },
}));
const connection = out("Groups", row, () => ({
entries: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(entry))) },
pageInfo: { type: new GraphQLNonNull(env.pageInfo) },
}));
roles.set(connection.name, { kind: "groups", row });
return {
filter: { type: new GraphQLNonNull(rowset(row)), args: { where: { type: new GraphQLNonNull(where(row)) } } },
distinct: { type: new GraphQLNonNull(rowset(row)), args: { by: { type: new GraphQLNonNull(keys(row)) } } },
expand: { type: new GraphQLNonNull(expand(row, row)) },
aggregate: { type: new GraphQLNonNull(aggregate(row)), args: { where: { type: where(row) } } },
groups: {
type: new GraphQLNonNull(connection),
args: {
by: { type: new GraphQLNonNull(keys(row)) },
where: { type: where(row) },
having: { type: having(row) },
orderBy: { type: new GraphQLList(new GraphQLNonNull(having(row, true))) },
first: { type: GraphQLInt },
all: { type: GraphQLInt },
after: { type: env.cursor },
},
},
};
});
roles.set(result.name, { kind: "rowset", row });
return result;
};
const helpers = (id: InterfaceRevisionId): GraphQLObjectType | undefined => {
const row = objectRow(id),
edges = relations(row).filter(many);
if (!edges.length) return undefined;
const result = out("Relations", row, () =>
Object.fromEntries(
edges.map((member) => [member.displayName, { type: new GraphQLNonNull(rowset(relationRow(member))) }]),
),
);
roles.set(result.name, { kind: "relations", row });
return result;
};
return { refs, fields, relations, relationRow, where, rowset, helpers, roles, discover, scalar, compare };
}
+149
View File
@@ -0,0 +1,149 @@
import type { InterfaceRevisionId, MemberId, ValueType } from "../capability-model/types.js";
import { valueType } from "../capability-model/types.js";
import { QueryCompileError, type QueryArgument, type QueryUse } from "./types.js";
/** A row is an input membership, not a newly allocated Camino object. */
export type QueryRow =
| {
kind: "object";
interfaceRevisionId: InterfaceRevisionId;
membership?: { keyType?: "string" | "boolean" | "int64" };
}
| { kind: "pair"; source: QueryRow; target: QueryRow };
export type AggregateOperator = "count" | "countPresent" | "sum" | "avg" | "min" | "max";
export const aggregateOperators: readonly AggregateOperator[] = ["count", "countPresent", "sum", "avg", "min", "max"];
export function aggregateType(operator: AggregateOperator, input?: ValueType): ValueType {
if (operator === "count") return valueType.uint64;
const base = input?.kind === "optional" ? input.value : input;
if (operator === "countPresent" && (base?.kind === "scalar" || base?.kind === "object-ref")) return valueType.uint64;
if (base?.kind !== "scalar")
throw new QueryCompileError("QUERY_AGGREGATE_TYPE", `${operator} requires a supported scalar operand`);
const numeric = ["int32", "uint32", "int64", "uint64", "double"].includes(base.name);
if ((operator === "min" || operator === "max") && (numeric || base.name === "string"))
return valueType.optional(base);
if (numeric && operator === "avg") return valueType.optional(valueType.double);
if (numeric && operator === "sum")
return valueType.optional(
base.name === "double" ? valueType.double : base.name.startsWith("u") ? valueType.uint64 : valueType.int64,
);
throw new QueryCompileError("QUERY_AGGREGATE_TYPE", `${operator} does not accept ${base.name}`);
}
export function queryKeyType(type: ValueType): boolean {
if (type.kind === "optional") return queryKeyType(type.value);
return type.kind === "object-ref" || (type.kind === "scalar" && type.name !== "bytes");
}
/** Paths are resolved once by the compiler. Runtime never resolves authored names. */
export type QueryPathStep =
| { kind: "source" | "target" }
| {
kind: "relation";
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
targetInterfaceRevisionId: InterfaceRevisionId;
optional: boolean;
};
export interface QueryExpression {
path: QueryPathStep[];
leaf:
| { kind: "field"; interfaceRevisionId: InterfaceRevisionId; memberId: MemberId }
| { kind: "ref"; interfaceRevisionId: InterfaceRevisionId }
| { kind: "entry" | "mapKey" };
type: ValueType;
}
export interface QueryReduction {
operator: AggregateOperator;
operand?: QueryExpression;
type: ValueType;
}
export type QueryPredicate =
| { kind: "and" | "or"; children: QueryPredicate[] }
| { kind: "not"; child: QueryPredicate }
| {
kind: "compare";
expression: QueryExpression;
operator: "eq" | "in" | "isNull" | "lt" | "lte" | "gt" | "gte";
value: QueryArgument;
}
| {
kind: "relation";
path: QueryPathStep[];
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
targetInterfaceRevisionId: InterfaceRevisionId;
operator: "some" | "none" | "is" | "isNull";
predicate?: QueryPredicate;
value?: QueryArgument;
}
| {
kind: "reduce";
path: QueryPathStep[];
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
targetInterfaceRevisionId: InterfaceRevisionId;
where?: QueryPredicate;
having: QueryAggregatePredicate;
};
export type QueryAggregatePredicate =
| { kind: "and" | "or"; children: QueryAggregatePredicate[] }
| { kind: "not"; child: QueryAggregatePredicate }
| {
kind: "compare";
expression: QueryExpression | QueryReduction;
operator: "eq" | "in" | "isNull" | "lt" | "lte" | "gt" | "gte";
value: QueryArgument;
};
export interface QueryKey {
path: string[];
expression: QueryExpression;
}
export interface QueryRelationalStage {
id: number;
input?: number;
row: QueryRow;
operation:
| {
kind: "source";
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
targetInterfaceRevisionId: InterfaceRevisionId;
}
| { kind: "filter"; predicate: QueryPredicate }
| {
kind: "expand";
path: QueryPathStep[];
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
targetInterfaceRevisionId: InterfaceRevisionId;
}
| { kind: "distinct"; keys: QueryKey[] };
}
export interface QueryRelationalTerminal {
stage: number;
path: string[];
kind: "aggregate" | "groups";
keys: QueryKey[];
reductions: { path: string[]; reduction: QueryReduction }[];
where?: QueryPredicate;
having?: QueryAggregatePredicate;
order: { expression: QueryExpression | QueryReduction; descending: boolean }[];
first?: QueryArgument;
all?: QueryArgument;
after?: QueryArgument;
/** Projection tree remains separate from the operator plan. */
selection: import("./types.js").QuerySelection[];
residual: boolean;
}
export interface QueryRelationalPlan {
stages: QueryRelationalStage[];
terminals: QueryRelationalTerminal[];
}
export type QueryEffectRecorder = (
id: InterfaceRevisionId,
name: string,
use: QueryUse,
node: import("graphql").ASTNode,
) => void;
+32 -31
View File
@@ -10,10 +10,14 @@ import {
GraphQLList, GraphQLList,
GraphQLNonNull, GraphQLNonNull,
GraphQLSchema, GraphQLSchema,
GraphQLDirective,
DirectiveLocation,
specifiedDirectives,
type GraphQLOutputType, type GraphQLOutputType,
type GraphQLInputType, type GraphQLInputType,
type GraphQLFieldConfigMap, type GraphQLFieldConfigMap,
type GraphQLInputFieldConfigMap, type GraphQLInputFieldConfigMap,
type DocumentNode,
} from "graphql"; } from "graphql";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { import type {
@@ -23,6 +27,7 @@ import type {
RelationshipInterfaceMember, RelationshipInterfaceMember,
} 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";
// 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 {
@@ -67,19 +72,21 @@ export const cursorScalar = new GraphQLScalarType({
return value; return value;
}, },
}); });
export const referenceScalar = new GraphQLScalarType({ name: "ManagedReference" });
export function querySchema(declaration: QueryDeclaration, interfaces: readonly InterfaceRevision[]) { export function querySchema(
declaration: QueryDeclaration,
interfaces: readonly InterfaceRevision[],
document?: DocumentNode,
) {
const contracts = new Map(interfaces.map((entry) => [entry.revisionId, entry])); const contracts = new Map(interfaces.map((entry) => [entry.revisionId, entry]));
const objects = new Map<InterfaceRevisionId, GraphQLObjectType>(); const objects = new Map<InterfaceRevisionId, GraphQLObjectType>();
const filters = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>(); const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
const byName = new Map<string, InterfaceRevision>(); const byName = new Map<string, InterfaceRevision>();
const comparisons = new Map<string, GraphQLInputObjectType>(); const comparisons = new Map<string, GraphQLInputObjectType>();
const metadata = new GraphQLObjectType({ const scalarTypes = new Map<string, ValueType>(
name: "QxMetadata", Object.entries(queryScalars).map(([name, type]) => [type.name, { kind: "scalar", name } as ValueType]),
fields: { ref: { type: new GraphQLNonNull(referenceScalar) } }, );
}); scalarTypes.set("Cursor", { kind: "scalar", name: "string" });
const pageInfo = new GraphQLObjectType({ const pageInfo = new GraphQLObjectType({
name: "QxPageInfo", name: "QxPageInfo",
fields: { fields: {
@@ -130,6 +137,8 @@ export function querySchema(declaration: QueryDeclaration, interfaces: readonly
let result = comparisons.get(key); let result = comparisons.get(key);
if (!result) { if (!result) {
const fields: GraphQLInputFieldConfigMap = { eq: { type: value }, isNull: { type: GraphQLBoolean } }; const fields: GraphQLInputFieldConfigMap = { eq: { type: value }, isNull: { type: GraphQLBoolean } };
if (base.kind === "scalar" && base.name !== "bytes")
fields.in = { type: new GraphQLList(new GraphQLNonNull(value)) };
if (base.kind === "scalar" && base.name !== "bool" && base.name !== "bytes") if (base.kind === "scalar" && base.name !== "bool" && base.name !== "bytes")
for (const op of ["lt", "lte", "gt", "gte"]) fields[op] = { type: value }; for (const op of ["lt", "lte", "gt", "gte"]) fields[op] = { type: value };
result = new GraphQLInputObjectType({ name: `QxCompare${key}`, fields }); result = new GraphQLInputObjectType({ name: `QxCompare${key}`, fields });
@@ -137,29 +146,12 @@ export function querySchema(declaration: QueryDeclaration, interfaces: readonly
} }
return result; return result;
}; };
const filter = (id: InterfaceRevisionId): GraphQLInputObjectType => { const relational = relationalSchema(
const existing = filters.get(id); { contracts, target, name, scalar, comparison, scalarTypes, direction, cursor: cursorScalar, pageInfo },
if (existing) return existing; document,
const result = new GraphQLInputObjectType({ );
name: `${name(id)}Where`, relational.discover(declaration.root);
fields: () => { const filter = (id: InterfaceRevisionId) => relational.where(objectRow(id));
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) => { const order = (id: InterfaceRevisionId) => {
let result = orders.get(id); let result = orders.get(id);
if (!result) { if (!result) {
@@ -181,6 +173,14 @@ export function querySchema(declaration: QueryDeclaration, interfaces: readonly
const result = new GraphQLObjectType({ const result = new GraphQLObjectType({
name: name(id), name: name(id),
fields: () => { fields: () => {
const helpers = relational.helpers(id);
const metadata = new GraphQLObjectType({
name: `QxMetadata_${name(id)}`,
fields: {
ref: { type: new GraphQLNonNull(relational.refs(id)) },
...(helpers ? { relations: { type: new GraphQLNonNull(helpers) } } : {}),
},
});
const fields: GraphQLFieldConfigMap<unknown, unknown> = { _qx: { type: new GraphQLNonNull(metadata) } }; const fields: GraphQLFieldConfigMap<unknown, unknown> = { _qx: { type: new GraphQLNonNull(metadata) } };
for (const member of contract(id).members) { for (const member of contract(id).members) {
if (member.kind === "operation" || !member.queryRead) continue; if (member.kind === "operation" || !member.queryRead) continue;
@@ -244,10 +244,11 @@ export function querySchema(declaration: QueryDeclaration, interfaces: readonly
return result; return result;
}; };
const schema = new GraphQLSchema({ const schema = new GraphQLSchema({
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(declaration.root)) } },
}), }),
}); });
return { schema, byName, target, contracts }; return { schema, byName, target, contracts, relational, scalarTypes };
} }
+4 -1
View File
@@ -6,7 +6,7 @@ import type {
ClosedTypeArgument, ClosedTypeArgument,
} from "../capability-model/generics.js"; } from "../capability-model/generics.js";
export type QueryUse = "select" | "predicate" | "order"; export type QueryUse = "select" | "predicate" | "order" | "aggregate" | "group" | "distinct";
export interface QueryAllowance { export interface QueryAllowance {
interfaceRevisionId: InterfaceRevisionId; interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId; memberId: MemberId;
@@ -98,6 +98,7 @@ export type QueryArgument =
| { kind: "list"; values: QueryArgument[] } | { kind: "list"; values: QueryArgument[] }
| { kind: "object"; fields: Record<string, QueryArgument> }; | { kind: "object"; fields: Record<string, QueryArgument> };
export interface QuerySelection { export interface QuerySelection {
liveSelectionId?: string;
name: string; name: string;
key: string; key: string;
interfaceRevisionId?: InterfaceRevisionId; interfaceRevisionId?: InterfaceRevisionId;
@@ -106,4 +107,6 @@ export interface QuerySelection {
conditions: { include: boolean; value: QueryArgument }[]; conditions: { include: boolean; value: QueryArgument }[];
arguments: Record<string, QueryArgument>; arguments: Record<string, QueryArgument>;
selection: QuerySelection[]; selection: QuerySelection[];
relational?: import("./relational.js").QueryRelationalPlan;
predicate?: import("./relational.js").QueryPredicate;
} }
+71
View File
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { compileCapabilityResourceSource } from "../../src/capability-language/index.js";
import type { InterfaceRevision } from "../../src/capability-model/types.js";
import { compileQuery } from "../../src/query/compile.js";
const source = { repository: "https://example.test/aggregation.git", commit: "a".repeat(40) };
const interfaces: InterfaceRevision[] = [];
function resource(text: string) {
const result = compileCapabilityResourceSource(`external atom TaskObject id "task";\n${text}`, {
source,
environment: {
interfaces: new Map(interfaces.map((i) => [i.displayName, i])),
interfaceClosure: interfaces,
},
});
assert.ok(result.ok, JSON.stringify(result.diagnostics));
if (result.resource.kind === "interface") interfaces.push(result.resource.revision);
return result.resource;
}
resource(`interface Person id "person" revision "person@1" {
queryable value name id "name" : string { get id "name:get"; }
}`);
resource(`interface Tag id "tag" revision "tag@1" {
queryable value color id "color" : string { get id "color:get"; }
queryable relation tasks id "tag-tasks" : many atom TaskObject { resolve id "tag-tasks:resolve"; }
}`);
resource(`import interface Tag; import interface Person;
interface Task id "task" revision "task@1" {
queryable value estimatedHours id "hours" : optional<double> { get id "hours:get"; }
queryable value cost id "cost" : int64 { get id "cost:get"; }
queryable rpc value score id "score" : optional<double> { get id "score:get"; }
queryable relation tags id "tags" : many-unique interface Tag { resolve id "tags:resolve"; }
queryable relation assignee id "assignee" : optional-one interface Person { resolve id "assignee:resolve"; }
}`);
resource(`import interface Task;
interface Tasks id "tasks" revision "tasks@1" {
queryable relation tasks id "tasks" : many-unique interface Task { resolve id "tasks:resolve"; }
queryable relation copies id "copies" : many interface Task ordered { resolve id "copies:resolve"; }
queryable relation slots id "slots" : many interface Task keyed "int64" { resolve id "slots:resolve"; }
}`);
const pkg = resource(`import interface Tasks; import interface Task;
package Reports id "reports" revision "reports@1" {
query Report id "report" root Tasks document "report.graphql" operation "Report" {
max rows 50;
max result-bytes 1048576;
max rpc-calls 100;
max deadline-ms 10000;
view TaskObject as Task;
allow Task.score aggregate "Bounded aggregation fixture";
allow Task.score predicate "Bounded aggregation fixture";
allow Task.score order "Bounded aggregation fixture";
allow Task.score group "Bounded aggregation fixture";
allow Task.score distinct "Bounded aggregation fixture";
}
}`);
if (pkg.kind !== "package") throw new Error("package expected");
const declaration = pkg.revision.queries![0]!;
export const compileAggregationDocument = (document: string) =>
compileQuery(declaration, interfaces, async () => document);
export const aggregationBindingSchema = (checked: Awaited<ReturnType<typeof compileQuery>>) => ({
format: "quixos-bindings" as const,
version: 1 as const,
interfaces,
packages: [{ ...pkg.revision, checkedQueries: [checked] }],
});
export const compileAggregation = (body: string, variables = "") =>
compileQuery(
declaration,
interfaces,
async () => `query Report${variables} { root { _qx { relations { tasks { ${body} } } } } }`,
);
+22 -4
View File
@@ -8,7 +8,7 @@ export const queryFixtureSource = {
repository: "https://query-fixture.example.test/source.git", repository: "https://query-fixture.example.test/source.git",
commit: "1".repeat(40), commit: "1".repeat(40),
}; };
export async function queryWorkspaceFixture() { export async function queryWorkspaceFixture(options: { live?: boolean } = {}) {
const interfaces = new Map<string, InterfaceRevision>(); const interfaces = new Map<string, InterfaceRevision>();
const sources: Record<string, string> = {}; const sources: Record<string, string> = {};
function iface(name: string, members: string, parameters = "") { function iface(name: string, members: string, parameters = "") {
@@ -29,7 +29,7 @@ export async function queryWorkspaceFixture() {
` `
queryable value title id "title" : string {get id "title:get"; set id "title:set";} queryable value title id "title" : string {get id "title:get"; set id "title:set";}
queryable value done id "done" : bool {get id "done:get";} queryable value done id "done" : bool {get id "done:get";}
queryable value rank id "rank" : int64 {get id "rank:get";} queryable value rank id "rank" : int64 {get id "rank:get"; set id "rank:set";}
queryable relation assignee id "assignee" : optional-one interface PersonFacts {resolve id "assignee:resolve";} queryable relation assignee id "assignee" : optional-one interface PersonFacts {resolve id "assignee:resolve";}
queryable rpc value score id "score" : int32 {get id "score:get";} queryable rpc value score id "score" : int32 {get id "score:get";}
`, `,
@@ -55,6 +55,13 @@ export async function queryWorkspaceFixture() {
allow TaskFacts.score predicate "Bounded local collection"; allow TaskFacts.score predicate "Bounded local collection";
allow TaskFacts.score order "Bounded local collection"; allow TaskFacts.score order "Bounded local collection";
} }
query Totals id "totals" root Collection<interface TaskFacts> document "totals.graphql" operation "Totals" {
max rows 30; watch;
}
query ScoreTotals id "score-totals" root Collection<interface TaskFacts> document "score-totals.graphql" operation "ScoreTotals" {
max rows 30; max candidates 60;
allow TaskFacts.score aggregate "Complete bounded collection report";
}
}`; }`;
sources.Queries = packageSource; sources.Queries = packageSource;
const pkgResult = compileCapabilityResourceSource(packageSource, { const pkgResult = compileCapabilityResourceSource(packageSource, {
@@ -70,9 +77,20 @@ export async function queryWorkspaceFixture() {
"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}}}}}`,
"ranked.graphql": `query Ranked {root {items(first: 3, where: {score: {gt: 0}}, orderBy: [{score: DESC}]) {entries {key node {_qx {ref} title}}}}}`, "ranked.graphql": `query Ranked {root {items(first: 3, where: {score: {gt: 0}}, orderBy: [{score: DESC}]) {entries {key node {_qx {ref} title}}}}}`,
"totals.graphql": `query Totals {root {_qx {relations {items {aggregate {count sum {rank}} groups(by: {done: true}, first: 10) {entries {group {done} aggregate {count sum {rank}}}}}}}}}`,
"score-totals.graphql": `query ScoreTotals {root {_qx {relations {items {aggregate {count sum {score}}}}}}}`,
}; };
pkg.checkedQueries = await Promise.all( pkg.checkedQueries = await Promise.all(
pkg.queries!.map((query) => compileQuery(query, allInterfaces, async (name) => documents[name]!)), pkg.queries!.map((query) =>
compileQuery(query, allInterfaces, async (name) =>
options.live
? documents[name]!.replace(/\btitle\b/g, "title @live").replace(
"title @live rank done",
"title @live rank @live done @live",
)
: documents[name]!,
),
),
); );
const implementation = (atom: string) => `conform ${atom} as TaskFacts id "${atom}-facts" { const implementation = (atom: string) => `conform ${atom} as TaskFacts id "${atom}-facts" {
private state Title${atom} id "${atom}:title" on ${atom} : string policy crdt(string) default "Untitled"; private state Title${atom} id "${atom}:title" on ${atom} : string policy crdt(string) default "Untitled";
@@ -83,7 +101,7 @@ export async function queryWorkspaceFixture() {
interface PersonFacts projection tasks id "${atom}:assignee:inverse" many; interface PersonFacts projection tasks id "${atom}:assignee:inverse" many;
} }
bind title.get to state Title${atom}.read; bind title.get to state Title${atom}.read;
bind title.set to package Queries.titleSet with {title to state Title${atom};}; ${options.live ? `bind title.set to state Title${atom}.write;` : `bind title.set to package Queries.titleSet with {title to state Title${atom};};`}
bind done to state Done${atom}; bind rank to state Rank${atom}; bind done to state Done${atom}; bind rank to state Rank${atom};
bind assignee.resolve to edge Assignee${atom}.assignee.resolve; bind assignee.resolve to edge Assignee${atom}.assignee.resolve;
bind score.get to package Queries.score query-reason "Explicit bounded score computation"; bind score.get to package Queries.score query-reason "Explicit bounded score computation";
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { compileAggregation as compile, compileAggregationDocument } from "./fixtures/query-aggregation.js";
import type { QuerySelection } from "../src/query/types.js";
import type { QueryRelationalPlan } from "../src/query/relational.js";
function findPlan(selections: QuerySelection[]): QueryRelationalPlan | undefined {
for (const selection of selections) {
const plan = selection.relational ?? findPlan(selection.selection);
if (plan) return plan;
}
}
test("aggregate output uses exact count and sum types and nullable reductions", async () => {
const checked = await compile(
`aggregate { count countPresent { estimatedHours } sum { estimatedHours cost } avg { cost } }`,
);
const json = JSON.stringify(checked.output);
assert.match(json, /"count":\{"kind":"scalar","name":"uint64"\}/);
assert.match(json, /"cost":\{"kind":"optional","value":\{"kind":"scalar","name":"int64"\}\}/);
assert.ok(checked.effects.some((e) => e.memberId === "hours" && e.uses.includes("aggregate")));
const plan = findPlan(checked.selection);
assert.ok(plan);
assert.equal(plan.terminals[0]!.reductions.length, 5);
});
test("explicit expansion and identity distinct retain checked grouped operands", async () => {
const checked = await compile(`expand { tags {
distinct(by: {source: {_qx: {ref: true}}, target: {color: true}}) {
groups(by: {target: {color: true}}, first: 50,
having: {sum: {source: {estimatedHours: {gt: 0}}}},
orderBy: [{sum: {source: {estimatedHours: DESC}}}]) {
entries { group {target {color}} aggregate {sum {source {estimatedHours}}} }
pageInfo {hasNextPage endCursor}
}
}
} }`);
const plan = findPlan(checked.selection)!;
assert.deepEqual(
plan.stages.map((s) => s.operation.kind),
["source", "expand", "distinct"],
);
assert.equal(plan.terminals[0]!.kind, "groups");
assert.ok(
checked.effects.some(
(e) => e.memberId === "hours" && ["aggregate", "predicate", "order"].every((u) => e.uses.includes(u as never)),
),
);
});
test("distinct and grouping reject fields not determined by their selected keys", async () => {
await assert.rejects(compile(`distinct(by: {cost: true}) {aggregate {sum {estimatedHours}}}`), /dropped by distinct/);
await assert.rejects(compile(`groups(by: {cost: true}, first: 10) {entries {group {estimatedHours}}}`), /group key/);
});
test("relationship existence predicates compile to typed plans", async () => {
const checked = await compile(
`filter(where: {_qx: {relations: {tags: {some: {color: {in: ["red","blue"]}}}}}}) {aggregate {count}}`,
);
const plan = findPlan(checked.selection)!;
assert.equal(plan.stages[1]!.operation.kind, "filter");
assert.ok(checked.effects.some((e) => e.memberId === "color" && e.uses.includes("predicate")));
});
test("group structure is literal, bounded and cannot project a representative field", async () => {
await assert.rejects(compile(`groups(by: {}, first: 2) {entries {aggregate {count}}}`), /key|empty/i);
await assert.rejects(compile(`groups(by: {cost: false}, first: 2) {entries {aggregate {count}}}`), /true/);
await assert.rejects(compile(`groups(by: {cost: true}) {entries {aggregate {count}}}`), /first or all/);
await assert.rejects(
compile(
`groups(by: {cost: true}, first: 2, orderBy: [{sum: {cost: ASC, estimatedHours: DESC}}]) {entries {aggregate {count}}}`,
),
/exactly one field/,
);
await assert.rejects(compile(`expand {tags {aggregate {sum {target {color}}}}}`), /QUERY_VALIDATION/);
});
test("RPC continuation rejection is local to the affected membership boundary", async () => {
await assert.rejects(
compile(`groups(by: {cost: true}, first: 2, after: null) {entries {aggregate {sum {score}}}}`),
/without continuation/,
);
await assert.rejects(
compileAggregationDocument(
`query Report {root {tasks(first: 2, after: null, where: {score: {gt: 0}}) {entries {node {cost}}}}}`,
),
/without continuation/,
);
const checked = await compileAggregationDocument(`query Report($after: Cursor) {root {
tasks(first: 2, after: $after) {entries {node {cost}}}
_qx {relations {tasks {aggregate {sum {score}}}}}
}}`);
assert.equal(findPlan(checked.selection)!.terminals[0]!.residual, true);
});
test("typed reference operands and scalar membership tests reject unbounded literal lists", async () => {
const checked = await compile(
`filter(where: {_qx: {ref: {in: $tasks}}}) {aggregate {count}}`,
"($tasks: [QxRef_Task!]!)",
);
assert.match(JSON.stringify(checked.variables), /object-ref/);
await assert.rejects(
compile(`filter(where: {_qx: {ref: {eq: "obj:made-up"}}}) {aggregate {count}}`),
/typed variables/,
);
await assert.rejects(
compile(`filter(where: {cost: {in: [${Array(1001).fill("1").join(",")}]}}) {aggregate {count}}`),
/1000 operands/,
);
});
+44
View File
@@ -4,6 +4,8 @@ import { compileCapabilityResourceSource } from "../src/capability-language/inde
import { compileQuery } from "../src/query/compile.js"; import { compileQuery } from "../src/query/compile.js";
import { QueryCompileError } from "../src/query/types.js"; import { QueryCompileError } from "../src/query/types.js";
import { checkQueryTemplate } from "../src/query/templates.js"; import { checkQueryTemplate } from "../src/query/templates.js";
import { queryPresentation } from "../src/query/presentation.js";
import { querySelectionToWire } from "../src/query/proto.js";
import type { InterfaceRevision } from "../src/capability-model/types.js"; import type { InterfaceRevision } from "../src/capability-model/types.js";
const source = { repository: "https://example.test/queries.git", commit: "a".repeat(40) }; const source = { repository: "https://example.test/queries.git", commit: "a".repeat(40) };
@@ -125,6 +127,48 @@ const document = `query Upcoming($first: Int!, $before: Int64!) {
const compile = (query = document, row = "fragment Row on TaskFacts { title due }", clauses = "") => const compile = (query = document, row = "fragment Row on TaskFacts { title due }", clauses = "") =>
compileQuery(fixture(clauses), [collection, facts], async (name) => (name.endsWith("row.graphql") ? row : query)); compileQuery(fixture(clauses), [collection, facts], async (name) => (name.endsWith("row.graphql") ? row : query));
test("live selections preserve aliases, nullable values, fragment conditions and wire identity", async () => {
const checked = await compile(
`query Upcoming($show: Boolean!) {root {items(first: 3) {entries {node {plain: title ...Row @include(if: $show)}}}}}`,
`fragment Row on TaskFacts {label: title @live due @live}`,
);
const fields = queryPresentation(checked, [collection, facts]);
assert.deepEqual(
fields.map((f) => f.path),
[
["root", "items", "entries", "*", "node", "label"],
["root", "items", "entries", "*", "node", "due"],
],
);
assert.equal(fields[0]!.getOperationId, "title:get");
assert.equal(fields[0]!.setOperationId, undefined);
assert.equal(fields[1]!.valueType.kind, "optional");
assert.equal(fields[0]!.conditions.length, 1);
assert.ok(JSON.stringify(checked.selection.map(querySelectionToWire)).includes(fields[0]!.selectionId));
const defaults = await compile(
`query Upcoming($show: Boolean! = true) {root {items(first: 3) {entries {node {...Row @include(if: $show)}}}}}`,
"fragment Row on TaskFacts {title @live}",
);
assert.equal(queryPresentation(defaults, [collection, facts])[0]!.conditions[0]!.defaultValue, true);
});
test("live selections reject RPC getters, synthetic selections and conflicting presentations", async () => {
for (const [selected, code] of [
["score @live", "QUERY_LIVE_UNSUPPORTED"],
["_qx @live {ref}", "QUERY_LIVE_UNSUPPORTED"],
["title @live title", "QUERY_LIVE_CONFLICT"],
["title @live(unchecked: true)", "QUERY_VALIDATION"],
])
await assert.rejects(
compile(
`query Upcoming {root {items(first: 3) {entries {node {...Row}}}}}`,
`fragment Row on TaskFacts {${selected}}`,
'allow TaskFacts.score select "bounded";',
),
(error: unknown) => error instanceof QueryCompileError && error.code === code,
);
});
test("query dependency ports resolve exact exports without declaration ordering constraints", () => { test("query dependency ports resolve exact exports without declaration ordering constraints", () => {
const result = compileCapabilityResourceSource( const result = compileCapabilityResourceSource(
`import interface Tasks; `import interface Tasks;
+9
View File
@@ -17,4 +17,13 @@ test("query fixture links generic collections, both implementations, and native
assert.equal(title.kind, "value"); assert.equal(title.kind, "value");
delete title.queryRead; delete title.queryRead;
assert.throws(() => linkQueries(broken), /changed/); assert.throws(() => linkQueries(broken), /changed/);
const aggregateBroken = structuredClone(workspace);
const rank = aggregateBroken.interfaceImports
.find((i) => i.displayName === "TaskFacts")!
.members.find((m) => m.id === "rank")!;
assert.equal(rank.kind, "value");
delete rank.queryRead;
assert.throws(() => linkQueries(aggregateBroken), /changed/);
const totals = workspace.linkedQueries.find((q) => q.id.endsWith(":totals"))!;
assert.equal(totals.fields.filter((f) => f.memberId === "rank").length, 2);
}); });
+26 -10
View File
@@ -7,13 +7,14 @@ import { spawnSync } from "node:child_process";
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js"; import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js";
import { generateReactBindings } from "../src/bindings/react.js"; import { generateReactBindings } from "../src/bindings/react.js";
import { reactPlatformTypes } from "../src/bindings/react-platform.js"; import { reactPlatformTypes } from "../src/bindings/react-platform.js";
import { compileQuery } from "../src/query/compile.js";
const source = { repository: "https://example.test/fields.git", commit: "a".repeat(40) }; const source = { repository: "https://example.test/fields.git", commit: "a".repeat(40) };
test("React bindings preserve read-only, writable and nested reference contracts", async (t) => { test("React bindings preserve read-only, writable and nested reference contracts", async (t) => {
const iface = compileCapabilityResourceSource( const iface = compileCapabilityResourceSource(
`interface Fields id "fields" revision "fields@1" { `interface Fields id "fields" revision "fields@1" {
value title id "title" : string { get id "title:get"; set id "title:set"; watch start id "watch" stop id "stop"; } queryable value title id "title" : string { get id "title:get"; set id "title:set"; watch start id "watch" stop id "stop"; }
value summary id "summary" : string { get id "summary:get"; } queryable value summary id "summary" : string { get id "summary:get"; }
}`, }`,
{ source }, { source },
); );
@@ -22,11 +23,19 @@ test("React bindings preserve read-only, writable and nested reference contracts
const pkg = compileCapabilityResourceSource( const pkg = compileCapabilityResourceSource(
`import interface Fields; package P id "p" revision "p@1" { `import interface Fields; package P id "p" revision "p@1" {
function props id "props" : unit -> record {fields: interface-ref<Fields>; caption: string;}; function props id "props" : unit -> record {fields: interface-ref<Fields>; caption: string;};
query Editor id "editor" root Fields document "editor.graphql" operation "Editor" {max rows 1; watch;}
}`, }`,
{ source, environment: { interfaces: new Map([["Fields", iface.resource.revision]]) } }, { source, environment: { interfaces: new Map([["Fields", iface.resource.revision]]) } },
); );
assert.ok(pkg.ok && pkg.resource.kind === "package"); assert.ok(pkg.ok && pkg.resource.kind === "package");
if (!pkg.ok || pkg.resource.kind !== "package") throw new Error("package failed"); if (!pkg.ok || pkg.resource.kind !== "package") throw new Error("package failed");
pkg.resource.revision.checkedQueries = [
await compileQuery(
pkg.resource.revision.queries![0]!,
[iface.resource.revision],
async () => "query Editor {root {title @live summary @live plain: title}}",
),
];
const schema = { const schema = {
format: "quixos-bindings", format: "quixos-bindings",
version: 1, version: 1,
@@ -68,7 +77,14 @@ test("React bindings preserve read-only, writable and nested reference contracts
path.join(root, "consumer.ts"), path.join(root, "consumer.ts"),
`import {useLiveField, tryConform, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime"; `import {useLiveField, tryConform, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime";
import {reactInterfaces} from "./react-props.gen.js"; import {reactInterfaces} from "./react-props.gen.js";
import type {ReactResults} from "./react-props.gen.js"; import type {ReactResults, QueryResults} from "./react-props.gen.js";
declare const query: QueryResults["Editor"];
useLiveField(query.root.title).set("new");
// @ts-expect-error live query fields retain exact setter types
useLiveField(query.root.title).set(123);
// @ts-expect-error readonly query fields do not acquire a setter
useLiveField(query.root.summary).set("no");
const plain: string = query.root.plain;
async function lookup() { async function lookup() {
const view = await tryConform("object", reactInterfaces.Fields); const view = await tryConform("object", reactInterfaces.Fields);
if (!view) return; if (!view) return;
@@ -79,14 +95,14 @@ async function lookup() {
await view.call["summary.set"]("no setter"); await view.call["summary.set"]("no setter");
} }
declare const props: ReactResults["props"]; declare const props: ReactResults["props"];
const [title, setTitle] = useLiveField(props.fields.fields.title); const title = useLiveField(props.fields.fields.title);
setTitle("new"); title.set("new");
// @ts-expect-error wrong setter value // @ts-expect-error wrong setter value
setTitle(123); title.set(123);
// @ts-expect-error read-only hook has no setter // @ts-expect-error read-only hook has no setter
const [summary, setSummary] = useLiveField(props.fields.fields.summary); useLiveField(props.fields.fields.summary).set("no");
const [manual, write] = useLiveField(props.fields.fields.summary, {write: async (value: string) => {}}); const manual = useLiveField(props.fields.fields.summary, {id: "manual", write: async (value: string) => {}});
write("new"); manual.set("new");
const readonly: ReadableField<string> = props.fields.fields.title; const readonly: ReadableField<string> = props.fields.fields.title;
// @ts-expect-error read-only does not satisfy writable // @ts-expect-error read-only does not satisfy writable
const writable: WritableField<string> = props.fields.fields.summary; const writable: WritableField<string> = props.fields.fields.summary;
@@ -94,7 +110,7 @@ declare const narrow: WritableField<"only">;
// @ts-expect-error writable references are invariant // @ts-expect-error writable references are invariant
const widened: WritableField<string> = narrow; const widened: WritableField<string> = narrow;
// @ts-expect-error callbacks must accept the field's type // @ts-expect-error callbacks must accept the field's type
useLiveField(props.fields.fields.summary, {write: async (value: number) => {}}); useLiveField(props.fields.fields.summary, {id: "manual", write: async (value: number) => {}});
`, `,
); );
const result = spawnSync( const result = spawnSync(