@ultimat3/db 1.2.0 → 3.0.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/src/client.ts CHANGED
@@ -3,9 +3,14 @@
3
3
  // pool like a `web` process behind a CDN. `Bun.SQL` is reached lazily so importing this module
4
4
  // never opens a socket (the CLI imports it to print help).
5
5
 
6
- import { type Role, resolveRole } from '@ultimat3/core';
7
- import { dbUnavailable } from './errors';
6
+ import { type Role, renderThrowable, resolveRole } from '@ultimat3/core';
7
+ import { statementAttribution } from './attribution';
8
+ import { DbError, dbUnavailable, driverError, poolAcquireTimeout, poolMaxInvalid } from './errors';
9
+ import { expectedQueryLoopReason } from './expected-loop';
10
+ import { mergeLibpqOptions } from './libpq-options';
11
+ import { statementObserver } from './observe';
8
12
  import { type SqlFragment, sql } from './sql';
13
+ import { withStatementSpan } from './statement-span';
9
14
  import { currentTx } from './transaction';
10
15
 
11
16
  export interface DbClient {
@@ -15,8 +20,13 @@ export interface DbClient {
15
20
  execute(fragment: SqlFragment): Promise<number>;
16
21
  }
17
22
 
18
- /** A connection pinned out of the pool. `withTransaction` needs one so BEGIN/COMMIT agree. */
19
- export interface DbConnection extends DbClient {
23
+ /**
24
+ * A connection pinned out of the pool. `withTransaction` needs one so BEGIN/COMMIT agree.
25
+ * `Disposable`, so `using connection = await client.reserve()` gives the pin back on every exit
26
+ * path — the hand-rolled `finally` is what forgets it on the one path nobody wrote a test for.
27
+ */
28
+ export interface DbConnection extends DbClient, Disposable {
29
+ /** Idempotent, and `[Symbol.dispose]` is the same call: releasing twice releases once. */
20
30
  release(): void;
21
31
  }
22
32
 
@@ -33,22 +43,94 @@ export interface PoolProfile {
33
43
  /** 0 disables the timeout — only `migrate`, which is allowed to take as long as it takes. */
34
44
  readonly statementTimeoutMs: number;
35
45
  readonly idleTimeoutMs: number;
46
+ /**
47
+ * How long a statement may **wait for a lock** before `55P03`, distinct from how long it may run.
48
+ * 0 everywhere but `migrate`, which is the only role that takes `ACCESS EXCLUSIVE`: an `alter
49
+ * table` queued behind a long `SELECT` puts every later query on that table behind it too,
50
+ * because Postgres' lock queue is FIFO — and `migrate` runs `statement_timeout = 0`, so nothing
51
+ * else would ever end the wait. Read by `migrate()` as a `SET LOCAL`, never by the pool.
52
+ */
53
+ readonly lockTimeoutMs: number;
54
+ /**
55
+ * How long `reserve()` may wait for a free connection before `X_DB_POOL_EXHAUSTED`. 0 waits
56
+ * forever, which is what a run-once role wants and what a request-serving one must never do:
57
+ * queueing turns exhaustion into a hang, `/readyz`'s `select 1` joins the same queue, the kubelet
58
+ * kills the pod, and the replacement inherits the same saturated database.
59
+ */
60
+ readonly acquireTimeoutMs: number;
36
61
  }
37
62
 
38
63
  /** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */
39
64
  export const POOL_PROFILES: Readonly<Record<Role, PoolProfile>> = Object.freeze({
40
- web: { max: 20, statementTimeoutMs: 10_000, idleTimeoutMs: 30_000 },
41
- sync: { max: 10, statementTimeoutMs: 10_000, idleTimeoutMs: 60_000 },
42
- worker: { max: 8, statementTimeoutMs: 120_000, idleTimeoutMs: 30_000 },
43
- scheduler: { max: 2, statementTimeoutMs: 15_000, idleTimeoutMs: 60_000 },
44
- migrate: { max: 1, statementTimeoutMs: 0, idleTimeoutMs: 10_000 },
45
- replicator: { max: 4, statementTimeoutMs: 0, idleTimeoutMs: 60_000 },
65
+ web: {
66
+ max: 20,
67
+ statementTimeoutMs: 10_000,
68
+ idleTimeoutMs: 30_000,
69
+ lockTimeoutMs: 0,
70
+ acquireTimeoutMs: 5_000,
71
+ },
72
+ sync: {
73
+ max: 10,
74
+ statementTimeoutMs: 10_000,
75
+ idleTimeoutMs: 60_000,
76
+ lockTimeoutMs: 0,
77
+ acquireTimeoutMs: 5_000,
78
+ },
79
+ worker: {
80
+ max: 8,
81
+ statementTimeoutMs: 120_000,
82
+ idleTimeoutMs: 30_000,
83
+ lockTimeoutMs: 0,
84
+ acquireTimeoutMs: 10_000,
85
+ },
86
+ scheduler: {
87
+ max: 2,
88
+ statementTimeoutMs: 15_000,
89
+ idleTimeoutMs: 60_000,
90
+ lockTimeoutMs: 0,
91
+ acquireTimeoutMs: 10_000,
92
+ },
93
+ // `migrate` waits: its pool is `max: 1` and the advisory-lock pin holds it for the whole run, so
94
+ // a deadline here would refuse the migration's own session. The wait that needed bounding is the
95
+ // advisory lock's, and `MIGRATION_LOCK_WAIT_MS` bounds it.
96
+ migrate: {
97
+ max: 1,
98
+ statementTimeoutMs: 0,
99
+ idleTimeoutMs: 10_000,
100
+ lockTimeoutMs: 3_000,
101
+ acquireTimeoutMs: 0,
102
+ },
103
+ replicator: {
104
+ max: 4,
105
+ statementTimeoutMs: 0,
106
+ idleTimeoutMs: 60_000,
107
+ lockTimeoutMs: 0,
108
+ acquireTimeoutMs: 0,
109
+ },
46
110
  });
47
111
 
48
112
  export function poolProfileFor(role: Role = resolveRole()): PoolProfile {
49
113
  return POOL_PROFILES[role];
50
114
  }
51
115
 
116
+ /** The one pool knob an operator can turn without a rebuild. Layered over the role default. */
117
+ export const POOL_MAX_ENV = 'DATABASE_POOL_MAX';
118
+
119
+ /**
120
+ * `DATABASE_POOL_MAX`, or nothing. `POOL_PROFILES` is frozen into the build, so before this the
121
+ * only way to change a fleet's connection count was to ship a new image — and 400 `web` pods at
122
+ * `max: 20` is 8,000 backends against a `max_connections` of 450. An unparseable value **refuses**
123
+ * rather than falling back: a fleet that ignored the number it was given is the failure the
124
+ * variable exists to prevent, and it would only be found in `pg_stat_activity` at 3am.
125
+ */
126
+ function poolMaxFromEnv(): Partial<PoolProfile> {
127
+ const raw = process.env[POOL_MAX_ENV];
128
+ if (raw === undefined || raw.trim() === '') return {};
129
+ const max = Number(raw);
130
+ if (!Number.isSafeInteger(max) || max < 1) throw poolMaxInvalid(raw);
131
+ return { max };
132
+ }
133
+
52
134
  /** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */
53
135
  interface BunSqlReserved {
54
136
  unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
@@ -91,10 +173,19 @@ function connectionUrl(options: PostgresClientOptions, profile: PoolProfile): st
91
173
  } catch (error) {
92
174
  throw dbUnavailable(`DATABASE_URL is not a valid url: ${raw}`, error);
93
175
  }
94
- // libpq `options` is the portable way to pin a statement timeout for every pooled connection.
95
- if (profile.statementTimeoutMs > 0) {
96
- url.searchParams.set('options', `-c statement_timeout=${profile.statementTimeoutMs}`);
97
- }
176
+ // libpq `options` is the portable way to pin a statement timeout for every pooled connection
177
+ // MERGED into the operator's own, never assigned over it, and emitted for every role including
178
+ // the two whose bound is 0. `set` here dropped a `?options=-c search_path=app` on `web`, `sync`,
179
+ // `worker` and `scheduler` and kept it on `migrate` and `replicator`, so the role that runs the
180
+ // migrations and the role that serves the traffic read different schemas. 0 is a value, not a
181
+ // silence: it is `migrate` saying it may take as long as it takes, and left unsaid a server-side
182
+ // `alter database ... set statement_timeout` kills the one role that must outlive it.
183
+ url.searchParams.set(
184
+ 'options',
185
+ mergeLibpqOptions(url.searchParams.get('options'), {
186
+ statement_timeout: String(profile.statementTimeoutMs),
187
+ }),
188
+ );
98
189
  url.searchParams.set('application_name', options.applicationName ?? 'ultimate');
99
190
  return url.toString();
100
191
  }
@@ -103,10 +194,57 @@ function rowsOf<T>(result: unknown): readonly T[] {
103
194
  return Array.isArray(result) ? (result as readonly T[]) : [];
104
195
  }
105
196
 
197
+ // The command tag only when it counted something, exactly like `rowsOf` in `pglite.ts` — one rule
198
+ // across both drivers, so `execute()` and the observer's event cannot answer differently for the
199
+ // same statement depending on which database is behind them. A driver that tags a read `0` while
200
+ // returning rows would otherwise report 0 here and the row count there.
106
201
  function affectedBy(result: unknown): number {
107
202
  if (!Array.isArray(result)) return 0;
108
203
  const count = (result as { count?: unknown }).count;
109
- return typeof count === 'number' ? count : result.length;
204
+ return typeof count === 'number' && count > 0 ? count : result.length;
205
+ }
206
+
207
+ /**
208
+ * `pool.reserve()` under a deadline. Without one an exhausted pool does not fail, it **queues** —
209
+ * so a slow endpoint filling all 20 slots turns every later request, `/readyz`'s `select 1`
210
+ * included, into a wait with no end and no error, and the pod is killed for being unready rather
211
+ * than answering 503 for the requests it cannot serve.
212
+ *
213
+ * The losing reservation is released, never dropped: the pool hands out a connection whenever one
214
+ * frees, deadline or no deadline, and a pin nobody holds is a connection nobody gets back. That is
215
+ * the whole reason this is not a bare `Promise.race`.
216
+ */
217
+ async function reserveWithin(
218
+ pool: Pick<BunSqlDriver, 'reserve'>,
219
+ profile: PoolProfile,
220
+ ): Promise<BunSqlReserved> {
221
+ const budget = profile.acquireTimeoutMs;
222
+ if (budget <= 0) return pool.reserve();
223
+ let timer: ReturnType<typeof setTimeout> | undefined;
224
+ let expired = false;
225
+ const pending = pool.reserve();
226
+ try {
227
+ return await Promise.race([
228
+ pending,
229
+ new Promise<never>((_resolve, reject) => {
230
+ timer = setTimeout(() => {
231
+ expired = true;
232
+ reject(poolAcquireTimeout(budget, profile.max));
233
+ }, budget);
234
+ // The deadline must not be what keeps a finished process alive.
235
+ timer.unref?.();
236
+ }),
237
+ ]);
238
+ } finally {
239
+ if (timer !== undefined) clearTimeout(timer);
240
+ // Attached unconditionally so a rejection arriving after we gave up is handled, not unhandled.
241
+ void pending.then(
242
+ (late) => {
243
+ if (expired) late.release();
244
+ },
245
+ () => undefined,
246
+ );
247
+ }
110
248
  }
111
249
 
112
250
  export interface PostgresClient extends ReservableClient {
@@ -129,17 +267,76 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
129
267
  return driver;
130
268
  }
131
269
 
132
- async function runOn(
270
+ /** The send itself: one statement on one handle, every driver failure typed on the way out. */
271
+ async function sendOn(
133
272
  driver: Pick<BunSqlDriver, 'unsafe'>,
134
273
  fragment: SqlFragment,
135
274
  ): Promise<unknown> {
136
275
  try {
137
276
  return await driver.unsafe(fragment.text, fragment.values);
138
277
  } catch (error) {
139
- throw dbUnavailable(`statement failed: ${fragment.text.slice(0, 120)}`, error);
278
+ // `driverError`, not `dbUnavailable`: the SQLSTATE has always been on this error and nothing
279
+ // read it, so a `23505` from two clicks racing a signup told the operator the database was
280
+ // unreachable and paged on-call for an outage that never happened. Everything the table does
281
+ // not classify is still `X_DB_UNAVAILABLE`, byte for byte.
282
+ throw driverError(`statement failed: ${fragment.text.slice(0, 120)}`, error);
140
283
  }
141
284
  }
142
285
 
286
+ /**
287
+ * The funnel — pooled and pinned statements both arrive here, which is why the observer hangs
288
+ * off this one function and nowhere else. Uninstalled it costs one property read and one
289
+ * branch: no clock read, no span, no event object, and `sendOn` receives exactly the call `runOn`
290
+ * made before the seam existed (axiom 6).
291
+ */
292
+ async function runOn(
293
+ driver: Pick<BunSqlDriver, 'unsafe'>,
294
+ fragment: SqlFragment,
295
+ ): Promise<unknown> {
296
+ const observer = statementObserver();
297
+ if (observer === undefined) return sendOn(driver, fragment);
298
+ // Read here, not by the consumer: the scope is gone by the time a per-request detector judges
299
+ // what it collected, so the reason has to be captured with the statement it defends.
300
+ const expected = expectedQueryLoopReason();
301
+ // Same moment, same argument: `postgresRepo` is several frames and a microtask above this one,
302
+ // and what it knows — the entity and the operation — is what turns fifty identical `select`s
303
+ // into "50× findById on members". Absent for hand-written SQL, a migration, a health probe.
304
+ const attribution = statementAttribution();
305
+ const started = performance.now();
306
+ let result: unknown;
307
+ try {
308
+ // The span wraps the send and nothing else, so its duration is the statement's and the
309
+ // observer's own work is not charged to the database.
310
+ result = await withStatementSpan(fragment.text, () => sendOn(driver, fragment));
311
+ } catch (error) {
312
+ // A statement that failed is still a statement: fifty identical timeouts are an N+1 of
313
+ // timeouts. The error is already `X_DB_UNAVAILABLE`, so the event carries what the caller
314
+ // is about to be thrown — and an observer that throws here replaces it, which is why
315
+ // `observe.ts` says a reporting-only observer must not throw.
316
+ observer.onStatement({
317
+ text: fragment.text,
318
+ values: fragment.values,
319
+ durationMs: performance.now() - started,
320
+ rows: 0,
321
+ error,
322
+ attribution,
323
+ expected,
324
+ });
325
+ throw error;
326
+ }
327
+ // Outside the `try` deliberately: a throw from `onStatement` is the observer's, not the
328
+ // database's, and catching it above would report a statement that succeeded as failed.
329
+ observer.onStatement({
330
+ text: fragment.text,
331
+ values: fragment.values,
332
+ durationMs: performance.now() - started,
333
+ rows: affectedBy(result),
334
+ attribution,
335
+ expected,
336
+ });
337
+ return result;
338
+ }
339
+
143
340
  async function run(fragment: SqlFragment): Promise<unknown> {
144
341
  return runOn(connect(), fragment);
145
342
  }
@@ -164,30 +361,52 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
164
361
  const pool = connect();
165
362
  let reserved: BunSqlReserved;
166
363
  try {
167
- reserved = await pool.reserve();
364
+ reserved = await reserveWithin(pool, profile);
168
365
  } catch (error) {
169
366
  // Acquiring the pin is the one step that runs outside `runOn`, so an exhausted or
170
367
  // unreachable pool would escape as an untyped driver error — and `readOnlyQuery` reaches
171
368
  // this line before its first statement, which is how MCP ends up returning something
172
- // other than X_DB_UNAVAILABLE.
173
- throw dbUnavailable('could not reserve a connection from the pool', error);
369
+ // other than X_DB_UNAVAILABLE. Our own deadline is already typed; only the driver's own
370
+ // failure needs classifying, and `53300` from the server lands as X_DB_POOL_EXHAUSTED too.
371
+ if (error instanceof DbError) throw error;
372
+ throw driverError('could not reserve a connection from the pool', error);
174
373
  }
374
+ let held = true;
375
+ // Direct only while the pin is held. `release()` hands this physical connection back, and
376
+ // the pool may already have given it to another unit of work mid-transaction — a statement
377
+ // issued on the stale handle would land inside theirs, committed or rolled back with it and
378
+ // no error anywhere to explain the row. So a late statement takes its own connection out of
379
+ // the pool, exactly like any other caller. Same rule as `pglite.ts`, one driver down.
380
+ const on = (fragment: SqlFragment): Promise<unknown> =>
381
+ held ? runOn(reserved, fragment) : run(fragment);
382
+ // Idempotent because two owners already exist on one exit path: `withTransaction` releases
383
+ // in a `finally` and `[Symbol.dispose]` fires on the same scope. A second `release()` on a
384
+ // handle already back in the pool frees whoever holds that connection now.
385
+ const release = (): void => {
386
+ if (!held) return;
387
+ held = false;
388
+ reserved.release();
389
+ };
175
390
  return {
176
- query: async <T>(fragment: SqlFragment) => rowsOf<T>(await runOn(reserved, fragment)),
177
- one: async <T>(fragment: SqlFragment) =>
178
- rowsOf<T>(await runOn(reserved, fragment))[0] ?? null,
179
- execute: async (fragment: SqlFragment) => affectedBy(await runOn(reserved, fragment)),
180
- release: () => {
181
- reserved.release();
182
- },
391
+ query: async <T>(fragment: SqlFragment) => rowsOf<T>(await on(fragment)),
392
+ one: async <T>(fragment: SqlFragment) => rowsOf<T>(await on(fragment))[0] ?? null,
393
+ execute: async (fragment: SqlFragment) => affectedBy(await on(fragment)),
394
+ release,
395
+ [Symbol.dispose]: release,
183
396
  };
184
397
  },
185
398
  async ping(): Promise<void> {
186
399
  await client.query(sql`select 1`);
187
400
  },
188
401
  async close(): Promise<void> {
189
- await driver?.close();
402
+ // Read-then-clear, the same shape as `pglite.ts`: a `close()` that rejects has still torn
403
+ // the pool down, so caching it would hand the next `connect()` a corpse and every statement
404
+ // after it would fail for a reason no caller can see. Clearing first also means a
405
+ // `connect()` racing the await opens a fresh pool instead of joining the one draining. The
406
+ // rejection still reaches the caller — a shutdown that could not drain wants to know.
407
+ const pool = driver;
190
408
  driver = undefined;
409
+ await pool?.close();
191
410
  },
192
411
  };
