@zerotal/orm 1.6.2 → 1.7.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/CHANGELOG.md +40 -1
- package/api-surface.md +1665 -0
- package/package.json +4 -3
- package/src/commands/MigrateCommand.ts +14 -0
- package/src/db/DB.ts +25 -0
- package/src/db/dialects/MysqlDialect.ts +4 -0
- package/src/db/dialects/PostgresDialect.ts +3 -0
- package/src/db/dialects/SqliteDialect.ts +4 -0
- package/src/db/dialects/types.ts +17 -0
- package/src/observability.ts +75 -1
- package/src/schema/MigrationRunner.ts +90 -14
- package/src/schema/Schema.ts +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/orm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"CHANGELOG.md",
|
|
16
|
+
"api-surface.md",
|
|
16
17
|
"src",
|
|
17
18
|
"!src/**/*.test.ts",
|
|
18
19
|
"!src/**/*.test.tsx",
|
|
@@ -30,8 +31,8 @@
|
|
|
30
31
|
"typecheck": "tsc --noEmit"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@zerotal/core": "1.
|
|
34
|
-
"@zerotal/validator": "1.
|
|
34
|
+
"@zerotal/core": "1.7.0",
|
|
35
|
+
"@zerotal/validator": "1.7.0"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"typescript": "^5.8.0"
|
|
@@ -51,6 +51,20 @@ export class MigrateCommand extends Command {
|
|
|
51
51
|
|
|
52
52
|
const runner = new MigrationRunner({ connection: _getConnection() });
|
|
53
53
|
|
|
54
|
+
// Said before anything runs, not after something breaks. On an engine with
|
|
55
|
+
// transactional DDL a failed migration leaves nothing behind; on MySQL every
|
|
56
|
+
// DDL statement implicitly commits, so a migration that fails half way leaves
|
|
57
|
+
// the half it did — and the operator needs to know which world they are in
|
|
58
|
+
// while they still have the option of taking a backup.
|
|
59
|
+
if (!runner.willRollBackOnFailure && entries.length > 0) {
|
|
60
|
+
this.warn(
|
|
61
|
+
"This database does not support transactional DDL, so a migration that fails " +
|
|
62
|
+
"part-way will leave the statements that already succeeded in place.",
|
|
63
|
+
);
|
|
64
|
+
this.dim(" Keep migrations small, and take a backup before running them in production.");
|
|
65
|
+
this.newLine();
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
if (fresh) {
|
|
55
69
|
await runner.reset(entries);
|
|
56
70
|
}
|
package/src/db/DB.ts
CHANGED
|
@@ -65,6 +65,31 @@ export function _getDbConnection(): SQLInstance {
|
|
|
65
65
|
return conn;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The connection statements at this call site must run on: the enclosing
|
|
70
|
+
* transaction if there is one, otherwise the ordinary connection.
|
|
71
|
+
*
|
|
72
|
+
* `_getDbConnection()` deliberately does not do this — it answers "what is the
|
|
73
|
+
* app's connection", which is what a migration command needs when it is *opening*
|
|
74
|
+
* a transaction. This answers "where does my SQL go", which is what anything
|
|
75
|
+
* running inside one needs.
|
|
76
|
+
*
|
|
77
|
+
* The distinction is not academic. `Schema` used the first, so DDL issued inside
|
|
78
|
+
* `DB.transaction()` — including every migration, which the runner wraps —
|
|
79
|
+
* executed on a pooled connection outside the transaction and committed on its
|
|
80
|
+
* own. The wrapper was decorative, and a migration that failed half way left the
|
|
81
|
+
* half it had done behind.
|
|
82
|
+
*/
|
|
83
|
+
export function _getScopedDbConnection(): SQLInstance {
|
|
84
|
+
const conn =
|
|
85
|
+
TransactionContext.getStore() ??
|
|
86
|
+
(RequestContext.tryGet()?._transaction as SQLInstance | undefined) ??
|
|
87
|
+
_fromContainer();
|
|
88
|
+
if (!conn)
|
|
89
|
+
throw new Error("[Zerotal ORM] No database connection. Is DatabaseProvider registered?");
|
|
90
|
+
return conn;
|
|
91
|
+
}
|
|
92
|
+
|
|
68
93
|
/** Alias used by migration command helpers. */
|
|
69
94
|
export function _getConnection(): SQLInstance {
|
|
70
95
|
return _getDbConnection();
|
|
@@ -8,6 +8,10 @@ export class MysqlDialect implements SqlDialect {
|
|
|
8
8
|
readonly name = "mysql" as const;
|
|
9
9
|
readonly supportsAdvisoryLocks = true;
|
|
10
10
|
|
|
11
|
+
// Every DDL statement implicitly commits, so a migration that fails part-way
|
|
12
|
+
// through cannot be undone. Stated here rather than discovered in production.
|
|
13
|
+
readonly supportsTransactionalDdl = false;
|
|
14
|
+
|
|
11
15
|
hasTableSql(table: string): DialectQuery {
|
|
12
16
|
return {
|
|
13
17
|
sql:
|
|
@@ -8,6 +8,9 @@ export class PostgresDialect implements SqlDialect {
|
|
|
8
8
|
readonly name = "postgres" as const;
|
|
9
9
|
readonly supportsAdvisoryLocks = true;
|
|
10
10
|
|
|
11
|
+
// Full transactional DDL: CREATE/ALTER/DROP roll back like any other statement.
|
|
12
|
+
readonly supportsTransactionalDdl = true;
|
|
13
|
+
|
|
11
14
|
hasTableSql(table: string): DialectQuery {
|
|
12
15
|
return {
|
|
13
16
|
sql: `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?`,
|
|
@@ -8,6 +8,10 @@ export class SqliteDialect implements SqlDialect {
|
|
|
8
8
|
readonly name = "sqlite" as const;
|
|
9
9
|
readonly supportsAdvisoryLocks = false;
|
|
10
10
|
|
|
11
|
+
// Transactional DDL, same as PostgreSQL — the schema lives in the same b-tree
|
|
12
|
+
// as the data and is written under the same transaction.
|
|
13
|
+
readonly supportsTransactionalDdl = true;
|
|
14
|
+
|
|
11
15
|
hasTableSql(table: string): DialectQuery {
|
|
12
16
|
return {
|
|
13
17
|
sql: `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
package/src/db/dialects/types.ts
CHANGED
|
@@ -59,6 +59,23 @@ export interface SqlDialect {
|
|
|
59
59
|
/** Whether the engine supports application-level advisory locks. */
|
|
60
60
|
readonly supportsAdvisoryLocks: boolean;
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Whether DDL participates in transactions — so a failed migration can be
|
|
64
|
+
* rolled back and leave nothing behind.
|
|
65
|
+
*
|
|
66
|
+
* PostgreSQL and SQLite: yes. `CREATE TABLE` inside a transaction is undone by
|
|
67
|
+
* `ROLLBACK` like any other statement, which is what lets the migration runner
|
|
68
|
+
* promise all-or-nothing.
|
|
69
|
+
*
|
|
70
|
+
* MySQL and MariaDB: **no**. Every DDL statement causes an implicit commit, so
|
|
71
|
+
* `BEGIN; CREATE TABLE …; ROLLBACK;` leaves the table behind — the `ROLLBACK`
|
|
72
|
+
* has nothing left to undo. (MySQL 8.0's "atomic DDL" makes each *individual*
|
|
73
|
+
* statement crash-safe; it does not put them in your transaction.) A runner
|
|
74
|
+
* that wrapped MySQL DDL in a transaction anyway would report a rollback that
|
|
75
|
+
* did not happen, which is worse than not offering one.
|
|
76
|
+
*/
|
|
77
|
+
readonly supportsTransactionalDdl: boolean;
|
|
78
|
+
|
|
62
79
|
/** Statement acquiring an advisory lock (blocking), or null when unsupported. */
|
|
63
80
|
advisoryLockSql(key: number): DialectQuery | null;
|
|
64
81
|
|
package/src/observability.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* observer packages — installing or removing an observer requires no change here.
|
|
9
9
|
* When an observer is not installed its binding is absent and its wiring is skipped.
|
|
10
10
|
*/
|
|
11
|
-
import { FrameworkEvents } from "@zerotal/core";
|
|
11
|
+
import { FrameworkEvents, RequestContext } from "@zerotal/core";
|
|
12
12
|
import type { Application } from "@zerotal/core";
|
|
13
13
|
import {
|
|
14
14
|
QueryExecuted,
|
|
@@ -54,6 +54,19 @@ interface DevtoolsSink {
|
|
|
54
54
|
q: { sql: string; bindings: unknown[]; startMs: number; durationMs: number; rowCount: number },
|
|
55
55
|
): void;
|
|
56
56
|
bufferWarning(ctx: object, w: { sql: string; count: number }): void;
|
|
57
|
+
channel(descriptor: {
|
|
58
|
+
id: string;
|
|
59
|
+
label: string;
|
|
60
|
+
badge?: string;
|
|
61
|
+
title?: string;
|
|
62
|
+
meta?: string[];
|
|
63
|
+
warn?: string;
|
|
64
|
+
order?: number;
|
|
65
|
+
render?: "rows" | "tree" | "table" | "kv" | "grouped";
|
|
66
|
+
groupBy?: string;
|
|
67
|
+
flags?: string[];
|
|
68
|
+
}): void;
|
|
69
|
+
record(ctx: object, channel: string, entry: Record<string, unknown>): void;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
/** The subset of the logger this bridge calls (bound as `log`). */
|
|
@@ -164,6 +177,67 @@ export function installOrmObservability(app: Application): () => void {
|
|
|
164
177
|
trace.bufferWarning(e.ctx, { sql: e.fingerprint.replaceAll("\x00", "?"), count: e.count });
|
|
165
178
|
}),
|
|
166
179
|
);
|
|
180
|
+
|
|
181
|
+
// Two more tabs, declared as data. Both were already on the bus and neither
|
|
182
|
+
// had anywhere to go: a request that wrote four rows and a request that wrote
|
|
183
|
+
// none looked identical in the panel, and a transaction that rolled back
|
|
184
|
+
// showed only as queries that appeared to succeed.
|
|
185
|
+
//
|
|
186
|
+
// Rows rather than a table for models, because the interesting thing is *how
|
|
187
|
+
// many* per model, and the table view of a three-column feed is a worse
|
|
188
|
+
// version of the same three columns.
|
|
189
|
+
trace.channel({
|
|
190
|
+
id: "models",
|
|
191
|
+
label: "Models",
|
|
192
|
+
badge: "operation",
|
|
193
|
+
title: "model",
|
|
194
|
+
meta: ["table"],
|
|
195
|
+
order: 40,
|
|
196
|
+
render: "grouped",
|
|
197
|
+
groupBy: "model",
|
|
198
|
+
});
|
|
199
|
+
trace.channel({
|
|
200
|
+
id: "tx",
|
|
201
|
+
label: "Transactions",
|
|
202
|
+
badge: "outcome",
|
|
203
|
+
title: "txId",
|
|
204
|
+
meta: ["durationMs", "reason"],
|
|
205
|
+
warn: "rolledBack",
|
|
206
|
+
order: 45,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
unsubs.push(
|
|
210
|
+
FrameworkEvents.on(ModelChanged, (e) => {
|
|
211
|
+
// `ModelChanged` carries no context — it rides the model hooks, which run
|
|
212
|
+
// wherever the write did — so the request is read from the ambient scope.
|
|
213
|
+
const ctx = RequestContext.tryGet();
|
|
214
|
+
if (ctx) {
|
|
215
|
+
trace.record(ctx, "models", {
|
|
216
|
+
model: e.model,
|
|
217
|
+
table: e.table,
|
|
218
|
+
operation: e.operation,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}),
|
|
222
|
+
FrameworkEvents.on(TransactionCommitted, (e) => {
|
|
223
|
+
if (!e.ctx) return;
|
|
224
|
+
trace.record(e.ctx, "tx", {
|
|
225
|
+
txId: e.txId,
|
|
226
|
+
outcome: "committed",
|
|
227
|
+
durationMs: e.durationMs,
|
|
228
|
+
});
|
|
229
|
+
}),
|
|
230
|
+
FrameworkEvents.on(TransactionRolledBack, (e) => {
|
|
231
|
+
if (!e.ctx) return;
|
|
232
|
+
trace.record(e.ctx, "tx", {
|
|
233
|
+
txId: e.txId,
|
|
234
|
+
outcome: "rolled back",
|
|
235
|
+
durationMs: e.durationMs,
|
|
236
|
+
rolledBack: true,
|
|
237
|
+
...(e.reason ? { reason: e.reason } : {}),
|
|
238
|
+
});
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
167
241
|
}
|
|
168
242
|
|
|
169
243
|
const log = app.container.tryMake("log" as never) as LogSink | undefined;
|
|
@@ -7,6 +7,7 @@ import { FrameworkEvents } from "@zerotal/core";
|
|
|
7
7
|
import { MigrationRan } from "../events.ts";
|
|
8
8
|
import { dialectFor } from "../db/QueryBuilder.ts";
|
|
9
9
|
import { getDialect } from "../db/dialects/index.ts";
|
|
10
|
+
import { TransactionContext } from "../db/TransactionContext.ts";
|
|
10
11
|
|
|
11
12
|
// ── Public types ──────────────────────────────────────────────────────────────
|
|
12
13
|
|
|
@@ -42,10 +43,36 @@ interface MigrationRow {
|
|
|
42
43
|
*
|
|
43
44
|
* Applied migrations are recorded in a tracking table (default `"migrations"`) by
|
|
44
45
|
* name and batch, so re-runs skip already-applied entries and rollbacks can undo a
|
|
45
|
-
* whole batch.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* whole batch. Every run/rollback emits a `MigrationRan` framework event (success
|
|
47
|
+
* or failure) for observability.
|
|
48
|
+
*
|
|
49
|
+
* ## The all-or-nothing guarantee
|
|
50
|
+
*
|
|
51
|
+
* On an engine with transactional DDL — PostgreSQL and SQLite — each migration and
|
|
52
|
+
* its tracking-table row are written in **one transaction**. A migration that
|
|
53
|
+
* throws half way leaves nothing behind: not the tables it managed to create, and
|
|
54
|
+
* not a row claiming it ran. That is what makes `zt deploy:<env>` safe to retry,
|
|
55
|
+
* because the only two states a deploy can be interrupted in are "not applied" and
|
|
56
|
+
* "applied and recorded".
|
|
57
|
+
*
|
|
58
|
+
* Three things had to be true for that to hold, and none of them were:
|
|
59
|
+
*
|
|
60
|
+
* 1. **The migration's statements must run on the transaction.** `Schema` resolved
|
|
61
|
+
* the *global* connection, so DDL inside the runner's `begin()` executed on a
|
|
62
|
+
* pooled connection and committed independently. The wrapper was decorative.
|
|
63
|
+
* 2. **The tracking insert must be inside it.** Recording after the commit leaves
|
|
64
|
+
* a window where the schema has moved and the record says otherwise — the
|
|
65
|
+
* migration runs a second time on the next deploy, against a schema it has
|
|
66
|
+
* already changed.
|
|
67
|
+
* 3. **The engine must actually support it.** MySQL implicitly commits on DDL, so
|
|
68
|
+
* a transaction there is a promise that cannot be kept.
|
|
69
|
+
*
|
|
70
|
+
* ## MySQL
|
|
71
|
+
*
|
|
72
|
+
* No transactional DDL, so migrations run unwrapped and a failure leaves the
|
|
73
|
+
* statements that already succeeded in place. `willRollBackOnFailure` reports
|
|
74
|
+
* this, and `zt migrate` says so before it starts rather than after it breaks.
|
|
75
|
+
* Keeping each migration small is the only mitigation the engine allows.
|
|
49
76
|
*
|
|
50
77
|
* @example
|
|
51
78
|
* ```ts
|
|
@@ -67,6 +94,49 @@ export class MigrationRunner {
|
|
|
67
94
|
this._table = options.table ?? "migrations";
|
|
68
95
|
}
|
|
69
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Whether a failed migration will be rolled back on this connection's engine.
|
|
99
|
+
*
|
|
100
|
+
* `false` on MySQL/MariaDB, where DDL implicitly commits. Callers surface this
|
|
101
|
+
* before running anything — a developer who knows a failure will leave a
|
|
102
|
+
* half-applied schema writes smaller migrations and takes a backup first.
|
|
103
|
+
*/
|
|
104
|
+
get willRollBackOnFailure(): boolean {
|
|
105
|
+
return getDialect(dialectFor(this._conn)).supportsTransactionalDdl;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run `work` inside a transaction, or directly when the engine cannot roll DDL
|
|
110
|
+
* back.
|
|
111
|
+
*
|
|
112
|
+
* The transaction connection is published on {@link TransactionContext}, which
|
|
113
|
+
* is what `Schema` (and anything else the migration touches) resolves through.
|
|
114
|
+
* Without that the statements run on a pooled connection and commit on their
|
|
115
|
+
* own, which is precisely the bug this method exists to close — so the ALS is
|
|
116
|
+
* not an optimisation here, it is the entire mechanism.
|
|
117
|
+
*/
|
|
118
|
+
private async _atomically(work: () => Promise<void>): Promise<void> {
|
|
119
|
+
if (!this.willRollBackOnFailure) {
|
|
120
|
+
await work();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await this._conn.begin(async (tx: SQLInstance) => {
|
|
124
|
+
await TransactionContext.run(tx, work);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The connection this runner's own statements go to: the open transaction when
|
|
130
|
+
* there is one, otherwise the connection it was constructed with.
|
|
131
|
+
*
|
|
132
|
+
* The tracking-table writes need this as much as the migration does. An INSERT
|
|
133
|
+
* that went to the pool while the DDL went to the transaction would record a
|
|
134
|
+
* migration the transaction could still roll back.
|
|
135
|
+
*/
|
|
136
|
+
private _active(): SQLInstance {
|
|
137
|
+
return TransactionContext.getStore() ?? this._conn;
|
|
138
|
+
}
|
|
139
|
+
|
|
70
140
|
/**
|
|
71
141
|
* Run all pending migrations from the provided list.
|
|
72
142
|
*
|
|
@@ -85,10 +155,13 @@ export class MigrationRunner {
|
|
|
85
155
|
for (const entry of pendingEntries) {
|
|
86
156
|
const start = performance.now();
|
|
87
157
|
try {
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
158
|
+
// The migration and its tracking row in one transaction. Recording after
|
|
159
|
+
// the commit would leave a window where the schema has moved and nothing
|
|
160
|
+
// says so — and the next deploy would run this migration again, against a
|
|
161
|
+
// schema it has already changed.
|
|
162
|
+
await this._atomically(async () => {
|
|
91
163
|
await entry.migration.up();
|
|
164
|
+
await this._record(entry.name, batch);
|
|
92
165
|
});
|
|
93
166
|
} catch (err) {
|
|
94
167
|
const cause = err instanceof Error ? err : new Error(String(err));
|
|
@@ -106,7 +179,6 @@ export class MigrationRunner {
|
|
|
106
179
|
FrameworkEvents.emit(
|
|
107
180
|
new MigrationRan(entry.name, "up", Math.round(performance.now() - start), true),
|
|
108
181
|
);
|
|
109
|
-
await this._record(entry.name, batch);
|
|
110
182
|
executed.push(entry.name);
|
|
111
183
|
}
|
|
112
184
|
|
|
@@ -151,7 +223,12 @@ export class MigrationRunner {
|
|
|
151
223
|
for (const entry of toRollback) {
|
|
152
224
|
const start = performance.now();
|
|
153
225
|
try {
|
|
154
|
-
|
|
226
|
+
// Same guarantee in reverse: a `down()` that fails half way leaves neither
|
|
227
|
+
// a partly-undone schema nor a deleted record claiming it was undone.
|
|
228
|
+
await this._atomically(async () => {
|
|
229
|
+
await entry.migration.down();
|
|
230
|
+
await this._deleteRecord(entry.name);
|
|
231
|
+
});
|
|
155
232
|
} catch (err) {
|
|
156
233
|
const cause = err instanceof Error ? err : new Error(String(err));
|
|
157
234
|
FrameworkEvents.emit(
|
|
@@ -168,7 +245,6 @@ export class MigrationRunner {
|
|
|
168
245
|
FrameworkEvents.emit(
|
|
169
246
|
new MigrationRan(entry.name, "down", Math.round(performance.now() - start), true),
|
|
170
247
|
);
|
|
171
|
-
await this._deleteRecord(entry.name);
|
|
172
248
|
rolledBack.push(entry.name);
|
|
173
249
|
}
|
|
174
250
|
|
|
@@ -300,7 +376,7 @@ export class MigrationRunner {
|
|
|
300
376
|
const tpl = Object.assign(strings, {
|
|
301
377
|
raw: strings,
|
|
302
378
|
}) as TemplateStringsArray;
|
|
303
|
-
await this.
|
|
379
|
+
await this._active()(tpl);
|
|
304
380
|
}
|
|
305
381
|
|
|
306
382
|
/** SELECT with no bound parameters. */
|
|
@@ -312,14 +388,14 @@ export class MigrationRunner {
|
|
|
312
388
|
private async _selectParam<T>(sql: string, value: unknown): Promise<T[]> {
|
|
313
389
|
const parts = sql.split("?");
|
|
314
390
|
const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
|
|
315
|
-
return this.
|
|
391
|
+
return this._active()<T>(tpl, value);
|
|
316
392
|
}
|
|
317
393
|
|
|
318
394
|
/** DML with N bound parameters. */
|
|
319
395
|
private async _exec(sql: string, ...values: unknown[]): Promise<void> {
|
|
320
396
|
const parts = sql.split("?");
|
|
321
397
|
const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
|
|
322
|
-
await this.
|
|
398
|
+
await this._active()(tpl, ...values);
|
|
323
399
|
}
|
|
324
400
|
|
|
325
401
|
private async _exec0<T>(sql: string): Promise<T[]> {
|
|
@@ -327,7 +403,7 @@ export class MigrationRunner {
|
|
|
327
403
|
const tpl = Object.assign(strings, {
|
|
328
404
|
raw: strings,
|
|
329
405
|
}) as TemplateStringsArray;
|
|
330
|
-
return this.
|
|
406
|
+
return this._active()<T>(tpl);
|
|
331
407
|
}
|
|
332
408
|
|
|
333
409
|
private async _loadDirectory(dir: string): Promise<MigrationEntry[]> {
|
package/src/schema/Schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { _getScopedDbConnection } from "../db/DB.ts";
|
|
2
2
|
import { _getDialect } from "../model/BaseModel.ts";
|
|
3
3
|
import { getDialect } from "../db/dialects/index.ts";
|
|
4
4
|
import { Blueprint } from "./Blueprint.ts";
|
|
@@ -11,7 +11,10 @@ import { Blueprint } from "./Blueprint.ts";
|
|
|
11
11
|
* Bun SQL tagged-template function without interpolating anything.
|
|
12
12
|
*/
|
|
13
13
|
async function ddl(sql: string): Promise<void> {
|
|
14
|
-
|
|
14
|
+
// Scoped, not global: DDL issued inside a transaction has to run *on* that
|
|
15
|
+
// transaction or it commits independently of it — which is what made the
|
|
16
|
+
// migration runner's `begin()` wrapper decorative.
|
|
17
|
+
const conn = _getScopedDbConnection();
|
|
15
18
|
const strings = [sql];
|
|
16
19
|
const tpl = Object.assign(strings, { raw: strings }) as TemplateStringsArray;
|
|
17
20
|
await conn(tpl);
|
|
@@ -22,7 +25,9 @@ async function ddl(sql: string): Promise<void> {
|
|
|
22
25
|
* We need this for parameterised introspection queries (hasTable, hasColumn).
|
|
23
26
|
*/
|
|
24
27
|
async function query<T = Record<string, unknown>>(sql: string, params: unknown[]): Promise<T[]> {
|
|
25
|
-
|
|
28
|
+
// Same connection as `ddl()` above: `hasTable()` inside a transaction must see
|
|
29
|
+
// the tables that transaction has created, which a pooled connection cannot.
|
|
30
|
+
const conn = _getScopedDbConnection();
|
|
26
31
|
const parts = sql.split("?");
|
|
27
32
|
const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
|
|
28
33
|
return conn<T>(tpl, ...params);
|