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