Publish quixos-protocol from c87835bff85794bc232dbaaddc1eb3b593c2aab8
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
|
||||
"sourceCommit": "4c88728f144add3457b4273d4b9a04ee53c53be3",
|
||||
"sourceCommit": "c87835bff85794bc232dbaaddc1eb3b593c2aab8",
|
||||
"sourcePath": "quixos-protocol",
|
||||
"exportName": "quixos-protocol",
|
||||
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
|
||||
|
||||
@@ -3,6 +3,7 @@ syntax = "proto3";
|
||||
package camino;
|
||||
|
||||
import "camino/schema.proto";
|
||||
import "quixos/refs.proto";
|
||||
|
||||
service CaminoService {
|
||||
rpc ExecuteQuery(QueryRequest) returns (QueryResponse);
|
||||
@@ -69,6 +70,27 @@ message Value {
|
||||
CrdtValue crdt_value = 10;
|
||||
}
|
||||
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 {
|
||||
@@ -271,6 +293,7 @@ message QueryResponse {
|
||||
// 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
|
||||
|
||||
@@ -104,6 +104,8 @@ message QuerySelection {
|
||||
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.
|
||||
|
||||
@@ -87,20 +87,16 @@ message InvokeCapabilityResponse {
|
||||
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 {
|
||||
// The public getter; setter must belong to the same value member.
|
||||
quixos.CapabilityRef capability = 1;
|
||||
string object_id = 2;
|
||||
string setter_operation_id = 3;
|
||||
string binding_digest = 4;
|
||||
camino.CrdtValue update = 5;
|
||||
oneof edit {
|
||||
camino.CrdtValue update = 5;
|
||||
camino.Value replacement = 7;
|
||||
}
|
||||
string client_mutation_id = 6;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,20 @@ message CapabilityRef {
|
||||
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;
|
||||
|
||||
@@ -5,6 +5,8 @@ declare module "@quixos/web-studio-react-runtime" {
|
||||
import type * as React from "react";
|
||||
export function useComponentOverlayContainer(): HTMLElement;
|
||||
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 & {
|
||||
readonly $quixosAtom: AtomId;
|
||||
};
|
||||
@@ -22,8 +24,9 @@ declare module "@quixos/web-studio-react-runtime" {
|
||||
export function useTryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>): ConformanceResult<View>;
|
||||
export 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 interface QueryDescriptor<Variables, Result> {readonly id: string; readonly definitionDigest: string; readonly rootInterfaceRevisionId: string; readonly variables: QueryValueType; readonly output: QueryValueType; readonly watch: boolean; readonly $types?: (variables: Variables, result: Result) => [Variables, Result]}
|
||||
export type QueryPartial<T> = T extends QueryReference ? T : T extends readonly (infer Item)[] ? QueryPartial<Item>[] : T extends object ? {[Key in keyof T]?: QueryPartial<T[Key]>} : T;
|
||||
export 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};
|
||||
@@ -55,8 +58,12 @@ declare module "@quixos/web-studio-react-runtime" {
|
||||
options?: {clientMutationId?: string; signal?: AbortSignal},
|
||||
) => Promise<Result>;
|
||||
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 function useLiveField<T>(field: WritableField<T>): readonly [T, (value: T) => Promise<void>];
|
||||
export function useLiveField<T>(field: ReadableField<T>): readonly [T];
|
||||
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 type WritableLiveFieldState<T> = LiveFieldState<T> & {set(value: T): Promise<void>; retry(): Promise<void>};
|
||||
export function useLiveField<T>(field: ReadableField<T>, options: {id: string; write: (value: T) => Promise<void>}): WritableLiveFieldState<T>;
|
||||
export function useLiveField<T>(field: WritableField<T>): WritableLiveFieldState<T>;
|
||||
export function useLiveField<T>(field: ReadableField<T>): LiveFieldState<T>;
|
||||
export function useSuspenseLiveField<T>(field: WritableField<T>): WritableLiveFieldState<T> & {status: "ready"; value: T};
|
||||
export function useSuspenseLiveField<T>(field: ReadableField<T>): LiveFieldState<T> & {status: "ready"; value: T};
|
||||
}
|
||||
`;
|
||||
|
||||
+19
-1
@@ -1,5 +1,6 @@
|
||||
import type { BindingSchema } from "./index.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. */
|
||||
export function generateReactBindings(
|
||||
@@ -128,9 +129,25 @@ export function generateReactBindings(
|
||||
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)}: ${queryType(query.output)}`).join(";")}};\n` +
|
||||
`export type QueryResults = {${queries.map((query) => `${q(query.declaration.displayName)}: ${presentedType(query.output, queryPresentation(query, schema.interfaces))}`).join(";")}};\n` +
|
||||
`export const queries: {${queries.map((query) => `${q(query.declaration.displayName)}: QueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}]>`).join(";")}} = ${JSON.stringify(
|
||||
Object.fromEntries(
|
||||
queries.map((query) => [
|
||||
@@ -141,6 +158,7 @@ export function generateReactBindings(
|
||||
rootInterfaceRevisionId: query.declaration.root,
|
||||
variables: query.variables,
|
||||
output: query.output,
|
||||
liveFields: queryPresentation(query, schema.interfaces),
|
||||
watch: query.declaration.watch,
|
||||
},
|
||||
]),
|
||||
|
||||
+150
-46
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+37
-59
File diff suppressed because one or more lines are too long
@@ -2,15 +2,15 @@
|
||||
// @generated from file quixos/refs.proto (package quixos, syntax proto3)
|
||||
/* eslint-disable */
|
||||
|
||||
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
|
||||
import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||
import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* Describes the file quixos/refs.proto.
|
||||
*/
|
||||
export const file_quixos_refs: GenFile = /*@__PURE__*/
|
||||
fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zInUKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCRIvCgtjb25mb3JtYW5jZRgDIAEoCzIaLnF1aXhvcy5Db25mb3JtYW5jZVdpdG5lc3MilgEKEkNvbmZvcm1hbmNlV2l0bmVzcxIRCglvYmplY3RfaWQYASABKAkSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAIgASgJEhYKDmNvbmZvcm1hbmNlX2lkGAMgASgJEh0KFXdvcmtzcGFjZV9yZXZpc2lvbl9pZBgEIAEoCRIXCg93b3Jrc3BhY2VfZXBvY2gYBSABKAkiQgoQUGFja2FnZUV4cG9ydFJlZhIbChNwYWNrYWdlX3JldmlzaW9uX2lkGAEgASgJEhEKCWV4cG9ydF9pZBgCIAEoCSLYAQoSSW5qZWN0ZWREZXBlbmRlbmN5Eg8KB3BvcnRfaWQYASABKAkSFwoNc3RhdGVfc2xvdF9pZBgCIAEoCUgAEiYKBGVkZ2UYAyABKAsyFi5xdWl4b3MuRWRnZURlcGVuZGVuY3lIABIfChVpbnRlcmZhY2VfcmV2aXNpb25faWQYBCABKAlIABIdChNjb25zdHJ1Y3Rvcl9hdG9tX2lkGAUgASgJSAASEgoIcXVlcnlfaWQYByABKAlIABIRCglvYmplY3RfaWQYBiABKAlCCQoHYmluZGluZyI9Cg5FZGdlRGVwZW5kZW5jeRIUCgxlZGdlX3R5cGVfaWQYASABKAkSFQoNcHJvamVjdGlvbl9pZBgCIAEoCWIGcHJvdG8z");
|
||||
fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zInUKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCRIvCgtjb25mb3JtYW5jZRgDIAEoCzIaLnF1aXhvcy5Db25mb3JtYW5jZVdpdG5lc3Mi0QEKDEZpZWxkRWRpdGluZxIbChNnZXR0ZXJfb3BlcmF0aW9uX2lkGAEgASgJEhsKE3NldHRlcl9vcGVyYXRpb25faWQYAiABKAkSFQoNZG9jdW1lbnRfdHlwZRgDIAEoCRIWCg5iaW5kaW5nX2RpZ2VzdBgEIAEoCRInCgRtb2RlGAUgASgOMhkucXVpeG9zLkZpZWxkRWRpdGluZy5Nb2RlIi8KBE1vZGUSDwoLVU5TUEVDSUZJRUQQABIMCghSRUdJU1RFUhABEggKBENSRFQQAiKWAQoSQ29uZm9ybWFuY2VXaXRuZXNzEhEKCW9iamVjdF9pZBgBIAEoCRIdChVpbnRlcmZhY2VfcmV2aXNpb25faWQYAiABKAkSFgoOY29uZm9ybWFuY2VfaWQYAyABKAkSHQoVd29ya3NwYWNlX3JldmlzaW9uX2lkGAQgASgJEhcKD3dvcmtzcGFjZV9lcG9jaBgFIAEoCSJCChBQYWNrYWdlRXhwb3J0UmVmEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSEQoJZXhwb3J0X2lkGAIgASgJItgBChJJbmplY3RlZERlcGVuZGVuY3kSDwoHcG9ydF9pZBgBIAEoCRIXCg1zdGF0ZV9zbG90X2lkGAIgASgJSAASJgoEZWRnZRgDIAEoCzIWLnF1aXhvcy5FZGdlRGVwZW5kZW5jeUgAEh8KFWludGVyZmFjZV9yZXZpc2lvbl9pZBgEIAEoCUgAEh0KE2NvbnN0cnVjdG9yX2F0b21faWQYBSABKAlIABISCghxdWVyeV9pZBgHIAEoCUgAEhEKCW9iamVjdF9pZBgGIAEoCUIJCgdiaW5kaW5nIj0KDkVkZ2VEZXBlbmRlbmN5EhQKDGVkZ2VfdHlwZV9pZBgBIAEoCRIVCg1wcm9qZWN0aW9uX2lkGAIgASgJYgZwcm90bzM");
|
||||
|
||||
/**
|
||||
* @generated from message quixos.CapabilityRef
|
||||
@@ -41,6 +41,71 @@ export type CapabilityRef = Message<"quixos.CapabilityRef"> & {
|
||||
export const CapabilityRefSchema: GenMessage<CapabilityRef> = /*@__PURE__*/
|
||||
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
|
||||
*/
|
||||
@@ -76,7 +141,7 @@ export type ConformanceWitness = Message<"quixos.ConformanceWitness"> & {
|
||||
* Use `create(ConformanceWitnessSchema)` to create a new message.
|
||||
*/
|
||||
export const ConformanceWitnessSchema: GenMessage<ConformanceWitness> = /*@__PURE__*/
|
||||
messageDesc(file_quixos_refs, 1);
|
||||
messageDesc(file_quixos_refs, 2);
|
||||
|
||||
/**
|
||||
* @generated from message quixos.PackageExportRef
|
||||
@@ -98,7 +163,7 @@ export type PackageExportRef = Message<"quixos.PackageExportRef"> & {
|
||||
* Use `create(PackageExportRefSchema)` to create a new message.
|
||||
*/
|
||||
export const PackageExportRefSchema: GenMessage<PackageExportRef> = /*@__PURE__*/
|
||||
messageDesc(file_quixos_refs, 2);
|
||||
messageDesc(file_quixos_refs, 3);
|
||||
|
||||
/**
|
||||
* @generated from message quixos.InjectedDependency
|
||||
@@ -158,7 +223,7 @@ export type InjectedDependency = Message<"quixos.InjectedDependency"> & {
|
||||
* Use `create(InjectedDependencySchema)` to create a new message.
|
||||
*/
|
||||
export const InjectedDependencySchema: GenMessage<InjectedDependency> = /*@__PURE__*/
|
||||
messageDesc(file_quixos_refs, 3);
|
||||
messageDesc(file_quixos_refs, 4);
|
||||
|
||||
/**
|
||||
* @generated from message quixos.EdgeDependency
|
||||
@@ -180,5 +245,5 @@ export type EdgeDependency = Message<"quixos.EdgeDependency"> & {
|
||||
* Use `create(EdgeDependencySchema)` to create a new message.
|
||||
*/
|
||||
export const EdgeDependencySchema: GenMessage<EdgeDependency> = /*@__PURE__*/
|
||||
messageDesc(file_quixos_refs, 4);
|
||||
messageDesc(file_quixos_refs, 5);
|
||||
|
||||
|
||||
+40
-7
@@ -101,7 +101,7 @@ export async function compileQuery(
|
||||
if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node);
|
||||
},
|
||||
Directive(node) {
|
||||
if (!["include", "skip"].includes(node.name.value))
|
||||
if (!["include", "skip", "live"].includes(node.name.value))
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
|
||||
},
|
||||
InlineFragment(node) {
|
||||
@@ -180,6 +180,15 @@ export async function compileQuery(
|
||||
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)!;
|
||||
@@ -286,9 +295,12 @@ export async function compileQuery(
|
||||
field.type,
|
||||
selection.selectionSet,
|
||||
depth + 1,
|
||||
conditional || !!selection.directives?.length,
|
||||
conditional || !!selection.directives?.some((entry) => entry.name.value !== "live"),
|
||||
);
|
||||
if ((conditional || selection.directives?.length) && fields[key]!.kind !== "optional")
|
||||
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]!);
|
||||
}
|
||||
@@ -354,10 +366,12 @@ export async function compileQuery(
|
||||
}
|
||||
};
|
||||
const conditions = (directives: readonly DirectiveNode[] = []) =>
|
||||
directives.map((directive) => ({
|
||||
include: directive.name.value === "include",
|
||||
value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value),
|
||||
}));
|
||||
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,
|
||||
@@ -390,6 +404,13 @@ export async function compileQuery(
|
||||
{
|
||||
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)],
|
||||
@@ -405,6 +426,18 @@ export async function compileQuery(
|
||||
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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
GraphQLList,
|
||||
GraphQLNonNull,
|
||||
GraphQLSchema,
|
||||
GraphQLDirective,
|
||||
DirectiveLocation,
|
||||
specifiedDirectives,
|
||||
type GraphQLOutputType,
|
||||
type GraphQLInputType,
|
||||
type GraphQLFieldConfigMap,
|
||||
@@ -241,6 +244,7 @@ export function querySchema(
|
||||
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)) } },
|
||||
|
||||
@@ -98,6 +98,7 @@ export type QueryArgument =
|
||||
| { kind: "list"; values: QueryArgument[] }
|
||||
| { kind: "object"; fields: Record<string, QueryArgument> };
|
||||
export interface QuerySelection {
|
||||
liveSelectionId?: string;
|
||||
name: string;
|
||||
key: string;
|
||||
interfaceRevisionId?: InterfaceRevisionId;
|
||||
|
||||
Vendored
+13
-4
@@ -8,7 +8,7 @@ export const queryFixtureSource = {
|
||||
repository: "https://query-fixture.example.test/source.git",
|
||||
commit: "1".repeat(40),
|
||||
};
|
||||
export async function queryWorkspaceFixture() {
|
||||
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 = "") {
|
||||
@@ -29,7 +29,7 @@ export async function queryWorkspaceFixture() {
|
||||
`
|
||||
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";}
|
||||
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";}
|
||||
`,
|
||||
@@ -81,7 +81,16 @@ export async function queryWorkspaceFixture() {
|
||||
"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) => documents[name]!)),
|
||||
pkg.queries!.map((query) =>
|
||||
compileQuery(query, allInterfaces, async (name) =>
|
||||
options.live
|
||||
? documents[name]!.replace(/\btitle\b/g, "title @live").replace(
|
||||
"title @live rank done",
|
||||
"title @live rank @live done @live",
|
||||
)
|
||||
: documents[name]!,
|
||||
),
|
||||
),
|
||||
);
|
||||
const implementation = (atom: string) => `conform ${atom} as TaskFacts id "${atom}-facts" {
|
||||
private state Title${atom} id "${atom}:title" on ${atom} : string policy crdt(string) default "Untitled";
|
||||
@@ -92,7 +101,7 @@ export async function queryWorkspaceFixture() {
|
||||
interface PersonFacts projection tasks id "${atom}:assignee:inverse" many;
|
||||
}
|
||||
bind title.get to state Title${atom}.read;
|
||||
bind title.set to package Queries.titleSet with {title to state Title${atom};};
|
||||
${options.live ? `bind title.set to state Title${atom}.write;` : `bind title.set to package Queries.titleSet with {title to state Title${atom};};`}
|
||||
bind done to state Done${atom}; bind rank to state Rank${atom};
|
||||
bind assignee.resolve to edge Assignee${atom}.assignee.resolve;
|
||||
bind score.get to package Queries.score query-reason "Explicit bounded score computation";
|
||||
|
||||
@@ -4,6 +4,8 @@ import { compileCapabilityResourceSource } from "../src/capability-language/inde
|
||||
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) };
|
||||
@@ -125,6 +127,48 @@ const document = `query Upcoming($first: Int!, $before: Int64!) {
|
||||
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;
|
||||
|
||||
+26
-10
@@ -7,13 +7,14 @@ import { spawnSync } from "node:child_process";
|
||||
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js";
|
||||
import { generateReactBindings } from "../src/bindings/react.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) };
|
||||
test("React bindings preserve read-only, writable and nested reference contracts", async (t) => {
|
||||
const iface = compileCapabilityResourceSource(
|
||||
`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"; }
|
||||
value summary id "summary" : string { get id "summary:get"; }
|
||||
queryable value title id "title" : string { get id "title:get"; set id "title:set"; watch start id "watch" stop id "stop"; }
|
||||
queryable value summary id "summary" : string { get id "summary:get"; }
|
||||
}`,
|
||||
{ source },
|
||||
);
|
||||
@@ -22,11 +23,19 @@ test("React bindings preserve read-only, writable and nested reference contracts
|
||||
const pkg = compileCapabilityResourceSource(
|
||||
`import interface Fields; package P id "p" revision "p@1" {
|
||||
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]]) } },
|
||||
);
|
||||
assert.ok(pkg.ok && pkg.resource.kind === "package");
|
||||
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 = {
|
||||
format: "quixos-bindings",
|
||||
version: 1,
|
||||
@@ -68,7 +77,14 @@ test("React bindings preserve read-only, writable and nested reference contracts
|
||||
path.join(root, "consumer.ts"),
|
||||
`import {useLiveField, tryConform, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime";
|
||||
import {reactInterfaces} from "./react-props.gen.js";
|
||||
import type {ReactResults} from "./react-props.gen.js";
|
||||
import type {ReactResults, QueryResults} from "./react-props.gen.js";
|
||||
declare const query: QueryResults["Editor"];
|
||||
useLiveField(query.root.title).set("new");
|
||||
// @ts-expect-error live query fields retain exact setter types
|
||||
useLiveField(query.root.title).set(123);
|
||||
// @ts-expect-error readonly query fields do not acquire a setter
|
||||
useLiveField(query.root.summary).set("no");
|
||||
const plain: string = query.root.plain;
|
||||
async function lookup() {
|
||||
const view = await tryConform("object", reactInterfaces.Fields);
|
||||
if (!view) return;
|
||||
@@ -79,14 +95,14 @@ async function lookup() {
|
||||
await view.call["summary.set"]("no setter");
|
||||
}
|
||||
declare const props: ReactResults["props"];
|
||||
const [title, setTitle] = useLiveField(props.fields.fields.title);
|
||||
setTitle("new");
|
||||
const title = useLiveField(props.fields.fields.title);
|
||||
title.set("new");
|
||||
// @ts-expect-error wrong setter value
|
||||
setTitle(123);
|
||||
title.set(123);
|
||||
// @ts-expect-error read-only hook has no setter
|
||||
const [summary, setSummary] = useLiveField(props.fields.fields.summary);
|
||||
const [manual, write] = useLiveField(props.fields.fields.summary, {write: async (value: string) => {}});
|
||||
write("new");
|
||||
useLiveField(props.fields.fields.summary).set("no");
|
||||
const manual = useLiveField(props.fields.fields.summary, {id: "manual", write: async (value: string) => {}});
|
||||
manual.set("new");
|
||||
const readonly: ReadableField<string> = props.fields.fields.title;
|
||||
// @ts-expect-error read-only does not satisfy writable
|
||||
const writable: WritableField<string> = props.fields.fields.summary;
|
||||
@@ -94,7 +110,7 @@ declare const narrow: WritableField<"only">;
|
||||
// @ts-expect-error writable references are invariant
|
||||
const widened: WritableField<string> = narrow;
|
||||
// @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(
|
||||
|
||||
Reference in New Issue
Block a user