@zerotal/orm 1.6.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.6.3",
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.6.3",
34
- "@zerotal/validator": "1.6.3"
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 = ?`,
@@ -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
 
@@ -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. Each `up()` runs inside its own transaction; a failure rolls that
46
- * migration back and surfaces as a {@link MigrationError} without affecting
47
- * already-committed migrations. Every run/rollback emits a `MigrationRan`
48
- * framework event (success or failure) for observability.
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
- // Each migration runs in its own transaction so a failure rolls back
89
- // that migration's DDL without affecting already-committed ones.
90
- await this._conn.begin(async () => {
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
- await entry.migration.down();
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._conn(tpl);
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._conn<T>(tpl, value);
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._conn(tpl, ...values);
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._conn<T>(tpl);
406
+ return this._active()<T>(tpl);
331
407
  }
332
408
 
333
409
  private async _loadDirectory(dir: string): Promise<MigrationEntry[]> {
@@ -1,4 +1,4 @@
1
- import { _getDbConnection } from "../db/DB.ts";
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
- const conn = _getDbConnection();
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
- const conn = _getDbConnection();
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);