@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/CLAUDE.md +618 -0
- package/README.md +159 -21
- package/package.json +3 -2
- package/src/attribution.ts +45 -0
- package/src/branch.ts +21 -5
- package/src/client.ts +259 -31
- package/src/destructive.ts +126 -0
- package/src/drift.ts +263 -14
- package/src/errors.ts +256 -13
- package/src/expected-loop.ts +53 -0
- package/src/fake-pglite.ts +32 -0
- package/src/fake-reservable.ts +50 -0
- package/src/fake.ts +16 -2
- package/src/foreign-key.ts +41 -0
- package/src/generate.ts +192 -39
- package/src/index.ts +38 -11
- package/src/introspect.ts +34 -8
- package/src/libpq-options.ts +76 -0
- package/src/migrate.ts +283 -64
- package/src/observe.ts +90 -0
- package/src/pglite-branch.ts +2 -1
- package/src/pglite-turns.ts +13 -10
- package/src/pglite.ts +85 -15
- package/src/readonly-query.ts +20 -8
- package/src/snapshot-json.ts +84 -0
- package/src/snapshot-parse.ts +99 -0
- package/src/sql-noise.ts +40 -0
- package/src/sql-scan.ts +159 -0
- package/src/sqlstate.ts +107 -0
- package/src/statement-shape.ts +58 -0
- package/src/statement-span.ts +40 -0
- package/src/statement-split.ts +51 -0
- package/src/transaction.ts +138 -16
- package/src/type-pins.ts +29 -0
- package/src/readonly.ts +0 -111
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 {
|
|
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
|
-
/**
|
|
19
|
-
|
|
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: {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
177
|
-
one: async <T>(fragment: SqlFragment) =>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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;
|