import type { CollectionSchemaBase, Field, SchemaBase } from './dsl.js'; import { assertNoInlineContainers } from './dto.js'; import type { DtoField } from './dto.js'; import type { ExceptionSchema } from './exception.js'; import type { FrontAppSchema, ProjectApiSchema } from './project.js'; // Base utility modules — business-agnostic helpers with full method // signatures (e.g. DateTimeUtils.format). Called at the service layer; // their args/results are own wire types, independent of business schemas. // A utils whose name matches a table name is a domain rule module: its // methods may wrap table columns via dtoField(table.columns.x) — the DtoField // wrapper owns the write-back, so the shared column instance is never // mutated (same rule as buildMessage: only inline fields get their // underlying Field written back). /** A utility method with a full signature. */ export interface UtilsMethodSchema extends SchemaBase { type: 'utilsMethod'; /** The utility module this method belongs to. */ schema: UtilsSchema; /** Input fields — DtoField wrappers (dtoField(...)); may wrap table columns. */ args: Record; /** Output field — a plain inline Field (boolean for checks, decimal for * computed amounts, ...). The method output is a fresh value, never a * shared table column. Omit for void methods (pure actions). */ result?: Field; /** Exceptions this method may throw — the failure contract of a defense * guard (assert/validate/ensure: void, throws internally). Flows route * invoked guards' throws into their escape set automatically, so a * guard's throws must be declared by the calling service method (or * caught in a TRY). Predicates (can/is/has, boolean) do not throw. */ throws?: ExceptionSchema[]; } /** Method input for defineUtils: type/schema/name are set by the builder. */ export type UtilsMethodDef = Omit; /** A base utility module (e.g. DateTimeUtils). */ export interface UtilsSchema extends CollectionSchemaBase { type: 'utils'; /** Backend binding — the api module this utils belongs to (shared instance * from project.config.ts apis). With `app` it serves that frontend * ({api}/{app}/utils/); alone it is the api's public module * ({api}/common/utils/). Unset = not backend-side. */ api?: ProjectApiSchema; /** Optional binding — the frontend app this utils serves (shared instance * from project.config.ts apps). Empty means a shared public module. */ app?: FrontAppSchema; /** Methods keyed by name — the map key is written back as the method name. */ methods: Record; } export function defineUtils(options: { name: string; api?: ProjectApiSchema; app?: FrontAppSchema; methods: Record; description?: string; }): UtilsSchema { if (options.api !== undefined && options.app !== undefined && !options.api.apps.includes(options.app)) { throw new Error(`utils ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`); } const schema: UtilsSchema = { type: 'utils', name: options.name, description: options.description, api: options.api, app: options.app, methods: {}, }; for (const key of Object.keys(options.methods)) { const method = options.methods[key] as UtilsMethodDef; // Same rule as DTO fields: args must not nest inline containers — use a // DTO field reference (args: { items: SubmitRequest.fields.items }). assertNoInlineContainers(options.name, method.args); const methodSchema: UtilsMethodSchema = { type: 'utilsMethod', name: key, description: method.description, schema, args: method.args, result: method.result, throws: method.throws, }; // Args: write back on the DtoField wrapper only (safe: wrappers are // created per method via dtoField(), never shared). Shared instances — // DTO fields passed by reference (args: { items: OrderSubmitRequest.fields.items }) // and fields wrapping table columns (domain rules) — stay untouched: the // DTO owns its field name/schema, the shared column instance keeps its // table identity. for (const argKey of Object.keys(methodSchema.args)) { const df = methodSchema.args[argKey] as DtoField; if (df.schema !== undefined) continue; df.name = argKey; df.schema = schema; if (df.field.schema === undefined) { df.field.name = argKey; df.field.schema = schema; } } // Result (optional): a plain inline Field — write name/schema back only // when it is unowned (schema === undefined); shared instances (table // columns, fields already claimed by another container) stay untouched. if (methodSchema.result !== undefined && methodSchema.result.schema === undefined) { methodSchema.result.name = key; methodSchema.result.schema = schema; } schema.methods[key] = methodSchema; } return schema; }