@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
@@ -4,6 +4,7 @@
4
4
  * @implements FR34
5
5
  */
6
6
 
7
+ import { randomUUID } from "node:crypto";
7
8
  import * as fsp from "node:fs/promises";
8
9
  import * as path from "node:path";
9
10
  import { pathToFileURL } from "node:url";
@@ -14,11 +15,33 @@ import {
14
15
  assertSafeName,
15
16
  pathExists,
16
17
  } from "../utils/safePath.js";
17
- import { listUserTables, withoutForeignKeys } from "./catalog.js";
18
+ import {
19
+ type CatalogConnection,
20
+ columnExists,
21
+ listUserTables,
22
+ runWithoutForeignKeys,
23
+ tableExists,
24
+ } from "./catalog.js";
18
25
  import type { DeferredMigrationCallback, Migration } from "./Migration.js";
26
+ import { readSchemaDumpManifest } from "./SchemaDumper.js";
19
27
 
20
28
  const DEFAULT_TABLE = "ream_migrations";
21
29
  const TABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
30
+ /** The single lock row's fixed primary key — the lock is always this one row. */
31
+ const LOCK_ROW_ID = 1;
32
+
33
+ /**
34
+ * Split a schema-dump `.sql` file into executable statements. Statements are
35
+ * `;`-terminated (the dump format atlas writes); chunks that are empty or only
36
+ * SQL comments once trimmed are dropped. Leading `--` comment lines on a real
37
+ * statement are kept (every dialect parses a comment before a statement).
38
+ */
39
+ function splitSqlStatements(sql: string): string[] {
40
+ return sql
41
+ .split(";")
42
+ .map((s) => s.trim())
43
+ .filter((s) => s.replace(/^\s*--.*$/gm, "").trim().length > 0);
44
+ }
22
45
 
