@ultimat3/db 1.1.0 → 2.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
@@ -4,8 +4,12 @@
4
4
  // never opens a socket (the CLI imports it to print help).
5
5
 
6
6
  import { type Role, resolveRole } from '@ultimat3/core';
7
- import { dbUnavailable } from './errors';
7
+ import { statementAttribution } from './attribution';
8
+ import { DbError, dbUnavailable, driverError, poolAcquireTimeout, poolMaxInvalid } from './errors';
9
+ import { expectedQueryLoopReason } from './expected-loop';
10
+ import { statementObserver } from './observe';
8
11
  import { type SqlFragment, sql } from './sql';
12
+ import { withStatementSpan } from './statement-span';
9
13
  import { currentTx } from './transaction';
10
14
 
11
15
  export interface DbClient {
@@ -15,8 +19,13 @@ export interface DbClient {
15
19
  execute(fragment: SqlFragment): Promise<number>;
16
20
  }
17
21
 
18
- /** A connection pinned out of the pool. `withTransaction` needs one so BEGIN/COMMIT agree. */
19
- export interface DbConnection extends DbClient {
22
+ /**
23
+ * A connection pinned out of the pool. `withTransaction` needs one so BEGIN/COMMIT agree.
24
+ * `Disposable`, so `using connection = await client.reserve()` gives the pin back on every exit
25
+ * path — the hand-rolled `finally` is what forgets it on the one path nobody wrote a test for.
26
+ */
27
+ export interface DbConnection extends DbClient, Disposable {
28
+ /** Idempotent, and `[Symbol.dispose]` is the same call: releasing twice releases once. */
20
29
  release(): void;
21
30
  }
22
31
 
@@ -33,22 +42,94 @@ export interface PoolProfile {
33
42
  /** 0 disables the timeout — only `migrate`, which is allowed to take as long as it takes. */
34
43
  readonly statementTimeoutMs: number;
35
44
  readonly idleTimeoutMs: number;
45
+ /**
46
+ * How long a statement may **wait for a lock** before `55P03`, distinct from how long it may run.
47
+ * 0 everywhere but `migrate`, which is the only role that takes `ACCESS EXCLUSIVE`: an `alter
48
+ * table` queued behind a long `SELECT` puts every later query on that table behind it too,
49
+ * because Postgres' lock queue is FIFO — and `migrate` runs `statement_timeout = 0`, so nothing
50
+ * else would ever end the wait. Read by `migrate()` as a `SET LOCAL`, never by the pool.
51
+ */
52
+ readonly lockTimeoutMs: number;
53
+ /**
54
+ * How long `reserve()` may wait for a free connection before `X_DB_POOL_EXHAUSTED`. 0 waits
55
+ * forever, which is what a run-once role wants and what a request-serving one must never do:
56
+ * queueing turns exhaustion into a hang, `/readyz`'s `select 1` joins the same queue, the kubelet
57
+ * kills the pod, and the replacement inherits the same saturated database.
58
+ */
59
+ readonly acquireTimeoutMs: number;
36
60
  }
37
61
 
38
62
  /** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */
39
63
  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 },
64
+ web: {
65
+ max: 20,
66
+ statementTimeoutMs: 10_000,
67
+ idleTimeoutMs: 30_000,
68
+ lockTimeoutMs: 0,
69
+ acquireTimeoutMs: 5_000,
70
+ },
71
+ sync: {
72
+ max: 10,
73
+ statementTimeoutMs: 10_000,
74
+ idleTimeoutMs: 60_000,
75
+ lockTimeoutMs: 0,
76
+ acquireTimeoutMs: 5_000,
77
+ },
78
+ worker: {
79
+ max: 8,
80
+ statementTimeoutMs: 120_000,
81
+ idleTimeoutMs: 30_000,
82
+ lockTimeoutMs: 0,
83
+ acquireTimeoutMs: 10_000,
84
+ },
85
+ scheduler: {
86
+ max: 2,
87
+ statementTimeoutMs: 15_000,
88
+ idleTimeoutMs: 60_000,
89
+ lockTimeoutMs: 0,
90
+ acquireTimeoutMs: 10_000,
91
+ },
92
+ // `migrate` waits: its pool is `max: 1` and the advisory-lock pin holds it for the whole run, so
93
+ // a deadline here would refuse the migration's own session. The wait that needed bounding is the
94
+ // advisory lock's, and `MIGRATION_LOCK_WAIT_MS` bounds it.
95
+ migrate: {
96
+ max: 1,
97
+ statementTimeoutMs: 0,
98
+ idleTimeoutMs: 10_000,
99
+ lockTimeoutMs: 3_000,
100
+ acquireTimeoutMs: 0,
101
+ },
102
+ replicator: {
103
+ max: 4,
104
+ statementTimeoutMs: 0,
105
+ idleTimeoutMs: 60_000,
106
+ lockTimeoutMs: 0,
107
+ acquireTimeoutMs: 0,
108
+ },
46
109
  });
