@c9up/atlas 0.2.0 → 0.2.1

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 (234) hide show
  1. package/README.md +8 -0
  2. package/db.darwin-arm64.node +0 -0
  3. package/db.darwin-x64.node +0 -0
  4. package/db.linux-arm64-gnu.node +0 -0
  5. package/db.linux-x64-gnu.node +0 -0
  6. package/db.win32-x64-msvc.node +0 -0
  7. package/dist/AtlasProvider.d.ts +60 -7
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +204 -38
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +17 -0
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js.map +1 -1
  14. package/dist/BaseModel.d.ts +63 -27
  15. package/dist/BaseModel.d.ts.map +1 -1
  16. package/dist/BaseModel.js +108 -51
  17. package/dist/BaseModel.js.map +1 -1
  18. package/dist/BaseRepository.d.ts +11 -1
  19. package/dist/BaseRepository.d.ts.map +1 -1
  20. package/dist/BaseRepository.js +95 -20
  21. package/dist/BaseRepository.js.map +1 -1
  22. package/dist/ConnectionManager.d.ts +106 -0
  23. package/dist/ConnectionManager.d.ts.map +1 -0
  24. package/dist/ConnectionManager.js +228 -0
  25. package/dist/ConnectionManager.js.map +1 -0
  26. package/dist/ModelQuery.d.ts +256 -43
  27. package/dist/ModelQuery.d.ts.map +1 -1
  28. package/dist/ModelQuery.js +899 -231
  29. package/dist/ModelQuery.js.map +1 -1
  30. package/dist/Transaction.d.ts +54 -1
  31. package/dist/Transaction.d.ts.map +1 -1
  32. package/dist/Transaction.js +121 -59
  33. package/dist/Transaction.js.map +1 -1
  34. package/dist/adapters/NapiDbAdapter.d.ts +11 -0
  35. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  36. package/dist/adapters/NapiDbAdapter.js +35 -7
  37. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  38. package/dist/console/contract.d.ts +62 -0
  39. package/dist/console/contract.d.ts.map +1 -0
  40. package/dist/console/contract.js +38 -0
  41. package/dist/console/contract.js.map +1 -0
  42. package/dist/console/factoryCommands.d.ts +23 -0
  43. package/dist/console/factoryCommands.d.ts.map +1 -0
  44. package/dist/console/factoryCommands.js +62 -0
  45. package/dist/console/factoryCommands.js.map +1 -0
  46. package/dist/console/migrationCommands.d.ts +38 -8
  47. package/dist/console/migrationCommands.d.ts.map +1 -1
  48. package/dist/console/migrationCommands.js +157 -50
  49. package/dist/console/migrationCommands.js.map +1 -1
  50. package/dist/console/schemaCheckCommand.d.ts +11 -17
  51. package/dist/console/schemaCheckCommand.d.ts.map +1 -1
  52. package/dist/console/schemaCheckCommand.js +21 -15
  53. package/dist/console/schemaCheckCommand.js.map +1 -1
  54. package/dist/console/schemaDumpCommand.d.ts +30 -0
  55. package/dist/console/schemaDumpCommand.d.ts.map +1 -0
  56. package/dist/console/schemaDumpCommand.js +69 -0
  57. package/dist/console/schemaDumpCommand.js.map +1 -0
  58. package/dist/console/schemaGenerateCommand.d.ts +100 -0
  59. package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
  60. package/dist/console/schemaGenerateCommand.js +246 -0
  61. package/dist/console/schemaGenerateCommand.js.map +1 -0
  62. package/dist/console/seederCommands.d.ts +46 -0
  63. package/dist/console/seederCommands.d.ts.map +1 -0
  64. package/dist/console/seederCommands.js +136 -0
  65. package/dist/console/seederCommands.js.map +1 -0
  66. package/dist/decorators/entity.d.ts +13 -2
  67. package/dist/decorators/entity.d.ts.map +1 -1
  68. package/dist/decorators/entity.js +10 -0
  69. package/dist/decorators/entity.js.map +1 -1
  70. package/dist/decorators/hooks.d.ts +3 -3
  71. package/dist/decorators/hooks.d.ts.map +1 -1
  72. package/dist/decorators/hooks.js.map +1 -1
  73. package/dist/events.d.ts +5 -0
  74. package/dist/events.d.ts.map +1 -1
  75. package/dist/events.js.map +1 -1
  76. package/dist/factories.d.ts +8 -0
  77. package/dist/factories.d.ts.map +1 -0
  78. package/dist/factories.js +8 -0
  79. package/dist/factories.js.map +1 -0
  80. package/dist/index.d.ts +11 -5
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +12 -4
  83. package/dist/index.js.map +1 -1
  84. package/dist/lucid-schema.d.ts +15 -0
  85. package/dist/lucid-schema.d.ts.map +1 -0
  86. package/dist/lucid-schema.js +16 -0
  87. package/dist/lucid-schema.js.map +1 -0
  88. package/dist/orm.d.ts +14 -0
  89. package/dist/orm.d.ts.map +1 -0
  90. package/dist/orm.js +14 -0
  91. package/dist/orm.js.map +1 -0
  92. package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
  93. package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
  94. package/dist/query/DatabaseQueryBuilder.js +1798 -0
  95. package/dist/query/DatabaseQueryBuilder.js.map +1 -0
  96. package/dist/query/DmlBuilder.d.ts +62 -0
  97. package/dist/query/DmlBuilder.d.ts.map +1 -0
  98. package/dist/query/DmlBuilder.js +84 -0
  99. package/dist/query/DmlBuilder.js.map +1 -0
  100. package/dist/query/QueryBuilder.d.ts +5 -0
  101. package/dist/query/QueryBuilder.d.ts.map +1 -1
  102. package/dist/query/QueryBuilder.js +7 -0
  103. package/dist/query/QueryBuilder.js.map +1 -1
  104. package/dist/query/RawQueryBuilder.d.ts +47 -0
  105. package/dist/query/RawQueryBuilder.d.ts.map +1 -0
  106. package/dist/query/RawQueryBuilder.js +138 -0
  107. package/dist/query/RawQueryBuilder.js.map +1 -0
  108. package/dist/query/interpolate.d.ts +45 -0
  109. package/dist/query/interpolate.d.ts.map +1 -0
  110. package/dist/query/interpolate.js +51 -0
  111. package/dist/query/interpolate.js.map +1 -0
  112. package/dist/query/native.d.ts +8 -0
  113. package/dist/query/native.d.ts.map +1 -1
  114. package/dist/query/native.js +14 -0
  115. package/dist/query/native.js.map +1 -1
  116. package/dist/query/operators.d.ts +4 -0
  117. package/dist/query/operators.d.ts.map +1 -0
  118. package/dist/query/operators.js +27 -0
  119. package/dist/query/operators.js.map +1 -0
  120. package/dist/schema/Migration.d.ts +31 -7
  121. package/dist/schema/Migration.d.ts.map +1 -1
  122. package/dist/schema/Migration.js +34 -7
  123. package/dist/schema/Migration.js.map +1 -1
  124. package/dist/schema/MigrationRunner.d.ts +56 -6
  125. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  126. package/dist/schema/MigrationRunner.js +382 -112
  127. package/dist/schema/MigrationRunner.js.map +1 -1
  128. package/dist/schema/Schema.d.ts +59 -2
  129. package/dist/schema/Schema.d.ts.map +1 -1
  130. package/dist/schema/Schema.js +143 -9
  131. package/dist/schema/Schema.js.map +1 -1
  132. package/dist/schema/SchemaDumper.d.ts +121 -0
  133. package/dist/schema/SchemaDumper.d.ts.map +1 -0
  134. package/dist/schema/SchemaDumper.js +365 -0
  135. package/dist/schema/SchemaDumper.js.map +1 -0
  136. package/dist/schema/Seeder.d.ts +13 -0
  137. package/dist/schema/Seeder.d.ts.map +1 -1
  138. package/dist/schema/Seeder.js +28 -5
  139. package/dist/schema/Seeder.js.map +1 -1
  140. package/dist/schema/TableBuilder.d.ts +27 -8
  141. package/dist/schema/TableBuilder.d.ts.map +1 -1
  142. package/dist/schema/TableBuilder.js +50 -12
  143. package/dist/schema/TableBuilder.js.map +1 -1
  144. package/dist/schema/catalog.d.ts +25 -1
  145. package/dist/schema/catalog.d.ts.map +1 -1
  146. package/dist/schema/catalog.js +69 -22
  147. package/dist/schema/catalog.js.map +1 -1
  148. package/dist/schema/introspect.d.ts +1 -1
  149. package/dist/schema/introspect.d.ts.map +1 -1
  150. package/dist/schema/introspect.js +26 -11
  151. package/dist/schema/introspect.js.map +1 -1
  152. package/dist/seeders.d.ts +7 -0
  153. package/dist/seeders.d.ts.map +1 -0
  154. package/dist/seeders.js +7 -0
  155. package/dist/seeders.js.map +1 -0
  156. package/dist/services/db.d.ts +120 -9
  157. package/dist/services/db.d.ts.map +1 -1
  158. package/dist/services/db.js +182 -26
  159. package/dist/services/db.js.map +1 -1
  160. package/dist/testing/DatabaseCleanup.d.ts +29 -6
  161. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  162. package/dist/testing/DatabaseCleanup.js +44 -17
  163. package/dist/testing/DatabaseCleanup.js.map +1 -1
  164. package/dist/testing/DbAssertions.d.ts +48 -0
  165. package/dist/testing/DbAssertions.d.ts.map +1 -0
  166. package/dist/testing/DbAssertions.js +80 -0
  167. package/dist/testing/DbAssertions.js.map +1 -0
  168. package/dist/testing/Factory.d.ts +111 -25
  169. package/dist/testing/Factory.d.ts.map +1 -1
  170. package/dist/testing/Factory.js +331 -56
  171. package/dist/testing/Factory.js.map +1 -1
  172. package/dist/testing/TestUtils.d.ts +49 -0
  173. package/dist/testing/TestUtils.d.ts.map +1 -0
  174. package/dist/testing/TestUtils.js +79 -0
  175. package/dist/testing/TestUtils.js.map +1 -0
  176. package/dist/testing/index.d.ts +3 -1
  177. package/dist/testing/index.d.ts.map +1 -1
  178. package/dist/testing/index.js +3 -1
  179. package/dist/testing/index.js.map +1 -1
  180. package/dist/types/relations.d.ts +31 -0
  181. package/dist/types/relations.d.ts.map +1 -0
  182. package/dist/types/relations.js +20 -0
  183. package/dist/types/relations.js.map +1 -0
  184. package/index.darwin-arm64.node +0 -0
  185. package/index.darwin-x64.node +0 -0
  186. package/index.linux-arm64-gnu.node +0 -0
  187. package/index.linux-x64-gnu.node +0 -0
  188. package/index.win32-x64-msvc.node +0 -0
  189. package/package.json +30 -2
  190. package/src/AtlasProvider.ts +297 -51
  191. package/src/BaseEntity.ts +29 -0
  192. package/src/BaseModel.ts +174 -37
  193. package/src/BaseRepository.ts +136 -17
  194. package/src/ConnectionManager.ts +298 -0
  195. package/src/ModelQuery.ts +1309 -260
  196. package/src/Transaction.ts +194 -60
  197. package/src/adapters/NapiDbAdapter.ts +72 -14
  198. package/src/console/contract.ts +96 -0
  199. package/src/console/factoryCommands.ts +73 -0
  200. package/src/console/migrationCommands.ts +253 -63
  201. package/src/console/schemaCheckCommand.ts +26 -22
  202. package/src/console/schemaDumpCommand.ts +96 -0
  203. package/src/console/schemaGenerateCommand.ts +380 -0
  204. package/src/console/seederCommands.ts +174 -0
  205. package/src/decorators/entity.ts +18 -2
  206. package/src/decorators/hooks.ts +5 -3
  207. package/src/events.ts +5 -0
  208. package/src/factories.ts +10 -0
  209. package/src/index.ts +50 -3
  210. package/src/lucid-schema.ts +22 -0
  211. package/src/orm.ts +13 -0
  212. package/src/query/DatabaseQueryBuilder.ts +2724 -0
  213. package/src/query/DmlBuilder.ts +131 -0
  214. package/src/query/QueryBuilder.ts +8 -0
  215. package/src/query/RawQueryBuilder.ts +173 -0
  216. package/src/query/interpolate.ts +73 -0
  217. package/src/query/native.ts +29 -0
  218. package/src/query/operators.ts +27 -0
  219. package/src/schema/Migration.ts +36 -7
  220. package/src/schema/MigrationRunner.ts +483 -119
  221. package/src/schema/Schema.ts +168 -19
  222. package/src/schema/SchemaDumper.ts +518 -0
  223. package/src/schema/Seeder.ts +47 -10
  224. package/src/schema/TableBuilder.ts +48 -11
  225. package/src/schema/catalog.ts +94 -24
  226. package/src/schema/introspect.ts +26 -9
  227. package/src/seeders.ts +16 -0
  228. package/src/services/db.ts +343 -31
  229. package/src/testing/DatabaseCleanup.ts +73 -21
  230. package/src/testing/DbAssertions.ts +127 -0
  231. package/src/testing/Factory.ts +559 -79
  232. package/src/testing/TestUtils.ts +123 -0
  233. package/src/testing/index.ts +12 -1
  234. package/src/types/relations.ts +43 -0
