@strav/cli 0.4.31 → 1.0.0-alpha.4

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