Add checked React field bindings and Web Studio factories
Replace opaque props with checked generic presentation contracts and lazy typed interface references. Generate readonly/writable field APIs and component checks. Preserve CRDT editing through explicit resolved getter/setter contracts, binding- fenced delta RPCs, native watches and replica-aware field adapters. Custom setters retain semantic writes; storage snapshots never grant write authority. Cover concurrent edits, lost acknowledgements, readonly contracts and authorization. Add receiver-free static factory dispatch, state-field binding shorthand, and conformance-based creation. Migrate TODO, editable scaffolds and authoring guides. Verify language/codegen, SDK, RPC, browser lifecycle, local scaffolds, production browser bundling and CRDT persistence with temporary PostgreSQL.
This commit is contained in:
+8
-2
@@ -3,16 +3,22 @@ import { readFile, writeFile } from "node:fs/promises";
|
||||
import { generateTypeScriptBindings } from "./index.js";
|
||||
import path from "node:path";
|
||||
import { reactPlatformTypes } from "./react-platform.js";
|
||||
import { generateReactBindings } from "./react.js";
|
||||
|
||||
const main = async () => {
|
||||
const [schema, revision, output, options, ...rest] = process.argv.slice(2);
|
||||
if (!schema || !revision || !output || rest.length)
|
||||
throw new Error("usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]");
|
||||
const config = options ? JSON.parse(await readFile(options, "utf8")) : {};
|
||||
const generated = generateTypeScriptBindings(JSON.parse(await readFile(schema, "utf8")), revision, config);
|
||||
const contracts = JSON.parse(await readFile(schema, "utf8"));
|
||||
const generated = generateTypeScriptBindings(contracts, revision, config);
|
||||
await writeFile(output, generated);
|
||||
if (config.messages?.["org.quixos.web-studio.ReactProps"]) {
|
||||
if (config.react) {
|
||||
await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes);
|
||||
await writeFile(
|
||||
path.join(path.dirname(output), "react-props.gen.ts"),
|
||||
generateReactBindings(contracts, revision, config.react.propsExports, config.react.components),
|
||||
);
|
||||
}
|
||||
};
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
@@ -124,7 +124,7 @@ export const genericImplementationType = (
|
||||
};
|
||||
const operations = (contract.template?.members ?? contract.members).flatMap((member) =>
|
||||
member.operations
|
||||
.filter((op) => op.mode === "call")
|
||||
.filter((op) => op.mode === "call" && op.scope !== "class")
|
||||
.map((op) => ({ ...op, name: `${member.displayName}.${op.displayName}` })),
|
||||
);
|
||||
return object([
|
||||
@@ -146,11 +146,21 @@ export const genericImplementationType = (
|
||||
.join(",");
|
||||
const receiver = definition.receiverRequirement;
|
||||
const context = object([
|
||||
["objectId", `QxObjectRef<${receiver.kind === "target" ? target(receiver.target, rootScope()) : "string"}>`],
|
||||
...(definition.kind === "function"
|
||||
? []
|
||||
: [
|
||||
[
|
||||
"objectId",
|
||||
`QxObjectRef<${receiver.kind === "target" ? target(receiver.target, rootScope()) : "string"}>`,
|
||||
] as [string, string],
|
||||
]),
|
||||
["input", value(definition.inputType, rootScope())],
|
||||
["ports", object(definition.dependencyPorts.map((entry) => [entry.displayName, port(entry)]))],
|
||||
]);
|
||||
const contextWithLifecycle = `${context} & QxContextLifecycle<${context} & {signal?: AbortSignal}>`;
|
||||
const contextWithLifecycle =
|
||||
definition.kind === "function"
|
||||
? `${context} & {signal?: AbortSignal}`
|
||||
: `${context} & QxContextLifecycle<${context} & {signal?: AbortSignal}>`;
|
||||
const result = value(definition.eventType ?? definition.outputType, rootScope());
|
||||
const handler = `<${declarations}>(context:${contextWithLifecycle})=>${result}|Promise<${result}>`;
|
||||
const derived = `{kind:"derived";get:${handler}}`;
|
||||
|
||||
@@ -180,7 +180,7 @@ export const generateTypeScriptBindings = (
|
||||
// Streaming ports need a future streaming ABI; ordinary calls are fully typed today.
|
||||
const operations = contract.members.flatMap((member) =>
|
||||
member.operations
|
||||
.filter((operation) => operation.mode === "call")
|
||||
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
|
||||
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })),
|
||||
);
|
||||
return {
|
||||
@@ -245,13 +245,15 @@ export const generateTypeScriptBindings = (
|
||||
? `QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>`
|
||||
: "QxObjectRef<string>";
|
||||
const contextShape = object([
|
||||
["objectId", receiver],
|
||||
...(entry.kind === "function" ? [] : [["objectId", receiver] as [string, string]]),
|
||||
["input", type(entry.inputType)],
|
||||
["ports", object(ports.map((port) => [port.name, port.type]))],
|
||||
]);
|
||||
contexts.push([
|
||||
entry.displayName,
|
||||
`${contextShape} & QxContextLifecycle<${contextShape} & {signal?: AbortSignal}>`,
|
||||
entry.kind === "function"
|
||||
? `${contextShape} & {signal?: AbortSignal}`
|
||||
: `${contextShape} & QxContextLifecycle<${contextShape} & {signal?: AbortSignal}>`,
|
||||
]);
|
||||
const event = entry.kind === "operation" ? entry.eventType : undefined;
|
||||
const contextType = `Contexts[${q(entry.displayName)}]`;
|
||||
@@ -266,6 +268,7 @@ export const generateTypeScriptBindings = (
|
||||
: `QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`,
|
||||
]);
|
||||
specs[entry.displayName] = {
|
||||
...(entry.kind === "function" ? { receiver: "none" } : {}),
|
||||
inputType: entry.inputType,
|
||||
outputType: entry.outputType,
|
||||
...(event ? { eventType: event } : {}),
|
||||
|
||||
@@ -8,21 +8,13 @@ declare module "@quixos/web-studio-react-runtime" {
|
||||
export type ObjectRef<AtomId extends string> = string & {
|
||||
readonly $quixosAtom: AtomId;
|
||||
};
|
||||
export type LiveFieldProp<T> = {
|
||||
value: T;
|
||||
source: {
|
||||
objectId: string;
|
||||
slotId: string;
|
||||
valueType?: string;
|
||||
storagePolicy?: string;
|
||||
revision?: string | number | bigint;
|
||||
crdtSnapshot?: {
|
||||
type: string;
|
||||
encoding: string;
|
||||
payload: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
export type FieldCapability = {objectId: string; interfaceRevisionId: string; getOperationId: string; watchOperationId?: string; setOperationId?: string; inputKind?: "fields" | "value"};
|
||||
export type ReadableField<T> = {readonly value?: T; readonly capability: FieldCapability};
|
||||
/** 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<T> = ReadableField<T> & {readonly $writeType?: (value: T) => T; readonly writable: true; readonly capability: FieldCapability & {setOperationId: string}};
|
||||
export type LiveFieldProp<T> = ReadableField<T>;
|
||||
export type InterfaceReference<I extends string, Fields> = {readonly $quixosRef: string; readonly interfaceRevisionId: I; readonly fields: Fields};
|
||||
export type ReactComponentHostProps<Action> = {
|
||||
onAction?: (action: Action) => void;
|
||||
fallback?: React.ReactNode;
|
||||
@@ -49,15 +41,8 @@ declare module "@quixos/web-studio-react-runtime" {
|
||||
options?: {clientMutationId?: string; signal?: AbortSignal},
|
||||
) => Promise<Result>;
|
||||
export const h: typeof React.createElement;
|
||||
export const useLiveField: <T>(
|
||||
field: LiveFieldProp<T>,
|
||||
options?: {
|
||||
reconcileRegister?: (state: {
|
||||
confirmed: T;
|
||||
optimistic: T;
|
||||
pending: boolean;
|
||||
}) => T;
|
||||
},
|
||||
) => readonly [T, (value: T) => Promise<void>];
|
||||
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];
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { BindingSchema } from "./index.js";
|
||||
import type { ValueType } from "../capability-model/types.js";
|
||||
|
||||
/** Browser projection of the SAME checked RPC types, not a second props schema. */
|
||||
export function generateReactBindings(
|
||||
schema: BindingSchema,
|
||||
packageRevisionId: string,
|
||||
propsExports: readonly string[],
|
||||
components: readonly { module: string; propsExport: string }[] = [],
|
||||
) {
|
||||
const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId);
|
||||
if (!pkg) throw new Error(`Unknown package ${packageRevisionId}`);
|
||||
const q = JSON.stringify;
|
||||
const references = new Map<string, string>();
|
||||
const declarations: string[] = [];
|
||||
const type = (value: ValueType): string => {
|
||||
switch (value.kind) {
|
||||
case "builtin":
|
||||
if (value.name !== "unit") throw new Error(`Unsupported React props builtin ${value.name}`);
|
||||
return "null";
|
||||
case "scalar":
|
||||
return {
|
||||
string: "string",
|
||||
bool: "boolean",
|
||||
bytes: "Uint8Array",
|
||||
int64: "bigint",
|
||||
uint64: "bigint",
|
||||
int32: "number",
|
||||
uint32: "number",
|
||||
double: "number",
|
||||
}[value.name];
|
||||
case "optional":
|
||||
return `(${type(value.value)} | null)`;
|
||||
case "list":
|
||||
return `Array<${type(value.value)}>`;
|
||||
case "record":
|
||||
return `{${Object.entries(value.fields)
|
||||
.map(([name, field]) => `${q(name)}: ${type(field)}`)
|
||||
.join(";")}}`;
|
||||
case "message":
|
||||
throw new Error(`React props require checked values, not opaque message ${value.descriptorId}`);
|
||||
case "object-ref": {
|
||||
if (value.expectation.kind === "atom") return `ObjectRef<${q(value.expectation.atomId)}>`;
|
||||
const id = value.expectation.interfaceRevisionId;
|
||||
const existing = references.get(id);
|
||||
if (existing) return existing;
|
||||
const name = `Interface${references.size}`;
|
||||
references.set(id, name);
|
||||
const iface = schema.interfaces.find((entry) => entry.revisionId === id);
|
||||
if (!iface) throw new Error(`Missing React reference contract ${id}`);
|
||||
const fields = iface.members.flatMap((member) => {
|
||||
if (member.kind === "operation") return [];
|
||||
const get = member.operations.find((op) => op.displayName === (member.kind === "value" ? "get" : "resolve"));
|
||||
if (!get) return [];
|
||||
const writable = member.kind === "value" && member.operations.some((op) => op.displayName === "set");
|
||||
return [`${q(member.displayName)}: ${writable ? "WritableField" : "ReadableField"}<${type(get.outputType)}>`];
|
||||
});
|
||||
declarations.push(`export type ${name} = InterfaceReference<${q(id)}, {${fields.join(";")}}> ;`);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Only exports explicitly selected as props are projected. Non-props exports
|
||||
// may legitimately use opaque messages or types unrelated to the browser.
|
||||
const selected = propsExports.map((name) => {
|
||||
const entry = pkg.exports.find((candidate) => candidate.displayName === name);
|
||||
if (!entry) throw new Error(`Unknown React props export ${name}`);
|
||||
return entry;
|
||||
});
|
||||
const results = selected.map(
|
||||
(entry) =>
|
||||
`${q(entry.displayName)}: ${type(entry.kind === "operation" ? (entry.eventType ?? entry.outputType) : entry.outputType)}`,
|
||||
);
|
||||
const checks = components.map((component, i) => {
|
||||
if (!propsExports.includes(component.propsExport))
|
||||
throw new Error(`Component refers to unselected props export ${component.propsExport}`);
|
||||
if (!component.module.startsWith("."))
|
||||
throw new Error("React component check must name a local module relative to generated bindings");
|
||||
return `type Component${i} = CheckedComponent<${q(component.propsExport)}, typeof import(${q(component.module)})["default"]>;`;
|
||||
});
|
||||
return (
|
||||
`// Generated from checked QX contracts. Do not edit.\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` +
|
||||
(checks.length
|
||||
? `type CheckedComponent<K extends keyof ReactResults, C extends (props: {camino: ReactResults[K]; render: any; dispatch: (action: any) => void}) => unknown> = C;\n${checks.join("\n")}\n`
|
||||
: "")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user