@c9up/atlas 0.1.18 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -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 +6 -0
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +2 -2
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseEntity.d.ts +171 -7
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +339 -31
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseModel.d.ts +91 -0
- package/dist/BaseModel.d.ts.map +1 -0
- package/dist/BaseModel.js +193 -0
- package/dist/BaseModel.js.map +1 -0
- package/dist/BaseRepository.d.ts +77 -15
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +1423 -354
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts +429 -11
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +1733 -145
- package/dist/ModelQuery.js.map +1 -1
- package/dist/Transaction.d.ts +17 -0
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +57 -5
- package/dist/Transaction.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts +33 -4
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +101 -11
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/console/migrationCommands.d.ts +48 -0
- package/dist/console/migrationCommands.d.ts.map +1 -0
- package/dist/console/migrationCommands.js +220 -0
- package/dist/console/migrationCommands.js.map +1 -0
- package/dist/decorators/entity.d.ts +37 -6
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +32 -2
- package/dist/decorators/entity.js.map +1 -1
- package/dist/events.d.ts +64 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +82 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- 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/schema/Migration.d.ts +26 -3
- package/dist/schema/Migration.d.ts.map +1 -1
- package/dist/schema/Migration.js +33 -24
- package/dist/schema/Migration.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts +43 -32
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +211 -26
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/Schema.d.ts +57 -0
- package/dist/schema/Schema.d.ts.map +1 -1
- package/dist/schema/Schema.js +138 -3
- package/dist/schema/Schema.js.map +1 -1
- package/dist/schema/SchemaCheck.d.ts.map +1 -1
- package/dist/schema/SchemaCheck.js +3 -1
- package/dist/schema/SchemaCheck.js.map +1 -1
- package/dist/schema/TableBuilder.d.ts +247 -8
- package/dist/schema/TableBuilder.d.ts.map +1 -1
- package/dist/schema/TableBuilder.js +607 -41
- package/dist/schema/TableBuilder.js.map +1 -1
- package/dist/schema/catalog.d.ts +47 -0
- package/dist/schema/catalog.d.ts.map +1 -0
- package/dist/schema/catalog.js +111 -0
- package/dist/schema/catalog.js.map +1 -0
- 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/services/db.d.ts +6 -0
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +17 -0
- package/dist/services/db.js.map +1 -1
- package/dist/testing/DatabaseCleanup.d.ts +7 -4
- package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
- package/dist/testing/DatabaseCleanup.js +21 -18
- package/dist/testing/DatabaseCleanup.js.map +1 -1
- package/dist/testing/Factory.d.ts +70 -5
- package/dist/testing/Factory.d.ts.map +1 -1
- package/dist/testing/Factory.js +209 -10
- package/dist/testing/Factory.js.map +1 -1
- 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 +4 -1
- package/scripts/guard-publish.mjs +15 -0
- package/src/AtlasProvider.ts +8 -1
- package/src/BaseEntity.ts +449 -40
- package/src/BaseModel.ts +324 -0
- package/src/BaseRepository.ts +1659 -371
- package/src/ModelQuery.ts +2290 -203
- package/src/Transaction.ts +68 -5
- package/src/adapters/NapiDbAdapter.ts +159 -10
- package/src/console/migrationCommands.ts +258 -0
- package/src/decorators/entity.ts +53 -6
- package/src/events.ts +112 -0
- package/src/index.ts +19 -0
- package/src/metadata-keys.ts +3 -2
- package/src/naming/NamingStrategy.ts +23 -0
- package/src/schema/Migration.ts +42 -3
- package/src/schema/MigrationRunner.ts +270 -27
- package/src/schema/Schema.ts +210 -3
- package/src/schema/SchemaCheck.ts +7 -2
- package/src/schema/TableBuilder.ts +735 -41
- package/src/schema/catalog.ts +166 -0
- package/src/schema/introspect.ts +3 -4
- package/src/schema/types.ts +137 -2
- package/src/services/db.ts +28 -0
- package/src/testing/DatabaseCleanup.ts +23 -22
- package/src/testing/Factory.ts +332 -15
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
assertSafeName,
|
|
15
15
|
pathExists,
|
|
16
16
|
} from "../utils/safePath.js";
|
|
17
|
-
import
|
|
17
|
+
import { listUserTables, withoutForeignKeys } from "./catalog.js";
|
|
18
|
+
import type { DeferredMigrationCallback, Migration } from "./Migration.js";
|
|
18
19
|
|
|
19
20
|
const DEFAULT_TABLE = "ream_migrations";
|
|
20
21
|
const TABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -103,7 +104,8 @@ export class MigrationRunner {
|
|
|
103
104
|
* throws `AtlasError("MIGRATION_INVALID_TABLE_NAME")` synchronously otherwise.
|
|
104
105
|
*
|
|
105
106
|
* Cleanup coupling: `DatabaseCleanup.truncateAll` (`src/testing/DatabaseCleanup.ts`)
|
|
106
|
-
* skips tables whose name starts with `ream_` (
|
|
107
|
+
* skips tables whose name starts with `ream_` (filtered in JS by the shared
|
|
108
|
+
* catalog helper, `src/schema/catalog.ts`).
|
|
107
109
|
* The default `"ream_migrations"` is therefore auto-protected. Choosing a name
|
|
108
110
|
* without the `ream_` prefix (e.g. `"schema_versions"`) opts the tracking table
|
|
109
111
|
* out of that protection — `truncateAll` will wipe it alongside user tables,
|
|
@@ -120,12 +122,28 @@ export class MigrationRunner {
|
|
|
120
122
|
* INSERT INTO schema_versions SELECT * FROM ream_migrations;
|
|
121
123
|
* -- (then DROP TABLE ream_migrations once verified)
|
|
122
124
|
*/
|
|
125
|
+
#disableRollbacksInProduction: boolean;
|
|
126
|
+
#disableLocks: boolean;
|
|
127
|
+
#lockTableName: string;
|
|
128
|
+
|
|
123
129
|
constructor(
|
|
124
130
|
db: DatabaseAdapter,
|
|
125
131
|
options?: {
|
|
126
132
|
migrationsDir?: string;
|
|
127
133
|
dialect?: AtlasDialect;
|
|
128
134
|
tableName?: string;
|
|
135
|
+
/**
|
|
136
|
+
* Refuse `rollback`/`reset` when `NODE_ENV === 'production'` (Adonis
|
|
137
|
+
* Lucid `disableRollbacksInProduction`) — a guard against dropping
|
|
138
|
+
* production data by accident. Override per call with `{ force: true }`.
|
|
139
|
+
*/
|
|
140
|
+
disableRollbacksInProduction?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Skip the migration lock (Adonis Lucid `--disable-locks`). The lock is
|
|
143
|
+
* a `<tableName>_lock` table with an `is_locked` flag (Knex mechanism),
|
|
144
|
+
* so it prevents concurrent migrations on every dialect, SQLite too.
|
|
145
|
+
*/
|
|
146
|
+
disableLocks?: boolean;
|
|
129
147
|
},
|
|
130
148
|
) {
|
|
131
149
|
this.#db = db;
|
|
@@ -135,6 +153,112 @@ export class MigrationRunner {
|
|
|
135
153
|
options?.tableName === undefined
|
|
136
154
|
? DEFAULT_TABLE
|
|
137
155
|
: validateTrackingTableName(options.tableName);
|
|
156
|
+
this.#disableRollbacksInProduction =
|
|
157
|
+
options?.disableRollbacksInProduction ?? false;
|
|
158
|
+
this.#disableLocks = options?.disableLocks ?? false;
|
|
159
|
+
this.#lockTableName = `${this.#tableName}_lock`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Ensure the lock table exists and holds exactly one row. Mirrors the Knex /
|
|
164
|
+
* AdonisJS Lucid migration-lock mechanism: a `<tableName>_lock` table with an
|
|
165
|
+
* `is_locked` flag (NOT a Postgres advisory lock), so it works identically on
|
|
166
|
+
* every dialect, SQLite included.
|
|
167
|
+
*/
|
|
168
|
+
async #ensureLockTable(): Promise<void> {
|
|
169
|
+
await runStmt(this.#db, this.#dialect, {
|
|
170
|
+
kind: "createTable",
|
|
171
|
+
table: this.#lockTableName,
|
|
172
|
+
ifNotExists: true,
|
|
173
|
+
columns: [
|
|
174
|
+
{
|
|
175
|
+
name: "id",
|
|
176
|
+
kind: "integer",
|
|
177
|
+
autoIncrement: true,
|
|
178
|
+
nullable: false,
|
|
179
|
+
primary: true,
|
|
180
|
+
unique: false,
|
|
181
|
+
default: null,
|
|
182
|
+
references: null,
|
|
183
|
+
length: null,
|
|
184
|
+
precision: null,
|
|
185
|
+
scale: null,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "is_locked",
|
|
189
|
+
kind: "integer",
|
|
190
|
+
nullable: false,
|
|
191
|
+
primary: false,
|
|
192
|
+
unique: false,
|
|
193
|
+
default: null,
|
|
194
|
+
references: null,
|
|
195
|
+
length: null,
|
|
196
|
+
precision: null,
|
|
197
|
+
scale: null,
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
indexes: [],
|
|
201
|
+
});
|
|
202
|
+
// The lock-table name derives from the validated tracking-table name, so
|
|
203
|
+
// it is a safe identifier — no injection surface in these raw statements.
|
|
204
|
+
const rows = await this.#db.query<{ c: number }>(
|
|
205
|
+
`SELECT COUNT(*) AS c FROM ${this.#lockTableName}`,
|
|
206
|
+
);
|
|
207
|
+
if (Number(rows[0]?.c ?? 0) === 0) {
|
|
208
|
+
await this.#db.execute(
|
|
209
|
+
`INSERT INTO ${this.#lockTableName} (is_locked) VALUES (0)`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Acquire the migration lock so two processes cannot migrate concurrently
|
|
216
|
+
* (Adonis Lucid / Knex parity — a lock TABLE, not an advisory lock). Throws
|
|
217
|
+
* `E_MIGRATION_LOCKED` when the lock is already held.
|
|
218
|
+
*/
|
|
219
|
+
async #acquireLock(): Promise<void> {
|
|
220
|
+
if (this.#disableLocks) return;
|
|
221
|
+
await this.#ensureLockTable();
|
|
222
|
+
const rows = await this.#db.query<{ is_locked: unknown }>(
|
|
223
|
+
`SELECT is_locked FROM ${this.#lockTableName}`,
|
|
224
|
+
);
|
|
225
|
+
const locked = rows[0]?.is_locked;
|
|
226
|
+
if (locked === 1 || locked === true || locked === "1") {
|
|
227
|
+
throw new AtlasError(
|
|
228
|
+
"E_MIGRATION_LOCKED",
|
|
229
|
+
"Could not acquire the migration lock — another migration is already running.",
|
|
230
|
+
{
|
|
231
|
+
hint: `Wait for it to finish, clear the ${this.#lockTableName} table if it is stuck, or pass disableLocks.`,
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
await this.#db.execute(`UPDATE ${this.#lockTableName} SET is_locked = 1`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Release the migration lock (clears the `is_locked` flag). */
|
|
239
|
+
async #releaseLock(): Promise<void> {
|
|
240
|
+
if (this.#disableLocks) return;
|
|
241
|
+
await this.#db.execute(`UPDATE ${this.#lockTableName} SET is_locked = 0`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Throw when rollbacks are disabled in production and this is a production
|
|
246
|
+
* run, unless the caller explicitly forces it.
|
|
247
|
+
*/
|
|
248
|
+
#assertRollbackAllowed(force: boolean): void {
|
|
249
|
+
if (
|
|
250
|
+
this.#disableRollbacksInProduction &&
|
|
251
|
+
!force &&
|
|
252
|
+
process.env.NODE_ENV === "production"
|
|
253
|
+
) {
|
|
254
|
+
throw new AtlasError(
|
|
255
|
+
"E_ROLLBACK_DISABLED_IN_PRODUCTION",
|
|
256
|
+
"Rollbacks are disabled in production. Pass { force: true } to override.",
|
|
257
|
+
{
|
|
258
|
+
hint: "This guard exists to prevent dropping production data by accident.",
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
}
|
|
138
262
|
}
|
|
139
263
|
|
|
140
264
|
/** Ensure the ream_migrations tracking table exists. */
|
|
@@ -203,6 +327,10 @@ export class MigrationRunner {
|
|
|
203
327
|
|
|
204
328
|
/** Get the status of all migrations. */
|
|
205
329
|
async status(): Promise<MigrationStatus[]> {
|
|
330
|
+
// Ensure the tracking table exists first — on a never-migrated database
|
|
331
|
+
// status must report every migration as pending, not throw on a missing
|
|
332
|
+
// `ream_migrations` table (AdonisJS/Lucid `migration:status` parity).
|
|
333
|
+
await this.init();
|
|
206
334
|
const applied = await queryStmt<MigrationRecord>(this.#db, this.#dialect, {
|
|
207
335
|
kind: "select",
|
|
208
336
|
table: this.#tableName,
|
|
@@ -229,6 +357,15 @@ export class MigrationRunner {
|
|
|
229
357
|
|
|
230
358
|
/** Run all pending migrations. */
|
|
231
359
|
async migrate(): Promise<string[]> {
|
|
360
|
+
await this.#acquireLock();
|
|
361
|
+
try {
|
|
362
|
+
return await this.#migrateLocked();
|
|
363
|
+
} finally {
|
|
364
|
+
await this.#releaseLock();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async #migrateLocked(): Promise<string[]> {
|
|
232
369
|
await this.init();
|
|
233
370
|
|
|
234
371
|
const applied = await queryStmt<MigrationRecord>(this.#db, this.#dialect, {
|
|
@@ -259,7 +396,8 @@ export class MigrationRunner {
|
|
|
259
396
|
for (const name of pending) {
|
|
260
397
|
this.#assertSafeName(name);
|
|
261
398
|
const migration = await this.#loadMigration(name);
|
|
262
|
-
const statements = await migration.getUpSQL();
|
|
399
|
+
const statements = await migration.getUpSQL(this.#db);
|
|
400
|
+
const deferred = migration.consumeDeferred();
|
|
263
401
|
|
|
264
402
|
// Compile the ream_migrations INSERT so we can include it in the same
|
|
265
403
|
// transaction as the migration's own DDL/DML — either everything
|
|
@@ -283,12 +421,12 @@ export class MigrationRunner {
|
|
|
283
421
|
`compileStatementNative returned no statements for migration insert of '${name}'`,
|
|
284
422
|
);
|
|
285
423
|
}
|
|
286
|
-
|
|
287
|
-
|
|
424
|
+
await this.#runStep(
|
|
425
|
+
statements,
|
|
426
|
+
deferred,
|
|
288
427
|
{ sql: insertSql, params: insertCompiled.params },
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
await this.#runAtomic(batchStatements, name);
|
|
428
|
+
name,
|
|
429
|
+
);
|
|
292
430
|
executed.push(name);
|
|
293
431
|
}
|
|
294
432
|
|
|
@@ -296,11 +434,34 @@ export class MigrationRunner {
|
|
|
296
434
|
}
|
|
297
435
|
|
|
298
436
|
/** Rollback the last batch of migrations. */
|
|
299
|
-
async rollback(
|
|
437
|
+
async rollback(
|
|
438
|
+
options: { batch?: number; force?: boolean } = {},
|
|
439
|
+
): Promise<string[]> {
|
|
440
|
+
this.#assertRollbackAllowed(options.force ?? false);
|
|
441
|
+
await this.#acquireLock();
|
|
442
|
+
try {
|
|
443
|
+
return await this.#rollbackLocked(options);
|
|
444
|
+
} finally {
|
|
445
|
+
await this.#releaseLock();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async #rollbackLocked(options: {
|
|
450
|
+
batch?: number;
|
|
451
|
+
force?: boolean;
|
|
452
|
+
}): Promise<string[]> {
|
|
300
453
|
await this.init();
|
|
301
454
|
|
|
302
|
-
const
|
|
303
|
-
if (
|
|
455
|
+
const current = await this.#currentBatch();
|
|
456
|
+
if (current === 0) {
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Default: roll back only the latest batch. With `batch: N`, roll back
|
|
461
|
+
// every migration applied AFTER batch N (Lucid's `--batch` — a target to
|
|
462
|
+
// return to, not a count). `batch: 0` rolls the whole history back.
|
|
463
|
+
const target = options.batch ?? current - 1;
|
|
464
|
+
if (target >= current) {
|
|
304
465
|
return [];
|
|
305
466
|
}
|
|
306
467
|
|
|
@@ -311,7 +472,9 @@ export class MigrationRunner {
|
|
|
311
472
|
kind: "select",
|
|
312
473
|
table: this.#tableName,
|
|
313
474
|
select: ["name"],
|
|
314
|
-
wheres: [
|
|
475
|
+
wheres: [
|
|
476
|
+
{ column: "batch", operator: ">", value: target, type: "and" },
|
|
477
|
+
],
|
|
315
478
|
// Reverse INSERTION order (auto-increment `id`), not name order — a
|
|
316
479
|
// date- or hash-prefixed naming scheme would otherwise roll back in
|
|
317
480
|
// the wrong sequence. `id DESC` is always the inverse of application.
|
|
@@ -331,7 +494,8 @@ export class MigrationRunner {
|
|
|
331
494
|
for (const record of toRollback) {
|
|
332
495
|
this.#assertSafeName(record.name);
|
|
333
496
|
const migration = await this.#loadMigration(record.name);
|
|
334
|
-
const statements = await migration.getDownSQL();
|
|
497
|
+
const statements = await migration.getDownSQL(this.#db);
|
|
498
|
+
const deferred = migration.consumeDeferred();
|
|
335
499
|
|
|
336
500
|
const deleteCompiled = compileStatementNative(
|
|
337
501
|
{
|
|
@@ -351,12 +515,12 @@ export class MigrationRunner {
|
|
|
351
515
|
`compileStatementNative returned no statements for migration delete of '${record.name}'`,
|
|
352
516
|
);
|
|
353
517
|
}
|
|
354
|
-
|
|
355
|
-
|
|
518
|
+
await this.#runStep(
|
|
519
|
+
statements,
|
|
520
|
+
deferred,
|
|
356
521
|
{ sql: deleteSql, params: deleteCompiled.params },
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
await this.#runAtomic(batchStatements, record.name);
|
|
522
|
+
record.name,
|
|
523
|
+
);
|
|
360
524
|
rolled.push(record.name);
|
|
361
525
|
}
|
|
362
526
|
|
|
@@ -368,6 +532,37 @@ export class MigrationRunner {
|
|
|
368
532
|
* it; otherwise fall back to sequential execute (best-effort). Adapters that
|
|
369
533
|
* lack transaction support get a warning so integrators notice the risk.
|
|
370
534
|
*/
|
|
535
|
+
/**
|
|
536
|
+
* Run one migration step: its schema statements plus the bookkeeping record
|
|
537
|
+
* write (INSERT for migrate, DELETE for rollback).
|
|
538
|
+
*
|
|
539
|
+
* With no deferred callbacks this is the strong atomic path — schema + record
|
|
540
|
+
* commit together. With `this.defer()` callbacks, the schema runs atomically
|
|
541
|
+
* first, then the deferred callbacks, then the record write — so a failing
|
|
542
|
+
* deferred callback leaves the migration UN-recorded (re-runnable), at the
|
|
543
|
+
* cost of the schema no longer sharing a transaction with the record.
|
|
544
|
+
*/
|
|
545
|
+
async #runStep(
|
|
546
|
+
statements: string[],
|
|
547
|
+
deferred: DeferredMigrationCallback[],
|
|
548
|
+
recordStmt: BatchStmt,
|
|
549
|
+
migrationName: string,
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
const schemaBatch: BatchStmt[] = statements.map((sql) => ({
|
|
552
|
+
sql,
|
|
553
|
+
params: [] as unknown[],
|
|
554
|
+
}));
|
|
555
|
+
if (deferred.length === 0) {
|
|
556
|
+
await this.#runAtomic([...schemaBatch, recordStmt], migrationName);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
await this.#runAtomic(schemaBatch, migrationName);
|
|
560
|
+
for (const callback of deferred) {
|
|
561
|
+
await callback(this.#db);
|
|
562
|
+
}
|
|
563
|
+
await this.#db.execute(recordStmt.sql, recordStmt.params);
|
|
564
|
+
}
|
|
565
|
+
|
|
371
566
|
async #runAtomic(
|
|
372
567
|
batch: readonly BatchStmt[],
|
|
373
568
|
migrationName: string,
|
|
@@ -392,32 +587,80 @@ export class MigrationRunner {
|
|
|
392
587
|
*
|
|
393
588
|
* @implements Story 32.11
|
|
394
589
|
*/
|
|
395
|
-
async refresh(
|
|
396
|
-
|
|
590
|
+
async refresh(
|
|
591
|
+
options: { force?: boolean } = {},
|
|
592
|
+
): Promise<{ rolled: string[]; executed: string[] }> {
|
|
593
|
+
const rolled = await this.reset(options);
|
|
397
594
|
const executed = await this.migrate();
|
|
398
595
|
return { rolled, executed };
|
|
399
596
|
}
|
|
400
597
|
|
|
401
598
|
/**
|
|
402
|
-
* Drop EVERY table
|
|
403
|
-
*
|
|
599
|
+
* Drop EVERY user table directly (no `down()`), then run every migration from
|
|
600
|
+
* scratch. `migrate:fresh` in Lucid parlance.
|
|
601
|
+
*
|
|
602
|
+
* Unlike {@link refresh} (which rolls back by calling each migration's
|
|
603
|
+
* `down()`), `fresh` wipes the tables outright — so it succeeds even when a
|
|
604
|
+
* `down()` is broken or missing, and it also clears orphan tables no migration
|
|
605
|
+
* tracks. This matches Lucid's `migration:fresh`. Views, types and domains are
|
|
606
|
+
* left intact: Lucid gates those behind opt-in flags (`--drop-views` /
|
|
607
|
+
* `--drop-types` / `--drop-domains`), so tables-only is its default.
|
|
404
608
|
*
|
|
405
|
-
*
|
|
609
|
+
* `rolled` is always empty — fresh drops rather than rolls back.
|
|
406
610
|
*/
|
|
407
611
|
async fresh(): Promise<{ rolled: string[]; executed: string[] }> {
|
|
408
|
-
|
|
612
|
+
await this.#dropAllTables();
|
|
613
|
+
const executed = await this.migrate();
|
|
614
|
+
return { rolled: [], executed };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Drop every user table — INCLUDING the migrations tracking table (Lucid's
|
|
619
|
+
* `db:wipe`). `fresh()` calls this and then re-migrates; a caller can use it
|
|
620
|
+
* directly to reset a database to empty.
|
|
621
|
+
*
|
|
622
|
+
* Inter-table foreign keys are handled per dialect: Postgres emits
|
|
623
|
+
* `DROP TABLE … CASCADE`; MySQL and SQLite suspend FK checks for the
|
|
624
|
+
* duration (they don't accept/respect CASCADE on `DROP TABLE`), restored
|
|
625
|
+
* even if a drop throws.
|
|
626
|
+
*/
|
|
627
|
+
async wipe(): Promise<void> {
|
|
628
|
+
return this.#dropAllTables();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async #dropAllTables(): Promise<void> {
|
|
632
|
+
// Include the tracking table: wipe/fresh reset to fully empty, and
|
|
633
|
+
// fresh() re-creates it via init() on the next migrate().
|
|
634
|
+
const tables = await listUserTables(this.#db, this.#dialect, {
|
|
635
|
+
includeFrameworkTables: true,
|
|
636
|
+
});
|
|
637
|
+
if (tables.length === 0) return;
|
|
638
|
+
|
|
639
|
+
const isPg = this.#dialect === "postgres";
|
|
640
|
+
await withoutForeignKeys(this.#db, this.#dialect, async () => {
|
|
641
|
+
for (const table of tables) {
|
|
642
|
+
const compiled = compileStatementNative(
|
|
643
|
+
{ kind: "dropTable", table, ifExists: true, cascade: isPg },
|
|
644
|
+
this.#dialect,
|
|
645
|
+
);
|
|
646
|
+
for (const sql of compiled.statements) {
|
|
647
|
+
await this.#db.execute(sql, compiled.params);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
});
|
|
409
651
|
}
|
|
410
652
|
|
|
411
653
|
/**
|
|
412
654
|
* Rollback every applied batch (alias for `migrate:reset`).
|
|
413
655
|
* Returns the list of rolled-back migration names (in rollback order).
|
|
414
656
|
*/
|
|
415
|
-
async reset(): Promise<string[]> {
|
|
657
|
+
async reset(options: { force?: boolean } = {}): Promise<string[]> {
|
|
658
|
+
this.#assertRollbackAllowed(options.force ?? false);
|
|
416
659
|
await this.init();
|
|
417
660
|
const all: string[] = [];
|
|
418
661
|
// Roll back batches one by one until nothing remains.
|
|
419
662
|
while ((await this.#currentBatch()) > 0) {
|
|
420
|
-
const rolled = await this.rollback();
|
|
663
|
+
const rolled = await this.rollback({ force: true });
|
|
421
664
|
if (rolled.length === 0) break;
|
|
422
665
|
all.push(...rolled);
|
|
423
666
|
}
|
|
@@ -454,7 +697,7 @@ export class MigrationRunner {
|
|
|
454
697
|
for (const name of files) {
|
|
455
698
|
this.#assertSafeName(name);
|
|
456
699
|
const migration = await this.#loadMigration(name);
|
|
457
|
-
const statements = await migration.getUpSQL();
|
|
700
|
+
const statements = await migration.getUpSQL(this.#db);
|
|
458
701
|
result.push({ name, sql: statements });
|
|
459
702
|
}
|
|
460
703
|
return result;
|
package/src/schema/Schema.ts
CHANGED
|
@@ -12,26 +12,146 @@ import {
|
|
|
12
12
|
compileStatementNative,
|
|
13
13
|
getAtlasDialect,
|
|
14
14
|
} from "../query/native.js";
|
|
15
|
+
import {
|
|
16
|
+
type CatalogConnection,
|
|
17
|
+
columnExists,
|
|
18
|
+
tableExists,
|
|
19
|
+
} from "./catalog.js";
|
|
15
20
|
import { TableBuilder } from "./TableBuilder.js";
|
|
16
21
|
|
|
17
22
|
export class Schema {
|
|
18
23
|
#dialect: AtlasDialect;
|
|
19
24
|
#statements: string[] = [];
|
|
25
|
+
#connection?: CatalogConnection;
|
|
26
|
+
#schemaName?: string;
|
|
20
27
|
|
|
21
28
|
constructor(dialect: AtlasDialect = getAtlasDialect()) {
|
|
22
29
|
this.#dialect = dialect;
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Qualify subsequent table names with `name` (Adonis Lucid/Knex
|
|
34
|
+
* `withSchema`), e.g. `withSchema('reporting').createTable('t', …)` targets
|
|
35
|
+
* `"reporting"."t"`. Applies to every table-taking method until changed; the
|
|
36
|
+
* Rust compiler quotes each dotted segment. Chainable.
|
|
37
|
+
*/
|
|
38
|
+
withSchema(name: string): this {
|
|
39
|
+
this.#schemaName = name;
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Prefix a table name with the active schema, if one was set. */
|
|
44
|
+
#qualify(table: string): string {
|
|
45
|
+
return this.#schemaName ? `${this.#schemaName}.${table}` : table;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Attach a live connection so the async introspection helpers
|
|
50
|
+
* ({@link hasTable}/{@link hasColumn}) can query the catalog. The migration
|
|
51
|
+
* runner calls this before running a migration's `up`/`down`.
|
|
52
|
+
*/
|
|
53
|
+
bindConnection(connection: CatalogConnection): void {
|
|
54
|
+
this.#connection = connection;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Does this table exist right now? (Adonis Lucid/Knex `hasTable`.) Runs a
|
|
59
|
+
* live catalog query, so it reflects migrations already applied — but NOT
|
|
60
|
+
* statements this same migration has only buffered (they run after `up`).
|
|
61
|
+
* Requires a bound connection (present inside a migration).
|
|
62
|
+
*/
|
|
63
|
+
async hasTable(name: string): Promise<boolean> {
|
|
64
|
+
return tableExists(
|
|
65
|
+
this.#requireConnection("hasTable"),
|
|
66
|
+
this.#dialect,
|
|
67
|
+
name,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Does this column exist on this table right now? (Adonis Lucid/Knex `hasColumn`.) */
|
|
72
|
+
async hasColumn(table: string, column: string): Promise<boolean> {
|
|
73
|
+
return columnExists(
|
|
74
|
+
this.#requireConnection("hasColumn"),
|
|
75
|
+
this.#dialect,
|
|
76
|
+
table,
|
|
77
|
+
column,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#requireConnection(method: string): CatalogConnection {
|
|
82
|
+
if (!this.#connection) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`E_NO_CONNECTION: schema.${method}() needs a live connection — it is available inside a migration, not on a standalone Schema.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return this.#connection;
|
|
88
|
+
}
|
|
89
|
+
|
|
25
90
|
createTable(name: string, callback: (table: TableBuilder) => void): this {
|
|
26
|
-
const builder = new TableBuilder(name);
|
|
91
|
+
const builder = new TableBuilder(this.#qualify(name));
|
|
27
92
|
callback(builder);
|
|
28
93
|
this.#statements.push(...builder.toStatements(this.#dialect));
|
|
29
94
|
return this;
|
|
30
95
|
}
|
|
31
96
|
|
|
97
|
+
/** `CREATE TABLE IF NOT EXISTS` (Lucid/Knex `createTableIfNotExists`). */
|
|
98
|
+
createTableIfNotExists(
|
|
99
|
+
name: string,
|
|
100
|
+
callback: (table: TableBuilder) => void,
|
|
101
|
+
): this {
|
|
102
|
+
const builder = new TableBuilder(this.#qualify(name));
|
|
103
|
+
callback(builder);
|
|
104
|
+
this.#statements.push(
|
|
105
|
+
...builder.toStatements(this.#dialect, { ifNotExists: true }),
|
|
106
|
+
);
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `ALTER TABLE` (Lucid/Knex `alterTable`, also spelled `table()`). Inside
|
|
112
|
+
* the callback a column-type method adds a column, `.alter()` changes one,
|
|
113
|
+
* and `dropColumn` / `renameColumn` / `setNullable` / `dropNullable` do
|
|
114
|
+
* what they say. Operations compile in call order.
|
|
115
|
+
*/
|
|
116
|
+
alterTable(name: string, callback: (table: TableBuilder) => void): this {
|
|
117
|
+
const builder = new TableBuilder(this.#qualify(name), "alter");
|
|
118
|
+
callback(builder);
|
|
119
|
+
// An empty callback is a caller bug, not an empty statement list: the
|
|
120
|
+
// Rust compiler rejects a no-op ALTER, so short-circuit with a clearer error.
|
|
121
|
+
if (
|
|
122
|
+
builder.getOperations().length === 0 &&
|
|
123
|
+
builder.getIndexes().length === 0
|
|
124
|
+
) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`E_ALTER_EMPTY: schema.alterTable('${name}') declared no operations`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
this.#statements.push(...builder.toStatements(this.#dialect));
|
|
130
|
+
return this;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Alias of {@link alterTable} (Lucid/Knex `table()`). */
|
|
134
|
+
table(name: string, callback: (table: TableBuilder) => void): this {
|
|
135
|
+
return this.alterTable(name, callback);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `ALTER TABLE old RENAME TO new` (Lucid/Knex `renameTable`). */
|
|
139
|
+
renameTable(from: string, to: string): this {
|
|
140
|
+
const { statements } = compileStatementNative(
|
|
141
|
+
{
|
|
142
|
+
kind: "renameTable",
|
|
143
|
+
table: this.#qualify(from),
|
|
144
|
+
to: this.#qualify(to),
|
|
145
|
+
},
|
|
146
|
+
this.#dialect,
|
|
147
|
+
);
|
|
148
|
+
this.#statements.push(...statements);
|
|
149
|
+
return this;
|
|
150
|
+
}
|
|
151
|
+
|
|
32
152
|
dropTable(name: string): this {
|
|
33
153
|
const { statements } = compileStatementNative(
|
|
34
|
-
{ kind: "dropTable", table: name, ifExists: true },
|
|
154
|
+
{ kind: "dropTable", table: this.#qualify(name), ifExists: true },
|
|
35
155
|
this.#dialect,
|
|
36
156
|
);
|
|
37
157
|
this.#statements.push(...statements);
|
|
@@ -47,7 +167,13 @@ export class Schema {
|
|
|
47
167
|
const cols = Array.isArray(columns) ? columns : [columns];
|
|
48
168
|
const indexName = name ?? `idx_${table}_${cols.join("_")}`;
|
|
49
169
|
const { statements } = compileStatementNative(
|
|
50
|
-
{
|
|
170
|
+
{
|
|
171
|
+
kind: "createIndex",
|
|
172
|
+
table: this.#qualify(table),
|
|
173
|
+
name: indexName,
|
|
174
|
+
columns: cols,
|
|
175
|
+
unique,
|
|
176
|
+
},
|
|
51
177
|
this.#dialect,
|
|
52
178
|
);
|
|
53
179
|
this.#statements.push(...statements);
|
|
@@ -63,6 +189,87 @@ export class Schema {
|
|
|
63
189
|
return this;
|
|
64
190
|
}
|
|
65
191
|
|
|
192
|
+
/**
|
|
193
|
+
* `CREATE VIEW name [(cols)] AS <select>` (Lucid/Knex `createView`). The
|
|
194
|
+
* `select` is raw, developer-authored SQL — same trust level as {@link raw} —
|
|
195
|
+
* embedded verbatim; the view name and column list are validated + quoted by
|
|
196
|
+
* the Rust compiler.
|
|
197
|
+
*/
|
|
198
|
+
createView(
|
|
199
|
+
name: string,
|
|
200
|
+
select: string,
|
|
201
|
+
options: { columns?: string[] } = {},
|
|
202
|
+
): this {
|
|
203
|
+
return this.#pushView(name, select, options);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** `CREATE OR REPLACE VIEW` (Lucid/Knex `createViewOrReplace`). Rejected on SQLite. */
|
|
207
|
+
createViewOrReplace(
|
|
208
|
+
name: string,
|
|
209
|
+
select: string,
|
|
210
|
+
options: { columns?: string[] } = {},
|
|
211
|
+
): this {
|
|
212
|
+
return this.#pushView(name, select, { ...options, orReplace: true });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** `CREATE MATERIALIZED VIEW` (Lucid/Knex `createMaterializedView`). Postgres-only. */
|
|
216
|
+
createMaterializedView(
|
|
217
|
+
name: string,
|
|
218
|
+
select: string,
|
|
219
|
+
options: { columns?: string[] } = {},
|
|
220
|
+
): this {
|
|
221
|
+
return this.#pushView(name, select, { ...options, materialized: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `DROP VIEW IF EXISTS name` (Lucid/Knex `dropView`/`dropViewIfExists`). */
|
|
225
|
+
dropView(name: string): this {
|
|
226
|
+
const { statements } = compileStatementNative(
|
|
227
|
+
{ kind: "dropView", name: this.#qualify(name), ifExists: true },
|
|
228
|
+
this.#dialect,
|
|
229
|
+
);
|
|
230
|
+
this.#statements.push(...statements);
|
|
231
|
+
return this;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `DROP MATERIALIZED VIEW IF EXISTS name` (Lucid/Knex). Postgres-only. */
|
|
235
|
+
dropMaterializedView(name: string): this {
|
|
236
|
+
const { statements } = compileStatementNative(
|
|
237
|
+
{
|
|
238
|
+
kind: "dropView",
|
|
239
|
+
name: this.#qualify(name),
|
|
240
|
+
ifExists: true,
|
|
241
|
+
materialized: true,
|
|
242
|
+
},
|
|
243
|
+
this.#dialect,
|
|
244
|
+
);
|
|
245
|
+
this.#statements.push(...statements);
|
|
246
|
+
return this;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#pushView(
|
|
250
|
+
name: string,
|
|
251
|
+
select: string,
|
|
252
|
+
options: {
|
|
253
|
+
columns?: string[];
|
|
254
|
+
orReplace?: boolean;
|
|
255
|
+
materialized?: boolean;
|
|
256
|
+
},
|
|
257
|
+
): this {
|
|
258
|
+
const { statements } = compileStatementNative(
|
|
259
|
+
{
|
|
260
|
+
kind: "createView",
|
|
261
|
+
name: this.#qualify(name),
|
|
262
|
+
select,
|
|
263
|
+
orReplace: options.orReplace ?? false,
|
|
264
|
+
materialized: options.materialized ?? false,
|
|
265
|
+
columns: options.columns ?? null,
|
|
266
|
+
},
|
|
267
|
+
this.#dialect,
|
|
268
|
+
);
|
|
269
|
+
this.#statements.push(...statements);
|
|
270
|
+
return this;
|
|
271
|
+
}
|
|
272
|
+
|
|
66
273
|
raw(sql: string): this {
|
|
67
274
|
this.#statements.push(sql);
|
|
68
275
|
return this;
|
|
@@ -152,7 +152,11 @@ function reconcile(
|
|
|
152
152
|
// excluded (DB-generated).
|
|
153
153
|
for (const dbCol of dbCols) {
|
|
154
154
|
if (dbCol.primaryKey) continue;
|
|
155
|
-
if (
|
|
155
|
+
if (
|
|
156
|
+
!dbCol.nullable &&
|
|
157
|
+
!dbCol.hasDefault &&
|
|
158
|
+
!mappedDbColumns.has(dbCol.name)
|
|
159
|
+
) {
|
|
156
160
|
findings.push({
|
|
157
161
|
entity: entityName,
|
|
158
162
|
table,
|
|
@@ -213,7 +217,8 @@ export async function checkSchema(
|
|
|
213
217
|
|
|
214
218
|
/** Render findings as a didactic, Adonis-style diff (grouped per table). */
|
|
215
219
|
export function formatSchemaFindings(findings: SchemaFinding[]): string {
|
|
216
|
-
if (findings.length === 0)
|
|
220
|
+
if (findings.length === 0)
|
|
221
|
+
return "[atlas:check] schema OK — models match the database.";
|
|
217
222
|
const byTable = new Map<string, SchemaFinding[]>();
|
|
218
223
|
for (const f of findings) {
|
|
219
224
|
const key = `${f.table} (${f.entity})`;
|