@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.
Files changed (127) 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 +171 -7
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -31
  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 +77 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1423 -354
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ModelQuery.d.ts +429 -11
  24. package/dist/ModelQuery.d.ts.map +1 -1
  25. package/dist/ModelQuery.js +1733 -145
  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/SchemaCheck.d.ts.map +1 -1
  70. package/dist/schema/SchemaCheck.js +3 -1
  71. package/dist/schema/SchemaCheck.js.map +1 -1
  72. package/dist/schema/TableBuilder.d.ts +247 -8
  73. package/dist/schema/TableBuilder.d.ts.map +1 -1
  74. package/dist/schema/TableBuilder.js +607 -41
  75. package/dist/schema/TableBuilder.js.map +1 -1
  76. package/dist/schema/catalog.d.ts +47 -0
  77. package/dist/schema/catalog.d.ts.map +1 -0
  78. package/dist/schema/catalog.js +111 -0
  79. package/dist/schema/catalog.js.map +1 -0
  80. package/dist/schema/introspect.js.map +1 -1
  81. package/dist/schema/types.d.ts +150 -1
  82. package/dist/schema/types.d.ts.map +1 -1
  83. package/dist/schema/types.js +11 -0
  84. package/dist/schema/types.js.map +1 -1
  85. package/dist/services/db.d.ts +6 -0
  86. package/dist/services/db.d.ts.map +1 -1
  87. package/dist/services/db.js +17 -0
  88. package/dist/services/db.js.map +1 -1
  89. package/dist/testing/DatabaseCleanup.d.ts +7 -4
  90. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  91. package/dist/testing/DatabaseCleanup.js +21 -18
  92. package/dist/testing/DatabaseCleanup.js.map +1 -1
  93. package/dist/testing/Factory.d.ts +70 -5
  94. package/dist/testing/Factory.d.ts.map +1 -1
  95. package/dist/testing/Factory.js +209 -10
  96. package/dist/testing/Factory.js.map +1 -1
  97. package/index.darwin-arm64.node +0 -0
  98. package/index.darwin-x64.node +0 -0
  99. package/index.linux-arm64-gnu.node +0 -0
  100. package/index.linux-x64-gnu.node +0 -0
  101. package/index.win32-x64-msvc.node +0 -0
  102. package/package.json +4 -1
  103. package/scripts/guard-publish.mjs +15 -0
  104. package/src/AtlasProvider.ts +8 -1
  105. package/src/BaseEntity.ts +449 -40
  106. package/src/BaseModel.ts +324 -0
  107. package/src/BaseRepository.ts +1659 -371
  108. package/src/ModelQuery.ts +2290 -203
  109. package/src/Transaction.ts +68 -5
  110. package/src/adapters/NapiDbAdapter.ts +159 -10
  111. package/src/console/migrationCommands.ts +258 -0
  112. package/src/decorators/entity.ts +53 -6
  113. package/src/events.ts +112 -0
  114. package/src/index.ts +19 -0
  115. package/src/metadata-keys.ts +3 -2
  116. package/src/naming/NamingStrategy.ts +23 -0
  117. package/src/schema/Migration.ts +42 -3
  118. package/src/schema/MigrationRunner.ts +270 -27
  119. package/src/schema/Schema.ts +210 -3
  120. package/src/schema/SchemaCheck.ts +7 -2
  121. package/src/schema/TableBuilder.ts +735 -41
  122. package/src/schema/catalog.ts +166 -0
  123. package/src/schema/introspect.ts +3 -4
  124. package/src/schema/types.ts +137 -2
  125. package/src/services/db.ts +28 -0
  126. package/src/testing/DatabaseCleanup.ts +23 -22
  127. package/src/testing/Factory.ts +332 -15
@@ -12,30 +12,85 @@ import {
12
12
  TRANSACTION_BRAND,
13
13
  } from "./utils/transactionBrand.js";
14
14
 
