@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.
- package/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- 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
|
+
}
|