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:
Timothy J. Aveni
2026-09-16 11:25:20 -07:00
parent 52803dda05
commit 59735c5e38
22 changed files with 3034 additions and 2300 deletions
+7 -1
View File
@@ -95,7 +95,7 @@ interfaceMember
; ;
operationMember operationMember
: OPERATION identifier ID stringLiteral COLON valueType ARROW valueType : STATIC? OPERATION identifier ID stringLiteral COLON valueType ARROW valueType
LBRACE CALL ID stringLiteral SEMI RBRACE LBRACE CALL ID stringLiteral SEMI RBRACE
; ;
@@ -239,9 +239,14 @@ conformanceDecl
conformanceItem conformanceItem
: PRIVATE attachmentDecl : PRIVATE attachmentDecl
| operationBindingDecl | operationBindingDecl
| stateFieldBindingDecl
| relationshipMaterializationDecl | relationshipMaterializationDecl
; ;
stateFieldBindingDecl
: BIND identifier TO STATE identifier SEMI
;
relationshipMaterializationDecl relationshipMaterializationDecl
: MATERIALIZE identifier IF ABSENT USING CONSTRUCTOR identifier VIA EDGE identifier DOT identifier SEMI : MATERIALIZE identifier IF ABSENT USING CONSTRUCTOR identifier VIA EDGE identifier DOT identifier SEMI
; ;
@@ -395,6 +400,7 @@ INPUT: 'input';
CONFORM: 'conform'; CONFORM: 'conform';
AS: 'as'; AS: 'as';
BIND: 'bind'; BIND: 'bind';
STATIC: 'static';
TO: 'to'; TO: 'to';
PRIVATE: 'private'; PRIVATE: 'private';
SHARED: 'shared'; SHARED: 'shared';
+44 -3
View File
@@ -9,6 +9,8 @@ import "quixos/runtime.proto";
service OrchestratorRuntime { service OrchestratorRuntime {
rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse); rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse);
rpc EditCapabilityField(EditCapabilityFieldRequest) returns (InvokeCapabilityResponse);
rpc InvokeClassCapability(InvokeClassCapabilityRequest) returns (InvokeCapabilityResponse);
rpc WatchCapability(WatchCapabilityRequest) returns (stream WatchCapabilityEvent); rpc WatchCapability(WatchCapabilityRequest) returns (stream WatchCapabilityEvent);
rpc ConstructObject(ConstructObjectRequest) returns (ConstructObjectResponse); rpc ConstructObject(ConstructObjectRequest) returns (ConstructObjectResponse);
rpc ResolveOrConstructRelatedObject(ResolveOrConstructRelatedObjectRequest) rpc ResolveOrConstructRelatedObject(ResolveOrConstructRelatedObjectRequest)
@@ -46,6 +48,11 @@ message InvokeCapabilityRequest {
// layers so a live-value controller can recognize its own confirmation. // layers so a live-value controller can recognize its own confirmation.
string client_mutation_id = 4; string client_mutation_id = 4;
} }
message InvokeClassCapabilityRequest {
string conformance_id = 1;
string operation_id = 2;
map<string, camino.Value> input = 3;
}
message InvokeCapabilityResponse { message InvokeCapabilityResponse {
string invocation_id = 1; string invocation_id = 1;
Activation activation = 2; Activation activation = 2;
@@ -53,6 +60,24 @@ message InvokeCapabilityResponse {
camino.Value result = 4; camino.Value result = 4;
string error = 5; string error = 5;
repeated quixos.runtime.DerivedDependency dependencies = 6; repeated quixos.runtime.DerivedDependency dependencies = 6;
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;
string client_mutation_id = 6;
} }
message WatchCapabilityRequest { message WatchCapabilityRequest {
@@ -68,18 +93,34 @@ message WatchCapabilityEvent {
repeated quixos.runtime.DerivedDependency dependencies = 5; repeated quixos.runtime.DerivedDependency dependencies = 5;
string error = 6; string error = 6;
bool initial = 7; bool initial = 7;
FieldEditing field_editing = 8;
} }
message GetWorkspaceRequest {} message GetWorkspaceRequest {
// Revision polling must not download the entire interface graph.
bool include_interface_contracts = 1;
}
message GetWorkspaceResponse { message GetWorkspaceResponse {
string workspace_id = 1; string workspace_id = 1;
string workspace_revision_id = 2; string workspace_revision_id = 2;
string source_root_commit = 3; string source_root_commit = 3;
// Checked constructors whose wire input can be empty. Web Studio intersects // Checked constructors whose wire input can be empty. The create panel uses
// this with its temporary Createable marker; the marker is not a factory. // class factory conformances instead of this constructor inventory.
repeated string empty_input_constructible_atom_ids = 4; repeated string empty_input_constructible_atom_ids = 4;
repeated CapabilityInputContract capability_inputs = 5; repeated CapabilityInputContract capability_inputs = 5;
repeated ConstructorInputContract constructor_inputs = 6; repeated ConstructorInputContract constructor_inputs = 6;
// Exact closed interface contracts used by checked presentation consumers.
string interfaces_json = 7;
repeated ClassCapability class_capabilities = 8;
}
message ClassCapability {
string conformance_id = 1;
string atom_id = 2;
string interface_revision_id = 3;
string definition_id = 4;
string operation_id = 5;
string input_type_json = 6;
string output_type_json = 7;
} }
message CapabilityInputContract { message CapabilityInputContract {
string interface_revision_id = 1; string interface_revision_id = 1;
+8 -2
View File
@@ -3,16 +3,22 @@ import { readFile, writeFile } from "node:fs/promises";
import { generateTypeScriptBindings } from "./index.js"; import { generateTypeScriptBindings } from "./index.js";
import path from "node:path"; import path from "node:path";
import { reactPlatformTypes } from "./react-platform.js"; import { reactPlatformTypes } from "./react-platform.js";
import { generateReactBindings } from "./react.js";
const main = async () => { const main = async () => {
const [schema, revision, output, options, ...rest] = process.argv.slice(2); const [schema, revision, output, options, ...rest] = process.argv.slice(2);
if (!schema || !revision || !output || rest.length) if (!schema || !revision || !output || rest.length)
throw new Error("usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]"); 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 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); 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), "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) => { main().catch((error: unknown) => {
+13 -3
View File
@@ -124,7 +124,7 @@ export const genericImplementationType = (
}; };
const operations = (contract.template?.members ?? contract.members).flatMap((member) => const operations = (contract.template?.members ?? contract.members).flatMap((member) =>
member.operations member.operations
.filter((op) => op.mode === "call") .filter((op) => op.mode === "call" && op.scope !== "class")
.map((op) => ({ ...op, name: `${member.displayName}.${op.displayName}` })), .map((op) => ({ ...op, name: `${member.displayName}.${op.displayName}` })),
); );
return object([ return object([
@@ -146,11 +146,21 @@ export const genericImplementationType = (
.join(","); .join(",");
const receiver = definition.receiverRequirement; const receiver = definition.receiverRequirement;
const context = object([ 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())], ["input", value(definition.inputType, rootScope())],
["ports", object(definition.dependencyPorts.map((entry) => [entry.displayName, port(entry)]))], ["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 result = value(definition.eventType ?? definition.outputType, rootScope());
const handler = `<${declarations}>(context:${contextWithLifecycle})=>${result}|Promise<${result}>`; const handler = `<${declarations}>(context:${contextWithLifecycle})=>${result}|Promise<${result}>`;
const derived = `{kind:"derived";get:${handler}}`; const derived = `{kind:"derived";get:${handler}}`;
+6 -3
View File
@@ -180,7 +180,7 @@ export const generateTypeScriptBindings = (
// Streaming ports need a future streaming ABI; ordinary calls are fully typed today. // Streaming ports need a future streaming ABI; ordinary calls are fully typed today.
const operations = contract.members.flatMap((member) => const operations = contract.members.flatMap((member) =>
member.operations member.operations
.filter((operation) => operation.mode === "call") .filter((operation) => operation.mode === "call" && operation.scope !== "class")
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })), .map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })),
); );
return { return {
@@ -245,13 +245,15 @@ export const generateTypeScriptBindings = (
? `QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>` ? `QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>`
: "QxObjectRef<string>"; : "QxObjectRef<string>";
const contextShape = object([ const contextShape = object([
["objectId", receiver], ...(entry.kind === "function" ? [] : [["objectId", receiver] as [string, string]]),
["input", type(entry.inputType)], ["input", type(entry.inputType)],
["ports", object(ports.map((port) => [port.name, port.type]))], ["ports", object(ports.map((port) => [port.name, port.type]))],
]); ]);
contexts.push([ contexts.push([
entry.displayName, 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 event = entry.kind === "operation" ? entry.eventType : undefined;
const contextType = `Contexts[${q(entry.displayName)}]`; 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}>` : ""}`, : `QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`,
]); ]);
specs[entry.displayName] = { specs[entry.displayName] = {
...(entry.kind === "function" ? { receiver: "none" } : {}),
inputType: entry.inputType, inputType: entry.inputType,
outputType: entry.outputType, outputType: entry.outputType,
...(event ? { eventType: event } : {}), ...(event ? { eventType: event } : {}),
+10 -25
View File
@@ -8,21 +8,13 @@ declare module "@quixos/web-studio-react-runtime" {
export type ObjectRef<AtomId extends string> = string & { export type ObjectRef<AtomId extends string> = string & {
readonly $quixosAtom: AtomId; readonly $quixosAtom: AtomId;
}; };
export type LiveFieldProp<T> = { export type FieldCapability = {objectId: string; interfaceRevisionId: string; getOperationId: string; watchOperationId?: string; setOperationId?: string; inputKind?: "fields" | "value"};
value: T; export type ReadableField<T> = {readonly value?: T; readonly capability: FieldCapability};
source: { /** set(T) uses the resolved capability: native CRDT fields send incremental edits;
objectId: string; * custom/manual setters receive semantic values. Storage provenance grants no authority. */
slotId: string; export type WritableField<T> = ReadableField<T> & {readonly $writeType?: (value: T) => T; readonly writable: true; readonly capability: FieldCapability & {setOperationId: string}};
valueType?: string; export type LiveFieldProp<T> = ReadableField<T>;
storagePolicy?: string; export type InterfaceReference<I extends string, Fields> = {readonly $quixosRef: string; readonly interfaceRevisionId: I; readonly fields: Fields};
revision?: string | number | bigint;
crdtSnapshot?: {
type: string;
encoding: string;
payload: string;
};
};
};
export type ReactComponentHostProps<Action> = { export type ReactComponentHostProps<Action> = {
onAction?: (action: Action) => void; onAction?: (action: Action) => void;
fallback?: React.ReactNode; fallback?: React.ReactNode;
@@ -49,15 +41,8 @@ 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 const useLiveField: <T>( export function useLiveField<T>(field: ReadableField<T>, options: {write: (value: T) => Promise<void>}): readonly [T, (value: T) => Promise<void>];
field: LiveFieldProp<T>, export function useLiveField<T>(field: WritableField<T>): readonly [T, (value: T) => Promise<void>];
options?: { export function useLiveField<T>(field: ReadableField<T>): readonly [T];
reconcileRegister?: (state: {
confirmed: T;
optimistic: T;
pending: boolean;
}) => T;
},
) => readonly [T, (value: T) => Promise<void>];
} }
`; `;
+87
View File
@@ -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`
: "")
);
}
File diff suppressed because one or more lines are too long
@@ -21,100 +21,101 @@ INPUT=20
CONFORM=21 CONFORM=21
AS=22 AS=22
BIND=23 BIND=23
TO=24 STATIC=24
PRIVATE=25 TO=25
SHARED=26 PRIVATE=26
STATE=27 SHARED=27
EDGE=28 STATE=28
PROJECTION=29 EDGE=29
WITH=30 PROJECTION=30
USING=31 WITH=31
VIA=32 USING=32
MATERIALIZE=33 VIA=33
IF=34 MATERIALIZE=34
ABSENT=35 IF=35
ON=36 ABSENT=36
POLICY=37 ON=37
DEFAULT=38 POLICY=38
SOURCE=39 DEFAULT=39
REPOSITORY=40 SOURCE=40
COMMIT=41 REPOSITORY=41
REVISION=42 COMMIT=42
SEMANTIC_MAJOR=43 REVISION=43
ON_DELETE=44 SEMANTIC_MAJOR=44
RETAIN_OTHER=45 ON_DELETE=45
KEYED=46 RETAIN_OTHER=46
PUBLIC_TRAVERSAL=47 KEYED=47
ID=48 PUBLIC_TRAVERSAL=48
DOC=49 ID=49
MODE=50 DOC=50
EMITS=51 MODE=51
RECEIVER=52 EMITS=52
REQUIRES=53 RECEIVER=53
ANY=54 REQUIRES=54
GET=55 ANY=55
SET=56 GET=56
WATCH=57 SET=57
START=58 WATCH=58
STOP=59 START=59
READ=60 STOP=60
WRITE=61 READ=61
RESOLVE=62 WRITE=62
CONNECT=63 RESOLVE=63
DISCONNECT=64 CONNECT=64
CALL=65 DISCONNECT=65
WATCH_START=66 CALL=66
WATCH_STOP=67 WATCH_START=67
SUBSCRIBE=68 WATCH_STOP=68
UNSUBSCRIBE=69 SUBSCRIBE=69
OPTIMISTIC_REGISTER=70 UNSUBSCRIBE=70
CRDT=71 OPTIMISTIC_REGISTER=71
OPTIONAL_ONE=72 CRDT=72
EXACTLY_ONE=73 OPTIONAL_ONE=73
MANY_UNIQUE=74 EXACTLY_ONE=74
MANY=75 MANY_UNIQUE=75
ORDERED=76 MANY=76
UNIT=77 ORDERED=77
WATCH_HANDLE=78 UNIT=78
MESSAGE=79 WATCH_HANDLE=79
ATOM_REF=80 MESSAGE=80
INTERFACE_REF=81 ATOM_REF=81
OPTIONAL=82 INTERFACE_REF=82
LIST=83 OPTIONAL=83
RECORD=84 LIST=84
BOOL=85 RECORD=85
BYTES=86 BOOL=86
DOUBLE=87 BYTES=87
INT32=88 DOUBLE=88
INT64=89 INT32=89
STRING=90 INT64=90
UINT32=91 STRING=91
UINT64=92 UINT32=92
TRUE=93 UINT64=93
FALSE=94 TRUE=94
NULL=95 FALSE=95
ARROW=96 NULL=96
COLON=97 ARROW=97
SEMI=98 COLON=98
COMMA=99 SEMI=99
DOT=100 COMMA=100
LBRACE=101 DOT=101
RBRACE=102 LBRACE=102
LBRACK=103 RBRACE=103
RBRACK=104 LBRACK=104
LPAREN=105 RBRACK=105
RPAREN=106 LPAREN=106
LT=107 RPAREN=107
GT=108 LT=108
AMP=109 GT=109
EQUAL=110 AMP=110
INTEGER=111 EQUAL=111
JSON_NUMBER=112 INTEGER=112
IDENTIFIER=113 JSON_NUMBER=113
STRING_LITERAL=114 IDENTIFIER=114
LINE_COMMENT=115 STRING_LITERAL=115
BLOCK_COMMENT=116 LINE_COMMENT=116
WS=117 BLOCK_COMMENT=117
WS=118
'workspace'=1 'workspace'=1
'type'=2 'type'=2
'object'=3 'object'=3
@@ -138,90 +139,91 @@ WS=117
'conform'=21 'conform'=21
'as'=22 'as'=22
'bind'=23 'bind'=23
'to'=24 'static'=24
'private'=25 'to'=25
'shared'=26 'private'=26
'state'=27 'shared'=27
'edge'=28 'state'=28
'projection'=29 'edge'=29
'with'=30 'projection'=30
'using'=31 'with'=31
'via'=32 'using'=32
'materialize'=33 'via'=33
'if'=34 'materialize'=34
'absent'=35 'if'=35
'on'=36 'absent'=36
'policy'=37 'on'=37
'default'=38 'policy'=38
'source'=39 'default'=39
'repository'=40 'source'=40
'commit'=41 'repository'=41
'revision'=42 'commit'=42
'semantic-major'=43 'revision'=43
'on-delete'=44 'semantic-major'=44
'retain-other'=45 'on-delete'=45
'keyed'=46 'retain-other'=46
'public-traversal'=47 'keyed'=47
'id'=48 'public-traversal'=48
'doc'=49 'id'=49
'mode'=50 'doc'=50
'emits'=51 'mode'=51
'receiver'=52 'emits'=52
'requires'=53 'receiver'=53
'any'=54 'requires'=54
'get'=55 'any'=55
'set'=56 'get'=56
'watch'=57 'set'=57
'start'=58 'watch'=58
'stop'=59 'start'=59
'read'=60 'stop'=60
'write'=61 'read'=61
'resolve'=62 'write'=62
'connect'=63 'resolve'=63
'disconnect'=64 'connect'=64
'call'=65 'disconnect'=65
'watch-start'=66 'call'=66
'watch-stop'=67 'watch-start'=67
'subscribe'=68 'watch-stop'=68
'unsubscribe'=69 'subscribe'=69
'optimistic-register'=70 'unsubscribe'=70
'crdt'=71 'optimistic-register'=71
'optional-one'=72 'crdt'=72
'exactly-one'=73 'optional-one'=73
'many-unique'=74 'exactly-one'=74
'many'=75 'many-unique'=75
'ordered'=76 'many'=76
'unit'=77 'ordered'=77
'watch-handle'=78 'unit'=78
'message'=79 'watch-handle'=79
'atom-ref'=80 'message'=80
'interface-ref'=81 'atom-ref'=81
'optional'=82 'interface-ref'=82
'list'=83 'optional'=83
'record'=84 'list'=84
'bool'=85 'record'=85
'bytes'=86 'bool'=86
'double'=87 'bytes'=87
'int32'=88 'double'=88
'int64'=89 'int32'=89
'string'=90 'int64'=90
'uint32'=91 'string'=91
'uint64'=92 'uint32'=92
'true'=93 'uint64'=93
'false'=94 'true'=94
'null'=95 'false'=95
'->'=96 'null'=96
':'=97 '->'=97
';'=98 ':'=98
','=99 ';'=99
'.'=100 ','=100
'{'=101 '.'=101
'}'=102 '{'=102
'['=103 '}'=103
']'=104 '['=104
'('=105 ']'=105
')'=106 '('=106
'<'=107 ')'=107
'>'=108 '<'=108
'&'=109 '>'=109
'='=110 '&'=110
'='=111
File diff suppressed because one or more lines are too long
@@ -21,100 +21,101 @@ INPUT=20
CONFORM=21 CONFORM=21
AS=22 AS=22
BIND=23 BIND=23
TO=24 STATIC=24
PRIVATE=25 TO=25
SHARED=26 PRIVATE=26
STATE=27 SHARED=27
EDGE=28 STATE=28
PROJECTION=29 EDGE=29
WITH=30 PROJECTION=30
USING=31 WITH=31
VIA=32 USING=32
MATERIALIZE=33 VIA=33
IF=34 MATERIALIZE=34
ABSENT=35 IF=35
ON=36 ABSENT=36
POLICY=37 ON=37
DEFAULT=38 POLICY=38
SOURCE=39 DEFAULT=39
REPOSITORY=40 SOURCE=40
COMMIT=41 REPOSITORY=41
REVISION=42 COMMIT=42
SEMANTIC_MAJOR=43 REVISION=43
ON_DELETE=44 SEMANTIC_MAJOR=44
RETAIN_OTHER=45 ON_DELETE=45
KEYED=46 RETAIN_OTHER=46
PUBLIC_TRAVERSAL=47 KEYED=47
ID=48 PUBLIC_TRAVERSAL=48
DOC=49 ID=49
MODE=50 DOC=50
EMITS=51 MODE=51
RECEIVER=52 EMITS=52
REQUIRES=53 RECEIVER=53
ANY=54 REQUIRES=54
GET=55 ANY=55
SET=56 GET=56
WATCH=57 SET=57
START=58 WATCH=58
STOP=59 START=59
READ=60 STOP=60
WRITE=61 READ=61
RESOLVE=62 WRITE=62
CONNECT=63 RESOLVE=63
DISCONNECT=64 CONNECT=64
CALL=65 DISCONNECT=65
WATCH_START=66 CALL=66
WATCH_STOP=67 WATCH_START=67
SUBSCRIBE=68 WATCH_STOP=68
UNSUBSCRIBE=69 SUBSCRIBE=69
OPTIMISTIC_REGISTER=70 UNSUBSCRIBE=70
CRDT=71 OPTIMISTIC_REGISTER=71
OPTIONAL_ONE=72 CRDT=72
EXACTLY_ONE=73 OPTIONAL_ONE=73
MANY_UNIQUE=74 EXACTLY_ONE=74
MANY=75 MANY_UNIQUE=75
ORDERED=76 MANY=76
UNIT=77 ORDERED=77
WATCH_HANDLE=78 UNIT=78
MESSAGE=79 WATCH_HANDLE=79
ATOM_REF=80 MESSAGE=80
INTERFACE_REF=81 ATOM_REF=81
OPTIONAL=82 INTERFACE_REF=82
LIST=83 OPTIONAL=83
RECORD=84 LIST=84
BOOL=85 RECORD=85
BYTES=86 BOOL=86
DOUBLE=87 BYTES=87
INT32=88 DOUBLE=88
INT64=89 INT32=89
STRING=90 INT64=90
UINT32=91 STRING=91
UINT64=92 UINT32=92
TRUE=93 UINT64=93
FALSE=94 TRUE=94
NULL=95 FALSE=95
ARROW=96 NULL=96
COLON=97 ARROW=97
SEMI=98 COLON=98
COMMA=99 SEMI=99
DOT=100 COMMA=100
LBRACE=101 DOT=101
RBRACE=102 LBRACE=102
LBRACK=103 RBRACE=103
RBRACK=104 LBRACK=104
LPAREN=105 RBRACK=105
RPAREN=106 LPAREN=106
LT=107 RPAREN=107
GT=108 LT=108
AMP=109 GT=109
EQUAL=110 AMP=110
INTEGER=111 EQUAL=111
JSON_NUMBER=112 INTEGER=112
IDENTIFIER=113 JSON_NUMBER=113
STRING_LITERAL=114 IDENTIFIER=114
LINE_COMMENT=115 STRING_LITERAL=115
BLOCK_COMMENT=116 LINE_COMMENT=116
WS=117 BLOCK_COMMENT=117
WS=118
'workspace'=1 'workspace'=1
'type'=2 'type'=2
'object'=3 'object'=3
@@ -138,90 +139,91 @@ WS=117
'conform'=21 'conform'=21
'as'=22 'as'=22
'bind'=23 'bind'=23
'to'=24 'static'=24
'private'=25 'to'=25
'shared'=26 'private'=26
'state'=27 'shared'=27
'edge'=28 'state'=28
'projection'=29 'edge'=29
'with'=30 'projection'=30
'using'=31 'with'=31
'via'=32 'using'=32
'materialize'=33 'via'=33
'if'=34 'materialize'=34
'absent'=35 'if'=35
'on'=36 'absent'=36
'policy'=37 'on'=37
'default'=38 'policy'=38
'source'=39 'default'=39
'repository'=40 'source'=40
'commit'=41 'repository'=41
'revision'=42 'commit'=42
'semantic-major'=43 'revision'=43
'on-delete'=44 'semantic-major'=44
'retain-other'=45 'on-delete'=45
'keyed'=46 'retain-other'=46
'public-traversal'=47 'keyed'=47
'id'=48 'public-traversal'=48
'doc'=49 'id'=49
'mode'=50 'doc'=50
'emits'=51 'mode'=51
'receiver'=52 'emits'=52
'requires'=53 'receiver'=53
'any'=54 'requires'=54
'get'=55 'any'=55
'set'=56 'get'=56
'watch'=57 'set'=57
'start'=58 'watch'=58
'stop'=59 'start'=59
'read'=60 'stop'=60
'write'=61 'read'=61
'resolve'=62 'write'=62
'connect'=63 'resolve'=63
'disconnect'=64 'connect'=64
'call'=65 'disconnect'=65
'watch-start'=66 'call'=66
'watch-stop'=67 'watch-start'=67
'subscribe'=68 'watch-stop'=68
'unsubscribe'=69 'subscribe'=69
'optimistic-register'=70 'unsubscribe'=70
'crdt'=71 'optimistic-register'=71
'optional-one'=72 'crdt'=72
'exactly-one'=73 'optional-one'=73
'many-unique'=74 'exactly-one'=74
'many'=75 'many-unique'=75
'ordered'=76 'many'=76
'unit'=77 'ordered'=77
'watch-handle'=78 'unit'=78
'message'=79 'watch-handle'=79
'atom-ref'=80 'message'=80
'interface-ref'=81 'atom-ref'=81
'optional'=82 'interface-ref'=82
'list'=83 'optional'=83
'record'=84 'list'=84
'bool'=85 'record'=85
'bytes'=86 'bool'=86
'double'=87 'bytes'=87
'int32'=88 'double'=88
'int64'=89 'int32'=89
'string'=90 'int64'=90
'uint32'=91 'string'=91
'uint64'=92 'uint32'=92
'true'=93 'uint64'=93
'false'=94 'true'=94
'null'=95 'false'=95
'->'=96 'null'=96
':'=97 '->'=97
';'=98 ':'=98
','=99 ';'=99
'.'=100 ','=100
'{'=101 '.'=101
'}'=102 '{'=102
'['=103 '}'=103
']'=104 '['=104
'('=105 ']'=105
')'=106 '('=106
'<'=107 ')'=107
'>'=108 '<'=108
'&'=109 '>'=109
'='=110 '&'=110
'='=111
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,7 @@ import { EdgeDeclContext } from "./QuixosCapabilityParser.js";
import { EdgeEndpointContext } from "./QuixosCapabilityParser.js"; import { EdgeEndpointContext } from "./QuixosCapabilityParser.js";
import { ConformanceDeclContext } from "./QuixosCapabilityParser.js"; import { ConformanceDeclContext } from "./QuixosCapabilityParser.js";
import { ConformanceItemContext } from "./QuixosCapabilityParser.js"; import { ConformanceItemContext } from "./QuixosCapabilityParser.js";
import { StateFieldBindingDeclContext } from "./QuixosCapabilityParser.js";
import { RelationshipMaterializationDeclContext } from "./QuixosCapabilityParser.js"; import { RelationshipMaterializationDeclContext } from "./QuixosCapabilityParser.js";
import { OperationBindingDeclContext } from "./QuixosCapabilityParser.js"; import { OperationBindingDeclContext } from "./QuixosCapabilityParser.js";
import { MemberOperationRefContext } from "./QuixosCapabilityParser.js"; import { MemberOperationRefContext } from "./QuixosCapabilityParser.js";
@@ -347,6 +348,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor result * @return the visitor result
*/ */
visitConformanceItem?: (ctx: ConformanceItemContext) => Result; visitConformanceItem?: (ctx: ConformanceItemContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.stateFieldBindingDecl`.
* @param ctx the parse tree
* @return the visitor result
*/
visitStateFieldBindingDecl?: (ctx: StateFieldBindingDeclContext) => Result;
/** /**
* Visit a parse tree produced by `QuixosCapabilityParser.relationshipMaterializationDecl`. * Visit a parse tree produced by `QuixosCapabilityParser.relationshipMaterializationDecl`.
* @param ctx the parse tree * @param ctx the parse tree
+46 -1
View File
@@ -552,6 +552,7 @@ const lowerOperationMember = (state: LoweringState, context: OperationMemberCont
inputType, inputType,
outputType, outputType,
mode: "call", mode: "call",
...(context.STATIC() ? { scope: "class" as const } : {}),
}, },
], ],
}; };
@@ -702,7 +703,15 @@ const lowerInterfaceTemplate = (
inputType, inputType,
outputType, outputType,
operations: [ operations: [
signature("call", capabilityId.operation(stringValue(operation.stringLiteral(1))), inputType, outputType), {
...signature(
"call",
capabilityId.operation(stringValue(operation.stringLiteral(1))),
inputType,
outputType,
),
...(operation.STATIC() ? { scope: "class" as const } : {}),
},
], ],
}; };
} }
@@ -1430,6 +1439,42 @@ const lowerConformance = (
const operationBindings = context const operationBindings = context
.conformanceItem() .conformanceItem()
.flatMap<WorkspaceRevision["conformances"][number]["operationBindings"][number]>((item) => { .flatMap<WorkspaceRevision["conformances"][number]["operationBindings"][number]>((item) => {
const field = item.stateFieldBindingDecl();
if (field) {
const memberName = identifier(field.identifier(0));
const member = interfaceSymbol.definition?.members.find((entry) => entry.displayName === memberName);
const attachment = requireSymbol(
state,
state.attachments,
identifier(field.identifier(1)),
field,
"attachment",
);
if (!member || member.kind !== "value" || attachment?.attachment.kind !== "state") {
loweringIssue(
state,
field,
"invalid-state-field-binding",
"Field shorthand requires a value member and a state slot",
);
return [];
}
const primitives = {
get: "read",
set: "write",
"watch-start": "watch-start",
"watch-stop": "watch-stop",
} as const;
const slotId = attachment.attachment.id;
return member.operations.map((operation) => ({
operationId: operation.id,
binding: {
kind: "state" as const,
slotId,
primitive: primitives[operation.displayName as keyof typeof primitives],
},
}));
}
const bindingContext = item.operationBindingDecl(); const bindingContext = item.operationBindingDecl();
if (!bindingContext) { if (!bindingContext) {
return []; return [];
+14 -10
View File
@@ -142,7 +142,7 @@ export const scaffoldRecipe = async (
if (react) { if (react) {
create( create(
"src/component.tsx", "src/component.tsx",
`// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1><p>Edit this component, then run qx-workspace check.</p></section>;\n}\n`, `// Add a checked props export, select it in quixos.check.json options.react,\n// then use its generated ReactResults type. Scaffold bundle does this for you.\nexport default function Component(_props: {camino: Record<string, never>; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1></section>;\n}\n`,
); );
create( create(
"src/impl/sourceGet.ts", "src/impl/sourceGet.ts",
@@ -180,14 +180,7 @@ export const scaffoldRecipe = async (
bindingOutput: "src/gen/qx.ts", bindingOutput: "src/gen/qx.ts",
...(react ...(react
? { ? {
options: { options: { react: { propsExports: [] } },
messages: {
"org.quixos.web-studio.ReactProps": {
module: "@quixos/camino-package-runtime",
export: "opaqueReactPropsBinding",
},
},
},
} }
: {}), : {}),
}), }),
@@ -394,9 +387,20 @@ export const scaffoldRecipe = async (
for (const [file, content] of Object.entries(spec.initialFiles)) { for (const [file, content] of Object.entries(spec.initialFiles)) {
if ( if (
typeof content !== "string" || typeof content !== "string" ||
["quixos.lock", "flake.nix", "quixos.toolchain.json", "quixos.check.json", "package.json"].includes(file) ["quixos.lock", "flake.nix", "quixos.toolchain.json", "package.json"].includes(file)
) )
throw new Error(`Not an initial authored file: ${file}`); throw new Error(`Not an initial authored file: ${file}`);
if (file === "quixos.check.json") {
const check = JSON.parse(content);
if (
check.backend !== "typescript" ||
check.bindingOutput !== "src/gen/qx.ts" ||
Object.keys(check).some((key) => !["backend", "bindingOutput", "options"].includes(key))
)
throw new Error(
"Initial check configuration may customize binding options, not the scaffold verification backend or output",
);
}
const existing = files.findIndex((entry) => entry.file === prefix + file); const existing = files.findIndex((entry) => entry.file === prefix + file);
if (existing >= 0) files.splice(existing, 1); if (existing >= 0) files.splice(existing, 1);
create(file, content); create(file, content);
+3
View File
@@ -310,6 +310,9 @@ const main = async () => {
throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source"); throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source");
const request: StructuralRequest = { const request: StructuralRequest = {
kind: "interface", kind: "interface",
// Like package scaffolding, declaration creation is provisional. Imports
// can be attached next; the normal check verifies the complete graph.
validation: "syntax",
source: spec.source, source: spec.source,
files: [ files: [
{ {
+2
View File
@@ -110,6 +110,8 @@ export interface InterfaceOperation<Type = ValueType> {
inputType: Type; inputType: Type;
outputType: Type; outputType: Type;
mode: InterfaceOperationMode; mode: InterfaceOperationMode;
/** Class capabilities have no object receiver and bind free functions. */
scope?: "class";
/** Required for watch-start/subscribe and absent for other modes. */ /** Required for watch-start/subscribe and absent for other modes. */
eventType?: Type; eventType?: Type;
} }
+44 -6
View File
@@ -23,6 +23,7 @@ import type {
OwnedAttachment, OwnedAttachment,
PackageExport, PackageExport,
PackageOperationExport, PackageOperationExport,
PackageFunctionExport,
PackageRevision, PackageRevision,
PackageRevisionId, PackageRevisionId,
PersistentAttachment, PersistentAttachment,
@@ -1423,6 +1424,24 @@ const validateConformances = (
} }
const operation = operationEntry.operation; const operation = operationEntry.operation;
const binding = entry.binding; const binding = entry.binding;
if (operation.scope === "class" && (!conformance.id || operation.mode !== "call")) {
issue(
issues,
"invalid-package-binding",
bindingPath,
"Class capabilities require an explicit conformance ID and call mode",
);
continue;
}
if (operation.scope === "class" && binding.kind !== "package") {
issue(
issues,
"invalid-package-binding",
bindingPath,
"Class capabilities must bind a free package function, not instance storage",
);
continue;
}
if (binding.kind === "state") { if (binding.kind === "state") {
const attachment = findAttachment(indexes, "state", binding.slotId); const attachment = findAttachment(indexes, "state", binding.slotId);
if (!attachment || attachment.attachment.kind !== "state") { if (!attachment || attachment.attachment.kind !== "state") {
@@ -1562,23 +1581,29 @@ const validateConformances = (
); );
continue; continue;
} }
if (packageExport.kind !== "operation") { if (operation.scope === "class" ? packageExport.kind !== "function" : packageExport.kind !== "operation") {
issue( issue(
issues, issues,
"invalid-package-binding", "invalid-package-binding",
`${bindingPath}.binding.exportId`, `${bindingPath}.binding.exportId`,
`Package export ${binding.exportId} is ${packageExport.kind}, not an operation`, `Package export ${binding.exportId} must be ${operation.scope === "class" ? "a free function" : "an instance operation"}`,
); );
continue; continue;
} }
if (!signaturesMatch(operation, packageExport)) { if (
!signaturesMatch(operation, {
...packageExport,
mode: packageExport.kind === "operation" ? packageExport.mode : "call",
})
) {
issue( issue(
issues, issues,
"invalid-package-binding", "invalid-package-binding",
`${bindingPath}.binding.exportId`, `${bindingPath}.binding.exportId`,
`Package export provides ${describeSignature(packageExport)}, but operation requires ${describeSignature(operation)}`, `Package export signature does not match ${describeSignature(operation)}`,
); );
} }
if (packageExport.kind === "operation")
validatePackageReceiver(issues, { validatePackageReceiver(issues, {
entry: packageExport, entry: packageExport,
atomId: conformance.atomId, atomId: conformance.atomId,
@@ -1587,6 +1612,18 @@ const validateConformances = (
requirementGraph, requirementGraph,
graphSourceKey: key, graphSourceKey: key,
}); });
if (
operation.scope === "class" &&
(binding.dependencies.some((entry) => entry.binding.kind !== "constructor") ||
packageExport.dependencyPorts.some((entry) => entry.requirement.kind !== "constructor"))
) {
issue(
issues,
"invalid-package-binding",
bindingPath,
"Class functions may inject constructors, not instance-dependent ports",
);
}
validateBoundDependencies(issues, { validateBoundDependencies(issues, {
dependencies: binding.dependencies, dependencies: binding.dependencies,
dependencyPorts: packageExport.dependencyPorts, dependencyPorts: packageExport.dependencyPorts,
@@ -1919,7 +1956,7 @@ export type ResolvedOperationPlan =
kind: "package"; kind: "package";
binding: Extract<Binding, { kind: "package" }>; binding: Extract<Binding, { kind: "package" }>;
packageRevision: PackageRevision; packageRevision: PackageRevision;
packageExport: PackageOperationExport; packageExport: PackageOperationExport | PackageFunctionExport;
dependencies: Array<{ dependencies: Array<{
port: DependencyPort; port: DependencyPort;
binding: DependencyBinding; binding: DependencyBinding;
@@ -1962,7 +1999,8 @@ export const resolveOperationPlan = (
} }
const packageRevision = plan.packages.get(binding.packageRevisionId); const packageRevision = plan.packages.get(binding.packageRevisionId);
const packageExport = packageRevision?.exports.find( const packageExport = packageRevision?.exports.find(
(entry): entry is PackageOperationExport => entry.id === binding.exportId && entry.kind === "operation", (entry): entry is PackageOperationExport | PackageFunctionExport =>
entry.id === binding.exportId && (entry.kind === "operation" || entry.kind === "function"),
); );
if (!packageRevision || !packageExport) { if (!packageRevision || !packageExport) {
return undefined; return undefined;
+217 -21
View File
File diff suppressed because one or more lines are too long
+187
View File
@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import test from "node:test";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
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";
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"; }
}`,
{ source },
);
assert.ok(iface.ok && iface.resource.kind === "interface");
if (!iface.ok || iface.resource.kind !== "interface") throw new Error("interface failed");
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;};
}`,
{ 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");
const schema = {
format: "quixos-bindings",
version: 1,
interfaces: [iface.resource.revision],
packages: [pkg.resource.revision],
} as const;
const generated = generateReactBindings(
{ ...schema, interfaces: [...schema.interfaces], packages: [...schema.packages] },
"p@1",
["props"],
);
assert.match(generated, /"title": WritableField<string>/);
assert.match(generated, /"summary": ReadableField<string>/);
assert.throws(
() => generateReactBindings({ ...schema, interfaces: [], packages: [...schema.packages] }, "p@1", ["props"]),
/Missing React reference/,
);
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-react-types-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.writeFile(path.join(root, "react-props.gen.ts"), generated);
await fs.writeFile(
path.join(root, "platform.d.ts"),
reactPlatformTypes +
'\ndeclare module "react" {export type ReactNode = unknown; export type CSSProperties = {}; export function createElement(...args: unknown[]): unknown;}\n',
);
await fs.writeFile(
path.join(root, "consumer.ts"),
`import {useLiveField, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime";
import type {ReactResults} from "./react-props.gen.js";
declare const props: ReactResults["props"];
const [title, setTitle] = useLiveField(props.fields.fields.title);
setTitle("new");
// @ts-expect-error wrong setter value
setTitle(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");
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;
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) => {}});
`,
);
const result = spawnSync(
process.execPath,
[
path.resolve("node_modules/typescript/bin/tsc"),
"--strict",
"--noEmit",
"--skipLibCheck",
"--target",
"ES2022",
path.join(root, "platform.d.ts"),
path.join(root, "consumer.ts"),
],
{ encoding: "utf8", cwd: root },
);
assert.equal(result.status, 0, result.stdout + result.stderr);
await fs.writeFile(
path.join(root, "react-props.gen.ts"),
generateReactBindings(
{ ...schema, interfaces: [...schema.interfaces], packages: [...schema.packages] },
"p@1",
["props"],
[{ module: "./component.js", propsExport: "props" }],
),
);
const checkComponent = () =>
spawnSync(
process.execPath,
[
path.resolve("node_modules/typescript/bin/tsc"),
"--strict",
"--noEmit",
"--skipLibCheck",
"--target",
"ES2022",
path.join(root, "platform.d.ts"),
path.join(root, "react-props.gen.ts"),
path.join(root, "component.ts"),
],
{ encoding: "utf8", cwd: root },
);
await fs.writeFile(
path.join(root, "component.ts"),
'import type {ReactResults} from "./react-props.gen.js"; export default function Component(props: {camino: ReactResults["props"]}) {return null;}',
);
const matching = checkComponent();
assert.equal(matching.status, 0, matching.stdout + matching.stderr);
await fs.writeFile(
path.join(root, "component.ts"),
'import type {WritableField} from "@quixos/web-studio-react-runtime"; export default function Component(props: {camino: {fields: {fields: {summary: WritableField<string>}}}}) {return null;}',
);
const incompatible = checkComponent();
assert.notEqual(incompatible.status, 0, "component cannot strengthen a read-only prop into a writable field");
assert.match(incompatible.stdout + incompatible.stderr, /writable|WritableField/);
});
test("class factories specialize return types and reject instance implementations or mismatched inputs", () => {
const factory = compileCapabilityResourceSource(
'interface Factory<object T> id "factory" revision "factory@1" {static operation create id "create" : unit -> ref<T> {call id "call";}}',
{ source },
);
assert.ok(factory.ok && factory.resource.kind === "interface");
if (!factory.ok || factory.resource.kind !== "interface") throw new Error("factory failed");
const factoryRevision = factory.resource.revision;
const compile = (declaration: string) => {
const pkg = compileCapabilityResourceSource(
`external atom Note id "note"; package P id "p" revision "p@1" {${declaration}}`,
{ source },
);
assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics));
if (!pkg.ok || pkg.resource.kind !== "package") throw new Error("package failed");
return compileCapabilitySource(
`workspace W id "w" revision "w@1" commit "${source.commit}" {
atom Note id "note"; import interface Factory; import package P;
conform Note as Factory<atom Note> id "factory-conformance" {bind create.call to package P.make;}
}`,
"workspace.qx",
{ interfaces: new Map([["Factory", factoryRevision]]), packages: new Map([["P", pkg.resource.revision]]) },
);
};
const good = compile('function make id "make" : unit -> atom-ref<Note>;');
assert.ok(good.ok, JSON.stringify(good.diagnostics));
assert.equal(good.workspace.interfaceImports[0].members[0].operations[0].scope, "class");
const wrongInput = compile('function make id "make" : string -> atom-ref<Note>;');
assert.equal(wrongInput.ok, false);
const wrongReceiver = compile('operation make id "make" : unit -> atom-ref<Note> mode call receiver atom Note;');
assert.equal(wrongReceiver.ok, false);
assert.match(JSON.stringify(wrongReceiver.diagnostics), /free function/);
});
test("state-field shorthand binds exactly the declared accessors, never invents writes", () => {
const iface = compileCapabilityResourceSource(
'interface Reader id "reader" revision "reader@1" {value title id "title" : string {get id "get"; watch start id "watch" stop id "stop";}}',
{ source },
);
assert.ok(iface.ok && iface.resource.kind === "interface");
if (!iface.ok || iface.resource.kind !== "interface") throw new Error("interface failed");
const result = compileCapabilitySource(
`workspace W id "w" revision "w@1" commit "${source.commit}" {
atom Note id "note"; import interface Reader;
conform Note as Reader id "reader-conformance" {private state Title id "title-slot" on Note : string policy crdt(string) default ""; bind title to state Title;}
}`,
"workspace.qx",
{ interfaces: new Map([["Reader", iface.resource.revision]]) },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
assert.deepEqual(
result.workspace.conformances[0].operationBindings.map((binding) => binding.operationId),
["get", "watch", "stop"],
);
});
+3 -5
View File
@@ -30,11 +30,9 @@ test("React preset applies its browser build script and shared-platform imports"
assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /browserSources = true/); assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /browserSources = true/);
assert.equal(await fs.readFile(path.join(root, "src/gen/web-studio-react-runtime.d.ts"), "utf8"), reactPlatformTypes); assert.equal(await fs.readFile(path.join(root, "src/gen/web-studio-react-runtime.d.ts"), "utf8"), reactPlatformTypes);
assert.match(await fs.readFile(path.join(root, "src/browser-assets.d.ts"), "utf8"), /declare module "\*\.css"/); assert.match(await fs.readFile(path.join(root, "src/browser-assets.d.ts"), "utf8"), /declare module "\*\.css"/);
assert.equal( assert.deepEqual(
JSON.parse(await fs.readFile(path.join(root, "quixos.check.json"), "utf8")).options.messages[ JSON.parse(await fs.readFile(path.join(root, "quixos.check.json"), "utf8")).options.react.propsExports,
"org.quixos.web-studio.ReactProps" [],
].export,
"opaqueReactPropsBinding",
); );
await assert.rejects(fs.access(path.join(root, "quixos.scaffold.json"))); await assert.rejects(fs.access(path.join(root, "quixos.scaffold.json")));
assert.equal( assert.equal(