193
412
  return client;
@@ -200,9 +419,16 @@ export function setDbClient(client: DbClient | undefined): void {
200
419
  ambient = client;
201
420
  }
202
421
 
203
- /** The pool, ignoring any open transaction. `withTransaction` must not re-enter `db()`. */
422
+ /**
423
+ * The pool, ignoring any open transaction. `withTransaction` must not re-enter `db()`.
424
+ *
425
+ * The role default is layered under `DATABASE_POOL_MAX`, because this is the one place the process
426
+ * builds its own client and therefore the only place an operator's value can reach one:
427
+ * `createPostgresClient` has always taken a `profile` override and nothing in a running app passed
428
+ * it, so `POOL_PROFILES` was the last word in a deployed image.
429
+ */
204
430
  export function baseClient(): DbClient {
205
- if (ambient === undefined) ambient = createPostgresClient();
431
+ if (ambient === undefined) ambient = createPostgresClient({ profile: poolMaxFromEnv() });
206
432
  return ambient;
207
433
  }
208
434
 
@@ -231,7 +457,9 @@ export async function checkDb(client: DbClient = baseClient()): Promise<DbHealth
231
457
  return {
232
458
  ok: false,
233
459
  latencyMs: Math.round(performance.now() - started),
234
- error: error instanceof Error ? error.message : String(error),
460
+ // `renderThrowable`, never `error.message`: the probe wants a report, and a render that
461
+ // throws is an exception out of `/readyz` — the one caller that cannot catch it.
462
+ error: renderThrowable(error),
235
463
  };