47
110
 
48
111
  export function poolProfileFor(role: Role = resolveRole()): PoolProfile {
49
112
  return POOL_PROFILES[role];
50
113
  }
51
114
 
115
+ /** The one pool knob an operator can turn without a rebuild. Layered over the role default. */
116
+ export const POOL_MAX_ENV = 'DATABASE_POOL_MAX';
117
+
118
+ /**
119
+ * `DATABASE_POOL_MAX`, or nothing. `POOL_PROFILES` is frozen into the build, so before this the
120
+ * only way to change a fleet's connection count was to ship a new image — and 400 `web` pods at
121
+ * `max: 20` is 8,000 backends against a `max_connections` of 450. An unparseable value **refuses**
122
+ * rather than falling back: a fleet that ignored the number it was given is the failure the
123
+ * variable exists to prevent, and it would only be found in `pg_stat_activity` at 3am.
124
+ */
125
+ function poolMaxFromEnv(): Partial<PoolProfile> {
126
+ const raw = process.env[POOL_MAX_ENV];
127
+ if (raw === undefined || raw.trim() === '') return {};
128
+ const max = Number(raw);
129
+ if (!Number.isSafeInteger(max) || max < 1) throw poolMaxInvalid(raw);
130
+ return { max };
131
+ }
132
+
52
133
  /** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */
