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.
This commit is contained in:
Timothy J. Aveni
2026-09-18 01:10:48 -07:00
parent a05f58f7fe
commit 2dffdbcd9f
17 changed files with 527 additions and 148 deletions
+23
View File
@@ -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
+2
View File
@@ -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.
+4 -8
View File
@@ -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;
}
+14
View File
@@ -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;
+12 -5
View File
@@ -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
View File
@@ -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
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)
/* 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
View File
@@ -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({
+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,
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)) } },
+1
View File
@@ -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;
+13 -4
View File
@@ -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";
+44
View File
@@ -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
View File
@@ -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(