import { DtoArrayField, DtoField, DtoMessage, DtoObjectField, ImportBase, ImportRef, isDtoField, isDtoMessage, resolveDtoRefChain } from './dto.js'; import { collectEnumRefs, EnumField, Field } from './dsl.js'; import type { TableSchema } from './db.js'; import type { TokenSchema } from './token.js'; // TypeBox driver: renders a DtoMessage into TypeBox TypeScript source. // Shape matches the codegen product consumed by fastify v5 TypeBoxTypeProvider: // // export const RegisterUserInput = Type.Object({...}); // export type RegisterUserInput = Static; // // ENUM fields reference a generated enum (see enum-driver) by its import // location — the resolver maps a jsName to its product import. // DTO bases (.include()) render as Type.Intersect([...bases, Type.Object({...})]). export type EnumResolver = (enumName: string) => ImportBase | undefined; function renderString(s: string): string { return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; } function renderDefault(v: unknown): string { if (typeof v === 'string') return renderString(v); return JSON.stringify(v); } /** BaseField-level description: description takes priority over label. */ function fieldDescription(field: { description?: string; label?: string }): string | undefined { return field.description ?? field.label; } /** DtoField-level description: own description > referenced field description/label. */ function dtoFieldDescription(f: DtoField): string | undefined { return f.description ?? fieldDescription(f.field); } function renderBasic( field: Field, pattern: string | undefined, defaultValue: unknown, resolver: EnumResolver | undefined, indent = 0, description?: string, ): string { if (pattern !== undefined && field.type !== 'string') { throw new Error(`pattern is only supported on string fields, got ${field.type} (${field.name})`); } const def = defaultValue !== undefined ? `default: ${renderDefault(defaultValue)}` : undefined; const desc = description !== undefined ? `description: ${renderString(description)}` : undefined; const withOpts = (base: string, opts: string[]): string => opts.length > 0 ? `${base}({ ${opts.join(', ')} })` : `${base}()`; switch (field.type) { case 'string': { const opts: string[] = []; if (field.minLength !== undefined) opts.push(`minLength: ${field.minLength}`); if (field.maxLength !== undefined) opts.push(`maxLength: ${field.maxLength}`); if (pattern !== undefined) opts.push(`pattern: ${renderString(pattern)}`); if (def !== undefined) opts.push(def); if (desc !== undefined) opts.push(desc); return withOpts('Type.String', opts); } case 'text': { const opts: string[] = []; if (def !== undefined) opts.push(def); if (desc !== undefined) opts.push(desc); return withOpts('Type.String', opts); } case 'integer': { const opts: string[] = []; if (field.min !== undefined) opts.push(`minimum: ${field.min}`); if (field.max !== undefined) opts.push(`maximum: ${field.max}`); if (def !== undefined) opts.push(def); if (desc !== undefined) opts.push(desc); return withOpts('Type.Integer', opts); } case 'bigint': case 'decimal': case 'rate': case 'time': case 'date': case 'datetime': { // Transmitted as string over HTTP: bigint/decimal/rate keep full // precision, date/time serialize to string. const opts: string[] = []; if (def !== undefined) opts.push(def); if (desc !== undefined) opts.push(desc); return withOpts('Type.String', opts); } case 'boolean': { const opts: string[] = []; if (def !== undefined) opts.push(def); if (desc !== undefined) opts.push(desc); return withOpts('Type.Boolean', opts); } case 'json': { const opts: string[] = []; if (def !== undefined) opts.push(def); if (desc !== undefined) opts.push(desc); return withOpts('Type.Unknown', opts); } case 'enum': { const ref = resolver?.(field.enum.jsName); if (!ref) throw new Error(`enum field ${field.name}: no import ref for ${field.enum.jsName} — pass an EnumResolver`); if (def !== undefined) { const member = field.enum.values.find((v) => v.value === defaultValue); if (!member) { throw new Error( `enum field ${field.name}: default ${renderDefault(defaultValue)} is not a member of ${field.enum.jsName}`, ); } const opts: string[] = []; if (desc !== undefined) opts.push(desc); return `Type.Enum(${ref.name}, { default: ${ref.name}.${member.symbol}${opts.length > 0 ? `, ${opts.join(', ')}` : ''} })`; } if (desc !== undefined) return `Type.Enum(${ref.name}, { ${desc} })`; return `Type.Enum(${ref.name})`; } case 'aggregate': { // Aggregate query outputs: count/sum(int) are numbers, everything else // arrives as a precision string. const opts: string[] = []; if (desc !== undefined) opts.push(desc); return field.jsType === 'number' ? withOpts('Type.Number', opts) : withOpts('Type.String', opts); } case 'array': return desc !== undefined ? `Type.Array(${renderFieldValue(field.items, indent, resolver)}, { ${desc} })` : `Type.Array(${renderFieldValue(field.items, indent, resolver)})`; case 'object': return renderFieldObject(field.properties, indent + 1, resolver, description); default: // Field union is exhaustive; this branch is unreachable at runtime. throw new Error(`unsupported field type: ${String((field as Field).type)}`); } } /** Render a plain Field value (wire-format nested fields), wrapping optional. */ function renderFieldValue(field: Field, indent: number, resolver: EnumResolver | undefined): string { const base = renderBasic(field, undefined, undefined, resolver, indent, fieldDescription(field)); return field.optional ? `Type.Optional(${base})` : base; } /** Render a plain Field object (wire-format nested object). */ function renderFieldObject( properties: Record, indent: number, resolver: EnumResolver | undefined, description?: string, ): string { const pad = ' '.repeat(indent); const entries = Object.entries(properties).map(([name, f]) => `${pad}${name}: ${renderFieldValue(f, indent, resolver)}`); const obj = `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`; return description !== undefined ? `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}}, { description: ${renderString(description)} })` : obj; } function renderObject( fields: Record, indent: number, resolver: EnumResolver | undefined, description?: string, ): string { const pad = ' '.repeat(indent); const entries = Object.entries(fields).map(([name, f]) => { const rendered = isDtoField(f) ? renderField(f, indent, resolver) : renderFieldValue(f, indent, resolver); return `${pad}${name}: ${rendered}`; }); const obj = `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`; return description !== undefined ? `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}}, { description: ${renderString(description)} })` : obj; } function renderField(f: DtoField, indent: number, resolver: EnumResolver | undefined): string { // Ref branch renders its own optional (referencing overrides + chain fallback). if (f.ref !== undefined) return renderValue(f, indent, resolver); const base = renderValue(f, indent, resolver); return f.isOptional() ? `Type.Optional(${base})` : base; } /** Render a ref-carrying field's bare type (no optional wrapper): resolve the * chain, inherit the terminal field's type/constraints, keep the referencing * field's own overrides (pattern / default / description). */ function renderRefBase(f: DtoField, indent: number, resolver: EnumResolver | undefined): string { const target = resolveDtoRefChain(f); const targetField = target.field as Field; const pattern = f.pattern ?? target.pattern; const defaultValue = f.default ?? target.default; const desc = f.description ?? dtoFieldDescription(target); return renderBasic(targetField, pattern, defaultValue, resolver, indent, desc); } /** Render a ref-carrying field with its optional wrapper: referencing * override first, then the chain's DtoField-level optional, then the bare * column optionality. */ function renderRefField(f: DtoField, indent: number, resolver: EnumResolver | undefined): string { const target = resolveDtoRefChain(f); const optional = f.optional ?? target.optional ?? (target.field as Field).optional ?? false; const base = renderRefBase(f, indent, resolver); return optional ? `Type.Optional(${base})` : base; } function renderValue(f: DtoField, indent: number, resolver: EnumResolver | undefined): string { if (f.ref !== undefined) return renderRefField(f, indent, resolver); if (f.field.type === 'array') { const items = f.field.items; const desc = dtoFieldDescription(f); // Referenced DTO element — render by name (same-file export), not expanded. if (isDtoMessage(items)) { return desc !== undefined ? `Type.Array(${items.name}, { description: ${renderString(desc)} })` : `Type.Array(${items.name})`; } if (isDtoField(items)) { const rendered = `Type.Array(${renderField(items, indent + 1, resolver)})`; return desc !== undefined ? `Type.Array(${renderField(items, indent + 1, resolver)}, { description: ${renderString(desc)} })` : rendered; } const rendered = `Type.Array(${renderFieldValue(items, indent, resolver)})`; return desc !== undefined ? `Type.Array(${renderFieldValue(items, indent, resolver)}, { description: ${renderString(desc)} })` : rendered; } if (f.field.type === 'object') { return renderObject(f.field.properties, indent + 1, resolver, dtoFieldDescription(f)); } // DtoField only wraps a database Field; array/object defs live in the subclasses. // Only DTO-level defaults (setDefault) are emitted as TypeBox default // annotations; DB field defaults are not carried into the API contract. return renderBasic(f.field as Field, f.pattern, f.default, resolver, indent, dtoFieldDescription(f)); } function collectEnumImports( f: DtoField, resolver: EnumResolver | undefined, out: Map, ): void { if (f.ref !== undefined) { collectEnumImports(resolveDtoRefChain(f), resolver, out); return; } if (f.field.type === 'array') { const items = f.field.items; if (isDtoMessage(items)) return; if (isDtoField(items)) { collectEnumImports(items, resolver, out); return; } collectFieldEnumImports(items, resolver, out); return; } if (f.field.type === 'object') { for (const child of Object.values(f.field.properties)) { if (isDtoField(child)) collectEnumImports(child, resolver, out); else collectFieldEnumImports(child, resolver, out); } return; } if (f.field.type === 'enum') collectEnumRef(f.field, resolver, out); } /** Enum import collection over a plain Field (wire-format nested fields). */ function collectFieldEnumImports( field: Field, resolver: EnumResolver | undefined, out: Map, ): void { for (const jsName of collectEnumRefs(field)) { const ref = resolver?.(jsName); if (!ref) throw new Error(`enum ${jsName}: no import ref — pass an EnumResolver`); out.set(`${ref.from}#${ref.name}`, ref); } } function collectEnumRef(field: EnumField, resolver: EnumResolver | undefined, out: Map): void { collectFieldEnumImports(field, resolver, out); } /** Collect all imports needed to render a DTO: include() bases + enum references. */ export function collectDtoImports( schema: DtoMessage, resolver: EnumResolver | undefined, out: Map, ): void { for (const base of schema.bases ?? []) out.set(`${base.from}#${base.name}`, base); for (const f of Object.values(schema.fields)) collectEnumImports(f, resolver, out); } /** JSON Schema readOnly annotation on a rendered scalar schema: the token * owns the field, the client must not send it (the __inject adapter * overwrites any client-supplied value anyway). Injection fields are always * scalar columns (tables forbid nested columns), so the only object literal * in a rendered scalar base is its options block. */ function withReadOnly(base: string): string { const idx = base.lastIndexOf('{'); if (idx === -1) return base.replace(/\(\s*\)$/, '({ readOnly: true })'); return `${base.slice(0, idx + 1)} readOnly: true,${base.slice(idx + 1)}`; } /** Render the server-injection base: token-injected fields as Optional * readOnly properties of a TypeBox object, plus a non-enumerable __inject * adapter (same mechanism as hand-written bases, see pylon __inject docs) * that fills each field from the token at runtime. */ function renderInjectBase(fields: Record, resolver: EnumResolver | undefined): string { const entries = Object.entries(fields).map(([name, f]) => { const base = f.ref !== undefined ? renderRefBase(f, 1, resolver) : renderValue(f, 1, resolver); return ` ${name}: Type.Optional(${withReadOnly(base)})`; }); const inner = `Type.Object({\n${entries.join(',\n')}\n})`; const assigns = Object.keys(fields) .map((k) => `body.${k} = token.${k};`) .join(' '); return [ `Object.defineProperty(`, ` ${inner},`, ` '__inject',`, ` { value: (body: Record, token: Record): void => { ${assigns} }, enumerable: false },`, `)`, ].join('\n'); } /** Render one DTO export (const + type) — no file header, for file-level generation. */ export function renderDtoExport(schema: DtoMessage, resolver: EnumResolver | undefined): string { const injectFields: Record = {}; const normalFields: Record = {}; for (const [key, f] of Object.entries(schema.fields)) { if (f.injectFrom !== undefined) injectFields[key] = f; else normalFields[key] = f; } const parts: string[] = []; if (Object.keys(injectFields).length > 0) parts.push(renderInjectBase(injectFields, resolver)); parts.push(renderObject(normalFields, 1, resolver, schema.description)); for (const base of schema.bases ?? []) parts.push(renderBase(base)); const body = parts.length > 1 ? `Type.Intersect([${parts.join(', ')}])` : parts[0]; return `export const ${schema.name} = ${body};`; } /** Render the Static type export for a DTO. */ export function renderDtoTypeExport(name: string): string { return `export type ${name} = Static;`; } /** Render one token export: a flat TypeBox object. Security fields keep * their declared optionality (secret required, cipher optional); identity * fields are Optional except the primary-key anchor — they only exist after * login (two-state object, flat runtime shape), while the PK is guaranteed * to be projected (token validation enforces it) and serves as the identity * anchor (e.g. the tenant key for tenant-scoped controllers). */ export function renderTokenExport(token: TokenSchema, resolver: EnumResolver | undefined): string { const constName = `${token.name}Token`; const entries = [ ...Object.entries(token.security).map(([name, f]) => ` ${name}: ${renderField(f, 1, resolver)}`), ...Object.entries(token.identity).map(([name, f]) => isPrimaryKeyProjection(f) ? ` ${name}: ${renderValue(f, 1, resolver)}` : ` ${name}: Type.Optional(${renderValue(f, 1, resolver)})`, ), ]; const desc = token.description !== undefined ? `, { description: ${renderString(token.description)} }` : ''; return `export const ${constName} = Type.Object({\n${entries.join(',\n')}\n}${desc});`; } /** True when the DTO field projects a primary-key column of its source table. */ function isPrimaryKeyProjection(f: DtoField): boolean { const tbl = f.field.schema; if (tbl?.type !== 'table') return false; // A table-backed field is always a plain Field (only Field carries a table // schema); array/object DTO fields reference messages, not tables. const field = f.field as Field; const table = tbl as TableSchema; const pk = table.primaryKey; if (!pk) return false; const pks = Array.isArray(pk) ? pk : [pk]; return pks.includes(field); } /** Render the Static type export for a token. */ export function renderTokenTypeExport(name: string): string { return `export type ${name}Token = Static;`; } /** Collect all imports needed to render a token: enum references across * security + identity fields. */ export function collectTokenImports( token: TokenSchema, resolver: EnumResolver | undefined, out: Map, ): void { for (const f of [...Object.values(token.security), ...Object.values(token.identity)]) { collectEnumImports(f, resolver, out); } } export function renderDtoMessage( schema: DtoMessage, options: { resolver?: EnumResolver; source?: string } = {}, ): string { const { resolver, source } = options; const imports = new Map(); collectDtoImports(schema, resolver, imports); const header = [ '// AUTO-GENERATED by typebox-driver — DO NOT EDIT', ...(source !== undefined ? [`// Source: ${source}`] : []), "import { Type, Static } from '@sinclair/typebox';", ...[...imports.values()].map((r) => `import${r.type ? ' type' : ''} { ${r.name} } from '${r.from}';`), ]; return [ ...header, '', renderDtoExport(schema, resolver), renderDtoTypeExport(schema.name), '', ].join('\n'); } function renderBase(base: ImportRef): string { if (base.args === undefined || base.args.length === 0) return base.name; const args = base.args.map((a) => (typeof a === 'string' ? a : a.name)); return `${base.name}(${args.join(', ')})`; }