23
46
  function validateTrackingTableName(name: string): string {
24
47
  if (!TABLE_NAME_PATTERN.test(name)) {
@@ -68,6 +91,17 @@ export interface DatabaseAdapter {
68
91
  * "Migrations non transactionnelles" in the remediation audit).
69
92
  */
70
93
  runInTransaction?(batch: readonly BatchStmt[]): Promise<number>;
94
+ /**
95
+ * Optional MANAGED interactive transaction pinned to one connection (Lucid
96
+ * `db.transaction(cb)`): commit on success, rollback on throw. Real
97
+ * connections (`createNapiConnection`), the provider and the CLI all supply
98
+ * it. It is REQUIRED for the two operations whose strong guarantee cannot be
99
+ * faked without it: `this.defer()` (its callbacks must share the migration's
100
+ * transaction) and MySQL FK suspension (a session-level `SET` needs a real
101
+ * `finally` to restore). Both REJECT rather than silently degrade when it is
102
+ * absent — plain migrations without either feature don't need it.
103
+ */
104
+ transaction?<T>(callback: (trx: CatalogConnection) => Promise<T>): Promise<T>;
71
105
  /** Close the connection. */
72
106
  close(): Promise<void>;
73
107
  }
@@ -124,7 +158,11 @@ export class MigrationRunner {
124
158
  */
125
159
  #disableRollbacksInProduction: boolean;
126
160
  #disableLocks: boolean;
161
+ #disableTransactions: boolean;
162
+ #naturalSort: boolean;
127
163
  #lockTableName: string;
164
+ /** Token identifying the lock WE hold, so release only clears our own lock. */
165
+ #lockToken: string | undefined;
128
166
 
129
167
  constructor(
130
168
  db: DatabaseAdapter,
@@ -133,9 +171,11 @@ export class MigrationRunner {
133
171
  dialect?: AtlasDialect;
134
172
  tableName?: string;
135
173
  /**
136
- * Refuse `rollback`/`reset` when `NODE_ENV === 'production'` (Adonis
137
- * Lucid `disableRollbacksInProduction`) — a guard against dropping
138
- * production data by accident. Override per call with `{ force: true }`.
174
+ * Refuse destructive ops (rollback/reset/refresh/fresh/wipe) when
175
+ * `NODE_ENV === 'production'` (Adonis Lucid `disableRollbacksInProduction`)
176
+ * — a guard against dropping production data by accident. **Defaults to
177
+ * ON**, matching Lucid: a destructive migration command in production
178
+ * throws unless `{ force: true }` (CLI `--force`). Pass `false` to opt out.
139
179
  */
140
180
  disableRollbacksInProduction?: boolean;
141
181
  /**
@@ -144,6 +184,21 @@ export class MigrationRunner {
144
184
  * so it prevents concurrent migrations on every dialect, SQLite too.
145
185
  */
146
186
  disableLocks?: boolean;
187
+ /**
188
+ * Run EVERY migration outside a transaction (Adonis Lucid global
189
+ * `migrations.disableTransactions`). A single migration can also opt out
190
+ * on its own with `static disableTransactions = true`; the effective value
191
+ * is the OR of the two. Defaults to `false` — migrations are wrapped in a
192
+ * transaction for clean, all-or-nothing rollbacks.
193
+ */
194
+ disableTransactions?: boolean;
195
+ /**
196
+ * Sort migration files with a numeric-aware comparator (Adonis Lucid
197
+ * `migrations.naturalSort`), so `2_x` orders before `10_x`. Defaults to
198
+ * `false` — plain lexicographic order, which is already correct for the
199
+ * fixed-width `Date.now()` prefixes `make:migration` generates.
200
+ */
201
+ naturalSort?: boolean;
147
202
  },
148
203
  ) {
149
204
  this.#db = db;
@@ -153,9 +208,13 @@ export class MigrationRunner {
153
208
  options?.tableName === undefined
154
209
  ? DEFAULT_TABLE
155
210
  : validateTrackingTableName(options.tableName);
211
+ // Defaults ON (Lucid parity): destructive ops are guarded in production
212
+ // unless the caller explicitly opts out or forces per call.
156
213
  this.#disableRollbacksInProduction =
157
- options?.disableRollbacksInProduction ?? false;
214
+ options?.disableRollbacksInProduction ?? true;
158
215
  this.#disableLocks = options?.disableLocks ?? false;
216
+ this.#disableTransactions = options?.disableTransactions ?? false;
217
+ this.#naturalSort = options?.naturalSort ?? false;
159
218
  this.#lockTableName = `${this.#tableName}_lock`;
160
219
  }
161
220
 
@@ -171,10 +230,14 @@ export class MigrationRunner {
171
230
  table: this.#lockTableName,
172
231
  ifNotExists: true,
173
232
  columns: [
233
+ // A FIXED single-row identity (id = LOCK_ROW_ID, PK). Not
234
+ // auto-increment: every acquire/release/seed targets `WHERE id = 1`,
235
+ // so a duplicate row is impossible (the PK rejects it). That removes
236
+ // any need for a "recover from multi-row" DELETE that could wipe an
237
+ // active lock held by another process.
174
238
  {
175
239
  name: "id",
176
240
  kind: "integer",
177
- autoIncrement: true,
178
241
  nullable: false,
179
242
  primary: true,
180
243
  unique: false,
@@ -196,34 +259,92 @@ export class MigrationRunner {
196
259
  precision: null,
197
260
  scale: null,
198
261
  },
262
+ {
263
+ name: "locked_by",
264
+ kind: "string",
265
+ nullable: true,
266
+ primary: false,
267
+ unique: false,
268
+ default: null,
269
+ references: null,
270
+ length: null,
271
+ precision: null,
272
+ scale: null,
273
+ },
199
274
  ],
200
275
  indexes: [],
201
276
  });
202
- // The lock-table name derives from the validated tracking-table name, so
203
- // it is a safe identifier no injection surface in these raw statements.
204
- const rows = await this.#db.query<{ c: number }>(
205
- `SELECT COUNT(*) AS c FROM ${this.#lockTableName}`,
206
- );
207
- if (Number(rows[0]?.c ?? 0) === 0) {
208
- await this.#db.execute(
209
- `INSERT INTO ${this.#lockTableName} (is_locked) VALUES (0)`,
210
- );
277
+ // Upgrade path: a lock table created by an earlier atlas (id + is_locked,
278
+ // no `locked_by`) is left untouched by CREATE TABLE IF NOT EXISTS, so the
279
+ // token UPDATE/SELECT below would hit a missing column. Add it if absent.
280
+ if (
281
+ !(await columnExists(
282
+ this.#db,
283
+ this.#dialect,
284
+ this.#lockTableName,
285
+ "locked_by",
286
+ ))
287
+ ) {
288
+ try {
289
+ await this.#db.execute(
290
+ `ALTER TABLE ${this.#lockTableName} ADD COLUMN locked_by TEXT`,
291
+ );
292
+ } catch {
293
+ // TOCTOU: two concurrent first-boots on a legacy lock table can both
294
+ // see the column absent and both ALTER; the loser hits a
295
+ // duplicate-column error (no dialect has ADD COLUMN IF NOT EXISTS on
296
+ // all three). Swallow it — the column now exists either way; a real
297
+ // failure surfaces on the very next statement (seed/UPDATE).
298
+ }
299
+ }
300
+ // Seed the single lock row idempotently. INSERT-or-ignore on the PK means
301
+ // concurrent seeders and re-runs never create a second row and never touch
302
+ // an existing (possibly held) lock. The lock-table name derives from the
303
+ // validated tracking-table name — a safe identifier, no injection surface.
304
+ await this.#db.execute(this.#seedLockRowSql());
305
+ }
306
+
307
+ /** Dialect-specific idempotent seed of the single `id = 1` lock row. */
308
+ #seedLockRowSql(): string {
309
+ const t = this.#lockTableName;
310
+ const values = `(${LOCK_ROW_ID}, 0)`;
311
+ switch (this.#dialect) {
312
+ case "sqlite":
313
+ return `INSERT OR IGNORE INTO ${t} (id, is_locked) VALUES ${values}`;
314
+ case "mysql":
315
+ return `INSERT IGNORE INTO ${t} (id, is_locked) VALUES ${values}`;
316
+ case "postgres":
317
+ return `INSERT INTO ${t} (id, is_locked) VALUES ${values} ON CONFLICT (id) DO NOTHING`;
211
318
  }
