diff --git a/src/bindings/react-platform.ts b/src/bindings/react-platform.ts index 790ccb4..90537c1 100644 --- a/src/bindings/react-platform.ts +++ b/src/bindings/react-platform.ts @@ -10,11 +10,16 @@ declare module "@quixos/web-studio-react-runtime" { export type ObjectRef = string & { readonly $quixosAtom: AtomId; }; - export type FieldCapability = {objectId: string; interfaceRevisionId: string; getOperationId: string; watchOperationId?: string; setOperationId?: string; inputKind?: "fields" | "value"}; - export type ReadableField = {readonly value?: T; readonly capability: FieldCapability}; + export type ReadableField = {readonly value?: T; readonly $field: "query" | "independent"}; + export type QueryField = ReadableField & {readonly $field: "query"}; + export type IndependentField = ReadableField & {readonly $field: "independent"}; /** set(T) uses the resolved capability: native CRDT fields send incremental edits; * custom/manual setters receive semantic values. Storage provenance grants no authority. */ - export type WritableField = ReadableField & {readonly $writeType?: (value: T) => T; readonly writable: true; readonly capability: FieldCapability & {setOperationId: string}}; + export type WritableField = ReadableField & {readonly $writeType?: (value: T) => T; readonly writable: true}; + export type WritableQueryField = WritableField & QueryField; + export type WritableIndependentField = WritableField & IndependentField; + export function detachField(field: WritableQueryField): WritableIndependentField; + export function detachField(field: QueryField): IndependentField; export type LiveFieldProp = ReadableField; export type InterfaceReference = {readonly $quixosRef: string; readonly interfaceRevisionId: I; readonly fields: Fields}; export type ReactInterfaceContract = {readonly interfaceRevisionId: string; readonly $viewType?: (view: View) => View}; @@ -26,7 +31,7 @@ declare module "@quixos/web-studio-react-runtime" { export type QueryReference = {readonly $quixosRef: string; readonly queryContract: Contract}; export type QueryLiveProjection = {path: readonly string[]; selectionId: string; interfaceRevisionId: string; getOperationId: string; setOperationId?: string; watchOperationId?: string; inputKind: "fields" | "value"; valueType: QueryValueType; conditions: {include: boolean; defaultValue?: boolean; value: {kind: "variable"; name: string} | {kind: "literal"; value: unknown}}[]}; export interface QueryDescriptor {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 extends {readonly $quixosRef: string} | {readonly capability: {readonly getOperationId: string}} ? T : T extends readonly (infer Item)[] ? QueryPartial[] : T extends object ? {[Key in keyof T]?: QueryPartial} : T; + export type QueryPartial = T extends {readonly $quixosRef: string} | ReadableField ? T : T extends readonly (infer Item)[] ? QueryPartial[] : T extends object ? {[Key in keyof T]?: QueryPartial} : T; export type QueryFieldState = {path: readonly (string | number)[]; status: "pending" | "error"; error?: string}; export type QueryState = {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; error?: undefined} | {status: "error"; data?: T | QueryPartial; error: Error}); export function useQuery(descriptor: QueryDescriptor, options: {root: string | {readonly $quixosRef: string}; variables: Variables}): QueryState & {refresh(): void}; @@ -58,7 +63,8 @@ declare module "@quixos/web-studio-react-runtime" { options?: {clientMutationId?: string; signal?: AbortSignal}, ) => Promise; export const h: typeof React.createElement; - export type LiveFieldState = {refreshing: boolean; stale: boolean; pendingWrites: number; writeError?: Error; refresh(): Promise; discard(): void} & ({status: "loading"; value?: undefined; error?: undefined} | {status: "ready"; value: T; error?: Error} | {status: "error"; value?: undefined; error: Error}); + export type FieldReadState = "connecting" | "live" | "snapshot" | "inactive" | "not-selected" | "error" | "obsolete"; + export type LiveFieldState = {readState: FieldReadState; refreshing: boolean; stale: boolean; pendingWrites: number; writeError?: Error; refresh(): Promise; discard(): void} & ({status: "loading"; value?: undefined; error?: undefined} | {status: "ready"; value: T; error?: Error} | {status: "error"; value?: undefined; error: Error}); export type WritableLiveFieldState = LiveFieldState & {set(value: T): Promise; retry(): Promise}; export function useLiveField(field: ReadableField, options: {id: string; write: (value: T) => Promise}): WritableLiveFieldState; export function useLiveField(field: WritableField): WritableLiveFieldState; diff --git a/src/bindings/react.ts b/src/bindings/react.ts index ab91177..0dc0084 100644 --- a/src/bindings/react.ts +++ b/src/bindings/react.ts @@ -136,7 +136,7 @@ export function generateReactBindings( ): 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)" : ""}`; + return `${field.conditions.length ? "(" : ""}${field.setOperationId ? "WritableQueryField" : "QueryField"}<${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") @@ -165,7 +165,7 @@ export function generateReactBindings( ), )};\n`; return ( - `// Generated from checked QX contracts. Do not edit.\nimport {defineReactInterfaceContract} from "@quixos/web-studio-react-runtime";\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField, QueryDescriptor, QueryReference} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` + + `// Generated from checked QX contracts. Do not edit.\nimport {defineReactInterfaceContract} from "@quixos/web-studio-react-runtime";\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField, QueryField, WritableQueryField, QueryDescriptor, QueryReference} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` + queryCode + `const contractsData = ${JSON.stringify(schema.interfaces)};\n` + `export const reactContracts = {${contracts.map(({ iface, view }) => `${q(iface.revisionId)}: defineReactInterfaceContract<${view}>(${q(iface.revisionId)}, contractsData)`).join(",\n")}};\n` + diff --git a/test/react-fields.test.ts b/test/react-fields.test.ts index 835c3f8..ba39d02 100644 --- a/test/react-fields.test.ts +++ b/test/react-fields.test.ts @@ -75,11 +75,16 @@ test("React bindings preserve read-only, writable and nested reference contracts ); await fs.writeFile( path.join(root, "consumer.ts"), - `import {useLiveField, tryConform, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime"; + `import {useLiveField, tryConform, detachField, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime"; import {reactInterfaces} from "./react-props.gen.js"; import type {ReactResults, QueryResults} from "./react-props.gen.js"; declare const query: QueryResults["Editor"]; useLiveField(query.root.title).set("new"); +useLiveField(detachField(query.root.title)).set("new"); +// @ts-expect-error detachment preserves readonly access +useLiveField(detachField(query.root.summary)).set("no"); +// @ts-expect-error query handles do not expose transport metadata +query.root.title.capability; // @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