@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.
- 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 +164 -5
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +339 -33
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseModel.d.ts +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 +71 -15
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +1355 -384
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts +341 -18
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +1406 -177
- 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/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/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 +3 -1
- package/scripts/guard-publish.mjs +15 -0
- package/src/AtlasProvider.ts +8 -1
- package/src/BaseEntity.ts +431 -41
- package/src/BaseModel.ts +324 -0
- package/src/BaseRepository.ts +1589 -419
- package/src/ModelQuery.ts +1817 -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/TableBuilder.ts +735 -41
- package/src/schema/catalog.ts +166 -0
- 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
package/src/Transaction.ts
CHANGED
|
@@ -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
|
|
57
|
+
await parent.execute(`SAVEPOINT ${name}`, []);
|
|
58
|
+
|
|
59
|
+
const commitHooks: AfterHook[] = [];
|
|
60
|
+
const rollbackHooks: AfterHook[] = [];
|
|
30
61
|
|
|
31
62
|
const trx: TransactionClient = {
|
|
32
|
-
execute:
|
|
33
|
-
query:
|
|
63
|
+
execute: parent.execute.bind(parent),
|
|
64
|
+
query: parent.query.bind(parent),
|
|
34
65
|
async commit() {
|
|
35
|
-
await
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
200
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
+
}
|