@strav/cli 0.1.0

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.
@@ -0,0 +1,11 @@
1
+ export { default as ModelGenerator } from './model_generator.ts'
2
+ export { default as ApiGenerator } from './api_generator.ts'
3
+ export { default as RouteGenerator } from './route_generator.ts'
4
+ export { default as TestGenerator } from './test_generator.ts'
5
+ export { default as DocGenerator } from './doc_generator.ts'
6
+ export type { GeneratedFile } from './model_generator.ts'
7
+ export { ApiRouting } from './route_generator.ts'
8
+ export { toRouteSegment, toChildSegment } from './route_generator.ts'
9
+ export type { ApiRoutingConfig } from './route_generator.ts'
10
+ export type { GeneratorConfig, GeneratorPaths } from './config.ts'
11
+ export { resolvePaths, relativeImport, formatAndWrite } from './config.ts'
@@ -0,0 +1,621 @@
1
+ import { join } from 'node:path'
2
+ import { Archetype } from '@stravigor/database/schema/types'
3
+ import type { SchemaDefinition } from '@stravigor/database/schema/types'
4
+ import type {
5
+ DatabaseRepresentation,
6
+ TableDefinition,
7
+ ColumnDefinition,
8
+ EnumDefinition,
9
+ } from '@stravigor/database/schema/database_representation'
10
+ import type { PostgreSQLCustomType } from '@stravigor/database/schema/postgres'
11
+ import { toSnakeCase, toCamelCase, toPascalCase } from '@stravigor/kernel/helpers/strings'
12
+ import type { GeneratorConfig, GeneratorPaths } from './config.ts'
13
+ import { resolvePaths, relativeImport, formatAndWrite, getModelPrefix } from './config.ts'
14
+
15
+ export interface GeneratedFile {
16
+ path: string
17
+ content: string
18
+ }
19
+
20
+ export default class ModelGenerator {
21
+ private schemaMap: Map<string, SchemaDefinition>
22
+ private paths: GeneratorPaths
23
+ private modelPrefix: string
24
+ private config?: GeneratorConfig
25
+
26
+ constructor(
27
+ private schemas: SchemaDefinition[],
28
+ private representation: DatabaseRepresentation,
29
+ config?: GeneratorConfig,
30
+ private domain?: string,
31
+ private allSchemasMap?: Map<string, string>
32
+ ) {
33
+ this.schemaMap = new Map(schemas.map(s => [s.name, s]))
34
+ this.paths = resolvePaths(config, domain)
35
+ this.modelPrefix = getModelPrefix(config, domain)
36
+ this.config = config
37
+ }
38
+
39
+ /** Generate all file contents without writing to disk. */
40
+ generate(): GeneratedFile[] {
41
+ const files: GeneratedFile[] = []
42
+
43
+ const enumFiles = this.generateEnums()
44
+ files.push(...enumFiles)
45
+
46
+ const modelFiles = this.generateModels()
47
+ files.push(...modelFiles)
48
+
49
+ // Barrel exports
50
+ if (enumFiles.length > 0) {
51
+ files.push(this.generateBarrel(this.paths.enums, enumFiles, 'named'))
52
+ }
53
+ if (modelFiles.length > 0) {
54
+ files.push(this.generateBarrel(this.paths.models, modelFiles, 'default'))
55
+ }
56
+
57
+ return files
58
+ }
59
+
60
+ /** Generate, format with Prettier, and write all files to disk. */
61
+ async writeAll(): Promise<GeneratedFile[]> {
62
+ const files = this.generate()
63
+ await formatAndWrite(files)
64
+ return files
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Enum generation
69
+ // ---------------------------------------------------------------------------
70
+
71
+ private generateEnums(): GeneratedFile[] {
72
+ const files: GeneratedFile[] = []
73
+ const enumsByEntity = new Map<string, EnumDefinition[]>()
74
+
75
+ for (const enumDef of this.representation.enums) {
76
+ const entity = this.findEnumOwner(enumDef.name)
77
+ const group = enumsByEntity.get(entity) ?? []
78
+ group.push(enumDef)
79
+ enumsByEntity.set(entity, group)
80
+ }
81
+
82
+ for (const [entity, enums] of enumsByEntity) {
83
+ const lines: string[] = []
84
+ for (let i = 0; i < enums.length; i++) {
85
+ const enumDef = enums[i]!
86
+ const enumName = toPascalCase(enumDef.name)
87
+ lines.push(`export enum ${enumName} {`)
88
+ for (const value of enumDef.values) {
89
+ lines.push(` ${toPascalCase(value)} = '${value}',`)
90
+ }
91
+ lines.push('}')
92
+ if (i < enums.length - 1) lines.push('')
93
+ }
94
+ lines.push('')
95
+
96
+ files.push({
97
+ path: join(this.paths.enums, `${entity}.ts`),
98
+ content: lines.join('\n'),
99
+ })
100
+ }
101
+
102
+ return files
103
+ }
104
+
105
+ /** Find which schema owns an enum by matching pgType.name across all fields. */
106
+ private findEnumOwner(enumName: string): string {
107
+ for (const schema of this.schemas) {
108
+ for (const fieldDef of Object.values(schema.fields)) {
109
+ if (isCustomType(fieldDef.pgType) && fieldDef.pgType.name === enumName) {
110
+ return schema.name
111
+ }
112
+ }
113
+ }
114
+ // Fallback: derive from name prefix
115
+ const idx = enumName.lastIndexOf('_')
116
+ return idx > 0 ? enumName.substring(0, idx) : enumName
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Model generation
121
+ // ---------------------------------------------------------------------------
122
+
123
+ private generateModels(): GeneratedFile[] {
124
+ const files: GeneratedFile[] = []
125
+ const assocIndex = this.buildAssociationIndex()
126
+
127
+ for (const schema of this.schemas) {
128
+ if (schema.archetype === Archetype.Association) continue
129
+
130
+ const table = this.representation.tables.find(t => t.name === toSnakeCase(schema.name))
131
+ if (!table) continue
132
+
133
+ files.push(this.generateModel(schema, table, assocIndex))
134
+ }
135
+
136
+ return files
137
+ }
138
+
139
+ private generateModel(
140
+ schema: SchemaDefinition,
141
+ table: TableDefinition,
142
+ assocIndex: Map<string, AssociationEntry[]>
143
+ ): GeneratedFile {
144
+ const className = toPascalCase(schema.name)
145
+ const timestampNames = new Set(['created_at', 'updated_at', 'deleted_at'])
146
+
147
+ // Categorize columns
148
+ const pkColumns: ColumnDefinition[] = []
149
+ const fkColumns: ColumnDefinition[] = []
150
+ const normalColumns: ColumnDefinition[] = []
151
+ const timestampColumns: ColumnDefinition[] = []
152
+
153
+ for (const col of table.columns) {
154
+ if (col.primaryKey) {
155
+ pkColumns.push(col)
156
+ } else if (this.isForeignKey(col.name, table)) {
157
+ fkColumns.push(col)
158
+ } else if (timestampNames.has(col.name)) {
159
+ timestampColumns.push(col)
160
+ } else {
161
+ normalColumns.push(col)
162
+ }
163
+ }
164
+
165
+ // Sort timestamps in canonical order
166
+ const tsOrder = ['created_at', 'updated_at', 'deleted_at']
167
+ timestampColumns.sort((a, b) => tsOrder.indexOf(a.name) - tsOrder.indexOf(b.name))
168
+
169
+ // Build references and associations
170
+ const references = this.buildReferences(schema)
171
+ const associations = assocIndex.get(schema.name) ?? []
172
+
173
+ // Soft deletes detection
174
+ const hasSoftDeletes = timestampColumns.some(c => c.name === 'deleted_at')
175
+
176
+ // Track imports
177
+ const enumImports = new Map<string, string[]>() // entity → enum names
178
+ const modelImports = new Set<string>() // PascalCase model names
179
+ let needsPrimaryImport = false
180
+ let needsReferenceImport = false
181
+ let needsAssociateImport = false
182
+
183
+ for (const ref of references) {
184
+ modelImports.add(ref.modelClass)
185
+ }
186
+ for (const assoc of associations) {
187
+ modelImports.add(assoc.model)
188
+ }
189
+
190
+ // Build property lines per section
191
+ const sections: string[][] = []
192
+
193
+ if (pkColumns.length > 0) {
194
+ const lines: string[] = []
195
+ for (const col of pkColumns) {
196
+ const propName = toCamelCase(col.name)
197
+ const tsType = this.mapTsType(col, enumImports)
198
+ const schemaDefault = this.formatSchemaDefault(col, schema, tsType)
199
+ lines.push(' @primary')
200
+ needsPrimaryImport = true
201
+ if (schemaDefault) {
202
+ lines.push(` ${propName}: ${tsType} = ${schemaDefault}`)
203
+ } else {
204
+ lines.push(` declare ${propName}: ${tsType}`)
205
+ }
206
+ }
207
+ sections.push(lines)
208
+ }
209
+
210
+ if (fkColumns.length > 0) {
211
+ const lines: string[] = []
212
+ for (const col of fkColumns) {
213
+ const propName = toCamelCase(col.name)
214
+ const tsType = this.mapTsType(col, enumImports)
215
+ const nullable = col.notNull ? '' : ' | null'
216
+ lines.push(` declare ${propName}: ${tsType}${nullable}`)
217
+ }
218
+ sections.push(lines)
219
+ }
220
+
221
+ if (normalColumns.length > 0) {
222
+ const lines: string[] = []
223
+ for (const col of normalColumns) {
224
+ const propName = toCamelCase(col.name)
225
+ const tsType = this.mapTsType(col, enumImports)
226
+ const schemaDefault = this.formatSchemaDefault(col, schema, tsType)
227
+ if (schemaDefault) {
228
+ lines.push(` ${propName}: ${tsType} = ${schemaDefault}`)
229
+ } else {
230
+ const nullable = col.notNull ? '' : ' | null'
231
+ lines.push(` declare ${propName}: ${tsType}${nullable}`)
232
+ }
233
+ }
234
+ sections.push(lines)
235
+ }
236
+
237
+ if (timestampColumns.length > 0) {
238
+ const lines: string[] = []
239
+ for (const col of timestampColumns) {
240
+ const propName = toCamelCase(col.name)
241
+ const nullable = col.notNull ? '' : ' | null'
242
+ lines.push(` declare ${propName}: DateTime${nullable}`)
243
+ }
244
+ sections.push(lines)
245
+ }
246
+
247
+ if (references.length > 0) {
248
+ const lines: string[] = []
249
+ for (const ref of references) {
250
+ lines.push(
251
+ ` @reference({ model: '${ref.modelClass}', foreignKey: '${ref.foreignKey}', targetPK: '${ref.targetPK}' })`
252
+ )
253
+ lines.push(` declare ${ref.propName}: ${ref.modelClass}`)
254
+ needsReferenceImport = true
255
+ }
256
+ sections.push(lines)
257
+ }
258
+
259
+ if (associations.length > 0) {
260
+ const lines: string[] = []
261
+ for (const assoc of associations) {
262
+ lines.push(
263
+ ` @associate({ through: '${assoc.through}', foreignKey: '${assoc.foreignKey}', otherKey: '${assoc.otherKey}', model: '${assoc.model}', targetPK: '${assoc.targetPK}' })`
264
+ )
265
+ lines.push(` declare ${assoc.property}: ${assoc.model}[]`)
266
+ needsAssociateImport = true
267
+ }
268
+ sections.push(lines)
269
+ }
270
+
271
+ // Assemble imports
272
+ const importLines: string[] = []
273
+ importLines.push("import { DateTime } from 'luxon'")
274
+ importLines.push("import BaseModel from '@stravigor/database/orm/base_model'")
275
+
276
+ const decoratorImports: string[] = []
277
+ if (needsPrimaryImport) decoratorImports.push('primary')
278
+ if (needsReferenceImport) decoratorImports.push('reference')
279
+ if (needsAssociateImport) decoratorImports.push('associate')
280
+ if (decoratorImports.length > 0) {
281
+ importLines.push(
282
+ `import { ${decoratorImports.join(', ')} } from '@stravigor/database/orm/decorators'`
283
+ )
284
+ }
285
+
286
+ for (const [entity, enumNames] of enumImports) {
287
+ const enumImportPath = relativeImport(this.paths.models, this.paths.enums)
288
+ importLines.push(`import { ${enumNames.join(', ')} } from '${enumImportPath}/${entity}'`)
289
+ }
290
+
291
+ for (const modelName of modelImports) {
292
+ if (modelName === className) continue // don't import self
293
+ const fileName = toSnakeCase(modelName)
294
+ importLines.push(`import type ${modelName} from './${fileName}'`)
295
+ }
296
+
297
+ // Assemble file
298
+ const lines: string[] = []
299
+ lines.push('// Generated by Strav — DO NOT EDIT')
300
+ lines.push(...importLines)
301
+ lines.push('')
302
+ lines.push(`export default class ${this.prefixClassName(className)} extends BaseModel {`)
303
+
304
+ if (hasSoftDeletes) {
305
+ lines.push(' static override softDeletes = true')
306
+ if (sections.length > 0) lines.push('')
307
+ }
308
+
309
+ for (let i = 0; i < sections.length; i++) {
310
+ lines.push(...sections[i]!)
311
+ if (i < sections.length - 1) lines.push('')
312
+ }
313
+
314
+ lines.push('}')
315
+ lines.push('')
316
+
317
+ return {
318
+ path: join(this.paths.models, `${toSnakeCase(schema.name)}.ts`),
319
+ content: lines.join('\n'),
320
+ }
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Type mapping
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /** Map a column to its TypeScript type string. Registers enum imports as a side-effect. */
328
+ private mapTsType(col: ColumnDefinition, enumImports: Map<string, string[]>): string {
329
+ const pgType = col.pgType
330
+
331
+ // Custom enum type
332
+ if (isCustomType(pgType)) {
333
+ const enumName = toPascalCase(pgType.name)
334
+ const ownerEntity = this.findEnumOwner(pgType.name)
335
+ const existing = enumImports.get(ownerEntity) ?? []
336
+ if (!existing.includes(enumName)) {
337
+ existing.push(enumName)
338
+ enumImports.set(ownerEntity, existing)
339
+ }
340
+ return enumName
341
+ }
342
+
343
+ if (typeof pgType !== 'string') return 'unknown'
344
+
345
+ switch (pgType) {
346
+ case 'serial':
347
+ case 'integer':
348
+ case 'smallint':
349
+ case 'smallserial':
350
+ case 'real':
351
+ case 'double_precision':
352
+ case 'decimal':
353
+ case 'numeric':
354
+ case 'money':
355
+ return 'number'
356
+
357
+ case 'bigserial':
358
+ case 'bigint':
359
+ return 'bigint'
360
+
361
+ case 'varchar':
362
+ case 'character_varying':
363
+ case 'char':
364
+ case 'character':
365
+ case 'text':
366
+ case 'uuid':
367
+ return 'string'
368
+
369
+ case 'boolean':
370
+ return 'boolean'
371
+
372
+ case 'timestamptz':
373
+ case 'timestamp':
374
+ return 'DateTime'
375
+
376
+ case 'json':
377
+ case 'jsonb':
378
+ return 'Record<string, unknown>'
379
+
380
+ case 'date':
381
+ case 'time':
382
+ case 'timetz':
383
+ case 'interval':
384
+ return 'string'
385
+
386
+ default:
387
+ return 'string'
388
+ }
389
+ }
390
+
391
+ /**
392
+ * If the column has a schema-level default, return the TS expression for it.
393
+ * Returns null if no schema default exists.
394
+ */
395
+ private formatSchemaDefault(
396
+ col: ColumnDefinition,
397
+ schema: SchemaDefinition,
398
+ tsType: string
399
+ ): string | null {
400
+ const fieldDef = this.findFieldForColumn(col.name, schema)
401
+ if (!fieldDef || fieldDef.defaultValue === undefined) return null
402
+
403
+ const defaultValue = fieldDef.defaultValue
404
+
405
+ // Enum default
406
+ if (isCustomType(col.pgType)) {
407
+ const enumName = toPascalCase(col.pgType.name)
408
+ const member = toPascalCase(String(defaultValue))
409
+ return `${enumName}.${member}`
410
+ }
411
+
412
+ // Literal defaults
413
+ if (typeof defaultValue === 'string') return `'${defaultValue}'`
414
+ if (typeof defaultValue === 'number') return String(defaultValue)
415
+ if (typeof defaultValue === 'boolean') return String(defaultValue)
416
+
417
+ return null
418
+ }
419
+
420
+ /**
421
+ * Find the schema field definition that corresponds to a given column name.
422
+ * Only returns non-reference fields (FK columns derived from references have no direct field).
423
+ */
424
+ private findFieldForColumn(
425
+ colName: string,
426
+ schema: SchemaDefinition
427
+ ): import('@stravigor/database/schema/field_definition').FieldDefinition | null {
428
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
429
+ if (fieldDef.references) continue
430
+ if (toSnakeCase(fieldName) === colName) {
431
+ return fieldDef
432
+ }
433
+ }
434
+ return null
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Reference detection
439
+ // ---------------------------------------------------------------------------
440
+
441
+ private buildReferences(
442
+ schema: SchemaDefinition
443
+ ): { propName: string; modelClass: string; foreignKey: string; targetPK: string }[] {
444
+ const refs: { propName: string; modelClass: string; foreignKey: string; targetPK: string }[] =
445
+ []
446
+
447
+ // Parent references
448
+ if (schema.parents) {
449
+ for (const parentName of schema.parents) {
450
+ const parentPK = this.findSchemaPK(parentName)
451
+ const fkCol = `${toSnakeCase(parentName)}_${toSnakeCase(parentPK)}`
452
+ refs.push({
453
+ propName: toCamelCase(parentName),
454
+ modelClass: this.prefixModelName(parentName),
455
+ foreignKey: toCamelCase(fkCol),
456
+ targetPK: parentPK,
457
+ })
458
+ }
459
+ }
460
+
461
+ // Reference fields
462
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
463
+ if (fieldDef.references) {
464
+ const refPK = this.findSchemaPK(fieldDef.references)
465
+ const fkCol = `${toSnakeCase(fieldName)}_${toSnakeCase(refPK)}`
466
+ refs.push({
467
+ propName: toCamelCase(fieldName),
468
+ modelClass: this.prefixModelName(fieldDef.references),
469
+ foreignKey: toCamelCase(fkCol),
470
+ targetPK: refPK,
471
+ })
472
+ }
473
+ }
474
+
475
+ return refs
476
+ }
477
+
478
+ /** Find the primary key field name (camelCase) for a schema. Defaults to 'id'. */
479
+ private findSchemaPK(schemaName: string): string {
480
+ const schema = this.schemaMap.get(schemaName)
481
+ if (!schema) return 'id'
482
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
483
+ if (fieldDef.primaryKey) return fieldName
484
+ }
485
+ return 'id'
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // Association index
490
+ // ---------------------------------------------------------------------------
491
+
492
+ /**
493
+ * Build an index: entity name → association entries.
494
+ * Only association schemas with an `as` option produce entries.
495
+ */
496
+ private buildAssociationIndex(): Map<string, AssociationEntry[]> {
497
+ const index = new Map<string, AssociationEntry[]>()
498
+
499
+ for (const schema of this.schemas) {
500
+ if (schema.archetype !== Archetype.Association || !schema.associates || !schema.as) continue
501
+
502
+ const [entityA, entityB] = schema.associates!
503
+ const pivotTable = toSnakeCase(schema.name)
504
+ const pkA = this.findSchemaPK(entityA!)
505
+ const pkB = this.findSchemaPK(entityB!)
506
+ const fkA = `${toSnakeCase(entityA!)}_${toSnakeCase(pkA)}`
507
+ const fkB = `${toSnakeCase(entityB!)}_${toSnakeCase(pkB)}`
508
+
509
+ // Entity A gets a property pointing to Entity B
510
+ if (schema.as![entityA!]) {
511
+ const entries = index.get(entityA!) ?? []
512
+ entries.push({
513
+ property: schema.as![entityA!]!,
514
+ through: pivotTable,
515
+ foreignKey: fkA,
516
+ otherKey: fkB,
517
+ model: this.prefixModelName(entityB!),
518
+ targetPK: pkB,
519
+ })
520
+ index.set(entityA!, entries)
521
+ }
522
+
523
+ // Entity B gets a property pointing to Entity A
524
+ if (schema.as![entityB!]) {
525
+ const entries = index.get(entityB!) ?? []
526
+ entries.push({
527
+ property: schema.as![entityB!]!,
528
+ through: pivotTable,
529
+ foreignKey: fkB,
530
+ otherKey: fkA,
531
+ model: this.prefixModelName(entityA!),
532
+ targetPK: pkA,
533
+ })
534
+ index.set(entityB!, entries)
535
+ }
536
+ }
537
+
538
+ return index
539
+ }
540
+
541
+ // ---------------------------------------------------------------------------
542
+ // Barrel generation
543
+ // ---------------------------------------------------------------------------
544
+
545
+ /**
546
+ * Generate a barrel (index.ts) file that re-exports all generated files
547
+ * in a directory. `mode` controls the export style:
548
+ * - `'default'` → `export { default as ClassName } from './file'`
549
+ * - `'named'` → `export * from './file'`
550
+ */
551
+ private generateBarrel(
552
+ dir: string,
553
+ files: GeneratedFile[],
554
+ mode: 'default' | 'named'
555
+ ): GeneratedFile {
556
+ const lines: string[] = ['// Generated by Strav — DO NOT EDIT', '']
557
+
558
+ for (const file of files) {
559
+ const basename = file.path.split('/').pop()!.replace(/\.ts$/, '')
560
+ if (mode === 'named') {
561
+ lines.push(`export * from './${basename}'`)
562
+ } else {
563
+ const className = this.prefixClassName(toPascalCase(basename))
564
+ lines.push(`export { default as ${className} } from './${basename}'`)
565
+ }
566
+ }
567
+
568
+ lines.push('')
569
+
570
+ return {
571
+ path: join(dir, 'index.ts'),
572
+ content: lines.join('\n'),
573
+ }
574
+ }
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // Helpers
578
+ // ---------------------------------------------------------------------------
579
+
580
+ /** Apply model name prefix to a class name. */
581
+ private prefixClassName(className: string): string {
582
+ return `${this.modelPrefix}${className}`
583
+ }
584
+
585
+ /** Apply appropriate prefix to a model name based on which domain it belongs to. */
586
+ private prefixModelName(modelName: string): string {
587
+ // If we don't have domain mapping, use current domain prefix
588
+ if (!this.allSchemasMap) {
589
+ return this.prefixClassName(toPascalCase(modelName))
590
+ }
591
+
592
+ // Check which domain this model belongs to
593
+ const modelDomain = this.allSchemasMap.get(modelName)
594
+
595
+ if (modelDomain) {
596
+ // Get the appropriate prefix for that domain
597
+ const prefix = getModelPrefix(this.config, modelDomain)
598
+ return `${prefix}${toPascalCase(modelName)}`
599
+ }
600
+
601
+ // Fallback to current domain prefix
602
+ return this.prefixClassName(toPascalCase(modelName))
603
+ }
604
+
605
+ private isForeignKey(columnName: string, table: TableDefinition): boolean {
606
+ return table.foreignKeys.some(fk => fk.columns.includes(columnName))
607
+ }
608
+ }
609
+
610
+ interface AssociationEntry {
611
+ property: string
612
+ through: string
613
+ foreignKey: string
614
+ otherKey: string
615
+ model: string
616
+ targetPK: string
617
+ }
618
+
619
+ function isCustomType(pgType: unknown): pgType is PostgreSQLCustomType {
620
+ return typeof pgType === 'object' && pgType !== null && (pgType as any).type === 'custom'
621
+ }