@stravigor/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +45 -0
  2. package/package.json +83 -0
  3. package/src/auth/access_token.ts +122 -0
  4. package/src/auth/auth.ts +86 -0
  5. package/src/auth/index.ts +7 -0
  6. package/src/auth/middleware/authenticate.ts +64 -0
  7. package/src/auth/middleware/csrf.ts +62 -0
  8. package/src/auth/middleware/guest.ts +46 -0
  9. package/src/broadcast/broadcast_manager.ts +411 -0
  10. package/src/broadcast/client.ts +302 -0
  11. package/src/broadcast/index.ts +58 -0
  12. package/src/cache/cache_manager.ts +56 -0
  13. package/src/cache/cache_store.ts +31 -0
  14. package/src/cache/helpers.ts +74 -0
  15. package/src/cache/http_cache.ts +109 -0
  16. package/src/cache/index.ts +6 -0
  17. package/src/cache/memory_store.ts +63 -0
  18. package/src/cli/bootstrap.ts +37 -0
  19. package/src/cli/commands/generate_api.ts +74 -0
  20. package/src/cli/commands/generate_key.ts +46 -0
  21. package/src/cli/commands/generate_models.ts +48 -0
  22. package/src/cli/commands/migration_compare.ts +152 -0
  23. package/src/cli/commands/migration_fresh.ts +123 -0
  24. package/src/cli/commands/migration_generate.ts +79 -0
  25. package/src/cli/commands/migration_rollback.ts +53 -0
  26. package/src/cli/commands/migration_run.ts +44 -0
  27. package/src/cli/commands/queue_flush.ts +35 -0
  28. package/src/cli/commands/queue_retry.ts +34 -0
  29. package/src/cli/commands/queue_work.ts +40 -0
  30. package/src/cli/commands/scheduler_work.ts +45 -0
  31. package/src/cli/strav.ts +33 -0
  32. package/src/config/configuration.ts +105 -0
  33. package/src/config/loaders/base_loader.ts +69 -0
  34. package/src/config/loaders/env_loader.ts +112 -0
  35. package/src/config/loaders/typescript_loader.ts +56 -0
  36. package/src/config/types.ts +8 -0
  37. package/src/core/application.ts +4 -0
  38. package/src/core/container.ts +117 -0
  39. package/src/core/index.ts +3 -0
  40. package/src/core/inject.ts +39 -0
  41. package/src/database/database.ts +54 -0
  42. package/src/database/index.ts +30 -0
  43. package/src/database/introspector.ts +446 -0
  44. package/src/database/migration/differ.ts +308 -0
  45. package/src/database/migration/file_generator.ts +125 -0
  46. package/src/database/migration/index.ts +18 -0
  47. package/src/database/migration/runner.ts +133 -0
  48. package/src/database/migration/sql_generator.ts +378 -0
  49. package/src/database/migration/tracker.ts +76 -0
  50. package/src/database/migration/types.ts +189 -0
  51. package/src/database/query_builder.ts +474 -0
  52. package/src/encryption/encryption_manager.ts +209 -0
  53. package/src/encryption/helpers.ts +158 -0
  54. package/src/encryption/index.ts +3 -0
  55. package/src/encryption/types.ts +6 -0
  56. package/src/events/emitter.ts +101 -0
  57. package/src/events/index.ts +2 -0
  58. package/src/exceptions/errors.ts +75 -0
  59. package/src/exceptions/exception_handler.ts +126 -0
  60. package/src/exceptions/helpers.ts +25 -0
  61. package/src/exceptions/http_exception.ts +129 -0
  62. package/src/exceptions/index.ts +23 -0
  63. package/src/exceptions/strav_error.ts +11 -0
  64. package/src/generators/api_generator.ts +972 -0
  65. package/src/generators/config.ts +87 -0
  66. package/src/generators/doc_generator.ts +974 -0
  67. package/src/generators/index.ts +11 -0
  68. package/src/generators/model_generator.ts +586 -0
  69. package/src/generators/route_generator.ts +188 -0
  70. package/src/generators/test_generator.ts +1666 -0
  71. package/src/helpers/crypto.ts +4 -0
  72. package/src/helpers/env.ts +50 -0
  73. package/src/helpers/identity.ts +12 -0
  74. package/src/helpers/index.ts +4 -0
  75. package/src/helpers/strings.ts +67 -0
  76. package/src/http/context.ts +215 -0
  77. package/src/http/cookie.ts +59 -0
  78. package/src/http/cors.ts +163 -0
  79. package/src/http/index.ts +16 -0
  80. package/src/http/middleware.ts +39 -0
  81. package/src/http/rate_limit.ts +173 -0
  82. package/src/http/router.ts +556 -0
  83. package/src/http/server.ts +79 -0
  84. package/src/i18n/defaults/en/validation.json +20 -0
  85. package/src/i18n/helpers.ts +72 -0
  86. package/src/i18n/i18n_manager.ts +155 -0
  87. package/src/i18n/index.ts +4 -0
  88. package/src/i18n/middleware.ts +90 -0
  89. package/src/i18n/translator.ts +96 -0
  90. package/src/i18n/types.ts +17 -0
  91. package/src/logger/index.ts +6 -0
  92. package/src/logger/logger.ts +100 -0
  93. package/src/logger/request_logger.ts +19 -0
  94. package/src/logger/sinks/console_sink.ts +24 -0
  95. package/src/logger/sinks/file_sink.ts +24 -0
  96. package/src/logger/sinks/sink.ts +36 -0
  97. package/src/mail/css_inliner.ts +79 -0
  98. package/src/mail/helpers.ts +212 -0
  99. package/src/mail/index.ts +19 -0
  100. package/src/mail/mail_manager.ts +92 -0
  101. package/src/mail/transports/log_transport.ts +69 -0
  102. package/src/mail/transports/resend_transport.ts +59 -0
  103. package/src/mail/transports/sendgrid_transport.ts +77 -0
  104. package/src/mail/transports/smtp_transport.ts +48 -0
  105. package/src/mail/types.ts +80 -0
  106. package/src/notification/base_notification.ts +67 -0
  107. package/src/notification/channels/database_channel.ts +30 -0
  108. package/src/notification/channels/discord_channel.ts +43 -0
  109. package/src/notification/channels/email_channel.ts +37 -0
  110. package/src/notification/channels/webhook_channel.ts +45 -0
  111. package/src/notification/helpers.ts +214 -0
  112. package/src/notification/index.ts +20 -0
  113. package/src/notification/notification_manager.ts +126 -0
  114. package/src/notification/types.ts +122 -0
  115. package/src/orm/base_model.ts +351 -0
  116. package/src/orm/decorators.ts +127 -0
  117. package/src/orm/index.ts +4 -0
  118. package/src/policy/authorize.ts +44 -0
  119. package/src/policy/index.ts +3 -0
  120. package/src/policy/policy_result.ts +13 -0
  121. package/src/queue/index.ts +11 -0
  122. package/src/queue/queue.ts +338 -0
  123. package/src/queue/worker.ts +197 -0
  124. package/src/scheduler/cron.ts +140 -0
  125. package/src/scheduler/index.ts +7 -0
  126. package/src/scheduler/runner.ts +116 -0
  127. package/src/scheduler/schedule.ts +183 -0
  128. package/src/scheduler/scheduler.ts +47 -0
  129. package/src/schema/database_representation.ts +122 -0
  130. package/src/schema/define_association.ts +60 -0
  131. package/src/schema/define_schema.ts +46 -0
  132. package/src/schema/field_builder.ts +155 -0
  133. package/src/schema/field_definition.ts +66 -0
  134. package/src/schema/index.ts +21 -0
  135. package/src/schema/naming.ts +19 -0
  136. package/src/schema/postgres.ts +109 -0
  137. package/src/schema/registry.ts +157 -0
  138. package/src/schema/representation_builder.ts +479 -0
  139. package/src/schema/type_builder.ts +107 -0
  140. package/src/schema/types.ts +35 -0
  141. package/src/session/index.ts +4 -0
  142. package/src/session/middleware.ts +46 -0
  143. package/src/session/session.ts +308 -0
  144. package/src/session/session_manager.ts +81 -0
  145. package/src/storage/index.ts +13 -0
  146. package/src/storage/local_driver.ts +46 -0
  147. package/src/storage/s3_driver.ts +51 -0
  148. package/src/storage/storage.ts +43 -0
  149. package/src/storage/storage_manager.ts +59 -0
  150. package/src/storage/types.ts +42 -0
  151. package/src/storage/upload.ts +91 -0
  152. package/src/validation/index.ts +18 -0
  153. package/src/validation/rules.ts +170 -0
  154. package/src/validation/validate.ts +41 -0
  155. package/src/view/cache.ts +47 -0
  156. package/src/view/client/islands.ts +50 -0
  157. package/src/view/compiler.ts +185 -0
  158. package/src/view/engine.ts +139 -0
  159. package/src/view/escape.ts +14 -0
  160. package/src/view/index.ts +13 -0
  161. package/src/view/islands/island_builder.ts +161 -0
  162. package/src/view/islands/vue_plugin.ts +140 -0
  163. package/src/view/middleware/static.ts +35 -0
  164. package/src/view/tokenizer.ts +172 -0
  165. package/tsconfig.json +4 -0
@@ -0,0 +1,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
+ }