236
464
  }
237
465
  }
@@ -0,0 +1,126 @@
1
+ // Single responsibility: decide whether a migration's `up` half destroys data, and whether the
2
+ // file admits it. The strong-migrations idea, enforced rather than documented — a drop is allowed,
3
+ // an *undeclared* drop is not. Only `up` is ever asked: reversing `create table` is `drop table`,
4
+ // so a rail reading `down` would mark every migration ever generated, and a mark on all is none.
5
+
6
+ import { stripSqlNoise } from './sql-noise';
7
+ import { noiseAt } from './sql-scan';
8
+ import { statementsOf } from './statement-split';
9
+
10
+ /** The line a migration carries to declare that applying it destroys data. */
11
+ export const DESTRUCTIVE_MARKER = '-- destructive: true';
12
+
13
+ /**
14
+ * The whole comment, so a file that merely *mentions* the marker — `-- destructive: true is
15
+ * required for a drop` — has not declared anything. `\r` is allowed because an editor writing
16
+ * CRLF must not turn a declared drop back into a gate failure.
17
+ */
18
+ const MARKER_COMMENT = /^--[ \t]*destructive:[ \t]*true[ \t]*\r?\n?$/i;
19
+
20
+ /** Whether only spaces and tabs separate `index` from the start of its line. */
21
+ function startsLine(sql: string, index: number): boolean {
22
+ for (let at = index - 1; at >= 0; at -= 1) {
23
+ const char = sql[at];
24
+ if (char === '\n') return true;
25
+ if (char !== ' ' && char !== '\t') return false;
26
+ }
27
+ return true;
28
+ }
29
+
30
+ /**
31
+ * Declared only where a reader would see it: a top-level `--` line of its own.
32
+ *
33
+ * A regex over the raw file matched the marker inside `/* … *\/` and inside a dollar-quoted body,
34
+ * where it declares nothing and no reviewer reading the diff would call it a declaration — yet it
35
+ * bought the file past `x verify`. Scanning is what tells a comment from a comment about a
36
+ * comment; the marker is a lexical fact, not a substring.
37
+ */
38
+ export function hasDestructiveMarker(sql: string): boolean {
39
+ let index = 0;
40
+ while (index < sql.length) {
41
+ const noise = noiseAt(sql, index);
42
+ if (noise === null) {
43
+ index += 1;
44
+ continue;
45
+ }
46
+ const text = sql.slice(index, noise.end);
47
+ if (noise.kind === 'line-comment' && startsLine(sql, index) && MARKER_COMMENT.test(text)) {
48
+ return true;
49
+ }
50
+ index = noise.end;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ export type DestructiveKind = 'drop-table' | 'drop-column' | 'retype-column' | 'truncate';
56
+
57
+ /** What each kind does to the rows, in the words `X_MIGRATION_DESTRUCTIVE` prints. */
58
+ export const DESTRUCTIVE_CAUSE: Readonly<Record<DestructiveKind, string>> = {
59
+ 'drop-table': 'drops a table',
60
+ 'drop-column': 'drops a column',
61
+ 'retype-column': 'rewrites a column to another type',
62
+ truncate: 'truncates a table',
63
+ };
64
+
65
+ export interface DestructiveStatement {
66
+ readonly kind: DestructiveKind;
67
+ /** The statement as written, on one line, without the comments that preceded it. */
68
+ readonly statement: string;
69
+ }
70
+
71
+ /**
72
+ * The closed list. Four kinds, because a rail that tried to enumerate every Postgres foot-gun
73
+ * would be a second SQL parser competing with the server's own — and every one of these is a
74
+ * statement `generateMigration` can emit, so each has a generated case to hold it honest.
75
+ *
76
+ * First match wins: one statement is one operation, and a second finding on the same line would
77
+ * repeat an instruction that is already one marker for the whole file.
78
+ */
79
+ const RULES: readonly (readonly [DestructiveKind, RegExp])[] = [
80
+ ['drop-table', /\bdrop\s+(?:foreign\s+)?table\b/],
81
+ ['truncate', /\btruncate\b/],
82
+ // Inside an `alter table`, a bare `drop <name>` is a column: every sub-clause that drops
83
+ // something the database can rebuild names itself, and all of them are listed here.
84
+ [
85
+ 'drop-column',
86
+ /\balter\s+table\b[\s\S]*?\bdrop\s+(?!constraint\b|default\b|not\b|identity\b|expression\b|generated\b)/,
87
+ ],
88
+ // Not "narrowing". Whether the new type is narrower is knowable only against the old one, and a
89
+ // rewrite that fails on one row fails the whole migration whichever direction it went.
90
+ ['retype-column', /\balter\s+column\b[\s\S]*?\btype\b/],
91
+ ];
92
+
93
+ /**
94
+ * One capped line — an error prints this, not a whole script. Only the comments *preceding* the
95
+ * statement come off, the ones `statementsOf` carries in from the file header; the SQL itself stays
96
+ * verbatim, because `stripSqlNoise` blanks quoted identifiers and `drop table ""` names nothing an
97
+ * author can act on. Blanking is for deciding, never for reporting.
98
+ */
99
+ function excerpt(statement: string): string {
100
+ const line = statement
101
+ .replace(/^(?:\s*(?:--[^\n]*|\/\*[\s\S]*?\*\/)\s*)+/, '')
102
+ .replace(/\s+/g, ' ')
103
+ .trim();
104
+ return line.length > 120 ? `${line.slice(0, 117)}...` : line;
105
+ }
106
+
107
+ /**
108
+ * Every destructive statement in `up`, in apply order.
109
+ *
110
+ * `statementsOf` cuts on a `;` that is not inside a literal, an identifier, a dollar-quoted body
111
+ * or a comment, and `stripSqlNoise` blanks all four before a keyword is looked for — so
112
+ * `-- drop table users` and `insert into audit values ('drop table users')` are prose and data,
113
+ * not operations. A naive keyword scan reports both, which is how a rail earns being ignored.
114
+ */
115
+ export function destructiveStatements(up: string): readonly DestructiveStatement[] {
116
+ const found: DestructiveStatement[] = [];
117
+ for (const statement of statementsOf(up)) {
118
+ const bare = stripSqlNoise(statement).toLowerCase();
119
+ const rule = RULES.find(([, pattern]) => pattern.test(bare));
120
+ if (rule !== undefined) found.push({ kind: rule[0], statement: excerpt(statement) });
121
+ }
122
+ return found;
123
+ }
124
+
125
+ /** Whether `up` destroys data at all — what `x db gen` writes the marker from. */
126
+ export const isDestructive = (up: string): boolean => destructiveStatements(up).length > 0;