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 02:19:49 -07:00
parent a05f58f7fe
commit 2dffdbcd9f
17 changed files with 527 additions and 148 deletions
+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;