@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,308 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DatabaseRepresentation,
|
|
3
|
+
TableDefinition,
|
|
4
|
+
ColumnDefinition,
|
|
5
|
+
ForeignKeyConstraint,
|
|
6
|
+
UniqueConstraint,
|
|
7
|
+
IndexDefinition,
|
|
8
|
+
DefaultValue,
|
|
9
|
+
} from '../../schema/database_representation.ts'
|
|
10
|
+
import type { PostgreSQLType } from '../../schema/postgres.ts'
|
|
11
|
+
import type {
|
|
12
|
+
SchemaDiff,
|
|
13
|
+
EnumDiff,
|
|
14
|
+
TableDiff,
|
|
15
|
+
ColumnDiff,
|
|
16
|
+
ConstraintDiff,
|
|
17
|
+
IndexDiff,
|
|
18
|
+
} from './types.ts'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compares two {@link DatabaseRepresentation} objects and produces a
|
|
22
|
+
* structured {@link SchemaDiff} describing the operations needed to
|
|
23
|
+
* transform `actual` into `desired`.
|
|
24
|
+
*/
|
|
25
|
+
export default class SchemaDiffer {
|
|
26
|
+
diff(desired: DatabaseRepresentation, actual: DatabaseRepresentation): SchemaDiff {
|
|
27
|
+
const enums = this.diffEnums(desired, actual)
|
|
28
|
+
const { tables, constraints, indexes } = this.diffTablesAndDeps(desired, actual)
|
|
29
|
+
return { enums, tables, constraints, indexes }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Enums
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
private diffEnums(desired: DatabaseRepresentation, actual: DatabaseRepresentation): EnumDiff[] {
|
|
37
|
+
const diffs: EnumDiff[] = []
|
|
38
|
+
const actualMap = new Map(actual.enums.map(e => [e.name, e]))
|
|
39
|
+
const desiredMap = new Map(desired.enums.map(e => [e.name, e]))
|
|
40
|
+
|
|
41
|
+
// Creates and modifications
|
|
42
|
+
for (const [name, desiredEnum] of desiredMap) {
|
|
43
|
+
const actualEnum = actualMap.get(name)
|
|
44
|
+
if (!actualEnum) {
|
|
45
|
+
diffs.push({ kind: 'create', name, values: desiredEnum.values })
|
|
46
|
+
} else {
|
|
47
|
+
const addedValues = desiredEnum.values.filter(v => !actualEnum.values.includes(v))
|
|
48
|
+
if (addedValues.length > 0) {
|
|
49
|
+
diffs.push({ kind: 'modify', name, addedValues })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Drops
|
|
55
|
+
for (const [name, actualEnum] of actualMap) {
|
|
56
|
+
if (!desiredMap.has(name)) {
|
|
57
|
+
diffs.push({ kind: 'drop', name, values: actualEnum.values })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return diffs
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Tables, Constraints, Indexes
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
private diffTablesAndDeps(
|
|
69
|
+
desired: DatabaseRepresentation,
|
|
70
|
+
actual: DatabaseRepresentation
|
|
71
|
+
): { tables: TableDiff[]; constraints: ConstraintDiff[]; indexes: IndexDiff[] } {
|
|
72
|
+
const tables: TableDiff[] = []
|
|
73
|
+
const constraints: ConstraintDiff[] = []
|
|
74
|
+
const indexes: IndexDiff[] = []
|
|
75
|
+
|
|
76
|
+
const actualMap = new Map(actual.tables.map(t => [t.name, t]))
|
|
77
|
+
const desiredMap = new Map(desired.tables.map(t => [t.name, t]))
|
|
78
|
+
|
|
79
|
+
// Creates and modifications
|
|
80
|
+
for (const [name, desiredTable] of desiredMap) {
|
|
81
|
+
const actualTable = actualMap.get(name)
|
|
82
|
+
if (!actualTable) {
|
|
83
|
+
tables.push({ kind: 'create', table: desiredTable })
|
|
84
|
+
// All constraints and indexes from a new table are additions
|
|
85
|
+
this.extractConstraintAdds(name, desiredTable, constraints)
|
|
86
|
+
this.extractIndexAdds(name, desiredTable, indexes)
|
|
87
|
+
} else {
|
|
88
|
+
// Column diff
|
|
89
|
+
const columnDiffs = this.diffColumns(desiredTable, actualTable)
|
|
90
|
+
if (columnDiffs.length > 0) {
|
|
91
|
+
tables.push({ kind: 'modify', tableName: name, columns: columnDiffs })
|
|
92
|
+
}
|
|
93
|
+
// Constraint diff
|
|
94
|
+
this.diffConstraints(name, desiredTable, actualTable, constraints)
|
|
95
|
+
// Index diff
|
|
96
|
+
this.diffIndexes(name, desiredTable, actualTable, indexes)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Drops
|
|
101
|
+
for (const [name, actualTable] of actualMap) {
|
|
102
|
+
if (!desiredMap.has(name)) {
|
|
103
|
+
tables.push({ kind: 'drop', table: actualTable })
|
|
104
|
+
// All constraints and indexes from a dropped table
|
|
105
|
+
this.extractConstraintDrops(name, actualTable, constraints)
|
|
106
|
+
this.extractIndexDrops(name, actualTable, indexes)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { tables, constraints, indexes }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Column diff
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
private diffColumns(desired: TableDefinition, actual: TableDefinition): ColumnDiff[] {
|
|
118
|
+
const diffs: ColumnDiff[] = []
|
|
119
|
+
const actualMap = new Map(actual.columns.map(c => [c.name, c]))
|
|
120
|
+
const desiredMap = new Map(desired.columns.map(c => [c.name, c]))
|
|
121
|
+
|
|
122
|
+
for (const [name, desiredCol] of desiredMap) {
|
|
123
|
+
const actualCol = actualMap.get(name)
|
|
124
|
+
if (!actualCol) {
|
|
125
|
+
diffs.push({ kind: 'add', column: desiredCol })
|
|
126
|
+
} else {
|
|
127
|
+
const alter = this.diffSingleColumn(name, desiredCol, actualCol)
|
|
128
|
+
if (alter) diffs.push(alter)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const [name, actualCol] of actualMap) {
|
|
133
|
+
if (!desiredMap.has(name)) {
|
|
134
|
+
diffs.push({ kind: 'drop', column: actualCol })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return diffs
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private diffSingleColumn(
|
|
142
|
+
name: string,
|
|
143
|
+
desired: ColumnDefinition,
|
|
144
|
+
actual: ColumnDefinition
|
|
145
|
+
): ColumnDiff | null {
|
|
146
|
+
const typeChanged = !pgTypesEqual(desired.pgType, actual.pgType)
|
|
147
|
+
const nullableChanged = desired.notNull !== actual.notNull
|
|
148
|
+
const defaultChanged = !defaultsEqual(desired.defaultValue, actual.defaultValue)
|
|
149
|
+
|
|
150
|
+
if (!typeChanged && !nullableChanged && !defaultChanged) return null
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
kind: 'alter',
|
|
154
|
+
columnName: name,
|
|
155
|
+
typeChange: typeChanged ? { from: actual.pgType, to: desired.pgType } : undefined,
|
|
156
|
+
nullableChange: nullableChanged ? { from: actual.notNull, to: desired.notNull } : undefined,
|
|
157
|
+
defaultChange: defaultChanged
|
|
158
|
+
? { from: actual.defaultValue, to: desired.defaultValue }
|
|
159
|
+
: undefined,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Constraint diff
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
private diffConstraints(
|
|
168
|
+
tableName: string,
|
|
169
|
+
desired: TableDefinition,
|
|
170
|
+
actual: TableDefinition,
|
|
171
|
+
out: ConstraintDiff[]
|
|
172
|
+
): void {
|
|
173
|
+
// Foreign keys — match by (columns, referencedTable, referencedColumns)
|
|
174
|
+
const actualFKs = new Set(actual.foreignKeys.map(fkKey))
|
|
175
|
+
const desiredFKs = new Set(desired.foreignKeys.map(fkKey))
|
|
176
|
+
|
|
177
|
+
for (const fk of desired.foreignKeys) {
|
|
178
|
+
if (!actualFKs.has(fkKey(fk))) {
|
|
179
|
+
out.push({ kind: 'add_fk', tableName, constraint: fk })
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const fk of actual.foreignKeys) {
|
|
183
|
+
if (!desiredFKs.has(fkKey(fk))) {
|
|
184
|
+
out.push({ kind: 'drop_fk', tableName, constraint: fk })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Unique constraints — match by sorted columns
|
|
189
|
+
const actualUqs = new Set(actual.uniqueConstraints.map(uqKey))
|
|
190
|
+
const desiredUqs = new Set(desired.uniqueConstraints.map(uqKey))
|
|
191
|
+
|
|
192
|
+
for (const uq of desired.uniqueConstraints) {
|
|
193
|
+
if (!actualUqs.has(uqKey(uq))) {
|
|
194
|
+
out.push({ kind: 'add_unique', tableName, constraint: uq })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const uq of actual.uniqueConstraints) {
|
|
198
|
+
if (!desiredUqs.has(uqKey(uq))) {
|
|
199
|
+
out.push({ kind: 'drop_unique', tableName, constraint: uq })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private extractConstraintAdds(
|
|
205
|
+
tableName: string,
|
|
206
|
+
table: TableDefinition,
|
|
207
|
+
out: ConstraintDiff[]
|
|
208
|
+
): void {
|
|
209
|
+
for (const fk of table.foreignKeys) {
|
|
210
|
+
out.push({ kind: 'add_fk', tableName, constraint: fk })
|
|
211
|
+
}
|
|
212
|
+
for (const uq of table.uniqueConstraints) {
|
|
213
|
+
out.push({ kind: 'add_unique', tableName, constraint: uq })
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private extractConstraintDrops(
|
|
218
|
+
tableName: string,
|
|
219
|
+
table: TableDefinition,
|
|
220
|
+
out: ConstraintDiff[]
|
|
221
|
+
): void {
|
|
222
|
+
for (const fk of table.foreignKeys) {
|
|
223
|
+
out.push({ kind: 'drop_fk', tableName, constraint: fk })
|
|
224
|
+
}
|
|
225
|
+
for (const uq of table.uniqueConstraints) {
|
|
226
|
+
out.push({ kind: 'drop_unique', tableName, constraint: uq })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Index diff
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
private diffIndexes(
|
|
235
|
+
tableName: string,
|
|
236
|
+
desired: TableDefinition,
|
|
237
|
+
actual: TableDefinition,
|
|
238
|
+
out: IndexDiff[]
|
|
239
|
+
): void {
|
|
240
|
+
const actualIdxs = new Set(actual.indexes.map(idxKey))
|
|
241
|
+
const desiredIdxs = new Set(desired.indexes.map(idxKey))
|
|
242
|
+
|
|
243
|
+
for (const idx of desired.indexes) {
|
|
244
|
+
if (!actualIdxs.has(idxKey(idx))) {
|
|
245
|
+
out.push({ kind: 'add', tableName, index: idx })
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const idx of actual.indexes) {
|
|
249
|
+
if (!desiredIdxs.has(idxKey(idx))) {
|
|
250
|
+
out.push({ kind: 'drop', tableName, index: idx })
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private extractIndexAdds(tableName: string, table: TableDefinition, out: IndexDiff[]): void {
|
|
256
|
+
for (const idx of table.indexes) {
|
|
257
|
+
out.push({ kind: 'add', tableName, index: idx })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private extractIndexDrops(tableName: string, table: TableDefinition, out: IndexDiff[]): void {
|
|
262
|
+
for (const idx of table.indexes) {
|
|
263
|
+
out.push({ kind: 'drop', tableName, index: idx })
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Comparison helpers
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
function fkKey(fk: ForeignKeyConstraint): string {
|
|
273
|
+
return `${fk.columns.join(',')}->${fk.referencedTable}(${fk.referencedColumns.join(',')})`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function uqKey(uq: UniqueConstraint): string {
|
|
277
|
+
return [...uq.columns].sort().join(',')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function idxKey(idx: IndexDefinition): string {
|
|
281
|
+
return `${[...idx.columns].sort().join(',')}_${idx.unique ? 'unique' : 'non_unique'}`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Deep-compare two PostgreSQL types. */
|
|
285
|
+
export function pgTypesEqual(a: PostgreSQLType, b: PostgreSQLType): boolean {
|
|
286
|
+
if (typeof a === 'string' && typeof b === 'string') return a === b
|
|
287
|
+
if (typeof a !== typeof b) return false
|
|
288
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
289
|
+
if (a.type !== (b as any).type) return false
|
|
290
|
+
if (a.type === 'custom' && (b as any).type === 'custom') {
|
|
291
|
+
return a.name === (b as any).name
|
|
292
|
+
}
|
|
293
|
+
if (a.type === 'array' && (b as any).type === 'array') {
|
|
294
|
+
return pgTypesEqual(a.element as PostgreSQLType, (b as any).element as PostgreSQLType)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return false
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Compare two default values for equality. */
|
|
301
|
+
export function defaultsEqual(a: DefaultValue | undefined, b: DefaultValue | undefined): boolean {
|
|
302
|
+
if (a === undefined && b === undefined) return true
|
|
303
|
+
if (a === undefined || b === undefined) return false
|
|
304
|
+
if (a.kind !== b.kind) return false
|
|
305
|
+
if (a.kind === 'literal' && b.kind === 'literal') return a.value === b.value
|
|
306
|
+
if (a.kind === 'expression' && b.kind === 'expression') return a.sql === b.sql
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import type {
|
|
3
|
+
SchemaDiff,
|
|
4
|
+
GeneratedSql,
|
|
5
|
+
MigrationManifest,
|
|
6
|
+
MigrationSummary,
|
|
7
|
+
TableDiff,
|
|
8
|
+
} from './types.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates the migration directory structure and writes SQL files + manifest.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const fileGen = new MigrationFileGenerator('database/migrations')
|
|
15
|
+
* await fileGen.generate(version, 'initial', generatedSql, diff)
|
|
16
|
+
*/
|
|
17
|
+
export default class MigrationFileGenerator {
|
|
18
|
+
constructor(private basePath: string) {}
|
|
19
|
+
|
|
20
|
+
async generate(
|
|
21
|
+
version: string,
|
|
22
|
+
message: string,
|
|
23
|
+
sql: GeneratedSql,
|
|
24
|
+
diff: SchemaDiff,
|
|
25
|
+
tableOrder?: string[]
|
|
26
|
+
): Promise<string> {
|
|
27
|
+
const migrationDir = join(this.basePath, version)
|
|
28
|
+
|
|
29
|
+
// Build file list and execution order
|
|
30
|
+
const upOrder: string[] = []
|
|
31
|
+
const downOrder: string[] = []
|
|
32
|
+
|
|
33
|
+
// 1. Enums
|
|
34
|
+
if (sql.enumsUp.trim()) {
|
|
35
|
+
await Bun.write(join(migrationDir, 'enums', 'up.sql'), sql.enumsUp + '\n')
|
|
36
|
+
await Bun.write(join(migrationDir, 'enums', 'down.sql'), sql.enumsDown + '\n')
|
|
37
|
+
upOrder.push('enums/up.sql')
|
|
38
|
+
downOrder.unshift('enums/down.sql')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. Tables — in dependency order for up, reverse for down
|
|
42
|
+
const tableNames = this.resolveTableOrder(diff.tables, tableOrder)
|
|
43
|
+
for (const name of tableNames) {
|
|
44
|
+
const tableSql = sql.tables.get(name)
|
|
45
|
+
if (!tableSql) continue
|
|
46
|
+
await Bun.write(join(migrationDir, 'tables', name, 'up.sql'), tableSql.up + '\n')
|
|
47
|
+
await Bun.write(join(migrationDir, 'tables', name, 'down.sql'), tableSql.down + '\n')
|
|
48
|
+
upOrder.push(`tables/${name}/up.sql`)
|
|
49
|
+
downOrder.unshift(`tables/${name}/down.sql`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 3. Constraints
|
|
53
|
+
if (sql.constraintsUp.trim()) {
|
|
54
|
+
await Bun.write(join(migrationDir, 'constraints', 'up.sql'), sql.constraintsUp + '\n')
|
|
55
|
+
await Bun.write(join(migrationDir, 'constraints', 'down.sql'), sql.constraintsDown + '\n')
|
|
56
|
+
upOrder.push('constraints/up.sql')
|
|
57
|
+
downOrder.unshift('constraints/down.sql')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 4. Indexes
|
|
61
|
+
if (sql.indexesUp.trim()) {
|
|
62
|
+
await Bun.write(join(migrationDir, 'indexes', 'up.sql'), sql.indexesUp + '\n')
|
|
63
|
+
await Bun.write(join(migrationDir, 'indexes', 'down.sql'), sql.indexesDown + '\n')
|
|
64
|
+
upOrder.push('indexes/up.sql')
|
|
65
|
+
downOrder.unshift('indexes/down.sql')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Build manifest
|
|
69
|
+
const manifest: MigrationManifest = {
|
|
70
|
+
version,
|
|
71
|
+
message,
|
|
72
|
+
generatedAt: new Date().toISOString(),
|
|
73
|
+
summary: this.buildSummary(diff),
|
|
74
|
+
executionOrder: { up: upOrder, down: downOrder },
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await Bun.write(join(migrationDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
|
|
78
|
+
|
|
79
|
+
return migrationDir
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private resolveTableOrder(tableDiffs: TableDiff[], tableOrder?: string[]): string[] {
|
|
83
|
+
const tableNames = tableDiffs.map(t => (t.kind === 'modify' ? t.tableName : t.table.name))
|
|
84
|
+
|
|
85
|
+
if (!tableOrder) return tableNames
|
|
86
|
+
|
|
87
|
+
// Sort based on provided dependency order, keeping unknown tables at the end
|
|
88
|
+
const orderIndex = new Map(tableOrder.map((name, i) => [name, i]))
|
|
89
|
+
return [...tableNames].sort((a, b) => {
|
|
90
|
+
const ai = orderIndex.get(a) ?? Infinity
|
|
91
|
+
const bi = orderIndex.get(b) ?? Infinity
|
|
92
|
+
return ai - bi
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private buildSummary(diff: SchemaDiff): MigrationSummary {
|
|
97
|
+
let tablesToCreate = 0
|
|
98
|
+
let tablesToDrop = 0
|
|
99
|
+
let tablesToModify = 0
|
|
100
|
+
let enumsToCreate = 0
|
|
101
|
+
let enumsToModify = 0
|
|
102
|
+
let enumsToDrop = 0
|
|
103
|
+
|
|
104
|
+
for (const t of diff.tables) {
|
|
105
|
+
if (t.kind === 'create') tablesToCreate++
|
|
106
|
+
else if (t.kind === 'drop') tablesToDrop++
|
|
107
|
+
else if (t.kind === 'modify') tablesToModify++
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const e of diff.enums) {
|
|
111
|
+
if (e.kind === 'create') enumsToCreate++
|
|
112
|
+
else if (e.kind === 'modify') enumsToModify++
|
|
113
|
+
else if (e.kind === 'drop') enumsToDrop++
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
tablesToCreate,
|
|
118
|
+
tablesToDrop,
|
|
119
|
+
tablesToModify,
|
|
120
|
+
enumsToCreate,
|
|
121
|
+
enumsToModify,
|
|
122
|
+
enumsToDrop,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { default as SchemaDiffer } from './differ.ts'
|
|
2
|
+
export { default as SqlGenerator } from './sql_generator.ts'
|
|
3
|
+
export { default as MigrationFileGenerator } from './file_generator.ts'
|
|
4
|
+
export { default as MigrationRunner } from './runner.ts'
|
|
5
|
+
export { default as MigrationTracker } from './tracker.ts'
|
|
6
|
+
export type {
|
|
7
|
+
SchemaDiff,
|
|
8
|
+
EnumDiff,
|
|
9
|
+
TableDiff,
|
|
10
|
+
ColumnDiff,
|
|
11
|
+
ConstraintDiff,
|
|
12
|
+
IndexDiff,
|
|
13
|
+
GeneratedSql,
|
|
14
|
+
MigrationManifest,
|
|
15
|
+
MigrationSummary,
|
|
16
|
+
MigrationRecord,
|
|
17
|
+
} from './types.ts'
|
|
18
|
+
export type { RunResult, RollbackResult } from './runner.ts'
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { readdirSync } from 'node:fs'
|
|
3
|
+
import type Database from '../database.ts'
|
|
4
|
+
import type MigrationTracker from './tracker.ts'
|
|
5
|
+
import type { MigrationManifest } from './types.ts'
|
|
6
|
+
import { DatabaseError } from '../../exceptions/errors.ts'
|
|
7
|
+
|
|
8
|
+
export interface RunResult {
|
|
9
|
+
applied: string[]
|
|
10
|
+
batch: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RollbackResult {
|
|
14
|
+
rolledBack: string[]
|
|
15
|
+
batch: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Executes migration SQL files against the database.
|
|
20
|
+
*
|
|
21
|
+
* Each migration version is run inside a transaction so a failure
|
|
22
|
+
* rolls back the entire version (not just the failing file).
|
|
23
|
+
*/
|
|
24
|
+
export default class MigrationRunner {
|
|
25
|
+
constructor(
|
|
26
|
+
private db: Database,
|
|
27
|
+
private tracker: MigrationTracker,
|
|
28
|
+
private migrationsPath: string
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
/** Apply all pending migrations. */
|
|
32
|
+
async run(): Promise<RunResult> {
|
|
33
|
+
await this.tracker.ensureTable()
|
|
34
|
+
|
|
35
|
+
const allVersions = this.listVersions()
|
|
36
|
+
const pending = await this.tracker.getPendingVersions(allVersions)
|
|
37
|
+
|
|
38
|
+
if (pending.length === 0) return { applied: [], batch: 0 }
|
|
39
|
+
|
|
40
|
+
const batch = (await this.tracker.getLastBatch()) + 1
|
|
41
|
+
|
|
42
|
+
for (const version of pending) {
|
|
43
|
+
await this.applyMigration(version, batch)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { applied: pending, batch }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Rollback the latest batch, or a specific batch if provided. */
|
|
50
|
+
async rollback(batch?: number): Promise<RollbackResult> {
|
|
51
|
+
await this.tracker.ensureTable()
|
|
52
|
+
|
|
53
|
+
const targetBatch = batch ?? (await this.tracker.getLastBatch())
|
|
54
|
+
if (targetBatch === 0) return { rolledBack: [], batch: 0 }
|
|
55
|
+
|
|
56
|
+
const records = await this.tracker.getMigrationsByBatch(targetBatch)
|
|
57
|
+
if (records.length === 0) return { rolledBack: [], batch: targetBatch }
|
|
58
|
+
|
|
59
|
+
// Records are already ordered DESC by version from the tracker
|
|
60
|
+
const rolledBack: string[] = []
|
|
61
|
+
for (const record of records) {
|
|
62
|
+
await this.rollbackMigration(record.version)
|
|
63
|
+
rolledBack.push(record.version)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { rolledBack, batch: targetBatch }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Internal
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
private async applyMigration(version: string, batch: number): Promise<void> {
|
|
74
|
+
const manifest = await this.readManifest(version)
|
|
75
|
+
const files = manifest.executionOrder.up
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await this.db.sql.begin(async tx => {
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
const sqlContent = await Bun.file(join(this.migrationsPath, version, file)).text()
|
|
81
|
+
if (sqlContent.trim()) {
|
|
82
|
+
await tx.unsafe(sqlContent)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
await tx.unsafe(`INSERT INTO _strav_migrations (version, batch) VALUES ($1, $2)`, [
|
|
86
|
+
version,
|
|
87
|
+
batch,
|
|
88
|
+
])
|
|
89
|
+
})
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw new DatabaseError(`Migration ${version} failed: ${err instanceof Error ? err.message : err}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async rollbackMigration(version: string): Promise<void> {
|
|
96
|
+
const manifest = await this.readManifest(version)
|
|
97
|
+
const files = manifest.executionOrder.down
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await this.db.sql.begin(async tx => {
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
const sqlContent = await Bun.file(join(this.migrationsPath, version, file)).text()
|
|
103
|
+
if (sqlContent.trim()) {
|
|
104
|
+
await tx.unsafe(sqlContent)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await tx.unsafe(`DELETE FROM _strav_migrations WHERE version = $1`, [version])
|
|
108
|
+
})
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new DatabaseError(
|
|
111
|
+
`Rollback of migration ${version} failed: ${err instanceof Error ? err.message : err}`
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async readManifest(version: string): Promise<MigrationManifest> {
|
|
117
|
+
const manifestPath = join(this.migrationsPath, version, 'manifest.json')
|
|
118
|
+
return await Bun.file(manifestPath).json()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** List all migration version directories sorted numerically. */
|
|
122
|
+
private listVersions(): string[] {
|
|
123
|
+
try {
|
|
124
|
+
const entries = readdirSync(this.migrationsPath, { withFileTypes: true })
|
|
125
|
+
return entries
|
|
126
|
+
.filter(e => e.isDirectory() && /^\d+$/.test(e.name))
|
|
127
|
+
.map(e => e.name)
|
|
128
|
+
.sort()
|
|
129
|
+
} catch {
|
|
130
|
+
return []
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|