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
+8 -2
View File
@@ -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) => {
+13 -3
View File
@@ -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}}`;
+6 -3
View File
@@ -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 } : {}),
+10 -25
View File
@@ -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];
}
`;
+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
AS=22
BIND=23
TO=24
PRIVATE=25
SHARED=26
STATE=27
EDGE=28
PROJECTION=29
WITH=30
USING=31
VIA=32
MATERIALIZE=33
IF=34
ABSENT=35
ON=36
POLICY=37
DEFAULT=38
SOURCE=39
REPOSITORY=40
COMMIT=41
REVISION=42
SEMANTIC_MAJOR=43
ON_DELETE=44
RETAIN_OTHER=45
KEYED=46
PUBLIC_TRAVERSAL=47
ID=48
DOC=49
MODE=50
EMITS=51
RECEIVER=52
REQUIRES=53
ANY=54
GET=55
SET=56
WATCH=57
START=58
STOP=59
READ=60
WRITE=61
RESOLVE=62
CONNECT=63
DISCONNECT=64
CALL=65
WATCH_START=66
WATCH_STOP=67
SUBSCRIBE=68
UNSUBSCRIBE=69
OPTIMISTIC_REGISTER=70
CRDT=71
OPTIONAL_ONE=72
EXACTLY_ONE=73
MANY_UNIQUE=74
MANY=75
ORDERED=76
UNIT=77
WATCH_HANDLE=78
MESSAGE=79
ATOM_REF=80
INTERFACE_REF=81
OPTIONAL=82
LIST=83
RECORD=84
BOOL=85
BYTES=86
DOUBLE=87
INT32=88
INT64=89
STRING=90
UINT32=91
UINT64=92
TRUE=93
FALSE=94
NULL=95
ARROW=96
COLON=97
SEMI=98
COMMA=99
DOT=100
LBRACE=101
RBRACE=102
LBRACK=103
RBRACK=104
LPAREN=105
RPAREN=106
LT=107
GT=108
AMP=109
EQUAL=110
INTEGER=111
JSON_NUMBER=112
IDENTIFIER=113
STRING_LITERAL=114
LINE_COMMENT=115
BLOCK_COMMENT=116
WS=117
STATIC=24
TO=25
PRIVATE=26
SHARED=27
STATE=28
EDGE=29
PROJECTION=30
WITH=31
USING=32
VIA=33
MATERIALIZE=34
IF=35
ABSENT=36
ON=37
POLICY=38
DEFAULT=39
SOURCE=40
REPOSITORY=41
COMMIT=42
REVISION=43
SEMANTIC_MAJOR=44
ON_DELETE=45
RETAIN_OTHER=46
KEYED=47
PUBLIC_TRAVERSAL=48
ID=49
DOC=50
MODE=51
EMITS=52
RECEIVER=53
REQUIRES=54
ANY=55
GET=56
SET=57
WATCH=58
START=59
STOP=60
READ=61
WRITE=62
RESOLVE=63
CONNECT=64
DISCONNECT=65
CALL=66
WATCH_START=67
WATCH_STOP=68
SUBSCRIBE=69
UNSUBSCRIBE=70
OPTIMISTIC_REGISTER=71
CRDT=72
OPTIONAL_ONE=73
EXACTLY_ONE=74
MANY_UNIQUE=75
MANY=76
ORDERED=77
UNIT=78
WATCH_HANDLE=79
MESSAGE=80
ATOM_REF=81
INTERFACE_REF=82
OPTIONAL=83
LIST=84
RECORD=85
BOOL=86
BYTES=87
DOUBLE=88
INT32=89
INT64=90
STRING=91
UINT32=92
UINT64=93
TRUE=94
FALSE=95
NULL=96
ARROW=97
COLON=98
SEMI=99
COMMA=100
DOT=101
LBRACE=102
RBRACE=103
LBRACK=104
RBRACK=105
LPAREN=106
RPAREN=107
LT=108
GT=109
AMP=110
EQUAL=111
INTEGER=112
JSON_NUMBER=113
IDENTIFIER=114
STRING_LITERAL=115
LINE_COMMENT=116
BLOCK_COMMENT=117
WS=118
'workspace'=1
'type'=2
'object'=3
@@ -138,90 +139,91 @@ WS=117
'conform'=21
'as'=22
'bind'=23
'to'=24
'private'=25
'shared'=26
'state'=27
'edge'=28
'projection'=29
'with'=30
'using'=31
'via'=32
'materialize'=33
'if'=34
'absent'=35
'on'=36
'policy'=37
'default'=38
'source'=39
'repository'=40
'commit'=41
'revision'=42
'semantic-major'=43
'on-delete'=44
'retain-other'=45
'keyed'=46
'public-traversal'=47
'id'=48
'doc'=49
'mode'=50
'emits'=51
'receiver'=52
'requires'=53
'any'=54
'get'=55
'set'=56
'watch'=57
'start'=58
'stop'=59
'read'=60
'write'=61
'resolve'=62
'connect'=63
'disconnect'=64
'call'=65
'watch-start'=66
'watch-stop'=67
'subscribe'=68
'unsubscribe'=69
'optimistic-register'=70
'crdt'=71
'optional-one'=72
'exactly-one'=73
'many-unique'=74
'many'=75
'ordered'=76
'unit'=77
'watch-handle'=78
'message'=79
'atom-ref'=80
'interface-ref'=81
'optional'=82
'list'=83
'record'=84
'bool'=85
'bytes'=86
'double'=87
'int32'=88
'int64'=89
'string'=90
'uint32'=91
'uint64'=92
'true'=93
'false'=94
'null'=95
'->'=96
':'=97
';'=98
','=99
'.'=100
'{'=101
'}'=102
'['=103
']'=104
'('=105
')'=106
'<'=107
'>'=108
'&'=109
'='=110
'static'=24
'to'=25
'private'=26
'shared'=27
'state'=28
'edge'=29
'projection'=30
'with'=31
'using'=32
'via'=33
'materialize'=34
'if'=35
'absent'=36
'on'=37
'policy'=38
'default'=39
'source'=40
'repository'=41
'commit'=42
'revision'=43
'semantic-major'=44
'on-delete'=45
'retain-other'=46
'keyed'=47
'public-traversal'=48
'id'=49
'doc'=50
'mode'=51
'emits'=52
'receiver'=53
'requires'=54
'any'=55
'get'=56
'set'=57
'watch'=58
'start'=59
'stop'=60
'read'=61
'write'=62
'resolve'=63
'connect'=64
'disconnect'=65
'call'=66
'watch-start'=67
'watch-stop'=68
'subscribe'=69
'unsubscribe'=70
'optimistic-register'=71
'crdt'=72
'optional-one'=73
'exactly-one'=74
'many-unique'=75
'many'=76
'ordered'=77
'unit'=78
'watch-handle'=79
'message'=80
'atom-ref'=81
'interface-ref'=82
'optional'=83
'list'=84
'record'=85
'bool'=86
'bytes'=87
'double'=88
'int32'=89
'int64'=90
'string'=91
'uint32'=92
'uint64'=93
'true'=94
'false'=95
'null'=96
'->'=97
':'=98
';'=99
','=100
'.'=101
'{'=102
'}'=103
'['=104
']'=105
'('=106
')'=107
'<'=108
'>'=109
'&'=110
'='=111
File diff suppressed because one or more lines are too long
@@ -21,100 +21,101 @@ INPUT=20
CONFORM=21
AS=22
BIND=23
TO=24
PRIVATE=25
SHARED=26
STATE=27
EDGE=28
PROJECTION=29
WITH=30
USING=31
VIA=32
MATERIALIZE=33
IF=34
ABSENT=35
ON=36
POLICY=37
DEFAULT=38
SOURCE=39
REPOSITORY=40
COMMIT=41
REVISION=42
SEMANTIC_MAJOR=43
ON_DELETE=44
RETAIN_OTHER=45
KEYED=46
PUBLIC_TRAVERSAL=47
ID=48
DOC=49
MODE=50
EMITS=51
RECEIVER=52
REQUIRES=53
ANY=54
GET=55
SET=56
WATCH=57
START=58
STOP=59
READ=60
WRITE=61
RESOLVE=62
CONNECT=63
DISCONNECT=64
CALL=65
WATCH_START=66
WATCH_STOP=67
SUBSCRIBE=68
UNSUBSCRIBE=69
OPTIMISTIC_REGISTER=70
CRDT=71
OPTIONAL_ONE=72
EXACTLY_ONE=73
MANY_UNIQUE=74
MANY=75
ORDERED=76
UNIT=77
WATCH_HANDLE=78
MESSAGE=79
ATOM_REF=80
INTERFACE_REF=81
OPTIONAL=82
LIST=83
RECORD=84
BOOL=85
BYTES=86
DOUBLE=87
INT32=88
INT64=89
STRING=90
UINT32=91
UINT64=92
TRUE=93
FALSE=94
NULL=95
ARROW=96
COLON=97
SEMI=98
COMMA=99
DOT=100
LBRACE=101
RBRACE=102
LBRACK=103
RBRACK=104
LPAREN=105
RPAREN=106
LT=107
GT=108
AMP=109
EQUAL=110
INTEGER=111
JSON_NUMBER=112
IDENTIFIER=113
STRING_LITERAL=114
LINE_COMMENT=115
BLOCK_COMMENT=116
WS=117
STATIC=24
TO=25
PRIVATE=26
SHARED=27
STATE=28
EDGE=29
PROJECTION=30
WITH=31
USING=32
VIA=33
MATERIALIZE=34
IF=35
ABSENT=36
ON=37
POLICY=38
DEFAULT=39
SOURCE=40
REPOSITORY=41
COMMIT=42
REVISION=43
SEMANTIC_MAJOR=44
ON_DELETE=45
RETAIN_OTHER=46
KEYED=47
PUBLIC_TRAVERSAL=48
ID=49
DOC=50
MODE=51
EMITS=52
RECEIVER=53
REQUIRES=54
ANY=55
GET=56
SET=57
WATCH=58
START=59
STOP=60
READ=61
WRITE=62
RESOLVE=63
CONNECT=64
DISCONNECT=65
CALL=66
WATCH_START=67
WATCH_STOP=68
SUBSCRIBE=69
UNSUBSCRIBE=70
OPTIMISTIC_REGISTER=71
CRDT=72
OPTIONAL_ONE=73
EXACTLY_ONE=74
MANY_UNIQUE=75
MANY=76
ORDERED=77
UNIT=78
WATCH_HANDLE=79
MESSAGE=80
ATOM_REF=81
INTERFACE_REF=82
OPTIONAL=83
LIST=84
RECORD=85
BOOL=86
BYTES=87
DOUBLE=88
INT32=89
INT64=90
STRING=91
UINT32=92
UINT64=93
TRUE=94
FALSE=95
NULL=96
ARROW=97
COLON=98
SEMI=99
COMMA=100
DOT=101
LBRACE=102
RBRACE=103
LBRACK=104
RBRACK=105
LPAREN=106
RPAREN=107
LT=108
GT=109
AMP=110
EQUAL=111
INTEGER=112
JSON_NUMBER=113
IDENTIFIER=114
STRING_LITERAL=115
LINE_COMMENT=116
BLOCK_COMMENT=117
WS=118
'workspace'=1
'type'=2
'object'=3
@@ -138,90 +139,91 @@ WS=117
'conform'=21
'as'=22
'bind'=23
'to'=24
'private'=25
'shared'=26
'state'=27
'edge'=28
'projection'=29
'with'=30
'using'=31
'via'=32
'materialize'=33
'if'=34
'absent'=35
'on'=36
'policy'=37
'default'=38
'source'=39
'repository'=40
'commit'=41
'revision'=42
'semantic-major'=43
'on-delete'=44
'retain-other'=45
'keyed'=46
'public-traversal'=47
'id'=48
'doc'=49
'mode'=50
'emits'=51
'receiver'=52
'requires'=53
'any'=54
'get'=55
'set'=56
'watch'=57
'start'=58
'stop'=59
'read'=60
'write'=61
'resolve'=62
'connect'=63
'disconnect'=64
'call'=65
'watch-start'=66
'watch-stop'=67
'subscribe'=68
'unsubscribe'=69
'optimistic-register'=70
'crdt'=71
'optional-one'=72
'exactly-one'=73
'many-unique'=74
'many'=75
'ordered'=76
'unit'=77
'watch-handle'=78
'message'=79
'atom-ref'=80
'interface-ref'=81
'optional'=82
'list'=83
'record'=84
'bool'=85
'bytes'=86
'double'=87
'int32'=88
'int64'=89
'string'=90
'uint32'=91
'uint64'=92
'true'=93
'false'=94
'null'=95
'->'=96
':'=97
';'=98
','=99
'.'=100
'{'=101
'}'=102
'['=103
']'=104
'('=105
')'=106
'<'=107
'>'=108
'&'=109
'='=110
'static'=24
'to'=25
'private'=26
'shared'=27
'state'=28
'edge'=29
'projection'=30
'with'=31
'using'=32
'via'=33
'materialize'=34
'if'=35
'absent'=36
'on'=37
'policy'=38
'default'=39
'source'=40
'repository'=41
'commit'=42
'revision'=43
'semantic-major'=44
'on-delete'=45
'retain-other'=46
'keyed'=47
'public-traversal'=48
'id'=49
'doc'=50
'mode'=51
'emits'=52
'receiver'=53
'requires'=54
'any'=55
'get'=56
'set'=57
'watch'=58
'start'=59
'stop'=60
'read'=61
'write'=62
'resolve'=63
'connect'=64
'disconnect'=65
'call'=66
'watch-start'=67
'watch-stop'=68
'subscribe'=69
'unsubscribe'=70
'optimistic-register'=71
'crdt'=72
'optional-one'=73
'exactly-one'=74
'many-unique'=75
'many'=76
'ordered'=77
'unit'=78
'watch-handle'=79
'message'=80
'atom-ref'=81
'interface-ref'=82
'optional'=83
'list'=84
'record'=85
'bool'=86
'bytes'=87
'double'=88
'int32'=89
'int64'=90
'string'=91
'uint32'=92
'uint64'=93
'true'=94
'false'=95
'null'=96
'->'=97
':'=98
';'=99
','=100
'.'=101
'{'=102
'}'=103
'['=104
']'=105
'('=106
')'=107
'<'=108
'>'=109
'&'=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 { ConformanceDeclContext } from "./QuixosCapabilityParser.js";
import { ConformanceItemContext } from "./QuixosCapabilityParser.js";
import { StateFieldBindingDeclContext } from "./QuixosCapabilityParser.js";
import { RelationshipMaterializationDeclContext } from "./QuixosCapabilityParser.js";
import { OperationBindingDeclContext } from "./QuixosCapabilityParser.js";
import { MemberOperationRefContext } from "./QuixosCapabilityParser.js";
@@ -347,6 +348,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor 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`.
* @param ctx the parse tree
+46 -1
View File
@@ -552,6 +552,7 @@ const lowerOperationMember = (state: LoweringState, context: OperationMemberCont
inputType,
outputType,
mode: "call",
...(context.STATIC() ? { scope: "class" as const } : {}),
},
],
};
@@ -702,7 +703,15 @@ const lowerInterfaceTemplate = (
inputType,
outputType,
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
.conformanceItem()
.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();
if (!bindingContext) {
return [];
+14 -10
View File
@@ -142,7 +142,7 @@ export const scaffoldRecipe = async (
if (react) {
create(
"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(
"src/impl/sourceGet.ts",
@@ -180,14 +180,7 @@ export const scaffoldRecipe = async (
bindingOutput: "src/gen/qx.ts",
...(react
? {
options: {
messages: {
"org.quixos.web-studio.ReactProps": {
module: "@quixos/camino-package-runtime",
export: "opaqueReactPropsBinding",
},
},
},
options: { react: { propsExports: [] } },
}
: {}),
}),
@@ -394,9 +387,20 @@ export const scaffoldRecipe = async (
for (const [file, content] of Object.entries(spec.initialFiles)) {
if (
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}`);
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);
if (existing >= 0) files.splice(existing, 1);
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");
const request: StructuralRequest = {
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,
files: [
{
+2
View File
@@ -110,6 +110,8 @@ export interface InterfaceOperation<Type = ValueType> {
inputType: Type;
outputType: Type;
mode: InterfaceOperationMode;
/** Class capabilities have no object receiver and bind free functions. */
scope?: "class";
/** Required for watch-start/subscribe and absent for other modes. */
eventType?: Type;
}
+52 -14
View File
@@ -23,6 +23,7 @@ import type {
OwnedAttachment,
PackageExport,
PackageOperationExport,
PackageFunctionExport,
PackageRevision,
PackageRevisionId,
PersistentAttachment,
@@ -1423,6 +1424,24 @@ const validateConformances = (
}
const operation = operationEntry.operation;
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") {
const attachment = findAttachment(indexes, "state", binding.slotId);
if (!attachment || attachment.attachment.kind !== "state") {
@@ -1562,31 +1581,49 @@ const validateConformances = (
);
continue;
}
if (packageExport.kind !== "operation") {
if (operation.scope === "class" ? packageExport.kind !== "function" : packageExport.kind !== "operation") {
issue(
issues,
"invalid-package-binding",
`${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;
}
if (!signaturesMatch(operation, packageExport)) {
if (
!signaturesMatch(operation, {
...packageExport,
mode: packageExport.kind === "operation" ? packageExport.mode : "call",
})
) {
issue(
issues,
"invalid-package-binding",
`${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, {
entry: packageExport,
atomId: conformance.atomId,
path: `${bindingPath}.binding.exportId`,
indexes,
requirementGraph,
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",
);
}
validatePackageReceiver(issues, {
entry: packageExport,
atomId: conformance.atomId,
path: `${bindingPath}.binding.exportId`,
indexes,
requirementGraph,
graphSourceKey: key,
});
validateBoundDependencies(issues, {
dependencies: binding.dependencies,
dependencyPorts: packageExport.dependencyPorts,
@@ -1919,7 +1956,7 @@ export type ResolvedOperationPlan =
kind: "package";
binding: Extract<Binding, { kind: "package" }>;
packageRevision: PackageRevision;
packageExport: PackageOperationExport;
packageExport: PackageOperationExport | PackageFunctionExport;
dependencies: Array<{
port: DependencyPort;
binding: DependencyBinding;
@@ -1962,7 +1999,8 @@ export const resolveOperationPlan = (
}
const packageRevision = plan.packages.get(binding.packageRevisionId);
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) {
return undefined;
+217 -21
View File
File diff suppressed because one or more lines are too long