@pylonts/dsl 1.1.15 → 1.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,235 +1,426 @@
1
- import { DtoArrayField, DtoField, DtoMessage, DtoObjectField, ImportBase, ImportRef, isDtoField, isDtoMessage } from './dto.js';
2
- import { collectEnumRefs, EnumField, Field } from './dsl.js';
3
-
4
- // TypeBox driver: renders a DtoMessage into TypeBox TypeScript source.
5
- // Shape matches the codegen product consumed by fastify v5 TypeBoxTypeProvider:
6
- //
7
- // export const RegisterUserInput = Type.Object({...});
8
- // export type RegisterUserInput = Static<typeof RegisterUserInput>;
9
- //
10
- // ENUM fields reference a generated enum (see enum-driver) by its import
11
- // location — the resolver maps a jsName to its product import.
12
- // DTO bases (.include()) render as Type.Intersect([...bases, Type.Object({...})]).
13
-
14
- export type EnumResolver = (enumName: string) => ImportBase | undefined;
15
-
16
- function renderString(s: string): string {
17
- return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
18
- }
19
-
20
- function renderDefault(v: unknown): string {
21
- if (typeof v === 'string') return renderString(v);
22
- return JSON.stringify(v);
23
- }
24
-
25
- function renderBasic(
26
- field: Field,
27
- pattern: string | undefined,
28
- defaultValue: unknown,
29
- resolver: EnumResolver | undefined,
30
- indent = 0,
31
- ): string {
32
- if (pattern !== undefined && field.type !== 'string') {
33
- throw new Error(`pattern is only supported on string fields, got ${field.type} (${field.name})`);
34
- }
35
- const def = defaultValue !== undefined ? `default: ${renderDefault(defaultValue)}` : undefined;
36
- switch (field.type) {
37
- case 'string': {
38
- const opts: string[] = [];
39
- if (field.minLength !== undefined) opts.push(`minLength: ${field.minLength}`);
40
- if (field.maxLength !== undefined) opts.push(`maxLength: ${field.maxLength}`);
41
- if (pattern !== undefined) opts.push(`pattern: ${renderString(pattern)}`);
42
- if (def !== undefined) opts.push(def);
43
- return opts.length > 0 ? `Type.String({ ${opts.join(', ')} })` : 'Type.String()';
44
- }
45
- case 'text':
46
- return def !== undefined ? `Type.String({ ${def} })` : 'Type.String()';
47
- case 'integer': {
48
- const opts: string[] = [];
49
- if (field.min !== undefined) opts.push(`minimum: ${field.min}`);
50
- if (field.max !== undefined) opts.push(`maximum: ${field.max}`);
51
- if (def !== undefined) opts.push(def);
52
- return opts.length > 0 ? `Type.Integer({ ${opts.join(', ')} })` : 'Type.Integer()';
53
- }
54
- case 'bigint':
55
- case 'decimal':
56
- case 'rate':
57
- case 'time':
58
- case 'date':
59
- case 'datetime':
60
- // Transmitted as string over HTTP: bigint/decimal/rate keep full
61
- // precision, date/time serialize to string.
62
- return def !== undefined ? `Type.String({ ${def} })` : 'Type.String()';
63
- case 'boolean':
64
- return def !== undefined ? `Type.Boolean({ ${def} })` : 'Type.Boolean()';
65
- case 'json':
66
- return def !== undefined ? `Type.Unknown({ ${def} })` : 'Type.Unknown()';
67
- case 'enum': {
68
- const ref = resolver?.(field.enum.jsName);
69
- if (!ref) throw new Error(`enum field ${field.name}: no import ref for ${field.enum.jsName} — pass an EnumResolver`);
70
- if (def !== undefined) {
71
- const member = field.enum.values.find((v) => v.value === defaultValue);
72
- if (!member) {
73
- throw new Error(
74
- `enum field ${field.name}: default ${renderDefault(defaultValue)} is not a member of ${field.enum.jsName}`,
75
- );
76
- }
77
- return `Type.Enum(${ref.name}, { default: ${ref.name}.${member.symbol} })`;
78
- }
79
- return `Type.Enum(${ref.name})`;
80
- }
81
- case 'aggregate':
82
- // Aggregate query outputs: count/sum(int) are numbers, everything else
83
- // arrives as a precision string.
84
- return field.jsType === 'number' ? 'Type.Number()' : 'Type.String()';
85
- case 'array':
86
- return `Type.Array(${renderFieldValue(field.items, indent, resolver)})`;
87
- case 'object':
88
- return renderFieldObject(field.properties, indent + 1, resolver);
89
- default:
90
- // Field union is exhaustive; this branch is unreachable at runtime.
91
- throw new Error(`unsupported field type: ${String((field as Field).type)}`);
92
- }
93
- }
94
-
95
- /** Render a plain Field value (wire-format nested fields), wrapping optional. */
96
- function renderFieldValue(field: Field, indent: number, resolver: EnumResolver | undefined): string {
97
- const base = renderBasic(field, undefined, undefined, resolver, indent);
98
- return field.optional ? `Type.Optional(${base})` : base;
99
- }
100
-
101
- /** Render a plain Field object (wire-format nested object). */
102
- function renderFieldObject(properties: Record<string, Field>, indent: number, resolver: EnumResolver | undefined): string {
103
- const pad = ' '.repeat(indent);
104
- const entries = Object.entries(properties).map(([name, f]) => `${pad}${name}: ${renderFieldValue(f, indent, resolver)}`);
105
- return `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
106
- }
107
-
108
- function renderObject(fields: Record<string, DtoField | Field>, indent: number, resolver: EnumResolver | undefined): string {
109
- const pad = ' '.repeat(indent);
110
- const entries = Object.entries(fields).map(([name, f]) => {
111
- const rendered = isDtoField(f) ? renderField(f, indent, resolver) : renderFieldValue(f, indent, resolver);
112
- return `${pad}${name}: ${rendered}`;
113
- });
114
- return `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
115
- }
116
-
117
- function renderField(f: DtoField, indent: number, resolver: EnumResolver | undefined): string {
118
- const base = renderValue(f, indent, resolver);
119
- return f.isOptional() ? `Type.Optional(${base})` : base;
120
- }
121
-
122
- function renderValue(f: DtoField, indent: number, resolver: EnumResolver | undefined): string {
123
- if (f.field.type === 'array') {
124
- const items = f.field.items;
125
- // Referenced DTO element — render by name (same-file export), not expanded.
126
- if (isDtoMessage(items)) return `Type.Array(${items.name})`;
127
- if (isDtoField(items)) return `Type.Array(${renderField(items, indent + 1, resolver)})`;
128
- return `Type.Array(${renderFieldValue(items, indent, resolver)})`;
129
- }
130
- if (f.field.type === 'object') {
131
- return renderObject(f.field.properties, indent + 1, resolver);
132
- }
133
- // DtoField only wraps a database Field; array/object defs live in the subclasses.
134
- // Only DTO-level defaults (setDefault) are emitted as TypeBox default
135
- // annotations; DB field defaults are not carried into the API contract.
136
- return renderBasic(f.field as Field, f.pattern, f.default, resolver, indent);
137
- }
138
-
139
- function collectEnumImports(
140
- f: DtoField,
141
- resolver: EnumResolver | undefined,
142
- out: Map<string, ImportBase>,
143
- ): void {
144
- if (f.field.type === 'array') {
145
- const items = f.field.items;
146
- if (isDtoMessage(items)) return;
147
- if (isDtoField(items)) {
148
- collectEnumImports(items, resolver, out);
149
- return;
150
- }
151
- collectFieldEnumImports(items, resolver, out);
152
- return;
153
- }
154
- if (f.field.type === 'object') {
155
- for (const child of Object.values(f.field.properties)) {
156
- if (isDtoField(child)) collectEnumImports(child, resolver, out);
157
- else collectFieldEnumImports(child, resolver, out);
158
- }
159
- return;
160
- }
161
- if (f.field.type === 'enum') collectEnumRef(f.field, resolver, out);
162
- }
163
-
164
- /** Enum import collection over a plain Field (wire-format nested fields). */
165
- function collectFieldEnumImports(
166
- field: Field,
167
- resolver: EnumResolver | undefined,
168
- out: Map<string, ImportBase>,
169
- ): void {
170
- for (const jsName of collectEnumRefs(field)) {
171
- const ref = resolver?.(jsName);
172
- if (!ref) throw new Error(`enum ${jsName}: no import ref — pass an EnumResolver`);
173
- out.set(`${ref.from}#${ref.name}`, ref);
174
- }
175
- }
176
-
177
- function collectEnumRef(field: EnumField, resolver: EnumResolver | undefined, out: Map<string, ImportBase>): void {
178
- collectFieldEnumImports(field, resolver, out);
179
- }
180
-
181
- /** Collect all imports needed to render a DTO: include() bases + enum references. */
182
- export function collectDtoImports(
183
- schema: DtoMessage,
184
- resolver: EnumResolver | undefined,
185
- out: Map<string, ImportBase>,
186
- ): void {
187
- for (const base of schema.bases ?? []) out.set(`${base.from}#${base.name}`, base);
188
- for (const f of Object.values(schema.fields)) collectEnumImports(f, resolver, out);
189
- }
190
-
191
- /** Render one DTO export (const + type) no file header, for file-level generation. */
192
- export function renderDtoExport(schema: DtoMessage, resolver: EnumResolver | undefined): string {
193
- const object = renderObject(schema.fields, 1, resolver);
194
- const bases = schema.bases ?? [];
195
- const body =
196
- bases.length > 0
197
- ? `Type.Intersect([${bases.map(renderBase).join(', ')}, ${object}])`
198
- : object;
199
- return `export const ${schema.name} = ${body};`;
200
- }
201
-
202
- /** Render the Static type export for a DTO. */
203
- export function renderDtoTypeExport(name: string): string {
204
- return `export type ${name} = Static<typeof ${name}>;`;
205
- }
206
-
207
- export function renderDtoMessage(
208
- schema: DtoMessage,
209
- options: { resolver?: EnumResolver; source?: string } = {},
210
- ): string {
211
- const { resolver, source } = options;
212
- const imports = new Map<string, ImportBase>();
213
- collectDtoImports(schema, resolver, imports);
214
-
215
- const header = [
216
- '// AUTO-GENERATED by typebox-driver DO NOT EDIT',
217
- ...(source !== undefined ? [`// Source: ${source}`] : []),
218
- "import { Type, Static } from '@sinclair/typebox';",
219
- ...[...imports.values()].map((r) => `import${r.type ? ' type' : ''} { ${r.name} } from '${r.from}';`),
220
- ];
221
-
222
- return [
223
- ...header,
224
- '',
225
- renderDtoExport(schema, resolver),
226
- renderDtoTypeExport(schema.name),
227
- '',
228
- ].join('\n');
229
- }
230
-
231
- function renderBase(base: ImportRef): string {
232
- if (base.args === undefined || base.args.length === 0) return base.name;
233
- const args = base.args.map((a) => (typeof a === 'string' ? a : a.name));
234
- return `${base.name}(${args.join(', ')})`;
1
+ import { DtoArrayField, DtoField, DtoMessage, DtoObjectField, ImportBase, ImportRef, isDtoField, isDtoMessage } from './dto.js';
2
+ import { collectEnumRefs, EnumField, Field } from './dsl.js';
3
+ import type { TableSchema } from './db.js';
4
+ import type { TokenSchema } from './token.js';
5
+
6
+ // TypeBox driver: renders a DtoMessage into TypeBox TypeScript source.
7
+ // Shape matches the codegen product consumed by fastify v5 TypeBoxTypeProvider:
8
+ //
9
+ // export const RegisterUserInput = Type.Object({...});
10
+ // export type RegisterUserInput = Static<typeof RegisterUserInput>;
11
+ //
12
+ // ENUM fields reference a generated enum (see enum-driver) by its import
13
+ // location — the resolver maps a jsName to its product import.
14
+ // DTO bases (.include()) render as Type.Intersect([...bases, Type.Object({...})]).
15
+
16
+ export type EnumResolver = (enumName: string) => ImportBase | undefined;
17
+
18
+ function renderString(s: string): string {
19
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
20
+ }
21
+
22
+ function renderDefault(v: unknown): string {
23
+ if (typeof v === 'string') return renderString(v);
24
+ return JSON.stringify(v);
25
+ }
26
+
27
+ /** BaseField-level description: description takes priority over label. */
28
+ function fieldDescription(field: { description?: string; label?: string }): string | undefined {
29
+ return field.description ?? field.label;
30
+ }
31
+
32
+ /** DtoField-level description: own description > referenced field description/label. */
33
+ function dtoFieldDescription(f: DtoField): string | undefined {
34
+ return f.description ?? fieldDescription(f.field);
35
+ }
36
+
37
+ /** Resolve a ref chain to its terminal field (the one without .ref).
38
+ * Cycles are a DSL definition error — fail loudly at render time. */
39
+ function resolveRefChain(f: DtoField): DtoField {
40
+ const seen = new Set<DtoField>();
41
+ let cur: DtoField = f;
42
+ while (cur.ref !== undefined) {
43
+ if (seen.has(cur.ref)) {
44
+ throw new Error(`dto field ${cur.name}: circular ref chain (field references itself)`);
45
+ }
46
+ seen.add(cur.ref);
47
+ cur = cur.ref;
48
+ }
49
+ return cur;
50
+ }
51
+
52
+ function renderBasic(
53
+ field: Field,
54
+ pattern: string | undefined,
55
+ defaultValue: unknown,
56
+ resolver: EnumResolver | undefined,
57
+ indent = 0,
58
+ description?: string,
59
+ ): string {
60
+ if (pattern !== undefined && field.type !== 'string') {
61
+ throw new Error(`pattern is only supported on string fields, got ${field.type} (${field.name})`);
62
+ }
63
+ const def = defaultValue !== undefined ? `default: ${renderDefault(defaultValue)}` : undefined;
64
+ const desc = description !== undefined ? `description: ${renderString(description)}` : undefined;
65
+ const withOpts = (base: string, opts: string[]): string =>
66
+ opts.length > 0 ? `${base}({ ${opts.join(', ')} })` : `${base}()`;
67
+ switch (field.type) {
68
+ case 'string': {
69
+ const opts: string[] = [];
70
+ if (field.minLength !== undefined) opts.push(`minLength: ${field.minLength}`);
71
+ if (field.maxLength !== undefined) opts.push(`maxLength: ${field.maxLength}`);
72
+ if (pattern !== undefined) opts.push(`pattern: ${renderString(pattern)}`);
73
+ if (def !== undefined) opts.push(def);
74
+ if (desc !== undefined) opts.push(desc);
75
+ return withOpts('Type.String', opts);
76
+ }
77
+ case 'text': {
78
+ const opts: string[] = [];
79
+ if (def !== undefined) opts.push(def);
80
+ if (desc !== undefined) opts.push(desc);
81
+ return withOpts('Type.String', opts);
82
+ }
83
+ case 'integer': {
84
+ const opts: string[] = [];
85
+ if (field.min !== undefined) opts.push(`minimum: ${field.min}`);
86
+ if (field.max !== undefined) opts.push(`maximum: ${field.max}`);
87
+ if (def !== undefined) opts.push(def);
88
+ if (desc !== undefined) opts.push(desc);
89
+ return withOpts('Type.Integer', opts);
90
+ }
91
+ case 'bigint':
92
+ case 'decimal':
93
+ case 'rate':
94
+ case 'time':
95
+ case 'date':
96
+ case 'datetime': {
97
+ // Transmitted as string over HTTP: bigint/decimal/rate keep full
98
+ // precision, date/time serialize to string.
99
+ const opts: string[] = [];
100
+ if (def !== undefined) opts.push(def);
101
+ if (desc !== undefined) opts.push(desc);
102
+ return withOpts('Type.String', opts);
103
+ }
104
+ case 'boolean': {
105
+ const opts: string[] = [];
106
+ if (def !== undefined) opts.push(def);
107
+ if (desc !== undefined) opts.push(desc);
108
+ return withOpts('Type.Boolean', opts);
109
+ }
110
+ case 'json': {
111
+ const opts: string[] = [];
112
+ if (def !== undefined) opts.push(def);
113
+ if (desc !== undefined) opts.push(desc);
114
+ return withOpts('Type.Unknown', opts);
115
+ }
116
+ case 'enum': {
117
+ const ref = resolver?.(field.enum.jsName);
118
+ if (!ref) throw new Error(`enum field ${field.name}: no import ref for ${field.enum.jsName} — pass an EnumResolver`);
119
+ if (def !== undefined) {
120
+ const member = field.enum.values.find((v) => v.value === defaultValue);
121
+ if (!member) {
122
+ throw new Error(
123
+ `enum field ${field.name}: default ${renderDefault(defaultValue)} is not a member of ${field.enum.jsName}`,
124
+ );
125
+ }
126
+ const opts: string[] = [];
127
+ if (desc !== undefined) opts.push(desc);
128
+ return `Type.Enum(${ref.name}, { default: ${ref.name}.${member.symbol}${opts.length > 0 ? `, ${opts.join(', ')}` : ''} })`;
129
+ }
130
+ if (desc !== undefined) return `Type.Enum(${ref.name}, { ${desc} })`;
131
+ return `Type.Enum(${ref.name})`;
132
+ }
133
+ case 'aggregate': {
134
+ // Aggregate query outputs: count/sum(int) are numbers, everything else
135
+ // arrives as a precision string.
136
+ const opts: string[] = [];
137
+ if (desc !== undefined) opts.push(desc);
138
+ return field.jsType === 'number' ? withOpts('Type.Number', opts) : withOpts('Type.String', opts);
139
+ }
140
+ case 'array':
141
+ return desc !== undefined
142
+ ? `Type.Array(${renderFieldValue(field.items, indent, resolver)}, { ${desc} })`
143
+ : `Type.Array(${renderFieldValue(field.items, indent, resolver)})`;
144
+ case 'object':
145
+ return renderFieldObject(field.properties, indent + 1, resolver, description);
146
+ default:
147
+ // Field union is exhaustive; this branch is unreachable at runtime.
148
+ throw new Error(`unsupported field type: ${String((field as Field).type)}`);
149
+ }
150
+ }
151
+
152
+ /** Render a plain Field value (wire-format nested fields), wrapping optional. */
153
+ function renderFieldValue(field: Field, indent: number, resolver: EnumResolver | undefined): string {
154
+ const base = renderBasic(field, undefined, undefined, resolver, indent, fieldDescription(field));
155
+ return field.optional ? `Type.Optional(${base})` : base;
156
+ }
157
+
158
+ /** Render a plain Field object (wire-format nested object). */
159
+ function renderFieldObject(
160
+ properties: Record<string, Field>,
161
+ indent: number,
162
+ resolver: EnumResolver | undefined,
163
+ description?: string,
164
+ ): string {
165
+ const pad = ' '.repeat(indent);
166
+ const entries = Object.entries(properties).map(([name, f]) => `${pad}${name}: ${renderFieldValue(f, indent, resolver)}`);
167
+ const obj = `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
168
+ return description !== undefined
169
+ ? `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}}, { description: ${renderString(description)} })`
170
+ : obj;
171
+ }
172
+
173
+ function renderObject(
174
+ fields: Record<string, DtoField | Field>,
175
+ indent: number,
176
+ resolver: EnumResolver | undefined,
177
+ description?: string,
178
+ ): string {
179
+ const pad = ' '.repeat(indent);
180
+ const entries = Object.entries(fields).map(([name, f]) => {
181
+ const rendered = isDtoField(f) ? renderField(f, indent, resolver) : renderFieldValue(f, indent, resolver);
182
+ return `${pad}${name}: ${rendered}`;
183
+ });
184
+ const obj = `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
185
+ return description !== undefined
186
+ ? `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}}, { description: ${renderString(description)} })`
187
+ : obj;
188
+ }
189
+
190
+ function renderField(f: DtoField, indent: number, resolver: EnumResolver | undefined): string {
191
+ // Ref branch renders its own optional (referencing overrides + chain fallback).
192
+ if (f.ref !== undefined) return renderValue(f, indent, resolver);
193
+ const base = renderValue(f, indent, resolver);
194
+ return f.isOptional() ? `Type.Optional(${base})` : base;
195
+ }
196
+
197
+ /** Render a ref-carrying field's bare type (no optional wrapper): resolve the
198
+ * chain, inherit the terminal field's type/constraints, keep the referencing
199
+ * field's own overrides (pattern / default / description). */
200
+ function renderRefBase(f: DtoField, indent: number, resolver: EnumResolver | undefined): string {
201
+ const target = resolveRefChain(f);
202
+ const targetField = target.field as Field;
203
+ const pattern = f.pattern ?? target.pattern;
204
+ const defaultValue = f.default ?? target.default;
205
+ const desc = f.description ?? dtoFieldDescription(target);
206
+ return renderBasic(targetField, pattern, defaultValue, resolver, indent, desc);
207
+ }
208
+
209
+ /** Render a ref-carrying field with its optional wrapper: referencing
210
+ * override first, then the chain's DtoField-level optional, then the bare
211
+ * column optionality. */
212
+ function renderRefField(f: DtoField, indent: number, resolver: EnumResolver | undefined): string {
213
+ const target = resolveRefChain(f);
214
+ const optional = f.optional ?? target.optional ?? (target.field as Field).optional ?? false;
215
+ const base = renderRefBase(f, indent, resolver);
216
+ return optional ? `Type.Optional(${base})` : base;
217
+ }
218
+
219
+ function renderValue(f: DtoField, indent: number, resolver: EnumResolver | undefined): string {
220
+ if (f.ref !== undefined) return renderRefField(f, indent, resolver);
221
+ if (f.field.type === 'array') {
222
+ const items = f.field.items;
223
+ const desc = dtoFieldDescription(f);
224
+ // Referenced DTO element — render by name (same-file export), not expanded.
225
+ if (isDtoMessage(items)) {
226
+ return desc !== undefined
227
+ ? `Type.Array(${items.name}, { description: ${renderString(desc)} })`
228
+ : `Type.Array(${items.name})`;
229
+ }
230
+ if (isDtoField(items)) {
231
+ const rendered = `Type.Array(${renderField(items, indent + 1, resolver)})`;
232
+ return desc !== undefined
233
+ ? `Type.Array(${renderField(items, indent + 1, resolver)}, { description: ${renderString(desc)} })`
234
+ : rendered;
235
+ }
236
+ const rendered = `Type.Array(${renderFieldValue(items, indent, resolver)})`;
237
+ return desc !== undefined
238
+ ? `Type.Array(${renderFieldValue(items, indent, resolver)}, { description: ${renderString(desc)} })`
239
+ : rendered;
240
+ }
241
+ if (f.field.type === 'object') {
242
+ return renderObject(f.field.properties, indent + 1, resolver, dtoFieldDescription(f));
243
+ }
244
+ // DtoField only wraps a database Field; array/object defs live in the subclasses.
245
+ // Only DTO-level defaults (setDefault) are emitted as TypeBox default
246
+ // annotations; DB field defaults are not carried into the API contract.
247
+ return renderBasic(f.field as Field, f.pattern, f.default, resolver, indent, dtoFieldDescription(f));
248
+ }
249
+
250
+ function collectEnumImports(
251
+ f: DtoField,
252
+ resolver: EnumResolver | undefined,
253
+ out: Map<string, ImportBase>,
254
+ ): void {
255
+ if (f.ref !== undefined) {
256
+ collectEnumImports(resolveRefChain(f), resolver, out);
257
+ return;
258
+ }
259
+ if (f.field.type === 'array') {
260
+ const items = f.field.items;
261
+ if (isDtoMessage(items)) return;
262
+ if (isDtoField(items)) {
263
+ collectEnumImports(items, resolver, out);
264
+ return;
265
+ }
266
+ collectFieldEnumImports(items, resolver, out);
267
+ return;
268
+ }
269
+ if (f.field.type === 'object') {
270
+ for (const child of Object.values(f.field.properties)) {
271
+ if (isDtoField(child)) collectEnumImports(child, resolver, out);
272
+ else collectFieldEnumImports(child, resolver, out);
273
+ }
274
+ return;
275
+ }
276
+ if (f.field.type === 'enum') collectEnumRef(f.field, resolver, out);
277
+ }
278
+
279
+ /** Enum import collection over a plain Field (wire-format nested fields). */
280
+ function collectFieldEnumImports(
281
+ field: Field,
282
+ resolver: EnumResolver | undefined,
283
+ out: Map<string, ImportBase>,
284
+ ): void {
285
+ for (const jsName of collectEnumRefs(field)) {
286
+ const ref = resolver?.(jsName);
287
+ if (!ref) throw new Error(`enum ${jsName}: no import ref — pass an EnumResolver`);
288
+ out.set(`${ref.from}#${ref.name}`, ref);
289
+ }
290
+ }
291
+
292
+ function collectEnumRef(field: EnumField, resolver: EnumResolver | undefined, out: Map<string, ImportBase>): void {
293
+ collectFieldEnumImports(field, resolver, out);
294
+ }
295
+
296
+ /** Collect all imports needed to render a DTO: include() bases + enum references. */
297
+ export function collectDtoImports(
298
+ schema: DtoMessage,
299
+ resolver: EnumResolver | undefined,
300
+ out: Map<string, ImportBase>,
301
+ ): void {
302
+ for (const base of schema.bases ?? []) out.set(`${base.from}#${base.name}`, base);
303
+ for (const f of Object.values(schema.fields)) collectEnumImports(f, resolver, out);
304
+ }
305
+
306
+ /** Render the server-injection base: token-injected fields as Optional
307
+ * properties of a TypeBox object, plus a non-enumerable __inject adapter
308
+ * (same mechanism as hand-written bases, see pylon __inject docs) that fills
309
+ * each field from the token at runtime. */
310
+ function renderInjectBase(fields: Record<string, DtoField>, resolver: EnumResolver | undefined): string {
311
+ const entries = Object.entries(fields).map(([name, f]) => {
312
+ const base = f.ref !== undefined ? renderRefBase(f, 1, resolver) : renderValue(f, 1, resolver);
313
+ return ` ${name}: Type.Optional(${base})`;
314
+ });
315
+ const inner = `Type.Object({\n${entries.join(',\n')}\n})`;
316
+ const assigns = Object.keys(fields)
317
+ .map((k) => `body.${k} = token.${k};`)
318
+ .join(' ');
319
+ return [
320
+ `Object.defineProperty(`,
321
+ ` ${inner},`,
322
+ ` '__inject',`,
323
+ ` { value: (body: Record<string, unknown>, token: Record<string, unknown>): void => { ${assigns} }, enumerable: false },`,
324
+ `)`,
325
+ ].join('\n');
326
+ }
327
+
328
+ /** Render one DTO export (const + type) — no file header, for file-level generation. */
329
+ export function renderDtoExport(schema: DtoMessage, resolver: EnumResolver | undefined): string {
330
+ const injectFields: Record<string, DtoField> = {};
331
+ const normalFields: Record<string, DtoField> = {};
332
+ for (const [key, f] of Object.entries(schema.fields)) {
333
+ if (f.injectFrom !== undefined) injectFields[key] = f;
334
+ else normalFields[key] = f;
335
+ }
336
+ const parts: string[] = [];
337
+ if (Object.keys(injectFields).length > 0) parts.push(renderInjectBase(injectFields, resolver));
338
+ parts.push(renderObject(normalFields, 1, resolver, schema.description));
339
+ for (const base of schema.bases ?? []) parts.push(renderBase(base));
340
+ const body = parts.length > 1 ? `Type.Intersect([${parts.join(', ')}])` : parts[0];
341
+ return `export const ${schema.name} = ${body};`;
342
+ }
343
+
344
+ /** Render the Static type export for a DTO. */
345
+ export function renderDtoTypeExport(name: string): string {
346
+ return `export type ${name} = Static<typeof ${name}>;`;
347
+ }
348
+
349
+ /** Render one token export: a flat TypeBox object. Security fields keep
350
+ * their declared optionality (secret required, cipher optional); identity
351
+ * fields are Optional except the primary-key anchor — they only exist after
352
+ * login (two-state object, flat runtime shape), while the PK is guaranteed
353
+ * to be projected (token validation enforces it) and serves as the identity
354
+ * anchor (e.g. the tenant key for tenant-scoped controllers). */
355
+ export function renderTokenExport(token: TokenSchema, resolver: EnumResolver | undefined): string {
356
+ const constName = `${token.name}Token`;
357
+ const entries = [
358
+ ...Object.entries(token.security).map(([name, f]) => ` ${name}: ${renderField(f, 1, resolver)}`),
359
+ ...Object.entries(token.identity).map(([name, f]) =>
360
+ isPrimaryKeyProjection(f) ? ` ${name}: ${renderValue(f, 1, resolver)}` : ` ${name}: Type.Optional(${renderValue(f, 1, resolver)})`,
361
+ ),
362
+ ];
363
+ const desc = token.description !== undefined ? `, { description: ${renderString(token.description)} }` : '';
364
+ return `export const ${constName} = Type.Object({\n${entries.join(',\n')}\n}${desc});`;
365
+ }
366
+
367
+ /** True when the DTO field projects a primary-key column of its source table. */
368
+ function isPrimaryKeyProjection(f: DtoField): boolean {
369
+ const tbl = f.field.schema;
370
+ if (tbl?.type !== 'table') return false;
371
+ // A table-backed field is always a plain Field (only Field carries a table
372
+ // schema); array/object DTO fields reference messages, not tables.
373
+ const field = f.field as Field;
374
+ const table = tbl as TableSchema;
375
+ const pk = table.primaryKey;
376
+ if (!pk) return false;
377
+ const pks = Array.isArray(pk) ? pk : [pk];
378
+ return pks.includes(field);
379
+ }
380
+
381
+ /** Render the Static type export for a token. */
382
+ export function renderTokenTypeExport(name: string): string {
383
+ return `export type ${name}Token = Static<typeof ${name}Token>;`;
384
+ }
385
+
386
+ /** Collect all imports needed to render a token: enum references across
387
+ * security + identity fields. */
388
+ export function collectTokenImports(
389
+ token: TokenSchema,
390
+ resolver: EnumResolver | undefined,
391
+ out: Map<string, ImportBase>,
392
+ ): void {
393
+ for (const f of [...Object.values(token.security), ...Object.values(token.identity)]) {
394
+ collectEnumImports(f, resolver, out);
395
+ }
396
+ }
397
+
398
+ export function renderDtoMessage(
399
+ schema: DtoMessage,
400
+ options: { resolver?: EnumResolver; source?: string } = {},
401
+ ): string {
402
+ const { resolver, source } = options;
403
+ const imports = new Map<string, ImportBase>();
404
+ collectDtoImports(schema, resolver, imports);
405
+
406
+ const header = [
407
+ '// AUTO-GENERATED by typebox-driver — DO NOT EDIT',
408
+ ...(source !== undefined ? [`// Source: ${source}`] : []),
409
+ "import { Type, Static } from '@sinclair/typebox';",
410
+ ...[...imports.values()].map((r) => `import${r.type ? ' type' : ''} { ${r.name} } from '${r.from}';`),
411
+ ];
412
+
413
+ return [
414
+ ...header,
415
+ '',
416
+ renderDtoExport(schema, resolver),
417
+ renderDtoTypeExport(schema.name),
418
+ '',
419
+ ].join('\n');
420
+ }
421
+
422
+ function renderBase(base: ImportRef): string {
423
+ if (base.args === undefined || base.args.length === 0) return base.name;
424
+ const args = base.args.map((a) => (typeof a === 'string' ? a : a.name));
425
+ return `${base.name}(${args.join(', ')})`;
235
426
  }