@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,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
|
+
}
|