@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,378 @@
|
|
|
1
|
+
import type { ColumnDefinition, DefaultValue } from '../../schema/database_representation.ts'
|
|
2
|
+
import type { PostgreSQLType } from '../../schema/postgres.ts'
|
|
3
|
+
import type {
|
|
4
|
+
SchemaDiff,
|
|
5
|
+
GeneratedSql,
|
|
6
|
+
EnumDiff,
|
|
7
|
+
TableDiff,
|
|
8
|
+
ColumnDiff,
|
|
9
|
+
ConstraintDiff,
|
|
10
|
+
IndexDiff,
|
|
11
|
+
} from './types.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generates SQL migration statements from a {@link SchemaDiff}.
|
|
15
|
+
*
|
|
16
|
+
* Output is split into four categories matching the migration file structure:
|
|
17
|
+
* enums, tables, constraints, indexes — each with up and down SQL.
|
|
18
|
+
*/
|
|
19
|
+
export default class SqlGenerator {
|
|
20
|
+
generate(diff: SchemaDiff): GeneratedSql {
|
|
21
|
+
return {
|
|
22
|
+
enumsUp: this.generateEnumsUp(diff.enums),
|
|
23
|
+
enumsDown: this.generateEnumsDown(diff.enums),
|
|
24
|
+
tables: this.generateTables(diff.tables),
|
|
25
|
+
constraintsUp: this.generateConstraintsUp(diff.constraints),
|
|
26
|
+
constraintsDown: this.generateConstraintsDown(diff.constraints),
|
|
27
|
+
indexesUp: this.generateIndexesUp(diff.indexes),
|
|
28
|
+
indexesDown: this.generateIndexesDown(diff.indexes),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Enums
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
private generateEnumsUp(diffs: EnumDiff[]): string {
|
|
37
|
+
const lines: string[] = []
|
|
38
|
+
for (const d of diffs) {
|
|
39
|
+
if (d.kind === 'create') {
|
|
40
|
+
const vals = d.values.map(v => `'${v}'`).join(', ')
|
|
41
|
+
lines.push(`-- Create enum: ${d.name}`)
|
|
42
|
+
lines.push(`CREATE TYPE "${d.name}" AS ENUM (${vals});\n`)
|
|
43
|
+
} else if (d.kind === 'modify') {
|
|
44
|
+
lines.push(`-- Add values to enum: ${d.name}`)
|
|
45
|
+
for (const v of d.addedValues) {
|
|
46
|
+
lines.push(`ALTER TYPE "${d.name}" ADD VALUE '${v}';`)
|
|
47
|
+
}
|
|
48
|
+
lines.push('')
|
|
49
|
+
} else if (d.kind === 'drop') {
|
|
50
|
+
lines.push(`-- Drop enum: ${d.name}`)
|
|
51
|
+
lines.push(`DROP TYPE IF EXISTS "${d.name}";\n`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return lines.join('\n').trim()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private generateEnumsDown(diffs: EnumDiff[]): string {
|
|
58
|
+
const lines: string[] = []
|
|
59
|
+
for (const d of diffs) {
|
|
60
|
+
if (d.kind === 'create') {
|
|
61
|
+
lines.push(`-- Reverse: drop enum ${d.name}`)
|
|
62
|
+
lines.push(`DROP TYPE IF EXISTS "${d.name}";\n`)
|
|
63
|
+
} else if (d.kind === 'modify') {
|
|
64
|
+
lines.push(`-- Reverse: cannot remove enum values for ${d.name}`)
|
|
65
|
+
lines.push(
|
|
66
|
+
`-- Manual intervention required to remove values: ${d.addedValues.join(', ')}\n`
|
|
67
|
+
)
|
|
68
|
+
} else if (d.kind === 'drop') {
|
|
69
|
+
const vals = d.values.map(v => `'${v}'`).join(', ')
|
|
70
|
+
lines.push(`-- Reverse: recreate enum ${d.name}`)
|
|
71
|
+
lines.push(`CREATE TYPE "${d.name}" AS ENUM (${vals});\n`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return lines.join('\n').trim()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Tables
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
private generateTables(diffs: TableDiff[]): Map<string, { up: string; down: string }> {
|
|
82
|
+
const result = new Map<string, { up: string; down: string }>()
|
|
83
|
+
|
|
84
|
+
for (const d of diffs) {
|
|
85
|
+
if (d.kind === 'create') {
|
|
86
|
+
result.set(d.table.name, {
|
|
87
|
+
up: this.generateCreateTable(
|
|
88
|
+
d.table.name,
|
|
89
|
+
d.table.columns,
|
|
90
|
+
d.table.primaryKey?.columns ?? []
|
|
91
|
+
),
|
|
92
|
+
down: `-- Drop table: ${d.table.name}\nDROP TABLE IF EXISTS "${d.table.name}" CASCADE;`,
|
|
93
|
+
})
|
|
94
|
+
} else if (d.kind === 'drop') {
|
|
95
|
+
result.set(d.table.name, {
|
|
96
|
+
up: `-- Drop table: ${d.table.name}\nDROP TABLE IF EXISTS "${d.table.name}" CASCADE;`,
|
|
97
|
+
down: this.generateCreateTable(
|
|
98
|
+
d.table.name,
|
|
99
|
+
d.table.columns,
|
|
100
|
+
d.table.primaryKey?.columns ?? []
|
|
101
|
+
),
|
|
102
|
+
})
|
|
103
|
+
} else if (d.kind === 'modify') {
|
|
104
|
+
result.set(d.tableName, {
|
|
105
|
+
up: this.generateAlterUp(d.tableName, d.columns),
|
|
106
|
+
down: this.generateAlterDown(d.tableName, d.columns),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private generateCreateTable(
|
|
115
|
+
name: string,
|
|
116
|
+
columns: ColumnDefinition[],
|
|
117
|
+
pkColumns: string[]
|
|
118
|
+
): string {
|
|
119
|
+
const lines: string[] = []
|
|
120
|
+
lines.push(`-- Create table: ${name}`)
|
|
121
|
+
lines.push(`CREATE TABLE IF NOT EXISTS "${name}" (`)
|
|
122
|
+
|
|
123
|
+
const colDefs: string[] = []
|
|
124
|
+
for (const c of columns) {
|
|
125
|
+
colDefs.push(` ${columnToSql(c)}`)
|
|
126
|
+
}
|
|
127
|
+
if (pkColumns.length > 0) {
|
|
128
|
+
const pkCols = pkColumns.map(c => `"${c}"`).join(', ')
|
|
129
|
+
colDefs.push(` CONSTRAINT "pk_${name}" PRIMARY KEY (${pkCols})`)
|
|
130
|
+
}
|
|
131
|
+
lines.push(colDefs.join(',\n'))
|
|
132
|
+
lines.push(');')
|
|
133
|
+
return lines.join('\n')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private generateAlterUp(tableName: string, columns: ColumnDiff[]): string {
|
|
137
|
+
const lines: string[] = []
|
|
138
|
+
lines.push(`-- Modify table: ${tableName}`)
|
|
139
|
+
|
|
140
|
+
for (const c of columns) {
|
|
141
|
+
if (c.kind === 'add') {
|
|
142
|
+
lines.push(`ALTER TABLE "${tableName}" ADD COLUMN ${columnToSql(c.column)};`)
|
|
143
|
+
} else if (c.kind === 'drop') {
|
|
144
|
+
lines.push(`ALTER TABLE "${tableName}" DROP COLUMN IF EXISTS "${c.column.name}";`)
|
|
145
|
+
} else if (c.kind === 'alter') {
|
|
146
|
+
if (c.typeChange) {
|
|
147
|
+
const newType = pgTypeToSql(c.typeChange.to)
|
|
148
|
+
lines.push(
|
|
149
|
+
`ALTER TABLE "${tableName}" ALTER COLUMN "${c.columnName}" TYPE ${newType} USING "${c.columnName}"::${newType};`
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
if (c.nullableChange) {
|
|
153
|
+
if (c.nullableChange.to) {
|
|
154
|
+
lines.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${c.columnName}" SET NOT NULL;`)
|
|
155
|
+
} else {
|
|
156
|
+
lines.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${c.columnName}" DROP NOT NULL;`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (c.defaultChange) {
|
|
160
|
+
if (c.defaultChange.to !== undefined) {
|
|
161
|
+
lines.push(
|
|
162
|
+
`ALTER TABLE "${tableName}" ALTER COLUMN "${c.columnName}" SET DEFAULT ${defaultValueToSql(c.defaultChange.to)};`
|
|
163
|
+
)
|
|
164
|
+
} else {
|
|
165
|
+
lines.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${c.columnName}" DROP DEFAULT;`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return lines.join('\n')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private generateAlterDown(tableName: string, columns: ColumnDiff[]): string {
|
|
175
|
+
const lines: string[] = []
|
|
176
|
+
lines.push(`-- Reverse modify table: ${tableName}`)
|
|
177
|
+
|
|
178
|
+
// Reverse in opposite order
|
|
179
|
+
for (const c of [...columns].reverse()) {
|
|
180
|
+
if (c.kind === 'add') {
|
|
181
|
+
// Reverse of add is drop
|
|
182
|
+
lines.push(`ALTER TABLE "${tableName}" DROP COLUMN IF EXISTS "${c.column.name}";`)
|
|
183
|
+
} else if (c.kind === 'drop') {
|
|
184
|
+
// Reverse of drop is add
|
|
185
|
+
lines.push(`ALTER TABLE "${tableName}" ADD COLUMN ${columnToSql(c.column)};`)
|
|
186
|
+
} else if (c.kind === 'alter') {
|
|
187
|
+
// Reverse each change
|
|
188
|
+
if (c.defaultChange) {
|
|
189
|
+
if (c.defaultChange.from !== undefined) {
|
|
190
|
+
lines.push(
|
|
191
|
+
`ALTER TABLE "${tableName}" ALTER COLUMN "${c.columnName}" SET DEFAULT ${defaultValueToSql(c.defaultChange.from)};`
|
|
192
|
+
)
|
|
193
|
+
} else {
|
|
194
|
+
lines.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${c.columnName}" DROP DEFAULT;`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (c.nullableChange) {
|
|
198
|
+
if (c.nullableChange.from) {
|
|
199
|
+
lines.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${c.columnName}" SET NOT NULL;`)
|
|
200
|
+
} else {
|
|
201
|
+
lines.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${c.columnName}" DROP NOT NULL;`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (c.typeChange) {
|
|
205
|
+
const oldType = pgTypeToSql(c.typeChange.from)
|
|
206
|
+
lines.push(
|
|
207
|
+
`ALTER TABLE "${tableName}" ALTER COLUMN "${c.columnName}" TYPE ${oldType} USING "${c.columnName}"::${oldType};`
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return lines.join('\n')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Constraints
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
private generateConstraintsUp(diffs: ConstraintDiff[]): string {
|
|
221
|
+
const lines: string[] = []
|
|
222
|
+
for (const d of diffs) {
|
|
223
|
+
if (d.kind === 'add_fk') {
|
|
224
|
+
const name = `fk_${d.tableName}_${d.constraint.columns.join('_')}`
|
|
225
|
+
const cols = d.constraint.columns.map(c => `"${c}"`).join(', ')
|
|
226
|
+
const refCols = d.constraint.referencedColumns.map(c => `"${c}"`).join(', ')
|
|
227
|
+
lines.push(
|
|
228
|
+
`ALTER TABLE "${d.tableName}" ADD CONSTRAINT "${name}" FOREIGN KEY (${cols}) REFERENCES "${d.constraint.referencedTable}" (${refCols}) ON DELETE ${d.constraint.onDelete} ON UPDATE ${d.constraint.onUpdate};`
|
|
229
|
+
)
|
|
230
|
+
} else if (d.kind === 'drop_fk') {
|
|
231
|
+
const name = `fk_${d.tableName}_${d.constraint.columns.join('_')}`
|
|
232
|
+
lines.push(`ALTER TABLE "${d.tableName}" DROP CONSTRAINT IF EXISTS "${name}";`)
|
|
233
|
+
} else if (d.kind === 'add_unique') {
|
|
234
|
+
const name = `uq_${d.tableName}_${d.constraint.columns.join('_')}`
|
|
235
|
+
const cols = d.constraint.columns.map(c => `"${c}"`).join(', ')
|
|
236
|
+
lines.push(`ALTER TABLE "${d.tableName}" ADD CONSTRAINT "${name}" UNIQUE (${cols});`)
|
|
237
|
+
} else if (d.kind === 'drop_unique') {
|
|
238
|
+
const name = `uq_${d.tableName}_${d.constraint.columns.join('_')}`
|
|
239
|
+
lines.push(`ALTER TABLE "${d.tableName}" DROP CONSTRAINT IF EXISTS "${name}";`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return lines.join('\n').trim()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private generateConstraintsDown(diffs: ConstraintDiff[]): string {
|
|
246
|
+
const lines: string[] = []
|
|
247
|
+
// Reverse: adds become drops, drops become adds
|
|
248
|
+
for (const d of [...diffs].reverse()) {
|
|
249
|
+
if (d.kind === 'add_fk') {
|
|
250
|
+
const name = `fk_${d.tableName}_${d.constraint.columns.join('_')}`
|
|
251
|
+
lines.push(`ALTER TABLE "${d.tableName}" DROP CONSTRAINT IF EXISTS "${name}";`)
|
|
252
|
+
} else if (d.kind === 'drop_fk') {
|
|
253
|
+
const name = `fk_${d.tableName}_${d.constraint.columns.join('_')}`
|
|
254
|
+
const cols = d.constraint.columns.map(c => `"${c}"`).join(', ')
|
|
255
|
+
const refCols = d.constraint.referencedColumns.map(c => `"${c}"`).join(', ')
|
|
256
|
+
lines.push(
|
|
257
|
+
`ALTER TABLE "${d.tableName}" ADD CONSTRAINT "${name}" FOREIGN KEY (${cols}) REFERENCES "${d.constraint.referencedTable}" (${refCols}) ON DELETE ${d.constraint.onDelete} ON UPDATE ${d.constraint.onUpdate};`
|
|
258
|
+
)
|
|
259
|
+
} else if (d.kind === 'add_unique') {
|
|
260
|
+
const name = `uq_${d.tableName}_${d.constraint.columns.join('_')}`
|
|
261
|
+
lines.push(`ALTER TABLE "${d.tableName}" DROP CONSTRAINT IF EXISTS "${name}";`)
|
|
262
|
+
} else if (d.kind === 'drop_unique') {
|
|
263
|
+
const name = `uq_${d.tableName}_${d.constraint.columns.join('_')}`
|
|
264
|
+
const cols = d.constraint.columns.map(c => `"${c}"`).join(', ')
|
|
265
|
+
lines.push(`ALTER TABLE "${d.tableName}" ADD CONSTRAINT "${name}" UNIQUE (${cols});`)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return lines.join('\n').trim()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Indexes
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
private generateIndexesUp(diffs: IndexDiff[]): string {
|
|
276
|
+
const lines: string[] = []
|
|
277
|
+
for (const d of diffs) {
|
|
278
|
+
if (d.kind === 'add') {
|
|
279
|
+
const name = indexName(d.tableName, d.index)
|
|
280
|
+
const cols = d.index.columns.map(c => `"${c}"`).join(', ')
|
|
281
|
+
const unique = d.index.unique ? 'UNIQUE ' : ''
|
|
282
|
+
lines.push(`CREATE ${unique}INDEX IF NOT EXISTS "${name}" ON "${d.tableName}" (${cols});`)
|
|
283
|
+
} else if (d.kind === 'drop') {
|
|
284
|
+
const name = indexName(d.tableName, d.index)
|
|
285
|
+
lines.push(`DROP INDEX IF EXISTS "${name}";`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return lines.join('\n').trim()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private generateIndexesDown(diffs: IndexDiff[]): string {
|
|
292
|
+
const lines: string[] = []
|
|
293
|
+
for (const d of [...diffs].reverse()) {
|
|
294
|
+
if (d.kind === 'add') {
|
|
295
|
+
const name = indexName(d.tableName, d.index)
|
|
296
|
+
lines.push(`DROP INDEX IF EXISTS "${name}";`)
|
|
297
|
+
} else if (d.kind === 'drop') {
|
|
298
|
+
const name = indexName(d.tableName, d.index)
|
|
299
|
+
const cols = d.index.columns.map(c => `"${c}"`).join(', ')
|
|
300
|
+
const unique = d.index.unique ? 'UNIQUE ' : ''
|
|
301
|
+
lines.push(`CREATE ${unique}INDEX IF NOT EXISTS "${name}" ON "${d.tableName}" (${cols});`)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return lines.join('\n').trim()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Helpers (exported for testing)
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
function indexName(tableName: string, idx: { columns: string[]; unique: boolean }): string {
|
|
313
|
+
const suffix = idx.unique ? '_unique' : ''
|
|
314
|
+
return `idx_${tableName}_${idx.columns.join('_')}${suffix}`
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Render a column definition as a SQL fragment (no trailing comma). */
|
|
318
|
+
export function columnToSql(c: ColumnDefinition): string {
|
|
319
|
+
const typeSql = pgTypeToSql(c.pgType, c)
|
|
320
|
+
const parts = [`"${c.name}" ${typeSql}`]
|
|
321
|
+
|
|
322
|
+
// Serial types handle NOT NULL and default implicitly
|
|
323
|
+
if (!isSerial(c.pgType)) {
|
|
324
|
+
if (c.notNull) parts.push('NOT NULL')
|
|
325
|
+
if (c.defaultValue !== undefined) parts.push(`DEFAULT ${defaultValueToSql(c.defaultValue)}`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return parts.join(' ')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Convert a PostgreSQLType (+ optional column metadata) to a SQL type string. */
|
|
332
|
+
export function pgTypeToSql(pgType: PostgreSQLType, col?: ColumnDefinition): string {
|
|
333
|
+
if (typeof pgType === 'object') {
|
|
334
|
+
if (pgType.type === 'custom') return `"${pgType.name}"`
|
|
335
|
+
if (pgType.type === 'array') return `${pgTypeToSql(pgType.element as PostgreSQLType)}[]`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Multi-word types
|
|
339
|
+
const multiWord: Record<string, string> = {
|
|
340
|
+
double_precision: 'DOUBLE PRECISION',
|
|
341
|
+
character_varying: 'CHARACTER VARYING',
|
|
342
|
+
bit_varying: 'BIT VARYING',
|
|
343
|
+
}
|
|
344
|
+
if (typeof pgType === 'string' && pgType in multiWord) {
|
|
345
|
+
return multiWord[pgType]!
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const upper = (pgType as string).toUpperCase()
|
|
349
|
+
|
|
350
|
+
// Types with parameters
|
|
351
|
+
if (col) {
|
|
352
|
+
if ((pgType === 'varchar' || pgType === 'character_varying') && col.length) {
|
|
353
|
+
return `VARCHAR(${col.length})`
|
|
354
|
+
}
|
|
355
|
+
if ((pgType === 'char' || pgType === 'character') && col.length) {
|
|
356
|
+
return `CHAR(${col.length})`
|
|
357
|
+
}
|
|
358
|
+
if ((pgType === 'numeric' || pgType === 'decimal') && col.precision !== undefined) {
|
|
359
|
+
if (col.scale !== undefined) return `${upper}(${col.precision},${col.scale})`
|
|
360
|
+
return `${upper}(${col.precision})`
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return upper
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Convert a DefaultValue to its SQL representation. */
|
|
368
|
+
export function defaultValueToSql(dv: DefaultValue): string {
|
|
369
|
+
if (dv.kind === 'expression') return dv.sql
|
|
370
|
+
if (dv.value === null) return 'NULL'
|
|
371
|
+
if (typeof dv.value === 'boolean') return dv.value ? 'true' : 'false'
|
|
372
|
+
if (typeof dv.value === 'number') return String(dv.value)
|
|
373
|
+
return `'${dv.value}'`
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isSerial(pgType: PostgreSQLType): boolean {
|
|
377
|
+
return pgType === 'serial' || pgType === 'bigserial' || pgType === 'smallserial'
|
|
378
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type Database from '../database.ts'
|
|
2
|
+
import type { MigrationRecord } from './types.ts'
|
|
3
|
+
|
|
4
|
+
const TABLE_NAME = '_strav_migrations'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Manages the `_strav_migrations` tracking table.
|
|
8
|
+
*
|
|
9
|
+
* Keeps track of which migration versions have been applied and
|
|
10
|
+
* groups them into batches so rollbacks can undo an entire batch at once.
|
|
11
|
+
*/
|
|
12
|
+
export default class MigrationTracker {
|
|
13
|
+
constructor(private db: Database) {}
|
|
14
|
+
|
|
15
|
+
/** Create the tracking table if it does not exist. */
|
|
16
|
+
async ensureTable(): Promise<void> {
|
|
17
|
+
await this.db.sql.unsafe(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
|
|
19
|
+
id SERIAL PRIMARY KEY,
|
|
20
|
+
version VARCHAR(255) NOT NULL,
|
|
21
|
+
batch INTEGER NOT NULL,
|
|
22
|
+
executed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
23
|
+
)
|
|
24
|
+
`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Return all applied migration records ordered by version. */
|
|
28
|
+
async getAppliedMigrations(): Promise<MigrationRecord[]> {
|
|
29
|
+
return (await this.db.sql.unsafe(
|
|
30
|
+
`SELECT id, version, batch, executed_at FROM ${TABLE_NAME} ORDER BY version`
|
|
31
|
+
)) as MigrationRecord[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Given a list of all known versions, return those not yet applied. */
|
|
35
|
+
async getPendingVersions(allVersions: string[]): Promise<string[]> {
|
|
36
|
+
const applied = await this.getAppliedMigrations()
|
|
37
|
+
const appliedSet = new Set(applied.map(r => r.version))
|
|
38
|
+
return allVersions.filter(v => !appliedSet.has(v))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Return the highest batch number, or 0 if no migrations exist. */
|
|
42
|
+
async getLastBatch(): Promise<number> {
|
|
43
|
+
const rows = await this.db.sql.unsafe(
|
|
44
|
+
`SELECT COALESCE(MAX(batch), 0) AS max_batch FROM ${TABLE_NAME}`
|
|
45
|
+
)
|
|
46
|
+
return (rows[0] as any).max_batch
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Record a migration as applied. */
|
|
50
|
+
async recordMigration(version: string, batch: number): Promise<void> {
|
|
51
|
+
await this.db.sql.unsafe(`INSERT INTO ${TABLE_NAME} (version, batch) VALUES ($1, $2)`, [
|
|
52
|
+
version,
|
|
53
|
+
batch,
|
|
54
|
+
])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Remove a migration record by version. */
|
|
58
|
+
async removeMigration(version: string): Promise<void> {
|
|
59
|
+
await this.db.sql.unsafe(`DELETE FROM ${TABLE_NAME} WHERE version = $1`, [version])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Return all migrations for a specific batch. */
|
|
63
|
+
async getMigrationsByBatch(batch: number): Promise<MigrationRecord[]> {
|
|
64
|
+
return (await this.db.sql.unsafe(
|
|
65
|
+
`SELECT id, version, batch, executed_at FROM ${TABLE_NAME} WHERE batch = $1 ORDER BY version DESC`,
|
|
66
|
+
[batch]
|
|
67
|
+
)) as MigrationRecord[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Return migrations from the latest batch (most recent run). */
|
|
71
|
+
async getLatestBatchMigrations(): Promise<MigrationRecord[]> {
|
|
72
|
+
const lastBatch = await this.getLastBatch()
|
|
73
|
+
if (lastBatch === 0) return []
|
|
74
|
+
return this.getMigrationsByBatch(lastBatch)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnDefinition,
|
|
3
|
+
ForeignKeyConstraint,
|
|
4
|
+
UniqueConstraint,
|
|
5
|
+
IndexDefinition,
|
|
6
|
+
TableDefinition,
|
|
7
|
+
} from '../../schema/database_representation.ts'
|
|
8
|
+
import type { PostgreSQLType } from '../../schema/postgres.ts'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Enum diffs
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface EnumCreate {
|
|
15
|
+
kind: 'create'
|
|
16
|
+
name: string
|
|
17
|
+
values: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EnumDrop {
|
|
21
|
+
kind: 'drop'
|
|
22
|
+
name: string
|
|
23
|
+
values: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EnumModify {
|
|
27
|
+
kind: 'modify'
|
|
28
|
+
name: string
|
|
29
|
+
addedValues: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type EnumDiff = EnumCreate | EnumDrop | EnumModify
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Column diffs
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export interface ColumnAdd {
|
|
39
|
+
kind: 'add'
|
|
40
|
+
column: ColumnDefinition
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ColumnDrop {
|
|
44
|
+
kind: 'drop'
|
|
45
|
+
column: ColumnDefinition
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ColumnAlter {
|
|
49
|
+
kind: 'alter'
|
|
50
|
+
columnName: string
|
|
51
|
+
typeChange?: { from: PostgreSQLType; to: PostgreSQLType }
|
|
52
|
+
nullableChange?: { from: boolean; to: boolean }
|
|
53
|
+
defaultChange?: {
|
|
54
|
+
from: ColumnDefinition['defaultValue']
|
|
55
|
+
to: ColumnDefinition['defaultValue']
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ColumnDiff = ColumnAdd | ColumnDrop | ColumnAlter
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Table diffs
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export interface TableCreate {
|
|
66
|
+
kind: 'create'
|
|
67
|
+
table: TableDefinition
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TableDrop {
|
|
71
|
+
kind: 'drop'
|
|
72
|
+
table: TableDefinition
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TableModify {
|
|
76
|
+
kind: 'modify'
|
|
77
|
+
tableName: string
|
|
78
|
+
columns: ColumnDiff[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type TableDiff = TableCreate | TableDrop | TableModify
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Constraint diffs
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export interface ForeignKeyAdd {
|
|
88
|
+
kind: 'add_fk'
|
|
89
|
+
tableName: string
|
|
90
|
+
constraint: ForeignKeyConstraint
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ForeignKeyDrop {
|
|
94
|
+
kind: 'drop_fk'
|
|
95
|
+
tableName: string
|
|
96
|
+
constraint: ForeignKeyConstraint
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface UniqueAdd {
|
|
100
|
+
kind: 'add_unique'
|
|
101
|
+
tableName: string
|
|
102
|
+
constraint: UniqueConstraint
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface UniqueDrop {
|
|
106
|
+
kind: 'drop_unique'
|
|
107
|
+
tableName: string
|
|
108
|
+
constraint: UniqueConstraint
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type ConstraintDiff = ForeignKeyAdd | ForeignKeyDrop | UniqueAdd | UniqueDrop
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Index diffs
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export interface IndexAdd {
|
|
118
|
+
kind: 'add'
|
|
119
|
+
tableName: string
|
|
120
|
+
index: IndexDefinition
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface IndexDrop {
|
|
124
|
+
kind: 'drop'
|
|
125
|
+
tableName: string
|
|
126
|
+
index: IndexDefinition
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type IndexDiff = IndexAdd | IndexDrop
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Top-level schema diff
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
export interface SchemaDiff {
|
|
136
|
+
enums: EnumDiff[]
|
|
137
|
+
tables: TableDiff[]
|
|
138
|
+
constraints: ConstraintDiff[]
|
|
139
|
+
indexes: IndexDiff[]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Generated SQL output
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
export interface GeneratedSql {
|
|
147
|
+
enumsUp: string
|
|
148
|
+
enumsDown: string
|
|
149
|
+
tables: Map<string, { up: string; down: string }>
|
|
150
|
+
constraintsUp: string
|
|
151
|
+
constraintsDown: string
|
|
152
|
+
indexesUp: string
|
|
153
|
+
indexesDown: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Migration manifest (stored as manifest.json)
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export interface MigrationSummary {
|
|
161
|
+
tablesToCreate: number
|
|
162
|
+
tablesToDrop: number
|
|
163
|
+
tablesToModify: number
|
|
164
|
+
enumsToCreate: number
|
|
165
|
+
enumsToModify: number
|
|
166
|
+
enumsToDrop: number
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface MigrationManifest {
|
|
170
|
+
version: string
|
|
171
|
+
message: string
|
|
172
|
+
generatedAt: string
|
|
173
|
+
summary: MigrationSummary
|
|
174
|
+
executionOrder: {
|
|
175
|
+
up: string[]
|
|
176
|
+
down: string[]
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Migration tracking record (from _strav_migrations table)
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export interface MigrationRecord {
|
|
185
|
+
id: number
|
|
186
|
+
version: string
|
|
187
|
+
batch: number
|
|
188
|
+
executed_at: Date
|
|
189
|
+
}
|