53
134
  interface BunSqlReserved {
54
135
  unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
@@ -103,10 +184,57 @@ function rowsOf<T>(result: unknown): readonly T[] {
103
184
  return Array.isArray(result) ? (result as readonly T[]) : [];
104
185
  }
105
186
 
187
+ // The command tag only when it counted something, exactly like `rowsOf` in `pglite.ts` — one rule
188
+ // across both drivers, so `execute()` and the observer's event cannot answer differently for the
189
+ // same statement depending on which database is behind them. A driver that tags a read `0` while
190
+ // returning rows would otherwise report 0 here and the row count there.
106
191
  function affectedBy(result: unknown): number {
107
192
  if (!Array.isArray(result)) return 0;
108
193
  const count = (result as { count?: unknown }).count;
109
- return typeof count === 'number' ? count : result.length;
194
+ return typeof count === 'number' && count > 0 ? count : result.length;
195
+ }
196
+
197
+ /**
198
+ * `pool.reserve()` under a deadline. Without one an exhausted pool does not fail, it **queues** —
199
+ * so a slow endpoint filling all 20 slots turns every later request, `/readyz`'s `select 1`
200
+ * included, into a wait with no end and no error, and the pod is killed for being unready rather
201
+ * than answering 503 for the requests it cannot serve.
202
+ *
203
+ * The losing reservation is released, never dropped: the pool hands out a connection whenever one
204
+ * frees, deadline or no deadline, and a pin nobody holds is a connection nobody gets back. That is
205
+ * the whole reason this is not a bare `Promise.race`.
206
+ */
207
+ async function reserveWithin(
208
+ pool: Pick<BunSqlDriver, 'reserve'>,
209
+ profile: PoolProfile,
210
+ ): Promise<BunSqlReserved> {
211
+ const budget = profile.acquireTimeoutMs;
212
+ if (budget <= 0) return pool.reserve();
213
+ let timer: ReturnType<typeof setTimeout> | undefined;
214
+ let expired = false;
215
+ const pending = pool.reserve();
216
+ try {
217
+ return await Promise.race([
218
+ pending,
219
+ new Promise<never>((_resolve, reject) => {
220
+ timer = setTimeout(() => {
221
+ expired = true;
222
+ reject(poolAcquireTimeout(budget, profile.max));
223
+ }, budget);
224
+ // The deadline must not be what keeps a finished process alive.
225
+ timer.unref?.();
226
+ }),
227
+ ]);
228
+ } finally {
229
+ if (timer !== undefined) clearTimeout(timer);
230
+ // Attached unconditionally so a rejection arriving after we gave up is handled, not unhandled.
231
+ void pending.then(
232
+ (late) => {
233
+ if (expired) late.release();
234
+ },
235
+ () => undefined,
236
+ );
237
+ }
110
238
  }
111
239
 
112
240
  export interface PostgresClient extends ReservableClient {
@@ -129,17 +257,76 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
129
257
  return driver;
130
258
  }
131
259
 
132
- async function runOn(
260
+ /** The send itself: one statement on one handle, every driver failure typed on the way out. */
261
+ async function sendOn(
133
262
  driver: Pick<BunSqlDriver, 'unsafe'>,
134
263
  fragment: SqlFragment,
135
264
  ): Promise<unknown> {
136
265
  try {
137
266
  return await driver.unsafe(fragment.text, fragment.values);
138
267
  } catch (error) {
139
- throw dbUnavailable(`statement failed: ${fragment.text.slice(0, 120)}`, error);
268
+ // `driverError`, not `dbUnavailable`: the SQLSTATE has always been on this error and nothing
269
+ // read it, so a `23505` from two clicks racing a signup told the operator the database was
270
+ // unreachable and paged on-call for an outage that never happened. Everything the table does
271
+ // not classify is still `X_DB_UNAVAILABLE`, byte for byte.
272
+ throw driverError(`statement failed: ${fragment.text.slice(0, 120)}`, error);
140
273
  }
141
274
  }
142
275
 
276
+ /**
277
+ * The funnel — pooled and pinned statements both arrive here, which is why the observer hangs
278
+ * off this one function and nowhere else. Uninstalled it costs one property read and one
279
+ * branch: no clock read, no span, no event object, and `sendOn` receives exactly the call `runOn`
280
+ * made before the seam existed (axiom 6).
281
+ */
282
+ async function runOn(
283
+ driver: Pick<BunSqlDriver, 'unsafe'>,
284
+ fragment: SqlFragment,
285
+ ): Promise<unknown> {
286
+ const observer = statementObserver();
287
+ if (observer === undefined) return sendOn(driver, fragment);
288
+ // Read here, not by the consumer: the scope is gone by the time a per-request detector judges
289
+ // what it collected, so the reason has to be captured with the statement it defends.
290
+ const expected = expectedQueryLoopReason();
291
+ // Same moment, same argument: `postgresRepo` is several frames and a microtask above this one,
292
+ // and what it knows — the entity and the operation — is what turns fifty identical `select`s
293
+ // into "50× findById on members". Absent for hand-written SQL, a migration, a health probe.
294
+ const attribution = statementAttribution();
295
+ const started = performance.now();
296
+ let result: unknown;
297
+ try {
298
+ // The span wraps the send and nothing else, so its duration is the statement's and the
299
+ // observer's own work is not charged to the database.
300
+ result = await withStatementSpan(fragment.text, () => sendOn(driver, fragment));
301
+ } catch (error) {
302
+ // A statement that failed is still a statement: fifty identical timeouts are an N+1 of
303
+ // timeouts. The error is already `X_DB_UNAVAILABLE`, so the event carries what the caller
304
+ // is about to be thrown — and an observer that throws here replaces it, which is why
305
+ // `observe.ts` says a reporting-only observer must not throw.
306
+ observer.onStatement({
307
+ text: fragment.text,
308
+ values: fragment.values,
309
+ durationMs: performance.now() - started,
310
+ rows: 0,
311
+ error,
312
+ attribution,
313
+ expected,
314
+ });
315
+ throw error;
316
+ }
317
+ // Outside the `try` deliberately: a throw from `onStatement` is the observer's, not the
318
+ // database's, and catching it above would report a statement that succeeded as failed.
319
+ observer.onStatement({
320
+ text: fragment.text,
321
+ values: fragment.values,
322
+ durationMs: performance.now() - started,
323
+ rows: affectedBy(result),
324
+ attribution,
325
+ expected,
326
+ });
327
+ return result;
328
+ }
329
+
143
330
  async function run(fragment: SqlFragment): Promise<unknown> {
144
331
  return runOn(connect(), fragment);
145
332
  }
@@ -164,30 +351,52 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
164
351
  const pool = connect();
165
352
  let reserved: BunSqlReserved;
166
353
  try {
167
- reserved = await pool.reserve();
354
+ reserved = await reserveWithin(pool, profile);
168
355
  } catch (error) {
169
356
  // Acquiring the pin is the one step that runs outside `runOn`, so an exhausted or
170
357
  // unreachable pool would escape as an untyped driver error — and `readOnlyQuery` reaches
171
358
  // 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);
359
+ // other than X_DB_UNAVAILABLE. Our own deadline is already typed; only the driver's own
360
+ // failure needs classifying, and `53300` from the server lands as X_DB_POOL_EXHAUSTED too.
361
+ if (error instanceof DbError) throw error;
362
+ throw driverError('could not reserve a connection from the pool', error);
174
363
  }
364
+ let held = true;
365
+ // Direct only while the pin is held. `release()` hands this physical connection back, and
366
+ // the pool may already have given it to another unit of work mid-transaction — a statement
367
+ // issued on the stale handle would land inside theirs, committed or rolled back with it and
368
+ // no error anywhere to explain the row. So a late statement takes its own connection out of
369
+ // the pool, exactly like any other caller. Same rule as `pglite.ts`, one driver down.
370
+ const on = (fragment: SqlFragment): Promise<unknown> =>
371
+ held ? runOn(reserved, fragment) : run(fragment);
372
+ // Idempotent because two owners already exist on one exit path: `withTransaction` releases
373
+ // in a `finally` and `[Symbol.dispose]` fires on the same scope. A second `release()` on a
374
+ // handle already back in the pool frees whoever holds that connection now.
375
+ const release = (): void => {
376
+ if (!held) return;
377
+ held = false;
378
+ reserved.release();
379
+ };
175
380
  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
- },
381
+ query: async <T>(fragment: SqlFragment) => rowsOf<T>(await on(fragment)),
382
+ one: async <T>(fragment: SqlFragment) => rowsOf<T>(await on(fragment))[0] ?? null,
383
+ execute: async (fragment: SqlFragment) => affectedBy(await on(fragment)),
384
+ release,
385
+ [Symbol.dispose]: release,
183
386
  };