212
319
  }
213
320
 
321
+ /** Dialect placeholder for a single bound parameter. */
322
+ get #ph(): string {
323
+ return this.#dialect === "postgres" ? "$1" : "?";
324
+ }
325
+
214
326
  /**
215
327
  * Acquire the migration lock so two processes cannot migrate concurrently
216
- * (Adonis Lucid / Knex parity — a lock TABLE, not an advisory lock). Throws
217
- * `E_MIGRATION_LOCKED` when the lock is already held.
328
+ * (Adonis Lucid / Knex parity — a lock TABLE, not an advisory lock).
329
+ *
330
+ * ATOMIC: a single conditional `UPDATE … WHERE is_locked = 0` stamps our
331
+ * token — the database serialises concurrent updates on that row, so only ONE
332
+ * writer flips 0→1; every other writer's `WHERE` no longer matches. The
333
+ * token read-back tells us whether WE won. This avoids the check-then-set
334
+ * race of a separate SELECT + UPDATE. Throws `E_MIGRATION_LOCKED` otherwise.
218
335
  */
219
336
  async #acquireLock(): Promise<void> {
220
337
  if (this.#disableLocks) return;
221
338
  await this.#ensureLockTable();
222
- const rows = await this.#db.query<{ is_locked: unknown }>(
223
- `SELECT is_locked FROM ${this.#lockTableName}`,
339
+ const token = randomUUID();
340
+ await this.#db.execute(
341
+ `UPDATE ${this.#lockTableName} SET is_locked = 1, locked_by = ${this.#ph} WHERE id = ${LOCK_ROW_ID} AND is_locked = 0`,
342
+ [token],
224
343
  );
225
- const locked = rows[0]?.is_locked;
226
- if (locked === 1 || locked === true || locked === "1") {
344
+ const rows = await this.#db.query<{ locked_by: unknown }>(
345
+ `SELECT locked_by FROM ${this.#lockTableName} WHERE id = ${LOCK_ROW_ID}`,
346
+ );
347
+ if (rows[0]?.locked_by !== token) {
227
348
  throw new AtlasError(
228
349
  "E_MIGRATION_LOCKED",
229
350
  "Could not acquire the migration lock — another migration is already running.",
@@ -232,18 +353,62 @@ export class MigrationRunner {
232
353
  },
233
354
  );
234
355
  }
235
- await this.#db.execute(`UPDATE ${this.#lockTableName} SET is_locked = 1`);
356
+ this.#lockToken = token;
357
+ }
358
+
359
+ /** Run `fn` while holding the migration lock; always release, even on throw. */
360
+ async #withLock<T>(fn: () => Promise<T>): Promise<T> {
361
+ await this.#acquireLock();
362
+ try {
363
+ return await fn();
364
+ } finally {
365
+ await this.#releaseLock();
366
+ }
236
367
  }
237
368
 
238
- /** Release the migration lock (clears the `is_locked` flag). */
369
+ /** Release the migration lock only OUR token, so we never clear someone else's. */
239
370
  async #releaseLock(): Promise<void> {
240
- if (this.#disableLocks) return;
241
- await this.#db.execute(`UPDATE ${this.#lockTableName} SET is_locked = 0`);
371
+ if (this.#disableLocks || this.#lockToken === undefined) return;
372
+ const token = this.#lockToken;
373
+ this.#lockToken = undefined;
374
+ await this.#db.execute(
375
+ `UPDATE ${this.#lockTableName} SET is_locked = 0, locked_by = NULL WHERE id = ${LOCK_ROW_ID} AND locked_by = ${this.#ph}`,
376
+ [token],
377
+ );
242
378
  }
243
379
 
244
380
  /**
245
- * Throw when rollbacks are disabled in production and this is a production
246
- * run, unless the caller explicitly forces it.
381
+ * Force-clear a stuck migration lock (Adonis Lucid `migration:unlock`). A
382
+ * process killed mid-migrate leaves `is_locked = 1` with a stale token and
383
+ * NO way for a later run to acquire — this unconditionally clears the row so
384
+ * migrations can proceed. Returns `true` if a held lock was cleared.
385
+ */
386
+ async forceUnlock(): Promise<boolean> {
387
+ if (!(await tableExists(this.#db, this.#dialect, this.#lockTableName))) {
388
+ return false;
389
+ }
390
+ // Upgrade a legacy lock table (id + is_locked, no `locked_by`) BEFORE the
391
+ // `locked_by = NULL` UPDATE below — otherwise it hits a missing column and
392
+ // throws instead of clearing the stuck lock. `#ensureLockTable` is
393
+ // idempotent (CREATE IF NOT EXISTS + ALTER-add + idempotent seed).
394
+ await this.#ensureLockTable();
395
+ const rows = await this.#db.query<{ is_locked: unknown }>(
396
+ `SELECT is_locked FROM ${this.#lockTableName} WHERE id = ${LOCK_ROW_ID}`,
397
+ );
398
+ const wasLocked =
399
+ rows[0]?.is_locked === 1 ||
400
+ rows[0]?.is_locked === true ||
401
+ rows[0]?.is_locked === "1";
402
+ await this.#db.execute(
403
+ `UPDATE ${this.#lockTableName} SET is_locked = 0, locked_by = NULL WHERE id = ${LOCK_ROW_ID}`,
404
+ );
405
+ return wasLocked;
406
+ }
407
+
408
+ /**
409
+ * Throw when destructive migration operations (rollback / reset / refresh /
410
+ * fresh / wipe) are disabled in production and this is a production run,
411
+ * unless the caller explicitly forces it.
247
412
  */
