@pylonts/dsl 1.1.15 → 1.1.16

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.
@@ -8,11 +8,21 @@ function renderDefault(v) {
8
8
  return renderString(v);
9
9
  return JSON.stringify(v);
10
10
  }
11
- function renderBasic(field, pattern, defaultValue, resolver, indent = 0) {
11
+ /** BaseField-level description: description takes priority over label. */
12
+ function fieldDescription(field) {
13
+ return field.description ?? field.label;
14
+ }
15
+ /** DtoField-level description: own description > referenced field description/label. */
16
+ function dtoFieldDescription(f) {
17
+ return f.description ?? fieldDescription(f.field);
18
+ }
19
+ function renderBasic(field, pattern, defaultValue, resolver, indent = 0, description) {
12
20
  if (pattern !== undefined && field.type !== 'string') {
13
21
  throw new Error(`pattern is only supported on string fields, got ${field.type} (${field.name})`);
14
22
  }
15
23
  const def = defaultValue !== undefined ? `default: ${renderDefault(defaultValue)}` : undefined;
24
+ const desc = description !== undefined ? `description: ${renderString(description)}` : undefined;
25
+ const withOpts = (base, opts) => opts.length > 0 ? `${base}({ ${opts.join(', ')} })` : `${base}()`;
16
26
  switch (field.type) {
17
27
  case 'string': {
18
28
  const opts = [];
@@ -24,10 +34,18 @@ function renderBasic(field, pattern, defaultValue, resolver, indent = 0) {
24
34
  opts.push(`pattern: ${renderString(pattern)}`);
25
35
  if (def !== undefined)
26
36
  opts.push(def);
27
- return opts.length > 0 ? `Type.String({ ${opts.join(', ')} })` : 'Type.String()';
37
+ if (desc !== undefined)
38
+ opts.push(desc);
39
+ return withOpts('Type.String', opts);
40
+ }
41
+ case 'text': {
42
+ const opts = [];
43
+ if (def !== undefined)
44
+ opts.push(def);
45
+ if (desc !== undefined)
46
+ opts.push(desc);
47
+ return withOpts('Type.String', opts);
28
48
  }
29
- case 'text':
30
- return def !== undefined ? `Type.String({ ${def} })` : 'Type.String()';
31
49
  case 'integer': {
32
50
  const opts = [];
33
51
  if (field.min !== undefined)
@@ -36,21 +54,41 @@ function renderBasic(field, pattern, defaultValue, resolver, indent = 0) {
36
54
  opts.push(`maximum: ${field.max}`);
37
55
  if (def !== undefined)
38
56
  opts.push(def);
39
- return opts.length > 0 ? `Type.Integer({ ${opts.join(', ')} })` : 'Type.Integer()';
57
+ if (desc !== undefined)
58
+ opts.push(desc);
59
+ return withOpts('Type.Integer', opts);
40
60
  }
41
61
  case 'bigint':
42
62
  case 'decimal':
43
63
  case 'rate':
44
64
  case 'time':
45
65
  case 'date':
46
- case 'datetime':
66
+ case 'datetime': {
47
67
  // Transmitted as string over HTTP: bigint/decimal/rate keep full
48
68
  // precision, date/time serialize to string.
49
- return def !== undefined ? `Type.String({ ${def} })` : 'Type.String()';
50
- case 'boolean':
51
- return def !== undefined ? `Type.Boolean({ ${def} })` : 'Type.Boolean()';
52
- case 'json':
53
- return def !== undefined ? `Type.Unknown({ ${def} })` : 'Type.Unknown()';
69
+ const opts = [];
70
+ if (def !== undefined)
71
+ opts.push(def);
72
+ if (desc !== undefined)
73
+ opts.push(desc);
74
+ return withOpts('Type.String', opts);
75
+ }
76
+ case 'boolean': {
77
+ const opts = [];
78
+ if (def !== undefined)
79
+ opts.push(def);
80
+ if (desc !== undefined)
81
+ opts.push(desc);
82
+ return withOpts('Type.Boolean', opts);
83
+ }
84
+ case 'json': {
85
+ const opts = [];
86
+ if (def !== undefined)
87
+ opts.push(def);
88
+ if (desc !== undefined)
89
+ opts.push(desc);
90
+ return withOpts('Type.Unknown', opts);
91
+ }
54
92
  case 'enum': {
55
93
  const ref = resolver?.(field.enum.jsName);
56
94
  if (!ref)
@@ -60,18 +98,29 @@ function renderBasic(field, pattern, defaultValue, resolver, indent = 0) {
60
98
  if (!member) {
61
99
  throw new Error(`enum field ${field.name}: default ${renderDefault(defaultValue)} is not a member of ${field.enum.jsName}`);
62
100
  }
63
- return `Type.Enum(${ref.name}, { default: ${ref.name}.${member.symbol} })`;
101
+ const opts = [];
102
+ if (desc !== undefined)
103
+ opts.push(desc);
104
+ return `Type.Enum(${ref.name}, { default: ${ref.name}.${member.symbol}${opts.length > 0 ? `, ${opts.join(', ')}` : ''} })`;
64
105
  }
106
+ if (desc !== undefined)
107
+ return `Type.Enum(${ref.name}, { ${desc} })`;
65
108
  return `Type.Enum(${ref.name})`;
66
109
  }
67
- case 'aggregate':
110
+ case 'aggregate': {
68
111
  // Aggregate query outputs: count/sum(int) are numbers, everything else
69
112
  // arrives as a precision string.
70
- return field.jsType === 'number' ? 'Type.Number()' : 'Type.String()';
113
+ const opts = [];
114
+ if (desc !== undefined)
115
+ opts.push(desc);
116
+ return field.jsType === 'number' ? withOpts('Type.Number', opts) : withOpts('Type.String', opts);
117
+ }
71
118
  case 'array':
72
- return `Type.Array(${renderFieldValue(field.items, indent, resolver)})`;
119
+ return desc !== undefined
120
+ ? `Type.Array(${renderFieldValue(field.items, indent, resolver)}, { ${desc} })`
121
+ : `Type.Array(${renderFieldValue(field.items, indent, resolver)})`;
73
122
  case 'object':
74
- return renderFieldObject(field.properties, indent + 1, resolver);
123
+ return renderFieldObject(field.properties, indent + 1, resolver, description);
75
124
  default:
76
125
  // Field union is exhaustive; this branch is unreachable at runtime.
77
126
  throw new Error(`unsupported field type: ${String(field.type)}`);
@@ -79,22 +128,28 @@ function renderBasic(field, pattern, defaultValue, resolver, indent = 0) {
79
128
  }
80
129
  /** Render a plain Field value (wire-format nested fields), wrapping optional. */
81
130
  function renderFieldValue(field, indent, resolver) {
82
- const base = renderBasic(field, undefined, undefined, resolver, indent);
131
+ const base = renderBasic(field, undefined, undefined, resolver, indent, fieldDescription(field));
83
132
  return field.optional ? `Type.Optional(${base})` : base;
84
133
  }
85
134
  /** Render a plain Field object (wire-format nested object). */
86
- function renderFieldObject(properties, indent, resolver) {
135
+ function renderFieldObject(properties, indent, resolver, description) {
87
136
  const pad = ' '.repeat(indent);
88
137
  const entries = Object.entries(properties).map(([name, f]) => `${pad}${name}: ${renderFieldValue(f, indent, resolver)}`);
89
- return `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
138
+ const obj = `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
139
+ return description !== undefined
140
+ ? `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}}, { description: ${renderString(description)} })`
141
+ : obj;
90
142
  }
91
- function renderObject(fields, indent, resolver) {
143
+ function renderObject(fields, indent, resolver, description) {
92
144
  const pad = ' '.repeat(indent);
93
145
  const entries = Object.entries(fields).map(([name, f]) => {
94
146
  const rendered = isDtoField(f) ? renderField(f, indent, resolver) : renderFieldValue(f, indent, resolver);
95
147
  return `${pad}${name}: ${rendered}`;
96
148
  });
97
- return `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
149
+ const obj = `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
150
+ return description !== undefined
151
+ ? `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}}, { description: ${renderString(description)} })`
152
+ : obj;
98
153
  }
99
154
  function renderField(f, indent, resolver) {
100
155
  const base = renderValue(f, indent, resolver);
@@ -103,20 +158,31 @@ function renderField(f, indent, resolver) {
103
158
  function renderValue(f, indent, resolver) {
104
159
  if (f.field.type === 'array') {
105
160
  const items = f.field.items;
161
+ const desc = dtoFieldDescription(f);
106
162
  // Referenced DTO element — render by name (same-file export), not expanded.
107
- if (isDtoMessage(items))
108
- return `Type.Array(${items.name})`;
109
- if (isDtoField(items))
110
- return `Type.Array(${renderField(items, indent + 1, resolver)})`;
111
- return `Type.Array(${renderFieldValue(items, indent, resolver)})`;
163
+ if (isDtoMessage(items)) {
164
+ return desc !== undefined
165
+ ? `Type.Array(${items.name}, { description: ${renderString(desc)} })`
166
+ : `Type.Array(${items.name})`;
167
+ }
168
+ if (isDtoField(items)) {
169
+ const rendered = `Type.Array(${renderField(items, indent + 1, resolver)})`;
170
+ return desc !== undefined
171
+ ? `Type.Array(${renderField(items, indent + 1, resolver)}, { description: ${renderString(desc)} })`
172
+ : rendered;
173
+ }
174
+ const rendered = `Type.Array(${renderFieldValue(items, indent, resolver)})`;
175
+ return desc !== undefined
176
+ ? `Type.Array(${renderFieldValue(items, indent, resolver)}, { description: ${renderString(desc)} })`
177
+ : rendered;
112
178
  }
113
179
  if (f.field.type === 'object') {
114
- return renderObject(f.field.properties, indent + 1, resolver);
180
+ return renderObject(f.field.properties, indent + 1, resolver, dtoFieldDescription(f));
115
181
  }
116
182
  // DtoField only wraps a database Field; array/object defs live in the subclasses.
117
183
  // Only DTO-level defaults (setDefault) are emitted as TypeBox default
118
184
  // annotations; DB field defaults are not carried into the API contract.
119
- return renderBasic(f.field, f.pattern, f.default, resolver, indent);
185
+ return renderBasic(f.field, f.pattern, f.default, resolver, indent, dtoFieldDescription(f));
120
186
  }
121
187
  function collectEnumImports(f, resolver, out) {
122
188
  if (f.field.type === 'array') {
@@ -163,7 +229,7 @@ export function collectDtoImports(schema, resolver, out) {
163
229
  }
164
230
  /** Render one DTO export (const + type) — no file header, for file-level generation. */
165
231
  export function renderDtoExport(schema, resolver) {
166
- const object = renderObject(schema.fields, 1, resolver);
232
+ const object = renderObject(schema.fields, 1, resolver, schema.description);
167
233
  const bases = schema.bases ?? [];
168
234
  const body = bases.length > 0
169
235
  ? `Type.Intersect([${bases.map(renderBase).join(', ')}, ${object}])`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonts/dsl",
3
- "version": "1.1.15",
3
+ "version": "1.1.16",
4
4
  "description": "Schema definition DSL with drivers: MySQL DDL, TS enum, TypeBox schema codegen.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,235 +1,304 @@
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
+
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
+ /** BaseField-level description: description takes priority over label. */
26
+ function fieldDescription(field: { description?: string; label?: string }): string | undefined {
27
+ return field.description ?? field.label;
28
+ }
29
+
30
+ /** DtoField-level description: own description > referenced field description/label. */
31
+ function dtoFieldDescription(f: DtoField): string | undefined {
32
+ return f.description ?? fieldDescription(f.field);
33
+ }
34
+
35
+ function renderBasic(
36
+ field: Field,
37
+ pattern: string | undefined,
38
+ defaultValue: unknown,
39
+ resolver: EnumResolver | undefined,
40
+ indent = 0,
41
+ description?: string,
42
+ ): string {
43
+ if (pattern !== undefined && field.type !== 'string') {
44
+ throw new Error(`pattern is only supported on string fields, got ${field.type} (${field.name})`);
45
+ }
46
+ const def = defaultValue !== undefined ? `default: ${renderDefault(defaultValue)}` : undefined;
47
+ const desc = description !== undefined ? `description: ${renderString(description)}` : undefined;
48
+ const withOpts = (base: string, opts: string[]): string =>
49
+ opts.length > 0 ? `${base}({ ${opts.join(', ')} })` : `${base}()`;
50
+ switch (field.type) {
51
+ case 'string': {
52
+ const opts: string[] = [];
53
+ if (field.minLength !== undefined) opts.push(`minLength: ${field.minLength}`);
54
+ if (field.maxLength !== undefined) opts.push(`maxLength: ${field.maxLength}`);
55
+ if (pattern !== undefined) opts.push(`pattern: ${renderString(pattern)}`);
56
+ if (def !== undefined) opts.push(def);
57
+ if (desc !== undefined) opts.push(desc);
58
+ return withOpts('Type.String', opts);
59
+ }
60
+ case 'text': {
61
+ const opts: string[] = [];
62
+ if (def !== undefined) opts.push(def);
63
+ if (desc !== undefined) opts.push(desc);
64
+ return withOpts('Type.String', opts);
65
+ }
66
+ case 'integer': {
67
+ const opts: string[] = [];
68
+ if (field.min !== undefined) opts.push(`minimum: ${field.min}`);
69
+ if (field.max !== undefined) opts.push(`maximum: ${field.max}`);
70
+ if (def !== undefined) opts.push(def);
71
+ if (desc !== undefined) opts.push(desc);
72
+ return withOpts('Type.Integer', opts);
73
+ }
74
+ case 'bigint':
75
+ case 'decimal':
76
+ case 'rate':
77
+ case 'time':
78
+ case 'date':
79
+ case 'datetime': {
80
+ // Transmitted as string over HTTP: bigint/decimal/rate keep full
81
+ // precision, date/time serialize to string.
82
+ const opts: string[] = [];
83
+ if (def !== undefined) opts.push(def);
84
+ if (desc !== undefined) opts.push(desc);
85
+ return withOpts('Type.String', opts);
86
+ }
87
+ case 'boolean': {
88
+ const opts: string[] = [];
89
+ if (def !== undefined) opts.push(def);
90
+ if (desc !== undefined) opts.push(desc);
91
+ return withOpts('Type.Boolean', opts);
92
+ }
93
+ case 'json': {
94
+ const opts: string[] = [];
95
+ if (def !== undefined) opts.push(def);
96
+ if (desc !== undefined) opts.push(desc);
97
+ return withOpts('Type.Unknown', opts);
98
+ }
99
+ case 'enum': {
100
+ const ref = resolver?.(field.enum.jsName);
101
+ if (!ref) throw new Error(`enum field ${field.name}: no import ref for ${field.enum.jsName} — pass an EnumResolver`);
102
+ if (def !== undefined) {
103
+ const member = field.enum.values.find((v) => v.value === defaultValue);
104
+ if (!member) {
105
+ throw new Error(
106
+ `enum field ${field.name}: default ${renderDefault(defaultValue)} is not a member of ${field.enum.jsName}`,
107
+ );
108
+ }
109
+ const opts: string[] = [];
110
+ if (desc !== undefined) opts.push(desc);
111
+ return `Type.Enum(${ref.name}, { default: ${ref.name}.${member.symbol}${opts.length > 0 ? `, ${opts.join(', ')}` : ''} })`;
112
+ }
113
+ if (desc !== undefined) return `Type.Enum(${ref.name}, { ${desc} })`;
114
+ return `Type.Enum(${ref.name})`;
115
+ }
116
+ case 'aggregate': {
117
+ // Aggregate query outputs: count/sum(int) are numbers, everything else
118
+ // arrives as a precision string.
119
+ const opts: string[] = [];
120
+ if (desc !== undefined) opts.push(desc);
121
+ return field.jsType === 'number' ? withOpts('Type.Number', opts) : withOpts('Type.String', opts);
122
+ }
123
+ case 'array':
124
+ return desc !== undefined
125
+ ? `Type.Array(${renderFieldValue(field.items, indent, resolver)}, { ${desc} })`
126
+ : `Type.Array(${renderFieldValue(field.items, indent, resolver)})`;
127
+ case 'object':
128
+ return renderFieldObject(field.properties, indent + 1, resolver, description);
129
+ default:
130
+ // Field union is exhaustive; this branch is unreachable at runtime.
131
+ throw new Error(`unsupported field type: ${String((field as Field).type)}`);
132
+ }
133
+ }
134
+
135
+ /** Render a plain Field value (wire-format nested fields), wrapping optional. */
136
+ function renderFieldValue(field: Field, indent: number, resolver: EnumResolver | undefined): string {
137
+ const base = renderBasic(field, undefined, undefined, resolver, indent, fieldDescription(field));
138
+ return field.optional ? `Type.Optional(${base})` : base;
139
+ }
140
+
141
+ /** Render a plain Field object (wire-format nested object). */
142
+ function renderFieldObject(
143
+ properties: Record<string, Field>,
144
+ indent: number,
145
+ resolver: EnumResolver | undefined,
146
+ description?: string,
147
+ ): string {
148
+ const pad = ' '.repeat(indent);
149
+ const entries = Object.entries(properties).map(([name, f]) => `${pad}${name}: ${renderFieldValue(f, indent, resolver)}`);
150
+ const obj = `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
151
+ return description !== undefined
152
+ ? `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}}, { description: ${renderString(description)} })`
153
+ : obj;
154
+ }
155
+
156
+ function renderObject(
157
+ fields: Record<string, DtoField | Field>,
158
+ indent: number,
159
+ resolver: EnumResolver | undefined,
160
+ description?: string,
161
+ ): string {
162
+ const pad = ' '.repeat(indent);
163
+ const entries = Object.entries(fields).map(([name, f]) => {
164
+ const rendered = isDtoField(f) ? renderField(f, indent, resolver) : renderFieldValue(f, indent, resolver);
165
+ return `${pad}${name}: ${rendered}`;
166
+ });
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 renderField(f: DtoField, indent: number, resolver: EnumResolver | undefined): string {
174
+ const base = renderValue(f, indent, resolver);
175
+ return f.isOptional() ? `Type.Optional(${base})` : base;
176
+ }
177
+
178
+ function renderValue(f: DtoField, indent: number, resolver: EnumResolver | undefined): string {
179
+ if (f.field.type === 'array') {
180
+ const items = f.field.items;
181
+ const desc = dtoFieldDescription(f);
182
+ // Referenced DTO element — render by name (same-file export), not expanded.
183
+ if (isDtoMessage(items)) {
184
+ return desc !== undefined
185
+ ? `Type.Array(${items.name}, { description: ${renderString(desc)} })`
186
+ : `Type.Array(${items.name})`;
187
+ }
188
+ if (isDtoField(items)) {
189
+ const rendered = `Type.Array(${renderField(items, indent + 1, resolver)})`;
190
+ return desc !== undefined
191
+ ? `Type.Array(${renderField(items, indent + 1, resolver)}, { description: ${renderString(desc)} })`
192
+ : rendered;
193
+ }
194
+ const rendered = `Type.Array(${renderFieldValue(items, indent, resolver)})`;
195
+ return desc !== undefined
196
+ ? `Type.Array(${renderFieldValue(items, indent, resolver)}, { description: ${renderString(desc)} })`
197
+ : rendered;
198
+ }
199
+ if (f.field.type === 'object') {
200
+ return renderObject(f.field.properties, indent + 1, resolver, dtoFieldDescription(f));
201
+ }
202
+ // DtoField only wraps a database Field; array/object defs live in the subclasses.
203
+ // Only DTO-level defaults (setDefault) are emitted as TypeBox default
204
+ // annotations; DB field defaults are not carried into the API contract.
205
+ return renderBasic(f.field as Field, f.pattern, f.default, resolver, indent, dtoFieldDescription(f));
206
+ }
207
+
208
+ function collectEnumImports(
209
+ f: DtoField,
210
+ resolver: EnumResolver | undefined,
211
+ out: Map<string, ImportBase>,
212
+ ): void {
213
+ if (f.field.type === 'array') {
214
+ const items = f.field.items;
215
+ if (isDtoMessage(items)) return;
216
+ if (isDtoField(items)) {
217
+ collectEnumImports(items, resolver, out);
218
+ return;
219
+ }
220
+ collectFieldEnumImports(items, resolver, out);
221
+ return;
222
+ }
223
+ if (f.field.type === 'object') {
224
+ for (const child of Object.values(f.field.properties)) {
225
+ if (isDtoField(child)) collectEnumImports(child, resolver, out);
226
+ else collectFieldEnumImports(child, resolver, out);
227
+ }
228
+ return;
229
+ }
230
+ if (f.field.type === 'enum') collectEnumRef(f.field, resolver, out);
231
+ }
232
+
233
+ /** Enum import collection over a plain Field (wire-format nested fields). */
234
+ function collectFieldEnumImports(
235
+ field: Field,
236
+ resolver: EnumResolver | undefined,
237
+ out: Map<string, ImportBase>,
238
+ ): void {
239
+ for (const jsName of collectEnumRefs(field)) {
240
+ const ref = resolver?.(jsName);
241
+ if (!ref) throw new Error(`enum ${jsName}: no import ref — pass an EnumResolver`);
242
+ out.set(`${ref.from}#${ref.name}`, ref);
243
+ }
244
+ }
245
+
246
+ function collectEnumRef(field: EnumField, resolver: EnumResolver | undefined, out: Map<string, ImportBase>): void {
247
+ collectFieldEnumImports(field, resolver, out);
248
+ }
249
+
250
+ /** Collect all imports needed to render a DTO: include() bases + enum references. */
251
+ export function collectDtoImports(
252
+ schema: DtoMessage,
253
+ resolver: EnumResolver | undefined,
254
+ out: Map<string, ImportBase>,
255
+ ): void {
256
+ for (const base of schema.bases ?? []) out.set(`${base.from}#${base.name}`, base);
257
+ for (const f of Object.values(schema.fields)) collectEnumImports(f, resolver, out);
258
+ }
259
+
260
+ /** Render one DTO export (const + type) — no file header, for file-level generation. */
261
+ export function renderDtoExport(schema: DtoMessage, resolver: EnumResolver | undefined): string {
262
+ const object = renderObject(schema.fields, 1, resolver, schema.description);
263
+ const bases = schema.bases ?? [];
264
+ const body =
265
+ bases.length > 0
266
+ ? `Type.Intersect([${bases.map(renderBase).join(', ')}, ${object}])`
267
+ : object;
268
+ return `export const ${schema.name} = ${body};`;
269
+ }
270
+
271
+ /** Render the Static type export for a DTO. */
272
+ export function renderDtoTypeExport(name: string): string {
273
+ return `export type ${name} = Static<typeof ${name}>;`;
274
+ }
275
+
276
+ export function renderDtoMessage(
277
+ schema: DtoMessage,
278
+ options: { resolver?: EnumResolver; source?: string } = {},
279
+ ): string {
280
+ const { resolver, source } = options;
281
+ const imports = new Map<string, ImportBase>();
282
+ collectDtoImports(schema, resolver, imports);
283
+
284
+ const header = [
285
+ '// AUTO-GENERATED by typebox-driver — DO NOT EDIT',
286
+ ...(source !== undefined ? [`// Source: ${source}`] : []),
287
+ "import { Type, Static } from '@sinclair/typebox';",
288
+ ...[...imports.values()].map((r) => `import${r.type ? ' type' : ''} { ${r.name} } from '${r.from}';`),
289
+ ];
290
+
291
+ return [
292
+ ...header,
293
+ '',
294
+ renderDtoExport(schema, resolver),
295
+ renderDtoTypeExport(schema.name),
296
+ '',
297
+ ].join('\n');
298
+ }
299
+
300
+ function renderBase(base: ImportRef): string {
301
+ if (base.args === undefined || base.args.length === 0) return base.name;
302
+ const args = base.args.map((a) => (typeof a === 'string' ? a : a.name));
303
+ return `${base.name}(${args.join(', ')})`;
235
304
  }