Compare commits
18 Commits
499b65b274
...
exported
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d31ba51c | |||
| 2dffdbcd9f | |||
| c9265e7903 | |||
| a05f58f7fe | |||
| 93c8cae1e6 | |||
| 5a611bbc9b | |||
| aadc35581d | |||
| e61b0a36ac | |||
| ae0a5064a4 | |||
| cd13120937 | |||
| 61a410f98f | |||
| e3501772c5 | |||
| 7863ce3e12 | |||
| ea51ca89e7 | |||
| c3fe7c86b8 | |||
| 74f00fe28f | |||
| eaf6203b73 | |||
| 17f6941f64 |
@@ -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": "1de28b236de076d239752b77553f833dfbdb5b43",
|
"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"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ operationMember
|
|||||||
;
|
;
|
||||||
|
|
||||||
valueMember
|
valueMember
|
||||||
: VALUE identifier ID stringLiteral COLON valueType
|
: queryReadContract? VALUE identifier ID stringLiteral COLON valueType
|
||||||
LBRACE valueMemberOperation* RBRACE
|
LBRACE valueMemberOperation* RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -111,10 +111,14 @@ valueMemberOperation
|
|||||||
;
|
;
|
||||||
|
|
||||||
relationshipMember
|
relationshipMember
|
||||||
: RELATION identifier ID stringLiteral COLON cardinality targetConstraint ORDERED?
|
: queryReadContract? RELATION identifier ID stringLiteral COLON cardinality targetConstraint ORDERED? (KEYED stringLiteral)?
|
||||||
LBRACE relationshipOperation* RBRACE
|
LBRACE relationshipOperation* RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
|
queryReadContract
|
||||||
|
: QUERYABLE RPC?
|
||||||
|
;
|
||||||
|
|
||||||
relationshipOperation
|
relationshipOperation
|
||||||
: RESOLVE ID stringLiteral SEMI
|
: RESOLVE ID stringLiteral SEMI
|
||||||
| CONNECT ID stringLiteral SEMI
|
| CONNECT ID stringLiteral SEMI
|
||||||
@@ -138,6 +142,26 @@ packageExport
|
|||||||
: packageOperationExport
|
: packageOperationExport
|
||||||
| packageFunctionExport
|
| packageFunctionExport
|
||||||
| packageConstructorExport
|
| packageConstructorExport
|
||||||
|
| packageQuery
|
||||||
|
| packageQuerySpecialization
|
||||||
|
;
|
||||||
|
|
||||||
|
packageQuery
|
||||||
|
: QUERY identifier typeParameters? ID stringLiteral ROOT interfaceType
|
||||||
|
DOCUMENT stringLiteral OPERATION stringLiteral LBRACE queryClause* RBRACE
|
||||||
|
;
|
||||||
|
|
||||||
|
packageQuerySpecialization
|
||||||
|
: QUERY identifier ID stringLiteral SPECIALIZE identifier typeArguments SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
queryClause
|
||||||
|
: FRAGMENTS stringLiteral SEMI
|
||||||
|
| VIEW OBJECT? identifier AS interfaceType SEMI
|
||||||
|
| MAX identifier INTEGER SEMI
|
||||||
|
| ALLOW interfaceType DOT identifier identifier stringLiteral SEMI
|
||||||
|
| WATCH SEMI
|
||||||
|
| POLL INTEGER stringLiteral SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
packageOperationExport
|
packageOperationExport
|
||||||
@@ -187,6 +211,7 @@ dependencyPort
|
|||||||
| EDGE identifier ID stringLiteral COLON cardinality targetConstraint primitiveList SEMI
|
| EDGE identifier ID stringLiteral COLON cardinality targetConstraint primitiveList SEMI
|
||||||
| INTERFACE identifier ID stringLiteral COLON identifier typeArguments? SEMI
|
| INTERFACE identifier ID stringLiteral COLON identifier typeArguments? SEMI
|
||||||
| CONSTRUCTOR identifier ID stringLiteral COLON identifier (INPUT valueType)? SEMI
|
| CONSTRUCTOR identifier ID stringLiteral COLON identifier (INPUT valueType)? SEMI
|
||||||
|
| QUERY identifier ID stringLiteral COLON identifier DOT identifier SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
primitiveList
|
primitiveList
|
||||||
@@ -252,7 +277,7 @@ relationshipMaterializationDecl
|
|||||||
;
|
;
|
||||||
|
|
||||||
operationBindingDecl
|
operationBindingDecl
|
||||||
: BIND memberOperationRef TO operationProvider SEMI
|
: BIND memberOperationRef TO operationProvider (QUERY_REASON stringLiteral)? SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
memberOperationRef
|
memberOperationRef
|
||||||
@@ -303,6 +328,7 @@ dependencyBinding
|
|||||||
| identifier TO EDGE identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
|
| identifier TO EDGE identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
|
||||||
| identifier TO INTERFACE identifier typeArguments? (VIA EDGE identifier DOT identifier)? SEMI
|
| identifier TO INTERFACE identifier typeArguments? (VIA EDGE identifier DOT identifier)? SEMI
|
||||||
| identifier TO CONSTRUCTOR identifier SEMI
|
| identifier TO CONSTRUCTOR identifier SEMI
|
||||||
|
| identifier TO QUERY identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
constructorBindingDecl
|
constructorBindingDecl
|
||||||
@@ -371,6 +397,20 @@ jsonArray
|
|||||||
identifier
|
identifier
|
||||||
: IDENTIFIER
|
: IDENTIFIER
|
||||||
| SOURCE
|
| SOURCE
|
||||||
|
| QUERY
|
||||||
|
| SPECIALIZE
|
||||||
|
| ROOT
|
||||||
|
| DOCUMENT
|
||||||
|
| FRAGMENTS
|
||||||
|
| VIEW
|
||||||
|
| MAX
|
||||||
|
| ALLOW
|
||||||
|
| POLL
|
||||||
|
| RPC
|
||||||
|
| QUERYABLE
|
||||||
|
| RESULT_BYTES
|
||||||
|
| RPC_CALLS
|
||||||
|
| DEADLINE_MS
|
||||||
;
|
;
|
||||||
|
|
||||||
stringLiteral
|
stringLiteral
|
||||||
@@ -378,6 +418,21 @@ stringLiteral
|
|||||||
;
|
;
|
||||||
|
|
||||||
WORKSPACE: 'workspace';
|
WORKSPACE: 'workspace';
|
||||||
|
RESULT_BYTES: 'result-bytes';
|
||||||
|
RPC_CALLS: 'rpc-calls';
|
||||||
|
DEADLINE_MS: 'deadline-ms';
|
||||||
|
QUERY: 'query';
|
||||||
|
SPECIALIZE: 'specialize';
|
||||||
|
ROOT: 'root';
|
||||||
|
DOCUMENT: 'document';
|
||||||
|
FRAGMENTS: 'fragments';
|
||||||
|
VIEW: 'view';
|
||||||
|
MAX: 'max';
|
||||||
|
ALLOW: 'allow';
|
||||||
|
POLL: 'poll';
|
||||||
|
QUERYABLE: 'queryable';
|
||||||
|
RPC: 'rpc';
|
||||||
|
QUERY_REASON: 'query-reason';
|
||||||
TYPE: 'type';
|
TYPE: 'type';
|
||||||
OBJECT: 'object';
|
OBJECT: 'object';
|
||||||
STORABLE: 'storable';
|
STORABLE: 'storable';
|
||||||
|
|||||||
+2
-1
@@ -32,7 +32,8 @@
|
|||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.0",
|
||||||
"@bufbuild/protobuf": "^2.12.1",
|
"@bufbuild/protobuf": "^2.12.1",
|
||||||
"@bufbuild/protoc-gen-es": "^2.12.1",
|
"@bufbuild/protoc-gen-es": "^2.12.1",
|
||||||
"antlr4ng": "^3.0.16"
|
"antlr4ng": "^3.0.16",
|
||||||
|
"graphql": "16.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24",
|
"@types/node": "^24",
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ 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 QueryChanges(QueryChangesRequest) returns (QueryChangesResponse);
|
||||||
rpc InstallPersistencePlan(InstallPersistencePlanRequest) returns (InstallPersistencePlanResponse);
|
rpc InstallPersistencePlan(InstallPersistencePlanRequest) returns (InstallPersistencePlanResponse);
|
||||||
rpc GetPersistencePlan(GetPersistencePlanRequest) returns (GetPersistencePlanResponse);
|
rpc GetPersistencePlan(GetPersistencePlanRequest) returns (GetPersistencePlanResponse);
|
||||||
rpc CreateObject(CreateObjectRequest) returns (CreateObjectResponse);
|
rpc CreateObject(CreateObjectRequest) returns (CreateObjectResponse);
|
||||||
@@ -67,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 {
|
||||||
@@ -214,3 +238,122 @@ message CaminoOp {
|
|||||||
string created_at = 6;
|
string created_at = 6;
|
||||||
string client_mutation_id = 7;
|
string client_mutation_id = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message QueryRequest {
|
||||||
|
string query_id = 1;
|
||||||
|
string object_id = 2;
|
||||||
|
map<string, Value> variables = 3;
|
||||||
|
// Typed callers require this exact checked definition. Administrative/raw
|
||||||
|
// callers may omit it to explicitly select the currently installed definition.
|
||||||
|
string expected_definition_digest = 4;
|
||||||
|
}
|
||||||
|
message QueryPathPart {
|
||||||
|
oneof part {
|
||||||
|
string field = 1;
|
||||||
|
uint32 index = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message QueryPendingField {
|
||||||
|
repeated QueryPathPart path = 1;
|
||||||
|
string object_id = 2;
|
||||||
|
string interface_revision_id = 3;
|
||||||
|
string member_id = 4;
|
||||||
|
string operation_id = 5;
|
||||||
|
string value_type_json = 6;
|
||||||
|
// Internal residual facts are separate from the authored result projection.
|
||||||
|
optional uint32 residual_window = 7;
|
||||||
|
uint32 residual_row = 8;
|
||||||
|
string residual_field = 9;
|
||||||
|
optional uint32 relational_capture = 10;
|
||||||
|
uint32 captured_object = 11;
|
||||||
|
}
|
||||||
|
message QueryStats {
|
||||||
|
uint32 sql_count = 1;
|
||||||
|
double sql_ms = 2;
|
||||||
|
uint32 rpc_count = 3;
|
||||||
|
double rpc_ms = 4;
|
||||||
|
uint32 result_bytes = 5;
|
||||||
|
double preparation_ms = 6;
|
||||||
|
double total_ms = 7;
|
||||||
|
uint32 relational_stages = 8;
|
||||||
|
uint32 captured_candidates = 9;
|
||||||
|
double relational_ms = 10;
|
||||||
|
double residual_ms = 11;
|
||||||
|
}
|
||||||
|
message QueryResponse {
|
||||||
|
Value value = 1;
|
||||||
|
string data_version = 2;
|
||||||
|
string binding_digest = 3;
|
||||||
|
repeated QueryPendingField pending = 4;
|
||||||
|
QueryStats stats = 5;
|
||||||
|
// Redeemable only through the host's private control socket, not an RPC grant.
|
||||||
|
string preparation_token = 6;
|
||||||
|
repeated QueryFieldFailure errors = 7;
|
||||||
|
repeated QueryResidualWindow residual_windows = 8;
|
||||||
|
// Native reads share one database snapshot; package enrichment does not.
|
||||||
|
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 {
|
||||||
|
string entry_id = 1;
|
||||||
|
map<string, Value> fields = 2;
|
||||||
|
}
|
||||||
|
message QueryResidualOrder {
|
||||||
|
string field = 1;
|
||||||
|
bool descending = 2;
|
||||||
|
}
|
||||||
|
message QueryResidualWindow {
|
||||||
|
repeated QueryPathPart path = 1;
|
||||||
|
repeated QueryResidualRow rows = 2;
|
||||||
|
string predicate_json = 3;
|
||||||
|
repeated QueryResidualOrder order = 4;
|
||||||
|
uint32 limit = 5;
|
||||||
|
bool bounded_all = 6;
|
||||||
|
repeated QuerySelection selection = 7;
|
||||||
|
map<string, string> field_types = 8;
|
||||||
|
bool relational = 9;
|
||||||
|
repeated string matched_entries = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryFieldFailure {
|
||||||
|
repeated QueryPathPart path = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
message QueryChangesRequest {
|
||||||
|
string query_id = 1;
|
||||||
|
string object_id = 2;
|
||||||
|
string data_version = 3;
|
||||||
|
}
|
||||||
|
message QueryChangesResponse {
|
||||||
|
bool changed = 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,4 +70,243 @@ message PersistencePlan {
|
|||||||
repeated AtomConformance conformances = 4;
|
repeated AtomConformance conformances = 4;
|
||||||
repeated StateAttachment states = 5;
|
repeated StateAttachment states = 5;
|
||||||
repeated EdgeAttachment edges = 6;
|
repeated EdgeAttachment edges = 6;
|
||||||
|
repeated InstalledQuery queries = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immutable checked query IR. Installed only with the checked persistence plan;
|
||||||
|
// query callers select its ID, never send or modify these definitions.
|
||||||
|
message QueryArgument {
|
||||||
|
oneof value {
|
||||||
|
string variable = 1;
|
||||||
|
string literal_json = 2;
|
||||||
|
QueryArgumentList list = 3;
|
||||||
|
QueryArgumentObject object = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message QueryArgumentList {
|
||||||
|
repeated QueryArgument values = 1;
|
||||||
|
}
|
||||||
|
message QueryArgumentObject {
|
||||||
|
map<string, QueryArgument> fields = 1;
|
||||||
|
}
|
||||||
|
message QueryCondition {
|
||||||
|
bool include = 1;
|
||||||
|
QueryArgument value = 2;
|
||||||
|
}
|
||||||
|
message QuerySelection {
|
||||||
|
string name = 1;
|
||||||
|
string key = 2;
|
||||||
|
string interface_revision_id = 3;
|
||||||
|
string member_id = 4;
|
||||||
|
string target_interface_revision_id = 5;
|
||||||
|
repeated QueryCondition conditions = 6;
|
||||||
|
map<string, QueryArgument> arguments = 7;
|
||||||
|
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 {
|
||||||
|
string atom_id = 1;
|
||||||
|
string interface_revision_id = 2;
|
||||||
|
string member_id = 3;
|
||||||
|
string getter_operation_id = 4;
|
||||||
|
string slot_id = 5;
|
||||||
|
string edge_type_id = 6;
|
||||||
|
string projection_id = 7;
|
||||||
|
bool rpc = 8;
|
||||||
|
string watch_start_operation_id = 9;
|
||||||
|
string watch_stop_operation_id = 10;
|
||||||
|
string field_name = 11;
|
||||||
|
string value_type_json = 12;
|
||||||
|
Cardinality cardinality = 13;
|
||||||
|
string key_type = 14;
|
||||||
|
}
|
||||||
|
message QueryBudgets {
|
||||||
|
uint32 rows = 1;
|
||||||
|
uint32 depth = 2;
|
||||||
|
uint32 result_bytes = 3;
|
||||||
|
uint32 candidates = 4;
|
||||||
|
uint32 rpc_calls = 5;
|
||||||
|
uint32 concurrency = 6;
|
||||||
|
uint32 deadline_ms = 7;
|
||||||
|
}
|
||||||
|
message InstalledQuery {
|
||||||
|
string id = 1;
|
||||||
|
string definition_digest = 2;
|
||||||
|
string binding_digest = 3;
|
||||||
|
string root_interface_revision_id = 4;
|
||||||
|
repeated QuerySelection selection = 5;
|
||||||
|
repeated QueryReadBinding bindings = 6;
|
||||||
|
QueryBudgets budgets = 7;
|
||||||
|
string variables_type_json = 8;
|
||||||
|
string output_type_json = 9;
|
||||||
|
map<string, QueryArgument> variable_defaults = 10;
|
||||||
|
bool watch = 11;
|
||||||
|
uint32 polling_interval_ms = 12;
|
||||||
|
bool rpc_predicate_or_order = 13;
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-7
@@ -3,11 +3,15 @@ syntax = "proto3";
|
|||||||
package quixos.orch;
|
package quixos.orch;
|
||||||
|
|
||||||
import "camino/api.proto";
|
import "camino/api.proto";
|
||||||
|
import "camino/schema.proto";
|
||||||
import "quixos/package.proto";
|
import "quixos/package.proto";
|
||||||
import "quixos/refs.proto";
|
import "quixos/refs.proto";
|
||||||
import "quixos/runtime.proto";
|
import "quixos/runtime.proto";
|
||||||
|
|
||||||
service OrchestratorRuntime {
|
service OrchestratorRuntime {
|
||||||
|
rpc ExecuteQuery(camino.QueryRequest) returns (camino.QueryResponse);
|
||||||
|
rpc WatchQuery(camino.QueryRequest) returns (stream QueryEvent);
|
||||||
|
rpc TryConform(TryConformRequest) returns (TryConformResponse);
|
||||||
rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse);
|
rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse);
|
||||||
rpc EditCapabilityField(EditCapabilityFieldRequest) returns (InvokeCapabilityResponse);
|
rpc EditCapabilityField(EditCapabilityFieldRequest) returns (InvokeCapabilityResponse);
|
||||||
rpc InvokeClassCapability(InvokeClassCapabilityRequest) returns (InvokeCapabilityResponse);
|
rpc InvokeClassCapability(InvokeClassCapabilityRequest) returns (InvokeCapabilityResponse);
|
||||||
@@ -22,6 +26,26 @@ service OrchestratorRuntime {
|
|||||||
rpc CloseActivation(CloseActivationRequest) returns (CloseActivationResponse);
|
rpc CloseActivation(CloseActivationRequest) returns (CloseActivationResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message QueryEvent {
|
||||||
|
string run_id = 1;
|
||||||
|
uint64 sequence = 2;
|
||||||
|
string kind = 3;
|
||||||
|
camino.QueryResponse snapshot = 4;
|
||||||
|
repeated camino.QueryPathPart path = 5;
|
||||||
|
camino.Value value = 6;
|
||||||
|
string error = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact closed interface lookup; no policy selection or competing conformances.
|
||||||
|
message TryConformRequest {
|
||||||
|
string object_id = 1;
|
||||||
|
string interface_revision_id = 2;
|
||||||
|
}
|
||||||
|
message TryConformResponse {
|
||||||
|
// Absent only when this object lacks a known contract. Other failures are errors.
|
||||||
|
quixos.ConformanceWitness conformance = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message ConstructObjectRequest {
|
message ConstructObjectRequest {
|
||||||
string atom_id = 1;
|
string atom_id = 1;
|
||||||
map<string, camino.Value> input = 2;
|
map<string, camino.Value> input = 2;
|
||||||
@@ -63,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +119,7 @@ message WatchCapabilityEvent {
|
|||||||
message GetWorkspaceRequest {
|
message GetWorkspaceRequest {
|
||||||
// Revision polling must not download the entire interface graph.
|
// Revision polling must not download the entire interface graph.
|
||||||
bool include_interface_contracts = 1;
|
bool include_interface_contracts = 1;
|
||||||
|
bool include_query_contracts = 2;
|
||||||
}
|
}
|
||||||
message GetWorkspaceResponse {
|
message GetWorkspaceResponse {
|
||||||
string workspace_id = 1;
|
string workspace_id = 1;
|
||||||
@@ -112,6 +133,18 @@ message GetWorkspaceResponse {
|
|||||||
// Exact closed interface contracts used by checked presentation consumers.
|
// Exact closed interface contracts used by checked presentation consumers.
|
||||||
string interfaces_json = 7;
|
string interfaces_json = 7;
|
||||||
repeated ClassCapability class_capabilities = 8;
|
repeated ClassCapability class_capabilities = 8;
|
||||||
|
repeated QueryDescription queries = 9;
|
||||||
|
uint32 active_query_executions = 10;
|
||||||
|
}
|
||||||
|
message QueryDescription {
|
||||||
|
string id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string package_revision_id = 3;
|
||||||
|
string document = 4;
|
||||||
|
string schema = 5;
|
||||||
|
repeated string source_files = 6;
|
||||||
|
string effects_json = 7;
|
||||||
|
camino.InstalledQuery plan = 8;
|
||||||
}
|
}
|
||||||
message ClassCapability {
|
message ClassCapability {
|
||||||
string conformance_id = 1;
|
string conformance_id = 1;
|
||||||
|
|||||||
@@ -5,6 +5,30 @@ package quixos;
|
|||||||
message CapabilityRef {
|
message CapabilityRef {
|
||||||
string interface_revision_id = 1;
|
string interface_revision_id = 1;
|
||||||
string operation_id = 2;
|
string operation_id = 2;
|
||||||
|
// Optional fence for a view acquired through TryConform. Not an authority grant.
|
||||||
|
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 {
|
||||||
|
string object_id = 1;
|
||||||
|
string interface_revision_id = 2;
|
||||||
|
string conformance_id = 3;
|
||||||
|
string workspace_revision_id = 4;
|
||||||
|
string workspace_epoch = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PackageExportRef {
|
message PackageExportRef {
|
||||||
@@ -19,6 +43,7 @@ message InjectedDependency {
|
|||||||
EdgeDependency edge = 3;
|
EdgeDependency edge = 3;
|
||||||
string interface_revision_id = 4;
|
string interface_revision_id = 4;
|
||||||
string constructor_atom_id = 5;
|
string constructor_atom_id = 5;
|
||||||
|
string query_id = 7;
|
||||||
}
|
}
|
||||||
// Defaults to the invocation receiver. A checked dependency traversal can
|
// Defaults to the invocation receiver. A checked dependency traversal can
|
||||||
// select a related object explicitly before the package runtime starts.
|
// select a related object explicitly before the package runtime starts.
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export const genericImplementationType = (
|
|||||||
definition: GenericPackageExport,
|
definition: GenericPackageExport,
|
||||||
interfaces: InterfaceRevision[],
|
interfaces: InterfaceRevision[],
|
||||||
concrete: (type: ValueType) => string,
|
concrete: (type: ValueType) => string,
|
||||||
|
queryPort: (requirement: Extract<GenericDependencyPort["requirement"], { kind: "query" }>) => string = () => {
|
||||||
|
throw new Error("Query binding schema required");
|
||||||
|
},
|
||||||
): string => {
|
): string => {
|
||||||
const names = new Map(definition.parameters.map((parameter, index) => [parameter.id, `T${index}`]));
|
const names = new Map(definition.parameters.map((parameter, index) => [parameter.id, `T${index}`]));
|
||||||
type Scope = { arguments: Map<string, string>; aliases: ValueAliasDefinition[] };
|
type Scope = { arguments: Map<string, string>; aliases: ValueAliasDefinition[] };
|
||||||
@@ -108,6 +111,8 @@ export const genericImplementationType = (
|
|||||||
]);
|
]);
|
||||||
return object(methods);
|
return object(methods);
|
||||||
}
|
}
|
||||||
|
case "query":
|
||||||
|
return queryPort(requirement);
|
||||||
case "constructor":
|
case "constructor":
|
||||||
return object([
|
return object([
|
||||||
[
|
[
|
||||||
@@ -127,7 +132,7 @@ export const genericImplementationType = (
|
|||||||
.filter((op) => op.mode === "call" && op.scope !== "class")
|
.filter((op) => op.mode === "call" && op.scope !== "class")
|
||||||
.map((op) => ({ ...op, name: `${member.displayName}.${op.displayName}` })),
|
.map((op) => ({ ...op, name: `${member.displayName}.${op.displayName}` })),
|
||||||
);
|
);
|
||||||
return object([
|
const methods: [string, string][] = [
|
||||||
["objectId", `QxObjectRef<${target({ kind: "application", application: requirement.application }, scope)}>`],
|
["objectId", `QxObjectRef<${target({ kind: "application", application: requirement.application }, scope)}>`],
|
||||||
["live", object(operations.map((op) => [op.name, `(${params(op.inputType, local)})=>Promise<QxLiveValue>`]))],
|
["live", object(operations.map((op) => [op.name, `(${params(op.inputType, local)})=>Promise<QxLiveValue>`]))],
|
||||||
...operations.map(
|
...operations.map(
|
||||||
@@ -137,7 +142,8 @@ export const genericImplementationType = (
|
|||||||
string,
|
string,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]);
|
];
|
||||||
|
return object([...methods, ["contract", `QxInterfaceContract<${object(methods)}>`]]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -146,6 +152,7 @@ export const genericImplementationType = (
|
|||||||
.join(",");
|
.join(",");
|
||||||
const receiver = definition.receiverRequirement;
|
const receiver = definition.receiverRequirement;
|
||||||
const context = object([
|
const context = object([
|
||||||
|
["conform", "QxConformer"],
|
||||||
...(definition.kind === "function"
|
...(definition.kind === "function"
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
|
|||||||
+94
-6
@@ -7,6 +7,7 @@ import type {
|
|||||||
} from "../capability-model/types.js";
|
} from "../capability-model/types.js";
|
||||||
import type { CompiledCapabilityResourceRepository } from "../capability-language/assembly.js";
|
import type { CompiledCapabilityResourceRepository } from "../capability-language/assembly.js";
|
||||||
import { genericImplementationType } from "./generics.js";
|
import { genericImplementationType } from "./generics.js";
|
||||||
|
import { capabilityId } from "../capability-model/types.js";
|
||||||
|
|
||||||
/** Portable generator input. New backends consume this instead of the parser or TS runtime. */
|
/** Portable generator input. New backends consume this instead of the parser or TS runtime. */
|
||||||
export type BindingSchema = {
|
export type BindingSchema = {
|
||||||
@@ -143,6 +144,33 @@ export const generateTypeScriptBindings = (
|
|||||||
const port = (entry: DependencyPort): { type: string; spec: unknown } => {
|
const port = (entry: DependencyPort): { type: string; spec: unknown } => {
|
||||||
const requirement = entry.requirement;
|
const requirement = entry.requirement;
|
||||||
switch (requirement.kind) {
|
switch (requirement.kind) {
|
||||||
|
case "query": {
|
||||||
|
const query = schema.packages
|
||||||
|
.find((candidate) => candidate.revisionId === requirement.packageRevisionId)
|
||||||
|
?.checkedQueries?.find((query) => query.declaration.id === requirement.queryId);
|
||||||
|
if (!query) throw new Error(`Missing checked query ${requirement.packageRevisionId}:${requirement.queryId}`);
|
||||||
|
return {
|
||||||
|
type: object([
|
||||||
|
["execute", `(variables: ${type(query.variables)}) => Promise<${type(query.output)}>`],
|
||||||
|
...(query.declaration.watch
|
||||||
|
? ([
|
||||||
|
[
|
||||||
|
"watch",
|
||||||
|
`(variables: ${type(query.variables)}, signal: AbortSignal) => AsyncIterable<QxQuerySnapshot<${type(query.output)}>>`,
|
||||||
|
],
|
||||||
|
] as [string, string][])
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
spec: {
|
||||||
|
kind: "query",
|
||||||
|
id: entry.id,
|
||||||
|
definitionDigest: query.definitionDigest,
|
||||||
|
variables: query.variables,
|
||||||
|
output: query.output,
|
||||||
|
watch: query.declaration.watch,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
case "state": {
|
case "state": {
|
||||||
const methods = requirement.primitives.map((primitive): [string, string] => {
|
const methods = requirement.primitives.map((primitive): [string, string] => {
|
||||||
if (primitive === "read") return ["get", `() => Promise<${type(requirement.valueType)}>`];
|
if (primitive === "read") return ["get", `() => Promise<${type(requirement.valueType)}>`];
|
||||||
@@ -183,8 +211,7 @@ export const generateTypeScriptBindings = (
|
|||||||
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
|
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
|
||||||
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })),
|
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })),
|
||||||
);
|
);
|
||||||
return {
|
const methods: [string, string][] = [
|
||||||
type: object([
|
|
||||||
["objectId", ref({ kind: "interface", interfaceRevisionId: requirement.interfaceRevisionId })],
|
["objectId", ref({ kind: "interface", interfaceRevisionId: requirement.interfaceRevisionId })],
|
||||||
[
|
[
|
||||||
"live",
|
"live",
|
||||||
@@ -199,10 +226,13 @@ export const generateTypeScriptBindings = (
|
|||||||
operation.name,
|
operation.name,
|
||||||
`(${params(operation.inputType)}) => Promise<${type(operation.outputType)}>`,
|
`(${params(operation.inputType)}) => Promise<${type(operation.outputType)}>`,
|
||||||
]),
|
]),
|
||||||
]),
|
];
|
||||||
|
return {
|
||||||
|
type: object([...methods, ["contract", `QxInterfaceContract<${object(methods)}>`]]),
|
||||||
spec: {
|
spec: {
|
||||||
kind: "interface",
|
kind: "interface",
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
interfaceRevisionId: requirement.interfaceRevisionId,
|
||||||
operations: Object.fromEntries(
|
operations: Object.fromEntries(
|
||||||
operations.map(({ name, id, inputType, outputType }) => [name, { id, inputType, outputType }]),
|
operations.map(({ name, id, inputType, outputType }) => [name, { id, inputType, outputType }]),
|
||||||
),
|
),
|
||||||
@@ -245,6 +275,7 @@ export const generateTypeScriptBindings = (
|
|||||||
? `QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>`
|
? `QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>`
|
||||||
: "QxObjectRef<string>";
|
: "QxObjectRef<string>";
|
||||||
const contextShape = object([
|
const contextShape = object([
|
||||||
|
["conform", "QxConformer"],
|
||||||
...(entry.kind === "function" ? [] : [["objectId", receiver] as [string, string]]),
|
...(entry.kind === "function" ? [] : [["objectId", receiver] as [string, string]]),
|
||||||
["input", type(entry.inputType)],
|
["input", type(entry.inputType)],
|
||||||
["ports", object(ports.map((port) => [port.name, port.type]))],
|
["ports", object(ports.map((port) => [port.name, port.type]))],
|
||||||
@@ -278,16 +309,62 @@ export const generateTypeScriptBindings = (
|
|||||||
for (const definition of pkg.genericExports ?? []) {
|
for (const definition of pkg.genericExports ?? []) {
|
||||||
handlers.push([
|
handlers.push([
|
||||||
definition.displayName,
|
definition.displayName,
|
||||||
genericImplementationType(definition, [...schema.interfaces, ...(schema.interfaceTemplates ?? [])], type),
|
genericImplementationType(
|
||||||
|
definition,
|
||||||
|
[...schema.interfaces, ...(schema.interfaceTemplates ?? [])],
|
||||||
|
type,
|
||||||
|
(requirement) => port({ id: capabilityId.dependencyPort("query"), displayName: "query", requirement }).type,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
// Unused imported interfaces must not introduce new message-codec obligations.
|
||||||
|
// A used port still fails above if its own required codec is missing.
|
||||||
|
const hasCodec = (value: ValueType): boolean => {
|
||||||
|
if (value.kind === "message") return !!options.messages?.[value.descriptorId];
|
||||||
|
if (value.kind === "record") return Object.values(value.fields).every(hasCodec);
|
||||||
|
if (value.kind === "list" || value.kind === "optional") return hasCodec(value.value);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const contracts = schema.interfaces
|
||||||
|
.filter((iface) =>
|
||||||
|
iface.members.every((member) =>
|
||||||
|
member.operations
|
||||||
|
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
|
||||||
|
.every((operation) => hasCodec(operation.inputType) && hasCodec(operation.outputType)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((iface) => {
|
||||||
|
const generated = port({
|
||||||
|
id: capabilityId.dependencyPort("contract"),
|
||||||
|
displayName: iface.displayName,
|
||||||
|
requirement: { kind: "interface", interfaceRevisionId: iface.revisionId },
|
||||||
|
});
|
||||||
|
const spec = generated.spec as { operations: unknown };
|
||||||
|
return {
|
||||||
|
iface,
|
||||||
|
type: generated.type,
|
||||||
|
code: `defineQxInterfaceContract<${generated.type}>(${q(iface.revisionId)}, ${JSON.stringify(spec.operations)})`,
|
||||||
|
};
|
||||||
|
});
|
||||||
const imports = [...messages].map(([id, alias]) => {
|
const imports = [...messages].map(([id, alias]) => {
|
||||||
const binding = options.messages![id]!;
|
const binding = options.messages![id]!;
|
||||||
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(binding.export))
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(binding.export))
|
||||||
throw new Error(`Invalid message binding export ${binding.export}`);
|
throw new Error(`Invalid message binding export ${binding.export}`);
|
||||||
return `import { ${binding.export} as ${alias} } from ${q(binding.module)};`;
|
return `import { ${binding.export} as ${alias} } from ${q(binding.module)};`;
|
||||||
});
|
});
|
||||||
const signatures = `${object(contexts)} ${object(handlers)} ${object(results)}`;
|
const queryVariables = object(
|
||||||
|
(pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, type(query.variables)]),
|
||||||
|
);
|
||||||
|
const queryResults = object(
|
||||||
|
(pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, type(query.output)]),
|
||||||
|
);
|
||||||
|
const queryDescriptors = object(
|
||||||
|
(pkg.checkedQueries ?? []).map((query) => [
|
||||||
|
query.declaration.displayName,
|
||||||
|
`QxQueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}], ${q(query.declaration.root)}>`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const signatures = `${object(contexts)} ${object(handlers)} ${object(results)} ${queryVariables} ${queryResults} ${queryDescriptors} ${contracts.map((entry) => entry.type).join(" ")}`;
|
||||||
const typeImports = [
|
const typeImports = [
|
||||||
"BindingValue",
|
"BindingValue",
|
||||||
"QxObjectRef",
|
"QxObjectRef",
|
||||||
@@ -295,17 +372,28 @@ export const generateTypeScriptBindings = (
|
|||||||
"QxHandler",
|
"QxHandler",
|
||||||
"QxDerived",
|
"QxDerived",
|
||||||
"QxContextLifecycle",
|
"QxContextLifecycle",
|
||||||
|
"QxConformer",
|
||||||
|
"QxInterfaceContract",
|
||||||
"QxLiveValue",
|
"QxLiveValue",
|
||||||
"RelationshipCollection",
|
"RelationshipCollection",
|
||||||
"RelationshipEntry",
|
"RelationshipEntry",
|
||||||
|
"QxQuerySnapshot",
|
||||||
|
"QxQueryDescriptor",
|
||||||
].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures));
|
].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures));
|
||||||
return (
|
return (
|
||||||
`// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` +
|
`// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` +
|
||||||
`import { ${exports.length ? "bindQxHandler, " : ""}${[...typeImports, "QxHandlerSpec", "QxMessages"].map((name) => `type ${name}`).join(", ")} } from ${q(options.runtimeModule ?? "@quixos/camino-package-runtime")};\n` +
|
`import { ${contracts.length ? "defineQxInterfaceContract, " : ""}${exports.length ? "bindQxHandler, " : ""}${[...typeImports, "QxHandlerSpec", "QxMessages"].map((name) => `type ${name}`).join(", ")} } from ${q(options.runtimeModule ?? "@quixos/camino-package-runtime")};\n` +
|
||||||
imports.join("\n") +
|
imports.join("\n") +
|
||||||
`\nexport const packageRevisionId = ${q(pkg.revisionId)};\n` +
|
`\nexport const packageRevisionId = ${q(pkg.revisionId)};\n` +
|
||||||
`declare const appliedType: unique symbol;\ntype QxApplied<Definition extends string, Arguments extends readonly unknown[]> = string & {readonly [appliedType]: (value: [Definition, Arguments]) => [Definition, Arguments]};\n` +
|
`declare const appliedType: unique symbol;\ntype QxApplied<Definition extends string, Arguments extends readonly unknown[]> = string & {readonly [appliedType]: (value: [Definition, Arguments]) => [Definition, Arguments]};\n` +
|
||||||
`export type Contexts = ${object(contexts)};\nexport type Results = ${object(results)};\nexport type Implementation = ${object(handlers)};\n` +
|
`export type Contexts = ${object(contexts)};\nexport type Results = ${object(results)};\nexport type Implementation = ${object(handlers)};\n` +
|
||||||
|
`export type QueryVariables = ${queryVariables};\nexport type QueryResults = ${queryResults};\n` +
|
||||||
|
`export const queries: ${queryDescriptors} = ${JSON.stringify(Object.fromEntries((pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, { id: `${pkg.revisionId}:${query.declaration.id}`, definitionDigest: query.definitionDigest, rootInterfaceRevisionId: query.declaration.root, variables: query.variables, output: query.output, watch: query.declaration.watch }])), null, 2)} as const;\n` +
|
||||||
|
`export const contracts = {${contracts.map((entry) => `${q(entry.iface.revisionId)}: ${entry.code}`).join(",\n")}};\n` +
|
||||||
|
`export const interfaces = {${contracts
|
||||||
|
.filter((entry) => contracts.filter((other) => other.iface.displayName === entry.iface.displayName).length === 1)
|
||||||
|
.map((entry) => `${q(entry.iface.displayName)}: contracts[${q(entry.iface.revisionId)}]`)
|
||||||
|
.join(",\n")}};\n` +
|
||||||
`const messages = { ${[...messages].map(([id, alias]) => `${q(id)}: ${alias}`).join(", ")} } satisfies QxMessages;\n` +
|
`const messages = { ${[...messages].map(([id, alias]) => `${q(id)}: ${alias}`).join(", ")} } satisfies QxMessages;\n` +
|
||||||
`const specs = ${JSON.stringify(specs, null, 2)} satisfies Record<string, QxHandlerSpec>;\n` +
|
`const specs = ${JSON.stringify(specs, null, 2)} satisfies Record<string, QxHandlerSpec>;\n` +
|
||||||
`export const createRuntime = (implementation: Implementation) => ({\n packageRevisionId,\n exports: {\n` +
|
`export const createRuntime = (implementation: Implementation) => ({\n packageRevisionId,\n exports: {\n` +
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
@@ -15,6 +17,21 @@ declare module "@quixos/web-studio-react-runtime" {
|
|||||||
export type WritableField<T> = ReadableField<T> & {readonly $writeType?: (value: T) => T; readonly writable: true; readonly capability: FieldCapability & {setOperationId: string}};
|
export type WritableField<T> = ReadableField<T> & {readonly $writeType?: (value: T) => T; readonly writable: true; readonly capability: FieldCapability & {setOperationId: string}};
|
||||||
export type LiveFieldProp<T> = ReadableField<T>;
|
export type LiveFieldProp<T> = ReadableField<T>;
|
||||||
export type InterfaceReference<I extends string, Fields> = {readonly $quixosRef: string; readonly interfaceRevisionId: I; readonly fields: Fields};
|
export type InterfaceReference<I extends string, Fields> = {readonly $quixosRef: string; readonly interfaceRevisionId: I; readonly fields: Fields};
|
||||||
|
export type ReactInterfaceContract<View> = {readonly interfaceRevisionId: string; readonly $viewType?: (view: View) => View};
|
||||||
|
export function defineReactInterfaceContract<View>(interfaceRevisionId: string, interfaces: unknown): ReactInterfaceContract<View>;
|
||||||
|
export function tryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>, options?: {signal?: AbortSignal}): Promise<View | undefined>;
|
||||||
|
export type ConformanceResult<View> = {status: "loading"} | {status: "absent"} | {status: "available"; view: View} | {status: "error"; error: Error};
|
||||||
|
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 QueryReference<Contract extends string = string> = {readonly $quixosRef: string; readonly queryContract: Contract};
|
||||||
|
export type QueryLiveProjection = {path: readonly string[]; selectionId: string; interfaceRevisionId: string; getOperationId: string; setOperationId?: string; watchOperationId?: string; inputKind: "fields" | "value"; valueType: QueryValueType; conditions: {include: boolean; defaultValue?: boolean; value: {kind: "variable"; name: string} | {kind: "literal"; value: unknown}}[]};
|
||||||
|
export 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 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 interface QueryConnection<Row> {entries: {key: string; node: Row; cursor?: string | null}[]; pageInfo: {hasNextPage: boolean; endCursor?: string | null}}
|
||||||
|
export function CollectionView<Result, Row>(props: {result: QueryState<Result> & {refresh(): void}; connection(data: QueryPartial<Result>): QueryPartial<QueryConnection<Row>> | undefined; renderItem(row: QueryPartial<Row>, entryKey: string): React.ReactNode; onCreate?: () => void; onLoadMore?: (cursor: string | null) => void; onReset?: () => void; loadingMore?: boolean; empty?: React.ReactNode; prefetch?: boolean}): React.ReactElement;
|
||||||
export type ReactComponentHostProps<Action> = {
|
export type ReactComponentHostProps<Action> = {
|
||||||
onAction?: (action: Action) => void;
|
onAction?: (action: Action) => void;
|
||||||
fallback?: React.ReactNode;
|
fallback?: React.ReactNode;
|
||||||
@@ -41,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};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
+94
-1
@@ -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(
|
||||||
@@ -78,8 +79,100 @@ export function generateReactBindings(
|
|||||||
throw new Error("React component check must name a local module relative to generated bindings");
|
throw new Error("React component check must name a local module relative to generated bindings");
|
||||||
return `type Component${i} = CheckedComponent<${q(component.propsExport)}, typeof import(${q(component.module)})["default"]>;`;
|
return `type Component${i} = CheckedComponent<${q(component.propsExport)}, typeof import(${q(component.module)})["default"]>;`;
|
||||||
});
|
});
|
||||||
|
// Emit optional discovery descriptors only when the browser has all codecs.
|
||||||
|
// Unrelated opaque-message imports must not break an otherwise checked UI.
|
||||||
|
const browserValue = (value: ValueType, seen: Set<string>): boolean => {
|
||||||
|
if (value.kind === "message") return false;
|
||||||
|
if (value.kind === "builtin") return value.name === "unit";
|
||||||
|
if (value.kind === "record") return Object.values(value.fields).every((field) => browserValue(field, seen));
|
||||||
|
if (value.kind === "list" || value.kind === "optional") return browserValue(value.value, seen);
|
||||||
|
if (value.kind === "object-ref" && value.expectation.kind === "interface") {
|
||||||
|
const id = value.expectation.interfaceRevisionId;
|
||||||
|
if (seen.has(id)) return true;
|
||||||
|
const contract = schema.interfaces.find((entry) => entry.revisionId === id);
|
||||||
|
return !!contract && browserInterface(contract, new Set([...seen, id]));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const browserInterface = (iface: (typeof schema.interfaces)[number], seen: Set<string>): boolean =>
|
||||||
|
iface.members.every((member) =>
|
||||||
|
member.operations
|
||||||
|
.filter((op) => op.mode === "call" && op.scope !== "class")
|
||||||
|
.every((op) => browserValue(op.inputType, seen) && browserValue(op.outputType, seen)),
|
||||||
|
);
|
||||||
|
const contracts = schema.interfaces
|
||||||
|
.filter((iface) => browserInterface(iface, new Set([iface.revisionId])))
|
||||||
|
.map((iface) => {
|
||||||
|
const reference = type({
|
||||||
|
kind: "object-ref",
|
||||||
|
expectation: { kind: "interface", interfaceRevisionId: iface.revisionId },
|
||||||
|
});
|
||||||
|
const calls = iface.members.flatMap((member) =>
|
||||||
|
member.operations
|
||||||
|
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
|
||||||
|
.map(
|
||||||
|
(operation) =>
|
||||||
|
`${q(`${member.displayName}.${operation.displayName}`)}: (${operation.inputType.kind === "builtin" && operation.inputType.name === "unit" ? "" : `input: ${type(operation.inputType)}`}) => Promise<${type(operation.outputType)}>`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { iface, view: `${reference} & {call: {${calls.join(";")}}}` };
|
||||||
|
});
|
||||||
|
const queryType = (value: ValueType): string => {
|
||||||
|
if (value.kind === "object-ref")
|
||||||
|
return `QueryReference<${q(value.expectation.kind === "atom" ? value.expectation.atomId : value.expectation.interfaceRevisionId)}>`;
|
||||||
|
if (value.kind === "optional") return `(${queryType(value.value)} | null)`;
|
||||||
|
if (value.kind === "list") return `Array<${queryType(value.value)}>`;
|
||||||
|
if (value.kind === "record")
|
||||||
|
return `{${Object.entries(value.fields)
|
||||||
|
.map(([name, field]) => `${q(name)}: ${queryType(field)}`)
|
||||||
|
.join(";")}}`;
|
||||||
|
return type(value);
|
||||||
|
};
|
||||||
|
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 =
|
||||||
|
`export type QueryVariables = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.variables)}`).join(";")}};\n` +
|
||||||
|
`export type QueryResults = {${queries.map((query) => `${q(query.declaration.displayName)}: ${presentedType(query.output, queryPresentation(query, schema.interfaces))}`).join(";")}};\n` +
|
||||||
|
`export 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(
|
||||||
|
queries.map((query) => [
|
||||||
|
query.declaration.displayName,
|
||||||
|
{
|
||||||
|
id: `${pkg.revisionId}:${query.declaration.id}`,
|
||||||
|
definitionDigest: query.definitionDigest,
|
||||||
|
rootInterfaceRevisionId: query.declaration.root,
|
||||||
|
variables: query.variables,
|
||||||
|
output: query.output,
|
||||||
|
liveFields: queryPresentation(query, schema.interfaces),
|
||||||
|
watch: query.declaration.watch,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)};\n`;
|
||||||
return (
|
return (
|
||||||
`// Generated from checked QX contracts. Do not edit.\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` +
|
`// Generated from checked QX contracts. Do not edit.\nimport {defineReactInterfaceContract} from "@quixos/web-studio-react-runtime";\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField, QueryDescriptor, QueryReference} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` +
|
||||||
|
queryCode +
|
||||||
|
`const contractsData = ${JSON.stringify(schema.interfaces)};\n` +
|
||||||
|
`export const reactContracts = {${contracts.map(({ iface, view }) => `${q(iface.revisionId)}: defineReactInterfaceContract<${view}>(${q(iface.revisionId)}, contractsData)`).join(",\n")}};\n` +
|
||||||
|
`export const reactInterfaces = {${contracts
|
||||||
|
.filter(({ iface }) => contracts.filter((other) => other.iface.displayName === iface.displayName).length === 1)
|
||||||
|
.map(({ iface }) => `${q(iface.displayName)}: reactContracts[${q(iface.revisionId)}]`)
|
||||||
|
.join(",\n")}};\n` +
|
||||||
(checks.length
|
(checks.length
|
||||||
? `type CheckedComponent<K extends keyof ReactResults, C extends (props: {camino: ReactResults[K]; render: any; dispatch: (action: any) => void}) => unknown> = C;\n${checks.join("\n")}\n`
|
? `type CheckedComponent<K extends keyof ReactResults, C extends (props: {camino: ReactResults[K]; render: any; dispatch: (action: any) => void}) => unknown> = C;\n${checks.join("\n")}\n`
|
||||||
: "")
|
: "")
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { readFile, realpath } from "node:fs/promises";
|
import { readFile, realpath } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
||||||
|
import { compileRepositoryQuery } from "../query/compile.js";
|
||||||
|
import { checkQueryTemplate } from "../query/templates.js";
|
||||||
|
import { readRepositorySource } from "./source-loader.js";
|
||||||
|
import { linkQueries } from "../query/link.js";
|
||||||
import {
|
import {
|
||||||
capabilityId,
|
capabilityId,
|
||||||
compileWorkspaceRevision,
|
compileWorkspaceRevision,
|
||||||
@@ -231,6 +235,20 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa
|
|||||||
}
|
}
|
||||||
assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources);
|
assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources);
|
||||||
if (compiled.resource.kind === "package") {
|
if (compiled.resource.kind === "package") {
|
||||||
|
const queryInterfaces = [
|
||||||
|
...(environment.interfaceClosure ?? []),
|
||||||
|
...(compiled.resource.specializations ?? []),
|
||||||
|
];
|
||||||
|
for (const template of compiled.resource.revision.queryTemplates ?? [])
|
||||||
|
await checkQueryTemplate(template, queryInterfaces, (name) =>
|
||||||
|
readRepositorySource(snapshot.directory, name),
|
||||||
|
);
|
||||||
|
if (compiled.resource.revision.queries?.length)
|
||||||
|
compiled.resource.revision.checkedQueries = await Promise.all(
|
||||||
|
compiled.resource.revision.queries.map((query) =>
|
||||||
|
compileRepositoryQuery(snapshot.directory, query, queryInterfaces),
|
||||||
|
),
|
||||||
|
);
|
||||||
let catalogText: string | undefined;
|
let catalogText: string | undefined;
|
||||||
try {
|
try {
|
||||||
catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8");
|
catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8");
|
||||||
@@ -389,6 +407,11 @@ export const compileWorkspaceRepository = async (options: {
|
|||||||
.join("\n")}`,
|
.join("\n")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const linkedQueries = linkQueries(workspace);
|
||||||
|
if (linkedQueries.length) {
|
||||||
|
workspace.linkedQueries = linkedQueries;
|
||||||
|
checked.plan.source.linkedQueries = structuredClone(linkedQueries);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
workspace,
|
workspace,
|
||||||
plan: checked.plan,
|
plan: checked.plan,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,229 +1,259 @@
|
|||||||
WORKSPACE=1
|
WORKSPACE=1
|
||||||
TYPE=2
|
RESULT_BYTES=2
|
||||||
OBJECT=3
|
RPC_CALLS=3
|
||||||
STORABLE=4
|
DEADLINE_MS=4
|
||||||
IMPLEMENTS=5
|
QUERY=5
|
||||||
REF=6
|
SPECIALIZE=6
|
||||||
FRAGMENT=7
|
ROOT=7
|
||||||
IMPORT=8
|
DOCUMENT=8
|
||||||
EXTERNAL=9
|
FRAGMENTS=9
|
||||||
ATOM=10
|
VIEW=10
|
||||||
INTERFACE=11
|
MAX=11
|
||||||
INTERFACES=12
|
ALLOW=12
|
||||||
PACKAGE=13
|
POLL=13
|
||||||
VALUE=14
|
QUERYABLE=14
|
||||||
RELATION=15
|
RPC=15
|
||||||
OPERATION=16
|
QUERY_REASON=16
|
||||||
FUNCTION=17
|
TYPE=17
|
||||||
CONSTRUCTOR=18
|
OBJECT=18
|
||||||
CONSTRUCTS=19
|
STORABLE=19
|
||||||
INPUT=20
|
IMPLEMENTS=20
|
||||||
CONFORM=21
|
REF=21
|
||||||
AS=22
|
FRAGMENT=22
|
||||||
BIND=23
|
IMPORT=23
|
||||||
STATIC=24
|
EXTERNAL=24
|
||||||
TO=25
|
ATOM=25
|
||||||
PRIVATE=26
|
INTERFACE=26
|
||||||
SHARED=27
|
INTERFACES=27
|
||||||
STATE=28
|
PACKAGE=28
|
||||||
EDGE=29
|
VALUE=29
|
||||||
PROJECTION=30
|
RELATION=30
|
||||||
WITH=31
|
OPERATION=31
|
||||||
USING=32
|
FUNCTION=32
|
||||||
VIA=33
|
CONSTRUCTOR=33
|
||||||
MATERIALIZE=34
|
CONSTRUCTS=34
|
||||||
IF=35
|
INPUT=35
|
||||||
ABSENT=36
|
CONFORM=36
|
||||||
ON=37
|
AS=37
|
||||||
POLICY=38
|
BIND=38
|
||||||
DEFAULT=39
|
STATIC=39
|
||||||
SOURCE=40
|
TO=40
|
||||||
REPOSITORY=41
|
PRIVATE=41
|
||||||
COMMIT=42
|
SHARED=42
|
||||||
REVISION=43
|
STATE=43
|
||||||
SEMANTIC_MAJOR=44
|
EDGE=44
|
||||||
ON_DELETE=45
|
PROJECTION=45
|
||||||
RETAIN_OTHER=46
|
WITH=46
|
||||||
KEYED=47
|
USING=47
|
||||||
PUBLIC_TRAVERSAL=48
|
VIA=48
|
||||||
ID=49
|
MATERIALIZE=49
|
||||||
DOC=50
|
IF=50
|
||||||
MODE=51
|
ABSENT=51
|
||||||
EMITS=52
|
ON=52
|
||||||
RECEIVER=53
|
POLICY=53
|
||||||
REQUIRES=54
|
DEFAULT=54
|
||||||
ANY=55
|
SOURCE=55
|
||||||
GET=56
|
REPOSITORY=56
|
||||||
SET=57
|
COMMIT=57
|
||||||
WATCH=58
|
REVISION=58
|
||||||
START=59
|
SEMANTIC_MAJOR=59
|
||||||
STOP=60
|
ON_DELETE=60
|
||||||
READ=61
|
RETAIN_OTHER=61
|
||||||
WRITE=62
|
KEYED=62
|
||||||
RESOLVE=63
|
PUBLIC_TRAVERSAL=63
|
||||||
CONNECT=64
|
ID=64
|
||||||
DISCONNECT=65
|
DOC=65
|
||||||
CALL=66
|
MODE=66
|
||||||
WATCH_START=67
|
EMITS=67
|
||||||
WATCH_STOP=68
|
RECEIVER=68
|
||||||
SUBSCRIBE=69
|
REQUIRES=69
|
||||||
UNSUBSCRIBE=70
|
ANY=70
|
||||||
OPTIMISTIC_REGISTER=71
|
GET=71
|
||||||
CRDT=72
|
SET=72
|
||||||
OPTIONAL_ONE=73
|
WATCH=73
|
||||||
EXACTLY_ONE=74
|
START=74
|
||||||
MANY_UNIQUE=75
|
STOP=75
|
||||||
MANY=76
|
READ=76
|
||||||
ORDERED=77
|
WRITE=77
|
||||||
UNIT=78
|
RESOLVE=78
|
||||||
WATCH_HANDLE=79
|
CONNECT=79
|
||||||
MESSAGE=80
|
DISCONNECT=80
|
||||||
ATOM_REF=81
|
CALL=81
|
||||||
INTERFACE_REF=82
|
WATCH_START=82
|
||||||
OPTIONAL=83
|
WATCH_STOP=83
|
||||||
LIST=84
|
SUBSCRIBE=84
|
||||||
RECORD=85
|
UNSUBSCRIBE=85
|
||||||
BOOL=86
|
OPTIMISTIC_REGISTER=86
|
||||||
BYTES=87
|
CRDT=87
|
||||||
DOUBLE=88
|
OPTIONAL_ONE=88
|
||||||
INT32=89
|
EXACTLY_ONE=89
|
||||||
INT64=90
|
MANY_UNIQUE=90
|
||||||
STRING=91
|
MANY=91
|
||||||
UINT32=92
|
ORDERED=92
|
||||||
UINT64=93
|
UNIT=93
|
||||||
TRUE=94
|
WATCH_HANDLE=94
|
||||||
FALSE=95
|
MESSAGE=95
|
||||||
NULL=96
|
ATOM_REF=96
|
||||||
ARROW=97
|
INTERFACE_REF=97
|
||||||
COLON=98
|
OPTIONAL=98
|
||||||
SEMI=99
|
LIST=99
|
||||||
COMMA=100
|
RECORD=100
|
||||||
DOT=101
|
BOOL=101
|
||||||
LBRACE=102
|
BYTES=102
|
||||||
RBRACE=103
|
DOUBLE=103
|
||||||
LBRACK=104
|
INT32=104
|
||||||
RBRACK=105
|
INT64=105
|
||||||
LPAREN=106
|
STRING=106
|
||||||
RPAREN=107
|
UINT32=107
|
||||||
LT=108
|
UINT64=108
|
||||||
GT=109
|
TRUE=109
|
||||||
AMP=110
|
FALSE=110
|
||||||
EQUAL=111
|
NULL=111
|
||||||
INTEGER=112
|
ARROW=112
|
||||||
JSON_NUMBER=113
|
COLON=113
|
||||||
IDENTIFIER=114
|
SEMI=114
|
||||||
STRING_LITERAL=115
|
COMMA=115
|
||||||
LINE_COMMENT=116
|
DOT=116
|
||||||
BLOCK_COMMENT=117
|
LBRACE=117
|
||||||
WS=118
|
RBRACE=118
|
||||||
|
LBRACK=119
|
||||||
|
RBRACK=120
|
||||||
|
LPAREN=121
|
||||||
|
RPAREN=122
|
||||||
|
LT=123
|
||||||
|
GT=124
|
||||||
|
AMP=125
|
||||||
|
EQUAL=126
|
||||||
|
INTEGER=127
|
||||||
|
JSON_NUMBER=128
|
||||||
|
IDENTIFIER=129
|
||||||
|
STRING_LITERAL=130
|
||||||
|
LINE_COMMENT=131
|
||||||
|
BLOCK_COMMENT=132
|
||||||
|
WS=133
|
||||||
'workspace'=1
|
'workspace'=1
|
||||||
'type'=2
|
'result-bytes'=2
|
||||||
'object'=3
|
'rpc-calls'=3
|
||||||
'storable'=4
|
'deadline-ms'=4
|
||||||
'implements'=5
|
'query'=5
|
||||||
'ref'=6
|
'specialize'=6
|
||||||
'fragment'=7
|
'root'=7
|
||||||
'import'=8
|
'document'=8
|
||||||
'external'=9
|
'fragments'=9
|
||||||
'atom'=10
|
'view'=10
|
||||||
'interface'=11
|
'max'=11
|
||||||
'interfaces'=12
|
'allow'=12
|
||||||
'package'=13
|
'poll'=13
|
||||||
'value'=14
|
'queryable'=14
|
||||||
'relation'=15
|
'rpc'=15
|
||||||
'operation'=16
|
'query-reason'=16
|
||||||
'function'=17
|
'type'=17
|
||||||
'constructor'=18
|
'object'=18
|
||||||
'constructs'=19
|
'storable'=19
|
||||||
'input'=20
|
'implements'=20
|
||||||
'conform'=21
|
'ref'=21
|
||||||
'as'=22
|
'fragment'=22
|
||||||
'bind'=23
|
'import'=23
|
||||||
'static'=24
|
'external'=24
|
||||||
'to'=25
|
'atom'=25
|
||||||
'private'=26
|
'interface'=26
|
||||||
'shared'=27
|
'interfaces'=27
|
||||||
'state'=28
|
'package'=28
|
||||||
'edge'=29
|
'value'=29
|
||||||
'projection'=30
|
'relation'=30
|
||||||
'with'=31
|
'operation'=31
|
||||||
'using'=32
|
'function'=32
|
||||||
'via'=33
|
'constructor'=33
|
||||||
'materialize'=34
|
'constructs'=34
|
||||||
'if'=35
|
'input'=35
|
||||||
'absent'=36
|
'conform'=36
|
||||||
'on'=37
|
'as'=37
|
||||||
'policy'=38
|
'bind'=38
|
||||||
'default'=39
|
'static'=39
|
||||||
'source'=40
|
'to'=40
|
||||||
'repository'=41
|
'private'=41
|
||||||
'commit'=42
|
'shared'=42
|
||||||
'revision'=43
|
'state'=43
|
||||||
'semantic-major'=44
|
'edge'=44
|
||||||
'on-delete'=45
|
'projection'=45
|
||||||
'retain-other'=46
|
'with'=46
|
||||||
'keyed'=47
|
'using'=47
|
||||||
'public-traversal'=48
|
'via'=48
|
||||||
'id'=49
|
'materialize'=49
|
||||||
'doc'=50
|
'if'=50
|
||||||
'mode'=51
|
'absent'=51
|
||||||
'emits'=52
|
'on'=52
|
||||||
'receiver'=53
|
'policy'=53
|
||||||
'requires'=54
|
'default'=54
|
||||||
'any'=55
|
'source'=55
|
||||||
'get'=56
|
'repository'=56
|
||||||
'set'=57
|
'commit'=57
|
||||||
'watch'=58
|
'revision'=58
|
||||||
'start'=59
|
'semantic-major'=59
|
||||||
'stop'=60
|
'on-delete'=60
|
||||||
'read'=61
|
'retain-other'=61
|
||||||
'write'=62
|
'keyed'=62
|
||||||
'resolve'=63
|
'public-traversal'=63
|
||||||
'connect'=64
|
'id'=64
|
||||||
'disconnect'=65
|
'doc'=65
|
||||||
'call'=66
|
'mode'=66
|
||||||
'watch-start'=67
|
'emits'=67
|
||||||
'watch-stop'=68
|
'receiver'=68
|
||||||
'subscribe'=69
|
'requires'=69
|
||||||
'unsubscribe'=70
|
'any'=70
|
||||||
'optimistic-register'=71
|
'get'=71
|
||||||
'crdt'=72
|
'set'=72
|
||||||
'optional-one'=73
|
'watch'=73
|
||||||
'exactly-one'=74
|
'start'=74
|
||||||
'many-unique'=75
|
'stop'=75
|
||||||
'many'=76
|
'read'=76
|
||||||
'ordered'=77
|
'write'=77
|
||||||
'unit'=78
|
'resolve'=78
|
||||||
'watch-handle'=79
|
'connect'=79
|
||||||
'message'=80
|
'disconnect'=80
|
||||||
'atom-ref'=81
|
'call'=81
|
||||||
'interface-ref'=82
|
'watch-start'=82
|
||||||
'optional'=83
|
'watch-stop'=83
|
||||||
'list'=84
|
'subscribe'=84
|
||||||
'record'=85
|
'unsubscribe'=85
|
||||||
'bool'=86
|
'optimistic-register'=86
|
||||||
'bytes'=87
|
'crdt'=87
|
||||||
'double'=88
|
'optional-one'=88
|
||||||
'int32'=89
|
'exactly-one'=89
|
||||||
'int64'=90
|
'many-unique'=90
|
||||||
'string'=91
|
'many'=91
|
||||||
'uint32'=92
|
'ordered'=92
|
||||||
'uint64'=93
|
'unit'=93
|
||||||
'true'=94
|
'watch-handle'=94
|
||||||
'false'=95
|
'message'=95
|
||||||
'null'=96
|
'atom-ref'=96
|
||||||
'->'=97
|
'interface-ref'=97
|
||||||
':'=98
|
'optional'=98
|
||||||
';'=99
|
'list'=99
|
||||||
','=100
|
'record'=100
|
||||||
'.'=101
|
'bool'=101
|
||||||
'{'=102
|
'bytes'=102
|
||||||
'}'=103
|
'double'=103
|
||||||
'['=104
|
'int32'=104
|
||||||
']'=105
|
'int64'=105
|
||||||
'('=106
|
'string'=106
|
||||||
')'=107
|
'uint32'=107
|
||||||
'<'=108
|
'uint64'=108
|
||||||
'>'=109
|
'true'=109
|
||||||
'&'=110
|
'false'=110
|
||||||
'='=111
|
'null'=111
|
||||||
|
'->'=112
|
||||||
|
':'=113
|
||||||
|
';'=114
|
||||||
|
','=115
|
||||||
|
'.'=116
|
||||||
|
'{'=117
|
||||||
|
'}'=118
|
||||||
|
'['=119
|
||||||
|
']'=120
|
||||||
|
'('=121
|
||||||
|
')'=122
|
||||||
|
'<'=123
|
||||||
|
'>'=124
|
||||||
|
'&'=125
|
||||||
|
'='=126
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,229 +1,259 @@
|
|||||||
WORKSPACE=1
|
WORKSPACE=1
|
||||||
TYPE=2
|
RESULT_BYTES=2
|
||||||
OBJECT=3
|
RPC_CALLS=3
|
||||||
STORABLE=4
|
DEADLINE_MS=4
|
||||||
IMPLEMENTS=5
|
QUERY=5
|
||||||
REF=6
|
SPECIALIZE=6
|
||||||
FRAGMENT=7
|
ROOT=7
|
||||||
IMPORT=8
|
DOCUMENT=8
|
||||||
EXTERNAL=9
|
FRAGMENTS=9
|
||||||
ATOM=10
|
VIEW=10
|
||||||
INTERFACE=11
|
MAX=11
|
||||||
INTERFACES=12
|
ALLOW=12
|
||||||
PACKAGE=13
|
POLL=13
|
||||||
VALUE=14
|
QUERYABLE=14
|
||||||
RELATION=15
|
RPC=15
|
||||||
OPERATION=16
|
QUERY_REASON=16
|
||||||
FUNCTION=17
|
TYPE=17
|
||||||
CONSTRUCTOR=18
|
OBJECT=18
|
||||||
CONSTRUCTS=19
|
STORABLE=19
|
||||||
INPUT=20
|
IMPLEMENTS=20
|
||||||
CONFORM=21
|
REF=21
|
||||||
AS=22
|
FRAGMENT=22
|
||||||
BIND=23
|
IMPORT=23
|
||||||
STATIC=24
|
EXTERNAL=24
|
||||||
TO=25
|
ATOM=25
|
||||||
PRIVATE=26
|
INTERFACE=26
|
||||||
SHARED=27
|
INTERFACES=27
|
||||||
STATE=28
|
PACKAGE=28
|
||||||
EDGE=29
|
VALUE=29
|
||||||
PROJECTION=30
|
RELATION=30
|
||||||
WITH=31
|
OPERATION=31
|
||||||
USING=32
|
FUNCTION=32
|
||||||
VIA=33
|
CONSTRUCTOR=33
|
||||||
MATERIALIZE=34
|
CONSTRUCTS=34
|
||||||
IF=35
|
INPUT=35
|
||||||
ABSENT=36
|
CONFORM=36
|
||||||
ON=37
|
AS=37
|
||||||
POLICY=38
|
BIND=38
|
||||||
DEFAULT=39
|
STATIC=39
|
||||||
SOURCE=40
|
TO=40
|
||||||
REPOSITORY=41
|
PRIVATE=41
|
||||||
COMMIT=42
|
SHARED=42
|
||||||
REVISION=43
|
STATE=43
|
||||||
SEMANTIC_MAJOR=44
|
EDGE=44
|
||||||
ON_DELETE=45
|
PROJECTION=45
|
||||||
RETAIN_OTHER=46
|
WITH=46
|
||||||
KEYED=47
|
USING=47
|
||||||
PUBLIC_TRAVERSAL=48
|
VIA=48
|
||||||
ID=49
|
MATERIALIZE=49
|
||||||
DOC=50
|
IF=50
|
||||||
MODE=51
|
ABSENT=51
|
||||||
EMITS=52
|
ON=52
|
||||||
RECEIVER=53
|
POLICY=53
|
||||||
REQUIRES=54
|
DEFAULT=54
|
||||||
ANY=55
|
SOURCE=55
|
||||||
GET=56
|
REPOSITORY=56
|
||||||
SET=57
|
COMMIT=57
|
||||||
WATCH=58
|
REVISION=58
|
||||||
START=59
|
SEMANTIC_MAJOR=59
|
||||||
STOP=60
|
ON_DELETE=60
|
||||||
READ=61
|
RETAIN_OTHER=61
|
||||||
WRITE=62
|
KEYED=62
|
||||||
RESOLVE=63
|
PUBLIC_TRAVERSAL=63
|
||||||
CONNECT=64
|
ID=64
|
||||||
DISCONNECT=65
|
DOC=65
|
||||||
CALL=66
|
MODE=66
|
||||||
WATCH_START=67
|
EMITS=67
|
||||||
WATCH_STOP=68
|
RECEIVER=68
|
||||||
SUBSCRIBE=69
|
REQUIRES=69
|
||||||
UNSUBSCRIBE=70
|
ANY=70
|
||||||
OPTIMISTIC_REGISTER=71
|
GET=71
|
||||||
CRDT=72
|
SET=72
|
||||||
OPTIONAL_ONE=73
|
WATCH=73
|
||||||
EXACTLY_ONE=74
|
START=74
|
||||||
MANY_UNIQUE=75
|
STOP=75
|
||||||
MANY=76
|
READ=76
|
||||||
ORDERED=77
|
WRITE=77
|
||||||
UNIT=78
|
RESOLVE=78
|
||||||
WATCH_HANDLE=79
|
CONNECT=79
|
||||||
MESSAGE=80
|
DISCONNECT=80
|
||||||
ATOM_REF=81
|
CALL=81
|
||||||
INTERFACE_REF=82
|
WATCH_START=82
|
||||||
OPTIONAL=83
|
WATCH_STOP=83
|
||||||
LIST=84
|
SUBSCRIBE=84
|
||||||
RECORD=85
|
UNSUBSCRIBE=85
|
||||||
BOOL=86
|
OPTIMISTIC_REGISTER=86
|
||||||
BYTES=87
|
CRDT=87
|
||||||
DOUBLE=88
|
OPTIONAL_ONE=88
|
||||||
INT32=89
|
EXACTLY_ONE=89
|
||||||
INT64=90
|
MANY_UNIQUE=90
|
||||||
STRING=91
|
MANY=91
|
||||||
UINT32=92
|
ORDERED=92
|
||||||
UINT64=93
|
UNIT=93
|
||||||
TRUE=94
|
WATCH_HANDLE=94
|
||||||
FALSE=95
|
MESSAGE=95
|
||||||
NULL=96
|
ATOM_REF=96
|
||||||
ARROW=97
|
INTERFACE_REF=97
|
||||||
COLON=98
|
OPTIONAL=98
|
||||||
SEMI=99
|
LIST=99
|
||||||
COMMA=100
|
RECORD=100
|
||||||
DOT=101
|
BOOL=101
|
||||||
LBRACE=102
|
BYTES=102
|
||||||
RBRACE=103
|
DOUBLE=103
|
||||||
LBRACK=104
|
INT32=104
|
||||||
RBRACK=105
|
INT64=105
|
||||||
LPAREN=106
|
STRING=106
|
||||||
RPAREN=107
|
UINT32=107
|
||||||
LT=108
|
UINT64=108
|
||||||
GT=109
|
TRUE=109
|
||||||
AMP=110
|
FALSE=110
|
||||||
EQUAL=111
|
NULL=111
|
||||||
INTEGER=112
|
ARROW=112
|
||||||
JSON_NUMBER=113
|
COLON=113
|
||||||
IDENTIFIER=114
|
SEMI=114
|
||||||
STRING_LITERAL=115
|
COMMA=115
|
||||||
LINE_COMMENT=116
|
DOT=116
|
||||||
BLOCK_COMMENT=117
|
LBRACE=117
|
||||||
WS=118
|
RBRACE=118
|
||||||
|
LBRACK=119
|
||||||
|
RBRACK=120
|
||||||
|
LPAREN=121
|
||||||
|
RPAREN=122
|
||||||
|
LT=123
|
||||||
|
GT=124
|
||||||
|
AMP=125
|
||||||
|
EQUAL=126
|
||||||
|
INTEGER=127
|
||||||
|
JSON_NUMBER=128
|
||||||
|
IDENTIFIER=129
|
||||||
|
STRING_LITERAL=130
|
||||||
|
LINE_COMMENT=131
|
||||||
|
BLOCK_COMMENT=132
|
||||||
|
WS=133
|
||||||
'workspace'=1
|
'workspace'=1
|
||||||
'type'=2
|
'result-bytes'=2
|
||||||
'object'=3
|
'rpc-calls'=3
|
||||||
'storable'=4
|
'deadline-ms'=4
|
||||||
'implements'=5
|
'query'=5
|
||||||
'ref'=6
|
'specialize'=6
|
||||||
'fragment'=7
|
'root'=7
|
||||||
'import'=8
|
'document'=8
|
||||||
'external'=9
|
'fragments'=9
|
||||||
'atom'=10
|
'view'=10
|
||||||
'interface'=11
|
'max'=11
|
||||||
'interfaces'=12
|
'allow'=12
|
||||||
'package'=13
|
'poll'=13
|
||||||
'value'=14
|
'queryable'=14
|
||||||
'relation'=15
|
'rpc'=15
|
||||||
'operation'=16
|
'query-reason'=16
|
||||||
'function'=17
|
'type'=17
|
||||||
'constructor'=18
|
'object'=18
|
||||||
'constructs'=19
|
'storable'=19
|
||||||
'input'=20
|
'implements'=20
|
||||||
'conform'=21
|
'ref'=21
|
||||||
'as'=22
|
'fragment'=22
|
||||||
'bind'=23
|
'import'=23
|
||||||
'static'=24
|
'external'=24
|
||||||
'to'=25
|
'atom'=25
|
||||||
'private'=26
|
'interface'=26
|
||||||
'shared'=27
|
'interfaces'=27
|
||||||
'state'=28
|
'package'=28
|
||||||
'edge'=29
|
'value'=29
|
||||||
'projection'=30
|
'relation'=30
|
||||||
'with'=31
|
'operation'=31
|
||||||
'using'=32
|
'function'=32
|
||||||
'via'=33
|
'constructor'=33
|
||||||
'materialize'=34
|
'constructs'=34
|
||||||
'if'=35
|
'input'=35
|
||||||
'absent'=36
|
'conform'=36
|
||||||
'on'=37
|
'as'=37
|
||||||
'policy'=38
|
'bind'=38
|
||||||
'default'=39
|
'static'=39
|
||||||
'source'=40
|
'to'=40
|
||||||
'repository'=41
|
'private'=41
|
||||||
'commit'=42
|
'shared'=42
|
||||||
'revision'=43
|
'state'=43
|
||||||
'semantic-major'=44
|
'edge'=44
|
||||||
'on-delete'=45
|
'projection'=45
|
||||||
'retain-other'=46
|
'with'=46
|
||||||
'keyed'=47
|
'using'=47
|
||||||
'public-traversal'=48
|
'via'=48
|
||||||
'id'=49
|
'materialize'=49
|
||||||
'doc'=50
|
'if'=50
|
||||||
'mode'=51
|
'absent'=51
|
||||||
'emits'=52
|
'on'=52
|
||||||
'receiver'=53
|
'policy'=53
|
||||||
'requires'=54
|
'default'=54
|
||||||
'any'=55
|
'source'=55
|
||||||
'get'=56
|
'repository'=56
|
||||||
'set'=57
|
'commit'=57
|
||||||
'watch'=58
|
'revision'=58
|
||||||
'start'=59
|
'semantic-major'=59
|
||||||
'stop'=60
|
'on-delete'=60
|
||||||
'read'=61
|
'retain-other'=61
|
||||||
'write'=62
|
'keyed'=62
|
||||||
'resolve'=63
|
'public-traversal'=63
|
||||||
'connect'=64
|
'id'=64
|
||||||
'disconnect'=65
|
'doc'=65
|
||||||
'call'=66
|
'mode'=66
|
||||||
'watch-start'=67
|
'emits'=67
|
||||||
'watch-stop'=68
|
'receiver'=68
|
||||||
'subscribe'=69
|
'requires'=69
|
||||||
'unsubscribe'=70
|
'any'=70
|
||||||
'optimistic-register'=71
|
'get'=71
|
||||||
'crdt'=72
|
'set'=72
|
||||||
'optional-one'=73
|
'watch'=73
|
||||||
'exactly-one'=74
|
'start'=74
|
||||||
'many-unique'=75
|
'stop'=75
|
||||||
'many'=76
|
'read'=76
|
||||||
'ordered'=77
|
'write'=77
|
||||||
'unit'=78
|
'resolve'=78
|
||||||
'watch-handle'=79
|
'connect'=79
|
||||||
'message'=80
|
'disconnect'=80
|
||||||
'atom-ref'=81
|
'call'=81
|
||||||
'interface-ref'=82
|
'watch-start'=82
|
||||||
'optional'=83
|
'watch-stop'=83
|
||||||
'list'=84
|
'subscribe'=84
|
||||||
'record'=85
|
'unsubscribe'=85
|
||||||
'bool'=86
|
'optimistic-register'=86
|
||||||
'bytes'=87
|
'crdt'=87
|
||||||
'double'=88
|
'optional-one'=88
|
||||||
'int32'=89
|
'exactly-one'=89
|
||||||
'int64'=90
|
'many-unique'=90
|
||||||
'string'=91
|
'many'=91
|
||||||
'uint32'=92
|
'ordered'=92
|
||||||
'uint64'=93
|
'unit'=93
|
||||||
'true'=94
|
'watch-handle'=94
|
||||||
'false'=95
|
'message'=95
|
||||||
'null'=96
|
'atom-ref'=96
|
||||||
'->'=97
|
'interface-ref'=97
|
||||||
':'=98
|
'optional'=98
|
||||||
';'=99
|
'list'=99
|
||||||
','=100
|
'record'=100
|
||||||
'.'=101
|
'bool'=101
|
||||||
'{'=102
|
'bytes'=102
|
||||||
'}'=103
|
'double'=103
|
||||||
'['=104
|
'int32'=104
|
||||||
']'=105
|
'int64'=105
|
||||||
'('=106
|
'string'=106
|
||||||
')'=107
|
'uint32'=107
|
||||||
'<'=108
|
'uint64'=108
|
||||||
'>'=109
|
'true'=109
|
||||||
'&'=110
|
'false'=110
|
||||||
'='=111
|
'null'=111
|
||||||
|
'->'=112
|
||||||
|
':'=113
|
||||||
|
';'=114
|
||||||
|
','=115
|
||||||
|
'.'=116
|
||||||
|
'{'=117
|
||||||
|
'}'=118
|
||||||
|
'['=119
|
||||||
|
']'=120
|
||||||
|
'('=121
|
||||||
|
')'=122
|
||||||
|
'<'=123
|
||||||
|
'>'=124
|
||||||
|
'&'=125
|
||||||
|
'='=126
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,10 +24,14 @@ import { OperationMemberContext } from "./QuixosCapabilityParser.js";
|
|||||||
import { ValueMemberContext } from "./QuixosCapabilityParser.js";
|
import { ValueMemberContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ValueMemberOperationContext } from "./QuixosCapabilityParser.js";
|
import { ValueMemberOperationContext } from "./QuixosCapabilityParser.js";
|
||||||
import { RelationshipMemberContext } from "./QuixosCapabilityParser.js";
|
import { RelationshipMemberContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { QueryReadContractContext } from "./QuixosCapabilityParser.js";
|
||||||
import { RelationshipOperationContext } from "./QuixosCapabilityParser.js";
|
import { RelationshipOperationContext } from "./QuixosCapabilityParser.js";
|
||||||
import { TargetConstraintContext } from "./QuixosCapabilityParser.js";
|
import { TargetConstraintContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageResourceDeclContext } from "./QuixosCapabilityParser.js";
|
import { PackageResourceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageExportContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { PackageQueryContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { PackageQuerySpecializationContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { QueryClauseContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageOperationExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageOperationExportContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageFunctionExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageFunctionExportContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageConstructorExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageConstructorExportContext } from "./QuixosCapabilityParser.js";
|
||||||
@@ -210,6 +214,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitRelationshipMember?: (ctx: RelationshipMemberContext) => Result;
|
visitRelationshipMember?: (ctx: RelationshipMemberContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.queryReadContract`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitQueryReadContract?: (ctx: QueryReadContractContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.relationshipOperation`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.relationshipOperation`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -234,6 +244,24 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitPackageExport?: (ctx: PackageExportContext) => Result;
|
visitPackageExport?: (ctx: PackageExportContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.packageQuery`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitPackageQuery?: (ctx: PackageQueryContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.packageQuerySpecialization`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitPackageQuerySpecialization?: (ctx: PackageQuerySpecializationContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.queryClause`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitQueryClause?: (ctx: QueryClauseContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.packageOperationExport`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.packageOperationExport`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ import {
|
|||||||
specializePackageExport,
|
specializePackageExport,
|
||||||
} from "../capability-model/index.js";
|
} from "../capability-model/index.js";
|
||||||
import { GenericSourceTypes } from "./generic-types.js";
|
import { GenericSourceTypes } from "./generic-types.js";
|
||||||
|
import {
|
||||||
|
defaultQueryBudgets,
|
||||||
|
type QueryDeclaration,
|
||||||
|
type QueryBudgets,
|
||||||
|
type QueryUse,
|
||||||
|
type QueryTemplate,
|
||||||
|
} from "../query/types.js";
|
||||||
|
import { specializeQueryTemplate } from "../query/templates.js";
|
||||||
import { QuixosCapabilityLexer } from "./generated/QuixosCapabilityLexer.js";
|
import { QuixosCapabilityLexer } from "./generated/QuixosCapabilityLexer.js";
|
||||||
import {
|
import {
|
||||||
QuixosCapabilityParser,
|
QuixosCapabilityParser,
|
||||||
@@ -176,6 +184,7 @@ interface PackageExportSymbol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PackageSymbol {
|
interface PackageSymbol {
|
||||||
|
queries?: Map<string, string>;
|
||||||
definition?: PackageRevision;
|
definition?: PackageRevision;
|
||||||
revisionId: PackageRevision["revisionId"];
|
revisionId: PackageRevision["revisionId"];
|
||||||
exports: Map<string, PackageExportSymbol>;
|
exports: Map<string, PackageExportSymbol>;
|
||||||
@@ -463,6 +472,13 @@ const lowerValueMember = (state: LoweringState, context: ValueMemberContext): In
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
kind: "value",
|
kind: "value",
|
||||||
|
...(context.queryReadContract()
|
||||||
|
? {
|
||||||
|
queryRead: {
|
||||||
|
execution: context.queryReadContract()!.RPC() ? ("rpc-permitted" as const) : ("native" as const),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
id: capabilityId.member(stringValue(context.stringLiteral())),
|
id: capabilityId.member(stringValue(context.stringLiteral())),
|
||||||
displayName: memberName,
|
displayName: memberName,
|
||||||
valueType: memberValueType,
|
valueType: memberValueType,
|
||||||
@@ -527,7 +543,15 @@ const lowerRelationshipMember = (
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
kind: "relationship",
|
kind: "relationship",
|
||||||
id: capabilityId.member(stringValue(context.stringLiteral())),
|
...(context.KEYED() ? { keyType: stringValue(context.stringLiteral(1)) as "string" | "boolean" | "int64" } : {}),
|
||||||
|
...(context.queryReadContract()
|
||||||
|
? {
|
||||||
|
queryRead: {
|
||||||
|
execution: context.queryReadContract()!.RPC() ? ("rpc-permitted" as const) : ("native" as const),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
id: capabilityId.member(stringValue(context.stringLiteral(0))),
|
||||||
displayName: identifier(context.identifier()),
|
displayName: identifier(context.identifier()),
|
||||||
target,
|
target,
|
||||||
cardinality,
|
cardinality,
|
||||||
@@ -672,6 +696,13 @@ const lowerInterfaceTemplate = (
|
|||||||
const type = types.expression(value.valueType());
|
const type = types.expression(value.valueType());
|
||||||
return {
|
return {
|
||||||
kind: "value",
|
kind: "value",
|
||||||
|
...(value.queryReadContract()
|
||||||
|
? {
|
||||||
|
queryRead: {
|
||||||
|
execution: value.queryReadContract()!.RPC() ? ("rpc-permitted" as const) : ("native" as const),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
id: capabilityId.member(stringValue(value.stringLiteral())),
|
id: capabilityId.member(stringValue(value.stringLiteral())),
|
||||||
displayName: identifier(value.identifier()),
|
displayName: identifier(value.identifier()),
|
||||||
valueType: type,
|
valueType: type,
|
||||||
@@ -725,7 +756,17 @@ const lowerInterfaceTemplate = (
|
|||||||
: { kind: cardinality === "optional-one" ? "optional" : "list", value: targetType };
|
: { kind: cardinality === "optional-one" ? "optional" : "list", value: targetType };
|
||||||
return {
|
return {
|
||||||
kind: "relationship",
|
kind: "relationship",
|
||||||
id: capabilityId.member(stringValue(relationship.stringLiteral())),
|
...(relationship.KEYED()
|
||||||
|
? { keyType: stringValue(relationship.stringLiteral(1)) as "string" | "boolean" | "int64" }
|
||||||
|
: {}),
|
||||||
|
...(relationship.queryReadContract()
|
||||||
|
? {
|
||||||
|
queryRead: {
|
||||||
|
execution: relationship.queryReadContract()!.RPC() ? ("rpc-permitted" as const) : ("native" as const),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
id: capabilityId.member(stringValue(relationship.stringLiteral(0))),
|
||||||
displayName: identifier(relationship.identifier()),
|
displayName: identifier(relationship.identifier()),
|
||||||
target,
|
target,
|
||||||
cardinality,
|
cardinality,
|
||||||
@@ -789,9 +830,34 @@ const lowerInterfaceTemplate = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lowerQueryReference = (
|
||||||
|
state: LoweringState,
|
||||||
|
packageName: string,
|
||||||
|
queryName: string,
|
||||||
|
context: ParserRuleContext,
|
||||||
|
) => {
|
||||||
|
const pkg = requireSymbol(state, state.packages, packageName, context, "package");
|
||||||
|
const queryId =
|
||||||
|
pkg?.queries?.get(queryName) ?? pkg?.definition?.queries?.find((query) => query.displayName === queryName)?.id;
|
||||||
|
if (!pkg || !queryId) {
|
||||||
|
loweringIssue(state, context, "unknown-query", `Unknown query export ${packageName}.${queryName}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { packageRevisionId: pkg.revisionId, queryId };
|
||||||
|
};
|
||||||
|
|
||||||
const lowerDependencyPort = (state: LoweringState, context: DependencyPortContext): DependencyPort | undefined => {
|
const lowerDependencyPort = (state: LoweringState, context: DependencyPortContext): DependencyPort | undefined => {
|
||||||
const name = identifier(context.identifier(0));
|
const name = identifier(context.identifier(0));
|
||||||
const id = capabilityId.dependencyPort(stringValue(context.stringLiteral()));
|
const id = capabilityId.dependencyPort(stringValue(context.stringLiteral()));
|
||||||
|
if (context.QUERY()) {
|
||||||
|
const query = lowerQueryReference(
|
||||||
|
state,
|
||||||
|
identifier(context.identifier(1)),
|
||||||
|
identifier(context.identifier(2)),
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
return query ? { id, displayName: name, requirement: { kind: "query", ...query } } : undefined;
|
||||||
|
}
|
||||||
if (context.STATE()) {
|
if (context.STATE()) {
|
||||||
const primitives = context
|
const primitives = context
|
||||||
.primitiveList()!
|
.primitiveList()!
|
||||||
@@ -1031,6 +1097,16 @@ const lowerGenericPackageExport = (
|
|||||||
.primitiveList()
|
.primitiveList()
|
||||||
?.primitive()
|
?.primitive()
|
||||||
.map((item) => text(item)) ?? [];
|
.map((item) => text(item)) ?? [];
|
||||||
|
if (port.QUERY()) {
|
||||||
|
const query = lowerQueryReference(state, identifier(port.identifier(1)), identifier(port.identifier(2)), port);
|
||||||
|
if (!query)
|
||||||
|
throw new GenericTypeError(
|
||||||
|
"unknown-query",
|
||||||
|
base.displayName,
|
||||||
|
"Query port requires an existing closed query export",
|
||||||
|
);
|
||||||
|
return { ...base, requirement: { kind: "query", ...query } };
|
||||||
|
}
|
||||||
if (port.STATE())
|
if (port.STATE())
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -1129,8 +1205,183 @@ const lowerPackage = (
|
|||||||
source: SourceRevision,
|
source: SourceRevision,
|
||||||
): PackageRevision => {
|
): PackageRevision => {
|
||||||
const alias = identifier(context.identifier());
|
const alias = identifier(context.identifier());
|
||||||
|
state.packages.get(alias)!.queries = new Map(
|
||||||
|
context.packageExport().flatMap((entry) => {
|
||||||
|
const query = entry.packageQuery();
|
||||||
|
const specialization = entry.packageQuerySpecialization();
|
||||||
|
return query && !query.typeParameters()
|
||||||
|
? [[identifier(query.identifier()), stringValue(query.stringLiteral(0))] as const]
|
||||||
|
: specialization
|
||||||
|
? [[identifier(specialization.identifier(0)), stringValue(specialization.stringLiteral())] as const]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
const genericExports: GenericPackageExport[] = [];
|
const genericExports: GenericPackageExport[] = [];
|
||||||
|
const queries: QueryDeclaration[] = [];
|
||||||
|
const queryTemplates: QueryTemplate[] = [];
|
||||||
const exports = context.packageExport().flatMap((exportContext): PackageExport[] => {
|
const exports = context.packageExport().flatMap((exportContext): PackageExport[] => {
|
||||||
|
if (exportContext.packageQuerySpecialization()) return [];
|
||||||
|
const query = exportContext.packageQuery();
|
||||||
|
if (query) {
|
||||||
|
const previousParameters = state.types.parameters;
|
||||||
|
state.types.parameters = new Map();
|
||||||
|
try {
|
||||||
|
const parameters = state.types.declareParameters(
|
||||||
|
query.typeParameters(),
|
||||||
|
`query:${stringValue(query.stringLiteral(0))}`,
|
||||||
|
);
|
||||||
|
const expressions = new Map<string, ReturnType<GenericSourceTypes["interface"]>>();
|
||||||
|
const templateViews: QueryTemplate["views"] = [];
|
||||||
|
const templateAllowances: QueryTemplate["allowances"] = [];
|
||||||
|
const close = (ctx: import("./generated/QuixosCapabilityParser.js").InterfaceTypeContext) =>
|
||||||
|
parameters.length
|
||||||
|
? (() => {
|
||||||
|
const id = capabilityId.interfaceRevision(`query-expression:${expressions.size}`);
|
||||||
|
expressions.set(id, state.types.interface(ctx));
|
||||||
|
return id;
|
||||||
|
})()
|
||||||
|
: new TypeSubstitution(state.types.environment()).application(state.types.interface(ctx));
|
||||||
|
const declaration: QueryDeclaration = {
|
||||||
|
id: stringValue(query.stringLiteral(0)),
|
||||||
|
displayName: identifier(query.identifier()),
|
||||||
|
root: close(query.interfaceType()),
|
||||||
|
document: stringValue(query.stringLiteral(1)),
|
||||||
|
operation: stringValue(query.stringLiteral(2)),
|
||||||
|
fragments: [],
|
||||||
|
views: [],
|
||||||
|
allowances: [],
|
||||||
|
budgets: { ...defaultQueryBudgets },
|
||||||
|
watch: false,
|
||||||
|
};
|
||||||
|
const budgetNames: Record<string, keyof QueryBudgets> = {
|
||||||
|
rows: "rows",
|
||||||
|
depth: "depth",
|
||||||
|
"result-bytes": "resultBytes",
|
||||||
|
candidates: "candidates",
|
||||||
|
"rpc-calls": "rpcCalls",
|
||||||
|
concurrency: "concurrency",
|
||||||
|
"deadline-ms": "deadlineMs",
|
||||||
|
};
|
||||||
|
const assigned = new Set<string>();
|
||||||
|
for (const clause of query.queryClause()) {
|
||||||
|
if (clause.FRAGMENTS()) declaration.fragments.push(stringValue(clause.stringLiteral()!));
|
||||||
|
else if (clause.VIEW()) {
|
||||||
|
const name = identifier(clause.identifier(0));
|
||||||
|
if (parameters.length) {
|
||||||
|
const parameter = state.types.parameters.get(name);
|
||||||
|
const atomId = state.atoms.get(name);
|
||||||
|
if (clause.OBJECT() && parameter?.kind === "object")
|
||||||
|
templateViews.push({
|
||||||
|
target: { kind: "parameter", parameterId: parameter.id },
|
||||||
|
interface: state.types.interface(clause.interfaceType()!),
|
||||||
|
});
|
||||||
|
else if (!clause.OBJECT() && atomId)
|
||||||
|
templateViews.push({
|
||||||
|
target: { kind: "atom", atomId },
|
||||||
|
interface: state.types.interface(clause.interfaceType()!),
|
||||||
|
});
|
||||||
|
else
|
||||||
|
loweringIssue(state, clause, "invalid-query-view", "Expected declared object parameter or atom view");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (clause.OBJECT())
|
||||||
|
loweringIssue(state, clause, "invalid-query-view", "Object parameter views require a query template");
|
||||||
|
const atomId = requireSymbol(state, state.atoms, name, clause, "atom");
|
||||||
|
if (atomId) declaration.views.push({ atomId, interfaceRevisionId: close(clause.interfaceType()!) });
|
||||||
|
} else if (clause.MAX()) {
|
||||||
|
const name = identifier(clause.identifier(0));
|
||||||
|
const key = budgetNames[name],
|
||||||
|
n = Number(clause.INTEGER()!.getText());
|
||||||
|
if (!key || assigned.has(name) || !Number.isSafeInteger(n) || n < 1 || n > defaultQueryBudgets[key])
|
||||||
|
loweringIssue(
|
||||||
|
state,
|
||||||
|
clause,
|
||||||
|
"invalid-query-budget",
|
||||||
|
`Invalid, duplicate, or excessive query budget ${name}`,
|
||||||
|
);
|
||||||
|
else {
|
||||||
|
declaration.budgets[key] = n;
|
||||||
|
assigned.add(name);
|
||||||
|
}
|
||||||
|
} else if (clause.ALLOW()) {
|
||||||
|
if (parameters.length) {
|
||||||
|
const use = identifier(clause.identifier(1));
|
||||||
|
const reason = stringValue(clause.stringLiteral()!);
|
||||||
|
if (!["select", "predicate", "order", "aggregate", "group", "distinct"].includes(use) || !reason.trim())
|
||||||
|
loweringIssue(
|
||||||
|
state,
|
||||||
|
clause,
|
||||||
|
"invalid-query-allowance",
|
||||||
|
"Expected select/predicate/order/aggregate/group/distinct and a nonempty reason",
|
||||||
|
);
|
||||||
|
templateAllowances.push({
|
||||||
|
interface: state.types.interface(clause.interfaceType()!),
|
||||||
|
memberName: identifier(clause.identifier(0)),
|
||||||
|
uses: [use as QueryUse],
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const interfaceRevisionId = close(clause.interfaceType()!);
|
||||||
|
const contract = [...state.types.interfaces.values(), ...state.types.applications.values()].find(
|
||||||
|
(entry) => entry.revisionId === interfaceRevisionId,
|
||||||
|
);
|
||||||
|
const member = contract?.members.find((entry) => entry.displayName === identifier(clause.identifier(0)));
|
||||||
|
const use = identifier(clause.identifier(1));
|
||||||
|
const reason = stringValue(clause.stringLiteral()!);
|
||||||
|
if (
|
||||||
|
!member ||
|
||||||
|
!["select", "predicate", "order", "aggregate", "group", "distinct"].includes(use) ||
|
||||||
|
!reason.trim()
|
||||||
|
)
|
||||||
|
loweringIssue(
|
||||||
|
state,
|
||||||
|
clause,
|
||||||
|
"invalid-query-allowance",
|
||||||
|
"Query allowances name an exact member, use (select/predicate/order/aggregate/group/distinct), and nonempty reason",
|
||||||
|
);
|
||||||
|
else
|
||||||
|
declaration.allowances.push({
|
||||||
|
interfaceRevisionId,
|
||||||
|
memberId: member.id,
|
||||||
|
uses: [use as QueryUse],
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
} else if (clause.WATCH()) declaration.watch = true;
|
||||||
|
else if (clause.POLL()) {
|
||||||
|
const intervalMs = Number(clause.INTEGER()!.getText()),
|
||||||
|
reason = stringValue(clause.stringLiteral()!);
|
||||||
|
if (declaration.polling || !Number.isSafeInteger(intervalMs) || intervalMs < 1000 || !reason.trim())
|
||||||
|
loweringIssue(
|
||||||
|
state,
|
||||||
|
clause,
|
||||||
|
"invalid-query-polling",
|
||||||
|
"Polling needs one interval of at least 1000ms and a reason",
|
||||||
|
);
|
||||||
|
else declaration.polling = { intervalMs, reason };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
[...queries, ...queryTemplates.map((entry) => entry.declaration)].some(
|
||||||
|
(entry) => entry.id === declaration.id || entry.displayName === declaration.displayName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loweringIssue(state, query, "duplicate-query", "Duplicate query name or ID");
|
||||||
|
if (parameters.length) {
|
||||||
|
const { root, views: _views, allowances: _allowances, ...common } = declaration;
|
||||||
|
queryTemplates.push({
|
||||||
|
declaration: common,
|
||||||
|
parameters,
|
||||||
|
root: expressions.get(root)!,
|
||||||
|
views: templateViews,
|
||||||
|
allowances: templateAllowances,
|
||||||
|
});
|
||||||
|
} else queries.push(declaration);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
state.types.parameters = previousParameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
const operation = exportContext.packageOperationExport();
|
const operation = exportContext.packageOperationExport();
|
||||||
const generic = operation ?? exportContext.packageFunctionExport();
|
const generic = operation ?? exportContext.packageFunctionExport();
|
||||||
if (generic?.typeParameters()) {
|
if (generic?.typeParameters()) {
|
||||||
@@ -1155,6 +1406,34 @@ const lowerPackage = (
|
|||||||
state.types.self = previousSelf;
|
state.types.self = previousSelf;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
for (const entry of context.packageExport()) {
|
||||||
|
const specialized = entry.packageQuerySpecialization();
|
||||||
|
if (!specialized) continue;
|
||||||
|
const templateName = identifier(specialized.identifier(1));
|
||||||
|
const template = queryTemplates.find((entry) => entry.declaration.displayName === templateName);
|
||||||
|
if (!template) {
|
||||||
|
loweringIssue(state, specialized, "unknown-query-template", `Unknown local query template ${templateName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const substitution = new TypeSubstitution(state.types.environment());
|
||||||
|
const arguments_ = state.types
|
||||||
|
.arguments(specialized.typeArguments())
|
||||||
|
.map((argument) => substitution.argument(argument));
|
||||||
|
const declaration = specializeQueryTemplate(
|
||||||
|
template,
|
||||||
|
arguments_,
|
||||||
|
state.types.environment(),
|
||||||
|
() => [...state.types.definitions.values()],
|
||||||
|
{ id: stringValue(specialized.stringLiteral()), displayName: identifier(specialized.identifier(0)) },
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
[...queries, ...queryTemplates.map((entry) => entry.declaration)].some(
|
||||||
|
(entry) => entry.id === declaration.id || entry.displayName === declaration.displayName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loweringIssue(state, specialized, "duplicate-query", "Duplicate query name or ID");
|
||||||
|
queries.push(declaration);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
packageId: capabilityId.package(stringValue(context.stringLiteral(0))),
|
packageId: capabilityId.package(stringValue(context.stringLiteral(0))),
|
||||||
revisionId: state.packages.get(alias)!.revisionId,
|
revisionId: state.packages.get(alias)!.revisionId,
|
||||||
@@ -1162,6 +1441,11 @@ const lowerPackage = (
|
|||||||
displayName: alias,
|
displayName: alias,
|
||||||
source,
|
source,
|
||||||
exports,
|
exports,
|
||||||
|
...(queries.length ? { queries } : {}),
|
||||||
|
...(queryTemplates.length ? { queryTemplates } : {}),
|
||||||
|
...(queries.some((query) => query.argumentRequirements?.length)
|
||||||
|
? { argumentRequirements: queries.flatMap((query) => query.argumentRequirements ?? []) }
|
||||||
|
: {}),
|
||||||
...(genericExports.length ? { genericExports } : {}),
|
...(genericExports.length ? { genericExports } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1291,6 +1575,13 @@ const lowerBoundDependencies = (
|
|||||||
);
|
);
|
||||||
return projectionId ? { edgeTypeId: attachment.attachment.id, projectionId } : undefined;
|
return projectionId ? { edgeTypeId: attachment.attachment.id, projectionId } : undefined;
|
||||||
};
|
};
|
||||||
|
if (entry.QUERY()) {
|
||||||
|
const query = lowerQueryReference(state, identifier(entry.identifier(1)), identifier(entry.identifier(2)), entry);
|
||||||
|
const via = entry.VIA() ? traversal(identifier(entry.identifier(3)), identifier(entry.identifier(4))) : undefined;
|
||||||
|
return query && (!entry.VIA() || via)
|
||||||
|
? [{ portId, binding: { kind: "query", ...query, ...(via ? { via } : {}) } }]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
if (entry.STATE()) {
|
if (entry.STATE()) {
|
||||||
const attachmentName = identifier(entry.identifier(1));
|
const attachmentName = identifier(entry.identifier(1));
|
||||||
const attachment = requireSymbol(state, state.attachments, attachmentName, entry, "attachment");
|
const attachment = requireSymbol(state, state.attachments, attachmentName, entry, "attachment");
|
||||||
@@ -1568,6 +1859,7 @@ const lowerConformance = (
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
operationId,
|
operationId,
|
||||||
|
...(bindingContext.stringLiteral() ? { queryReason: stringValue(bindingContext.stringLiteral()!) } : {}),
|
||||||
binding: {
|
binding: {
|
||||||
kind: "package" as const,
|
kind: "package" as const,
|
||||||
packageRevisionId: packageSymbol.revisionId,
|
packageRevisionId: packageSymbol.revisionId,
|
||||||
|
|||||||
@@ -4,13 +4,20 @@ import { parseQx, validateQxImportPath, walkSyntax } from "./source.js";
|
|||||||
|
|
||||||
export const readQxSource = async (root: string, name: string) => {
|
export const readQxSource = async (root: string, name: string) => {
|
||||||
validateQxImportPath(name);
|
validateQxImportPath(name);
|
||||||
|
return readRepositorySource(root, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Refuse symlinks at every component, including the final source file. */
|
||||||
|
export const readRepositorySource = async (root: string, name: string) => {
|
||||||
|
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(name))
|
||||||
|
throw new Error(`Invalid repository-relative source path: ${name}`);
|
||||||
let current = path.resolve(root);
|
let current = path.resolve(root);
|
||||||
const segments = name.split("/");
|
const segments = name.split("/");
|
||||||
for (const [index, segment] of segments.entries()) {
|
for (const [index, segment] of segments.entries()) {
|
||||||
current = path.join(current, segment);
|
current = path.join(current, segment);
|
||||||
const stat = await lstat(current);
|
const stat = await lstat(current);
|
||||||
if (stat.isSymbolicLink() || (index === segments.length - 1 ? !stat.isFile() : !stat.isDirectory()))
|
if (stat.isSymbolicLink() || (index === segments.length - 1 ? !stat.isFile() : !stat.isDirectory()))
|
||||||
throw new Error(`QX imports must be ordinary files beneath ordinary directories: ${name}`);
|
throw new Error(`Sources must be ordinary files beneath ordinary directories: ${name}`);
|
||||||
}
|
}
|
||||||
return readFile(current, "utf8");
|
return readFile(current, "utf8");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type Change = { file: string; before: string | null; after: string; mode: number
|
|||||||
type Journal = { schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[] };
|
type Journal = { schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[] };
|
||||||
const safeFile = (file: string) => {
|
const safeFile = (file: string) => {
|
||||||
if (
|
if (
|
||||||
!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|css|mjs|json|nix|txtpb|md))$/.test(
|
!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|graphql|lock|ts|tsx|css|mjs|json|nix|txtpb|md))$/.test(
|
||||||
file,
|
file,
|
||||||
) ||
|
) ||
|
||||||
file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))
|
file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))
|
||||||
|
|||||||
@@ -132,6 +132,21 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
|
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
|
||||||
|
if (binding.kind === "query") {
|
||||||
|
const pkg = workspace.packageImports.find((pkg) => pkg.revisionId === binding.packageRevisionId);
|
||||||
|
const query = pkg?.checkedQueries?.find((query) => query.declaration.id === binding.queryId);
|
||||||
|
if (!query) throw new Error(`Missing checked query contract ${binding.packageRevisionId}:${binding.queryId}`);
|
||||||
|
const key = `query:${binding.packageRevisionId}:${binding.queryId}`;
|
||||||
|
const queryNode = nodes.get(key) ?? node(key, query);
|
||||||
|
parent.dependencies.add(key);
|
||||||
|
for (const effect of query.effects) {
|
||||||
|
queryNode.dependencies.add(`interface:${effect.interfaceRevisionId}`);
|
||||||
|
for (const conformance of workspace.conformances.filter(
|
||||||
|
(entry) => entry.interfaceRevisionId === effect.interfaceRevisionId,
|
||||||
|
))
|
||||||
|
queryNode.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
|
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
|
||||||
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
|
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
|
||||||
if (binding.kind === "constructor") {
|
if (binding.kind === "constructor") {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
|
|
||||||
export type GenericDependencyPort = Omit<DependencyPort, "requirement"> & {
|
export type GenericDependencyPort = Omit<DependencyPort, "requirement"> & {
|
||||||
requirement:
|
requirement:
|
||||||
|
| Extract<DependencyPort["requirement"], { kind: "query" }>
|
||||||
| {
|
| {
|
||||||
kind: "state";
|
kind: "state";
|
||||||
valueType: ValueTypeExpression;
|
valueType: ValueTypeExpression;
|
||||||
@@ -79,6 +80,8 @@ export const specializePackageExport = (
|
|||||||
const ports: DependencyPort[] = definition.dependencyPorts.map((port) => {
|
const ports: DependencyPort[] = definition.dependencyPorts.map((port) => {
|
||||||
const requirement = port.requirement;
|
const requirement = port.requirement;
|
||||||
switch (requirement.kind) {
|
switch (requirement.kind) {
|
||||||
|
case "query":
|
||||||
|
return { ...port, requirement };
|
||||||
case "state":
|
case "state":
|
||||||
return { ...port, requirement: { ...requirement, valueType: substitution.value(requirement.valueType) } };
|
return { ...port, requirement: { ...requirement, valueType: substitution.value(requirement.valueType) } };
|
||||||
case "edge":
|
case "edge":
|
||||||
|
|||||||
@@ -87,14 +87,21 @@ export const instantiateInterface = (
|
|||||||
const common = { id: member.id, displayName: member.displayName, operations: member.operations.map(operation) };
|
const common = { id: member.id, displayName: member.displayName, operations: member.operations.map(operation) };
|
||||||
switch (member.kind) {
|
switch (member.kind) {
|
||||||
case "value":
|
case "value":
|
||||||
return { ...common, kind: "value", valueType: substitution.value(member.valueType, member.displayName) };
|
return {
|
||||||
|
...common,
|
||||||
|
kind: "value",
|
||||||
|
...(member.queryRead ? { queryRead: { ...member.queryRead } } : {}),
|
||||||
|
valueType: substitution.value(member.valueType, member.displayName),
|
||||||
|
};
|
||||||
case "relationship":
|
case "relationship":
|
||||||
return {
|
return {
|
||||||
...common,
|
...common,
|
||||||
kind: "relationship",
|
kind: "relationship",
|
||||||
|
...(member.queryRead ? { queryRead: { ...member.queryRead } } : {}),
|
||||||
target: substitution.object(member.target, member.displayName),
|
target: substitution.object(member.target, member.displayName),
|
||||||
cardinality: member.cardinality,
|
cardinality: member.cardinality,
|
||||||
ordered: member.ordered,
|
ordered: member.ordered,
|
||||||
|
...(member.keyType ? { keyType: member.keyType } : {}),
|
||||||
};
|
};
|
||||||
case "operation":
|
case "operation":
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ export interface InterfaceOperation<Type = ValueType> {
|
|||||||
eventType?: Type;
|
eventType?: Type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QueryReadContract = { execution: "native" | "rpc-permitted" };
|
||||||
|
|
||||||
interface InterfaceMemberBase<Type = ValueType> {
|
interface InterfaceMemberBase<Type = ValueType> {
|
||||||
id: MemberId;
|
id: MemberId;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -124,6 +126,7 @@ interface InterfaceMemberBase<Type = ValueType> {
|
|||||||
|
|
||||||
export interface ValueInterfaceMember<Type = ValueType> extends InterfaceMemberBase<Type> {
|
export interface ValueInterfaceMember<Type = ValueType> extends InterfaceMemberBase<Type> {
|
||||||
kind: "value";
|
kind: "value";
|
||||||
|
queryRead?: QueryReadContract;
|
||||||
valueType: Type;
|
valueType: Type;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +144,11 @@ export interface RelationshipInterfaceMember<
|
|||||||
Target = EdgeEndpointConstraint,
|
Target = EdgeEndpointConstraint,
|
||||||
> extends InterfaceMemberBase<Type> {
|
> extends InterfaceMemberBase<Type> {
|
||||||
kind: "relationship";
|
kind: "relationship";
|
||||||
|
queryRead?: QueryReadContract;
|
||||||
target: Target;
|
target: Target;
|
||||||
cardinality: EdgeCardinality;
|
cardinality: EdgeCardinality;
|
||||||
ordered: boolean;
|
ordered: boolean;
|
||||||
|
keyType?: "string" | "boolean" | "int64";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A named callable capability that is not value or relationship sugar. */
|
/** A named callable capability that is not value or relationship sugar. */
|
||||||
@@ -218,6 +223,7 @@ export type PackageReceiverRequirement =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DependencyPortRequirement =
|
export type DependencyPortRequirement =
|
||||||
|
| { kind: "query"; packageRevisionId: PackageRevisionId; queryId: string }
|
||||||
| {
|
| {
|
||||||
kind: "state";
|
kind: "state";
|
||||||
valueType: ValueType;
|
valueType: ValueType;
|
||||||
@@ -270,6 +276,9 @@ export interface PackageConstructorExport extends PackageExportBase {
|
|||||||
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
|
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
|
||||||
|
|
||||||
export interface PackageRevision {
|
export interface PackageRevision {
|
||||||
|
queries?: import("../query/types.js").QueryDeclaration[];
|
||||||
|
checkedQueries?: import("../query/types.js").CheckedQuery[];
|
||||||
|
queryTemplates?: import("../query/types.js").QueryTemplate[];
|
||||||
genericExports?: import("./generic-packages.js").GenericPackageExport[];
|
genericExports?: import("./generic-packages.js").GenericPackageExport[];
|
||||||
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
|
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
|
||||||
migrationCatalog?: import("./migrations.js").MigrationCatalog;
|
migrationCatalog?: import("./migrations.js").MigrationCatalog;
|
||||||
@@ -283,6 +292,7 @@ export interface PackageRevision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DependencyBinding =
|
export type DependencyBinding =
|
||||||
|
| { kind: "query"; packageRevisionId: PackageRevisionId; queryId: string; via?: EdgeTraversal }
|
||||||
| { kind: "state"; slotId: SlotId; via?: EdgeTraversal }
|
| { kind: "state"; slotId: SlotId; via?: EdgeTraversal }
|
||||||
| {
|
| {
|
||||||
kind: "edge";
|
kind: "edge";
|
||||||
@@ -342,6 +352,8 @@ export type Binding =
|
|||||||
export interface OperationBinding {
|
export interface OperationBinding {
|
||||||
operationId: OperationId;
|
operationId: OperationId;
|
||||||
binding: Binding;
|
binding: Binding;
|
||||||
|
/** Required for package-backed reads of an RPC-permitted queryable field. */
|
||||||
|
queryReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Conformance {
|
export interface Conformance {
|
||||||
@@ -363,6 +375,7 @@ export interface AtomConstructorBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceRevision {
|
export interface WorkspaceRevision {
|
||||||
|
linkedQueries?: import("../query/link.js").LinkedQuery[];
|
||||||
id: WorkspaceRevisionId;
|
id: WorkspaceRevisionId;
|
||||||
workspaceId: WorkspaceId;
|
workspaceId: WorkspaceId;
|
||||||
parentRevisionIds: WorkspaceRevisionId[];
|
parentRevisionIds: WorkspaceRevisionId[];
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import { specializePackageExport } from "./generic-packages.js";
|
|||||||
import { isDeepStrictEqual } from "node:util";
|
import { isDeepStrictEqual } from "node:util";
|
||||||
|
|
||||||
export type CapabilityValidationIssueCode =
|
export type CapabilityValidationIssueCode =
|
||||||
|
| "invalid-query-contract"
|
||||||
|
| "query-provider-reason-required"
|
||||||
| "invalid-semantic-major"
|
| "invalid-semantic-major"
|
||||||
| "duplicate-conformance-id"
|
| "duplicate-conformance-id"
|
||||||
| "required-value"
|
| "required-value"
|
||||||
@@ -502,6 +504,33 @@ const collectIdentityIndexes = (
|
|||||||
const operations = new Map<OperationId, InterfaceOperationEntry>();
|
const operations = new Map<OperationId, InterfaceOperationEntry>();
|
||||||
for (const [memberIndex, member] of revision.members.entries()) {
|
for (const [memberIndex, member] of revision.members.entries()) {
|
||||||
const memberPath = `${path}.members[${memberIndex}]`;
|
const memberPath = `${path}.members[${memberIndex}]`;
|
||||||
|
if (
|
||||||
|
member.kind === "relationship" &&
|
||||||
|
member.keyType !== undefined &&
|
||||||
|
(!["string", "boolean", "int64"].includes(member.keyType) ||
|
||||||
|
!["many", "many-unique"].includes(member.cardinality))
|
||||||
|
)
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-query-contract",
|
||||||
|
memberPath,
|
||||||
|
"Keyed relationships require many cardinality and string, boolean or int64 keys",
|
||||||
|
);
|
||||||
|
if (member.kind !== "operation" && member.queryRead) {
|
||||||
|
const contract = member.queryRead.execution;
|
||||||
|
const getter = member.kind === "value" ? "get" : "resolve";
|
||||||
|
if (contract !== "native" && contract !== "rpc-permitted")
|
||||||
|
issue(issues, "invalid-query-contract", memberPath, "Unknown query execution contract");
|
||||||
|
if (member.operations.filter((operation) => operation.displayName === getter).length !== 1)
|
||||||
|
issue(issues, "invalid-query-contract", memberPath, `Queryable members require exactly one ${getter}`);
|
||||||
|
if (member.kind === "relationship" && contract === "rpc-permitted")
|
||||||
|
issue(issues, "invalid-query-contract", memberPath, "RPC query relationships are not supported");
|
||||||
|
if (member.kind === "value") {
|
||||||
|
const type = member.valueType.kind === "optional" ? member.valueType.value : member.valueType;
|
||||||
|
if (type.kind !== "scalar")
|
||||||
|
issue(issues, "invalid-query-contract", memberPath, "Queryable values must be scalar or optional scalar");
|
||||||
|
}
|
||||||
|
}
|
||||||
requireText(issues, member.id, `${memberPath}.id`, "Member ID");
|
requireText(issues, member.id, `${memberPath}.id`, "Member ID");
|
||||||
requireText(issues, member.displayName, `${memberPath}.displayName`, "Member name");
|
requireText(issues, member.displayName, `${memberPath}.displayName`, "Member name");
|
||||||
if (memberIds.has(member.id)) {
|
if (memberIds.has(member.id)) {
|
||||||
@@ -967,6 +996,21 @@ const validatePackages = (
|
|||||||
for (const [portIndex, port] of entry.dependencyPorts.entries()) {
|
for (const [portIndex, port] of entry.dependencyPorts.entries()) {
|
||||||
const portPath = `${exportPath}.dependencyPorts[${portIndex}]`;
|
const portPath = `${exportPath}.dependencyPorts[${portIndex}]`;
|
||||||
switch (port.requirement.kind) {
|
switch (port.requirement.kind) {
|
||||||
|
case "query":
|
||||||
|
if (
|
||||||
|
!indexes.packages
|
||||||
|
.get(port.requirement.packageRevisionId)
|
||||||
|
?.revision.queries?.some(
|
||||||
|
(query) => port.requirement.kind === "query" && query.id === port.requirement.queryId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"unresolved-reference",
|
||||||
|
portPath,
|
||||||
|
`Unknown query ${port.requirement.packageRevisionId}:${port.requirement.queryId}`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
case "state":
|
case "state":
|
||||||
validateValueType(issues, port.requirement.valueType, `${portPath}.requirement.valueType`, indexes);
|
validateValueType(issues, port.requirement.valueType, `${portPath}.requirement.valueType`, indexes);
|
||||||
uniquePrimitiveList(issues, port.requirement.primitives, `${portPath}.requirement.primitives`);
|
uniquePrimitiveList(issues, port.requirement.primitives, `${portPath}.requirement.primitives`);
|
||||||
@@ -1116,6 +1160,41 @@ const validateBoundDependencies = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
switch (requirement.kind) {
|
switch (requirement.kind) {
|
||||||
|
case "query": {
|
||||||
|
const selected = binding as Extract<DependencyBinding, { kind: "query" }>;
|
||||||
|
const query = params.indexes.packages
|
||||||
|
.get(requirement.packageRevisionId)
|
||||||
|
?.revision.queries?.find((query) => query.id === requirement.queryId);
|
||||||
|
if (
|
||||||
|
!query ||
|
||||||
|
selected.queryId !== requirement.queryId ||
|
||||||
|
selected.packageRevisionId !== requirement.packageRevisionId
|
||||||
|
) {
|
||||||
|
issue(issues, "invalid-dependency-binding", path, "Query port must bind its exact checked query export");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const traversal = selected.via
|
||||||
|
? validateTraversal(
|
||||||
|
issues,
|
||||||
|
params.indexes,
|
||||||
|
params.atomId,
|
||||||
|
selected.via,
|
||||||
|
`${path}.binding.via`,
|
||||||
|
params.conformance,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const targets = selected.via
|
||||||
|
? traversal
|
||||||
|
? [...params.indexes.atoms.keys()].filter((atom) =>
|
||||||
|
atomSatisfiesConstraint(atom as AtomId, traversal.target.constraint, params.indexes.conformances),
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
: [params.atomId];
|
||||||
|
for (const atom of targets)
|
||||||
|
if (!params.indexes.conformances.has(conformanceKey(atom as AtomId, query.root)))
|
||||||
|
issue(issues, "unsatisfied-interface", path, `Query root ${atom} does not conform to ${query.root}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "state": {
|
case "state": {
|
||||||
const stateBinding = binding as Extract<DependencyBinding, { kind: "state" }>;
|
const stateBinding = binding as Extract<DependencyBinding, { kind: "state" }>;
|
||||||
const attachment = findAttachment(params.indexes, "state", stateBinding.slotId);
|
const attachment = findAttachment(params.indexes, "state", stateBinding.slotId);
|
||||||
@@ -1424,6 +1503,31 @@ const validateConformances = (
|
|||||||
}
|
}
|
||||||
const operation = operationEntry.operation;
|
const operation = operationEntry.operation;
|
||||||
const binding = entry.binding;
|
const binding = entry.binding;
|
||||||
|
const member = operationEntry.member;
|
||||||
|
if (
|
||||||
|
member.kind !== "operation" &&
|
||||||
|
member.queryRead &&
|
||||||
|
operation.displayName === (member.kind === "value" ? "get" : "resolve")
|
||||||
|
) {
|
||||||
|
const native =
|
||||||
|
member.kind === "value"
|
||||||
|
? binding.kind === "state" && binding.primitive === "read"
|
||||||
|
: binding.kind === "edge" && binding.primitive === "resolve";
|
||||||
|
if (!native && (member.queryRead.execution === "native" || binding.kind !== "package"))
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-query-contract",
|
||||||
|
bindingPath,
|
||||||
|
"This queryable contract requires a native read binding; a justification cannot weaken it",
|
||||||
|
);
|
||||||
|
else if (!native && !entry.queryReason?.trim())
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"query-provider-reason-required",
|
||||||
|
bindingPath,
|
||||||
|
"Package-backed query reads require a concrete query-reason justification",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (operation.scope === "class" && (!conformance.id || operation.mode !== "call")) {
|
if (operation.scope === "class" && (!conformance.id || operation.mode !== "call")) {
|
||||||
issue(
|
issue(
|
||||||
issues,
|
issues,
|
||||||
@@ -1541,7 +1645,8 @@ const validateConformances = (
|
|||||||
operationEntry.member.target,
|
operationEntry.member.target,
|
||||||
indexes.conformances,
|
indexes.conformances,
|
||||||
) ||
|
) ||
|
||||||
operationEntry.member.cardinality !== projection.endpoint.cardinality
|
operationEntry.member.cardinality !== projection.endpoint.cardinality ||
|
||||||
|
(operationEntry.member.keyType !== undefined && operationEntry.member.keyType !== projection.endpoint.keyType)
|
||||||
) {
|
) {
|
||||||
issue(
|
issue(
|
||||||
issues,
|
issues,
|
||||||
@@ -2049,6 +2154,21 @@ export const computeCapabilityClosure = (
|
|||||||
const includeDependencies = (atomId: AtomId, dependencies: readonly BoundDependency[]) => {
|
const includeDependencies = (atomId: AtomId, dependencies: readonly BoundDependency[]) => {
|
||||||
for (const dependency of dependencies) {
|
for (const dependency of dependencies) {
|
||||||
switch (dependency.binding.kind) {
|
switch (dependency.binding.kind) {
|
||||||
|
case "query": {
|
||||||
|
packages.add(dependency.binding.packageRevisionId);
|
||||||
|
const query = plan.packages
|
||||||
|
.get(dependency.binding.packageRevisionId)
|
||||||
|
?.checkedQueries?.find(
|
||||||
|
(query) => dependency.binding.kind === "query" && query.declaration.id === dependency.binding.queryId,
|
||||||
|
);
|
||||||
|
if (dependency.binding.via) attachments.add(dependency.binding.via.edgeTypeId);
|
||||||
|
for (const effect of query?.effects ?? []) {
|
||||||
|
for (const conformance of sourceConformances.values())
|
||||||
|
if (conformance.interfaceRevisionId === effect.interfaceRevisionId)
|
||||||
|
queued.push({ atomId: conformance.atomId, interfaceRevisionId: conformance.interfaceRevisionId });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "state":
|
case "state":
|
||||||
attachments.add(dependency.binding.slotId);
|
attachments.add(dependency.binding.slotId);
|
||||||
if (dependency.binding.via) {
|
if (dependency.binding.via) {
|
||||||
|
|||||||
+728
-32
File diff suppressed because one or more lines are too long
+1224
-1
File diff suppressed because one or more lines are too long
+226
-65
File diff suppressed because one or more lines are too long
+121
-6
@@ -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("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zIkQKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCSJCChBQYWNrYWdlRXhwb3J0UmVmEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSEQoJZXhwb3J0X2lkGAIgASgJIsQBChJJbmplY3RlZERlcGVuZGVuY3kSDwoHcG9ydF9pZBgBIAEoCRIXCg1zdGF0ZV9zbG90X2lkGAIgASgJSAASJgoEZWRnZRgDIAEoCzIWLnF1aXhvcy5FZGdlRGVwZW5kZW5jeUgAEh8KFWludGVyZmFjZV9yZXZpc2lvbl9pZBgEIAEoCUgAEh0KE2NvbnN0cnVjdG9yX2F0b21faWQYBSABKAlIABIRCglvYmplY3RfaWQYBiABKAlCCQoHYmluZGluZyI9Cg5FZGdlRGVwZW5kZW5jeRIUCgxlZGdlX3R5cGVfaWQYASABKAkSFQoNcHJvamVjdGlvbl9pZBgCIAEoCWIGcHJvdG8z");
|
fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zInUKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCRIvCgtjb25mb3JtYW5jZRgDIAEoCzIaLnF1aXhvcy5Db25mb3JtYW5jZVdpdG5lc3Mi0QEKDEZpZWxkRWRpdGluZxIbChNnZXR0ZXJfb3BlcmF0aW9uX2lkGAEgASgJEhsKE3NldHRlcl9vcGVyYXRpb25faWQYAiABKAkSFQoNZG9jdW1lbnRfdHlwZRgDIAEoCRIWCg5iaW5kaW5nX2RpZ2VzdBgEIAEoCRInCgRtb2RlGAUgASgOMhkucXVpeG9zLkZpZWxkRWRpdGluZy5Nb2RlIi8KBE1vZGUSDwoLVU5TUEVDSUZJRUQQABIMCghSRUdJU1RFUhABEggKBENSRFQQAiKWAQoSQ29uZm9ybWFuY2VXaXRuZXNzEhEKCW9iamVjdF9pZBgBIAEoCRIdChVpbnRlcmZhY2VfcmV2aXNpb25faWQYAiABKAkSFgoOY29uZm9ybWFuY2VfaWQYAyABKAkSHQoVd29ya3NwYWNlX3JldmlzaW9uX2lkGAQgASgJEhcKD3dvcmtzcGFjZV9lcG9jaBgFIAEoCSJCChBQYWNrYWdlRXhwb3J0UmVmEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSEQoJZXhwb3J0X2lkGAIgASgJItgBChJJbmplY3RlZERlcGVuZGVuY3kSDwoHcG9ydF9pZBgBIAEoCRIXCg1zdGF0ZV9zbG90X2lkGAIgASgJSAASJgoEZWRnZRgDIAEoCzIWLnF1aXhvcy5FZGdlRGVwZW5kZW5jeUgAEh8KFWludGVyZmFjZV9yZXZpc2lvbl9pZBgEIAEoCUgAEh0KE2NvbnN0cnVjdG9yX2F0b21faWQYBSABKAlIABISCghxdWVyeV9pZBgHIAEoCUgAEhEKCW9iamVjdF9pZBgGIAEoCUIJCgdiaW5kaW5nIj0KDkVkZ2VEZXBlbmRlbmN5EhQKDGVkZ2VfdHlwZV9pZBgBIAEoCRIVCg1wcm9qZWN0aW9uX2lkGAIgASgJYgZwcm90bzM");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.CapabilityRef
|
* @generated from message quixos.CapabilityRef
|
||||||
@@ -25,6 +25,13 @@ export type CapabilityRef = Message<"quixos.CapabilityRef"> & {
|
|||||||
* @generated from field: string operation_id = 2;
|
* @generated from field: string operation_id = 2;
|
||||||
*/
|
*/
|
||||||
operationId: string;
|
operationId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional fence for a view acquired through TryConform. Not an authority grant.
|
||||||
|
*
|
||||||
|
* @generated from field: quixos.ConformanceWitness conformance = 3;
|
||||||
|
*/
|
||||||
|
conformance?: ConformanceWitness | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +41,108 @@ 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
|
||||||
|
*/
|
||||||
|
export type ConformanceWitness = Message<"quixos.ConformanceWitness"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string object_id = 1;
|
||||||
|
*/
|
||||||
|
objectId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string interface_revision_id = 2;
|
||||||
|
*/
|
||||||
|
interfaceRevisionId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string conformance_id = 3;
|
||||||
|
*/
|
||||||
|
conformanceId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string workspace_revision_id = 4;
|
||||||
|
*/
|
||||||
|
workspaceRevisionId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string workspace_epoch = 5;
|
||||||
|
*/
|
||||||
|
workspaceEpoch: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message quixos.ConformanceWitness.
|
||||||
|
* Use `create(ConformanceWitnessSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ConformanceWitnessSchema: GenMessage<ConformanceWitness> = /*@__PURE__*/
|
||||||
|
messageDesc(file_quixos_refs, 2);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.PackageExportRef
|
* @generated from message quixos.PackageExportRef
|
||||||
*/
|
*/
|
||||||
@@ -54,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, 1);
|
messageDesc(file_quixos_refs, 3);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.InjectedDependency
|
* @generated from message quixos.InjectedDependency
|
||||||
@@ -92,6 +201,12 @@ export type InjectedDependency = Message<"quixos.InjectedDependency"> & {
|
|||||||
*/
|
*/
|
||||||
value: string;
|
value: string;
|
||||||
case: "constructorAtomId";
|
case: "constructorAtomId";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: string query_id = 7;
|
||||||
|
*/
|
||||||
|
value: string;
|
||||||
|
case: "queryId";
|
||||||
} | { case: undefined; value?: undefined };
|
} | { case: undefined; value?: undefined };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,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, 2);
|
messageDesc(file_quixos_refs, 4);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.EdgeDependency
|
* @generated from message quixos.EdgeDependency
|
||||||
@@ -130,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, 3);
|
messageDesc(file_quixos_refs, 5);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import {
|
||||||
|
parse,
|
||||||
|
Source,
|
||||||
|
validate,
|
||||||
|
specifiedRules,
|
||||||
|
print,
|
||||||
|
printSchema,
|
||||||
|
visit,
|
||||||
|
TypeInfo,
|
||||||
|
visitWithTypeInfo,
|
||||||
|
getNamedType,
|
||||||
|
isNonNullType,
|
||||||
|
isListType,
|
||||||
|
isObjectType,
|
||||||
|
isInputObjectType,
|
||||||
|
isScalarType,
|
||||||
|
isEnumType,
|
||||||
|
typeFromAST,
|
||||||
|
type GraphQLType,
|
||||||
|
type SelectionSetNode,
|
||||||
|
type FragmentDefinitionNode,
|
||||||
|
type ValueNode,
|
||||||
|
type ASTNode,
|
||||||
|
type DocumentNode,
|
||||||
|
type DirectiveNode,
|
||||||
|
} from "graphql";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { canonicalJson } from "../capability-model/evolution.js";
|
||||||
|
import { readRepositorySource } from "../capability-language/source-loader.js";
|
||||||
|
import {
|
||||||
|
valueType,
|
||||||
|
type InterfaceRevision,
|
||||||
|
type ValueType,
|
||||||
|
type InterfaceRevisionId,
|
||||||
|
} from "../capability-model/types.js";
|
||||||
|
import { querySchema } from "./schema.js";
|
||||||
|
import { objectRow } from "./relational-schema.js";
|
||||||
|
import { relationalCompiler } from "./relational-compile.js";
|
||||||
|
import {
|
||||||
|
QueryCompileError,
|
||||||
|
type QueryDeclaration,
|
||||||
|
type CheckedQuery,
|
||||||
|
type QueryFieldEffect,
|
||||||
|
type QueryUse,
|
||||||
|
type QueryArgument,
|
||||||
|
type QuerySelection,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const 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,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Only immutable repository sources call this. Runtime requests never compile documents. */
|
||||||
|
export async function compileQuery(
|
||||||
|
declaration: QueryDeclaration,
|
||||||
|
interfaces: readonly InterfaceRevision[],
|
||||||
|
read: (name: string) => Promise<string>,
|
||||||
|
): Promise<CheckedQuery> {
|
||||||
|
const paths = [declaration.document, ...declaration.fragments];
|
||||||
|
if (paths.length > 128 || new Set(paths).size !== paths.length)
|
||||||
|
fail("QUERY_SOURCE_LIMIT", "Query files must be distinct and bounded to 128");
|
||||||
|
const definitions: DocumentNode["definitions"][number][] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (const path of paths) {
|
||||||
|
if (
|
||||||
|
!path.endsWith(".graphql") ||
|
||||||
|
path.startsWith("/") ||
|
||||||
|
path.split(/[\\/]/).some((part) => !part || part === "." || part === "..")
|
||||||
|
)
|
||||||
|
fail("QUERY_SOURCE_PATH", `Invalid query source path ${path}`);
|
||||||
|
const text = await read(path);
|
||||||
|
totalBytes += Buffer.byteLength(text);
|
||||||
|
if (totalBytes > 262144) fail("QUERY_SOURCE_LIMIT", "Query sources exceed 256 KiB");
|
||||||
|
const document = parse(new Source(text, path), { maxTokens: 10000 });
|
||||||
|
definitions.push(...document.definitions);
|
||||||
|
}
|
||||||
|
const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
|
||||||
|
const generated = querySchema(declaration, interfaces, document);
|
||||||
|
const operations = definitions.filter((entry) => entry.kind === "OperationDefinition");
|
||||||
|
if (
|
||||||
|
operations.length !== 1 ||
|
||||||
|
operations[0]!.operation !== "query" ||
|
||||||
|
operations[0]!.name?.value !== declaration.operation
|
||||||
|
)
|
||||||
|
fail("QUERY_UNSUPPORTED_FEATURE", "Exactly one named query matching the declaration is required");
|
||||||
|
visit(document, {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
Directive(node) {
|
||||||
|
if (!["include", "skip", "live"].includes(node.name.value))
|
||||||
|
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
|
||||||
|
},
|
||||||
|
InlineFragment(node) {
|
||||||
|
fail("QUERY_UNSUPPORTED_FEATURE", "Use named fragments on the exact selected type", node);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const errors = validate(generated.schema, document, specifiedRules, { maxErrors: 20 });
|
||||||
|
if (errors.length) {
|
||||||
|
const error = errors[0]!;
|
||||||
|
fail("QUERY_VALIDATION", error.message, error.nodes?.[0]);
|
||||||
|
}
|
||||||
|
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 contract = generated.contracts.get(id)!;
|
||||||
|
const member = contract.members.find((entry) => entry.displayName === name);
|
||||||
|
if (!member || member.kind === "operation" || !member.queryRead)
|
||||||
|
return fail("QUERY_FIELD_NOT_QUERYABLE", `${contract.displayName}.${name} is not queryable`, node);
|
||||||
|
const key = `${id}\0${member.id}`;
|
||||||
|
const effect = effects.get(key) ?? {
|
||||||
|
interfaceRevisionId: id,
|
||||||
|
memberId: member.id,
|
||||||
|
uses: [],
|
||||||
|
execution: member.queryRead.execution,
|
||||||
|
};
|
||||||
|
if (!effect.uses.includes(use)) effect.uses.push(use);
|
||||||
|
effects.set(key, effect);
|
||||||
|
if (
|
||||||
|
effect.execution === "rpc-permitted" &&
|
||||||
|
!declaration.allowances.some(
|
||||||
|
(allow) =>
|
||||||
|
allow.interfaceRevisionId === id &&
|
||||||
|
allow.memberId === member.id &&
|
||||||
|
allow.uses.includes(use) &&
|
||||||
|
allow.reason.trim(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fail("QUERY_RPC_CONSUMER_REASON", `${contract.displayName}.${name} needs an allowance for ${use}`, node);
|
||||||
|
if (
|
||||||
|
declaration.watch &&
|
||||||
|
effect.execution === "rpc-permitted" &&
|
||||||
|
!declaration.polling &&
|
||||||
|
!member.operations.some((op) => op.displayName === "watch-start")
|
||||||
|
)
|
||||||
|
fail(
|
||||||
|
"QUERY_WATCH_UNSUPPORTED",
|
||||||
|
`${contract.displayName}.${name} has no watch; explicitly acknowledge polling or use a one-shot query`,
|
||||||
|
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") => {
|
||||||
|
if (use === "predicate") {
|
||||||
|
relational.predicate(objectRow(id), value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.kind === "Variable") {
|
||||||
|
// A dynamic filter may name any field in its declared input type.
|
||||||
|
for (const member of generated.contracts.get(id)!.members)
|
||||||
|
if (member.kind === "value" && member.queryRead) mark(id, member.displayName, use, value);
|
||||||
|
} else if (value.kind === "ListValue") value.values.forEach((child) => inputEffects(id, child, use));
|
||||||
|
else if (value.kind === "ObjectValue")
|
||||||
|
for (const field of value.fields) {
|
||||||
|
mark(id, field.name.value, use, field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const info = new TypeInfo(generated.schema);
|
||||||
|
visit(
|
||||||
|
document,
|
||||||
|
visitWithTypeInfo(info, {
|
||||||
|
Field(node) {
|
||||||
|
const parent = info.getParentType();
|
||||||
|
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;
|
||||||
|
mark(contract.revisionId, node.name.value, "select", node);
|
||||||
|
const member = contract.members.find((entry) => entry.displayName === node.name.value)!;
|
||||||
|
if (member.kind !== "relationship" || (member.cardinality !== "many" && member.cardinality !== "many-unique"))
|
||||||
|
return;
|
||||||
|
const target = generated.target(member);
|
||||||
|
const bounds = (node.arguments ?? []).filter(
|
||||||
|
(argument) => argument.name.value === "first" || argument.name.value === "all",
|
||||||
|
);
|
||||||
|
if (bounds.length !== 1) fail("QUERY_ROW_LIMIT", "Specify exactly one of first or all", node);
|
||||||
|
if (bounds[0]!.name.value === "all" && node.arguments?.some((argument) => argument.name.value === "after"))
|
||||||
|
fail("QUERY_UNSUPPORTED_FEATURE", "Bounded-all does not accept a continuation", node);
|
||||||
|
for (const argument of node.arguments ?? []) {
|
||||||
|
if (argument.name.value === "where")
|
||||||
|
compiledPredicates.set(node, relational.predicate(objectRow(target), argument.value));
|
||||||
|
if (argument.name.value === "orderBy") inputEffects(target, argument.value, "order");
|
||||||
|
if (
|
||||||
|
["first", "all"].includes(argument.name.value) &&
|
||||||
|
argument.value.kind !== "Variable" &&
|
||||||
|
(argument.value.kind !== "IntValue" ||
|
||||||
|
Number(argument.value.value) < 1 ||
|
||||||
|
Number(argument.value.value) > declaration.budgets.rows)
|
||||||
|
)
|
||||||
|
fail(
|
||||||
|
"QUERY_ROW_LIMIT",
|
||||||
|
`${argument.name.value} must be between 1 and ${declaration.budgets.rows}`,
|
||||||
|
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) {
|
||||||
|
const fragment = definitions.find(
|
||||||
|
(entry) => entry.kind === "FragmentDefinition" && entry.name.value === node.name.value,
|
||||||
|
) as FragmentDefinitionNode;
|
||||||
|
if (fragment.typeCondition.name.value !== info.getParentType()?.name)
|
||||||
|
fail("QUERY_UNSUPPORTED_FEATURE", "Fragments must select the exact current type", node);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let expandedFields = 0;
|
||||||
|
// GraphQL merges repeated response keys. In particular, fragments may each
|
||||||
|
// contribute different children of one relationship; last-write-wins would
|
||||||
|
// silently remove fields from both the generated type and the wire codec.
|
||||||
|
const mergeOutput = (left: ValueType, right: ValueType): ValueType => {
|
||||||
|
const a = left.kind === "optional" ? left.value : left;
|
||||||
|
const b = right.kind === "optional" ? right.value : right;
|
||||||
|
let result = a;
|
||||||
|
if (a.kind === "record" && b.kind === "record") {
|
||||||
|
const fields = { ...a.fields };
|
||||||
|
for (const [key, type] of Object.entries(b.fields))
|
||||||
|
fields[key] = fields[key] ? mergeOutput(fields[key], type) : type;
|
||||||
|
result = { kind: "record", fields };
|
||||||
|
} else if (a.kind === "list" && b.kind === "list") result = valueType.list(mergeOutput(a.value, b.value));
|
||||||
|
return left.kind === "optional" && right.kind === "optional" ? valueType.optional(result) : result;
|
||||||
|
};
|
||||||
|
const output = (type: GraphQLType, selections?: SelectionSetNode, depth = 0, conditional = false): ValueType => {
|
||||||
|
if (depth > declaration.budgets.depth * 4 + 4) fail("QUERY_DEPTH_LIMIT", "Expanded query exceeds its depth budget");
|
||||||
|
if (isNonNullType(type)) return required(type.ofType, selections, depth, conditional);
|
||||||
|
return valueType.optional(required(type, selections, depth, conditional));
|
||||||
|
};
|
||||||
|
const required = (type: GraphQLType, selections?: SelectionSetNode, depth = 0, conditional = false): ValueType => {
|
||||||
|
if (isListType(type)) return valueType.list(output(type.ofType, selections, depth, conditional));
|
||||||
|
if (isObjectType(type)) {
|
||||||
|
const fields: Record<string, ValueType> = {};
|
||||||
|
const add = (set: SelectionSetNode, conditional = false) => {
|
||||||
|
for (const selection of set.selections) {
|
||||||
|
if (++expandedFields > 10000) fail("QUERY_WORK_LIMIT", "Expanded query exceeds 10000 fields", selection);
|
||||||
|
if (selection.kind === "FragmentSpread") {
|
||||||
|
add(fragments.get(selection.name.value)!.selectionSet, conditional || !!selection.directives?.length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (selection.kind !== "Field") continue;
|
||||||
|
const key = selection.alias?.value ?? selection.name.value;
|
||||||
|
const field = type.getFields()[selection.name.value]!;
|
||||||
|
const previous = fields[key];
|
||||||
|
fields[key] = output(
|
||||||
|
field.type,
|
||||||
|
selection.selectionSet,
|
||||||
|
depth + 1,
|
||||||
|
conditional || !!selection.directives?.some((entry) => entry.name.value !== "live"),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(conditional || selection.directives?.some((entry) => entry.name.value !== "live")) &&
|
||||||
|
fields[key]!.kind !== "optional"
|
||||||
|
)
|
||||||
|
fields[key] = valueType.optional(fields[key]!);
|
||||||
|
if (previous) fields[key] = mergeOutput(previous, fields[key]!);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (selections) add(selections, conditional);
|
||||||
|
return { kind: "record", fields };
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
generated.scalarTypes.get(getNamedType(type)!.name) ??
|
||||||
|
fail("QUERY_TYPE", `Unsupported query scalar ${getNamedType(type)!.name}`)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const variables: Record<string, ValueType> = {};
|
||||||
|
const inputShape = (type: GraphQLType, seen = new Set<string>()): ValueType => {
|
||||||
|
if (isNonNullType(type)) return inputRequired(type.ofType, seen);
|
||||||
|
return valueType.optional(inputRequired(type, seen));
|
||||||
|
};
|
||||||
|
const inputRequired = (type: GraphQLType, seen: Set<string>): ValueType => {
|
||||||
|
if (isListType(type)) return valueType.list(inputShape(type.ofType, seen));
|
||||||
|
if (isInputObjectType(type)) {
|
||||||
|
// Recursive boolean filter inputs need generated recursive TS shapes; never degrade to any.
|
||||||
|
if (seen.has(type.name))
|
||||||
|
fail(
|
||||||
|
"QUERY_UNSUPPORTED_FEATURE",
|
||||||
|
"Pass scalar variables inside fixed filter expressions instead of a whole recursive filter",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kind: "record",
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries(type.getFields()).map(([key, field]) => [
|
||||||
|
key,
|
||||||
|
inputShape(field.type, new Set([...seen, 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");
|
||||||
|
};
|
||||||
|
for (const variable of operations[0]!.variableDefinitions ?? [])
|
||||||
|
variables[variable.variable.name.value] = inputShape(typeFromAST(generated.schema, variable.type)!);
|
||||||
|
const result = required(generated.schema.getQueryType()!, operations[0]!.selectionSet);
|
||||||
|
const argument = (node: ValueNode): QueryArgument => {
|
||||||
|
switch (node.kind) {
|
||||||
|
case "Variable":
|
||||||
|
return { kind: "variable", name: node.name.value };
|
||||||
|
case "ListValue":
|
||||||
|
return { kind: "list", values: node.values.map(argument) };
|
||||||
|
case "ObjectValue":
|
||||||
|
return {
|
||||||
|
kind: "object",
|
||||||
|
fields: Object.fromEntries(node.fields.map((field) => [field.name.value, argument(field.value)])),
|
||||||
|
};
|
||||||
|
case "NullValue":
|
||||||
|
return { kind: "literal", value: null };
|
||||||
|
case "BooleanValue":
|
||||||
|
return { kind: "literal", value: node.value };
|
||||||
|
// Int literals stay decimal until their checked field codec interprets them.
|
||||||
|
default:
|
||||||
|
return { kind: "literal", value: node.value };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const conditions = (directives: readonly DirectiveNode[] = []) =>
|
||||||
|
directives
|
||||||
|
.filter((directive) => directive.name.value !== "live")
|
||||||
|
.map((directive) => ({
|
||||||
|
include: directive.name.value === "include",
|
||||||
|
value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value),
|
||||||
|
}));
|
||||||
|
const selection = (
|
||||||
|
set: SelectionSetNode,
|
||||||
|
parent: import("graphql").GraphQLObjectType,
|
||||||
|
inherited: QuerySelection["conditions"] = [],
|
||||||
|
): QuerySelection[] =>
|
||||||
|
set.selections.flatMap((node): QuerySelection[] => {
|
||||||
|
if (node.kind === "FragmentSpread")
|
||||||
|
return selection(fragments.get(node.name.value)!.selectionSet, parent, [
|
||||||
|
...inherited,
|
||||||
|
...conditions(node.directives),
|
||||||
|
]);
|
||||||
|
if (node.kind !== "Field") return [];
|
||||||
|
const contract = generated.byName.get(parent.name);
|
||||||
|
const member = contract?.members.find((entry) => entry.displayName === node.name.value);
|
||||||
|
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 [
|
||||||
|
{
|
||||||
|
name: 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?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}),
|
||||||
|
conditions: [...inherited, ...conditions(node.directives)],
|
||||||
|
arguments: Object.fromEntries(
|
||||||
|
(node.arguments ?? []).map((entry) => [entry.name.value, argument(entry.value)]),
|
||||||
|
),
|
||||||
|
selection: node.selectionSet && isObjectType(type) ? selection(node.selectionSet, type) : [],
|
||||||
|
...(plan ? { relational: plan } : {}),
|
||||||
|
...(compiledPredicates.has(node) ? { predicate: compiledPredicates.get(node)! } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const normalized = print(document),
|
||||||
|
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")
|
||||||
|
.update(
|
||||||
|
canonicalJson({
|
||||||
|
semantics: 2,
|
||||||
|
declaration,
|
||||||
|
normalized,
|
||||||
|
interfaces: [...generated.byName.values()].sort((a, b) =>
|
||||||
|
a.revisionId < b.revisionId ? -1 : a.revisionId > b.revisionId ? 1 : 0,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest("hex");
|
||||||
|
return {
|
||||||
|
declaration,
|
||||||
|
definitionDigest,
|
||||||
|
document: normalized,
|
||||||
|
schema,
|
||||||
|
variables: { kind: "record", fields: variables },
|
||||||
|
output: result,
|
||||||
|
effects: [...effects.values()],
|
||||||
|
selection: selections,
|
||||||
|
variableDefaults: Object.fromEntries(
|
||||||
|
(operations[0]!.variableDefinitions ?? [])
|
||||||
|
.filter((entry) => entry.defaultValue)
|
||||||
|
.map((entry) => [entry.variable.name.value, argument(entry.defaultValue!)]),
|
||||||
|
),
|
||||||
|
sourceFiles: paths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const compileRepositoryQuery = async (
|
||||||
|
root: string,
|
||||||
|
declaration: QueryDeclaration,
|
||||||
|
interfaces: readonly InterfaceRevision[],
|
||||||
|
) => {
|
||||||
|
const started = performance.now();
|
||||||
|
try {
|
||||||
|
return await compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name));
|
||||||
|
} finally {
|
||||||
|
// Timing belongs in check logs, never in immutable artifact identities.
|
||||||
|
console.error(
|
||||||
|
`[query-check] ${JSON.stringify(declaration.displayName)}: ${(performance.now() - started).toFixed(1)}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type {
|
||||||
|
WorkspaceRevision,
|
||||||
|
Binding,
|
||||||
|
AtomId,
|
||||||
|
InterfaceRevisionId,
|
||||||
|
MemberId,
|
||||||
|
PersistentAttachment,
|
||||||
|
} from "../capability-model/types.js";
|
||||||
|
import { QueryCompileError, type CheckedQuery } from "./types.js";
|
||||||
|
import { queryRuntimePlan } from "./proto.js";
|
||||||
|
import { canonicalJson } from "../capability-model/evolution.js";
|
||||||
|
|
||||||
|
export interface LinkedQueryField {
|
||||||
|
atomId: AtomId;
|
||||||
|
interfaceRevisionId: InterfaceRevisionId;
|
||||||
|
memberId: MemberId;
|
||||||
|
getter: string;
|
||||||
|
binding: Binding;
|
||||||
|
watch?: { start: string; stop: string; binding: Binding };
|
||||||
|
}
|
||||||
|
export interface LinkedQuery {
|
||||||
|
runtime?: import("@bufbuild/protobuf").JsonValue;
|
||||||
|
id: string;
|
||||||
|
packageRevisionId: string;
|
||||||
|
checked: CheckedQuery;
|
||||||
|
bindingDigest: string;
|
||||||
|
fields: LinkedQueryField[];
|
||||||
|
attachments: PersistentAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link every possible implementation, including atoms absent from today's data. */
|
||||||
|
export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
|
||||||
|
const interfaces = new Map(workspace.interfaceImports.map((entry) => [entry.revisionId, entry]));
|
||||||
|
const attachments = [
|
||||||
|
...workspace.sharedAttachments,
|
||||||
|
...workspace.conformances.flatMap((entry) => entry.privateAttachments),
|
||||||
|
];
|
||||||
|
const linked: LinkedQuery[] = [];
|
||||||
|
for (const pkg of workspace.packageImports)
|
||||||
|
for (const declaration of pkg.queries ?? []) {
|
||||||
|
const checked = pkg.checkedQueries?.find((query) => query.declaration.id === declaration.id);
|
||||||
|
if (!checked || JSON.stringify(checked.declaration) !== JSON.stringify(declaration))
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_ARTIFACT_MISSING",
|
||||||
|
`${pkg.displayName}.${declaration.displayName} has no checked source artifact`,
|
||||||
|
);
|
||||||
|
const fields: LinkedQueryField[] = [];
|
||||||
|
const needed = new Set<string>();
|
||||||
|
for (const view of declaration.views)
|
||||||
|
if (
|
||||||
|
!workspace.conformances.some(
|
||||||
|
(entry) => entry.atomId === view.atomId && entry.interfaceRevisionId === view.interfaceRevisionId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_VIEW_REQUIRED",
|
||||||
|
`${view.atomId} does not conform to declared query view ${view.interfaceRevisionId}`,
|
||||||
|
);
|
||||||
|
for (const effect of checked.effects) {
|
||||||
|
const contract = interfaces.get(effect.interfaceRevisionId);
|
||||||
|
const member = contract?.members.find((entry) => entry.id === effect.memberId);
|
||||||
|
if (!member || member.kind === "operation" || member.queryRead?.execution !== effect.execution)
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_STALE_CONTRACT",
|
||||||
|
`Query field ${effect.interfaceRevisionId}.${effect.memberId} changed`,
|
||||||
|
);
|
||||||
|
const getter = member.operations.find((op) => op.displayName === (member.kind === "value" ? "get" : "resolve"));
|
||||||
|
if (!getter) throw new QueryCompileError("QUERY_CONTRACT", `Missing getter ${effect.memberId}`);
|
||||||
|
for (const conformance of workspace.conformances.filter(
|
||||||
|
(entry) => entry.interfaceRevisionId === effect.interfaceRevisionId,
|
||||||
|
)) {
|
||||||
|
const provider = conformance.operationBindings.find((entry) => entry.operationId === getter.id);
|
||||||
|
if (!provider)
|
||||||
|
throw new QueryCompileError("QUERY_CONTRACT", `Missing binding ${conformance.atomId}.${getter.id}`);
|
||||||
|
const binding = provider.binding;
|
||||||
|
if (binding.kind === "package") {
|
||||||
|
if (effect.execution !== "rpc-permitted")
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_NATIVE_BINDING_REQUIRED",
|
||||||
|
`Native query field ${effect.memberId} binds package code`,
|
||||||
|
);
|
||||||
|
if (!provider.queryReason?.trim())
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_RPC_PROVIDER_REASON",
|
||||||
|
`Package query field ${effect.memberId} needs query-reason`,
|
||||||
|
);
|
||||||
|
} else if (binding.kind === "state" && binding.primitive === "read") needed.add(binding.slotId);
|
||||||
|
else if (binding.kind === "edge" && binding.primitive === "resolve") needed.add(binding.edgeTypeId);
|
||||||
|
else
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_NATIVE_BINDING_REQUIRED",
|
||||||
|
`Unsupported query read binding ${effect.memberId}`,
|
||||||
|
);
|
||||||
|
const start = member.operations.find((op) => op.displayName === "watch-start");
|
||||||
|
const stop = member.operations.find((op) => op.displayName === "watch-stop");
|
||||||
|
const watch = start && stop && conformance.operationBindings.find((entry) => entry.operationId === start.id);
|
||||||
|
if (
|
||||||
|
binding.kind === "state" &&
|
||||||
|
watch &&
|
||||||
|
(watch.binding.kind !== "state" ||
|
||||||
|
watch.binding.slotId !== binding.slotId ||
|
||||||
|
watch.binding.primitive !== "watch-start")
|
||||||
|
)
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_WATCH_CONTRACT",
|
||||||
|
`Native query field ${effect.memberId} watch disagrees with its getter`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
binding.kind === "edge" &&
|
||||||
|
watch &&
|
||||||
|
(watch.binding.kind !== "edge" ||
|
||||||
|
watch.binding.edgeTypeId !== binding.edgeTypeId ||
|
||||||
|
watch.binding.projectionId !== binding.projectionId)
|
||||||
|
)
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_WATCH_CONTRACT",
|
||||||
|
`Native query relation ${effect.memberId} watch disagrees with its resolver`,
|
||||||
|
);
|
||||||
|
fields.push({
|
||||||
|
atomId: conformance.atomId,
|
||||||
|
interfaceRevisionId: effect.interfaceRevisionId,
|
||||||
|
memberId: effect.memberId,
|
||||||
|
getter: getter.id,
|
||||||
|
binding,
|
||||||
|
...(watch && start && stop ? { watch: { start: start.id, stop: stop.id, binding: watch.binding } } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const storage = attachments.filter((entry) => needed.has(entry.id));
|
||||||
|
const bindingDigest = createHash("sha256")
|
||||||
|
.update(
|
||||||
|
canonicalJson({
|
||||||
|
definition: checked.definitionDigest,
|
||||||
|
fields: [...fields].sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))),
|
||||||
|
storage: [...storage].sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest("hex");
|
||||||
|
linked.push({
|
||||||
|
id: `${pkg.revisionId}:${declaration.id}`,
|
||||||
|
packageRevisionId: pkg.revisionId,
|
||||||
|
checked,
|
||||||
|
bindingDigest,
|
||||||
|
fields,
|
||||||
|
attachments: storage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const entry of linked) entry.runtime = queryRuntimePlan(entry, workspace);
|
||||||
|
if (new Set(linked.map((entry) => entry.id)).size !== linked.length)
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_ID_COLLISION",
|
||||||
|
"Query export identities collide; choose distinct package/query IDs",
|
||||||
|
);
|
||||||
|
return linked;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { create, toJson } from "@bufbuild/protobuf";
|
||||||
|
import {
|
||||||
|
InstalledQuerySchema,
|
||||||
|
QueryArgumentSchema,
|
||||||
|
QuerySelectionSchema,
|
||||||
|
QueryReadBindingSchema,
|
||||||
|
Cardinality,
|
||||||
|
type QueryArgument as WireArgument,
|
||||||
|
} from "../gen/camino/schema_pb.js";
|
||||||
|
import type { LinkedQuery } from "./link.js";
|
||||||
|
import type { QueryArgument, QuerySelection } from "./types.js";
|
||||||
|
import type { WorkspaceRevision } from "../capability-model/types.js";
|
||||||
|
import { relationalWire } from "./relational-proto.js";
|
||||||
|
|
||||||
|
const argument = (entry: QueryArgument): WireArgument => {
|
||||||
|
switch (entry.kind) {
|
||||||
|
case "variable":
|
||||||
|
return create(QueryArgumentSchema, { value: { case: "variable", value: entry.name } });
|
||||||
|
case "literal":
|
||||||
|
return create(QueryArgumentSchema, { value: { case: "literalJson", value: JSON.stringify(entry.value) } });
|
||||||
|
case "list":
|
||||||
|
return create(QueryArgumentSchema, { value: { case: "list", value: { values: entry.values.map(argument) } } });
|
||||||
|
case "object":
|
||||||
|
return create(QueryArgumentSchema, {
|
||||||
|
value: {
|
||||||
|
case: "object",
|
||||||
|
value: { fields: Object.fromEntries(Object.entries(entry.fields).map(([k, v]) => [k, argument(v)])) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const selection = (entry: QuerySelection): import("../gen/camino/schema_pb.js").QuerySelection =>
|
||||||
|
create(QuerySelectionSchema, {
|
||||||
|
...entry,
|
||||||
|
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)])),
|
||||||
|
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) =>
|
||||||
|
toJson(
|
||||||
|
InstalledQuerySchema,
|
||||||
|
create(InstalledQuerySchema, {
|
||||||
|
id: linked.id,
|
||||||
|
definitionDigest: linked.checked.definitionDigest,
|
||||||
|
bindingDigest: linked.bindingDigest,
|
||||||
|
rootInterfaceRevisionId: linked.checked.declaration.root,
|
||||||
|
selection: linked.checked.selection.map(selection),
|
||||||
|
budgets: linked.checked.declaration.budgets,
|
||||||
|
variablesTypeJson: JSON.stringify(linked.checked.variables),
|
||||||
|
outputTypeJson: JSON.stringify(linked.checked.output),
|
||||||
|
variableDefaults: Object.fromEntries(
|
||||||
|
Object.entries(linked.checked.variableDefaults).map(([k, v]) => [k, argument(v)]),
|
||||||
|
),
|
||||||
|
watch: linked.checked.declaration.watch,
|
||||||
|
pollingIntervalMs: linked.checked.declaration.polling?.intervalMs,
|
||||||
|
rpcPredicateOrOrder: linked.checked.effects.some(
|
||||||
|
(effect) => effect.execution === "rpc-permitted" && effect.uses.some((use) => use !== "select"),
|
||||||
|
),
|
||||||
|
bindings: linked.fields.map((field) => {
|
||||||
|
const member = workspace.interfaceImports
|
||||||
|
.find((entry) => entry.revisionId === field.interfaceRevisionId)!
|
||||||
|
.members.find((entry) => entry.id === field.memberId)!;
|
||||||
|
return create(QueryReadBindingSchema, {
|
||||||
|
atomId: field.atomId,
|
||||||
|
interfaceRevisionId: field.interfaceRevisionId,
|
||||||
|
memberId: field.memberId,
|
||||||
|
getterOperationId: field.getter,
|
||||||
|
fieldName: member.displayName,
|
||||||
|
keyType: member.kind === "relationship" ? member.keyType : undefined,
|
||||||
|
valueTypeJson: member.kind === "value" ? JSON.stringify(member.valueType) : "",
|
||||||
|
cardinality:
|
||||||
|
member.kind === "relationship"
|
||||||
|
? {
|
||||||
|
"optional-one": Cardinality.OPTIONAL_ONE,
|
||||||
|
"exactly-one": Cardinality.EXACTLY_ONE,
|
||||||
|
many: Cardinality.MANY,
|
||||||
|
"many-unique": Cardinality.MANY_UNIQUE,
|
||||||
|
}[member.cardinality]
|
||||||
|
: undefined,
|
||||||
|
slotId: field.binding.kind === "state" ? field.binding.slotId : "",
|
||||||
|
edgeTypeId: field.binding.kind === "edge" ? field.binding.edgeTypeId : "",
|
||||||
|
projectionId: field.binding.kind === "edge" ? field.binding.projectionId : "",
|
||||||
|
rpc: field.binding.kind === "package",
|
||||||
|
watchStartOperationId: field.watch?.start,
|
||||||
|
watchStopOperationId: field.watch?.stop,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import {
|
||||||
|
GraphQLBoolean,
|
||||||
|
GraphQLString,
|
||||||
|
GraphQLInt,
|
||||||
|
GraphQLFloat,
|
||||||
|
GraphQLScalarType,
|
||||||
|
GraphQLObjectType,
|
||||||
|
GraphQLInputObjectType,
|
||||||
|
GraphQLEnumType,
|
||||||
|
GraphQLList,
|
||||||
|
GraphQLNonNull,
|
||||||
|
GraphQLSchema,
|
||||||
|
GraphQLDirective,
|
||||||
|
DirectiveLocation,
|
||||||
|
specifiedDirectives,
|
||||||
|
type GraphQLOutputType,
|
||||||
|
type GraphQLInputType,
|
||||||
|
type GraphQLFieldConfigMap,
|
||||||
|
type GraphQLInputFieldConfigMap,
|
||||||
|
type DocumentNode,
|
||||||
|
} from "graphql";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type {
|
||||||
|
InterfaceRevision,
|
||||||
|
InterfaceRevisionId,
|
||||||
|
ValueType,
|
||||||
|
RelationshipInterfaceMember,
|
||||||
|
} from "../capability-model/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.
|
||||||
|
function integerParser(name: string, min: bigint, max: bigint, value: unknown): string {
|
||||||
|
if (
|
||||||
|
typeof value !== "string" &&
|
||||||
|
typeof value !== "bigint" &&
|
||||||
|
!(typeof value === "number" && Number.isSafeInteger(value))
|
||||||
|
)
|
||||||
|
throw new Error(`${name} requires a canonical integer`);
|
||||||
|
const text = String(value);
|
||||||
|
if (!/^-?(0|[1-9][0-9]*)$/.test(text) || text === "-0") throw new Error(`${name} requires a canonical integer`);
|
||||||
|
const n = BigInt(text);
|
||||||
|
if (n < min || n > max) throw new Error(`${name} out of range`);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const integerScalar = (name: string, min: bigint, max: bigint) => {
|
||||||
|
const parse = (v: unknown) => integerParser(name, min, max, v);
|
||||||
|
return new GraphQLScalarType({
|
||||||
|
name,
|
||||||
|
serialize: parse,
|
||||||
|
parseValue: parse,
|
||||||
|
parseLiteral(node) {
|
||||||
|
if (node.kind !== "IntValue" && node.kind !== "StringValue") throw new Error(`${name} requires an integer`);
|
||||||
|
return parse(node.value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const queryScalars = {
|
||||||
|
bool: GraphQLBoolean,
|
||||||
|
string: GraphQLString,
|
||||||
|
int32: GraphQLInt,
|
||||||
|
int64: integerScalar("Int64", -(1n << 63n), (1n << 63n) - 1n),
|
||||||
|
uint32: integerScalar("UInt32", 0n, (1n << 32n) - 1n),
|
||||||
|
uint64: integerScalar("UInt64", 0n, (1n << 64n) - 1n),
|
||||||
|
double: GraphQLFloat,
|
||||||
|
bytes: new GraphQLScalarType({ name: "Bytes" }),
|
||||||
|
};
|
||||||
|
export const cursorScalar = new GraphQLScalarType({
|
||||||
|
name: "Cursor",
|
||||||
|
parseValue(value) {
|
||||||
|
if (typeof value !== "string" || value.length > 4096) throw new Error("Invalid cursor");
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function querySchema(
|
||||||
|
declaration: QueryDeclaration,
|
||||||
|
interfaces: readonly InterfaceRevision[],
|
||||||
|
document?: DocumentNode,
|
||||||
|
) {
|
||||||
|
const contracts = new Map(interfaces.map((entry) => [entry.revisionId, entry]));
|
||||||
|
const objects = new Map<InterfaceRevisionId, GraphQLObjectType>();
|
||||||
|
const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
|
||||||
|
const byName = new Map<string, InterfaceRevision>();
|
||||||
|
const comparisons = new Map<string, GraphQLInputObjectType>();
|
||||||
|
const scalarTypes = new Map<string, ValueType>(
|
||||||
|
Object.entries(queryScalars).map(([name, type]) => [type.name, { kind: "scalar", name } as ValueType]),
|
||||||
|
);
|
||||||
|
scalarTypes.set("Cursor", { kind: "scalar", name: "string" });
|
||||||
|
const pageInfo = new GraphQLObjectType({
|
||||||
|
name: "QxPageInfo",
|
||||||
|
fields: {
|
||||||
|
hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) },
|
||||||
|
endCursor: { type: cursorScalar },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const direction = new GraphQLEnumType({ name: "QxDirection", values: { ASC: {}, DESC: {} } });
|
||||||
|
const contract = (id: InterfaceRevisionId) => {
|
||||||
|
const found = contracts.get(id);
|
||||||
|
if (!found || found.template) throw new QueryCompileError("QUERY_CONTRACT", `Expected closed interface ${id}`);
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
const name = (id: InterfaceRevisionId) => {
|
||||||
|
const revision = contract(id);
|
||||||
|
const result =
|
||||||
|
revision.displayName +
|
||||||
|
(revision.application ? `_${createHash("sha256").update(id).digest("hex").slice(0, 12)}` : "");
|
||||||
|
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(result) || result.startsWith("__") || result.startsWith("Qx"))
|
||||||
|
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Unsupported query interface name ${result}`);
|
||||||
|
const existing = byName.get(result);
|
||||||
|
if (existing && existing.revisionId !== id)
|
||||||
|
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Conflicting query interface name ${result}`);
|
||||||
|
byName.set(result, revision);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const target = (member: RelationshipInterfaceMember): InterfaceRevisionId => {
|
||||||
|
if (member.target.kind === "interface") return member.target.interfaceRevisionId;
|
||||||
|
const atomId = member.target.atomId;
|
||||||
|
const views = declaration.views.filter((entry) => entry.atomId === atomId);
|
||||||
|
if (views.length !== 1)
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_VIEW_REQUIRED",
|
||||||
|
`Declare exactly one interface view for ${member.target.atomId}`,
|
||||||
|
);
|
||||||
|
return views[0]!.interfaceRevisionId;
|
||||||
|
};
|
||||||
|
const scalar = (type: ValueType): GraphQLOutputType & GraphQLInputType => {
|
||||||
|
if (type.kind === "optional") return scalar(type.value);
|
||||||
|
if (type.kind !== "scalar")
|
||||||
|
throw new QueryCompileError("QUERY_TYPE", "Only scalar/optional scalar query fields are supported");
|
||||||
|
return queryScalars[type.name];
|
||||||
|
};
|
||||||
|
const comparison = (type: ValueType) => {
|
||||||
|
const base = type.kind === "optional" ? type.value : type;
|
||||||
|
const value = scalar(base);
|
||||||
|
const key = String(value);
|
||||||
|
let result = comparisons.get(key);
|
||||||
|
if (!result) {
|
||||||
|
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")
|
||||||
|
for (const op of ["lt", "lte", "gt", "gte"]) fields[op] = { type: value };
|
||||||
|
result = new GraphQLInputObjectType({ name: `QxCompare${key}`, fields });
|
||||||
|
comparisons.set(key, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const relational = relationalSchema(
|
||||||
|
{ contracts, target, name, scalar, comparison, scalarTypes, direction, cursor: cursorScalar, pageInfo },
|
||||||
|
document,
|
||||||
|
);
|
||||||
|
relational.discover(declaration.root);
|
||||||
|
const filter = (id: InterfaceRevisionId) => relational.where(objectRow(id));
|
||||||
|
const order = (id: InterfaceRevisionId) => {
|
||||||
|
let result = orders.get(id);
|
||||||
|
if (!result) {
|
||||||
|
const fields: GraphQLInputFieldConfigMap = {};
|
||||||
|
for (const member of contract(id).members) {
|
||||||
|
if (member.kind !== "value" || !member.queryRead) continue;
|
||||||
|
const t = member.valueType.kind === "optional" ? member.valueType.value : member.valueType;
|
||||||
|
if (t.kind === "scalar" && t.name !== "bytes") fields[member.displayName] = { type: direction };
|
||||||
|
}
|
||||||
|
if (Object.keys(fields).length === 0) return undefined;
|
||||||
|
result = new GraphQLInputObjectType({ name: `${name(id)}Order`, fields });
|
||||||
|
orders.set(id, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const object = (id: InterfaceRevisionId): GraphQLObjectType => {
|
||||||
|
const existing = objects.get(id);
|
||||||
|
if (existing) return existing;
|
||||||
|
const result = new GraphQLObjectType({
|
||||||
|
name: name(id),
|
||||||
|
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) } };
|
||||||
|
for (const member of contract(id).members) {
|
||||||
|
if (member.kind === "operation" || !member.queryRead) continue;
|
||||||
|
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(member.displayName) || member.displayName.startsWith("_"))
|
||||||
|
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Unsupported query field name ${member.displayName}`);
|
||||||
|
if (member.kind === "value") {
|
||||||
|
const type = scalar(member.valueType);
|
||||||
|
fields[member.displayName] = {
|
||||||
|
type: member.valueType.kind === "optional" ? type : new GraphQLNonNull(type),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const targetId = target(member),
|
||||||
|
node = object(targetId);
|
||||||
|
if (member.cardinality === "exactly-one" || member.cardinality === "optional-one")
|
||||||
|
fields[member.displayName] = {
|
||||||
|
type: member.cardinality === "exactly-one" ? new GraphQLNonNull(node) : node,
|
||||||
|
};
|
||||||
|
else {
|
||||||
|
const entry = new GraphQLObjectType({
|
||||||
|
name: `${name(id)}_${member.displayName}_Entry`,
|
||||||
|
fields: {
|
||||||
|
key: { type: new GraphQLNonNull(GraphQLString) },
|
||||||
|
...(member.keyType
|
||||||
|
? {
|
||||||
|
mapKey: {
|
||||||
|
type: new GraphQLNonNull(
|
||||||
|
queryScalars[member.keyType === "boolean" ? "bool" : member.keyType],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
cursor: { type: cursorScalar },
|
||||||
|
node: { type: new GraphQLNonNull(node) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const connection = new GraphQLObjectType({
|
||||||
|
name: `${name(id)}_${member.displayName}_Connection`,
|
||||||
|
fields: {
|
||||||
|
entries: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(entry))) },
|
||||||
|
pageInfo: { type: new GraphQLNonNull(pageInfo) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ordering = order(targetId);
|
||||||
|
fields[member.displayName] = {
|
||||||
|
type: new GraphQLNonNull(connection),
|
||||||
|
args: {
|
||||||
|
first: { type: GraphQLInt },
|
||||||
|
all: { type: GraphQLInt },
|
||||||
|
after: { type: cursorScalar },
|
||||||
|
where: { type: filter(targetId) },
|
||||||
|
...(ordering ? { orderBy: { type: new GraphQLList(new GraphQLNonNull(ordering)) } } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
objects.set(id, result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const schema = new GraphQLSchema({
|
||||||
|
directives: [...specifiedDirectives, new GraphQLDirective({ name: "live", locations: [DirectiveLocation.FIELD] })],
|
||||||
|
query: new GraphQLObjectType({
|
||||||
|
name: "QxQuery",
|
||||||
|
fields: { root: { type: new GraphQLNonNull(object(declaration.root)) } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return { schema, byName, target, contracts, relational, scalarTypes };
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { capabilityId, type InterfaceRevision, type InterfaceRevisionId } from "../capability-model/types.js";
|
||||||
|
import {
|
||||||
|
TypeSubstitution,
|
||||||
|
bindTypeParameters,
|
||||||
|
type ClosedTypeArgument,
|
||||||
|
type GenericTypeEnvironment,
|
||||||
|
} from "../capability-model/generics.js";
|
||||||
|
import { GenericSourceTypes } from "../capability-language/generic-types.js";
|
||||||
|
import { compileQuery } from "./compile.js";
|
||||||
|
import { QueryCompileError, type QueryDeclaration, type QueryTemplate } from "./types.js";
|
||||||
|
|
||||||
|
export function specializeQueryTemplate(
|
||||||
|
template: QueryTemplate,
|
||||||
|
arguments_: ClosedTypeArgument[],
|
||||||
|
environment: GenericTypeEnvironment,
|
||||||
|
interfaces: () => readonly InterfaceRevision[],
|
||||||
|
identity: { id: string; displayName: string },
|
||||||
|
): QueryDeclaration {
|
||||||
|
const obligations: NonNullable<QueryDeclaration["argumentRequirements"]> = [];
|
||||||
|
const checked = {
|
||||||
|
...environment,
|
||||||
|
implementsInterface: (
|
||||||
|
target: Parameters<GenericTypeEnvironment["implementsInterface"]>[0],
|
||||||
|
required: InterfaceRevisionId,
|
||||||
|
) => {
|
||||||
|
obligations.push({ target, required });
|
||||||
|
return environment.implementsInterface(target, required);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const substitution = new TypeSubstitution({
|
||||||
|
...checked,
|
||||||
|
arguments: bindTypeParameters(template.parameters, arguments_, checked, template.declaration.displayName),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...structuredClone(template.declaration),
|
||||||
|
...identity,
|
||||||
|
root: substitution.application(template.root),
|
||||||
|
views: template.views
|
||||||
|
.map((view) => {
|
||||||
|
const target = substitution.object(view.target);
|
||||||
|
const interfaceRevisionId = substitution.application(view.interface);
|
||||||
|
if (target.kind === "interface") {
|
||||||
|
if (target.interfaceRevisionId !== interfaceRevisionId)
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_VIEW_REQUIRED",
|
||||||
|
"An interface argument must use its exact selected query view",
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { atomId: target.atomId, interfaceRevisionId };
|
||||||
|
})
|
||||||
|
.filter((view): view is NonNullable<typeof view> => !!view),
|
||||||
|
allowances: template.allowances.map((allowance) => {
|
||||||
|
const interfaceRevisionId = substitution.application(allowance.interface);
|
||||||
|
const member = interfaces()
|
||||||
|
.find((entry) => entry.revisionId === interfaceRevisionId)
|
||||||
|
?.members.find((entry) => entry.displayName === allowance.memberName);
|
||||||
|
if (!member) throw new QueryCompileError("QUERY_ALLOWANCE", `Unknown template field ${allowance.memberName}`);
|
||||||
|
return { interfaceRevisionId, memberId: member.id, uses: allowance.uses, reason: allowance.reason };
|
||||||
|
}),
|
||||||
|
application: { templateId: template.declaration.id, arguments: structuredClone(arguments_) },
|
||||||
|
argumentRequirements: obligations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check a document with only its declared bounds available, even if no concrete
|
||||||
|
* specialization is installed. No concrete atom fields can leak into this check. */
|
||||||
|
export async function checkQueryTemplate(
|
||||||
|
template: QueryTemplate,
|
||||||
|
interfaces: readonly InterfaceRevision[],
|
||||||
|
read: (name: string) => Promise<string>,
|
||||||
|
) {
|
||||||
|
const types = new GenericSourceTypes(new Map());
|
||||||
|
for (const contract of interfaces) types.register(contract.displayName, contract);
|
||||||
|
const arguments_: ClosedTypeArgument[] = template.parameters.map((parameter) => {
|
||||||
|
if (parameter.kind !== "object")
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_TEMPLATE_PARAMETER",
|
||||||
|
"Query templates currently require object parameters with explicit interface views; scalar/value polymorphism is not queryable",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kind: "object",
|
||||||
|
target: { kind: "atom", atomId: capabilityId.atom(`query-bound:${template.declaration.id}:${parameter.id}`) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const bindings = new Map(template.parameters.map((parameter, index) => [parameter.id, arguments_[index]!]));
|
||||||
|
const substitution = new TypeSubstitution({ ...types.environment(), arguments: bindings });
|
||||||
|
const evidence = new Map(
|
||||||
|
arguments_.map((argument, index) => {
|
||||||
|
const parameter = template.parameters[index]!;
|
||||||
|
const target = argument.kind === "object" && argument.target.kind === "atom" ? argument.target.atomId : "";
|
||||||
|
return [
|
||||||
|
target,
|
||||||
|
new Set(
|
||||||
|
parameter.kind === "object" ? parameter.implements.map((bound) => substitution.application(bound)) : [],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const implies = (offered: string, required: string, seen = new Set<string>()): boolean => {
|
||||||
|
if (offered === required) return true;
|
||||||
|
if (seen.has(offered)) return false;
|
||||||
|
seen.add(offered);
|
||||||
|
const contract = types.definitions.get(offered);
|
||||||
|
return (contract?.requiredInterfaces ?? []).some((parent) => implies(parent, required, seen));
|
||||||
|
};
|
||||||
|
const environment = {
|
||||||
|
...types.environment(),
|
||||||
|
implementsInterface: (
|
||||||
|
target: Parameters<GenericTypeEnvironment["implementsInterface"]>[0],
|
||||||
|
required: InterfaceRevisionId,
|
||||||
|
) =>
|
||||||
|
target.kind === "atom" && [...(evidence.get(target.atomId) ?? [])].some((offered) => implies(offered, required)),
|
||||||
|
};
|
||||||
|
const declaration = specializeQueryTemplate(
|
||||||
|
template,
|
||||||
|
arguments_,
|
||||||
|
environment,
|
||||||
|
() => [...types.definitions.values()],
|
||||||
|
template.declaration,
|
||||||
|
);
|
||||||
|
for (const view of declaration.views)
|
||||||
|
if (!environment.implementsInterface({ kind: "atom", atomId: view.atomId }, view.interfaceRevisionId))
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_TEMPLATE_BOUND",
|
||||||
|
`The declared bounds do not guarantee the selected view ${view.interfaceRevisionId}`,
|
||||||
|
);
|
||||||
|
for (const application of types.applications.values())
|
||||||
|
for (const obligation of application.argumentRequirements ?? [])
|
||||||
|
if (!environment.implementsInterface(obligation.target, obligation.required))
|
||||||
|
throw new QueryCompileError(
|
||||||
|
"QUERY_TEMPLATE_BOUND",
|
||||||
|
`Template arguments do not guarantee ${obligation.required}`,
|
||||||
|
);
|
||||||
|
return compileQuery(declaration, [...types.definitions.values()], read);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { AtomId, InterfaceRevisionId, MemberId, ValueType } from "../capability-model/types.js";
|
||||||
|
import type {
|
||||||
|
TypeParameter,
|
||||||
|
InterfaceApplicationExpression,
|
||||||
|
ObjectTypeExpression,
|
||||||
|
ClosedTypeArgument,
|
||||||
|
} from "../capability-model/generics.js";
|
||||||
|
|
||||||
|
export type QueryUse = "select" | "predicate" | "order" | "aggregate" | "group" | "distinct";
|
||||||
|
export interface QueryAllowance {
|
||||||
|
interfaceRevisionId: InterfaceRevisionId;
|
||||||
|
memberId: MemberId;
|
||||||
|
uses: QueryUse[];
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
export interface QueryBudgets {
|
||||||
|
rows: number;
|
||||||
|
depth: number;
|
||||||
|
resultBytes: number;
|
||||||
|
candidates: number;
|
||||||
|
rpcCalls: number;
|
||||||
|
concurrency: number;
|
||||||
|
deadlineMs: number;
|
||||||
|
}
|
||||||
|
export const defaultQueryBudgets: Readonly<QueryBudgets> = Object.freeze({
|
||||||
|
rows: 100,
|
||||||
|
depth: 8,
|
||||||
|
resultBytes: 1048576,
|
||||||
|
candidates: 100,
|
||||||
|
rpcCalls: 200,
|
||||||
|
concurrency: 8,
|
||||||
|
deadlineMs: 10000,
|
||||||
|
});
|
||||||
|
export interface QueryDeclaration {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
root: InterfaceRevisionId;
|
||||||
|
document: string;
|
||||||
|
operation: string;
|
||||||
|
fragments: string[];
|
||||||
|
views: { atomId: AtomId; interfaceRevisionId: InterfaceRevisionId }[];
|
||||||
|
allowances: QueryAllowance[];
|
||||||
|
budgets: QueryBudgets;
|
||||||
|
watch: boolean;
|
||||||
|
polling?: { intervalMs: number; reason: string };
|
||||||
|
application?: { templateId: string; arguments: ClosedTypeArgument[] };
|
||||||
|
argumentRequirements?: {
|
||||||
|
target: import("../capability-model/types.js").ObjectExpectation;
|
||||||
|
required: InterfaceRevisionId;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
/** Source-only template; neither parameters nor synthetic bound witnesses are installed. */
|
||||||
|
export interface QueryTemplate {
|
||||||
|
declaration: Omit<QueryDeclaration, "root" | "views" | "allowances">;
|
||||||
|
parameters: TypeParameter[];
|
||||||
|
root: InterfaceApplicationExpression;
|
||||||
|
views: { target: ObjectTypeExpression; interface: InterfaceApplicationExpression }[];
|
||||||
|
allowances: { interface: InterfaceApplicationExpression; memberName: string; uses: QueryUse[]; reason: string }[];
|
||||||
|
}
|
||||||
|
export interface QueryFieldEffect {
|
||||||
|
interfaceRevisionId: InterfaceRevisionId;
|
||||||
|
memberId: MemberId;
|
||||||
|
uses: QueryUse[];
|
||||||
|
execution: "native" | "rpc-permitted";
|
||||||
|
}
|
||||||
|
export interface QuerySourceLocation {
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
}
|
||||||
|
export class QueryCompileError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: string,
|
||||||
|
message: string,
|
||||||
|
readonly location?: QuerySourceLocation,
|
||||||
|
) {
|
||||||
|
super(`${code}${location ? ` ${location.file}:${location.line}:${location.column}` : ""}: ${message}`);
|
||||||
|
this.name = "QueryCompileError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface CheckedQuery {
|
||||||
|
declaration: QueryDeclaration;
|
||||||
|
definitionDigest: string;
|
||||||
|
/** Fixed, validated document: never supplied by runtime callers. */
|
||||||
|
document: string;
|
||||||
|
schema: string;
|
||||||
|
variables: ValueType;
|
||||||
|
output: ValueType;
|
||||||
|
effects: QueryFieldEffect[];
|
||||||
|
selection: QuerySelection[];
|
||||||
|
variableDefaults: Record<string, QueryArgument>;
|
||||||
|
sourceFiles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueryArgument =
|
||||||
|
| { kind: "variable"; name: string }
|
||||||
|
| { kind: "literal"; value: string | number | boolean | null }
|
||||||
|
| { kind: "list"; values: QueryArgument[] }
|
||||||
|
| { kind: "object"; fields: Record<string, QueryArgument> };
|
||||||
|
export interface QuerySelection {
|
||||||
|
liveSelectionId?: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
interfaceRevisionId?: InterfaceRevisionId;
|
||||||
|
memberId?: MemberId;
|
||||||
|
targetInterfaceRevisionId?: InterfaceRevisionId;
|
||||||
|
conditions: { include: boolean; value: QueryArgument }[];
|
||||||
|
arguments: Record<string, QueryArgument>;
|
||||||
|
selection: QuerySelection[];
|
||||||
|
relational?: import("./relational.js").QueryRelationalPlan;
|
||||||
|
predicate?: import("./relational.js").QueryPredicate;
|
||||||
|
}
|
||||||
Vendored
+71
@@ -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} } } } } }`,
|
||||||
|
);
|
||||||
Vendored
+135
@@ -0,0 +1,135 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { compileCapabilityResourceSource, compileCapabilitySource } from "../../src/capability-language/index.js";
|
||||||
|
import { compileQuery } from "../../src/query/compile.js";
|
||||||
|
import { linkQueries } from "../../src/query/link.js";
|
||||||
|
import type { InterfaceRevision, PackageRevision } from "../../src/capability-model/types.js";
|
||||||
|
|
||||||
|
export const queryFixtureSource = {
|
||||||
|
repository: "https://query-fixture.example.test/source.git",
|
||||||
|
commit: "1".repeat(40),
|
||||||
|
};
|
||||||
|
export async function queryWorkspaceFixture(options: { live?: boolean } = {}) {
|
||||||
|
const interfaces = new Map<string, InterfaceRevision>();
|
||||||
|
const sources: Record<string, string> = {};
|
||||||
|
function iface(name: string, members: string, parameters = "") {
|
||||||
|
const text = `${[...interfaces.keys()].map((name) => `import interface ${name};`).join("\n")}
|
||||||
|
interface ${name}${parameters} id "${name}" revision "${name}@1" {${members}}`;
|
||||||
|
sources[name] = text;
|
||||||
|
const result = compileCapabilityResourceSource(text, {
|
||||||
|
source: queryFixtureSource,
|
||||||
|
environment: { interfaces, interfaceClosure: [...interfaces.values()] },
|
||||||
|
});
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
if (result.resource.kind !== "interface") throw new Error("interface expected");
|
||||||
|
interfaces.set(name, result.resource.revision);
|
||||||
|
}
|
||||||
|
iface("PersonFacts", `queryable value name id "name" : string {get id "name:get";}`);
|
||||||
|
iface(
|
||||||
|
"TaskFacts",
|
||||||
|
`
|
||||||
|
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 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 rpc value score id "score" : int32 {get id "score:get";}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
iface(
|
||||||
|
"Collection",
|
||||||
|
`queryable relation items id "items" : many object Item ordered {resolve id "items:resolve";}`,
|
||||||
|
"<object Item implements TaskFacts>",
|
||||||
|
);
|
||||||
|
const packageSource = `${[...interfaces.keys()].map((name) => `import interface ${name};`).join("\n")}
|
||||||
|
package Queries id "queries" revision "queries@1" {
|
||||||
|
operation titleSet id "title-set" : string -> unit mode call receiver any
|
||||||
|
requires {state title id "title-port" : string [write];};
|
||||||
|
operation score id "score" : unit -> int32 mode call receiver any;
|
||||||
|
query Upcoming id "upcoming" root Collection<interface TaskFacts> document "upcoming.graphql" operation "Upcoming" {
|
||||||
|
fragments "row.graphql"; max rows 30; watch;
|
||||||
|
}
|
||||||
|
query Enriched id "enriched" root Collection<interface TaskFacts> document "enriched.graphql" operation "Enriched" {
|
||||||
|
max rows 30; allow TaskFacts.score select "Small visible page only";
|
||||||
|
}
|
||||||
|
query Ranked id "ranked" root Collection<interface TaskFacts> document "ranked.graphql" operation "Ranked" {
|
||||||
|
max rows 30; max candidates 60;
|
||||||
|
allow TaskFacts.score predicate "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;
|
||||||
|
const pkgResult = compileCapabilityResourceSource(packageSource, {
|
||||||
|
source: queryFixtureSource,
|
||||||
|
environment: { interfaces, interfaceClosure: [...interfaces.values()] },
|
||||||
|
});
|
||||||
|
assert.ok(pkgResult.ok, JSON.stringify(pkgResult.diagnostics));
|
||||||
|
if (pkgResult.resource.kind !== "package") throw new Error("package expected");
|
||||||
|
const pkg: PackageRevision = pkgResult.resource.revision;
|
||||||
|
const allInterfaces = [...interfaces.values(), ...(pkgResult.resource.specializations ?? [])];
|
||||||
|
const documents: Record<string, string> = {
|
||||||
|
"upcoming.graphql": `query Upcoming($first: Int!, $after: Cursor) {root {items(first: $first, after: $after, where: {done: {eq: false}}, orderBy: [{rank: ASC}]) {entries {key cursor node {_qx {ref} ...TaskRow}} pageInfo {hasNextPage endCursor}}}}`,
|
||||||
|
"row.graphql": `fragment TaskRow on TaskFacts {title rank done assignee {name}}`,
|
||||||
|
"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}}}}}`,
|
||||||
|
"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.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" {
|
||||||
|
private state Title${atom} id "${atom}:title" on ${atom} : string policy crdt(string) default "Untitled";
|
||||||
|
private state Done${atom} id "${atom}:done" on ${atom} : bool policy optimistic-register default false;
|
||||||
|
private state Rank${atom} id "${atom}:rank" on ${atom} : int64 policy optimistic-register default 0;
|
||||||
|
private edge Assignee${atom} id "${atom}:assignee" {
|
||||||
|
atom ${atom} projection assignee id "${atom}:assignee:forward" optional-one;
|
||||||
|
interface PersonFacts projection tasks id "${atom}:assignee:inverse" many;
|
||||||
|
}
|
||||||
|
bind title.get to state Title${atom}.read;
|
||||||
|
${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 assignee.resolve to edge Assignee${atom}.assignee.resolve;
|
||||||
|
bind score.get to package Queries.score query-reason "Explicit bounded score computation";
|
||||||
|
}`;
|
||||||
|
const workspaceSource = `workspace QueryFixture id "query-fixture" revision "query-fixture@1" commit "${queryFixtureSource.commit}" {
|
||||||
|
import interface PersonFacts; import interface TaskFacts; import interface Collection; import package Queries;
|
||||||
|
atom Task id "Task"; atom Reminder id "Reminder"; atom Person id "Person"; atom Tasks id "Tasks";
|
||||||
|
conform Person as PersonFacts id "person-facts" {
|
||||||
|
private state Name id "person:name" on Person : string policy optimistic-register default "Nobody";
|
||||||
|
bind name to state Name;
|
||||||
|
}
|
||||||
|
${implementation("Task")} ${implementation("Reminder")}
|
||||||
|
conform Tasks as Collection<interface TaskFacts> id "tasks-collection" {
|
||||||
|
private edge Items id "items" {
|
||||||
|
atom Tasks projection items id "items:forward" many ordered;
|
||||||
|
interface TaskFacts projection collections id "items:inverse" many;
|
||||||
|
}
|
||||||
|
bind items.resolve to edge Items.items.resolve;
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
sources.workspace = workspaceSource;
|
||||||
|
const result = compileCapabilitySource(workspaceSource, "workspace.qx", {
|
||||||
|
interfaces,
|
||||||
|
interfaceClosure: allInterfaces,
|
||||||
|
packages: new Map([["Queries", pkg]]),
|
||||||
|
packageClosure: [pkg],
|
||||||
|
});
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
const workspace = { ...result.workspace, linkedQueries: linkQueries(result.workspace) };
|
||||||
|
return { workspace, pkg, interfaces: allInterfaces, sources, documents };
|
||||||
|
}
|
||||||
@@ -12,6 +12,100 @@ import { genericImplementationType } from "../src/bindings/generics.js";
|
|||||||
import { compileWorkspaceRevision, runtimeContracts } from "../src/capability-model/index.js";
|
import { compileWorkspaceRevision, runtimeContracts } from "../src/capability-model/index.js";
|
||||||
import { generateAppliedClientContracts } from "../src/bindings/client.js";
|
import { generateAppliedClientContracts } from "../src/bindings/client.js";
|
||||||
const source = { repository: "https://example.test/generic.git", commit: "a".repeat(40) };
|
const source = { repository: "https://example.test/generic.git", commit: "a".repeat(40) };
|
||||||
|
test("runtime conformance descriptors preserve closed and universal collection item types", async (t) => {
|
||||||
|
const sdk = path.resolve("../quixos-instance/packages/camino-package-runtime/dist/index.js");
|
||||||
|
// The protocol is also published and tested as a standalone subtree. This
|
||||||
|
// integration uses the real SDK only when both projects are checked out.
|
||||||
|
if (
|
||||||
|
!(await fs.access(sdk).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
t.skip("Real SDK integration requires the monorepo checkout");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const collection = compileCapabilityResourceSource(
|
||||||
|
`interface Collection<object Item> id "collection" revision "collection@1" {
|
||||||
|
relation items id "items" : many-unique object Item {resolve id "get"; connect id "add";}
|
||||||
|
}`,
|
||||||
|
{ source },
|
||||||
|
);
|
||||||
|
assert.ok(collection.ok && collection.resource.kind === "interface");
|
||||||
|
if (!collection.ok || collection.resource.kind !== "interface") throw Error("interface failed");
|
||||||
|
const pkg = compileCapabilityResourceSource(
|
||||||
|
`import interface Collection;
|
||||||
|
external atom Book id "book"; external atom Tool id "tool";
|
||||||
|
package P id "p" revision "p@1" {
|
||||||
|
operation both id "both" : record {book:atom-ref<Book>; tool:atom-ref<Tool>;} -> unit mode call receiver any requires {
|
||||||
|
interface books id "books" : Collection<atom Book>;
|
||||||
|
interface tools id "tools" : Collection<atom Tool>;
|
||||||
|
};
|
||||||
|
operation inspect<object Item> id "inspect" : ref<Item> -> unit mode call receiver any requires {
|
||||||
|
interface items id "items" : Collection<Item>;
|
||||||
|
};
|
||||||
|
}`,
|
||||||
|
{ source, environment: { interfaces: new Map([["Collection", collection.resource.revision]]) } },
|
||||||
|
);
|
||||||
|
assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics));
|
||||||
|
if (!pkg.ok || pkg.resource.kind !== "package") throw Error("package failed");
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "qx-conformance-types-"));
|
||||||
|
try {
|
||||||
|
const generated = generateTypeScriptBindings(
|
||||||
|
{
|
||||||
|
format: "quixos-bindings",
|
||||||
|
version: 1,
|
||||||
|
interfaces: pkg.resource.specializations ?? [],
|
||||||
|
interfaceTemplates: [collection.resource.revision],
|
||||||
|
packages: [pkg.resource.revision],
|
||||||
|
},
|
||||||
|
"p@1",
|
||||||
|
{ runtimeModule: sdk },
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(directory, "bindings.mts"), generated);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(directory, "consumer.mts"),
|
||||||
|
`import type {Implementation} from "./bindings.mjs";
|
||||||
|
const implementation: Implementation = {
|
||||||
|
both: async (context) => {
|
||||||
|
const view = await context.conform.tryConform(context.objectId, context.ports.books.contract);
|
||||||
|
if (view) {
|
||||||
|
await view["items.connect"](context.input.book);
|
||||||
|
// @ts-expect-error Tool is not Book
|
||||||
|
await view["items.connect"](context.input.tool);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
inspect: async (context) => {
|
||||||
|
const view = await context.conform.tryConform(context.objectId, context.ports.items.contract);
|
||||||
|
if (view) await view["items.connect"](context.input);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};`,
|
||||||
|
);
|
||||||
|
const compiler = path.join(
|
||||||
|
path.dirname(createRequire(import.meta.url).resolve("typescript/package.json")),
|
||||||
|
"bin/tsc",
|
||||||
|
);
|
||||||
|
const result = await promisify(execFile)(process.execPath, [
|
||||||
|
compiler,
|
||||||
|
"--ignoreConfig",
|
||||||
|
"--noEmit",
|
||||||
|
"--strict",
|
||||||
|
"--skipLibCheck",
|
||||||
|
"--target",
|
||||||
|
"es2023",
|
||||||
|
"--module",
|
||||||
|
"nodenext",
|
||||||
|
path.join(directory, "consumer.mts"),
|
||||||
|
]).catch((error) => {
|
||||||
|
throw Error(error.stdout + error.stderr);
|
||||||
|
});
|
||||||
|
assert.equal(result.stderr, "");
|
||||||
|
} finally {
|
||||||
|
await fs.rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
test("generic port aliases retain their defining scope, including shadowed alias names", () => {
|
test("generic port aliases retain their defining scope, including shadowed alias names", () => {
|
||||||
const iface = compileCapabilityResourceSource(
|
const iface = compileCapabilityResourceSource(
|
||||||
'type Box<value V> = list<V>; interface Data<value V> id "data" revision "data@1" {value payload id "payload" : Box<V> {get id "payload:get";}}',
|
'type Box<value V> = list<V>; interface Data<value V> id "data" revision "data@1" {value payload id "payload" : Box<V> {get id "payload:get";}}',
|
||||||
@@ -108,7 +202,7 @@ test("generated universal implementations compile and cannot assume a concrete v
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const compiler = path.join(path.dirname(require.resolve("typescript/package.json")), "bin/tsc");
|
const compiler = path.join(path.dirname(require.resolve("typescript/package.json")), "bin/tsc");
|
||||||
const run = promisify(execFile);
|
const run = promisify(execFile);
|
||||||
const preamble = `type QxObjectRef<T extends string>={readonly identity:T}; type QxContextLifecycle<C>={signal?:AbortSignal}; type Handler=${signature};\n`;
|
const preamble = `type QxObjectRef<T extends string>={readonly identity:T}; type QxConformer={}; type QxContextLifecycle<C>={signal?:AbortSignal}; type Handler=${signature};\n`;
|
||||||
try {
|
try {
|
||||||
const file = path.join(directory, "generic.ts");
|
const file = path.join(directory, "generic.ts");
|
||||||
await fs.writeFile(file, preamble + `const handler:Handler=({input})=>input;`);
|
await fs.writeFile(file, preamble + `const handler:Handler=({input})=>input;`);
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { compileCapabilityResourceSource } from "../src/capability-language/index.js";
|
||||||
|
import { compileQuery } from "../src/query/compile.js";
|
||||||
|
import { QueryCompileError } from "../src/query/types.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";
|
||||||
|
|
||||||
|
const source = { repository: "https://example.test/queries.git", commit: "a".repeat(40) };
|
||||||
|
const iface = (text: string, dependencies: InterfaceRevision[] = []) => {
|
||||||
|
const compiled = compileCapabilityResourceSource(text, {
|
||||||
|
source,
|
||||||
|
environment: {
|
||||||
|
interfaces: new Map(dependencies.map((entry) => [entry.displayName, entry])),
|
||||||
|
interfaceClosure: dependencies,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(compiled.ok, JSON.stringify(compiled.diagnostics));
|
||||||
|
assert.equal(compiled.resource.kind, "interface");
|
||||||
|
if (compiled.resource.kind !== "interface") throw new Error("interface required");
|
||||||
|
return compiled.resource.revision;
|
||||||
|
};
|
||||||
|
const facts = iface(`interface TaskFacts id "facts" revision "facts@1" {
|
||||||
|
queryable value title id "title" : string { get id "title:get"; }
|
||||||
|
queryable value done id "done" : bool { get id "done:get"; }
|
||||||
|
queryable value due id "due" : optional<int64> { get id "due:get"; }
|
||||||
|
queryable rpc value score id "score" : int32 { get id "score:get"; }
|
||||||
|
value hidden id "hidden" : string { get id "hidden:get"; }
|
||||||
|
}`);
|
||||||
|
const collection = iface(
|
||||||
|
`import interface TaskFacts;
|
||||||
|
interface Tasks id "tasks" revision "tasks@1" {
|
||||||
|
queryable relation items id "items" : many interface TaskFacts ordered { resolve id "items:resolve"; }
|
||||||
|
}`,
|
||||||
|
[facts],
|
||||||
|
);
|
||||||
|
|
||||||
|
test("generic query templates check declared bounds before closed specialization", async () => {
|
||||||
|
const generic = iface(
|
||||||
|
`import interface TaskFacts;
|
||||||
|
interface Collection<object Item implements TaskFacts> id "generic-collection" revision "generic-collection@1" {
|
||||||
|
queryable relation items id "items" : many object Item { resolve id "items:resolve"; }
|
||||||
|
}`,
|
||||||
|
[facts],
|
||||||
|
);
|
||||||
|
const build = (bound: string) =>
|
||||||
|
compileCapabilityResourceSource(
|
||||||
|
`import interface Collection; import interface TaskFacts;
|
||||||
|
package GenericQueries id "generic-queries" revision "generic-queries@1" {
|
||||||
|
query Rows<object Item ${bound}> id "rows-template" root Collection<Item>
|
||||||
|
document "rows.graphql" operation "Rows" { view object Item as TaskFacts; max rows 30; }
|
||||||
|
query TaskRows id "task-rows" specialize Rows<interface TaskFacts>;
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
source,
|
||||||
|
environment: {
|
||||||
|
interfaces: new Map([
|
||||||
|
["Collection", generic],
|
||||||
|
["TaskFacts", facts],
|
||||||
|
]),
|
||||||
|
interfaceClosure: [generic, facts],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const result = build("implements TaskFacts");
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
if (result.resource.kind !== "package") throw new Error("package expected");
|
||||||
|
const pkg = result.resource.revision;
|
||||||
|
assert.equal(pkg.queries?.length, 1);
|
||||||
|
assert.equal(pkg.queryTemplates?.length, 1);
|
||||||
|
assert.equal(pkg.queries![0]!.application!.templateId, "rows-template");
|
||||||
|
const read = async () => `query Rows { root { items(first: 3) { entries { node { title } } } } }`;
|
||||||
|
const universal = await checkQueryTemplate(pkg.queryTemplates![0]!, [generic, facts], read);
|
||||||
|
assert.equal(
|
||||||
|
universal.effects.some((effect) => effect.memberId === "title"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const specialized = await compileQuery(
|
||||||
|
pkg.queries![0]!,
|
||||||
|
[generic, facts, ...(result.resource.specializations ?? [])],
|
||||||
|
read,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
specialized.effects.some((effect) => effect.memberId === "title"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const invalid = build("");
|
||||||
|
assert.ok(invalid.ok, JSON.stringify(invalid.diagnostics));
|
||||||
|
if (invalid.resource.kind !== "package") throw new Error("package expected");
|
||||||
|
await assert.rejects(
|
||||||
|
checkQueryTemplate(invalid.resource.revision.queryTemplates![0]!, [generic, facts], read),
|
||||||
|
/bound|guarantee|implement/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
function fixture(clauses = "") {
|
||||||
|
const result = compileCapabilityResourceSource(
|
||||||
|
`import interface Tasks; import interface TaskFacts;
|
||||||
|
package Queries id "queries" revision "queries@1" {
|
||||||
|
query Upcoming id "upcoming" root Tasks document "queries/upcoming.graphql" operation "Upcoming" {
|
||||||
|
fragments "queries/row.graphql"; max rows 30; ${clauses}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
source,
|
||||||
|
environment: {
|
||||||
|
interfaces: new Map([
|
||||||
|
["Tasks", collection],
|
||||||
|
["TaskFacts", facts],
|
||||||
|
]),
|
||||||
|
interfaceClosure: [collection, facts],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
assert.equal(result.resource.kind, "package");
|
||||||
|
if (result.resource.kind !== "package") throw new Error("package required");
|
||||||
|
assert.equal(result.resource.revision.exports.length, 0);
|
||||||
|
return result.resource.revision.queries![0]!;
|
||||||
|
}
|
||||||
|
const document = `query Upcoming($first: Int!, $before: Int64!) {
|
||||||
|
root { items(first: $first, where: {done: {eq: false}, due: {lt: $before}}, orderBy: [{due: ASC}]) {
|
||||||
|
entries { key node { _qx { ref } ...Row } } pageInfo { hasNextPage endCursor }
|
||||||
|
} }
|
||||||
|
}`;
|
||||||
|
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));
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
const result = compileCapabilityResourceSource(
|
||||||
|
`import interface Tasks;
|
||||||
|
package Queries id "queries" revision "queries@1" {
|
||||||
|
operation load id "read" : unit -> unit mode call receiver interfaces [Tasks]
|
||||||
|
requires { query upcoming id "upcoming-port" : Queries.Upcoming; };
|
||||||
|
query Upcoming id "upcoming" root Tasks document "upcoming.graphql" operation "Upcoming" { max rows 30; }
|
||||||
|
}`,
|
||||||
|
{ source, environment: { interfaces: new Map([["Tasks", collection]]), interfaceClosure: [collection, facts] } },
|
||||||
|
);
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
assert.equal(result.resource.kind, "package");
|
||||||
|
if (result.resource.kind !== "package") throw new Error("package expected");
|
||||||
|
assert.deepEqual(result.resource.revision.exports[0]!.dependencyPorts[0]!.requirement, {
|
||||||
|
kind: "query",
|
||||||
|
packageRevisionId: "queries@1",
|
||||||
|
queryId: "upcoming",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("result types merge repeated selections and preserve conditional fragment fields", async () => {
|
||||||
|
const checked = await compile(
|
||||||
|
`query Upcoming($show: Boolean!) {
|
||||||
|
root { items(first: 3) { entries { node { title } } } }
|
||||||
|
root { items(first: 3) { entries { node { ...Row @include(if: $show) } } } }
|
||||||
|
}`,
|
||||||
|
"fragment Row on TaskFacts { due done }",
|
||||||
|
);
|
||||||
|
const output = JSON.stringify(checked.output);
|
||||||
|
assert.match(output, /\"title\":\{\"kind\":\"scalar\",\"name\":\"string\"\}/);
|
||||||
|
assert.match(output, /\"done\":\{\"kind\":\"optional\",\"value\":\{\"kind\":\"scalar\",\"name\":\"bool\"\}\}/);
|
||||||
|
assert.match(output, /\"due\"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fixed GraphQL yields exact effects, typed references and distinct query artifacts", async () => {
|
||||||
|
const checked = await compile();
|
||||||
|
assert.deepEqual(checked.variables, {
|
||||||
|
kind: "record",
|
||||||
|
fields: {
|
||||||
|
first: { kind: "scalar", name: "int32" },
|
||||||
|
before: { kind: "scalar", name: "int64" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.match(JSON.stringify(checked.output), /"kind":"object-ref"/);
|
||||||
|
assert.ok(
|
||||||
|
checked.effects.some(
|
||||||
|
(effect) => effect.memberId === "due" && effect.uses.includes("order") && effect.uses.includes("predicate"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(checked.definitionDigest, (await compile()).definitionDigest);
|
||||||
|
assert.notEqual(
|
||||||
|
checked.definitionDigest,
|
||||||
|
(await compile(document, "fragment Row on TaskFacts { title }")).definitionDigest,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("query allowlist and two-sided RPC effects fail during compilation", async () => {
|
||||||
|
const rejects = (promise: Promise<unknown>, code: string) =>
|
||||||
|
assert.rejects(promise, (error: unknown) => error instanceof QueryCompileError && error.code === code);
|
||||||
|
await rejects(compile(document, "fragment Row on TaskFacts { hidden }"), "QUERY_VALIDATION");
|
||||||
|
await rejects(compile(document, "fragment Row on TaskFacts { score }"), "QUERY_RPC_CONSUMER_REASON");
|
||||||
|
await compile(
|
||||||
|
document,
|
||||||
|
"fragment Row on TaskFacts { score }",
|
||||||
|
'allow TaskFacts.score select "Only the visible page is enriched";',
|
||||||
|
);
|
||||||
|
await rejects(
|
||||||
|
compile(document, "fragment Row on TaskFacts { score }", 'watch; allow TaskFacts.score select "Visible rows";'),
|
||||||
|
"QUERY_WATCH_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
await compile(
|
||||||
|
document,
|
||||||
|
"fragment Row on TaskFacts { score }",
|
||||||
|
'watch; poll 5000 "External score has no push API"; allow TaskFacts.score select "Visible rows";',
|
||||||
|
);
|
||||||
|
await rejects(compile(document.replace("first: $first", "first: 31")), "QUERY_VALIDATION");
|
||||||
|
await rejects(compile(document.replace("...Row", "__typename ...Row")), "QUERY_UNSUPPORTED_FEATURE");
|
||||||
|
await rejects(compile(document, "fragment Row on TaskFacts { ...Row }"), "QUERY_VALIDATION");
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { compileCapabilityResourceSource } from "../src/capability-language/index.js";
|
||||||
|
import {
|
||||||
|
capabilityFixtureSource,
|
||||||
|
capabilityResourceSources,
|
||||||
|
compileCapabilityFixture,
|
||||||
|
} from "./fixtures/capability-model.js";
|
||||||
|
|
||||||
|
test("query contracts retain native reads and explicitly acknowledged package reads", () => {
|
||||||
|
const named = capabilityResourceSources.named.replace("value name", "queryable value name");
|
||||||
|
const native = compileCapabilityFixture({ named });
|
||||||
|
assert.equal(native.ok, true, JSON.stringify(native.diagnostics));
|
||||||
|
const summary = capabilityResourceSources.summary.replace("value summary", "queryable rpc value summary");
|
||||||
|
const missing = compileCapabilityFixture({ summary });
|
||||||
|
assert.equal(missing.ok, false);
|
||||||
|
assert.ok(missing.diagnostics.some((issue) => issue.code === "query-provider-reason-required"));
|
||||||
|
const workspace = capabilityFixtureSource.replace(
|
||||||
|
"person to constructor Person;\n };",
|
||||||
|
'person to constructor Person;\n } query-reason "Summary needs package execution; bounded query callers acknowledge this cost";',
|
||||||
|
);
|
||||||
|
const allowed = compileCapabilityFixture({ named, summary, workspace });
|
||||||
|
assert.equal(allowed.ok, true, JSON.stringify(allowed.diagnostics));
|
||||||
|
const denied = compileCapabilityFixture({ summary: summary.replace("queryable rpc", "queryable"), workspace });
|
||||||
|
assert.equal(denied.ok, false);
|
||||||
|
assert.ok(denied.diagnostics.some((issue) => issue.code === "invalid-query-contract"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queryable fields need one authoritative getter and supported values", () => {
|
||||||
|
const compile = (member: string) =>
|
||||||
|
compileCapabilityResourceSource(`interface Facts id "facts" revision "facts@1" { ${member} }`, {
|
||||||
|
source: { repository: "https://example.test/facts.git", commit: "a".repeat(40) },
|
||||||
|
});
|
||||||
|
assert.equal(compile('queryable value title id "title" : string { get id "title:get"; }').ok, true);
|
||||||
|
for (const member of [
|
||||||
|
'queryable value title id "title" : string { set id "title:set"; }',
|
||||||
|
'queryable value title id "title" : list<string> { get id "title:get"; }',
|
||||||
|
'queryable value title id "title" : string { get id "title:get"; get id "other:get"; }',
|
||||||
|
])
|
||||||
|
assert.equal(compile(member).ok, false, member);
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { queryWorkspaceFixture } from "./fixtures/query-workspace.js";
|
||||||
|
import { linkQueries } from "../src/query/link.js";
|
||||||
|
|
||||||
|
test("query fixture links generic collections, both implementations, and native getter/RPC setter", async () => {
|
||||||
|
const { workspace } = await queryWorkspaceFixture();
|
||||||
|
const query = workspace.linkedQueries.find((query) => query.id.endsWith(":upcoming"))!;
|
||||||
|
assert.ok(query);
|
||||||
|
assert.equal(query.fields.filter((field) => field.memberId === "title").length, 2);
|
||||||
|
assert.ok(
|
||||||
|
query.fields.filter((field) => field.memberId === "title").every((field) => field.binding.kind === "state"),
|
||||||
|
);
|
||||||
|
const broken = structuredClone(workspace);
|
||||||
|
const facts = broken.interfaceImports.find((iface) => iface.displayName === "TaskFacts")!;
|
||||||
|
const title = facts.members.find((member) => member.id === "title")!;
|
||||||
|
assert.equal(title.kind, "value");
|
||||||
|
delete title.queryRead;
|
||||||
|
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);
|
||||||
|
});
|
||||||
+49
-11
@@ -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,17 +23,37 @@ 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,
|
||||||
interfaces: [iface.resource.revision],
|
interfaces: [iface.resource.revision],
|
||||||
packages: [pkg.resource.revision],
|
packages: [pkg.resource.revision],
|
||||||
} as const;
|
} as const;
|
||||||
|
const opaque = structuredClone(iface.resource.revision);
|
||||||
|
opaque.revisionId = "opaque@1" as typeof opaque.revisionId;
|
||||||
|
opaque.displayName = "Opaque";
|
||||||
|
for (const member of opaque.members)
|
||||||
|
for (const operation of member.operations)
|
||||||
|
operation.outputType = { kind: "message", descriptorId: "UnsupportedBrowserMessage" };
|
||||||
|
const withUnusedOpaque = generateReactBindings(
|
||||||
|
{ ...schema, interfaces: [...schema.interfaces, opaque], packages: [...schema.packages] },
|
||||||
|
"p@1",
|
||||||
|
["props"],
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(withUnusedOpaque, /"opaque@1": defineReactInterfaceContract/);
|
||||||
const generated = generateReactBindings(
|
const generated = generateReactBindings(
|
||||||
{ ...schema, interfaces: [...schema.interfaces], packages: [...schema.packages] },
|
{ ...schema, interfaces: [...schema.interfaces], packages: [...schema.packages] },
|
||||||
"p@1",
|
"p@1",
|
||||||
@@ -54,17 +75,34 @@ test("React bindings preserve read-only, writable and nested reference contracts
|
|||||||
);
|
);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(root, "consumer.ts"),
|
path.join(root, "consumer.ts"),
|
||||||
`import {useLiveField, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime";
|
`import {useLiveField, tryConform, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime";
|
||||||
import type {ReactResults} from "./react-props.gen.js";
|
import {reactInterfaces} 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() {
|
||||||
|
const view = await tryConform("object", reactInterfaces.Fields);
|
||||||
|
if (!view) return;
|
||||||
|
await view.call["title.set"]("works");
|
||||||
|
// @ts-expect-error exact checked setter input
|
||||||
|
await view.call["title.set"](123);
|
||||||
|
// @ts-expect-error no invented operation
|
||||||
|
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;
|
||||||
@@ -72,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(
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ cacheEntries = {
|
|||||||
"commander@npm:13.1.0" = { filename = "commander-npm-13.1.0-bdbbfaaf9d-7b8c5544bb.zip"; hash = "sha512-e4xVRLunBPvoS3yrLgQ9+FhtXBFKTFtgf4OuUGBwiUDtC1vVg4z4zidTnN4mXBy9Wc48jGsBftPuyJQ+OkFRZA=="; };
|
"commander@npm:13.1.0" = { filename = "commander-npm-13.1.0-bdbbfaaf9d-7b8c5544bb.zip"; hash = "sha512-e4xVRLunBPvoS3yrLgQ9+FhtXBFKTFtgf4OuUGBwiUDtC1vVg4z4zidTnN4mXBy9Wc48jGsBftPuyJQ+OkFRZA=="; };
|
||||||
"debug@npm:4.4.3" = { filename = "debug-npm-4.4.3-0105c6123a-d79136ec6c.zip"; hash = "sha512-15E27GyD7L79D2pVk9pqnJHsTX3cS1TIg9bnHsmsy19noaXpbQCjKBlrW1yG02XpjYo6cIVqrxa057GYXmf1pg=="; };
|
"debug@npm:4.4.3" = { filename = "debug-npm-4.4.3-0105c6123a-d79136ec6c.zip"; hash = "sha512-15E27GyD7L79D2pVk9pqnJHsTX3cS1TIg9bnHsmsy19noaXpbQCjKBlrW1yG02XpjYo6cIVqrxa057GYXmf1pg=="; };
|
||||||
"fast-printf@npm:1.6.10" = { filename = "fast-printf-npm-1.6.10-c05cca9b81-630cccbef8.zip"; hash = "sha512-YwzMvvg0mm7q2plYyTkenRS1vZUn3SjZ5+nylbAGxkDdyo/CRwz/VepcbJLKUwSIu62/fCT0hSrYw02RVdqddQ=="; };
|
"fast-printf@npm:1.6.10" = { filename = "fast-printf-npm-1.6.10-c05cca9b81-630cccbef8.zip"; hash = "sha512-YwzMvvg0mm7q2plYyTkenRS1vZUn3SjZ5+nylbAGxkDdyo/CRwz/VepcbJLKUwSIu62/fCT0hSrYw02RVdqddQ=="; };
|
||||||
|
"graphql@npm:16.11.0" = { filename = "graphql-npm-16.11.0-836e6ade28-124da7860a.zip"; hash = "sha512-Ek2nhgoikums8v7Qxx/A9qm5yoZdOQ0RK91WPB9HQ1cUFQHBKJH0Fk/phDFXZHNq1n9wUhnGL3WAaB1DGoXbiA=="; };
|
||||||
"he@npm:1.2.0" = { filename = "he-npm-1.2.0-3b73a2ff07-a27d478bef.zip"; hash = "sha512-on1Hi+/jyBkvAGzdBjmmZ5iXnfpuISXGrFgqGaXr/sYq2D6DguYDYXDYc/RuRTan55W/i5W/fCR/TMCCXMyMFw=="; };
|
"he@npm:1.2.0" = { filename = "he-npm-1.2.0-3b73a2ff07-a27d478bef.zip"; hash = "sha512-on1Hi+/jyBkvAGzdBjmmZ5iXnfpuISXGrFgqGaXr/sYq2D6DguYDYXDYc/RuRTan55W/i5W/fCR/TMCCXMyMFw=="; };
|
||||||
"luxon@npm:3.5.0" = { filename = "luxon-npm-3.5.0-92bb977f7f-335789bba9.zip"; hash = "sha512-M1eJu6lQd9uDHvmYlO2t6yMCOz6yE3obVqzQ0pAIK2kc95MUPWnjC8Bp7JXwtJ82QZ9I6VHGgBTxn/4SBF40lA=="; };
|
"luxon@npm:3.5.0" = { filename = "luxon-npm-3.5.0-92bb977f7f-335789bba9.zip"; hash = "sha512-M1eJu6lQd9uDHvmYlO2t6yMCOz6yE3obVqzQ0pAIK2kc95MUPWnjC8Bp7JXwtJ82QZ9I6VHGgBTxn/4SBF40lA=="; };
|
||||||
"ms@npm:2.1.3" = { filename = "ms-npm-2.1.3-81ff3cfac1-d924b57e73.zip"; hash = "sha512-2SS1fnMSs7Y60h/Fs9wK9eeNYaH8fPtUV+2vJjJr9ivlMHzIf/toYu8cKzOwIzzbXU8BxMlYzA1mCUi2Wih6SA=="; };
|
"ms@npm:2.1.3" = { filename = "ms-npm-2.1.3-81ff3cfac1-d924b57e73.zip"; hash = "sha512-2SS1fnMSs7Y60h/Fs9wK9eeNYaH8fPtUV+2vJjJr9ivlMHzIf/toYu8cKzOwIzzbXU8BxMlYzA1mCUi2Wih6SA=="; };
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ __metadata:
|
|||||||
"@types/node": "npm:^24"
|
"@types/node": "npm:^24"
|
||||||
antlr-ng: "npm:^1.0.10"
|
antlr-ng: "npm:^1.0.10"
|
||||||
antlr4ng: "npm:^3.0.16"
|
antlr4ng: "npm:^3.0.16"
|
||||||
|
graphql: "npm:16.11.0"
|
||||||
typescript: "npm:^7.0.2"
|
typescript: "npm:^7.0.2"
|
||||||
bin:
|
bin:
|
||||||
quixos-capability-compile: dist/src/capability-language/cli.js
|
quixos-capability-compile: dist/src/capability-language/cli.js
|
||||||
@@ -321,6 +322,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"graphql@npm:16.11.0":
|
||||||
|
version: 16.11.0
|
||||||
|
resolution: "graphql@npm:16.11.0"
|
||||||
|
checksum: 10c0/124da7860a2292e9acf2fed0c71fc0f6a9b9ca865d390d112bdd563c1f474357141501c12891f4164fe984315764736ad67f705219c62f7580681d431a85db88
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"he@npm:1.2.0":
|
"he@npm:1.2.0":
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
resolution: "he@npm:1.2.0"
|
resolution: "he@npm:1.2.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user