@c9up/atlas 0.1.19 → 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.
Files changed (121) hide show
  1. package/README.md +55 -14
  2. package/db.darwin-arm64.node +0 -0
  3. package/db.darwin-x64.node +0 -0
  4. package/db.linux-arm64-gnu.node +0 -0
  5. package/db.linux-x64-gnu.node +0 -0
  6. package/db.win32-x64-msvc.node +0 -0
  7. package/dist/AtlasProvider.d.ts +6 -0
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +2 -2
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +164 -5
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -33
  14. package/dist/BaseEntity.js.map +1 -1
  15. package/dist/BaseModel.d.ts +91 -0
  16. package/dist/BaseModel.d.ts.map +1 -0
  17. package/dist/BaseModel.js +193 -0
  18. package/dist/BaseModel.js.map +1 -0
  19. package/dist/BaseRepository.d.ts +71 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1355 -384
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ModelQuery.d.ts +341 -18
  24. package/dist/ModelQuery.d.ts.map +1 -1
  25. package/dist/ModelQuery.js +1406 -177
  26. package/dist/ModelQuery.js.map +1 -1
  27. package/dist/Transaction.d.ts +17 -0
  28. package/dist/Transaction.d.ts.map +1 -1
  29. package/dist/Transaction.js +57 -5
  30. package/dist/Transaction.js.map +1 -1
  31. package/dist/adapters/NapiDbAdapter.d.ts +33 -4
  32. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  33. package/dist/adapters/NapiDbAdapter.js +101 -11
  34. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  35. package/dist/console/migrationCommands.d.ts +48 -0
  36. package/dist/console/migrationCommands.d.ts.map +1 -0
  37. package/dist/console/migrationCommands.js +220 -0
  38. package/dist/console/migrationCommands.js.map +1 -0
  39. package/dist/decorators/entity.d.ts +37 -6
  40. package/dist/decorators/entity.d.ts.map +1 -1
  41. package/dist/decorators/entity.js +32 -2
  42. package/dist/decorators/entity.js.map +1 -1
  43. package/dist/events.d.ts +64 -0
  44. package/dist/events.d.ts.map +1 -0
  45. package/dist/events.js +82 -0
  46. package/dist/events.js.map +1 -0
  47. package/dist/index.d.ts +5 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/metadata-keys.d.ts +3 -2
  52. package/dist/metadata-keys.d.ts.map +1 -1
  53. package/dist/naming/NamingStrategy.d.ts +7 -0
  54. package/dist/naming/NamingStrategy.d.ts.map +1 -1
  55. package/dist/naming/NamingStrategy.js +16 -0
  56. package/dist/naming/NamingStrategy.js.map +1 -1
  57. package/dist/schema/Migration.d.ts +26 -3
  58. package/dist/schema/Migration.d.ts.map +1 -1
  59. package/dist/schema/Migration.js +33 -24
  60. package/dist/schema/Migration.js.map +1 -1
  61. package/dist/schema/MigrationRunner.d.ts +43 -32
  62. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  63. package/dist/schema/MigrationRunner.js +211 -26
  64. package/dist/schema/MigrationRunner.js.map +1 -1
  65. package/dist/schema/Schema.d.ts +57 -0
  66. package/dist/schema/Schema.d.ts.map +1 -1
  67. package/dist/schema/Schema.js +138 -3
  68. package/dist/schema/Schema.js.map +1 -1
  69. package/dist/schema/TableBuilder.d.ts +247 -8
  70. package/dist/schema/TableBuilder.d.ts.map +1 -1
  71. package/dist/schema/TableBuilder.js +607 -41
  72. package/dist/schema/TableBuilder.js.map +1 -1
  73. package/dist/schema/catalog.d.ts +47 -0
  74. package/dist/schema/catalog.d.ts.map +1 -0
  75. package/dist/schema/catalog.js +111 -0
  76. package/dist/schema/catalog.js.map +1 -0
  77. package/dist/schema/types.d.ts +150 -1
  78. package/dist/schema/types.d.ts.map +1 -1
  79. package/dist/schema/types.js +11 -0
  80. package/dist/schema/types.js.map +1 -1
  81. package/dist/services/db.d.ts +6 -0
  82. package/dist/services/db.d.ts.map +1 -1
  83. package/dist/services/db.js +17 -0
  84. package/dist/services/db.js.map +1 -1
  85. package/dist/testing/DatabaseCleanup.d.ts +7 -4
  86. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  87. package/dist/testing/DatabaseCleanup.js +21 -18
  88. package/dist/testing/DatabaseCleanup.js.map +1 -1
  89. package/dist/testing/Factory.d.ts +70 -5
  90. package/dist/testing/Factory.d.ts.map +1 -1
  91. package/dist/testing/Factory.js +209 -10
  92. package/dist/testing/Factory.js.map +1 -1
  93. package/index.darwin-arm64.node +0 -0
  94. package/index.darwin-x64.node +0 -0
  95. package/index.linux-arm64-gnu.node +0 -0
  96. package/index.linux-x64-gnu.node +0 -0
  97. package/index.win32-x64-msvc.node +0 -0
  98. package/package.json +3 -1
  99. package/scripts/guard-publish.mjs +15 -0
  100. package/src/AtlasProvider.ts +8 -1
  101. package/src/BaseEntity.ts +431 -41
  102. package/src/BaseModel.ts +324 -0
  103. package/src/BaseRepository.ts +1589 -419
  104. package/src/ModelQuery.ts +1817 -203
  105. package/src/Transaction.ts +68 -5
  106. package/src/adapters/NapiDbAdapter.ts +159 -10
  107. package/src/console/migrationCommands.ts +258 -0
  108. package/src/decorators/entity.ts +53 -6
  109. package/src/events.ts +112 -0
  110. package/src/index.ts +19 -0
  111. package/src/metadata-keys.ts +3 -2
  112. package/src/naming/NamingStrategy.ts +23 -0
  113. package/src/schema/Migration.ts +42 -3
  114. package/src/schema/MigrationRunner.ts +270 -27
  115. package/src/schema/Schema.ts +210 -3
  116. package/src/schema/TableBuilder.ts +735 -41
  117. package/src/schema/catalog.ts +166 -0
  118. package/src/schema/types.ts +137 -2
  119. package/src/services/db.ts +28 -0
  120. package/src/testing/DatabaseCleanup.ts +23 -22
  121. 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 type { Migration } from "./Migration.js";
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_` (via `LIKE 'ream\_%' ESCAPE '\'`).
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
- const batchStatements: BatchStmt[] = [
287
- ...statements.map((sql) => ({ sql, params: [] as unknown[] })),
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(): Promise<string[]> {
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 batch = await this.#currentBatch();
303
- if (batch === 0) {
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: [{ column: "batch", operator: "=", value: batch, type: "and" }],
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
- const batchStatements: BatchStmt[] = [
355
- ...statements.map((sql) => ({ sql, params: [] as unknown[] })),
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(): Promise<{ rolled: string[]; executed: string[] }> {
396
- const rolled = await this.reset();
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 the migrations created (via successive rollbacks) then
403
- * run every migration from scratch. `migrate:fresh` in Lucid parlance.
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
- * Distinct from `refresh()` only in intentboth end up with a fresh DB.
609
+ * `rolled` is always emptyfresh drops rather than rolls back.
406
610
  */
407
611
  async fresh(): Promise<{ rolled: string[]; executed: string[] }> {
408
- return this.refresh();
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;
@@ -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
- { kind: "createIndex", table, name: indexName, columns: cols, unique },
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;