15
+ /** A post-commit / post-rollback side effect (Lucid `trx.after(...)`). */
16
+ export type AfterHook = () => void | Promise<void>;
17
+
15
18
  export interface TransactionClient extends DatabaseConnection {
16
19
  commit(): Promise<void>;
17
20
  rollback(): Promise<void>;
21
+ /**
22
+ * Register a side effect to run AFTER the transaction is durable (Lucid
23
+ * `trx.after('commit' | 'rollback', cb)`). A `commit` hook fires only once the
24
+ * ROOT transaction commits — inside a nested (SAVEPOINT) transaction it is
25
+ * forwarded to the parent, so a later outer rollback never runs it. Errors
26
+ * thrown by a hook are swallowed (the caller already saw the transaction
27
+ * succeed).
28
+ */
29
+ after(event: "commit" | "rollback", cb: AfterHook): void;
18
30
  readonly isNested: boolean;
19
31
  readonly [TRANSACTION_BRAND]: true;
20
32
  }
21
33
 
34
+ /**
35
+ * Run every registered after-hook, swallowing errors — a post-commit side
36
+ * effect must never surface a failure on a transaction the caller already saw
37
+ * commit (Lucid parity). Shared with the napi adapter's pinned-transaction path.
38
+ */
39
+ export async function runAfterHooks(hooks: AfterHook[]): Promise<void> {
40
+ for (const hook of hooks) {
41
+ try {
42
+ await hook();
43
+ } catch {
44
+ /* swallowed — the transaction already succeeded */
45
+ }
46
+ }
47
+ }
48
+
22
49
  export async function transaction<T>(
23
50
  db: DatabaseConnection,
24
51
  callback: (trx: TransactionClient) => Promise<T> | T,
25
52
  options?: TransactionOptions,
26
53
  ): Promise<T> {
27
54
  if (isTransactionClient(db)) {
55
+ const parent = db;
28
56
  const name = `sp_${randomBytes(6).toString("hex")}`;
29
- await db.execute(`SAVEPOINT ${name}`, []);
57
+ await parent.execute(`SAVEPOINT ${name}`, []);
58
+
59
+ const commitHooks: AfterHook[] = [];
60
+ const rollbackHooks: AfterHook[] = [];
30
61
 
31
62
  const trx: TransactionClient = {
32
- execute: db.execute.bind(db),
33
- query: db.query.bind(db),
63
+ execute: parent.execute.bind(parent),
64
+ query: parent.query.bind(parent),
34
65
  async commit() {
35
- await db.execute(`RELEASE SAVEPOINT ${name}`, []);
66
+ await parent.execute(`RELEASE SAVEPOINT ${name}`, []);
67
+ // A nested commit is NOT durable until the root commits — forward BOTH
68
+ // hook sets to the parent. Commit hooks fire on the real (root) commit
69
+ // and drop if the outer later rolls back. Rollback hooks must ALSO
70
+ // forward: this savepoint's released work is folded into the parent, so
71
+ // an outer rollback undoes it too — dropping them here would strand any
72
+ // in-memory restoration a nested caller registered on `after('rollback')`
73
+ // (the work IS rolled back, just by the parent). The parent fires exactly
74
+ // one of its two hook sets, so no double-run.
75
+ for (const hook of commitHooks) parent.after("commit", hook);
76
+ for (const hook of rollbackHooks) parent.after("rollback", hook);
36
77
  },
37
78
  async rollback() {
38
- await db.execute(`ROLLBACK TO SAVEPOINT ${name}`, []);
79
+ await parent.execute(`ROLLBACK TO SAVEPOINT ${name}`, []);
80
+ // ROLLBACK TO leaves the savepoint ESTABLISHED — release it so it doesn't
81
+ // stay stacked on the connection through a long outer transaction with
82
+ // many nested failures. Best-effort: the rollback already unwound the
83
+ // work, so a RELEASE failure must not surface. (PG/MySQL/SQLite all
84
+ // accept RELEASE after ROLLBACK TO.)
85
+ try {
86
+ await parent.execute(`RELEASE SAVEPOINT ${name}`, []);
87
+ } catch {
88
+ /* best-effort — the savepoint is already logically unwound */
89
+ }
90
+ await runAfterHooks(rollbackHooks);
91
+ },
92
+ after(event, cb) {
93
+ (event === "commit" ? commitHooks : rollbackHooks).push(cb);
39
94
  },
40
95
  isNested: true,
41
96
  [TRANSACTION_BRAND]: true,
@@ -68,14 +123,22 @@ export async function transaction<T>(
68
123
 
69
124
  await db.execute("BEGIN", []);
70
125
 
126
+ const commitHooks: AfterHook[] = [];
127
+ const rollbackHooks: AfterHook[] = [];
128
+
71
129
  const trx: TransactionClient = {
72
130
  execute: db.execute.bind(db),
73
131
  query: db.query.bind(db),
74
132
  async commit() {
75
133
  await db.execute("COMMIT", []);
134
+ await runAfterHooks(commitHooks);
76
135
  },
77
136
  async rollback() {
78
137
  await db.execute("ROLLBACK", []);
138
+ await runAfterHooks(rollbackHooks);
139
+ },
140
+ after(event, cb) {
141
+ (event === "commit" ? commitHooks : rollbackHooks).push(cb);
79
142
  },
80
143
  isNested: false,
81
144
  [TRANSACTION_BRAND]: true,
@@ -2,10 +2,51 @@
2
2
  * NapiDbAdapter — bridges the Rust atlas-db NAPI binding to Atlas.
3
3
  */
4
4
 
5
- import type { TransactionClient } from "../Transaction.js";
5
+ import { emitDbQuery, hasDbQueryListeners } from "../events.js";
6
+ import {
7
+ type AfterHook,
8
+ runAfterHooks,
9
+ type TransactionClient,
10
+ } from "../Transaction.js";
6
11
  import { dialectFromUrl } from "../utils/dialectFromUrl.js";
7
12
  import { TRANSACTION_BRAND } from "../utils/transactionBrand.js";
8
13
 
14
+ /**
15
+ * JSON replacer for the napi boundary. `BigInt` throws in a plain
16
+ * `JSON.stringify` and a `Uint8Array`/`Buffer` serializes to a useless
17
+ * index-map, so both are wrapped in envelopes the Rust side decodes and binds
18
+ * losslessly: `{"$bigint": "123"}` → i64, `{"$bytes": "<base64>"}` → BLOB/BYTEA.
19
+ */
20
+ function napiReplacer(_key: string, value: unknown): unknown {
21
+ if (typeof value === "bigint") return { $bigint: value.toString() };
22
+ if (value instanceof Uint8Array) {
23
+ return { $bytes: Buffer.from(value).toString("base64") };
24
+ }
25
+ return value;
26
+ }
27
+
28
+ /**
29
+ * JSON reviver for napi result sets. Rebuilds `{"$bytes": …}` envelopes (emitted
30
+ * by the Rust decoder for BLOB/BYTEA columns) into a `Uint8Array`. Integers
31
+ * beyond JS's safe range arrive pre-stringified by Rust — no precision loss — so
32
+ * they stay strings, matching the pg/mysql driver convention.
33
+ */
34
+ function napiReviver(_key: string, value: unknown): unknown {
35
+ if (
36
+ typeof value === "object" &&
37
+ value !== null &&
38
+ !Array.isArray(value) &&
39
+ "$bytes" in value &&
40
+ Object.keys(value).length === 1
41
+ ) {
42
+ const bytes = value.$bytes;
43
+ if (typeof bytes === "string") {
44
+ return Uint8Array.from(Buffer.from(bytes, "base64"));
45
+ }
46
+ }
47
+ return value;
48
+ }
49
+
9
50
  /** One `(sql, params)` pair passed to `runInTransaction`. */
10
51
  export interface BatchStatement {
11
52
  sql: string;
@@ -28,6 +69,37 @@ export interface TransactionOptions {
28
69
  isolationLevel?: IsolationLevel;
29
70
  }
30
71
 
72
+ /**
73
+ * Context a caller can attach to a statement so the `db:query` event can say
74
+ * where it came from. Optional everywhere — a connection that ignores it stays
75
+ * a valid `AsyncDatabaseConnection`, which is what lets test doubles skip it.
76
+ */
77
+ export interface QueryMeta {
78
+ /** Entity class name, when the statement came from a repository/model. */
79
+ model?: string;
80
+ /** The call that produced it (`exec`, `first`, `paginate`, …). */
81
+ method?: string;
82
+ /** True for schema statements. */
83
+ ddl?: boolean;
84
+ /**
85
+ * Force emission for this statement even when the connection has
86
+ * `debug: false` — this is what `ModelQuery.debug()` sets.
87
+ */
88
+ debug?: boolean;
89
+ }
90
+
91
+ /** Per-connection observability settings (Lucid's `debug` connection option). */
92
+ export interface ObservabilityOptions {
93
+ /**
94
+ * Emit a `db:query` event for every statement on this connection. Off by
95
+ * default: it costs a timing pair per query, and nothing is emitted anyway
96
+ * unless something subscribed via `onDbQuery`.
97
+ */
98
+ debug?: boolean;
99
+ /** Connection name, reported on each event so multi-connection apps can tell them apart. */
100
+ connectionName?: string;
101
+ }
102
+
31
103
  /** Async database connection backed by Rust (sqlx). */
32
104
  export interface AsyncDatabaseConnection {
33
105
  /** The dialect this connection targets — derived from the URL scheme at connect time. */
@@ -35,8 +107,13 @@ export interface AsyncDatabaseConnection {
35
107
  query<T = Record<string, unknown>>(
36
108
  sql: string,
37
109
  params?: unknown[],
110
+ meta?: QueryMeta,
38
111
  ): Promise<T[]>;
39
- execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>;
112
+ execute(
113
+ sql: string,
114
+ params?: unknown[],
115
+ meta?: QueryMeta,
116
+ ): Promise<{ rowsAffected: number }>;
40
117
  /**
41
118
  * Run every statement in `batch` atomically inside a single sqlx transaction.
42
119
  * Either every statement commits or none do — used by MigrationRunner to
@@ -129,7 +206,9 @@ export async function createNapiConnection(
129
206
  poolMax = 10,
130
207
  pragmas?: Record<string, string | number>,
131
208
  retry?: ConnectRetryOptions,
209
+ observability: ObservabilityOptions = {},
132
210
  ): Promise<AsyncDatabaseConnection> {
211
+ const { debug = false, connectionName } = observability;
133
212
  // Throws with the underlying cause if the binary can't be loaded.
134
213
  const native = await loadNativeDb();
135
214
 
@@ -184,26 +263,41 @@ export async function createNapiConnection(
184
263
  isolationLevel?: IsolationLevel,
185
264
  ): Promise<TransactionClient> {
186
265
  const native = await db.begin(isolationLevel);
266
+ // Root (non-nested) transaction: after-hooks fire once the underlying
267
+ // COMMIT / ROLLBACK is durable (Lucid `trx.after(...)`), errors swallowed.
268
+ const commitHooks: AfterHook[] = [];
269
+ const rollbackHooks: AfterHook[] = [];
187
270
  return {
188
271
  async execute(
189
272
  sql: string,
190
273
  params: unknown[] = [],
191
274
  ): Promise<{ rowsAffected: number }> {
192
- const affected = await native.execute(sql, JSON.stringify(params));
275
+ const affected = await native.execute(
276
+ sql,
277
+ JSON.stringify(params, napiReplacer),
278
+ );
193
279
  return { rowsAffected: affected };
194
280
  },
195
281
  async query<T = Record<string, unknown>>(
196
282
  sql: string,
197
283
  params: unknown[] = [],
198
284
  ): Promise<T[]> {
199
- const json = await native.query(sql, JSON.stringify(params));
200
- return JSON.parse(json) as T[];
285
+ const json = await native.query(
286
+ sql,
287
+ JSON.stringify(params, napiReplacer),
288
+ );
289
+ return JSON.parse(json, napiReviver) as T[];
201
290
  },
202
291
  async commit(): Promise<void> {
203
292
  await native.commit();
293
+ await runAfterHooks(commitHooks);
204
294
  },
205
295
  async rollback(): Promise<void> {
206
296
  await native.rollback();
297
+ await runAfterHooks(rollbackHooks);
298
+ },
299
+ after(event: "commit" | "rollback", cb: AfterHook): void {
300
+ (event === "commit" ? commitHooks : rollbackHooks).push(cb);
207
301
  },
208
302
  isNested: false,
209
303
  [TRANSACTION_BRAND]: true,
@@ -240,29 +334,84 @@ export async function createNapiConnection(
240
334
  }
241
335
  }
242
336
 
337
+ /**
338
+ * Run `fn`, emitting a `db:query` event around it when observation is on.
339
+ *
340
+ * The fast path is a single boolean pair: with no listeners, or with debug
341
+ * off and no per-query override, this adds nothing but the check. The event
342
+ * is emitted on failure too — a slow query that then throws is exactly the
343
+ * one worth seeing.
344
+ */
345
+ async function observed<T>(
346
+ sql: string,
347
+ params: unknown[],
348
+ meta: QueryMeta | undefined,
349
+ fn: () => Promise<T>,
350
+ ): Promise<T> {
351
+ if (!(debug || meta?.debug) || !hasDbQueryListeners()) return fn();
352
+
353
+ const startedAt = performance.now();
354
+ try {
355
+ const result = await fn();
356
+ emitDbQuery({
357
+ sql,
358
+ bindings: params,
359
+ duration: performance.now() - startedAt,
360
+ connection: connectionName,
361
+ model: meta?.model,
362
+ method: meta?.method,
363
+ ddl: meta?.ddl,
364
+ inTransaction: false,
365
+ });
366
+ return result;
367
+ } catch (error) {
368
+ emitDbQuery({
369
+ sql,
370
+ bindings: params,
371
+ duration: performance.now() - startedAt,
372
+ connection: connectionName,
373
+ model: meta?.model,
374
+ method: meta?.method,
375
+ ddl: meta?.ddl,
376
+ inTransaction: false,
377
+ error: error instanceof Error ? error : new Error(String(error)),
378
+ });
379
+ throw error;
380
+ }
381
+ }
382
+
243
383
  return {
244
384
  dialect,
245
385
  transaction,
246
386
  async query<T = Record<string, unknown>>(
247
387
  sql: string,
248
388
  params: unknown[] = [],
389
+ meta?: QueryMeta,
249
390
  ): Promise<T[]> {
250
- const json = await db.query(sql, JSON.stringify(params));
251
- return JSON.parse(json) as T[];
391
+ return observed(sql, params, meta, async () => {
392
+ const json = await db.query(sql, JSON.stringify(params, napiReplacer));
393
+ return JSON.parse(json, napiReviver) as T[];
394
+ });
252
395
  },
253
396
 
254
397
  async execute(
255
398
  sql: string,
256
399
  params: unknown[] = [],
400
+ meta?: QueryMeta,
257
401
  ): Promise<{ rowsAffected: number }> {
258
- const affected = await db.execute(sql, JSON.stringify(params));
259
- return { rowsAffected: affected };
402
+ return observed(sql, params, meta, async () => {
403
+ const affected = await db.execute(
404
+ sql,
405
+ JSON.stringify(params, napiReplacer),
406
+ );
407
+ return { rowsAffected: affected };
408
+ });
260
409
  },
261
410
 
262
411
  async runInTransaction(batch: readonly BatchStatement[]): Promise<number> {
263
412
  // Rust side expects `[[sql, params], ...]`
264
413
  const payload = batch.map((s) => [s.sql, s.params ?? []]);
265
- return db.runInTransaction(JSON.stringify(payload));
414
+ return db.runInTransaction(JSON.stringify(payload, napiReplacer));
266
415
  },
267
416
 
268
417
  async close(): Promise<void> {
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Migration console commands — the Ream-idiomatic CLI for running, rolling back,
3
+ * inspecting, and wiping migrations. Same shape and contract as
4
+ * {@link schemaCheckCommand}: plain `{ name, description, run }` objects
5
+ * registered in `reamrc.commands` and dispatched by the console kernel.
6
+ *
7
+ * Each command resolves the live connection from atlas's OWN service locator
8
+ * (`getDb`), never importing `@c9up/ream`, and drives the already-tested
9
+ * {@link MigrationRunner}. Atlas has no global config registry (Lucid parity —
10
+ * you pass your own paths), so every factory takes the `migrationsDir`.
11
+ *
12
+ * @example
13
+ * // commands/migrate.ts
14
+ * import { migrationRunCommand } from '@c9up/atlas'
15
+ * export default migrationRunCommand({ migrationsDir: 'database/migrations' })
16
+ *
17
+ * // reamrc.ts → commands: [() => import('./commands/migrate.js')]
18
+ * // run: <console-entry> migration:run
19
+ */
20
+
21
+ import * as fsp from "node:fs/promises";
22
+ import * as path from "node:path";
23
+ import type { AsyncDatabaseConnection } from "../adapters/NapiDbAdapter.js";
24
+ import {
25
+ type DatabaseAdapter,
26
+ MigrationRunner,
27
+ } from "../schema/MigrationRunner.js";
28
+ import { getDb } from "../services/db.js";
29
+ import { assertSafeName } from "../utils/safePath.js";
30
+ import type { AtlasCommand } from "./schemaCheckCommand.js";
31
+
32
+ export interface MigrationCommandOptions {
33
+ /** Directory holding the numbered migration files. */
34
+ migrationsDir: string;
35
+ }
36
+
37
+ /**
38
+ * Adapt the shared singleton connection to the runner's {@link DatabaseAdapter}.
39
+ * `close()` is intentionally a no-op — the console kernel owns the connection's
40
+ * lifecycle, so a command must not tear down a connection other commands share.
41
+ */
42
+ function toAdapter(conn: AsyncDatabaseConnection): DatabaseAdapter {
43
+ return {
44
+ execute: async (sql, params) => {
45
+ await conn.execute(sql, params);
46
+ },
47
+ query: (sql, params) => conn.query(sql, params),
48
+ runInTransaction: (batch) => conn.runInTransaction(batch),
49
+ close: async () => {},
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Resolve a runner from the live connection, or report and set a failing exit
55
+ * code when no connection is registered (mirrors `schemaCheckCommand`).
56
+ */
57
+ function resolveRunner(
58
+ options: MigrationCommandOptions,
59
+ ): MigrationRunner | undefined {
60
+ const db = getDb();
61
+ if (!db) {
62
+ console.error(
63
+ "[atlas] no database connection — is AtlasProvider registered?",
64
+ );
65
+ process.exitCode = 1;
66
+ return undefined;
67
+ }
68
+ return new MigrationRunner(toAdapter(db), {
69
+ migrationsDir: options.migrationsDir,
70
+ });
71
+ }
72
+
73
+ /** `migration:run` — apply every pending migration. */
74
+ export function migrationRunCommand(
75
+ options: MigrationCommandOptions,
76
+ ): AtlasCommand {
77
+ return {
78
+ name: "migration:run",
79
+ description: "Run all pending migrations",
80
+ async run() {
81
+ const runner = resolveRunner(options);
82
+ if (!runner) return;
83
+ const ran = await runner.migrate();
84
+ console.log(
85
+ ran.length ? `Migrated: ${ran.join(", ")}` : "Already up to date",
86
+ );
87
+ },
88
+ };
89
+ }
90
+
91
+ /**
92
+ * `migration:rollback` — undo the latest batch, or with `--batch=N` roll back
93
+ * everything applied after batch N (`--batch=0` rolls back all).
94
+ */
95
+ export function migrationRollbackCommand(
96
+ options: MigrationCommandOptions,
97
+ ): AtlasCommand {
98
+ return {
99
+ name: "migration:rollback",
100
+ description: "Roll back the latest batch (or --batch=N)",
101
+ async run(_args, flags) {
102
+ const runner = resolveRunner(options);
103
+ if (!runner) return;
104
+ const batch = parseBatchFlag(flags.batch);
105
+ if (batch === "invalid") {
106
+ console.error("[atlas] --batch must be a non-negative integer");
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ const rolled = await runner.rollback(
111
+ batch === undefined ? {} : { batch },
112
+ );
113
+ console.log(
114
+ rolled.length
115
+ ? `Rolled back: ${rolled.join(", ")}`
116
+ : "Nothing to roll back",
117
+ );
118
+ },
119
+ };
120
+ }
121
+
122
+ /** `migration:status` — list every migration and whether it is applied. */
123
+ export function migrationStatusCommand(
124
+ options: MigrationCommandOptions,
125
+ ): AtlasCommand {
126
+ return {
127
+ name: "migration:status",
128
+ description: "Show applied and pending migrations",
129
+ async run() {
130
+ const runner = resolveRunner(options);
131
+ if (!runner) return;
132
+ const rows = await runner.status();
133
+ if (rows.length === 0) {
134
+ console.log("No migrations found");
135
+ return;
136
+ }
137
+ for (const row of rows) {
138
+ const batch = row.batch === undefined ? "" : ` (batch ${row.batch})`;
139
+ console.log(`${row.status.padEnd(8)} ${row.name}${batch}`);
140
+ }
141
+ },
142
+ };
143
+ }
144
+
145
+ /** `migration:reset` — roll back every applied migration. */
146
+ export function migrationResetCommand(
147
+ options: MigrationCommandOptions,
148
+ ): AtlasCommand {
149
+ return {
150
+ name: "migration:reset",
151
+ description: "Roll back all migrations",
152
+ async run() {
153
+ const runner = resolveRunner(options);
154
+ if (!runner) return;
155
+ const rolled = await runner.reset();
156
+ console.log(
157
+ rolled.length
158
+ ? `Rolled back: ${rolled.join(", ")}`
159
+ : "Nothing to reset",
160
+ );
161
+ },
162
+ };
163
+ }
164
+
165
+ /** `migration:refresh` — roll everything back, then re-run all migrations. */
166
+ export function migrationRefreshCommand(
167
+ options: MigrationCommandOptions,
168
+ ): AtlasCommand {
169
+ return {
170
+ name: "migration:refresh",
171
+ description: "Roll back all migrations, then re-run them",
172
+ async run() {
173
+ const runner = resolveRunner(options);
174
+ if (!runner) return;
175
+ const { rolled, executed } = await runner.refresh();
176
+ console.log(`Rolled back: ${rolled.length}, re-ran: ${executed.length}`);
177
+ },
178
+ };
179
+ }
180
+
181
+ /** `db:wipe` — drop every table, including the migrations bookkeeping table. */
182
+ export function dbWipeCommand(options: MigrationCommandOptions): AtlasCommand {
183
+ return {
184
+ name: "db:wipe",
185
+ description: "Drop all tables (including the migrations table)",
186
+ async run() {
187
+ const runner = resolveRunner(options);
188
+ if (!runner) return;
189
+ await runner.wipe();
190
+ console.log("Dropped all tables");
191
+ },
192
+ };
193
+ }
194
+
195
+ /** Scaffold body for a fresh migration (`make:migration`). */
196
+ const MIGRATION_STUB = `import { Migration } from '@c9up/atlas'
197
+
198
+ export default class extends Migration {
199
+ async up() {
200
+ // this.schema.createTable('table_name', (table) => {
201
+ // table.increments('id')
202
+ // })
203
+ }
204
+
205
+ async down() {
206
+ // this.schema.dropTable('table_name')
207
+ }
208
+ }
209
+ `;
210
+
211
+ /**
212
+ * `make:migration <name>` — scaffold a timestamped migration file in
213
+ * `migrationsDir`. The `Date.now()` prefix keeps files in creation order under
214
+ * the runner's lexicographic sort (same convention as AdonisJS/Lucid). The name
215
+ * is validated (no path separators / traversal) and the file is written with
216
+ * `wx` so an existing migration is never clobbered.
217
+ */
218
+ export function makeMigrationCommand(
219
+ options: MigrationCommandOptions,
220
+ ): AtlasCommand {
221
+ return {
222
+ name: "make:migration",
223
+ description: "Scaffold a new timestamped migration file",
224
+ async run(args) {
225
+ const name = args[0];
226
+ if (!name) {
227
+ console.error("[atlas] usage: make:migration <name>");
228
+ process.exitCode = 1;
229
+ return;
230
+ }
231
+ try {
232
+ assertSafeName(name, "MIGRATION_INVALID", "migration");
233
+ } catch {
234
+ console.error(`[atlas] invalid migration name: ${name}`);
235
+ process.exitCode = 1;
236
+ return;
237
+ }
238
+ const fileName = `${Date.now()}_${name}.ts`;
239
+ const filePath = path.join(options.migrationsDir, fileName);
240
+ await fsp.mkdir(options.migrationsDir, { recursive: true });
241
+ await fsp.writeFile(filePath, MIGRATION_STUB, { flag: "wx" });
242
+ console.log(`Created ${filePath}`);
243
+ },
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Parse the `--batch` flag: absent → undefined (default rollback), a
249
+ * non-negative integer string → that number, anything else → `"invalid"`.
250
+ */
251
+ function parseBatchFlag(
252
+ value: string | boolean | undefined,
253
+ ): number | undefined | "invalid" {
254
+ if (value === undefined || value === true) return undefined;
255
+ if (value === false) return "invalid";
256
+ const n = Number(value);
257
+ return Number.isInteger(n) && n >= 0 ? n : "invalid";
258
+ }