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