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