@@ -3,15 +3,31 @@
3
3
  *
4
4
  * @implements FR34
5
5
  */
6
+ import { randomUUID } from "node:crypto";
6
7
  import * as fsp from "node:fs/promises";
7
8
  import * as path from "node:path";
8
9
  import { pathToFileURL } from "node:url";
9
10
  import { AtlasError } from "../errors.js";
10
11
  import { compileStatementNative } from "../query/native.js";
11
12
  import { assertPathInsideBase, assertSafeName, pathExists, } from "../utils/safePath.js";
12
- import { listUserTables, withoutForeignKeys } from "./catalog.js";
13
+ import { columnExists, listUserTables, runWithoutForeignKeys, tableExists, } from "./catalog.js";
14
+ import { readSchemaDumpManifest } from "./SchemaDumper.js";
13
15
  const DEFAULT_TABLE = "ream_migrations";
14
16
  const TABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
17
+ /** The single lock row's fixed primary key — the lock is always this one row. */
18
+ const LOCK_ROW_ID = 1;
19
+ /**
20
+ * Split a schema-dump `.sql` file into executable statements. Statements are
21
+ * `;`-terminated (the dump format atlas writes); chunks that are empty or only
22
+ * SQL comments once trimmed are dropped. Leading `--` comment lines on a real
23
+ * statement are kept (every dialect parses a comment before a statement).
24
+ */
25
+ function splitSqlStatements(sql) {
26
+ return sql
27
+ .split(";")
28
+ .map((s) => s.trim())
29
+ .filter((s) => s.replace(/^\s*--.*$/gm, "").trim().length > 0);
30
+ }
15
31
  function validateTrackingTableName(name) {
16
32
  if (!TABLE_NAME_PATTERN.test(name)) {
17
33
  throw new AtlasError("MIGRATION_INVALID_TABLE_NAME", `Invalid tracking-table name: ${JSON.stringify(name)}. ` +
@@ -67,7 +83,11 @@ export class MigrationRunner {
67
83
  */
68
84
  #disableRollbacksInProduction;
69
85
  #disableLocks;
86
+ #disableTransactions;
87
+ #naturalSort;
70
88
  #lockTableName;
89
+ /** Token identifying the lock WE hold, so release only clears our own lock. */
90
+ #lockToken;
71
91
  constructor(db, options) {
72
92
  this.#db = db;
73
93
  this.#migrationsDir = options?.migrationsDir ?? "database/migrations";
@@ -76,9 +96,13 @@ export class MigrationRunner {
76
96
  options?.tableName === undefined
77
97
  ? DEFAULT_TABLE
78
98
  : validateTrackingTableName(options.tableName);
99
+ // Defaults ON (Lucid parity): destructive ops are guarded in production
100
+ // unless the caller explicitly opts out or forces per call.
79
101
  this.#disableRollbacksInProduction =
80
- options?.disableRollbacksInProduction ?? false;
102
+ options?.disableRollbacksInProduction ?? true;
81
103
  this.#disableLocks = options?.disableLocks ?? false;
104
+ this.#disableTransactions = options?.disableTransactions ?? false;
105
+ this.#naturalSort = options?.naturalSort ?? false;
82
106
  this.#lockTableName = `${this.#tableName}_lock`;
83
107
  }
84
108
  /**
@@ -93,10 +117,14 @@ export class MigrationRunner {
93
117
  table: this.#lockTableName,
94
118
  ifNotExists: true,
95
119
  columns: [
120
+ // A FIXED single-row identity (id = LOCK_ROW_ID, PK). Not
121
+ // auto-increment: every acquire/release/seed targets `WHERE id = 1`,
122
+ // so a duplicate row is impossible (the PK rejects it). That removes
123
+ // any need for a "recover from multi-row" DELETE that could wipe an
124
+ // active lock held by another process.
96
125
  {
97
126
  name: "id",
98
127
  kind: "integer",
99
- autoIncrement: true,
100
128
  nullable: false,
101
129
  primary: true,
102
130
  unique: false,
@@ -118,49 +146,133 @@ export class MigrationRunner {
118
146
  precision: null,
119
147
  scale: null,
120
148
  },
149
+ {
150
+ name: "locked_by",
151
+ kind: "string",
152
+ nullable: true,
153
+ primary: false,
154
+ unique: false,
155
+ default: null,
156
+ references: null,
157
+ length: null,
158
+ precision: null,
159
+ scale: null,
160
+ },
121
161
  ],
122
162
  indexes: [],
123
163
  });
124
- // The lock-table name derives from the validated tracking-table name, so
125
- // it is a safe identifier no injection surface in these raw statements.
126
- const rows = await this.#db.query(`SELECT COUNT(*) AS c FROM ${this.#lockTableName}`);
127
- if (Number(rows[0]?.c ?? 0) === 0) {
128
- await this.#db.execute(`INSERT INTO ${this.#lockTableName} (is_locked) VALUES (0)`);
164
+ // Upgrade path: a lock table created by an earlier atlas (id + is_locked,
165
+ // no `locked_by`) is left untouched by CREATE TABLE IF NOT EXISTS, so the
166
+ // token UPDATE/SELECT below would hit a missing column. Add it if absent.
167
+ if (!(await columnExists(this.#db, this.#dialect, this.#lockTableName, "locked_by"))) {
168
+ try {
169
+ await this.#db.execute(`ALTER TABLE ${this.#lockTableName} ADD COLUMN locked_by TEXT`);
170
+ }
171
+ catch {
172
+ // TOCTOU: two concurrent first-boots on a legacy lock table can both
173
+ // see the column absent and both ALTER; the loser hits a
174
+ // duplicate-column error (no dialect has ADD COLUMN IF NOT EXISTS on
175
+ // all three). Swallow it — the column now exists either way; a real
176
+ // failure surfaces on the very next statement (seed/UPDATE).
177
+ }
129
178
  }
179
+ // Seed the single lock row idempotently. INSERT-or-ignore on the PK means
180
+ // concurrent seeders and re-runs never create a second row and never touch
181
+ // an existing (possibly held) lock. The lock-table name derives from the
182
+ // validated tracking-table name — a safe identifier, no injection surface.
183
+ await this.#db.execute(this.#seedLockRowSql());
184
+ }
185
+ /** Dialect-specific idempotent seed of the single `id = 1` lock row. */
186
+ #seedLockRowSql() {
187
+ const t = this.#lockTableName;
188
+ const values = `(${LOCK_ROW_ID}, 0)`;
189
+ switch (this.#dialect) {
190
+ case "sqlite":
191
+ return `INSERT OR IGNORE INTO ${t} (id, is_locked) VALUES ${values}`;
192
+ case "mysql":
193
+ return `INSERT IGNORE INTO ${t} (id, is_locked) VALUES ${values}`;
194
+ case "postgres":
195
+ return `INSERT INTO ${t} (id, is_locked) VALUES ${values} ON CONFLICT (id) DO NOTHING`;
196
+ }
197
+ }
198
+ /** Dialect placeholder for a single bound parameter. */
199
+ get #ph() {
200
+ return this.#dialect === "postgres" ? "$1" : "?";
130
201
  }
131
202
  /**
132
203
  * Acquire the migration lock so two processes cannot migrate concurrently
133
- * (Adonis Lucid / Knex parity — a lock TABLE, not an advisory lock). Throws
134
- * `E_MIGRATION_LOCKED` when the lock is already held.
204
+ * (Adonis Lucid / Knex parity — a lock TABLE, not an advisory lock).
205
+ *
206
+ * ATOMIC: a single conditional `UPDATE … WHERE is_locked = 0` stamps our
207
+ * token — the database serialises concurrent updates on that row, so only ONE
208
+ * writer flips 0→1; every other writer's `WHERE` no longer matches. The
209
+ * token read-back tells us whether WE won. This avoids the check-then-set
210
+ * race of a separate SELECT + UPDATE. Throws `E_MIGRATION_LOCKED` otherwise.
135
211
  */
136
212
  async #acquireLock() {
137
213
  if (this.#disableLocks)
138
214
  return;
139
215
  await this.#ensureLockTable();
140
- const rows = await this.#db.query(`SELECT is_locked FROM ${this.#lockTableName}`);
141
- const locked = rows[0]?.is_locked;
142
- if (locked === 1 || locked === true || locked === "1") {
216
+ const token = randomUUID();
217
+ await this.#db.execute(`UPDATE ${this.#lockTableName} SET is_locked = 1, locked_by = ${this.#ph} WHERE id = ${LOCK_ROW_ID} AND is_locked = 0`, [token]);
218
+ const rows = await this.#db.query(`SELECT locked_by FROM ${this.#lockTableName} WHERE id = ${LOCK_ROW_ID}`);
219
+ if (rows[0]?.locked_by !== token) {
143
220
  throw new AtlasError("E_MIGRATION_LOCKED", "Could not acquire the migration lock — another migration is already running.", {
144
221
  hint: `Wait for it to finish, clear the ${this.#lockTableName} table if it is stuck, or pass disableLocks.`,
145
222
  });
146
223
  }
147
- await this.#db.execute(`UPDATE ${this.#lockTableName} SET is_locked = 1`);
224
+ this.#lockToken = token;
148
225
  }
149
- /** Release the migration lock (clears the `is_locked` flag). */
226
+ /** Run `fn` while holding the migration lock; always release, even on throw. */
227
+ async #withLock(fn) {
228
+ await this.#acquireLock();
229
+ try {
230
+ return await fn();
231
+ }
232
+ finally {
233
+ await this.#releaseLock();
234
+ }
235
+ }
236
+ /** Release the migration lock — only OUR token, so we never clear someone else's. */
150
237
  async #releaseLock() {
151
- if (this.#disableLocks)
238
+ if (this.#disableLocks || this.#lockToken === undefined)
152
239
  return;
153
- await this.#db.execute(`UPDATE ${this.#lockTableName} SET is_locked = 0`);
240
+ const token = this.#lockToken;
241
+ this.#lockToken = undefined;
242
+ await this.#db.execute(`UPDATE ${this.#lockTableName} SET is_locked = 0, locked_by = NULL WHERE id = ${LOCK_ROW_ID} AND locked_by = ${this.#ph}`, [token]);
154
243
  }
155
244
  /**
156
- * Throw when rollbacks are disabled in production and this is a production
157
- * run, unless the caller explicitly forces it.
245
+ * Force-clear a stuck migration lock (Adonis Lucid `migration:unlock`). A
246
+ * process killed mid-migrate leaves `is_locked = 1` with a stale token and
247
+ * NO way for a later run to acquire — this unconditionally clears the row so
248
+ * migrations can proceed. Returns `true` if a held lock was cleared.
249
+ */
250
+ async forceUnlock() {
251
+ if (!(await tableExists(this.#db, this.#dialect, this.#lockTableName))) {
252
+ return false;
253
+ }
254
+ // Upgrade a legacy lock table (id + is_locked, no `locked_by`) BEFORE the
255
+ // `locked_by = NULL` UPDATE below — otherwise it hits a missing column and
256
+ // throws instead of clearing the stuck lock. `#ensureLockTable` is
257
+ // idempotent (CREATE IF NOT EXISTS + ALTER-add + idempotent seed).
258
+ await this.#ensureLockTable();
259
+ const rows = await this.#db.query(`SELECT is_locked FROM ${this.#lockTableName} WHERE id = ${LOCK_ROW_ID}`);
260
+ const wasLocked = rows[0]?.is_locked === 1 ||
261
+ rows[0]?.is_locked === true ||
262
+ rows[0]?.is_locked === "1";
263
+ await this.#db.execute(`UPDATE ${this.#lockTableName} SET is_locked = 0, locked_by = NULL WHERE id = ${LOCK_ROW_ID}`);
264
+ return wasLocked;
265
+ }
266
+ /**
267
+ * Throw when destructive migration operations (rollback / reset / refresh /
268
+ * fresh / wipe) are disabled in production and this is a production run,
269
+ * unless the caller explicitly forces it.
158
270
  */
159
271
  #assertRollbackAllowed(force) {
160
272
  if (this.#disableRollbacksInProduction &&
161
273
  !force &&
162
274
  process.env.NODE_ENV === "production") {
163
- throw new AtlasError("E_ROLLBACK_DISABLED_IN_PRODUCTION", "Rollbacks are disabled in production. Pass { force: true } to override.", {
275
+ throw new AtlasError("E_ROLLBACK_DISABLED_IN_PRODUCTION", "Destructive migration operations are disabled in production. Pass { force: true } to override.", {
164
276
  hint: "This guard exists to prevent dropping production data by accident.",
165
277
  });
166
278
  }
@@ -230,24 +342,26 @@ export class MigrationRunner {
230
342
  }
231
343
  /** Get the status of all migrations. */
232
344
  async status() {
233
- // Ensure the tracking table exists first on a never-migrated database
234
- // status must report every migration as pending, not throw on a missing
235
- // `ream_migrations` table (AdonisJS/Lucid `migration:status` parity).
236
- await this.init();
237
- const applied = await queryStmt(this.#db, this.#dialect, {
238
- kind: "select",
239
- table: this.#tableName,
240
- select: ["name", "batch"],
241
- wheres: [],
242
- orderBy: [{ column: "name", direction: "asc" }],
243
- groupBy: [],
244
- having: [],
245
- limit: null,
246
- offset: null,
247
- distinct: false,
248
- ctes: [],
249
- unions: [],
250
- });
345
+ // READ-ONLY: do NOT init() (which would CREATE the tracking table). On a
346
+ // never-migrated database the table is simply absent everything pending,
347
+ // no side effect (AdonisJS/Lucid `migration:status` parity, same guarantee
348
+ // as dryRun).
349
+ const applied = (await tableExists(this.#db, this.#dialect, this.#tableName))
350
+ ? await queryStmt(this.#db, this.#dialect, {
351
+ kind: "select",
352
+ table: this.#tableName,
353
+ select: ["name", "batch"],
354
+ wheres: [],
355
+ orderBy: [{ column: "name", direction: "asc" }],
356
+ groupBy: [],
357
+ having: [],
358
+ limit: null,
359
+ offset: null,
360
+ distinct: false,
361
+ ctes: [],
362
+ unions: [],
363
+ })
364
+ : [];
251
365
  const appliedMap = new Map(applied.map((r) => [r.name, r.batch]));
252
366
  const files = await this.#discoverFiles();
253
367
  return files.map((f) => ({
@@ -257,16 +371,14 @@ export class MigrationRunner {
257
371
  }));
258
372
  }
259
373
  /** Run all pending migrations. */
260
- async migrate() {
261
- await this.#acquireLock();
262
- try {
263
- return await this.#migrateLocked();
264
- }
265
- finally {
266
- await this.#releaseLock();
267
- }
374
+ async migrate(options = {}) {
375
+ return this.#withLock(() => this.#migrateLocked(options.schemaPath));
268
376
  }
269
- async #migrateLocked() {
377
+ async #migrateLocked(schemaPath) {
378
+ // Adonis Lucid `migration:run --schema-path`: when nothing is applied yet
379
+ // and a dump exists, load it in place of replaying history, then run only
380
+ // the migrations that postdate the dump (below, via the normal pending set).
381
+ await this.#maybeLoadDump(schemaPath);
270
382
  await this.init();
271
383
  const applied = await queryStmt(this.#db, this.#dialect, {
272
384
  kind: "select",
@@ -295,6 +407,9 @@ export class MigrationRunner {
295
407
  const migration = await this.#loadMigration(name);
296
408
  const statements = await migration.getUpSQL(this.#db);
297
409
  const deferred = migration.consumeDeferred();
410
+ // Effective opt-out: the global config OR this migration's own
411
+ // `static disableTransactions = true`.
412
+ const disableTx = this.#disableTransactions || migration.transactionsDisabled;
298
413
  // Compile the ream_migrations INSERT so we can include it in the same
299
414
  // transaction as the migration's own DDL/DML — either everything
300
415
  // commits or nothing does.
@@ -310,7 +425,7 @@ export class MigrationRunner {
310
425
  if (insertSql === undefined) {
311
426
  throw new AtlasError("MIGRATION_COMPILE_EMPTY", `compileStatementNative returned no statements for migration insert of '${name}'`);
312
427
  }
313
- await this.#runStep(statements, deferred, { sql: insertSql, params: insertCompiled.params }, name);
428
+ await this.#runStep(statements, deferred, { sql: insertSql, params: insertCompiled.params }, name, disableTx);
314
429
  executed.push(name);
315
430
  }
316
431
  return executed;
@@ -318,13 +433,7 @@ export class MigrationRunner {
318
433
  /** Rollback the last batch of migrations. */
319
434
  async rollback(options = {}) {
320
435
  this.#assertRollbackAllowed(options.force ?? false);
321
- await this.#acquireLock();
322
- try {
323
- return await this.#rollbackLocked(options);
324
- }
325
- finally {
326
- await this.#releaseLock();
327
- }
436
+ return this.#withLock(() => this.#rollbackLocked(options));
328
437
  }
329
438
  async #rollbackLocked(options) {
330
439
  await this.init();
@@ -364,6 +473,8 @@ export class MigrationRunner {
364
473
  const migration = await this.#loadMigration(record.name);
365
474
  const statements = await migration.getDownSQL(this.#db);
366
475
  const deferred = migration.consumeDeferred();
476
+ // `static disableTransactions` applies to down() too (Adonis parity).
477
+ const disableTx = this.#disableTransactions || migration.transactionsDisabled;
367
478
  const deleteCompiled = compileStatementNative({
368
479
  kind: "delete",
369
480
  table: this.#tableName,
@@ -375,40 +486,94 @@ export class MigrationRunner {
375
486
  if (deleteSql === undefined) {
376
487
  throw new AtlasError("MIGRATION_COMPILE_EMPTY", `compileStatementNative returned no statements for migration delete of '${record.name}'`);
377
488
  }
378
- await this.#runStep(statements, deferred, { sql: deleteSql, params: deleteCompiled.params }, record.name);
489
+ await this.#runStep(statements, deferred, { sql: deleteSql, params: deleteCompiled.params }, record.name, disableTx);
379
490
  rolled.push(record.name);
380
491
  }
381
492
  return rolled;
382
493
  }
383
- /**
384
- * Run a batch of statements in a single transaction if the adapter supports
385
- * it; otherwise fall back to sequential execute (best-effort). Adapters that
386
- * lack transaction support get a warning so integrators notice the risk.
387
- */
388
494
  /**
389
495
  * Run one migration step: its schema statements plus the bookkeeping record
390
496
  * write (INSERT for migrate, DELETE for rollback).
391
497
  *
392
- * With no deferred callbacks this is the strong atomic path schema + record
393
- * commit together. With `this.defer()` callbacks, the schema runs atomically
394
- * first, then the deferred callbacks, then the record write so a failing
395
- * deferred callback leaves the migration UN-recorded (re-runnable), at the
396
- * cost of the schema no longer sharing a transaction with the record.
498
+ * With no deferred callbacks, schema + record commit together in one atomic
499
+ * batch. With `this.defer()` callbacks and an adapter that exposes an
500
+ * interactive `transaction()`, the schema, the deferred callbacks AND the
501
+ * record write all run in ONE transaction a throwing callback rolls the
502
+ * schema back too (fully atomic on sqlite/postgres; MySQL auto-commits DDL, so
503
+ * only its tracking row is bound to the callbacks). An adapter with no
504
+ * `transaction()` cannot make defer atomic, so it is REJECTED with
505
+ * `E_DEFER_REQUIRES_TRANSACTION` rather than silently degrading to a
506
+ * schema-then-callbacks-then-record best effort.
507
+ *
508
+ * When `disableTransactions` is set (Adonis `static disableTransactions` / the
509
+ * global config — a DELIBERATE opt-out for txn-incompatible DDL), every
510
+ * statement runs OUTSIDE a transaction: schema, then deferred callbacks, then
511
+ * the record, each committed on its own. Non-atomic by design, so defer runs
512
+ * loose here and no `transaction()` is required.
397
513
  */
398
- async #runStep(statements, deferred, recordStmt, migrationName) {
514
+ async #runStep(statements, deferred, recordStmt, migrationName, disableTransactions) {
399
515
  const schemaBatch = statements.map((sql) => ({
400
516
  sql,
401
517
  params: [],
402
518
  }));
519
+ if (disableTransactions) {
520
+ // Deliberate opt-out (Adonis): run everything unwrapped. No transaction()
521
+ // needed and no throw — the caller has accepted non-atomicity for DDL that
522
+ // cannot run in a transaction (e.g. Postgres CREATE INDEX CONCURRENTLY).
523
+ if (deferred.length > 0) {
524
+ // The sharp edge: with the transaction gone, the schema, the deferred
525
+ // callbacks and the tracking row commit SEPARATELY, so a failing
526
+ // callback leaves the schema applied but the migration unrecorded.
527
+ // Adonis doesn't warn here — a named safety nudge so the non-atomicity
528
+ // of this exact combo is never a surprise. Make both the DDL and the
529
+ // deferred work idempotent (guarded DDL + re-runnable seeds).
530
+ console.warn(`[atlas] Migration '${migrationName}' combines this.defer() with disableTransactions — the schema, the deferred callbacks and the tracking row commit separately (non-atomic). Make the DDL and the deferred work idempotent.`);
531
+ }
532
+ for (const { sql, params } of schemaBatch) {
533
+ await this.#db.execute(sql, params);
534
+ }
535
+ for (const callback of deferred) {
536
+ await callback(this.#db);
537
+ }
538
+ await this.#db.execute(recordStmt.sql, recordStmt.params);
539
+ return;
540
+ }
403
541
  if (deferred.length === 0) {
404
542
  await this.#runAtomic([...schemaBatch, recordStmt], migrationName);
405
543
  return;
406
544
  }
407
- await this.#runAtomic(schemaBatch, migrationName);
408
- for (const callback of deferred) {
409
- await callback(this.#db);
545
+ // With deferred callbacks: run the schema, the callbacks, AND the tracking
546
+ // row in ONE managed interactive transaction (Lucid runs defer inside the
547
+ // migration transaction). A throwing callback then rolls back the schema
548
+ // too, so the migration is genuinely all-or-nothing / re-runnable — not
549
+ // left applied-but-unrecorded. Fully atomic on sqlite/postgres; MySQL
550
+ // auto-commits DDL so its schema part can't roll back, but the tracking row
551
+ // is still bound to the callbacks. Requires `transaction()` on the adapter.
552
+ if (this.#db.transaction) {
553
+ await this.#db.transaction(async (trx) => {
554
+ for (const { sql, params } of schemaBatch) {
555
+ await trx.execute(sql, params);
556
+ }
557
+ for (const callback of deferred) {
558
+ await callback(trx);
559
+ }
560
+ await trx.execute(recordStmt.sql, recordStmt.params);
561
+ });
562
+ return;
410
563
  }
411
- await this.#db.execute(recordStmt.sql, recordStmt.params);
564
+ // No interactive transaction() available: this.defer() CANNOT be atomic —
565
+ // its callbacks must share the migration's transaction (Adonis wraps every
566
+ // migration in a transaction by default, so defer runs inside it). Refuse
567
+ // loudly rather than commit the schema and leave the migration half-applied
568
+ // behind a warning: a strong guarantee must never silently degrade to a
569
+ // weak one. Reached ONLY by a capability gap — an adapter that cannot do
570
+ // transactions at all (real connections, the provider and the CLI always
571
+ // can). This is NOT Adonis's `disableTransactions` opt-out (a deliberate
572
+ // per-migration choice for txn-incompatible DDL); if that parity feature is
573
+ // added, it must route its own non-atomic path, not trip this guard.
574
+ throw new AtlasError("E_DEFER_REQUIRES_TRANSACTION", `Migration '${migrationName}' uses this.defer(), which needs an interactive transaction() on the adapter to run atomically — this adapter has none.`, {
575
+ hint: "Use a real connection (createNapiConnection) or an adapter that implements transaction(); otherwise remove this.defer().",
576
+ });
412
577
  }
413
578
  async #runAtomic(batch, migrationName) {
414
579
  if (this.#db.runInTransaction) {
@@ -429,9 +594,14 @@ export class MigrationRunner {
429
594
  * @implements Story 32.11
430
595
  */
431
596
  async refresh(options = {}) {
432
- const rolled = await this.reset(options);
433
- const executed = await this.migrate();
434
- return { rolled, executed };
597
+ this.#assertRollbackAllowed(options.force ?? false);
598
+ // One lock held across the WHOLE rollback+re-migrate, so no other run can
599
+ // slip into the free window between reset and migrate.
600
+ return this.#withLock(async () => {
601
+ const rolled = await this.#resetLocked();
602
+ const executed = await this.#migrateLocked();
603
+ return { rolled, executed };
604
+ });
435
605
  }
436
606
  /**
437
607
  * Drop EVERY user table directly (no `down()`), then run every migration from
@@ -446,10 +616,65 @@ export class MigrationRunner {
446
616
  *
447
617
  * `rolled` is always empty — fresh drops rather than rolls back.
448
618
  */
449
- async fresh() {
450
- await this.#dropAllTables();
451
- const executed = await this.migrate();
452
- return { rolled: [], executed };
619
+ async fresh(options = {}) {
620
+ // `fresh` DROPS every table — at least as destructive as rollback, so it
621
+ // must honour the same production guard (Lucid runs it behind `--force` in
622
+ // prod). Held under the migration lock for the whole drop+migrate.
623
+ this.#assertRollbackAllowed(options.force ?? false);
624
+ return this.#withLock(async () => {
625
+ await this.#dropAllTables();
626
+ // With `--schema-path`, rebuild from the dump instead of replaying every
627
+ // migration file (Adonis Lucid `migration:fresh --schema-path`).
628
+ const executed = await this.#migrateLocked(options.schemaPath);
629
+ return { rolled: [], executed };
630
+ });
631
+ }
632
+ /**
633
+ * Load a schema dump (Adonis Lucid `SchemaDumper` output) into the database —
634
+ * executes every statement in the `.sql` file in order, recreating the tables
635
+ * AND the migration bookkeeping rows. Used by `--schema-path`; also callable
636
+ * directly to seed a fresh database from a committed dump.
637
+ */
638
+ async loadDump(sqlPath) {
639
+ const sql = await fsp.readFile(sqlPath, "utf8");
640
+ for (const statement of splitSqlStatements(sql)) {
641
+ await this.#db.execute(statement, []);
642
+ }
643
+ }
644
+ /** Load the dump only when `--schema-path` is set, it exists, and nothing is applied. */
645
+ async #maybeLoadDump(schemaPath) {
646
+ if (!schemaPath)
647
+ return;
648
+ if (await this.#hasAppliedMigrations())
649
+ return;
650
+ if (!(await pathExists(schemaPath)))
651
+ return;
652
+ // Validate the sidecar manifest first (throws on a corrupt one), then check
653
+ // it matches THIS runner — a dump for another dialect can't be loaded, and a
654
+ // different bookkeeping table would leave the runner unable to see the
655
+ // embedded applied-migration rows.
656
+ const manifest = await readSchemaDumpManifest(schemaPath);
657
+ if (manifest) {
658
+ if (manifest.dialect !== this.#dialect) {
659
+ throw new AtlasError("E_SCHEMA_DUMP_DIALECT_MISMATCH", `Schema dump is for '${manifest.dialect}', but this connection is '${this.#dialect}'.`);
660
+ }
661
+ if (manifest.schemaTableName !== this.#tableName) {
662
+ throw new AtlasError("E_SCHEMA_DUMP_TABLE_MISMATCH", `Schema dump uses tracking table '${manifest.schemaTableName}', but this runner expects '${this.#tableName}'.`);
663
+ }
664
+ }
665
+ await this.loadDump(schemaPath);
666
+ }
667
+ /** True when the tracking table exists AND has at least one applied row. */
668
+ async #hasAppliedMigrations() {
669
+ if (!(await tableExists(this.#db, this.#dialect, this.#tableName))) {
670
+ return false;
671
+ }
672
+ const rows = await this.#db.query(`SELECT COUNT(*) AS n FROM ${this.#quoteTable(this.#tableName)}`);
673
+ return Number(rows[0]?.n ?? 0) > 0;
674
+ }
675
+ /** Quote the tracking-table name for the current dialect. */
676
+ #quoteTable(name) {
677
+ return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
453
678
  }
454
679
  /**
455
680
  * Drop every user table — INCLUDING the migrations tracking table (Lucid's
@@ -461,26 +686,52 @@ export class MigrationRunner {
461
686
  * duration (they don't accept/respect CASCADE on `DROP TABLE`), restored
462
687
  * even if a drop throws.
463
688
  */
464
- async wipe() {
465
- return this.#dropAllTables();
689
+ async wipe(options = {}) {
690
+ // `db:wipe` drops every table — same production guard as rollback/fresh.
691
+ this.#assertRollbackAllowed(options.force ?? false);
692
+ // Drop everything WHILE STILL HOLDING the lock — including the lock table
693
+ // itself as the last step — then null our token so the release is a no-op
694
+ // (the table is gone). Doing it inside the critical section leaves NO window
695
+ // for another process to acquire/recreate the lock and get dropped from
696
+ // under it. Result: a truly empty database.
697
+ await this.#acquireLock();
698
+ try {
699
+ await this.#dropAllTables();
700
+ // Always drop the lock table (IF EXISTS) so wipe leaves a truly empty DB
701
+ // — including when disableLocks is set and a PRIOR locked run created it.
702
+ // Under a held lock this is the last step in the critical section; under
703
+ // disableLocks we hold nothing, so dropping it is safe either way.
704
+ const compiled = compileStatementNative({ kind: "dropTable", table: this.#lockTableName, ifExists: true }, this.#dialect);
705
+ for (const sql of compiled.statements) {
706
+ await this.#db.execute(sql, compiled.params);
707
+ }
708
+ this.#lockToken = undefined;
709
+ }
710
+ finally {
711
+ await this.#releaseLock();
712
+ }
466
713
  }
467
714
  async #dropAllTables() {
468
715
  // Include the tracking table: wipe/fresh reset to fully empty, and
469
- // fresh() re-creates it via init() on the next migrate().
470
- const tables = await listUserTables(this.#db, this.#dialect, {
716
+ // fresh() re-creates it via init() on the next migrate(). The lock table
717
+ // is deliberately KEPT we're holding the lock through it, and dropping it
718
+ // mid-operation would break the release. It is infrastructure, not data.
719
+ const tables = (await listUserTables(this.#db, this.#dialect, {
471
720
  includeFrameworkTables: true,
472
- });
721
+ })).filter((t) => t !== this.#lockTableName);
473
722
  if (tables.length === 0)
474
723
  return;
475
724
  const isPg = this.#dialect === "postgres";
476
- await withoutForeignKeys(this.#db, this.#dialect, async () => {
477
- for (const table of tables) {
478
- const compiled = compileStatementNative({ kind: "dropTable", table, ifExists: true, cascade: isPg }, this.#dialect);
479
- for (const sql of compiled.statements) {
480
- await this.#db.execute(sql, compiled.params);
481
- }
725
+ const statements = [];
726
+ for (const table of tables) {
727
+ const compiled = compileStatementNative({ kind: "dropTable", table, ifExists: true, cascade: isPg }, this.#dialect);
728
+ for (const sql of compiled.statements) {
729
+ statements.push({ sql, params: compiled.params });
482
730
  }
483
- });
731
+ }
732
+ // FK toggle + every DROP run on ONE pinned connection (not scattered
733
+ // across the pool) — see `runWithoutForeignKeys`.
734
+ await runWithoutForeignKeys(this.#db, this.#dialect, statements);
484
735
  }
485
736
  /**
486
737
  * Rollback every applied batch (alias for `migrate:reset`).
@@ -488,11 +739,15 @@ export class MigrationRunner {
488
739
  */
489
740
  async reset(options = {}) {
490
741
  this.#assertRollbackAllowed(options.force ?? false);
742
+ return this.#withLock(() => this.#resetLocked());
743
+ }
744
+ /** The reset loop, WITHOUT re-acquiring the lock — the caller holds it, so
745
+ * the whole reset is one atomic critical section (not lock-per-batch). */
746
+ async #resetLocked() {
491
747
  await this.init();
492
748
  const all = [];
493
- // Roll back batches one by one until nothing remains.
494
749
  while ((await this.#currentBatch()) > 0) {
495
- const rolled = await this.rollback({ force: true });
750
+ const rolled = await this.#rollbackLocked({ batch: undefined });
496
751
  if (rolled.length === 0)
497
752
  break;
498
753
  all.push(...rolled);
@@ -505,27 +760,36 @@ export class MigrationRunner {
505
760
  * migration file. Useful for CI pre-flight checks.
506
761
  */
507
762
  async dryRun() {
508
- await this.init();
509
- const applied = await queryStmt(this.#db, this.#dialect, {
510
- kind: "select",
511
- table: this.#tableName,
512
- select: ["name"],
513
- wheres: [],
514
- orderBy: [],
515
- groupBy: [],
516
- having: [],
517
- limit: null,
518
- offset: null,
519
- distinct: false,
520
- ctes: [],
521
- unions: [],
522
- });
523
- const appliedNames = new Set(applied.map((r) => r.name));
763
+ // A dry-run must be side-effect-free: do NOT call init() (it would CREATE
764
+ // the tracking table on an empty database). If the table doesn't exist
765
+ // yet, nothing has been applied.
766
+ const appliedNames = new Set();
767
+ if (await tableExists(this.#db, this.#dialect, this.#tableName)) {
768
+ const applied = await queryStmt(this.#db, this.#dialect, {
769
+ kind: "select",
770
+ table: this.#tableName,
771
+ select: ["name"],
772
+ wheres: [],
773
+ orderBy: [],
774
+ groupBy: [],
775
+ having: [],
776
+ limit: null,
777
+ offset: null,
778
+ distinct: false,
779
+ ctes: [],
780
+ unions: [],
781
+ });
782
+ for (const r of applied)
783
+ appliedNames.add(r.name);
784
+ }
524
785
  const files = (await this.#discoverFiles()).filter((f) => !appliedNames.has(f));
525
786
  const result = [];
526
787
  for (const name of files) {
527
788
  this.#assertSafeName(name);
528
789
  const migration = await this.#loadMigration(name);
790
+ // Adonis `this.dryRun`: let up()/down() branch on it while we only
791
+ // collect SQL and never execute or run deferred callbacks.
792
+ migration.dryRun = true;
529
793
  const statements = await migration.getUpSQL(this.#db);
530
794
  result.push({ name, sql: statements });
531
795
  }
@@ -553,10 +817,16 @@ export class MigrationRunner {
553
817
  async #discoverFiles() {
554
818
  try {
555
819
  const entries = await fsp.readdir(this.#migrationsDir);
556
- return entries
557
- .filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
558
- .sort()
559
- .map((f) => f.replace(/\.(ts|js)$/, ""));
820
+ const files = entries.filter((f) => f.endsWith(".ts") || f.endsWith(".js"));
821
+ // naturalSort (Lucid): numeric-aware so `2_x` < `10_x`. Default: plain
822
+ // lexicographic (UTF-16), correct for fixed-width Date.now() prefixes.
823
+ files.sort(this.#naturalSort
824
+ ? (a, b) => a.localeCompare(b, undefined, {
825
+ numeric: true,
826
+ sensitivity: "base",
827
+ })
828
+ : undefined);
829
+ return files.map((f) => f.replace(/\.(ts|js)$/, ""));
560
830
  }
561
831
  catch (err) {
562
832
  // Missing directory → no migrations. Any other error propagates.