184
387
  },
185
388
  async ping(): Promise<void> {
186
389
  await client.query(sql`select 1`);
187
390
  },
188
391
  async close(): Promise<void> {
189
- await driver?.close();
392
+ // Read-then-clear, the same shape as `pglite.ts`: a `close()` that rejects has still torn
393
+ // the pool down, so caching it would hand the next `connect()` a corpse and every statement
394
+ // after it would fail for a reason no caller can see. Clearing first also means a
395
+ // `connect()` racing the await opens a fresh pool instead of joining the one draining. The
396
+ // rejection still reaches the caller — a shutdown that could not drain wants to know.
397
+ const pool = driver;
190
398
  driver = undefined;
399
+ await pool?.close();
191
400
  },
192
401
  };
193
402
  return client;
@@ -200,9 +409,16 @@ export function setDbClient(client: DbClient | undefined): void {
200
409
  ambient = client;
201
410
  }
202
411
 
203
- /** The pool, ignoring any open transaction. `withTransaction` must not re-enter `db()`. */
412
+ /**
413
+ * The pool, ignoring any open transaction. `withTransaction` must not re-enter `db()`.
414
+ *
415
+ * The role default is layered under `DATABASE_POOL_MAX`, because this is the one place the process
416
+ * builds its own client and therefore the only place an operator's value can reach one:
417
+ * `createPostgresClient` has always taken a `profile` override and nothing in a running app passed
418
+ * it, so `POOL_PROFILES` was the last word in a deployed image.
419
+ */
204
420
  export function baseClient(): DbClient {
205
- if (ambient === undefined) ambient = createPostgresClient();
421
+ if (ambient === undefined) ambient = createPostgresClient({ profile: poolMaxFromEnv() });
206
422
  return ambient;
207
423
  }
208
424
 
@@ -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;