@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.
- package/README.md +8 -0
- package/db.darwin-arm64.node +0 -0
- package/db.darwin-x64.node +0 -0
- package/db.linux-arm64-gnu.node +0 -0
- package/db.linux-x64-gnu.node +0 -0
- package/db.win32-x64-msvc.node +0 -0
- package/dist/AtlasProvider.d.ts +60 -7
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +204 -38
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseEntity.d.ts +17 -0
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseModel.d.ts +63 -27
- package/dist/BaseModel.d.ts.map +1 -1
- package/dist/BaseModel.js +108 -51
- package/dist/BaseModel.js.map +1 -1
- package/dist/BaseRepository.d.ts +11 -1
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +95 -20
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ConnectionManager.d.ts +106 -0
- package/dist/ConnectionManager.d.ts.map +1 -0
- package/dist/ConnectionManager.js +228 -0
- package/dist/ConnectionManager.js.map +1 -0
- package/dist/ModelQuery.d.ts +256 -43
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +899 -231
- package/dist/ModelQuery.js.map +1 -1
- package/dist/Transaction.d.ts +54 -1
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +121 -59
- package/dist/Transaction.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts +11 -0
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +35 -7
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/console/contract.d.ts +62 -0
- package/dist/console/contract.d.ts.map +1 -0
- package/dist/console/contract.js +38 -0
- package/dist/console/contract.js.map +1 -0
- package/dist/console/factoryCommands.d.ts +23 -0
- package/dist/console/factoryCommands.d.ts.map +1 -0
- package/dist/console/factoryCommands.js +62 -0
- package/dist/console/factoryCommands.js.map +1 -0
- package/dist/console/migrationCommands.d.ts +38 -8
- package/dist/console/migrationCommands.d.ts.map +1 -1
- package/dist/console/migrationCommands.js +157 -50
- package/dist/console/migrationCommands.js.map +1 -1
- package/dist/console/schemaCheckCommand.d.ts +11 -17
- package/dist/console/schemaCheckCommand.d.ts.map +1 -1
- package/dist/console/schemaCheckCommand.js +21 -15
- package/dist/console/schemaCheckCommand.js.map +1 -1
- package/dist/console/schemaDumpCommand.d.ts +30 -0
- package/dist/console/schemaDumpCommand.d.ts.map +1 -0
- package/dist/console/schemaDumpCommand.js +69 -0
- package/dist/console/schemaDumpCommand.js.map +1 -0
- package/dist/console/schemaGenerateCommand.d.ts +100 -0
- package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
- package/dist/console/schemaGenerateCommand.js +246 -0
- package/dist/console/schemaGenerateCommand.js.map +1 -0
- package/dist/console/seederCommands.d.ts +46 -0
- package/dist/console/seederCommands.d.ts.map +1 -0
- package/dist/console/seederCommands.js +136 -0
- package/dist/console/seederCommands.js.map +1 -0
- package/dist/decorators/entity.d.ts +13 -2
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +10 -0
- package/dist/decorators/entity.js.map +1 -1
- package/dist/decorators/hooks.d.ts +3 -3
- package/dist/decorators/hooks.d.ts.map +1 -1
- package/dist/decorators/hooks.js.map +1 -1
- package/dist/events.d.ts +5 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/factories.d.ts +8 -0
- package/dist/factories.d.ts.map +1 -0
- package/dist/factories.js +8 -0
- package/dist/factories.js.map +1 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/lucid-schema.d.ts +15 -0
- package/dist/lucid-schema.d.ts.map +1 -0
- package/dist/lucid-schema.js +16 -0
- package/dist/lucid-schema.js.map +1 -0
- package/dist/orm.d.ts +14 -0
- package/dist/orm.d.ts.map +1 -0
- package/dist/orm.js +14 -0
- package/dist/orm.js.map +1 -0
- package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
- package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
- package/dist/query/DatabaseQueryBuilder.js +1798 -0
- package/dist/query/DatabaseQueryBuilder.js.map +1 -0
- package/dist/query/DmlBuilder.d.ts +62 -0
- package/dist/query/DmlBuilder.d.ts.map +1 -0
- package/dist/query/DmlBuilder.js +84 -0
- package/dist/query/DmlBuilder.js.map +1 -0
- package/dist/query/QueryBuilder.d.ts +5 -0
- package/dist/query/QueryBuilder.d.ts.map +1 -1
- package/dist/query/QueryBuilder.js +7 -0
- package/dist/query/QueryBuilder.js.map +1 -1
- package/dist/query/RawQueryBuilder.d.ts +47 -0
- package/dist/query/RawQueryBuilder.d.ts.map +1 -0
- package/dist/query/RawQueryBuilder.js +138 -0
- package/dist/query/RawQueryBuilder.js.map +1 -0
- package/dist/query/interpolate.d.ts +45 -0
- package/dist/query/interpolate.d.ts.map +1 -0
- package/dist/query/interpolate.js +51 -0
- package/dist/query/interpolate.js.map +1 -0
- package/dist/query/native.d.ts +8 -0
- package/dist/query/native.d.ts.map +1 -1
- package/dist/query/native.js +14 -0
- package/dist/query/native.js.map +1 -1
- package/dist/query/operators.d.ts +4 -0
- package/dist/query/operators.d.ts.map +1 -0
- package/dist/query/operators.js +27 -0
- package/dist/query/operators.js.map +1 -0
- package/dist/schema/Migration.d.ts +31 -7
- package/dist/schema/Migration.d.ts.map +1 -1
- package/dist/schema/Migration.js +34 -7
- package/dist/schema/Migration.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts +56 -6
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +382 -112
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/Schema.d.ts +59 -2
- package/dist/schema/Schema.d.ts.map +1 -1
- package/dist/schema/Schema.js +143 -9
- package/dist/schema/Schema.js.map +1 -1
- package/dist/schema/SchemaDumper.d.ts +121 -0
- package/dist/schema/SchemaDumper.d.ts.map +1 -0
- package/dist/schema/SchemaDumper.js +365 -0
- package/dist/schema/SchemaDumper.js.map +1 -0
- package/dist/schema/Seeder.d.ts +13 -0
- package/dist/schema/Seeder.d.ts.map +1 -1
- package/dist/schema/Seeder.js +28 -5
- package/dist/schema/Seeder.js.map +1 -1
- package/dist/schema/TableBuilder.d.ts +27 -8
- package/dist/schema/TableBuilder.d.ts.map +1 -1
- package/dist/schema/TableBuilder.js +50 -12
- package/dist/schema/TableBuilder.js.map +1 -1
- package/dist/schema/catalog.d.ts +25 -1
- package/dist/schema/catalog.d.ts.map +1 -1
- package/dist/schema/catalog.js +69 -22
- package/dist/schema/catalog.js.map +1 -1
- package/dist/schema/introspect.d.ts +1 -1
- package/dist/schema/introspect.d.ts.map +1 -1
- package/dist/schema/introspect.js +26 -11
- package/dist/schema/introspect.js.map +1 -1
- package/dist/seeders.d.ts +7 -0
- package/dist/seeders.d.ts.map +1 -0
- package/dist/seeders.js +7 -0
- package/dist/seeders.js.map +1 -0
- package/dist/services/db.d.ts +120 -9
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +182 -26
- package/dist/services/db.js.map +1 -1
- package/dist/testing/DatabaseCleanup.d.ts +29 -6
- package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
- package/dist/testing/DatabaseCleanup.js +44 -17
- package/dist/testing/DatabaseCleanup.js.map +1 -1
- package/dist/testing/DbAssertions.d.ts +48 -0
- package/dist/testing/DbAssertions.d.ts.map +1 -0
- package/dist/testing/DbAssertions.js +80 -0
- package/dist/testing/DbAssertions.js.map +1 -0
- package/dist/testing/Factory.d.ts +111 -25
- package/dist/testing/Factory.d.ts.map +1 -1
- package/dist/testing/Factory.js +331 -56
- package/dist/testing/Factory.js.map +1 -1
- package/dist/testing/TestUtils.d.ts +49 -0
- package/dist/testing/TestUtils.d.ts.map +1 -0
- package/dist/testing/TestUtils.js +79 -0
- package/dist/testing/TestUtils.js.map +1 -0
- package/dist/testing/index.d.ts +3 -1
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +3 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/types/relations.d.ts +31 -0
- package/dist/types/relations.d.ts.map +1 -0
- package/dist/types/relations.js +20 -0
- package/dist/types/relations.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +30 -2
- package/src/AtlasProvider.ts +297 -51
- package/src/BaseEntity.ts +29 -0
- package/src/BaseModel.ts +174 -37
- package/src/BaseRepository.ts +136 -17
- package/src/ConnectionManager.ts +298 -0
- package/src/ModelQuery.ts +1309 -260
- package/src/Transaction.ts +194 -60
- package/src/adapters/NapiDbAdapter.ts +72 -14
- package/src/console/contract.ts +96 -0
- package/src/console/factoryCommands.ts +73 -0
- package/src/console/migrationCommands.ts +253 -63
- package/src/console/schemaCheckCommand.ts +26 -22
- package/src/console/schemaDumpCommand.ts +96 -0
- package/src/console/schemaGenerateCommand.ts +380 -0
- package/src/console/seederCommands.ts +174 -0
- package/src/decorators/entity.ts +18 -2
- package/src/decorators/hooks.ts +5 -3
- package/src/events.ts +5 -0
- package/src/factories.ts +10 -0
- package/src/index.ts +50 -3
- package/src/lucid-schema.ts +22 -0
- package/src/orm.ts +13 -0
- package/src/query/DatabaseQueryBuilder.ts +2724 -0
- package/src/query/DmlBuilder.ts +131 -0
- package/src/query/QueryBuilder.ts +8 -0
- package/src/query/RawQueryBuilder.ts +173 -0
- package/src/query/interpolate.ts +73 -0
- package/src/query/native.ts +29 -0
- package/src/query/operators.ts +27 -0
- package/src/schema/Migration.ts +36 -7
- package/src/schema/MigrationRunner.ts +483 -119
- package/src/schema/Schema.ts +168 -19
- package/src/schema/SchemaDumper.ts +518 -0
- package/src/schema/Seeder.ts +47 -10
- package/src/schema/TableBuilder.ts +48 -11
- package/src/schema/catalog.ts +94 -24
- package/src/schema/introspect.ts +26 -9
- package/src/seeders.ts +16 -0
- package/src/services/db.ts +343 -31
- package/src/testing/DatabaseCleanup.ts +73 -21
- package/src/testing/DbAssertions.ts +127 -0
- package/src/testing/Factory.ts +559 -79
- package/src/testing/TestUtils.ts +123 -0
- package/src/testing/index.ts +12 -1
- 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 {
|
|
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
|
|
137
|
-
* Lucid `disableRollbacksInProduction`)
|
|
138
|
-
* production data by accident.
|
|
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 ??
|
|
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
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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).
|
|
217
|
-
*
|
|
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
|
|
223
|
-
|
|
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
|
|
226
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
246
|
-
*
|
|
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
|
-
"
|
|
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
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
const applied = await
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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(
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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 [];
|