Compare commits

..

2 Commits

Author SHA1 Message Date
Quixos Subtree Publisher b0d31ba51c Publish quixos-protocol from c87835bff85794bc232dbaaddc1eb3b593c2aab8 2026-09-18 09:19:49 +00:00
Timothy J. Aveni 2dffdbcd9f Implement shared live fields and checked query hydration
Add native register acknowledgments and idempotent mutation replay, shared optimistic field controllers, overlapping custom setters, non-suspending hooks and explicit Suspense. Carry checked @live provenance through batched queries and hydrate shared browser fields with coverage leases. Update tracker/scaffolds/guides and verify compiler, PostgreSQL, browser and immutable workspace paths.
2026-09-18 02:19:49 -07:00
18 changed files with 528 additions and 149 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"version": 1, "version": 1,
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
"sourceCommit": "4c88728f144add3457b4273d4b9a04ee53c53be3", "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"
+23
View File
@@ -3,6 +3,7 @@ syntax = "proto3";
package camino; package camino;
import "camino/schema.proto"; import "camino/schema.proto";
import "quixos/refs.proto";
service CaminoService { service CaminoService {
rpc ExecuteQuery(QueryRequest) returns (QueryResponse); rpc ExecuteQuery(QueryRequest) returns (QueryResponse);
@@ -69,6 +70,27 @@ message Value {
CrdtValue crdt_value = 10; CrdtValue crdt_value = 10;
} }
ValueSource source = 11; ValueSource source = 11;
// Trusted query coordinator annotation. Travels with values through residual
// row selection, then is removed in favor of final-path hydration metadata.
QueryFieldOrigin query_origin = 12;
}
message QueryFieldOrigin {
string selection_id = 1;
string atom_id = 2;
string interface_revision_id = 3;
string member_id = 4;
StateValueSource source = 5;
}
message QueryLiveField {
repeated QueryPathPart path = 1;
string selection_id = 2;
string object_id = 3;
quixos.CapabilityRef capability = 4;
string watch_operation_id = 5;
string setter_operation_id = 6;
quixos.FieldEditing editing = 7;
StateValueSource source = 8;
} }
message InstallPersistencePlanRequest { message InstallPersistencePlanRequest {
@@ -271,6 +293,7 @@ message QueryResponse {
// Native reads share one database snapshot; package enrichment does not. // Native reads share one database snapshot; package enrichment does not.
string consistency = 9; // native-snapshot | mixed string consistency = 9; // native-snapshot | mixed
repeated QueryRelationalCapture relational_captures = 10; repeated QueryRelationalCapture relational_captures = 10;
repeated QueryLiveField live_fields = 11;
} }
// Private coordinator input, removed before publishing a result. Memberships // Private coordinator input, removed before publishing a result. Memberships
+2
View File
@@ -104,6 +104,8 @@ message QuerySelection {
repeated QuerySelection selection = 8; repeated QuerySelection selection = 8;
QueryRelationalPlan relational = 9; QueryRelationalPlan relational = 9;
QueryPredicate predicate = 10; 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 // Resolved IDs, not GraphQL names, determine execution. Response selections
// remain separate so aliases and fragments cannot change relational semantics. // remain separate so aliases and fragments cannot change relational semantics.
+3 -7
View File
@@ -87,20 +87,16 @@ message InvokeCapabilityResponse {
FieldEditing field_editing = 7; FieldEditing field_editing = 7;
} }
// Resolved from the checked native getter/setter binding, not Value.source.
message FieldEditing {
string getter_operation_id = 1;
string setter_operation_id = 2;
string document_type = 3;
string binding_digest = 4;
}
message EditCapabilityFieldRequest { message EditCapabilityFieldRequest {
// The public getter; setter must belong to the same value member. // The public getter; setter must belong to the same value member.
quixos.CapabilityRef capability = 1; quixos.CapabilityRef capability = 1;
string object_id = 2; string object_id = 2;
string setter_operation_id = 3; string setter_operation_id = 3;
string binding_digest = 4; string binding_digest = 4;
oneof edit {
camino.CrdtValue update = 5; camino.CrdtValue update = 5;
camino.Value replacement = 7;
}
string client_mutation_id = 6; string client_mutation_id = 6;
} }
+14
View File
@@ -9,6 +9,20 @@ message CapabilityRef {
ConformanceWitness conformance = 3; ConformanceWitness conformance = 3;
} }
// Resolved native editing semantics. A source snapshot alone grants no writer.
message FieldEditing {
string getter_operation_id = 1;
string setter_operation_id = 2;
string document_type = 3;
string binding_digest = 4;
enum Mode {
UNSPECIFIED = 0;
REGISTER = 1;
CRDT = 2;
}
Mode mode = 5;
}
message ConformanceWitness { message ConformanceWitness {
string object_id = 1; string object_id = 1;
string interface_revision_id = 2; string interface_revision_id = 2;
+12 -5
View File
@@ -5,6 +5,8 @@ declare module "@quixos/web-studio-react-runtime" {
import type * as React from "react"; import type * as React from "react";
export function useComponentOverlayContainer(): HTMLElement; export function useComponentOverlayContainer(): HTMLElement;
export function useComponentStyleRoot(): ShadowRoot; export function useComponentStyleRoot(): ShadowRoot;
/** Aggregate transport counters only; contains no object identities or values. */
export function getFieldTransportMetrics(): Partial<Record<"get" | "watch" | "register" | "crdt" | "setter", Readonly<{started: number; active: number; completed: number; failed: number; totalMs: number}>>>;
export type ObjectRef<AtomId extends string> = string & { export type ObjectRef<AtomId extends string> = string & {
readonly $quixosAtom: AtomId; readonly $quixosAtom: AtomId;
}; };
@@ -22,8 +24,9 @@ declare module "@quixos/web-studio-react-runtime" {
export function useTryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>): ConformanceResult<View>; export function useTryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>): ConformanceResult<View>;
export type QueryValueType = {kind: "builtin" | "scalar"; name: string} | {kind: "record"; fields: Record<string, QueryValueType>} | {kind: "optional" | "list"; value: QueryValueType} | {kind: "object-ref"; expectation: {kind: "atom"; atomId: string} | {kind: "interface"; interfaceRevisionId: string}}; export type QueryValueType = {kind: "builtin" | "scalar"; name: string} | {kind: "record"; fields: Record<string, QueryValueType>} | {kind: "optional" | "list"; value: QueryValueType} | {kind: "object-ref"; expectation: {kind: "atom"; atomId: string} | {kind: "interface"; interfaceRevisionId: string}};
export type QueryReference<Contract extends string = string> = {readonly $quixosRef: string; readonly queryContract: Contract}; export type QueryReference<Contract extends string = string> = {readonly $quixosRef: string; readonly queryContract: Contract};
export interface QueryDescriptor<Variables, Result> {readonly id: string; readonly definitionDigest: string; readonly rootInterfaceRevisionId: string; readonly variables: QueryValueType; readonly output: QueryValueType; readonly watch: boolean; readonly $types?: (variables: Variables, result: Result) => [Variables, Result]} export type QueryLiveProjection = {path: readonly string[]; selectionId: string; interfaceRevisionId: string; getOperationId: string; setOperationId?: string; watchOperationId?: string; inputKind: "fields" | "value"; valueType: QueryValueType; conditions: {include: boolean; defaultValue?: boolean; value: {kind: "variable"; name: string} | {kind: "literal"; value: unknown}}[]};
export type QueryPartial<T> = T extends QueryReference ? T : T extends readonly (infer Item)[] ? QueryPartial<Item>[] : T extends object ? {[Key in keyof T]?: QueryPartial<T[Key]>} : T; export interface QueryDescriptor<Variables, Result> {readonly id: string; readonly definitionDigest: string; readonly rootInterfaceRevisionId: string; readonly variables: QueryValueType; readonly output: QueryValueType; readonly watch: boolean; readonly liveFields?: readonly QueryLiveProjection[]; readonly $types?: (variables: Variables, result: Result) => [Variables, Result]}
export type QueryPartial<T> = T extends {readonly $quixosRef: string} | {readonly capability: {readonly getOperationId: string}} ? T : T extends readonly (infer Item)[] ? QueryPartial<Item>[] : T extends object ? {[Key in keyof T]?: QueryPartial<T[Key]>} : T;
export type QueryFieldState = {path: readonly (string | number)[]; status: "pending" | "error"; error?: string}; export type QueryFieldState = {path: readonly (string | number)[]; status: "pending" | "error"; error?: string};
export type QueryState<T> = {refreshing: boolean; fields: QueryFieldState[]; runId?: string; sequence?: bigint; consistency?: string} & ({status: "loading"; data?: undefined; error?: undefined} | {status: "ready"; data: T; error?: undefined} | {status: "partial"; data: QueryPartial<T>; error?: undefined} | {status: "error"; data?: T | QueryPartial<T>; error: Error}); export type QueryState<T> = {refreshing: boolean; fields: QueryFieldState[]; runId?: string; sequence?: bigint; consistency?: string} & ({status: "loading"; data?: undefined; error?: undefined} | {status: "ready"; data: T; error?: undefined} | {status: "partial"; data: QueryPartial<T>; error?: undefined} | {status: "error"; data?: T | QueryPartial<T>; error: Error});
export function useQuery<Variables, Result>(descriptor: QueryDescriptor<Variables, Result>, options: {root: string | {readonly $quixosRef: string}; variables: Variables}): QueryState<Result> & {refresh(): void}; export function useQuery<Variables, Result>(descriptor: QueryDescriptor<Variables, Result>, options: {root: string | {readonly $quixosRef: string}; variables: Variables}): QueryState<Result> & {refresh(): void};
@@ -55,8 +58,12 @@ declare module "@quixos/web-studio-react-runtime" {
options?: {clientMutationId?: string; signal?: AbortSignal}, options?: {clientMutationId?: string; signal?: AbortSignal},
) => Promise<Result>; ) => Promise<Result>;
export const h: typeof React.createElement; export const h: typeof React.createElement;
export function useLiveField<T>(field: ReadableField<T>, options: {write: (value: T) => Promise<void>}): readonly [T, (value: T) => Promise<void>]; export type LiveFieldState<T> = {refreshing: boolean; stale: boolean; pendingWrites: number; writeError?: Error; refresh(): Promise<void>; discard(): void} & ({status: "loading"; value?: undefined; error?: undefined} | {status: "ready"; value: T; error?: Error} | {status: "error"; value?: undefined; error: Error});
export function useLiveField<T>(field: WritableField<T>): readonly [T, (value: T) => Promise<void>]; export type WritableLiveFieldState<T> = LiveFieldState<T> & {set(value: T): Promise<void>; retry(): Promise<void>};
export function useLiveField<T>(field: ReadableField<T>): readonly [T]; export function useLiveField<T>(field: ReadableField<T>, options: {id: string; write: (value: T) => Promise<void>}): WritableLiveFieldState<T>;
export function useLiveField<T>(field: WritableField<T>): WritableLiveFieldState<T>;
export function useLiveField<T>(field: ReadableField<T>): LiveFieldState<T>;
export function useSuspenseLiveField<T>(field: WritableField<T>): WritableLiveFieldState<T> & {status: "ready"; value: T};
export function useSuspenseLiveField<T>(field: ReadableField<T>): LiveFieldState<T> & {status: "ready"; value: T};
} }
`; `;
+19 -1
View File
@@ -1,5 +1,6 @@
import type { BindingSchema } from "./index.js"; import type { BindingSchema } from "./index.js";
import type { ValueType } from "../capability-model/types.js"; import type { ValueType } from "../capability-model/types.js";
import { queryPresentation } from "../query/presentation.js";
/** Browser projection of the SAME checked RPC types, not a second props schema. */ /** Browser projection of the SAME checked RPC types, not a second props schema. */
export function generateReactBindings( export function generateReactBindings(
@@ -128,9 +129,25 @@ export function generateReactBindings(
return type(value); return type(value);
}; };
const queries = pkg.checkedQueries ?? []; const queries = pkg.checkedQueries ?? [];
const presentedType = (
value: ValueType,
fields: ReturnType<typeof queryPresentation>,
path: string[] = [],
): string => {
const field = fields.find((field) => JSON.stringify(field.path) === JSON.stringify(path));
if (field)
return `${field.conditions.length ? "(" : ""}${field.setOperationId ? "WritableField" : "ReadableField"}<${type(field.valueType)}>${field.conditions.length ? " | null)" : ""}`;
if (value.kind === "optional") return `(${presentedType(value.value, fields, path)} | null)`;
if (value.kind === "list") return `Array<${presentedType(value.value, fields, [...path, "*"])}>`;
if (value.kind === "record")
return `{${Object.entries(value.fields)
.map(([key, child]) => `${q(key)}: ${presentedType(child, fields, [...path, key])}`)
.join(";")}}`;
return queryType(value);
};
const queryCode = const queryCode =
`export type QueryVariables = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.variables)}`).join(";")}};\n` + `export type QueryVariables = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.variables)}`).join(";")}};\n` +
`export type QueryResults = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.output)}`).join(";")}};\n` + `export type QueryResults = {${queries.map((query) => `${q(query.declaration.displayName)}: ${presentedType(query.output, queryPresentation(query, schema.interfaces))}`).join(";")}};\n` +
`export const queries: {${queries.map((query) => `${q(query.declaration.displayName)}: QueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}]>`).join(";")}} = ${JSON.stringify( `export const queries: {${queries.map((query) => `${q(query.declaration.displayName)}: QueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}]>`).join(";")}} = ${JSON.stringify(
Object.fromEntries( Object.fromEntries(
queries.map((query) => [ queries.map((query) => [
@@ -141,6 +158,7 @@ export function generateReactBindings(
rootInterfaceRevisionId: query.declaration.root, rootInterfaceRevisionId: query.declaration.root,
variables: query.variables, variables: query.variables,
output: query.output, output: query.output,
liveFields: queryPresentation(query, schema.interfaces),
watch: query.declaration.watch, watch: query.declaration.watch,
}, },
]), ]),
+150 -46
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+72 -7
View File
@@ -2,15 +2,15 @@
// @generated from file quixos/refs.proto (package quixos, syntax proto3) // @generated from file quixos/refs.proto (package quixos, syntax proto3)
/* eslint-disable */ /* eslint-disable */
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf"; import type { Message } from "@bufbuild/protobuf";
/** /**
* Describes the file quixos/refs.proto. * Describes the file quixos/refs.proto.
*/ */
export const file_quixos_refs: GenFile = /*@__PURE__*/ export const file_quixos_refs: GenFile = /*@__PURE__*/
fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zInUKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCRIvCgtjb25mb3JtYW5jZRgDIAEoCzIaLnF1aXhvcy5Db25mb3JtYW5jZVdpdG5lc3MilgEKEkNvbmZvcm1hbmNlV2l0bmVzcxIRCglvYmplY3RfaWQYASABKAkSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAIgASgJEhYKDmNvbmZvcm1hbmNlX2lkGAMgASgJEh0KFXdvcmtzcGFjZV9yZXZpc2lvbl9pZBgEIAEoCRIXCg93b3Jrc3BhY2VfZXBvY2gYBSABKAkiQgoQUGFja2FnZUV4cG9ydFJlZhIbChNwYWNrYWdlX3JldmlzaW9uX2lkGAEgASgJEhEKCWV4cG9ydF9pZBgCIAEoCSLYAQoSSW5qZWN0ZWREZXBlbmRlbmN5Eg8KB3BvcnRfaWQYASABKAkSFwoNc3RhdGVfc2xvdF9pZBgCIAEoCUgAEiYKBGVkZ2UYAyABKAsyFi5xdWl4b3MuRWRnZURlcGVuZGVuY3lIABIfChVpbnRlcmZhY2VfcmV2aXNpb25faWQYBCABKAlIABIdChNjb25zdHJ1Y3Rvcl9hdG9tX2lkGAUgASgJSAASEgoIcXVlcnlfaWQYByABKAlIABIRCglvYmplY3RfaWQYBiABKAlCCQoHYmluZGluZyI9Cg5FZGdlRGVwZW5kZW5jeRIUCgxlZGdlX3R5cGVfaWQYASABKAkSFQoNcHJvamVjdGlvbl9pZBgCIAEoCWIGcHJvdG8z"); fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zInUKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCRIvCgtjb25mb3JtYW5jZRgDIAEoCzIaLnF1aXhvcy5Db25mb3JtYW5jZVdpdG5lc3Mi0QEKDEZpZWxkRWRpdGluZxIbChNnZXR0ZXJfb3BlcmF0aW9uX2lkGAEgASgJEhsKE3NldHRlcl9vcGVyYXRpb25faWQYAiABKAkSFQoNZG9jdW1lbnRfdHlwZRgDIAEoCRIWCg5iaW5kaW5nX2RpZ2VzdBgEIAEoCRInCgRtb2RlGAUgASgOMhkucXVpeG9zLkZpZWxkRWRpdGluZy5Nb2RlIi8KBE1vZGUSDwoLVU5TUEVDSUZJRUQQABIMCghSRUdJU1RFUhABEggKBENSRFQQAiKWAQoSQ29uZm9ybWFuY2VXaXRuZXNzEhEKCW9iamVjdF9pZBgBIAEoCRIdChVpbnRlcmZhY2VfcmV2aXNpb25faWQYAiABKAkSFgoOY29uZm9ybWFuY2VfaWQYAyABKAkSHQoVd29ya3NwYWNlX3JldmlzaW9uX2lkGAQgASgJEhcKD3dvcmtzcGFjZV9lcG9jaBgFIAEoCSJCChBQYWNrYWdlRXhwb3J0UmVmEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSEQoJZXhwb3J0X2lkGAIgASgJItgBChJJbmplY3RlZERlcGVuZGVuY3kSDwoHcG9ydF9pZBgBIAEoCRIXCg1zdGF0ZV9zbG90X2lkGAIgASgJSAASJgoEZWRnZRgDIAEoCzIWLnF1aXhvcy5FZGdlRGVwZW5kZW5jeUgAEh8KFWludGVyZmFjZV9yZXZpc2lvbl9pZBgEIAEoCUgAEh0KE2NvbnN0cnVjdG9yX2F0b21faWQYBSABKAlIABISCghxdWVyeV9pZBgHIAEoCUgAEhEKCW9iamVjdF9pZBgGIAEoCUIJCgdiaW5kaW5nIj0KDkVkZ2VEZXBlbmRlbmN5EhQKDGVkZ2VfdHlwZV9pZBgBIAEoCRIVCg1wcm9qZWN0aW9uX2lkGAIgASgJYgZwcm90bzM");
/** /**
* @generated from message quixos.CapabilityRef * @generated from message quixos.CapabilityRef
@@ -41,6 +41,71 @@ export type CapabilityRef = Message<"quixos.CapabilityRef"> & {
export const CapabilityRefSchema: GenMessage<CapabilityRef> = /*@__PURE__*/ export const CapabilityRefSchema: GenMessage<CapabilityRef> = /*@__PURE__*/
messageDesc(file_quixos_refs, 0); messageDesc(file_quixos_refs, 0);
/**
* Resolved native editing semantics. A source snapshot alone grants no writer.
*
* @generated from message quixos.FieldEditing
*/
export type FieldEditing = Message<"quixos.FieldEditing"> & {
/**
* @generated from field: string getter_operation_id = 1;
*/
getterOperationId: string;
/**
* @generated from field: string setter_operation_id = 2;
*/
setterOperationId: string;
/**
* @generated from field: string document_type = 3;
*/
documentType: string;
/**
* @generated from field: string binding_digest = 4;
*/
bindingDigest: string;
/**
* @generated from field: quixos.FieldEditing.Mode mode = 5;
*/
mode: FieldEditing_Mode;
};
/**
* Describes the message quixos.FieldEditing.
* Use `create(FieldEditingSchema)` to create a new message.
*/
export const FieldEditingSchema: GenMessage<FieldEditing> = /*@__PURE__*/
messageDesc(file_quixos_refs, 1);
/**
* @generated from enum quixos.FieldEditing.Mode
*/
export enum FieldEditing_Mode {
/**
* @generated from enum value: UNSPECIFIED = 0;
*/
UNSPECIFIED = 0,
/**
* @generated from enum value: REGISTER = 1;
*/
REGISTER = 1,
/**
* @generated from enum value: CRDT = 2;
*/
CRDT = 2,
}
/**
* Describes the enum quixos.FieldEditing.Mode.
*/
export const FieldEditing_ModeSchema: GenEnum<FieldEditing_Mode> = /*@__PURE__*/
enumDesc(file_quixos_refs, 1, 0);
/** /**
* @generated from message quixos.ConformanceWitness * @generated from message quixos.ConformanceWitness
*/ */
@@ -76,7 +141,7 @@ export type ConformanceWitness = Message<"quixos.ConformanceWitness"> & {
* Use `create(ConformanceWitnessSchema)` to create a new message. * Use `create(ConformanceWitnessSchema)` to create a new message.
*/ */
export const ConformanceWitnessSchema: GenMessage<ConformanceWitness> = /*@__PURE__*/ export const ConformanceWitnessSchema: GenMessage<ConformanceWitness> = /*@__PURE__*/
messageDesc(file_quixos_refs, 1); messageDesc(file_quixos_refs, 2);
/** /**
* @generated from message quixos.PackageExportRef * @generated from message quixos.PackageExportRef
@@ -98,7 +163,7 @@ export type PackageExportRef = Message<"quixos.PackageExportRef"> & {
* Use `create(PackageExportRefSchema)` to create a new message. * Use `create(PackageExportRefSchema)` to create a new message.
*/ */
export const PackageExportRefSchema: GenMessage<PackageExportRef> = /*@__PURE__*/ export const PackageExportRefSchema: GenMessage<PackageExportRef> = /*@__PURE__*/
messageDesc(file_quixos_refs, 2); messageDesc(file_quixos_refs, 3);
/** /**
* @generated from message quixos.InjectedDependency * @generated from message quixos.InjectedDependency
@@ -158,7 +223,7 @@ export type InjectedDependency = Message<"quixos.InjectedDependency"> & {
* Use `create(InjectedDependencySchema)` to create a new message. * Use `create(InjectedDependencySchema)` to create a new message.
*/ */
export const InjectedDependencySchema: GenMessage<InjectedDependency> = /*@__PURE__*/ export const InjectedDependencySchema: GenMessage<InjectedDependency> = /*@__PURE__*/
messageDesc(file_quixos_refs, 3); messageDesc(file_quixos_refs, 4);
/** /**
* @generated from message quixos.EdgeDependency * @generated from message quixos.EdgeDependency
@@ -180,5 +245,5 @@ export type EdgeDependency = Message<"quixos.EdgeDependency"> & {
* Use `create(EdgeDependencySchema)` to create a new message. * Use `create(EdgeDependencySchema)` to create a new message.
*/ */
export const EdgeDependencySchema: GenMessage<EdgeDependency> = /*@__PURE__*/ export const EdgeDependencySchema: GenMessage<EdgeDependency> = /*@__PURE__*/
messageDesc(file_quixos_refs, 4); messageDesc(file_quixos_refs, 5);
+37 -4
View File
@@ -101,7 +101,7 @@ export async function compileQuery(
if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node); if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node);
}, },
Directive(node) { Directive(node) {
if (!["include", "skip"].includes(node.name.value)) if (!["include", "skip", "live"].includes(node.name.value))
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node); fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
}, },
InlineFragment(node) { InlineFragment(node) {
@@ -180,6 +180,15 @@ export async function compileQuery(
Field(node) { Field(node) {
const parent = info.getParentType(); const parent = info.getParentType();
const contract = parent && generated.byName.get(parent.name); const contract = parent && generated.byName.get(parent.name);
if (node.directives?.some((directive) => directive.name.value === "live")) {
const member = contract?.members.find((entry) => entry.displayName === node.name.value);
if (!member || member.kind !== "value" || member.queryRead?.execution !== "native")
fail(
"QUERY_LIVE_UNSUPPORTED",
"@live requires a direct native-queryable value field, not RPC enrichment or a synthetic result",
node,
);
}
if (!contract || node.name.value === "_qx") return; if (!contract || node.name.value === "_qx") return;
mark(contract.revisionId, node.name.value, "select", node); mark(contract.revisionId, node.name.value, "select", node);
const member = contract.members.find((entry) => entry.displayName === node.name.value)!; const member = contract.members.find((entry) => entry.displayName === node.name.value)!;
@@ -286,9 +295,12 @@ export async function compileQuery(
field.type, field.type,
selection.selectionSet, selection.selectionSet,
depth + 1, depth + 1,
conditional || !!selection.directives?.length, conditional || !!selection.directives?.some((entry) => entry.name.value !== "live"),
); );
if ((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]!); fields[key] = valueType.optional(fields[key]!);
if (previous) fields[key] = mergeOutput(previous, fields[key]!); if (previous) fields[key] = mergeOutput(previous, fields[key]!);
} }
@@ -354,7 +366,9 @@ export async function compileQuery(
} }
}; };
const conditions = (directives: readonly DirectiveNode[] = []) => const conditions = (directives: readonly DirectiveNode[] = []) =>
directives.map((directive) => ({ directives
.filter((directive) => directive.name.value !== "live")
.map((directive) => ({
include: directive.name.value === "include", include: directive.name.value === "include",
value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value), value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value),
})); }));
@@ -390,6 +404,13 @@ export async function compileQuery(
{ {
name: node.name.value, name: node.name.value,
key: node.alias?.value ?? node.name.value, key: node.alias?.value ?? node.name.value,
...(node.directives?.some((directive) => directive.name.value === "live")
? {
liveSelectionId: createHash("sha256")
.update(JSON.stringify([node.loc?.source.name, node.loc?.start, contract?.revisionId, member?.id]))
.digest("hex"),
}
: {}),
...(member && contract ? { interfaceRevisionId: contract.revisionId, memberId: member.id } : {}), ...(member && contract ? { interfaceRevisionId: contract.revisionId, memberId: member.id } : {}),
...(member?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}), ...(member?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}),
conditions: [...inherited, ...conditions(node.directives)], conditions: [...inherited, ...conditions(node.directives)],
@@ -405,6 +426,18 @@ export async function compileQuery(
const normalized = print(document), const normalized = print(document),
schema = printSchema(generated.schema); schema = printSchema(generated.schema);
const selections = selection(operations[0]!.selectionSet, generated.schema.getQueryType()!); const selections = selection(operations[0]!.selectionSet, generated.schema.getQueryType()!);
const checkPresentation = (entries: QuerySelection[]) => {
for (const key of new Set(entries.map((entry) => entry.key))) {
const siblings = entries.filter((entry) => entry.key === key);
if (siblings.some((entry) => entry.liveSelectionId) && siblings.some((entry) => !entry.liveSelectionId))
throw new QueryCompileError(
"QUERY_LIVE_CONFLICT",
`${key} mixes live and plain selections; use separate aliases`,
);
checkPresentation(siblings.flatMap((entry) => entry.selection));
}
};
checkPresentation(selections);
const definitionDigest = createHash("sha256") const definitionDigest = createHash("sha256")
.update( .update(
canonicalJson({ canonicalJson({
+58
View File
@@ -0,0 +1,58 @@
import type { InterfaceRevision, ValueType } from "../capability-model/types.js";
import type { CheckedQuery, QuerySelection } from "./types.js";
/** Browser presentation is separate from the portable query value contract. */
export function queryPresentation(query: CheckedQuery, interfaces: readonly InterfaceRevision[]) {
const result: {
path: string[];
selectionId: string;
interfaceRevisionId: string;
getOperationId: string;
setOperationId?: string;
watchOperationId?: string;
inputKind: "value" | "fields";
valueType: ValueType;
conditions: (QuerySelection["conditions"][number] & { defaultValue?: boolean })[];
}[] = [];
const visit = (type: ValueType, selections: QuerySelection[], path: string[]) => {
if (type.kind === "optional") return visit(type.value, selections, path);
if (type.kind === "list") return visit(type.value, selections, [...path, "*"]);
if (type.kind !== "record") return;
for (const [name, field] of Object.entries(type.fields)) {
const matches = selections.filter((entry) => entry.key === name);
if (matches.some((entry) => entry.liveSelectionId) && matches.some((entry) => !entry.liveSelectionId))
throw new Error(`QUERY_LIVE_CONFLICT: ${[...path, name].join(".")} mixes live and plain selections`);
for (const entry of matches) {
if (entry.liveSelectionId) {
const iface = interfaces.find((iface) => iface.revisionId === entry.interfaceRevisionId)!;
const member = iface.members.find((member) => member.id === entry.memberId)!;
if (member.kind !== "value") throw new Error("QUERY_LIVE_UNSUPPORTED");
const get = member.operations.find((op) => op.displayName === "get")!;
const set = member.operations.find((op) => op.displayName === "set");
result.push({
path: [...path, name],
selectionId: entry.liveSelectionId,
interfaceRevisionId: iface.revisionId,
getOperationId: get.id,
setOperationId: set?.id,
watchOperationId: member.operations.find((op) => op.displayName === "watch-start")?.id,
inputKind: set?.inputType.kind === "record" ? "fields" : "value",
valueType: get.outputType,
conditions: entry.conditions.map((condition) => {
const fallback =
condition.value.kind === "variable" ? query.variableDefaults[condition.value.name] : undefined;
return {
...condition,
...(fallback?.kind === "literal" && typeof fallback.value === "boolean"
? { defaultValue: fallback.value }
: {}),
};
}),
});
} else visit(field, entry.selection, [...path, name]);
}
}
};
visit(query.output, query.selection, []);
return result;
}
+4
View File
@@ -10,6 +10,9 @@ import {
GraphQLList, GraphQLList,
GraphQLNonNull, GraphQLNonNull,
GraphQLSchema, GraphQLSchema,
GraphQLDirective,
DirectiveLocation,
specifiedDirectives,
type GraphQLOutputType, type GraphQLOutputType,
type GraphQLInputType, type GraphQLInputType,
type GraphQLFieldConfigMap, type GraphQLFieldConfigMap,
@@ -241,6 +244,7 @@ export function querySchema(
return result; return result;
}; };
const schema = new GraphQLSchema({ const schema = new GraphQLSchema({
directives: [...specifiedDirectives, new GraphQLDirective({ name: "live", locations: [DirectiveLocation.FIELD] })],
query: new GraphQLObjectType({ query: new GraphQLObjectType({
name: "QxQuery", name: "QxQuery",
fields: { root: { type: new GraphQLNonNull(object(declaration.root)) } }, fields: { root: { type: new GraphQLNonNull(object(declaration.root)) } },
+1
View File
@@ -98,6 +98,7 @@ export type QueryArgument =
| { kind: "list"; values: QueryArgument[] } | { kind: "list"; values: QueryArgument[] }
| { kind: "object"; fields: Record<string, QueryArgument> }; | { kind: "object"; fields: Record<string, QueryArgument> };
export interface QuerySelection { export interface QuerySelection {
liveSelectionId?: string;
name: string; name: string;
key: string; key: string;
interfaceRevisionId?: InterfaceRevisionId; interfaceRevisionId?: InterfaceRevisionId;
+13 -4
View File
@@ -8,7 +8,7 @@ export const queryFixtureSource = {
repository: "https://query-fixture.example.test/source.git", repository: "https://query-fixture.example.test/source.git",
commit: "1".repeat(40), commit: "1".repeat(40),
}; };
export async function queryWorkspaceFixture() { export async function queryWorkspaceFixture(options: { live?: boolean } = {}) {
const interfaces = new Map<string, InterfaceRevision>(); const interfaces = new Map<string, InterfaceRevision>();
const sources: Record<string, string> = {}; const sources: Record<string, string> = {};
function iface(name: string, members: string, parameters = "") { function iface(name: string, members: string, parameters = "") {
@@ -29,7 +29,7 @@ export async function queryWorkspaceFixture() {
` `
queryable value title id "title" : string {get id "title:get"; set id "title:set";} queryable value title id "title" : string {get id "title:get"; set id "title:set";}
queryable value done id "done" : bool {get id "done:get";} queryable value done id "done" : bool {get id "done:get";}
queryable value rank id "rank" : int64 {get id "rank:get";} queryable value rank id "rank" : int64 {get id "rank:get"; set id "rank:set";}
queryable relation assignee id "assignee" : optional-one interface PersonFacts {resolve id "assignee:resolve";} queryable relation assignee id "assignee" : optional-one interface PersonFacts {resolve id "assignee:resolve";}
queryable rpc value score id "score" : int32 {get id "score:get";} queryable rpc value score id "score" : int32 {get id "score:get";}
`, `,
@@ -81,7 +81,16 @@ export async function queryWorkspaceFixture() {
"score-totals.graphql": `query ScoreTotals {root {_qx {relations {items {aggregate {count sum {score}}}}}}}`, "score-totals.graphql": `query ScoreTotals {root {_qx {relations {items {aggregate {count sum {score}}}}}}}`,
}; };
pkg.checkedQueries = await Promise.all( pkg.checkedQueries = await Promise.all(
pkg.queries!.map((query) => compileQuery(query, allInterfaces, async (name) => documents[name]!)), pkg.queries!.map((query) =>
compileQuery(query, allInterfaces, async (name) =>
options.live
? documents[name]!.replace(/\btitle\b/g, "title @live").replace(
"title @live rank done",
"title @live rank @live done @live",
)
: documents[name]!,
),
),
); );
const implementation = (atom: string) => `conform ${atom} as TaskFacts id "${atom}-facts" { const implementation = (atom: string) => `conform ${atom} as TaskFacts id "${atom}-facts" {
private state Title${atom} id "${atom}:title" on ${atom} : string policy crdt(string) default "Untitled"; private state Title${atom} id "${atom}:title" on ${atom} : string policy crdt(string) default "Untitled";
@@ -92,7 +101,7 @@ export async function queryWorkspaceFixture() {
interface PersonFacts projection tasks id "${atom}:assignee:inverse" many; interface PersonFacts projection tasks id "${atom}:assignee:inverse" many;
} }
bind title.get to state Title${atom}.read; bind title.get to state Title${atom}.read;
bind title.set to package Queries.titleSet with {title to state Title${atom};}; ${options.live ? `bind title.set to state Title${atom}.write;` : `bind title.set to package Queries.titleSet with {title to state Title${atom};};`}
bind done to state Done${atom}; bind rank to state Rank${atom}; bind done to state Done${atom}; bind rank to state Rank${atom};
bind assignee.resolve to edge Assignee${atom}.assignee.resolve; bind assignee.resolve to edge Assignee${atom}.assignee.resolve;
bind score.get to package Queries.score query-reason "Explicit bounded score computation"; bind score.get to package Queries.score query-reason "Explicit bounded score computation";
+44
View File
@@ -4,6 +4,8 @@ import { compileCapabilityResourceSource } from "../src/capability-language/inde
import { compileQuery } from "../src/query/compile.js"; import { compileQuery } from "../src/query/compile.js";
import { QueryCompileError } from "../src/query/types.js"; import { QueryCompileError } from "../src/query/types.js";
import { checkQueryTemplate } from "../src/query/templates.js"; import { checkQueryTemplate } from "../src/query/templates.js";
import { queryPresentation } from "../src/query/presentation.js";
import { querySelectionToWire } from "../src/query/proto.js";
import type { InterfaceRevision } from "../src/capability-model/types.js"; import type { InterfaceRevision } from "../src/capability-model/types.js";
const source = { repository: "https://example.test/queries.git", commit: "a".repeat(40) }; const source = { repository: "https://example.test/queries.git", commit: "a".repeat(40) };
@@ -125,6 +127,48 @@ const document = `query Upcoming($first: Int!, $before: Int64!) {
const compile = (query = document, row = "fragment Row on TaskFacts { title due }", clauses = "") => const compile = (query = document, row = "fragment Row on TaskFacts { title due }", clauses = "") =>
compileQuery(fixture(clauses), [collection, facts], async (name) => (name.endsWith("row.graphql") ? row : query)); compileQuery(fixture(clauses), [collection, facts], async (name) => (name.endsWith("row.graphql") ? row : query));
test("live selections preserve aliases, nullable values, fragment conditions and wire identity", async () => {
const checked = await compile(
`query Upcoming($show: Boolean!) {root {items(first: 3) {entries {node {plain: title ...Row @include(if: $show)}}}}}`,
`fragment Row on TaskFacts {label: title @live due @live}`,
);
const fields = queryPresentation(checked, [collection, facts]);
assert.deepEqual(
fields.map((f) => f.path),
[
["root", "items", "entries", "*", "node", "label"],
["root", "items", "entries", "*", "node", "due"],
],
);
assert.equal(fields[0]!.getOperationId, "title:get");
assert.equal(fields[0]!.setOperationId, undefined);
assert.equal(fields[1]!.valueType.kind, "optional");
assert.equal(fields[0]!.conditions.length, 1);
assert.ok(JSON.stringify(checked.selection.map(querySelectionToWire)).includes(fields[0]!.selectionId));
const defaults = await compile(
`query Upcoming($show: Boolean! = true) {root {items(first: 3) {entries {node {...Row @include(if: $show)}}}}}`,
"fragment Row on TaskFacts {title @live}",
);
assert.equal(queryPresentation(defaults, [collection, facts])[0]!.conditions[0]!.defaultValue, true);
});
test("live selections reject RPC getters, synthetic selections and conflicting presentations", async () => {
for (const [selected, code] of [
["score @live", "QUERY_LIVE_UNSUPPORTED"],
["_qx @live {ref}", "QUERY_LIVE_UNSUPPORTED"],
["title @live title", "QUERY_LIVE_CONFLICT"],
["title @live(unchecked: true)", "QUERY_VALIDATION"],
])
await assert.rejects(
compile(
`query Upcoming {root {items(first: 3) {entries {node {...Row}}}}}`,
`fragment Row on TaskFacts {${selected}}`,
'allow TaskFacts.score select "bounded";',
),
(error: unknown) => error instanceof QueryCompileError && error.code === code,
);
});
test("query dependency ports resolve exact exports without declaration ordering constraints", () => { test("query dependency ports resolve exact exports without declaration ordering constraints", () => {
const result = compileCapabilityResourceSource( const result = compileCapabilityResourceSource(
`import interface Tasks; `import interface Tasks;
+26 -10
View File
@@ -7,13 +7,14 @@ import { spawnSync } from "node:child_process";
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js"; import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js";
import { generateReactBindings } from "../src/bindings/react.js"; import { generateReactBindings } from "../src/bindings/react.js";
import { reactPlatformTypes } from "../src/bindings/react-platform.js"; import { reactPlatformTypes } from "../src/bindings/react-platform.js";
import { compileQuery } from "../src/query/compile.js";
const source = { repository: "https://example.test/fields.git", commit: "a".repeat(40) }; const source = { repository: "https://example.test/fields.git", commit: "a".repeat(40) };
test("React bindings preserve read-only, writable and nested reference contracts", async (t) => { test("React bindings preserve read-only, writable and nested reference contracts", async (t) => {
const iface = compileCapabilityResourceSource( const iface = compileCapabilityResourceSource(
`interface Fields id "fields" revision "fields@1" { `interface Fields id "fields" revision "fields@1" {
value title id "title" : string { get id "title:get"; set id "title:set"; watch start id "watch" stop id "stop"; } queryable value title id "title" : string { get id "title:get"; set id "title:set"; watch start id "watch" stop id "stop"; }
value summary id "summary" : string { get id "summary:get"; } queryable value summary id "summary" : string { get id "summary:get"; }
}`, }`,
{ source }, { source },
); );
@@ -22,11 +23,19 @@ test("React bindings preserve read-only, writable and nested reference contracts
const pkg = compileCapabilityResourceSource( const pkg = compileCapabilityResourceSource(
`import interface Fields; package P id "p" revision "p@1" { `import interface Fields; package P id "p" revision "p@1" {
function props id "props" : unit -> record {fields: interface-ref<Fields>; caption: string;}; function props id "props" : unit -> record {fields: interface-ref<Fields>; caption: string;};
query Editor id "editor" root Fields document "editor.graphql" operation "Editor" {max rows 1; watch;}
}`, }`,
{ source, environment: { interfaces: new Map([["Fields", iface.resource.revision]]) } }, { source, environment: { interfaces: new Map([["Fields", iface.resource.revision]]) } },
); );
assert.ok(pkg.ok && pkg.resource.kind === "package"); assert.ok(pkg.ok && pkg.resource.kind === "package");
if (!pkg.ok || pkg.resource.kind !== "package") throw new Error("package failed"); if (!pkg.ok || pkg.resource.kind !== "package") throw new Error("package failed");
pkg.resource.revision.checkedQueries = [
await compileQuery(
pkg.resource.revision.queries![0]!,
[iface.resource.revision],
async () => "query Editor {root {title @live summary @live plain: title}}",
),
];
const schema = { const schema = {
format: "quixos-bindings", format: "quixos-bindings",
version: 1, version: 1,
@@ -68,7 +77,14 @@ test("React bindings preserve read-only, writable and nested reference contracts
path.join(root, "consumer.ts"), path.join(root, "consumer.ts"),
`import {useLiveField, tryConform, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime"; `import {useLiveField, tryConform, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime";
import {reactInterfaces} from "./react-props.gen.js"; import {reactInterfaces} from "./react-props.gen.js";
import type {ReactResults} from "./react-props.gen.js"; import type {ReactResults, QueryResults} from "./react-props.gen.js";
declare const query: QueryResults["Editor"];
useLiveField(query.root.title).set("new");
// @ts-expect-error live query fields retain exact setter types
useLiveField(query.root.title).set(123);
// @ts-expect-error readonly query fields do not acquire a setter
useLiveField(query.root.summary).set("no");
const plain: string = query.root.plain;
async function lookup() { async function lookup() {
const view = await tryConform("object", reactInterfaces.Fields); const view = await tryConform("object", reactInterfaces.Fields);
if (!view) return; if (!view) return;
@@ -79,14 +95,14 @@ async function lookup() {
await view.call["summary.set"]("no setter"); await view.call["summary.set"]("no setter");
} }
declare const props: ReactResults["props"]; declare const props: ReactResults["props"];
const [title, setTitle] = useLiveField(props.fields.fields.title); const title = useLiveField(props.fields.fields.title);
setTitle("new"); title.set("new");
// @ts-expect-error wrong setter value // @ts-expect-error wrong setter value
setTitle(123); title.set(123);
// @ts-expect-error read-only hook has no setter // @ts-expect-error read-only hook has no setter
const [summary, setSummary] = useLiveField(props.fields.fields.summary); useLiveField(props.fields.fields.summary).set("no");
const [manual, write] = useLiveField(props.fields.fields.summary, {write: async (value: string) => {}}); const manual = useLiveField(props.fields.fields.summary, {id: "manual", write: async (value: string) => {}});
write("new"); manual.set("new");
const readonly: ReadableField<string> = props.fields.fields.title; const readonly: ReadableField<string> = props.fields.fields.title;
// @ts-expect-error read-only does not satisfy writable // @ts-expect-error read-only does not satisfy writable
const writable: WritableField<string> = props.fields.fields.summary; const writable: WritableField<string> = props.fields.fields.summary;
@@ -94,7 +110,7 @@ declare const narrow: WritableField<"only">;
// @ts-expect-error writable references are invariant // @ts-expect-error writable references are invariant
const widened: WritableField<string> = narrow; const widened: WritableField<string> = narrow;
// @ts-expect-error callbacks must accept the field's type // @ts-expect-error callbacks must accept the field's type
useLiveField(props.fields.fields.summary, {write: async (value: number) => {}}); useLiveField(props.fields.fields.summary, {id: "manual", write: async (value: number) => {}});
`, `,
); );
const result = spawnSync( const result = spawnSync(