Publish updated Camino codegen artifacts
This commit is contained in:
Vendored
+238
-19
@@ -1,9 +1,10 @@
|
||||
#!/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";
|
||||
const usage = () => {
|
||||
console.error("Usage: camino-codegen-ts-runtime <class-schema.camino.proto> <out-dir> [implementation-import]");
|
||||
console.error("Usage: camino-codegen-ts-runtime [--react-only] <class-schema.camino.proto> <out-dir> [implementation-import]");
|
||||
process.exit(1);
|
||||
};
|
||||
const required = (value) => value ?? usage();
|
||||
@@ -18,11 +19,17 @@ const camel = (value) => value
|
||||
const isIdentifier = (value) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
|
||||
const classId = (schema) => `${schema.id.namespace}:${schema.id.name}:${schema.id.version}:${schema.id.hash ?? ""}`;
|
||||
const symbolId = (value) => `${value.namespace}:${value.name}:${value.version}:${value.hash ?? ""}`;
|
||||
const tsTypeForTypeRef = (type) => {
|
||||
const normalizedProtoType = (value) => value.replace(/^\./, "");
|
||||
const declarationName = (value) => value.name;
|
||||
const tsTypeForTypeRef = (type, root) => {
|
||||
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";
|
||||
@@ -52,14 +59,26 @@ const tsTypeForTypeRef = (type) => {
|
||||
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) => tsTypeForTypeRef(field.type);
|
||||
const tsValueTypeForField = (field) => field.repeated ? `${tsTypeForField(field)}[]` : tsTypeForField(field);
|
||||
const tsFieldApiType = (field) => field.conflict === "crdt"
|
||||
? `CrdtField<${tsValueTypeForField(field)}>`
|
||||
: `Field<${tsValueTypeForField(field)}>`;
|
||||
const tsTypeForField = (field, root) => tsTypeForTypeRef(field.type, root);
|
||||
const tsValueTypeForField = (field, root) => field.repeated ? `${tsTypeForField(field, root)}[]` : tsTypeForField(field, root);
|
||||
const tsFieldApiType = (field, root) => field.conflict === "crdt"
|
||||
? `CrdtField<${tsValueTypeForField(field, root)}>`
|
||||
: `Field<${tsValueTypeForField(field, root)}>`;
|
||||
const functionExportName = (fn, fallback) => {
|
||||
if (isIdentifier(fn.symbol)) {
|
||||
return fn.symbol;
|
||||
@@ -71,6 +90,68 @@ const functionExportName = (fn, fallback) => {
|
||||
throw new Error(`Cannot generate TS export name for function ${fn.symbol}`);
|
||||
};
|
||||
const generatedWarning = `// @generated by camino-codegen-ts-runtime. Do not edit.\n`;
|
||||
const scalarTsType = (field, root) => {
|
||||
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, types) => {
|
||||
const declarations = new Map();
|
||||
const visit = (value) => {
|
||||
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");
|
||||
};
|
||||
const fieldTypeId = (field) => field.type.symbol ? symbolId(field.type.symbol) : field.type.protoType;
|
||||
const crdtStorageTypeId = (field) => field.type.protoType ||
|
||||
(field.type.symbol
|
||||
@@ -96,8 +177,8 @@ ${bindings}
|
||||
};`;
|
||||
})
|
||||
.join("\n\n");
|
||||
const interfaceMixins = (schema) => schema.implements.map((implementation) => {
|
||||
const typeArgs = implementation.typeBindings.map((binding) => tsTypeForTypeRef(binding.type));
|
||||
const interfaceMixins = (schema, root) => schema.implements.map((implementation) => {
|
||||
const typeArgs = implementation.typeBindings.map((binding) => tsTypeForTypeRef(binding.type, root));
|
||||
return `${interfaceTypeName(implementation)}${typeArgs.length > 0 ? `<${typeArgs.join(", ")}>` : ""}`;
|
||||
});
|
||||
const operationFunctionSpecs = (schema) => schema.fields.flatMap((field) => {
|
||||
@@ -125,7 +206,7 @@ const operationFunctionSpecs = (schema) => schema.fields.flatMap((field) => {
|
||||
},
|
||||
}));
|
||||
});
|
||||
const renderRuntime = (schema) => {
|
||||
const renderRuntime = (schema, root) => {
|
||||
const className = schema.id.name;
|
||||
const objectTypeName = `${className}Object`;
|
||||
const refTypeName = `${className}Ref`;
|
||||
@@ -134,7 +215,7 @@ const renderRuntime = (schema) => {
|
||||
const serveName = `serve${className}Runtime`;
|
||||
const createObjectName = `create${className}Object`;
|
||||
const interfaceRefTypes = renderInterfaceRefTypes(schema);
|
||||
const refType = [`ObjectRef<${stringLiteral(classId(schema))}>`, ...interfaceMixins(schema)]
|
||||
const refType = [`ObjectRef<${stringLiteral(classId(schema))}>`, ...interfaceMixins(schema, root)]
|
||||
.join(" & ");
|
||||
const edgeFields = new Set(schema.edges.map((edge) => edge.sourceField));
|
||||
const writableFields = schema.fields.filter((field) => field.storage.kind === "stored" &&
|
||||
@@ -199,12 +280,14 @@ const renderRuntime = (schema) => {
|
||||
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} = {
|
||||
id: ${refTypeName};
|
||||
${writableFields
|
||||
.map((field) => ` ${camel(field.name)}: ${tsFieldApiType(field)};`)
|
||||
.map((field) => ` ${camel(field.name)}: ${tsFieldApiType(field, root)};`)
|
||||
.join("\n")}
|
||||
};
|
||||
|
||||
@@ -271,6 +354,129 @@ export const ${serveName} = (implementation: ${implementationTypeName}) => {
|
||||
};
|
||||
`;
|
||||
};
|
||||
const implementationNamed = (schema, name) => schema.implements.find((entry) => entry.interface.namespace === "quixos.react" &&
|
||||
entry.interface.name === name);
|
||||
const requiredBinding = (implementation, name) => {
|
||||
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, 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, implementationImport) => {
|
||||
const className = schema.id.name;
|
||||
return `${generatedWarning}import * as implementation from ${stringLiteral(implementationImport)};
|
||||
@@ -280,10 +486,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}`);
|
||||
}
|
||||
@@ -293,7 +502,17 @@ 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));
|
||||
fs.writeFileSync(path.join(outDir, `${baseName}.server.ts`), renderServer(schema, implementationImport));
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user