Generate typed React component bindings

This commit is contained in:
Timothy J. Aveni
2026-07-18 10:33:55 -07:00
parent 0e52062631
commit 9f6f36f993
26 changed files with 620 additions and 71 deletions
+5 -5
View File
@@ -74,17 +74,17 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1784127127,
"narHash": "sha256-M36R+j10krhGRrhCozKkV7PsRM9BklQNqWox2bW7t/0=",
"lastModified": 1784395768,
"narHash": "sha256-g03xOnzJHiL/edNV6nqURtaPqiapq2uCm7wQTZh4b5s=",
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"revCount": 22,
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"revCount": 24,
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
},
"original": {
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
}
+1 -1
View File
@@ -4,7 +4,7 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=a9134843bc947546c78b28902d081f451733ddba";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=e6b34535afa6e94a118e1f9d082054855e7df632";
};
outputs = { self, nixpkgs, flake-utils, quixos-protocol, ... }:
+5 -5
View File
@@ -74,17 +74,17 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1784127127,
"narHash": "sha256-M36R+j10krhGRrhCozKkV7PsRM9BklQNqWox2bW7t/0=",
"lastModified": 1784395768,
"narHash": "sha256-g03xOnzJHiL/edNV6nqURtaPqiapq2uCm7wQTZh4b5s=",
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"revCount": 22,
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"revCount": 24,
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
},
"original": {
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
}
+1 -1
View File
@@ -4,7 +4,7 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=a9134843bc947546c78b28902d081f451733ddba";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=e6b34535afa6e94a118e1f9d082054855e7df632";
};
outputs = { self, nixpkgs, flake-utils, quixos-protocol, ... }:
+1
View File
@@ -28,6 +28,7 @@ const todoSchemaPaths = [
"../schemas/quixos.todo.Project/schema.camino.proto",
"../schemas/quixos.js.ESModule/schema.camino.proto",
"../schemas/quixos.react.ReactComponent/schema.camino.proto",
"../schemas/quixos.react.ReactComponentFor/schema.camino.proto",
"../schemas/quixos.todo.TodoReactComponent/schema.camino.proto",
"../schemas/quixos.todo.Task/schema.camino.proto",
"../schemas/quixos.todo.Comment/schema.camino.proto",
File diff suppressed because one or more lines are too long
@@ -321,6 +321,10 @@ const interfaceFieldContractToProto = (
watchable: value.watchable,
typeParam: value.typeParam ?? "",
type: value.type ? typeToProto(value.type) : undefined,
refTargetTypeParam: value.refTargetTypeParam ?? "",
edgeCardinality: value.edgeCardinality
? cardinalityToProto(value.edgeCardinality)
: Cardinality.CARDINALITY_UNSPECIFIED,
})
: undefined;
@@ -187,6 +187,11 @@ const interfaceFieldContractFromOption = (
return undefined;
}
const type = typeRefFromOption(object.type, schemaVersion);
const refTargetTypeParam = readString(object, "ref_target_type_param");
const edgeCardinality =
object.edge_cardinality === undefined
? undefined
: cardinalityFromOption(object.edge_cardinality);
return {
required: readBoolean(object, "required") ?? false,
readable: readBoolean(object, "readable") ?? false,
@@ -196,6 +201,8 @@ const interfaceFieldContractFromOption = (
? { typeParam: readString(object, "type_param") }
: {}),
...(type ? { type } : {}),
...(refTargetTypeParam ? { refTargetTypeParam } : {}),
...(edgeCardinality ? { edgeCardinality } : {}),
};
};
@@ -854,6 +861,9 @@ const interfaceSchemaFromType = (
),
sourceFile,
protoMessage: type.fullName,
typeParameters: asArray(interfaceOption.type_parameter)
.map((entry) => String(entry).trim())
.filter(Boolean),
};
};
+3
View File
@@ -69,6 +69,8 @@ export type InterfaceFieldContract = {
watchable: boolean;
typeParam?: string;
type?: TypeRef;
refTargetTypeParam?: string;
edgeCardinality?: Cardinality;
};
export type InterfaceImplementation = {
@@ -162,6 +164,7 @@ export type InterfaceSchema = {
fields: FieldSchema[];
sourceFile: string;
protoMessage: string;
typeParameters: string[];
};
export type SchemaCompileResult = {
+118 -3
View File
@@ -121,6 +121,38 @@ const boundInterfaceFieldType = (
)?.type;
};
const bindingForTypeParameter = (
implementation: InterfaceImplementation,
name: string,
) => implementation.typeBindings.find((binding) => binding.name === name)?.type;
const edgeEndpointForProjection = (
classSchema: ClassSchema,
fieldName: string,
) => {
const edge = classSchema.edges.find(
(candidate) => candidate.sourceField === fieldName,
);
if (!edge) {
return undefined;
}
if (edge.fromEndpoint.projection === fieldName) {
return {
edge,
endpoint: edge.fromEndpoint,
otherEndpoint: edge.toEndpoint,
};
}
if (edge.toEndpoint.projection === fieldName) {
return {
edge,
endpoint: edge.toEndpoint,
otherEndpoint: edge.fromEndpoint,
};
}
return undefined;
};
const validateInterfaceImplementation = (
classSchema: ClassSchema,
implementation: InterfaceImplementation,
@@ -133,6 +165,23 @@ const validateInterfaceImplementation = (
return [`${classId} implements unknown interface ${interfaceId}`];
}
const errors: string[] = [];
const declaredTypeParameters = new Set(interfaceSchema.typeParameters);
const boundTypeParameters = new Set<string>();
for (const binding of implementation.typeBindings) {
if (!declaredTypeParameters.has(binding.name)) {
errors.push(`${classId}.interface.${interfaceId} binds unknown type parameter ${binding.name}`);
}
if (boundTypeParameters.has(binding.name)) {
errors.push(`${classId}.interface.${interfaceId} binds ${binding.name} more than once`);
}
boundTypeParameters.add(binding.name);
}
for (const typeParameter of declaredTypeParameters) {
if (!boundTypeParameters.has(typeParameter)) {
errors.push(`${classId}.interface.${interfaceId} is missing type binding ${typeParameter}`);
}
}
for (const interfaceField of interfaceSchema.fields) {
const contract = interfaceField.interfaceContract;
@@ -151,12 +200,54 @@ const validateInterfaceImplementation = (
}
const expectedType = boundInterfaceFieldType(implementation, contract);
if (expectedType && typeKey(fieldValueType(classSchema, field)) !== typeKey(expectedType)) {
if (
expectedType &&
!contract.refTargetTypeParam &&
typeKey(fieldValueType(classSchema, field)) !== typeKey(expectedType)
) {
errors.push(
`${fieldLabel} expected ${typeKey(expectedType)}, got ${typeKey(fieldValueType(classSchema, field)) ?? "unknown"}`,
);
}
if (contract.refTargetTypeParam) {
const targetType = bindingForTypeParameter(
implementation,
contract.refTargetTypeParam,
);
const projection = edgeEndpointForProjection(classSchema, field.name);
if (!targetType?.symbol) {
errors.push(
`${fieldLabel} requires ${contract.refTargetTypeParam} to bind a Camino class symbol`,
);
}
if (!projection) {
errors.push(`${fieldLabel} must declare an edge projection`);
} else {
if (
targetType?.symbol &&
symbolKey(projection.otherEndpoint.class ?? { namespace: "", name: "", version: "" }) !==
symbolKey(targetType.symbol)
) {
errors.push(
`${fieldLabel} expected edge target ${symbolKey(targetType.symbol)}, got ${
projection.otherEndpoint.class
? symbolKey(projection.otherEndpoint.class)
: "an interface-constrained endpoint"
}`,
);
}
if (
contract.edgeCardinality &&
projection.endpoint.cardinality !== contract.edgeCardinality
) {
errors.push(
`${fieldLabel} expected ${contract.edgeCardinality} cardinality, got ${projection.endpoint.cardinality}`,
);
}
}
}
if (contract.readable && field.ops && !fieldHasOperation(classSchema, field, "Get")) {
errors.push(`${fieldLabel} must be readable via Get`);
}
@@ -191,11 +282,35 @@ export const validateSchemaInterfaces = (
interfaceSchema,
]),
);
return schema.classes.flatMap((classSchema) =>
const interfaceErrors = schema.interfaces.flatMap((interfaceSchema) => {
const interfaceId = symbolKey(interfaceSchema.id);
const duplicates = interfaceSchema.typeParameters.filter(
(parameter, index, all) => all.indexOf(parameter) !== index,
);
const declared = new Set(interfaceSchema.typeParameters);
const errors = duplicates.map(
(parameter) => `${interfaceId} declares type parameter ${parameter} more than once`,
);
for (const field of interfaceSchema.fields) {
const contract = field.interfaceContract;
for (const parameter of [contract?.typeParam, contract?.refTargetTypeParam]) {
if (parameter && !declared.has(parameter)) {
errors.push(
`${interfaceId}.${field.name} references undeclared type parameter ${parameter}`,
);
}
}
}
return errors;
});
return [
...interfaceErrors,
...schema.classes.flatMap((classSchema) =>
classSchema.implements.flatMap((implementation) =>
validateInterfaceImplementation(classSchema, implementation, interfaces),
),
);
),
];
};
const collectFieldRequirements = (
+30 -4
View File
@@ -40,6 +40,7 @@ const todoSchemaPaths = [
"../schemas/quixos.todo.TodoApp/schema.camino.proto",
"../schemas/quixos.todo.Project/schema.camino.proto",
"../schemas/quixos.react.ReactComponent/schema.camino.proto",
"../schemas/quixos.react.ReactComponentFor/schema.camino.proto",
"../schemas/quixos.todo.TodoReactComponent/schema.camino.proto",
"../schemas/quixos.todo.Task/schema.camino.proto",
"../schemas/quixos.todo.Comment/schema.camino.proto",
@@ -142,6 +143,14 @@ const taskCardSchemaSource = `
proto_type: "string"
}
}
type_binding: {
name: "RenderProps"
type: { proto_type: "string" }
}
type_binding: {
name: "Action"
type: { proto_type: "string" }
}
};
option (camino.method) = {
@@ -270,9 +279,7 @@ test("schema compiler lowers globally named interfaces", async () => {
assert.ok(props);
assert.equal(props.interfaceContract?.typeParam, "Props");
assert.equal(props.interfaceContract?.watchable, true);
const source = react.fields.find((field) => field.name === "source");
assert.ok(source);
assert.equal(source.interfaceContract?.type?.protoType, "string");
assert.deepEqual(react.typeParameters, ["Props", "RenderProps", "Action"]);
});
test("schema validation accepts imported interface implementations", async () => {
@@ -334,7 +341,26 @@ test("schema validation rejects broken interface implementations", async () => {
const compiled = await compileCaminoSchema(sourceFile);
const errors = validateSchemaInterfaces(compiled).join("\n");
assert.match(errors, /ReactComponent:1\.props expected string, got int32/);
assert.match(errors, /ReactComponent:1\.source requires field source/);
assert.match(errors, /missing type binding RenderProps/);
assert.match(errors, /missing type binding Action/);
});
});
test("ReactComponentFor validates its bound edge target and cardinality", async () => {
await withSchemaImportPath(async () => {
const compiled = await compileCaminoSchema(
path.resolve("../schemas/quixos.todo.TodoReactComponent/schema.camino.proto"),
);
assert.deepEqual(validateSchemaInterfaces(compiled), []);
const componentFor = compiled.interfaces.find(
(entry) => entry.id.name === "ReactComponentFor",
);
assert.deepEqual(componentFor?.typeParameters, ["Object"]);
const contract = componentFor?.fields.find(
(field) => field.name === "for_object",
)?.interfaceContract;
assert.equal(contract?.refTargetTypeParam, "Object");
assert.equal(contract?.edgeCardinality, "exactly_one");
});
});
+5 -5
View File
@@ -74,17 +74,17 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1784127127,
"narHash": "sha256-M36R+j10krhGRrhCozKkV7PsRM9BklQNqWox2bW7t/0=",
"lastModified": 1784395768,
"narHash": "sha256-g03xOnzJHiL/edNV6nqURtaPqiapq2uCm7wQTZh4b5s=",
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"revCount": 22,
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"revCount": 24,
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
},
"original": {
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
}
@@ -4,7 +4,7 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=a9134843bc947546c78b28902d081f451733ddba";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=e6b34535afa6e94a118e1f9d082054855e7df632";
quixosNixHelpers = {
url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/heads/exported&rev=1f0c39b01501d646fe97dfc6e5777ccab0bde2f1";
flake = false;
File diff suppressed because one or more lines are too long
@@ -1,7 +1,11 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { compileCaminoSchema } from "./schema-compiler.js";
import protobuf from "protobufjs";
import {
compileCaminoSchema,
loadCaminoSchemaRoot,
} from "./schema-compiler.js";
import type {
ClassSchema,
FieldSchema,
@@ -12,7 +16,7 @@ import type {
const usage = (): never => {
console.error(
"Usage: camino-codegen-ts-runtime <class-schema.camino.proto> <out-dir> [implementation-import]",
"Usage: camino-codegen-ts-runtime [--react-only] <class-schema.camino.proto> <out-dir> [implementation-import]",
);
process.exit(1);
};
@@ -46,11 +50,22 @@ const classId = (schema: ClassSchema) =>
const symbolId = (value: { namespace: string; name: string; version: string; hash?: string }) =>
`${value.namespace}:${value.name}:${value.version}:${value.hash ?? ""}`;
const tsTypeForTypeRef = (type: TypeRef): string => {
const normalizedProtoType = (value: string) => value.replace(/^\./, "");
const declarationName = (value: protobuf.Type | protobuf.Enum) => value.name;
const tsTypeForTypeRef = (
type: TypeRef,
root?: protobuf.Root,
): string => {
if (type.symbol) {
return `ObjectRef<${stringLiteral(symbolId(type.symbol))}>`;
}
if (type.enumValues && type.enumValues.length > 0) {
return type.enumValues.map(stringLiteral).join(" | ");
}
switch (type.protoType.replace(/^\./, "")) {
const protoType = normalizedProtoType(type.protoType);
switch (protoType) {
case "string":
case "google.protobuf.StringValue":
return "string";
@@ -80,19 +95,31 @@ const tsTypeForTypeRef = (type: TypeRef): string => {
case "google.protobuf.BytesValue":
return "Uint8Array";
default:
if (protoType === "google.protobuf.Empty") {
return "Record<string, never>";
}
try {
const reflected = root?.lookup(protoType);
if (reflected instanceof protobuf.Type || reflected instanceof protobuf.Enum) {
return declarationName(reflected);
}
} catch {
// Unknown imported message types remain opaque at this codegen boundary.
}
return "unknown";
}
};
const tsTypeForField = (field: FieldSchema): string => tsTypeForTypeRef(field.type);
const tsTypeForField = (field: FieldSchema, root?: protobuf.Root): string =>
tsTypeForTypeRef(field.type, root);
const tsValueTypeForField = (field: FieldSchema) =>
field.repeated ? `${tsTypeForField(field)}[]` : tsTypeForField(field);
const tsValueTypeForField = (field: FieldSchema, root?: protobuf.Root) =>
field.repeated ? `${tsTypeForField(field, root)}[]` : tsTypeForField(field, root);
const tsFieldApiType = (field: FieldSchema) =>
const tsFieldApiType = (field: FieldSchema, root?: protobuf.Root) =>
field.conflict === "crdt"
? `CrdtField<${tsValueTypeForField(field)}>`
: `Field<${tsValueTypeForField(field)}>`;
? `CrdtField<${tsValueTypeForField(field, root)}>`
: `Field<${tsValueTypeForField(field, root)}>`;
const functionExportName = (fn: FunctionRef, fallback: string) => {
if (isIdentifier(fn.symbol)) {
@@ -107,6 +134,74 @@ const functionExportName = (fn: FunctionRef, fallback: string) => {
const generatedWarning = `// @generated by camino-codegen-ts-runtime. Do not edit.\n`;
const scalarTsType = (field: protobuf.Field, root: protobuf.Root): string => {
const primitive = tsTypeForTypeRef({ protoType: field.type }, root);
if (primitive !== "unknown") {
return primitive;
}
const resolved = field.resolvedType;
if (resolved instanceof protobuf.Type || resolved instanceof protobuf.Enum) {
return declarationName(resolved);
}
return "unknown";
};
const collectProtoDeclarations = (
root: protobuf.Root,
types: TypeRef[],
) => {
const declarations = new Map<string, protobuf.Type | protobuf.Enum>();
const visit = (value: protobuf.Type | protobuf.Enum) => {
const key = value.fullName;
if (declarations.has(key)) {
return;
}
declarations.set(key, value);
if (value instanceof protobuf.Type) {
for (const field of value.fieldsArray) {
if (
field.resolvedType instanceof protobuf.Type ||
field.resolvedType instanceof protobuf.Enum
) {
visit(field.resolvedType);
}
}
}
};
for (const type of types) {
if (type.symbol || !type.protoType) {
continue;
}
try {
const reflected = root.lookup(normalizedProtoType(type.protoType));
if (reflected instanceof protobuf.Type || reflected instanceof protobuf.Enum) {
visit(reflected);
}
} catch {
// Opaque imported types are represented as unknown by the generated API.
}
}
return [...declarations.values()]
.map((value) => {
if (value instanceof protobuf.Enum) {
return `export type ${declarationName(value)} = ${Object.keys(value.values)
.map(stringLiteral)
.join(" | ")};`;
}
const fields = value.fieldsArray.map((field) => {
const fieldType = scalarTsType(field, root);
const valueType = field.map
? `Record<string, ${fieldType}>`
: field.repeated
? `${fieldType}[]`
: fieldType;
return ` ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${valueType};`;
});
return `export type ${declarationName(value)} = {\n${fields.join("\n")}\n};`;
})
.join("\n\n");
};
type FunctionSpec = {
exportName: string;
symbol: string;
@@ -158,10 +253,10 @@ ${bindings}
})
.join("\n\n");
const interfaceMixins = (schema: ClassSchema) =>
const interfaceMixins = (schema: ClassSchema, root: protobuf.Root) =>
schema.implements.map((implementation) => {
const typeArgs = implementation.typeBindings.map((binding) =>
tsTypeForTypeRef(binding.type),
tsTypeForTypeRef(binding.type, root),
);
return `${interfaceTypeName(implementation)}${
typeArgs.length > 0 ? `<${typeArgs.join(", ")}>` : ""
@@ -202,7 +297,7 @@ const operationFunctionSpecs = (schema: ClassSchema): FunctionSpec[] =>
}));
});
const renderRuntime = (schema: ClassSchema) => {
const renderRuntime = (schema: ClassSchema, root: protobuf.Root) => {
const className = schema.id.name;
const objectTypeName = `${className}Object`;
const refTypeName = `${className}Ref`;
@@ -212,7 +307,7 @@ const renderRuntime = (schema: ClassSchema) => {
const createObjectName = `create${className}Object`;
const interfaceRefTypes = renderInterfaceRefTypes(schema);
const refType =
[ `ObjectRef<${stringLiteral(classId(schema))}>`, ...interfaceMixins(schema)]
[ `ObjectRef<${stringLiteral(classId(schema))}>`, ...interfaceMixins(schema, root)]
.join(" & ");
const edgeFields = new Set(schema.edges.map((edge) => edge.sourceField));
const writableFields = schema.fields.filter(
@@ -291,6 +386,8 @@ const renderRuntime = (schema: ClassSchema) => {
serveQuixosPackageRuntime,
} from "@quixos/camino-package-runtime";
${collectProtoDeclarations(root, schema.implements.flatMap((entry) => entry.typeBindings.map((binding) => binding.type)))}
${interfaceRefTypes ? `${interfaceRefTypes}\n\n` : ""}export type ${refTypeName} = ${refType};
export type ${objectTypeName} = {
@@ -298,7 +395,7 @@ export type ${objectTypeName} = {
${writableFields
.map(
(field) =>
` ${camel(field.name)}: ${tsFieldApiType(field)};`,
` ${camel(field.name)}: ${tsFieldApiType(field, root)};`,
)
.join("\n")}
};
@@ -379,6 +476,150 @@ export const ${serveName} = (implementation: ${implementationTypeName}) => {
`;
};
const implementationNamed = (schema: ClassSchema, name: string) =>
schema.implements.find(
(entry) =>
entry.interface.namespace === "quixos.react" &&
entry.interface.name === name,
);
const requiredBinding = (
implementation: InterfaceImplementation,
name: string,
) => {
const binding = implementation.typeBindings.find((entry) => entry.name === name);
if (!binding) {
throw new Error(
`${implementation.interface.name} implementation is missing ${name}`,
);
}
return binding.type;
};
const renderReactClient = (schema: ClassSchema, root: protobuf.Root) => {
const react = implementationNamed(schema, "ReactComponent");
const componentFor = implementationNamed(schema, "ReactComponentFor");
if (!react || !componentFor) {
return undefined;
}
const renderProps = requiredBinding(react, "RenderProps");
const action = requiredBinding(react, "Action");
const object = requiredBinding(componentFor, "Object");
if (!object.symbol) {
throw new Error(`${schema.id.name}.ReactComponentFor.Object must bind a class symbol`);
}
const edge = schema.edges.find((candidate) => candidate.sourceField === "for_object");
if (!edge) {
throw new Error(`${schema.id.name} must declare its ReactComponentFor edge`);
}
const componentEndpoint =
edge.fromEndpoint.projection === "for_object"
? edge.fromEndpoint
: edge.toEndpoint.projection === "for_object"
? edge.toEndpoint
: undefined;
const objectEndpoint =
componentEndpoint === edge.fromEndpoint ? edge.toEndpoint : edge.fromEndpoint;
if (!componentEndpoint || !objectEndpoint.projection) {
throw new Error(`${schema.id.name}.for_object edge has no inverse projection`);
}
if (componentEndpoint.cardinality !== "exactly_one") {
throw new Error(`${schema.id.name}.for_object must have exactly_one cardinality`);
}
if (
!objectEndpoint.materialization ||
symbolId(objectEndpoint.materialization.class) !== symbolId(schema.id)
) {
throw new Error(
`${schema.id.name}.${objectEndpoint.projection} must materialize ${classId(schema)}`,
);
}
const className = schema.id.name;
const componentName = className.endsWith("ReactComponent")
? `${className.slice(0, -"ReactComponent".length)}Component`
: className;
const propsTypeName = `${componentName}Props`;
const renderPropsType = tsTypeForTypeRef(renderProps, root);
const actionType = tsTypeForTypeRef(action, root);
const objectType = tsTypeForTypeRef(object, root);
const declarations = collectProtoDeclarations(root, [renderProps, action]);
return `${generatedWarning}import {
createReactComponentFor,
type ReactComponentHostProps,
type ObjectRef,
} from "@quixos/camino-react-runtime";
${declarations}
export type ${propsTypeName} = {
forObject: ${objectType};
} & ${renderPropsType} & ReactComponentHostProps<${actionType}>;
export const ${componentName} = createReactComponentFor<
${objectType},
${renderPropsType},
${actionType}
>({
componentClassId: ${stringLiteral(classId(schema))},
projection: ${stringLiteral(objectEndpoint.projection)},
});
`;
};
const reactRuntimeDeclaration = `${generatedWarning}declare module "@quixos/camino-react-runtime" {
import type * as AutomergeNamespace from "@automerge/automerge";
export type ObjectRef<ClassId extends string> = string & {
readonly $caminoClass: ClassId;
};
export type LiveFieldProp<T> = {
value: T;
source: {
objectId: string;
fieldName: string;
fieldType?: string;
fieldStorage?: string;
conflictStrategy?: string;
revision?: string | number | bigint;
};
};
export type ReactComponentHostProps<Action> = {
onAction?: (action: Action) => void;
fallback?: unknown;
className?: string;
style?: Record<string, string | number | undefined>;
onError?: (error: Error) => void;
};
export type ReactComponentImplementationProps<CaminoProps, RenderProps, Action> = {
camino: CaminoProps;
render: RenderProps;
dispatch: (action: Action) => void;
};
export const createReactComponentFor: <
ForObject extends ObjectRef<string>,
RenderProps extends object,
Action,
>(config: {
componentClassId: string;
projection: string;
}) => (props: {
forObject: ForObject;
} & RenderProps & ReactComponentHostProps<Action>) => any;
export const h: (...args: any[]) => any;
export const useLiveField: <T>(
field: LiveFieldProp<T>,
) => [T, (value: T) => void | Promise<void>];
export const Automerge: typeof AutomergeNamespace;
}
`;
const renderServer = (schema: ClassSchema, implementationImport: string) => {
const className = schema.id.name;
return `${generatedWarning}import * as implementation from ${stringLiteral(implementationImport)};
@@ -389,10 +630,13 @@ serve${className}Runtime(implementation);
};
const main = async () => {
const sourceFile = required(process.argv[2]);
const outDir = required(process.argv[3]);
const implementationImport = process.argv[4] ?? "../task.impl.js";
const reactOnly = process.argv[2] === "--react-only";
const offset = reactOnly ? 1 : 0;
const sourceFile = required(process.argv[2 + offset]);
const outDir = required(process.argv[3 + offset]);
const implementationImport = process.argv[4 + offset] ?? "../task.impl.js";
const compiled = await compileCaminoSchema(sourceFile);
const { root } = await loadCaminoSchemaRoot(sourceFile);
if (compiled.classes.length !== 1) {
throw new Error(
`Expected exactly one Camino class in ${sourceFile}, found ${compiled.classes.length}`,
@@ -404,11 +648,26 @@ const main = async () => {
}
fs.mkdirSync(outDir, { recursive: true });
const baseName = `${schema.id.namespace}.${schema.id.name}`;
fs.writeFileSync(path.join(outDir, `${baseName}.runtime.ts`), renderRuntime(schema));
const reactClient = renderReactClient(schema, root);
if (!reactOnly) {
fs.writeFileSync(
path.join(outDir, `${baseName}.runtime.ts`),
renderRuntime(schema, root),
);
fs.writeFileSync(
path.join(outDir, `${baseName}.server.ts`),
renderServer(schema, implementationImport),
);
}
if (reactClient) {
fs.writeFileSync(path.join(outDir, `${baseName}.react.ts`), reactClient);
fs.writeFileSync(
path.join(outDir, "camino-react-runtime.d.ts"),
reactRuntimeDeclaration,
);
} else if (reactOnly) {
throw new Error(`${schema.id.name} does not implement ReactComponentFor`);
}
};
await main();
@@ -187,6 +187,11 @@ const interfaceFieldContractFromOption = (
return undefined;
}
const type = typeRefFromOption(object.type, schemaVersion);
const refTargetTypeParam = readString(object, "ref_target_type_param");
const edgeCardinality =
object.edge_cardinality === undefined
? undefined
: cardinalityFromOption(object.edge_cardinality);
return {
required: readBoolean(object, "required") ?? false,
readable: readBoolean(object, "readable") ?? false,
@@ -196,6 +201,8 @@ const interfaceFieldContractFromOption = (
? { typeParam: readString(object, "type_param") }
: {}),
...(type ? { type } : {}),
...(refTargetTypeParam ? { refTargetTypeParam } : {}),
...(edgeCardinality ? { edgeCardinality } : {}),
};
};
@@ -854,6 +861,9 @@ const interfaceSchemaFromType = (
),
sourceFile,
protoMessage: type.fullName,
typeParameters: asArray(interfaceOption.type_parameter)
.map((entry) => String(entry).trim())
.filter(Boolean),
};
};
@@ -884,11 +894,10 @@ const walkServices = (
}
};
export const compileCaminoSchema = async (
export const loadCaminoSchemaRoot = async (
sourceFile: string,
): Promise<SchemaCompileResult> => {
): Promise<{ absoluteSourceFile: string; root: protobuf.Root }> => {
const absoluteSourceFile = path.resolve(sourceFile);
const defaults = extractFileDefaults(absoluteSourceFile);
const root = new protobuf.Root();
const envIncludeDirs = [
process.env.QUIXOS_PROTO_PATH,
@@ -923,6 +932,15 @@ export const compileCaminoSchema = async (
await root.load(absoluteSourceFile, { keepCase: true });
root.resolveAll();
return { absoluteSourceFile, root };
};
export const compileCaminoSchema = async (
sourceFile: string,
): Promise<SchemaCompileResult> => {
const { absoluteSourceFile, root } = await loadCaminoSchemaRoot(sourceFile);
const defaults = extractFileDefaults(absoluteSourceFile);
const operationServices: OperationServiceSchema[] = [];
walkServices(root, (service) => {
operationServices.push(
@@ -69,6 +69,8 @@ export type InterfaceFieldContract = {
watchable: boolean;
typeParam?: string;
type?: TypeRef;
refTargetTypeParam?: string;
edgeCardinality?: Cardinality;
};
export type InterfaceImplementation = {
@@ -162,6 +164,7 @@ export type InterfaceSchema = {
fields: FieldSchema[];
sourceFile: string;
protoMessage: string;
typeParameters: string[];
};
export type SchemaCompileResult = {
+5 -5
View File
@@ -154,17 +154,17 @@
"nixpkgs": "nixpkgs_3"
},
"locked": {
"lastModified": 1784127127,
"narHash": "sha256-M36R+j10krhGRrhCozKkV7PsRM9BklQNqWox2bW7t/0=",
"lastModified": 1784395768,
"narHash": "sha256-g03xOnzJHiL/edNV6nqURtaPqiapq2uCm7wQTZh4b5s=",
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"revCount": 22,
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"revCount": 24,
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
},
"original": {
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
}
@@ -10,7 +10,7 @@
inputs.quixos-protocol.follows = "quixos-protocol";
inputs.quixosNixHelpers.follows = "quixosNixHelpers";
};
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=a9134843bc947546c78b28902d081f451733ddba";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=e6b34535afa6e94a118e1f9d082054855e7df632";
quixosNixHelpers = {
url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/heads/exported&rev=1f0c39b01501d646fe97dfc6e5777ccab0bde2f1";
flake = false;
+5 -5
View File
@@ -135,17 +135,17 @@
"nixpkgs": "nixpkgs_3"
},
"locked": {
"lastModified": 1784127127,
"narHash": "sha256-M36R+j10krhGRrhCozKkV7PsRM9BklQNqWox2bW7t/0=",
"lastModified": 1784395768,
"narHash": "sha256-g03xOnzJHiL/edNV6nqURtaPqiapq2uCm7wQTZh4b5s=",
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"revCount": 22,
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"revCount": 24,
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
},
"original": {
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
}
@@ -10,7 +10,7 @@
inputs.quixos-protocol.follows = "quixos-protocol";
inputs.quixosNixHelpers.follows = "quixosNixHelpers";
};
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=a9134843bc947546c78b28902d081f451733ddba";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=e6b34535afa6e94a118e1f9d082054855e7df632";
quixosNixHelpers = {
url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/heads/exported&rev=1f0c39b01501d646fe97dfc6e5777ccab0bde2f1";
flake = false;
+5 -5
View File
@@ -135,17 +135,17 @@
"nixpkgs": "nixpkgs_3"
},
"locked": {
"lastModified": 1784127127,
"narHash": "sha256-M36R+j10krhGRrhCozKkV7PsRM9BklQNqWox2bW7t/0=",
"lastModified": 1784395768,
"narHash": "sha256-g03xOnzJHiL/edNV6nqURtaPqiapq2uCm7wQTZh4b5s=",
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"revCount": 22,
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"revCount": 24,
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
},
"original": {
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
}
@@ -10,7 +10,7 @@
inputs.quixos-protocol.follows = "quixos-protocol";
inputs.quixosNixHelpers.follows = "quixosNixHelpers";
};
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=a9134843bc947546c78b28902d081f451733ddba";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=e6b34535afa6e94a118e1f9d082054855e7df632";
quixosNixHelpers = {
url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/heads/exported&rev=1f0c39b01501d646fe97dfc6e5777ccab0bde2f1";
flake = false;
@@ -16,6 +16,9 @@ message ReactComponent {
name: "ReactComponent"
version: "1"
}
type_parameter: "Props"
type_parameter: "RenderProps"
type_parameter: "Action"
};
camino.CustomField props = 1 [
@@ -0,0 +1,32 @@
syntax = "proto3";
package quixos.react;
import "camino/options.proto";
import "camino/schema.proto";
option (camino.schema_namespace) = "quixos.react";
option (camino.schema_version) = "1";
// Associates a component object with exactly one domain object. The inverse
// projection and its materialization policy remain concrete-schema concerns.
message ReactComponentFor {
option (camino.interface) = {
version: 1
id: {
namespace: "quixos.react"
name: "ReactComponentFor"
version: "1"
}
type_parameter: "Object"
};
camino.Ref for_object = 1 [
(camino.interface_field) = {
required: true
readable: true
ref_target_type_param: "Object"
edge_cardinality: EXACTLY_ONE
}
];
}
@@ -8,6 +8,7 @@ import "camino/options.proto";
import "camino/schema.proto";
import "quixos.js.ESModule/schema.camino.proto";
import "quixos.react.ReactComponent/schema.camino.proto";
import "quixos.react.ReactComponentFor/schema.camino.proto";
option (camino.schema_namespace) = "quixos.todo";
option (camino.schema_version) = "1";
@@ -20,6 +21,20 @@ message TodoReactComponentProps {
string display_label = 5;
}
message TodoReactComponentRenderProps {
optional bool selected = 1;
optional bool compact = 2;
}
enum TodoReactComponentActionType {
TODO_REACT_COMPONENT_ACTION_TYPE_UNSPECIFIED = 0;
SELECT = 1;
}
message TodoReactComponentAction {
TodoReactComponentActionType type = 1;
}
message TodoReactComponent {
option (camino.class) = {
version: 1
@@ -42,6 +57,36 @@ message TodoReactComponent {
proto_type: "quixos.todo.TodoReactComponentProps"
}
}
type_binding: {
name: "RenderProps"
type: {
proto_type: "quixos.todo.TodoReactComponentRenderProps"
}
}
type_binding: {
name: "Action"
type: {
proto_type: "quixos.todo.TodoReactComponentAction"
}
}
};
option (camino.implements) = {
interface: {
namespace: "quixos.react"
name: "ReactComponentFor"
version: "1"
}
type_binding: {
name: "Object"
type: {
symbol: {
namespace: "quixos.todo"
name: "Task"
version: "1"
}
}
}
};
option (camino.implements) = {