248
413
  #assertRollbackAllowed(force: boolean): void {
249
414
  if (
@@ -253,7 +418,7 @@ export class MigrationRunner {
253
418
  ) {
254
419
  throw new AtlasError(
255
420
  "E_ROLLBACK_DISABLED_IN_PRODUCTION",
256
- "Rollbacks are disabled in production. Pass { force: true } to override.",
421
+ "Destructive migration operations are disabled in production. Pass { force: true } to override.",
257
422
  {
258
423
  hint: "This guard exists to prevent dropping production data by accident.",
259
424
  },
@@ -327,24 +492,30 @@ export class MigrationRunner {
327
492
 
328
493
  /** Get the status of all migrations. */
329
494
  async status(): Promise<MigrationStatus[]> {
330
- // Ensure the tracking table exists first on a never-migrated database
331
- // status must report every migration as pending, not throw on a missing
332
- // `ream_migrations` table (AdonisJS/Lucid `migration:status` parity).
333
- await this.init();
334
- const applied = await queryStmt<MigrationRecord>(this.#db, this.#dialect, {
335
- kind: "select",
336
- table: this.#tableName,
337
- select: ["name", "batch"],
338
- wheres: [],
339
- orderBy: [{ column: "name", direction: "asc" }],
340
- groupBy: [],
341
- having: [],
342
- limit: null,
343
- offset: null,
344
- distinct: false,
345
- ctes: [],
346
- unions: [],
347
- });
495
+ // READ-ONLY: do NOT init() (which would CREATE the tracking table). On a
496
+ // never-migrated database the table is simply absent everything pending,
497
+ // no side effect (AdonisJS/Lucid `migration:status` parity, same guarantee
498
+ // as dryRun).
499
+ const applied: MigrationRecord[] = (await tableExists(
500
+ this.#db,
501
+ this.#dialect,
502
+ this.#tableName,
503
+ ))
504
+ ? await queryStmt<MigrationRecord>(this.#db, this.#dialect, {
505
+ kind: "select",
506
+ table: this.#tableName,
507
+ select: ["name", "batch"],
508
+ wheres: [],
509
+ orderBy: [{ column: "name", direction: "asc" }],
510
+ groupBy: [],
511
+ having: [],
512
+ limit: null,
513
+ offset: null,
514
+ distinct: false,
515
+ ctes: [],
516
+ unions: [],
517
+ })
518
+ : [];
348
519
  const appliedMap = new Map(applied.map((r) => [r.name, r.batch]));
349
520
 
350
521
  const files = await this.#discoverFiles();
@@ -356,16 +527,15 @@ export class MigrationRunner {
356
527
  }
357
528
 
358
529
  /** Run all pending migrations. */
359
- async migrate(): Promise<string[]> {
360
- await this.#acquireLock();
361
- try {
362
- return await this.#migrateLocked();
363
- } finally {
364
- await this.#releaseLock();
365
- }
530
+ async migrate(options: { schemaPath?: string } = {}): Promise<string[]> {
531
+ return this.#withLock(() => this.#migrateLocked(options.schemaPath));
366
532
  }
367
533
 
368
- async #migrateLocked(): Promise<string[]> {
534
+ async #migrateLocked(schemaPath?: string): Promise<string[]> {
535
+ // Adonis Lucid `migration:run --schema-path`: when nothing is applied yet
536
+ // and a dump exists, load it in place of replaying history, then run only
537
+ // the migrations that postdate the dump (below, via the normal pending set).
538
+ await this.#maybeLoadDump(schemaPath);
369
539
  await this.init();
370
540
 
371
541
  const applied = await queryStmt<MigrationRecord>(this.#db, this.#dialect, {
@@ -398,6 +568,10 @@ export class MigrationRunner {
398
568
  const migration = await this.#loadMigration(name);
399
569
  const statements = await migration.getUpSQL(this.#db);
400
570
  const deferred = migration.consumeDeferred();
571
+ // Effective opt-out: the global config OR this migration's own
572
+ // `static disableTransactions = true`.
573
+ const disableTx =
574
+ this.#disableTransactions || migration.transactionsDisabled;
401
575
 
402
576
  // Compile the ream_migrations INSERT so we can include it in the same
403
577
  // transaction as the migration's own DDL/DML — either everything
@@ -426,6 +600,7 @@ export class MigrationRunner {
426
600
  deferred,
427
601
  { sql: insertSql, params: insertCompiled.params },
428
602
  name,
603
+ disableTx,
429
604
  );
430
605
  executed.push(name);
431
606
  }
@@ -438,12 +613,7 @@ export class MigrationRunner {
438
613
  options: { batch?: number; force?: boolean } = {},
439
614
  ): Promise<string[]> {
440
615
  this.#assertRollbackAllowed(options.force ?? false);
441
- await this.#acquireLock();
442
- try {
443
- return await this.#rollbackLocked(options);
444
- } finally {
445
- await this.#releaseLock();
446
- }
616
+ return this.#withLock(() => this.#rollbackLocked(options));
447
617
  }
448
618
 
449
619
  async #rollbackLocked(options: {
@@ -496,6 +666,9 @@ export class MigrationRunner {
496
666
  const migration = await this.#loadMigration(record.name);
497
667
  const statements = await migration.getDownSQL(this.#db);
498
668
  const deferred = migration.consumeDeferred();
669
+ // `static disableTransactions` applies to down() too (Adonis parity).
670
+ const disableTx =
671
+ this.#disableTransactions || migration.transactionsDisabled;
499
672
 
500
673
  const deleteCompiled = compileStatementNative(
501
674
  {
@@ -520,6 +693,7 @@ export class MigrationRunner {
520
693
  deferred,
521
694
  { sql: deleteSql, params: deleteCompiled.params },
522
695
  record.name,
696
+ disableTx,
523
697
  );
524
698
  rolled.push(record.name);
525
699
  }
@@ -527,40 +701,101 @@ export class MigrationRunner {
527
701
  return rolled;
528
702
  }
529
703
 
530
- /**
531
- * Run a batch of statements in a single transaction if the adapter supports
532
- * it; otherwise fall back to sequential execute (best-effort). Adapters that
533
- * lack transaction support get a warning so integrators notice the risk.
534
- */
535
704
  /**
536
705
  * Run one migration step: its schema statements plus the bookkeeping record
537
706
  * write (INSERT for migrate, DELETE for rollback).
538
707
  *
539
- * With no deferred callbacks this is the strong atomic path schema + record
540
- * commit together. With `this.defer()` callbacks, the schema runs atomically
541
- * first, then the deferred callbacks, then the record write so a failing
542
- * deferred callback leaves the migration UN-recorded (re-runnable), at the
543
- * cost of the schema no longer sharing a transaction with the record.
708
+ * With no deferred callbacks, schema + record commit together in one atomic
709
+ * batch. With `this.defer()` callbacks and an adapter that exposes an
710
+ * interactive `transaction()`, the schema, the deferred callbacks AND the
711
+ * record write all run in ONE transaction a throwing callback rolls the
712
+ * schema back too (fully atomic on sqlite/postgres; MySQL auto-commits DDL, so
713
+ * only its tracking row is bound to the callbacks). An adapter with no
714
+ * `transaction()` cannot make defer atomic, so it is REJECTED with
715
+ * `E_DEFER_REQUIRES_TRANSACTION` rather than silently degrading to a
716
+ * schema-then-callbacks-then-record best effort.
717
+ *
718
+ * When `disableTransactions` is set (Adonis `static disableTransactions` / the
719
+ * global config — a DELIBERATE opt-out for txn-incompatible DDL), every
720
+ * statement runs OUTSIDE a transaction: schema, then deferred callbacks, then
721
+ * the record, each committed on its own. Non-atomic by design, so defer runs
722
+ * loose here and no `transaction()` is required.
544
723
  */
545
724
  async #runStep(
546
725
  statements: string[],
547
726
  deferred: DeferredMigrationCallback[],
548
727
  recordStmt: BatchStmt,
549
728
  migrationName: string,
729
+ disableTransactions: boolean,
550
730
  ): Promise<void> {
551
731
  const schemaBatch: BatchStmt[] = statements.map((sql) => ({
552
732
  sql,
553
733
  params: [] as unknown[],
554
734
  }));
735
+ if (disableTransactions) {
736
+ // Deliberate opt-out (Adonis): run everything unwrapped. No transaction()
737
+ // needed and no throw — the caller has accepted non-atomicity for DDL that
738
+ // cannot run in a transaction (e.g. Postgres CREATE INDEX CONCURRENTLY).
739
+ if (deferred.length > 0) {
740
+ // The sharp edge: with the transaction gone, the schema, the deferred
741
+ // callbacks and the tracking row commit SEPARATELY, so a failing
742
+ // callback leaves the schema applied but the migration unrecorded.
743
+ // Adonis doesn't warn here — a named safety nudge so the non-atomicity
744
+ // of this exact combo is never a surprise. Make both the DDL and the
745
+ // deferred work idempotent (guarded DDL + re-runnable seeds).
746
+ console.warn(
747
+ `[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.`,
748
+ );
749
+ }
750
+ for (const { sql, params } of schemaBatch) {
751
+ await this.#db.execute(sql, params);
752
+ }
753
+ for (const callback of deferred) {
754
+ await callback(this.#db);
755
+ }
756
+ await this.#db.execute(recordStmt.sql, recordStmt.params);
757
+ return;
758
+ }
555
759
  if (deferred.length === 0) {
556
760
  await this.#runAtomic([...schemaBatch, recordStmt], migrationName);
557
761
  return;
558
762
  }
559
- await this.#runAtomic(schemaBatch, migrationName);
560
- for (const callback of deferred) {
561
- await callback(this.#db);
763
+ // With deferred callbacks: run the schema, the callbacks, AND the tracking
764
+ // row in ONE managed interactive transaction (Lucid runs defer inside the
765
+ // migration transaction). A throwing callback then rolls back the schema
766
+ // too, so the migration is genuinely all-or-nothing / re-runnable — not
767
+ // left applied-but-unrecorded. Fully atomic on sqlite/postgres; MySQL
768
+ // auto-commits DDL so its schema part can't roll back, but the tracking row
769
+ // is still bound to the callbacks. Requires `transaction()` on the adapter.
770
+ if (this.#db.transaction) {
771
+ await this.#db.transaction(async (trx) => {
772
+ for (const { sql, params } of schemaBatch) {
773
+ await trx.execute(sql, params);
774
+ }
775
+ for (const callback of deferred) {
776
+ await callback(trx);
777
+ }
778
+ await trx.execute(recordStmt.sql, recordStmt.params);
779
+ });
780
+ return;
562
781
  }
563
- await this.#db.execute(recordStmt.sql, recordStmt.params);
782
+ // No interactive transaction() available: this.defer() CANNOT be atomic —
783
+ // its callbacks must share the migration's transaction (Adonis wraps every
784
+ // migration in a transaction by default, so defer runs inside it). Refuse
785
+ // loudly rather than commit the schema and leave the migration half-applied
786
+ // behind a warning: a strong guarantee must never silently degrade to a
787
+ // weak one. Reached ONLY by a capability gap — an adapter that cannot do
788
+ // transactions at all (real connections, the provider and the CLI always
789
+ // can). This is NOT Adonis's `disableTransactions` opt-out (a deliberate
790
+ // per-migration choice for txn-incompatible DDL); if that parity feature is
791
+ // added, it must route its own non-atomic path, not trip this guard.
792
+ throw new AtlasError(
793
+ "E_DEFER_REQUIRES_TRANSACTION",
794
+ `Migration '${migrationName}' uses this.defer(), which needs an interactive transaction() on the adapter to run atomically — this adapter has none.`,
795
+ {
796
+ hint: "Use a real connection (createNapiConnection) or an adapter that implements transaction(); otherwise remove this.defer().",
797
+ },
798
+ );
564
799
  }
565
800
 
566
801
  async #runAtomic(
@@ -590,9 +825,14 @@ export class MigrationRunner {
590
825
  async refresh(
591
826
  options: { force?: boolean } = {},
592
827
  ): Promise<{ rolled: string[]; executed: string[] }> {
593
- const rolled = await this.reset(options);
594
- const executed = await this.migrate();
595
- return { rolled, executed };
828
+ this.#assertRollbackAllowed(options.force ?? false);
829
+ // One lock held across the WHOLE rollback+re-migrate, so no other run can
830
+ // slip into the free window between reset and migrate.
831
+ return this.#withLock(async () => {
832
+ const rolled = await this.#resetLocked();
833
+ const executed = await this.#migrateLocked();
834
+ return { rolled, executed };
835
+ });
596
836
  }
597
837
 
598
838
  /**
@@ -608,10 +848,76 @@ export class MigrationRunner {
608
848
  *
609
849
  * `rolled` is always empty — fresh drops rather than rolls back.
610
850
  */
611
- async fresh(): Promise<{ rolled: string[]; executed: string[] }> {
612
- await this.#dropAllTables();
613
- const executed = await this.migrate();
614
- return { rolled: [], executed };
851
+ async fresh(
852
+ options: { force?: boolean; schemaPath?: string } = {},
853
+ ): Promise<{ rolled: string[]; executed: string[] }> {
854
+ // `fresh` DROPS every table — at least as destructive as rollback, so it
855
+ // must honour the same production guard (Lucid runs it behind `--force` in
856
+ // prod). Held under the migration lock for the whole drop+migrate.
857
+ this.#assertRollbackAllowed(options.force ?? false);
858
+ return this.#withLock(async () => {
859
+ await this.#dropAllTables();
860
+ // With `--schema-path`, rebuild from the dump instead of replaying every
861
+ // migration file (Adonis Lucid `migration:fresh --schema-path`).
862
+ const executed = await this.#migrateLocked(options.schemaPath);
863
+ return { rolled: [], executed };
864
+ });
865
+ }
866
+
867
+ /**
868
+ * Load a schema dump (Adonis Lucid `SchemaDumper` output) into the database —
869
+ * executes every statement in the `.sql` file in order, recreating the tables
870
+ * AND the migration bookkeeping rows. Used by `--schema-path`; also callable
871
+ * directly to seed a fresh database from a committed dump.
872
+ */
873
+ async loadDump(sqlPath: string): Promise<void> {
874
+ const sql = await fsp.readFile(sqlPath, "utf8");
875
+ for (const statement of splitSqlStatements(sql)) {
876
+ await this.#db.execute(statement, []);
877
+ }
878
+ }
879
+
880
+ /** Load the dump only when `--schema-path` is set, it exists, and nothing is applied. */
881
+ async #maybeLoadDump(schemaPath?: string): Promise<void> {
882
+ if (!schemaPath) return;
883
+ if (await this.#hasAppliedMigrations()) return;
884
+ if (!(await pathExists(schemaPath))) return;
885
+ // Validate the sidecar manifest first (throws on a corrupt one), then check
886
+ // it matches THIS runner — a dump for another dialect can't be loaded, and a
887
+ // different bookkeeping table would leave the runner unable to see the
888
+ // embedded applied-migration rows.
889
+ const manifest = await readSchemaDumpManifest(schemaPath);
890
+ if (manifest) {
891
+ if (manifest.dialect !== this.#dialect) {
892
+ throw new AtlasError(
893
+ "E_SCHEMA_DUMP_DIALECT_MISMATCH",
894
+ `Schema dump is for '${manifest.dialect}', but this connection is '${this.#dialect}'.`,
895
+ );
896
+ }
897
+ if (manifest.schemaTableName !== this.#tableName) {
898
+ throw new AtlasError(
899
+ "E_SCHEMA_DUMP_TABLE_MISMATCH",
900
+ `Schema dump uses tracking table '${manifest.schemaTableName}', but this runner expects '${this.#tableName}'.`,
901
+ );
902
+ }
903
+ }
904
+ await this.loadDump(schemaPath);
905
+ }
906
+
907
+ /** True when the tracking table exists AND has at least one applied row. */
908
+ async #hasAppliedMigrations(): Promise<boolean> {
909
+ if (!(await tableExists(this.#db, this.#dialect, this.#tableName))) {
910
+ return false;
911
+ }
912
+ const rows = await this.#db.query<{ n: number }>(
913
+ `SELECT COUNT(*) AS n FROM ${this.#quoteTable(this.#tableName)}`,
914
+ );
915
+ return Number(rows[0]?.n ?? 0) > 0;
916
+ }
917
+
918
+ /** Quote the tracking-table name for the current dialect. */
919
+ #quoteTable(name: string): string {
920
+ return this.#dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
615
921
  }
616
922
 
617
923
  /**
@@ -624,30 +930,60 @@ export class MigrationRunner {
624
930
  * duration (they don't accept/respect CASCADE on `DROP TABLE`), restored
625
931
  * even if a drop throws.
626
932
  */
627
- async wipe(): Promise<void> {
628
- return this.#dropAllTables();
933
+ async wipe(options: { force?: boolean } = {}): Promise<void> {
934
+ // `db:wipe` drops every table — same production guard as rollback/fresh.
935
+ this.#assertRollbackAllowed(options.force ?? false);
936
+ // Drop everything WHILE STILL HOLDING the lock — including the lock table
937
+ // itself as the last step — then null our token so the release is a no-op
938
+ // (the table is gone). Doing it inside the critical section leaves NO window
939
+ // for another process to acquire/recreate the lock and get dropped from
940
+ // under it. Result: a truly empty database.
941
+ await this.#acquireLock();
942
+ try {
943
+ await this.#dropAllTables();
944
+ // Always drop the lock table (IF EXISTS) so wipe leaves a truly empty DB
945
+ // — including when disableLocks is set and a PRIOR locked run created it.
946
+ // Under a held lock this is the last step in the critical section; under
947
+ // disableLocks we hold nothing, so dropping it is safe either way.
948
+ const compiled = compileStatementNative(
949
+ { kind: "dropTable", table: this.#lockTableName, ifExists: true },
950
+ this.#dialect,
951
+ );
952
+ for (const sql of compiled.statements) {
953
+ await this.#db.execute(sql, compiled.params);
954
+ }
955
+ this.#lockToken = undefined;
956
+ } finally {
957
+ await this.#releaseLock();
958
+ }
629
959
  }
630
960
 
631
961
  async #dropAllTables(): Promise<void> {
632
962
  // Include the tracking table: wipe/fresh reset to fully empty, and
633
- // fresh() re-creates it via init() on the next migrate().
634
- const tables = await listUserTables(this.#db, this.#dialect, {
635
- includeFrameworkTables: true,
636
- });
963
+ // fresh() re-creates it via init() on the next migrate(). The lock table
964
+ // is deliberately KEPT we're holding the lock through it, and dropping it
965
+ // mid-operation would break the release. It is infrastructure, not data.
966
+ const tables = (
967
+ await listUserTables(this.#db, this.#dialect, {
968
+ includeFrameworkTables: true,
969
+ })
970
+ ).filter((t) => t !== this.#lockTableName);
637
971
  if (tables.length === 0) return;
638
972
 
639
973
  const isPg = this.#dialect === "postgres";
640
- await withoutForeignKeys(this.#db, this.#dialect, async () => {
641
- for (const table of tables) {
642
- const compiled = compileStatementNative(
643
- { kind: "dropTable", table, ifExists: true, cascade: isPg },
644
- this.#dialect,
645
- );
646
- for (const sql of compiled.statements) {
647
- await this.#db.execute(sql, compiled.params);
648
- }
974
+ const statements: Array<{ sql: string; params?: unknown[] }> = [];
975
+ for (const table of tables) {
976
+ const compiled = compileStatementNative(
977
+ { kind: "dropTable", table, ifExists: true, cascade: isPg },
978
+ this.#dialect,
979
+ );
980
+ for (const sql of compiled.statements) {
981
+ statements.push({ sql, params: compiled.params });
649
982
  }
650
- });
983
+ }
984
+ // FK toggle + every DROP run on ONE pinned connection (not scattered
985
+ // across the pool) — see `runWithoutForeignKeys`.
986
+ await runWithoutForeignKeys(this.#db, this.#dialect, statements);
651
987
  }
652
988
 
653
989
  /**
@@ -656,11 +992,16 @@ export class MigrationRunner {
656
992
  */
657
993
  async reset(options: { force?: boolean } = {}): Promise<string[]> {
658
994
  this.#assertRollbackAllowed(options.force ?? false);
995
+ return this.#withLock(() => this.#resetLocked());
996
+ }
997
+
998
+ /** The reset loop, WITHOUT re-acquiring the lock — the caller holds it, so
999
+ * the whole reset is one atomic critical section (not lock-per-batch). */
1000
+ async #resetLocked(): Promise<string[]> {
659
1001
  await this.init();
660
1002
  const all: string[] = [];
661
- // Roll back batches one by one until nothing remains.
662
1003
  while ((await this.#currentBatch()) > 0) {
663
- const rolled = await this.rollback({ force: true });
1004
+ const rolled = await this.#rollbackLocked({ batch: undefined });
664
1005
  if (rolled.length === 0) break;
665
1006
  all.push(...rolled);
666
1007
  }
@@ -673,22 +1014,31 @@ export class MigrationRunner {
673
1014
  * migration file. Useful for CI pre-flight checks.
674
1015
  */
675
1016
  async dryRun(): Promise<Array<{ name: string; sql: string[] }>> {
676
- await this.init();
677
- const applied = await queryStmt<MigrationRecord>(this.#db, this.#dialect, {
678
- kind: "select",
679
- table: this.#tableName,
680
- select: ["name"],
681
- wheres: [],
682
- orderBy: [],
683
- groupBy: [],
684
- having: [],
685
- limit: null,
686
- offset: null,
687
- distinct: false,
688
- ctes: [],
689
- unions: [],
690
- });
691
- const appliedNames = new Set(applied.map((r) => r.name));
1017
+ // A dry-run must be side-effect-free: do NOT call init() (it would CREATE
1018
+ // the tracking table on an empty database). If the table doesn't exist
1019
+ // yet, nothing has been applied.
1020
+ const appliedNames = new Set<string>();
1021
+ if (await tableExists(this.#db, this.#dialect, this.#tableName)) {
1022
+ const applied = await queryStmt<MigrationRecord>(
1023
+ this.#db,
1024
+ this.#dialect,
1025
+ {
1026
+ kind: "select",
1027
+ table: this.#tableName,
1028
+ select: ["name"],
1029
+ wheres: [],
1030
+ orderBy: [],
1031
+ groupBy: [],
1032
+ having: [],
1033
+ limit: null,
1034
+ offset: null,
1035
+ distinct: false,
1036
+ ctes: [],
1037
+ unions: [],
1038
+ },
1039
+ );
1040
+ for (const r of applied) appliedNames.add(r.name);
1041
+ }
692
1042
  const files = (await this.#discoverFiles()).filter(
693
1043
  (f) => !appliedNames.has(f),
694
1044
  );
@@ -697,6 +1047,9 @@ export class MigrationRunner {
697
1047
  for (const name of files) {
698
1048
  this.#assertSafeName(name);
699
1049
  const migration = await this.#loadMigration(name);
1050
+ // Adonis `this.dryRun`: let up()/down() branch on it while we only
1051
+ // collect SQL and never execute or run deferred callbacks.
1052
+ migration.dryRun = true;
700
1053
  const statements = await migration.getUpSQL(this.#db);
701
1054
  result.push({ name, sql: statements });
702
1055
  }
@@ -726,10 +1079,21 @@ export class MigrationRunner {
726
1079
  async #discoverFiles(): Promise<string[]> {
727
1080
  try {
728
1081
  const entries = await fsp.readdir(this.#migrationsDir);
729
- return entries
730
- .filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
731
- .sort()
732
- .map((f) => f.replace(/\.(ts|js)$/, ""));
1082
+ const files = entries.filter(
1083
+ (f) => f.endsWith(".ts") || f.endsWith(".js"),
1084
+ );
1085
+ // naturalSort (Lucid): numeric-aware so `2_x` < `10_x`. Default: plain
1086
+ // lexicographic (UTF-16), correct for fixed-width Date.now() prefixes.
1087
+ files.sort(
1088
+ this.#naturalSort
1089
+ ? (a, b) =>
1090
+ a.localeCompare(b, undefined, {
1091
+ numeric: true,
1092
+ sensitivity: "base",
1093
+ })
1094
+ : undefined,
1095
+ );
1096
+ return files.map((f) => f.replace(/\.(ts|js)$/, ""));
733
1097
  } catch (err) {
734
1098
  // Missing directory → no migrations. Any other error propagates.
735
1099
  if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];