@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,1036 @@
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
+ } from '@stravigor/database/schema/database_representation'
9
+ import type { FieldDefinition, FieldValidator } from '@stravigor/database/schema/field_definition'
10
+ import type { PostgreSQLCustomType } from '@stravigor/database/schema/postgres'
11
+ import { toSnakeCase, toCamelCase, toPascalCase } from '@stravigor/kernel/helpers/strings'
12
+ import type { GeneratedFile } from './model_generator.ts'
13
+ import type { GeneratorConfig, GeneratorPaths } from './config.ts'
14
+ import { resolvePaths, relativeImport, formatAndWrite } from './config.ts'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Archetype behaviour tables
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Which event constants each archetype produces. */
21
+ const ARCHETYPE_EVENTS: Record<Archetype, string[]> = {
22
+ [Archetype.Entity]: ['CREATED', 'UPDATED', 'SYNCED', 'DELETED'],
23
+ [Archetype.Attribute]: ['CREATED', 'UPDATED', 'SYNCED', 'DELETED'],
24
+ [Archetype.Contribution]: ['CREATED', 'UPDATED', 'SYNCED', 'DELETED'],
25
+ [Archetype.Reference]: ['CREATED', 'UPDATED', 'SYNCED', 'DELETED'],
26
+ [Archetype.Component]: ['UPDATED', 'SYNCED'],
27
+ [Archetype.Event]: ['CREATED'],
28
+ [Archetype.Configuration]: ['UPDATED', 'SYNCED'],
29
+ [Archetype.Association]: [],
30
+ }
31
+
32
+ /** Policy methods per archetype. */
33
+ const ARCHETYPE_POLICY: Record<Archetype, string[]> = {
34
+ [Archetype.Entity]: ['canList', 'canView', 'canCreate', 'canUpdate', 'canDelete'],
35
+ [Archetype.Attribute]: ['canList', 'canView', 'canCreate', 'canUpdate', 'canDelete'],
36
+ [Archetype.Reference]: ['canList', 'canView', 'canCreate', 'canUpdate', 'canDelete'],
37
+ [Archetype.Contribution]: [
38
+ 'canList',
39
+ 'canView',
40
+ 'canCreate',
41
+ 'canUpdate',
42
+ 'canDelete',
43
+ 'canModerate',
44
+ ],
45
+ [Archetype.Component]: ['canList', 'canView', 'canUpdate'],
46
+ [Archetype.Event]: ['canList', 'canView', 'canAppend'],
47
+ [Archetype.Configuration]: ['canView', 'canUpsert', 'canReset'],
48
+ [Archetype.Association]: [],
49
+ }
50
+
51
+ /** Service methods per archetype. */
52
+ const ARCHETYPE_SERVICE: Record<Archetype, string[]> = {
53
+ [Archetype.Entity]: ['list', 'find', 'create', 'update', 'delete'],
54
+ [Archetype.Attribute]: ['listByParent', 'find', 'create', 'update', 'delete'],
55
+ [Archetype.Contribution]: ['listByParent', 'find', 'create', 'update', 'delete'],
56
+ [Archetype.Reference]: ['list', 'find', 'create', 'update', 'delete'],
57
+ [Archetype.Component]: ['listByParent', 'find', 'update'],
58
+ [Archetype.Event]: ['listByParent', 'find', 'append'],
59
+ [Archetype.Configuration]: ['get', 'upsert', 'reset'],
60
+ [Archetype.Association]: [],
61
+ }
62
+
63
+ /** Controller actions per archetype. */
64
+ const ARCHETYPE_CONTROLLER: Record<Archetype, string[]> = {
65
+ [Archetype.Entity]: ['index', 'show', 'store', 'update', 'destroy'],
66
+ [Archetype.Attribute]: ['index', 'show', 'store', 'update', 'destroy'],
67
+ [Archetype.Contribution]: ['index', 'show', 'store', 'update', 'destroy'],
68
+ [Archetype.Reference]: ['index', 'show', 'store', 'update', 'destroy'],
69
+ [Archetype.Component]: ['index', 'show', 'update'],
70
+ [Archetype.Event]: ['index', 'show', 'store'],
71
+ [Archetype.Configuration]: ['show', 'update', 'destroy'],
72
+ [Archetype.Association]: [],
73
+ }
74
+
75
+ /** Maps controller action → policy method + whether it receives a loaded resource. */
76
+ const ACTION_POLICY: Record<
77
+ Archetype,
78
+ Record<string, { method: string; withResource: boolean }>
79
+ > = {
80
+ [Archetype.Entity]: {
81
+ index: { method: 'canList', withResource: false },
82
+ show: { method: 'canView', withResource: true },
83
+ store: { method: 'canCreate', withResource: false },
84
+ update: { method: 'canUpdate', withResource: true },
85
+ destroy: { method: 'canDelete', withResource: true },
86
+ },
87
+ [Archetype.Attribute]: {
88
+ index: { method: 'canList', withResource: false },
89
+ show: { method: 'canView', withResource: true },
90
+ store: { method: 'canCreate', withResource: false },
91
+ update: { method: 'canUpdate', withResource: true },
92
+ destroy: { method: 'canDelete', withResource: true },
93
+ },
94
+ [Archetype.Reference]: {
95
+ index: { method: 'canList', withResource: false },
96
+ show: { method: 'canView', withResource: true },
97
+ store: { method: 'canCreate', withResource: false },
98
+ update: { method: 'canUpdate', withResource: true },
99
+ destroy: { method: 'canDelete', withResource: true },
100
+ },
101
+ [Archetype.Contribution]: {
102
+ index: { method: 'canList', withResource: false },
103
+ show: { method: 'canView', withResource: true },
104
+ store: { method: 'canCreate', withResource: false },
105
+ update: { method: 'canUpdate', withResource: true },
106
+ destroy: { method: 'canDelete', withResource: true },
107
+ },
108
+ [Archetype.Component]: {
109
+ index: { method: 'canList', withResource: false },
110
+ show: { method: 'canView', withResource: true },
111
+ update: { method: 'canUpdate', withResource: true },
112
+ },
113
+ [Archetype.Event]: {
114
+ index: { method: 'canList', withResource: false },
115
+ show: { method: 'canView', withResource: true },
116
+ store: { method: 'canAppend', withResource: false },
117
+ },
118
+ [Archetype.Configuration]: {
119
+ show: { method: 'canView', withResource: true },
120
+ update: { method: 'canUpsert', withResource: false },
121
+ destroy: { method: 'canReset', withResource: false },
122
+ },
123
+ [Archetype.Association]: {},
124
+ }
125
+
126
+ /** Archetypes that have a parent FK (dependent archetypes). */
127
+ const PARENT_FK_ARCHETYPES: Set<Archetype> = new Set([
128
+ Archetype.Component,
129
+ Archetype.Attribute,
130
+ Archetype.Event,
131
+ Archetype.Configuration,
132
+ Archetype.Contribution,
133
+ ])
134
+
135
+ /** System-managed column names that should never appear in validators. */
136
+ const SYSTEM_COLUMNS = new Set(['id', 'created_at', 'updated_at', 'deleted_at'])
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // ApiGenerator
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export default class ApiGenerator {
143
+ private schemaMap: Map<string, SchemaDefinition>
144
+ private paths: GeneratorPaths
145
+
146
+ constructor(
147
+ private schemas: SchemaDefinition[],
148
+ private representation: DatabaseRepresentation,
149
+ config?: GeneratorConfig
150
+ ) {
151
+ this.schemaMap = new Map(schemas.map(s => [s.name, s]))
152
+ this.paths = resolvePaths(config)
153
+ }
154
+
155
+ /** Generate all file contents without writing to disk. */
156
+ generate(): GeneratedFile[] {
157
+ const eventFiles: GeneratedFile[] = []
158
+ const validatorFiles: GeneratedFile[] = []
159
+ const policyFiles: GeneratedFile[] = []
160
+ const serviceFiles: GeneratedFile[] = []
161
+ const controllerFiles: GeneratedFile[] = []
162
+ const resourceFiles: GeneratedFile[] = []
163
+
164
+ for (const schema of this.schemas) {
165
+ if (schema.archetype === Archetype.Association) continue
166
+
167
+ const table = this.representation.tables.find(t => t.name === toSnakeCase(schema.name))
168
+ if (!table) continue
169
+
170
+ eventFiles.push(this.generateEvents(schema))
171
+ validatorFiles.push(this.generateValidator(schema, table))
172
+ policyFiles.push(this.generatePolicy(schema))
173
+ serviceFiles.push(this.generateService(schema, table))
174
+ controllerFiles.push(this.generateController(schema, table))
175
+ resourceFiles.push(this.generateResource(schema, table))
176
+ }
177
+
178
+ const files = [
179
+ ...eventFiles,
180
+ ...validatorFiles,
181
+ ...policyFiles,
182
+ ...serviceFiles,
183
+ ...controllerFiles,
184
+ ...resourceFiles,
185
+ ]
186
+
187
+ // Barrel exports
188
+ if (eventFiles.length > 0) {
189
+ files.push(this.generateBarrel(this.paths.events, eventFiles, 'named'))
190
+ }
191
+ if (validatorFiles.length > 0) {
192
+ files.push(this.generateBarrel(this.paths.validators, validatorFiles, 'named'))
193
+ }
194
+ if (policyFiles.length > 0) {
195
+ files.push(this.generateBarrel(this.paths.policies, policyFiles, 'default'))
196
+ }
197
+ if (serviceFiles.length > 0) {
198
+ files.push(this.generateBarrel(this.paths.services, serviceFiles, 'default'))
199
+ }
200
+ if (controllerFiles.length > 0) {
201
+ files.push(this.generateBarrel(this.paths.controllers, controllerFiles, 'default'))
202
+ }
203
+ if (resourceFiles.length > 0) {
204
+ files.push(this.generateBarrel(this.paths.resources, resourceFiles, 'default'))
205
+ }
206
+
207
+ return files
208
+ }
209
+
210
+ /** Generate, format with Prettier, and write all files to disk. */
211
+ async writeAll(): Promise<GeneratedFile[]> {
212
+ const files = this.generate()
213
+ await formatAndWrite(files)
214
+ return files
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // 1. Event constants
219
+ // ---------------------------------------------------------------------------
220
+
221
+ private generateEvents(schema: SchemaDefinition): GeneratedFile {
222
+ const className = toPascalCase(schema.name)
223
+ const snakeName = toSnakeCase(schema.name)
224
+ const events = ARCHETYPE_EVENTS[schema.archetype] ?? []
225
+
226
+ const lines: string[] = [
227
+ '// Generated by Strav — DO NOT EDIT',
228
+ '',
229
+ `export const ${className}Events = {`,
230
+ ]
231
+
232
+ for (const event of events) {
233
+ lines.push(` ${event}: '${snakeName}.${event.toLowerCase()}',`)
234
+ }
235
+
236
+ lines.push('} as const')
237
+ lines.push('')
238
+
239
+ return {
240
+ path: join(this.paths.events, `${snakeName}.ts`),
241
+ content: lines.join('\n'),
242
+ }
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // 2. Validator rules
247
+ // ---------------------------------------------------------------------------
248
+
249
+ private generateValidator(schema: SchemaDefinition, table: TableDefinition): GeneratedFile {
250
+ const className = toPascalCase(schema.name)
251
+ const snakeName = toSnakeCase(schema.name)
252
+ const fields = this.getValidatableFields(schema, table)
253
+
254
+ // Collect which rule imports we need
255
+ const ruleImports = new Set<string>()
256
+ const enumImports = new Map<string, string[]>() // ownerEntity → [EnumName, ...]
257
+ const storeRules: [string, string[]][] = []
258
+ const updateRules: [string, string[]][] = []
259
+
260
+ for (const { fieldName, fieldDef, column } of fields) {
261
+ const camelName = toCamelCase(fieldName)
262
+
263
+ // Detect custom enum type → track import, pass enum name to rule builder
264
+ let enumName: string | undefined
265
+ if (isCustomType(fieldDef.pgType) && fieldDef.pgType.name) {
266
+ enumName = toPascalCase(fieldDef.pgType.name)
267
+ const ownerEntity = this.findEnumOwner(fieldDef.pgType.name)
268
+ const existing = enumImports.get(ownerEntity) ?? []
269
+ if (!existing.includes(enumName)) {
270
+ existing.push(enumName)
271
+ enumImports.set(ownerEntity, existing)
272
+ }
273
+ }
274
+
275
+ const store = this.buildFieldRules(fieldDef, column, true, enumName)
276
+ const update = this.buildFieldRules(fieldDef, column, false, enumName)
277
+
278
+ for (const r of store) ruleImports.add(r.name)
279
+ for (const r of update) ruleImports.add(r.name)
280
+
281
+ if (store.length > 0) storeRules.push([camelName, store.map(r => r.code)])
282
+ if (update.length > 0) updateRules.push([camelName, update.map(r => r.code)])
283
+ }
284
+
285
+ const lines: string[] = [
286
+ '// Generated by Strav — DO NOT EDIT',
287
+ `import { ${[...ruleImports].sort().join(', ')} } from '@stravigor/http/validation'`,
288
+ `import type { RuleSet } from '@stravigor/http/validation'`,
289
+ ]
290
+
291
+ const enumImportPath = relativeImport(this.paths.validators, this.paths.enums)
292
+ for (const [entity, enums] of enumImports) {
293
+ lines.push(`import { ${enums.join(', ')} } from '${enumImportPath}/${toSnakeCase(entity)}'`)
294
+ }
295
+
296
+ lines.push('')
297
+ lines.push(`export const ${className}Rules: Record<string, RuleSet> = {`)
298
+
299
+ // Store rules
300
+ const hasStore = ARCHETYPE_SERVICE[schema.archetype]?.some(
301
+ m => m === 'create' || m === 'append' || m === 'upsert'
302
+ )
303
+ if (hasStore && storeRules.length > 0) {
304
+ lines.push(' store: {')
305
+ for (const [name, rules] of storeRules) {
306
+ lines.push(` ${name}: [${rules.join(', ')}],`)
307
+ }
308
+ lines.push(' },')
309
+ }
310
+
311
+ // Update rules
312
+ const hasUpdate = ARCHETYPE_SERVICE[schema.archetype]?.some(
313
+ m => m === 'update' || m === 'upsert'
314
+ )
315
+ if (hasUpdate && updateRules.length > 0) {
316
+ lines.push(' update: {')
317
+ for (const [name, rules] of updateRules) {
318
+ lines.push(` ${name}: [${rules.join(', ')}],`)
319
+ }
320
+ lines.push(' },')
321
+ }
322
+
323
+ lines.push('}')
324
+ lines.push('')
325
+
326
+ return {
327
+ path: join(this.paths.validators, `${snakeName}_validator.ts`),
328
+ content: lines.join('\n'),
329
+ }
330
+ }
331
+
332
+ /** Get fields that should appear in validators (exclude system-managed columns). */
333
+ private getValidatableFields(
334
+ schema: SchemaDefinition,
335
+ table: TableDefinition
336
+ ): { fieldName: string; fieldDef: FieldDefinition; column: ColumnDefinition | undefined }[] {
337
+ const parentFkCols = new Set(
338
+ (schema.parents ?? []).map(p => `${toSnakeCase(p)}_${toSnakeCase(this.findSchemaPK(p))}`)
339
+ )
340
+
341
+ const result: {
342
+ fieldName: string
343
+ fieldDef: FieldDefinition
344
+ column: ColumnDefinition | undefined
345
+ }[] = []
346
+
347
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
348
+ if (fieldDef.primaryKey) continue
349
+
350
+ // Reference fields → use FK column name and referenced PK type
351
+ if (fieldDef.references) {
352
+ const refPK = this.findSchemaPK(fieldDef.references)
353
+ const fkColName = `${toSnakeCase(fieldName)}_${toSnakeCase(refPK)}`
354
+ if (SYSTEM_COLUMNS.has(fkColName)) continue
355
+ if (parentFkCols.has(fkColName)) continue
356
+
357
+ // Resolve the referenced PK's pgType for validation
358
+ let fkPgType = fieldDef.pgType
359
+ const refSchema = this.schemaMap.get(fieldDef.references)
360
+ if (refSchema) {
361
+ for (const [, fd] of Object.entries(refSchema.fields)) {
362
+ if (fd.primaryKey) {
363
+ fkPgType = fd.pgType
364
+ break
365
+ }
366
+ }
367
+ }
368
+
369
+ const column = table.columns.find(c => c.name === fkColName)
370
+ result.push({
371
+ fieldName: fkColName,
372
+ fieldDef: { ...fieldDef, pgType: fkPgType, references: undefined, validators: [] },
373
+ column,
374
+ })
375
+ continue
376
+ }
377
+
378
+ const colName = toSnakeCase(fieldName)
379
+ if (SYSTEM_COLUMNS.has(colName)) continue
380
+ if (parentFkCols.has(colName)) continue
381
+
382
+ const column = table.columns.find(c => c.name === colName)
383
+ result.push({ fieldName, fieldDef, column })
384
+ }
385
+
386
+ return result
387
+ }
388
+
389
+ /** Build validation rule calls for a single field. */
390
+ private buildFieldRules(
391
+ fieldDef: FieldDefinition,
392
+ column: ColumnDefinition | undefined,
393
+ isStore: boolean,
394
+ enumName?: string
395
+ ): { name: string; code: string }[] {
396
+ const rules: { name: string; code: string }[] = []
397
+
398
+ // required — only on store, when field is required
399
+ if (isStore && fieldDef.required) {
400
+ rules.push({ name: 'required', code: 'required()' })
401
+ }
402
+
403
+ // type rule based on pgType
404
+ const typeRule = this.pgTypeToRule(fieldDef.pgType)
405
+ if (typeRule) rules.push(typeRule)
406
+
407
+ // enum: custom type → enumOf(Enum), inline values → oneOf([...])
408
+ if (enumName) {
409
+ rules.push({ name: 'enumOf', code: `enumOf(${enumName})` })
410
+ } else if (fieldDef.enumValues?.length) {
411
+ const vals = fieldDef.enumValues.map(v => `'${v}'`).join(', ')
412
+ rules.push({ name: 'oneOf', code: `oneOf([${vals}])` })
413
+ }
414
+
415
+ // length constraint for varchar
416
+ if (fieldDef.length) {
417
+ rules.push({ name: 'max', code: `max(${fieldDef.length})` })
418
+ }
419
+
420
+ // Schema-level validators
421
+ for (const v of fieldDef.validators) {
422
+ const rule = this.validatorToRule(v)
423
+ if (rule) rules.push(rule)
424
+ }
425
+
426
+ return rules
427
+ }
428
+
429
+ /** Map a PostgreSQL type to its corresponding validation rule. */
430
+ private pgTypeToRule(pgType: unknown): { name: string; code: string } | null {
431
+ if (typeof pgType !== 'string') return null
432
+
433
+ switch (pgType) {
434
+ case 'varchar':
435
+ case 'character_varying':
436
+ case 'char':
437
+ case 'character':
438
+ case 'text':
439
+ case 'uuid':
440
+ return { name: 'string', code: 'string()' }
441
+ case 'integer':
442
+ case 'smallint':
443
+ case 'serial':
444
+ case 'smallserial':
445
+ return { name: 'integer', code: 'integer()' }
446
+ case 'bigint':
447
+ case 'bigserial':
448
+ case 'real':
449
+ case 'double_precision':
450
+ case 'decimal':
451
+ case 'numeric':
452
+ case 'money':
453
+ return { name: 'number', code: 'number()' }
454
+ case 'boolean':
455
+ return { name: 'boolean', code: 'boolean()' }
456
+ default:
457
+ return null
458
+ }
459
+ }
460
+
461
+ /** Convert a schema FieldValidator to a rule call. */
462
+ private validatorToRule(v: FieldValidator): { name: string; code: string } | null {
463
+ switch (v.type) {
464
+ case 'min':
465
+ return { name: 'min', code: `min(${v.params?.value ?? 0})` }
466
+ case 'max':
467
+ return { name: 'max', code: `max(${v.params?.value ?? 0})` }
468
+ case 'email':
469
+ return { name: 'email', code: 'email()' }
470
+ case 'url':
471
+ return { name: 'url', code: 'url()' }
472
+ case 'regex':
473
+ return v.params?.pattern ? { name: 'regex', code: `regex(${v.params.pattern})` } : null
474
+ default:
475
+ return null
476
+ }
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // 3. Policy skeleton
481
+ // ---------------------------------------------------------------------------
482
+
483
+ private generatePolicy(schema: SchemaDefinition): GeneratedFile {
484
+ const className = toPascalCase(schema.name)
485
+ const snakeName = toSnakeCase(schema.name)
486
+ const methods = ARCHETYPE_POLICY[schema.archetype] ?? []
487
+
488
+ const lines: string[] = [
489
+ '// Generated by Strav — DO NOT EDIT',
490
+ `import { allow } from '@stravigor/http/policy'`,
491
+ `import type { PolicyResult } from '@stravigor/http/policy'`,
492
+ '',
493
+ `export default class ${className}Policy {`,
494
+ ]
495
+
496
+ const isDependent = PARENT_FK_ARCHETYPES.has(schema.archetype)
497
+
498
+ for (let i = 0; i < methods.length; i++) {
499
+ const method = methods[i]!
500
+ // Methods that receive a resource as second arg
501
+ const withResource = ['canView', 'canUpdate', 'canDelete', 'canModerate'].includes(method)
502
+ let params: string
503
+ if (withResource) {
504
+ params = `actor: any, ${toCamelCase(schema.name)}: any`
505
+ } else if (isDependent) {
506
+ params = 'actor: any, parentId: string | number'
507
+ } else {
508
+ params = 'actor: any'
509
+ }
510
+
511
+ lines.push(` static ${method}(${params}): PolicyResult {`)
512
+ lines.push(' return allow()')
513
+ lines.push(' }')
514
+ if (i < methods.length - 1) lines.push('')
515
+ }
516
+
517
+ lines.push('}')
518
+ lines.push('')
519
+
520
+ return {
521
+ path: join(this.paths.policies, `${snakeName}_policy.ts`),
522
+ content: lines.join('\n'),
523
+ }
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // 4. Service
528
+ // ---------------------------------------------------------------------------
529
+
530
+ private generateService(schema: SchemaDefinition, table: TableDefinition): GeneratedFile {
531
+ const className = toPascalCase(schema.name)
532
+ const snakeName = toSnakeCase(schema.name)
533
+ const camelName = toCamelCase(schema.name)
534
+ const methods = ARCHETYPE_SERVICE[schema.archetype] ?? []
535
+ const isDependent = PARENT_FK_ARCHETYPES.has(schema.archetype)
536
+ const parentName = schema.parents?.[0]
537
+ const parentClassName = parentName ? toPascalCase(parentName) : null
538
+ const parentFkProp = parentName
539
+ ? toCamelCase(`${toSnakeCase(parentName)}_${toSnakeCase(this.findSchemaPK(parentName))}`)
540
+ : null
541
+
542
+ const needsQuery = methods.some(
543
+ m => m === 'list' || m === 'listByParent' || m === 'get' || m === 'upsert' || m === 'reset'
544
+ )
545
+
546
+ const modelImport = relativeImport(this.paths.services, this.paths.models)
547
+ const eventImport = relativeImport(this.paths.services, this.paths.events)
548
+
549
+ const lines: string[] = [
550
+ '// Generated by Strav — DO NOT EDIT',
551
+ `import { inject } from '@stravigor/kernel/core/inject'`,
552
+ `import ${className} from '${modelImport}/${snakeName}'`,
553
+ `import { ${className}Events } from '${eventImport}/${snakeName}'`,
554
+ `import Emitter from '@stravigor/kernel/events/emitter'`,
555
+ ]
556
+ if (needsQuery) {
557
+ lines.push(`import { query } from '@stravigor/database/database'`)
558
+ }
559
+ lines.push('')
560
+ lines.push(`@inject`)
561
+ lines.push(`export default class ${className}Service {`)
562
+
563
+ for (let i = 0; i < methods.length; i++) {
564
+ const method = methods[i]!
565
+ lines.push(...this.generateServiceMethod(method, schema, className, camelName, parentFkProp))
566
+ if (i < methods.length - 1) lines.push('')
567
+ }
568
+
569
+ lines.push('}')
570
+ lines.push('')
571
+
572
+ return {
573
+ path: join(this.paths.services, `${snakeName}_service.ts`),
574
+ content: lines.join('\n'),
575
+ }
576
+ }
577
+
578
+ private generateServiceMethod(
579
+ method: string,
580
+ schema: SchemaDefinition,
581
+ className: string,
582
+ camelName: string,
583
+ parentFkProp: string | null
584
+ ): string[] {
585
+ const lines: string[] = []
586
+
587
+ switch (method) {
588
+ case 'list':
589
+ lines.push(` async list(page: number, perPage: number) {`)
590
+ lines.push(` return query(${className}).paginate(page, perPage)`)
591
+ lines.push(` }`)
592
+ break
593
+
594
+ case 'listByParent':
595
+ lines.push(
596
+ ` async listByParent(parentId: string | number, page: number, perPage: number) {`
597
+ )
598
+ lines.push(
599
+ ` return query(${className}).where('${parentFkProp}', parentId).paginate(page, perPage)`
600
+ )
601
+ lines.push(` }`)
602
+ break
603
+
604
+ case 'find':
605
+ lines.push(` async find(id: string | number) {`)
606
+ lines.push(` return ${className}.find(id)`)
607
+ lines.push(` }`)
608
+ break
609
+
610
+ case 'create':
611
+ lines.push(` async create(data: Record<string, unknown>) {`)
612
+ lines.push(` const ${camelName} = new ${className}()`)
613
+ lines.push(` ${camelName}.merge(data)`)
614
+ lines.push(` await ${camelName}.save()`)
615
+ lines.push(` await Emitter.emit(${className}Events.CREATED, ${camelName})`)
616
+ lines.push(` return ${camelName}`)
617
+ lines.push(` }`)
618
+ break
619
+
620
+ case 'append':
621
+ lines.push(` async append(data: Record<string, unknown>) {`)
622
+ lines.push(` const ${camelName} = new ${className}()`)
623
+ lines.push(` ${camelName}.merge(data)`)
624
+ lines.push(` await ${camelName}.save()`)
625
+ lines.push(` await Emitter.emit(${className}Events.CREATED, ${camelName})`)
626
+ lines.push(` return ${camelName}`)
627
+ lines.push(` }`)
628
+ break
629
+
630
+ case 'update':
631
+ lines.push(` async update(id: string | number, data: Record<string, unknown>) {`)
632
+ lines.push(` const ${camelName} = await ${className}.find(id)`)
633
+ lines.push(` if (!${camelName}) return null`)
634
+ lines.push(` ${camelName}.merge(data)`)
635
+ lines.push(` await ${camelName}.save()`)
636
+ lines.push(` await Emitter.emit(${className}Events.UPDATED, ${camelName})`)
637
+ lines.push(` return ${camelName}`)
638
+ lines.push(` }`)
639
+ break
640
+
641
+ case 'delete':
642
+ lines.push(` async delete(id: string | number) {`)
643
+ lines.push(` const ${camelName} = await ${className}.find(id)`)
644
+ lines.push(` if (!${camelName}) return false`)
645
+ lines.push(` await ${camelName}.delete()`)
646
+ lines.push(` await Emitter.emit(${className}Events.DELETED, ${camelName})`)
647
+ lines.push(` return true`)
648
+ lines.push(` }`)
649
+ break
650
+
651
+ case 'get':
652
+ lines.push(` async get(parentId: string | number) {`)
653
+ lines.push(` return query(${className}).where('${parentFkProp}', parentId).first()`)
654
+ lines.push(` }`)
655
+ break
656
+
657
+ case 'upsert':
658
+ lines.push(` async upsert(parentId: string | number, data: Record<string, unknown>) {`)
659
+ lines.push(
660
+ ` let ${camelName} = await query(${className}).where('${parentFkProp}', parentId).first()`
661
+ )
662
+ lines.push(` if (${camelName}) {`)
663
+ lines.push(` ${camelName}.merge(data)`)
664
+ lines.push(` } else {`)
665
+ lines.push(` ${camelName} = new ${className}()`)
666
+ lines.push(` ${camelName}.merge({ ${parentFkProp}: parentId, ...data })`)
667
+ lines.push(` }`)
668
+ lines.push(` await ${camelName}.save()`)
669
+ lines.push(` await Emitter.emit(${className}Events.UPDATED, ${camelName})`)
670
+ lines.push(` return ${camelName}`)
671
+ lines.push(` }`)
672
+ break
673
+
674
+ case 'reset':
675
+ lines.push(` async reset(parentId: string | number) {`)
676
+ lines.push(
677
+ ` const ${camelName} = await query(${className}).where('${parentFkProp}', parentId).first()`
678
+ )
679
+ lines.push(` if (!${camelName}) return false`)
680
+ lines.push(` await ${camelName}.delete()`)
681
+ lines.push(` return true`)
682
+ lines.push(` }`)
683
+ break
684
+ }
685
+
686
+ return lines
687
+ }
688
+
689
+ // ---------------------------------------------------------------------------
690
+ // 5. Controller
691
+ // ---------------------------------------------------------------------------
692
+
693
+ private generateController(schema: SchemaDefinition, table: TableDefinition): GeneratedFile {
694
+ const className = toPascalCase(schema.name)
695
+ const snakeName = toSnakeCase(schema.name)
696
+ const camelName = toCamelCase(schema.name)
697
+ const actions = ARCHETYPE_CONTROLLER[schema.archetype] ?? []
698
+ const isDependent = PARENT_FK_ARCHETYPES.has(schema.archetype)
699
+ const isConfiguration = schema.archetype === Archetype.Configuration
700
+ const isEvent = schema.archetype === Archetype.Event
701
+
702
+ const serviceImport = relativeImport(this.paths.controllers, this.paths.services)
703
+ const validatorImport = relativeImport(this.paths.controllers, this.paths.validators)
704
+ const policyImport = relativeImport(this.paths.controllers, this.paths.policies)
705
+ const resourceImport = relativeImport(this.paths.controllers, this.paths.resources)
706
+
707
+ const lines: string[] = [
708
+ '// Generated by Strav — DO NOT EDIT',
709
+ `import { inject } from '@stravigor/kernel/core/inject'`,
710
+ `import type Context from '@stravigor/http/http/context'`,
711
+ `import { validate } from '@stravigor/http/validation'`,
712
+ `import ${className}Service from '${serviceImport}/${snakeName}_service'`,
713
+ `import { ${className}Rules } from '${validatorImport}/${snakeName}_validator'`,
714
+ `import ${className}Policy from '${policyImport}/${snakeName}_policy'`,
715
+ `import ${className}Resource from '${resourceImport}/${snakeName}_resource'`,
716
+ '',
717
+ `@inject`,
718
+ `export default class ${className}Controller {`,
719
+ ` constructor(protected service: ${className}Service) {}`,
720
+ ]
721
+
722
+ for (const action of actions) {
723
+ lines.push('')
724
+ lines.push(
725
+ ...this.generateControllerAction(
726
+ action,
727
+ schema,
728
+ className,
729
+ camelName,
730
+ isDependent,
731
+ isConfiguration,
732
+ isEvent
733
+ )
734
+ )
735
+ }
736
+
737
+ lines.push('}')
738
+ lines.push('')
739
+
740
+ return {
741
+ path: join(this.paths.controllers, `${snakeName}_controller.ts`),
742
+ content: lines.join('\n'),
743
+ }
744
+ }
745
+
746
+ private generateControllerAction(
747
+ action: string,
748
+ schema: SchemaDefinition,
749
+ className: string,
750
+ camelName: string,
751
+ isDependent: boolean,
752
+ isConfiguration: boolean,
753
+ isEvent: boolean
754
+ ): string[] {
755
+ const lines: string[] = []
756
+ const parentParam = isDependent ? `ctx.params.parentId!` : null
757
+ const policy = ACTION_POLICY[schema.archetype]?.[action]
758
+ const policyClass = `${className}Policy`
759
+
760
+ // Whether there are content lines above (to decide blank line before comment)
761
+ let hasContentAbove = false
762
+
763
+ // Helper: emit policy guard (no resource)
764
+ const guardNoResource = () => {
765
+ if (!policy) return
766
+ if (hasContentAbove) lines.push('')
767
+ lines.push(` // Check policy`)
768
+ lines.push(` const actor = ctx.get('user')`)
769
+ const policyArgs = isDependent ? `actor, ${parentParam}` : 'actor'
770
+ lines.push(` const access = ${policyClass}.${policy.method}(${policyArgs})`)
771
+ lines.push(
772
+ ` if (!access.allowed) return ctx.json({ error: access.reason }, access.status)`
773
+ )
774
+ }
775
+
776
+ // Helper: emit policy guard (with resource)
777
+ const guardWithResource = (resourceVar: string) => {
778
+ if (!policy) return
779
+ if (hasContentAbove) lines.push('')
780
+ lines.push(` // Check policy`)
781
+ lines.push(` const actor = ctx.get('user')`)
782
+ lines.push(` const access = ${policyClass}.${policy.method}(actor, ${resourceVar})`)
783
+ lines.push(
784
+ ` if (!access.allowed) return ctx.json({ error: access.reason }, access.status)`
785
+ )
786
+ }
787
+
788
+ switch (action) {
789
+ case 'index':
790
+ lines.push(` async index(ctx: Context) {`)
791
+ guardNoResource()
792
+ lines.push('')
793
+ lines.push(` // Pagination`)
794
+ lines.push(` const page = Number(ctx.query.get('page')) || 1`)
795
+ lines.push(` const perPage = Number(ctx.query.get('perPage')) || 20`)
796
+ lines.push('')
797
+ lines.push(` // Execute business logic`)
798
+ if (isDependent) {
799
+ lines.push(
800
+ ` const result = await this.service.listByParent(${parentParam}, page, perPage)`
801
+ )
802
+ } else {
803
+ lines.push(` const result = await this.service.list(page, perPage)`)
804
+ }
805
+ lines.push('')
806
+ lines.push(` // Done.`)
807
+ lines.push(` return ctx.json(${className}Resource.paginate(result))`)
808
+ lines.push(` }`)
809
+ break
810
+
811
+ case 'show':
812
+ lines.push(` async show(ctx: Context) {`)
813
+ if (isConfiguration) {
814
+ lines.push(` const item = await this.service.get(ctx.params.parentId!)`)
815
+ } else {
816
+ lines.push(` const item = await this.service.find(ctx.params.id!)`)
817
+ }
818
+ lines.push(` if (!item) return ctx.json({ error: 'Not Found' }, 404)`)
819
+ hasContentAbove = true
820
+ if (policy?.withResource) {
821
+ guardWithResource('item')
822
+ } else {
823
+ guardNoResource()
824
+ }
825
+ lines.push('')
826
+ lines.push(` // Done.`)
827
+ lines.push(` return ctx.json(${className}Resource.make(item))`)
828
+ lines.push(` }`)
829
+ break
830
+
831
+ case 'store': {
832
+ const serviceCall = isEvent ? 'this.service.append' : 'this.service.create'
833
+ lines.push(` async store(ctx: Context) {`)
834
+ guardNoResource()
835
+ lines.push('')
836
+ lines.push(` // Validate user input`)
837
+ lines.push(` const body = await ctx.body<Record<string, unknown>>()`)
838
+ lines.push(
839
+ ` const { data: validated, errors } = validate(body, ${className}Rules.store!)`
840
+ )
841
+ lines.push(` if (errors) return ctx.json({ errors }, 422)`)
842
+ lines.push('')
843
+ lines.push(` // Execute business logic`)
844
+ if (isDependent) {
845
+ lines.push(
846
+ ` const item = await ${serviceCall}({ ...validated, ${this.getParentFkAssignment(schema)} })`
847
+ )
848
+ } else {
849
+ lines.push(` const item = await ${serviceCall}(validated)`)
850
+ }
851
+ lines.push('')
852
+ lines.push(` // Done.`)
853
+ lines.push(` return ctx.json(${className}Resource.make(item), 201)`)
854
+ lines.push(` }`)
855
+ break
856
+ }
857
+
858
+ case 'update':
859
+ lines.push(` async update(ctx: Context) {`)
860
+ if (isConfiguration) {
861
+ guardNoResource()
862
+ lines.push('')
863
+ lines.push(` // Validate user input`)
864
+ lines.push(` const body = await ctx.body<Record<string, unknown>>()`)
865
+ lines.push(
866
+ ` const { data: validated, errors } = validate(body, ${className}Rules.update!)`
867
+ )
868
+ lines.push(` if (errors) return ctx.json({ errors }, 422)`)
869
+ lines.push('')
870
+ lines.push(` // Execute business logic`)
871
+ lines.push(` const item = await this.service.upsert(ctx.params.parentId!, validated)`)
872
+ lines.push('')
873
+ lines.push(` // Done.`)
874
+ lines.push(` return ctx.json(${className}Resource.make(item))`)
875
+ } else {
876
+ lines.push(` const item = await this.service.find(ctx.params.id!)`)
877
+ lines.push(` if (!item) return ctx.json({ error: 'Not Found' }, 404)`)
878
+ hasContentAbove = true
879
+ if (policy?.withResource) {
880
+ guardWithResource('item')
881
+ }
882
+ lines.push('')
883
+ lines.push(` // Validate user input`)
884
+ lines.push(` const body = await ctx.body<Record<string, unknown>>()`)
885
+ lines.push(
886
+ ` const { data: validated, errors } = validate(body, ${className}Rules.update!)`
887
+ )
888
+ lines.push(` if (errors) return ctx.json({ errors }, 422)`)
889
+ lines.push('')
890
+ lines.push(` // Execute business logic`)
891
+ lines.push(` const updated = await this.service.update(ctx.params.id!, validated)`)
892
+ lines.push(` if (!updated) return ctx.json({ error: 'Not Found' }, 404)`)
893
+ lines.push('')
894
+ lines.push(` // Done.`)
895
+ lines.push(` return ctx.json(${className}Resource.make(updated))`)
896
+ }
897
+ lines.push(` }`)
898
+ break
899
+
900
+ case 'destroy':
901
+ lines.push(` async destroy(ctx: Context) {`)
902
+ if (isConfiguration) {
903
+ guardNoResource()
904
+ lines.push('')
905
+ lines.push(` // Execute business logic`)
906
+ lines.push(` const deleted = await this.service.reset(ctx.params.parentId!)`)
907
+ lines.push(` if (!deleted) return ctx.json({ error: 'Not Found' }, 404)`)
908
+ } else {
909
+ lines.push(` const item = await this.service.find(ctx.params.id!)`)
910
+ lines.push(` if (!item) return ctx.json({ error: 'Not Found' }, 404)`)
911
+ hasContentAbove = true
912
+ if (policy?.withResource) {
913
+ guardWithResource('item')
914
+ }
915
+ lines.push('')
916
+ lines.push(` // Execute business logic`)
917
+ lines.push(` const deleted = await this.service.delete(ctx.params.id!)`)
918
+ lines.push(` if (!deleted) return ctx.json({ error: 'Not Found' }, 404)`)
919
+ }
920
+ lines.push('')
921
+ lines.push(` // Done.`)
922
+ lines.push(` return ctx.json({ ok: true })`)
923
+ lines.push(` }`)
924
+ break
925
+ }
926
+
927
+ return lines
928
+ }
929
+
930
+ // ---------------------------------------------------------------------------
931
+ // 6. Resource
932
+ // ---------------------------------------------------------------------------
933
+
934
+ private generateResource(schema: SchemaDefinition, table: TableDefinition): GeneratedFile {
935
+ const className = toPascalCase(schema.name)
936
+ const snakeName = toSnakeCase(schema.name)
937
+ const camelName = toCamelCase(schema.name)
938
+ const modelImport = relativeImport(this.paths.resources, this.paths.models)
939
+
940
+ const lines: string[] = [
941
+ '// Generated by Strav — DO NOT EDIT',
942
+ `import { Resource } from '@stravigor/http/http'`,
943
+ `import type ${className} from '${modelImport}/${snakeName}'`,
944
+ '',
945
+ `export default class ${className}Resource extends Resource<${className}> {`,
946
+ ` define(${camelName}: ${className}) {`,
947
+ ` return {`,
948
+ ]
949
+
950
+ for (const col of table.columns) {
951
+ if (col.name === 'deleted_at') continue
952
+ if (col.sensitive) continue
953
+ const propName = toCamelCase(col.name)
954
+ lines.push(` ${propName}: ${camelName}.${propName},`)
955
+ }
956
+
957
+ lines.push(` }`)
958
+ lines.push(` }`)
959
+ lines.push(`}`)
960
+ lines.push('')
961
+
962
+ return {
963
+ path: join(this.paths.resources, `${snakeName}_resource.ts`),
964
+ content: lines.join('\n'),
965
+ }
966
+ }
967
+
968
+ /** Generate the parent FK assignment expression for dependent controllers. */
969
+ private getParentFkAssignment(schema: SchemaDefinition): string {
970
+ const routeParent = schema.parents?.[0]
971
+ if (!routeParent) return ''
972
+ const pkName = this.findSchemaPK(routeParent)
973
+ const fkProp = toCamelCase(`${toSnakeCase(routeParent)}_${toSnakeCase(pkName)}`)
974
+ return `${fkProp}: ctx.params.parentId!`
975
+ }
976
+
977
+ // ---------------------------------------------------------------------------
978
+ // Barrel generation
979
+ // ---------------------------------------------------------------------------
980
+
981
+ private generateBarrel(
982
+ dir: string,
983
+ files: GeneratedFile[],
984
+ mode: 'default' | 'named'
985
+ ): GeneratedFile {
986
+ const lines: string[] = ['// Generated by Strav — DO NOT EDIT', '']
987
+
988
+ for (const file of files) {
989
+ const basename = file.path.split('/').pop()!.replace(/\.ts$/, '')
990
+ if (mode === 'named') {
991
+ lines.push(`export * from './${basename}'`)
992
+ } else {
993
+ const className = toPascalCase(basename)
994
+ lines.push(`export { default as ${className} } from './${basename}'`)
995
+ }
996
+ }
997
+
998
+ lines.push('')
999
+
1000
+ return {
1001
+ path: join(dir, 'index.ts'),
1002
+ content: lines.join('\n'),
1003
+ }
1004
+ }
1005
+
1006
+ // ---------------------------------------------------------------------------
1007
+ // Shared helpers
1008
+ // ---------------------------------------------------------------------------
1009
+
1010
+ /** Find the primary key field name (camelCase) for a schema. Defaults to 'id'. */
1011
+ private findSchemaPK(schemaName: string): string {
1012
+ const schema = this.schemaMap.get(schemaName)
1013
+ if (!schema) return 'id'
1014
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
1015
+ if (fieldDef.primaryKey) return fieldName
1016
+ }
1017
+ return 'id'
1018
+ }
1019
+
1020
+ /** Find which schema owns an enum by matching pgType.name across all fields. */
1021
+ private findEnumOwner(enumName: string): string {
1022
+ for (const schema of this.schemas) {
1023
+ for (const fieldDef of Object.values(schema.fields)) {
1024
+ if (isCustomType(fieldDef.pgType) && fieldDef.pgType.name === enumName) {
1025
+ return schema.name
1026
+ }
1027
+ }
1028
+ }
1029
+ const idx = enumName.lastIndexOf('_')
1030
+ return idx > 0 ? enumName.substring(0, idx) : enumName
1031
+ }
1032
+ }
1033
+
1034
+ function isCustomType(pgType: unknown): pgType is PostgreSQLCustomType {
1035
+ return typeof pgType === 'object' && pgType !== null && (pgType as any).type === 'custom'
1036
+ }