#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { compileCaminoSchema } from "./schema-compiler.js"; const usage = () => { console.error("Usage: camino-codegen-ts-runtime [implementation-import]"); process.exit(1); }; const required = (value) => value ?? usage(); const stringLiteral = (value) => JSON.stringify(value); const upperFirst = (value) => value.length === 0 ? value : `${value[0]?.toUpperCase()}${value.slice(1)}`; const lowerFirst = (value) => value.length === 0 ? value : `${value[0]?.toLowerCase()}${value.slice(1)}`; const camel = (value) => value .split(/[^a-zA-Z0-9]+/) .filter(Boolean) .map((part, index) => index === 0 ? lowerFirst(part) : upperFirst(lowerFirst(part))) .join(""); 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) => { if (type.enumValues && type.enumValues.length > 0) { return type.enumValues.map(stringLiteral).join(" | "); } switch (type.protoType.replace(/^\./, "")) { case "string": case "google.protobuf.StringValue": return "string"; case "bool": case "google.protobuf.BoolValue": return "boolean"; case "double": case "float": case "google.protobuf.DoubleValue": case "google.protobuf.FloatValue": case "int32": case "sint32": case "sfixed32": case "uint32": case "fixed32": case "int64": case "sint64": case "sfixed64": case "uint64": case "fixed64": case "google.protobuf.Int32Value": case "google.protobuf.UInt32Value": case "google.protobuf.Int64Value": case "google.protobuf.UInt64Value": return "number"; case "bytes": case "google.protobuf.BytesValue": return "Uint8Array"; default: 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 functionExportName = (fn, fallback) => { if (isIdentifier(fn.symbol)) { return fn.symbol; } const fallbackName = camel(fallback); if (isIdentifier(fallbackName)) { return fallbackName; } 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 fieldTypeId = (field) => field.type.symbol ? symbolId(field.type.symbol) : field.type.protoType; const crdtStorageTypeId = (field) => field.type.protoType || (field.type.symbol ? `${field.type.symbol.namespace}.${field.type.symbol.name}` : fieldTypeId(field)); const fieldStorageId = (field) => field.ops && field.storage.kind === "stored" ? "derived" : field.storage.kind; const interfaceTypeName = (implementation) => `${implementation.interface.name}Ref`; const renderInterfaceRefTypes = (schema) => schema.implements .map((implementation) => { const typeParams = implementation.typeBindings.map((binding) => binding.name); const generic = typeParams.length > 0 ? `<${typeParams.join(", ")}>` : ""; const bindings = typeParams.length > 0 ? typeParams .map((param) => ` readonly ${param}: ${param};`) .join("\n") : " readonly value: unknown;"; return `export type ${interfaceTypeName(implementation)}${generic} = ObjectRef & { readonly $caminoInterfaces: { readonly ${stringLiteral(symbolId(implementation.interface))}: { ${bindings} }; }; };`; }) .join("\n\n"); const interfaceMixins = (schema) => schema.implements.map((implementation) => { const typeArgs = implementation.typeBindings.map((binding) => tsTypeForTypeRef(binding.type)); return `${interfaceTypeName(implementation)}${typeArgs.length > 0 ? `<${typeArgs.join(", ")}>` : ""}`; }); const operationFunctionSpecs = (schema) => schema.fields.flatMap((field) => { const serviceName = field.ops?.service.replace(/^\./, ""); if (!serviceName) { return []; } const service = schema.operationServices.find((candidate) => candidate.fullName === serviceName); if (!service) { throw new Error(`${schema.id.name}.${field.name} points to missing operation service ${field.ops?.service}`); } return service.operations.map((operation) => ({ exportName: functionExportName(operation.function, `${field.name}_${operation.name}`), symbol: operation.function.symbol, ...(operation.function.operation ? { operation: operation.function.operation } : {}), packageNamespace: operation.function.packageNamespace, packageName: operation.function.packageName, field: { name: field.name, type: fieldTypeId(field), storage: fieldStorageId(field), conflict: field.conflict, }, })); }); const renderRuntime = (schema) => { const className = schema.id.name; const objectTypeName = `${className}Object`; const refTypeName = `${className}Ref`; const contextTypeName = `${className}Context`; const implementationTypeName = `${className}Implementation`; const serveName = `serve${className}Runtime`; const createObjectName = `create${className}Object`; const interfaceRefTypes = renderInterfaceRefTypes(schema); const refType = [`ObjectRef<${stringLiteral(classId(schema))}>`, ...interfaceMixins(schema)] .join(" & "); const edgeFields = new Set(schema.edges.map((edge) => edge.sourceField)); const writableFields = schema.fields.filter((field) => field.storage.kind === "stored" && !field.ops && !edgeFields.has(field.name)); const functionSpecs = [ ...(schema.constructorSpec ? [ { exportName: functionExportName(schema.constructorSpec.function, "constructor"), symbol: schema.constructorSpec.function.symbol, ...(schema.constructorSpec.function.operation ? { operation: schema.constructorSpec.function.operation } : {}), packageNamespace: schema.constructorSpec.function.packageNamespace, packageName: schema.constructorSpec.function.packageName, }, ] : []), ...schema.methods.map((method) => ({ exportName: functionExportName(method.function, method.name), symbol: method.function.symbol, ...(method.function.operation ? { operation: method.function.operation } : {}), packageNamespace: method.function.packageNamespace, packageName: method.function.packageName, })), ...schema.fields.flatMap((field) => field.storage.resolver ? [ { exportName: functionExportName(field.storage.resolver, field.name), symbol: field.storage.resolver.symbol, ...(field.storage.resolver.operation ? { operation: field.storage.resolver.operation } : {}), packageNamespace: field.storage.resolver.packageNamespace, packageName: field.storage.resolver.packageName, }, ] : []), ...operationFunctionSpecs(schema), ]; const firstFunction = functionSpecs[0]; if (!firstFunction) { throw new Error(`${schema.id.name} has no runtime functions to generate`); } const mixedPackage = functionSpecs.find((spec) => spec.packageNamespace !== firstFunction.packageNamespace || spec.packageName !== firstFunction.packageName); if (mixedPackage) { throw new Error(`${schema.id.name} points to multiple packages; v0 TS runtime generation expects one package`); } return `${generatedWarning}import { type CaminoClient, createCrdtField, createField, type CrdtField, type Field, type FieldOperation, type InvokeRequest, objectRef, type ObjectRef, type RuntimeHandler, serveQuixosPackageRuntime, } from "@quixos/camino-package-runtime"; ${interfaceRefTypes ? `${interfaceRefTypes}\n\n` : ""}export type ${refTypeName} = ${refType}; export type ${objectTypeName} = { id: ${refTypeName}; ${writableFields .map((field) => ` ${camel(field.name)}: ${tsFieldApiType(field)};`) .join("\n")} }; export type ${contextTypeName} = { object: ${objectTypeName}; camino: CaminoClient; request: InvokeRequest; }; export type ${implementationTypeName} = { ${functionSpecs .map((spec) => spec.operation ? ` ${spec.exportName}: FieldOperation<${contextTypeName}>;` : ` ${spec.exportName}: RuntimeHandler<${contextTypeName}>;`) .filter((line, index, lines) => lines.indexOf(line) === index) .join("\n")} }; const packageNamespace = ${stringLiteral(firstFunction.packageNamespace)}; const packageName = ${stringLiteral(firstFunction.packageName)}; const runtimeProtocolVersion = ${stringLiteral("camino-orch-v0")}; const functions = [ ${functionSpecs .map((spec) => ` { exportName: ${stringLiteral(spec.exportName)}, symbol: ${stringLiteral(spec.symbol)}${spec.operation ? `, operation: ${stringLiteral(spec.operation)}` : ""}${spec.field ? `, field: { name: ${stringLiteral(spec.field.name)}, type: ${stringLiteral(spec.field.type)}, storage: ${stringLiteral(spec.field.storage)}, conflict: ${stringLiteral(spec.field.conflict)} }` : ""} },`) .join("\n")} ] as const; export const ${createObjectName} = ( camino: CaminoClient, objectId: string, ): ${objectTypeName} => { const id = objectRef<${stringLiteral(classId(schema))}>(objectId) as ${refTypeName}; return { id, ${writableFields .map((fieldSchema) => fieldSchema.conflict === "crdt" ? ` ${camel(fieldSchema.name)}: createCrdtField(camino, id, ${stringLiteral(fieldSchema.name)}, ${stringLiteral(crdtStorageTypeId(fieldSchema))}),` : ` ${camel(fieldSchema.name)}: createField(camino, id, ${stringLiteral(fieldSchema.name)}),`) .join("\n")} }; }; export const create${className}Context = ( camino: CaminoClient, request: InvokeRequest, ): ${contextTypeName} => ({ object: ${createObjectName}(camino, request.objectId), camino, request, }); export const ${serveName} = (implementation: ${implementationTypeName}) => { serveQuixosPackageRuntime({ packageNamespace, packageName, runtimeProtocolVersion, functions, implementation, createContext: create${className}Context, }); }; `; }; const renderServer = (schema, implementationImport) => { const className = schema.id.name; return `${generatedWarning}import * as implementation from ${stringLiteral(implementationImport)}; import { serve${className}Runtime } from "./${schema.id.namespace}.${className}.runtime.js"; 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 compiled = await compileCaminoSchema(sourceFile); if (compiled.classes.length !== 1) { throw new Error(`Expected exactly one Camino class in ${sourceFile}, found ${compiled.classes.length}`); } const schema = compiled.classes[0]; if (!schema) { throw new Error(`No Camino class found in ${sourceFile}`); } 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)); }; await main();