@ultimat3/db 16.0.0 → 18.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 +170 -12
- package/README.md +4 -3
- package/package.json +3 -3
- package/src/array-parameter.ts +91 -0
- package/src/bun-sql.ts +32 -0
- package/src/client.ts +17 -321
- package/src/connection-url.ts +102 -0
- package/src/db-health.ts +31 -0
- package/src/default-client.ts +2 -1
- package/src/drift-errors.ts +36 -0
- package/src/drift-findings.ts +63 -6
- package/src/drift-fixtures.ts +23 -0
- package/src/errors.ts +0 -9
- package/src/generate.ts +40 -2
- package/src/index.ts +15 -15
- package/src/introspect.ts +16 -0
- package/src/migrate.ts +19 -11
- package/src/pool-profile.ts +125 -0
- package/src/pool-reserve.ts +50 -0
- package/src/readonly-query.ts +21 -8
- package/src/replica-client.ts +16 -3
- package/src/replica-identity.ts +84 -0
- package/src/snapshot-parse.ts +11 -2
- package/src/sql.ts +36 -0
- package/src/statement-funnel.ts +101 -0
- package/src/ungeneratable.ts +11 -0
package/src/client.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
// Single responsibility: the Postgres
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
1
|
+
// Single responsibility: the pooled Postgres client and the ambient `db()` handle — the lazy
|
|
2
|
+
// connect, the reserved-connection pin, and the process-wide client every repository reaches
|
|
3
|
+
// through. Sizing lives in `pool-profile.ts`, the connection string in `connection-url.ts`, the
|
|
4
|
+
// `Bun.SQL` slice in `bun-sql.ts` and the observed statement funnel in `statement-funnel.ts`, so
|
|
5
|
+
// importing this module never opens a socket.
|
|
6
|
+
|
|
7
|
+
import { type Role, resolveRole } from '@ultimat3/core';
|
|
8
|
+
import { type BunSqlDriver, type BunSqlReserved, bunSqlFactory } from './bun-sql';
|
|
9
|
+
import { connectionUrl } from './connection-url';
|
|
8
10
|
// Deliberate cycle, the same shape as `client.ts ⇄ transaction.ts`: nothing here is referenced at
|
|
9
11
|
// module evaluation, and both sides are `function` declarations, so hoisting covers the TDZ.
|
|
10
12
|
import { defaultClient } from './default-client';
|
|
11
|
-
import { DbError,
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { statementObserver } from './observe';
|
|
13
|
+
import { DbError, driverError } from './errors';
|
|
14
|
+
import { assertPoolProfile, type PoolProfile, poolProfileFor } from './pool-profile';
|
|
15
|
+
import { reserveWithin } from './pool-reserve';
|
|
15
16
|
import { type SqlFragment, sql } from './sql';
|
|
16
|
-
import {
|
|
17
|
+
import { affectedBy, rowsOf, runOn } from './statement-funnel';
|
|
17
18
|
import { currentTx } from './transaction';
|
|
18
19
|
|
|
19
20
|
export interface DbClient {
|
|
@@ -41,123 +42,6 @@ export function isReservable(client: DbClient): client is ReservableClient {
|
|
|
41
42
|
return typeof (client as Partial<ReservableClient>).reserve === 'function';
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
export interface PoolProfile {
|
|
45
|
-
readonly max: number;
|
|
46
|
-
/** 0 disables the timeout — only `migrate`, which is allowed to take as long as it takes. */
|
|
47
|
-
readonly statementTimeoutMs: number;
|
|
48
|
-
readonly idleTimeoutMs: number;
|
|
49
|
-
/**
|
|
50
|
-
* How long a statement may **wait for a lock** before `55P03`, distinct from how long it may run.
|
|
51
|
-
* 0 everywhere but `migrate`, which is the only role that takes `ACCESS EXCLUSIVE`: an `alter
|
|
52
|
-
* table` queued behind a long `SELECT` puts every later query on that table behind it too,
|
|
53
|
-
* because Postgres' lock queue is FIFO — and `migrate` runs `statement_timeout = 0`, so nothing
|
|
54
|
-
* else would ever end the wait. Read by `migrate()` as a `SET LOCAL`, never by the pool.
|
|
55
|
-
*/
|
|
56
|
-
readonly lockTimeoutMs: number;
|
|
57
|
-
/**
|
|
58
|
-
* How long `reserve()` may wait for a free connection before `X_DB_POOL_EXHAUSTED`. 0 waits
|
|
59
|
-
* forever, which is what a run-once role wants and what a request-serving one must never do:
|
|
60
|
-
* queueing turns exhaustion into a hang, `/readyz`'s `select 1` joins the same queue, the kubelet
|
|
61
|
-
* kills the pod, and the replacement inherits the same saturated database.
|
|
62
|
-
*/
|
|
63
|
-
readonly acquireTimeoutMs: number;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */
|
|
67
|
-
export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
|
|
68
|
-
web: {
|
|
69
|
-
max: 20,
|
|
70
|
-
statementTimeoutMs: 10_000,
|
|
71
|
-
idleTimeoutMs: 30_000,
|
|
72
|
-
lockTimeoutMs: 0,
|
|
73
|
-
acquireTimeoutMs: 5_000,
|
|
74
|
-
},
|
|
75
|
-
sync: {
|
|
76
|
-
max: 10,
|
|
77
|
-
statementTimeoutMs: 10_000,
|
|
78
|
-
idleTimeoutMs: 60_000,
|
|
79
|
-
lockTimeoutMs: 0,
|
|
80
|
-
acquireTimeoutMs: 5_000,
|
|
81
|
-
},
|
|
82
|
-
worker: {
|
|
83
|
-
max: 8,
|
|
84
|
-
statementTimeoutMs: 120_000,
|
|
85
|
-
idleTimeoutMs: 30_000,
|
|
86
|
-
lockTimeoutMs: 0,
|
|
87
|
-
acquireTimeoutMs: 10_000,
|
|
88
|
-
},
|
|
89
|
-
scheduler: {
|
|
90
|
-
max: 2,
|
|
91
|
-
statementTimeoutMs: 15_000,
|
|
92
|
-
idleTimeoutMs: 60_000,
|
|
93
|
-
lockTimeoutMs: 0,
|
|
94
|
-
acquireTimeoutMs: 10_000,
|
|
95
|
-
},
|
|
96
|
-
// `migrate` waits: its pool is `max: 1` and the advisory-lock pin holds it for the whole run, so
|
|
97
|
-
// a deadline here would refuse the migration's own session. The wait that needed bounding is the
|
|
98
|
-
// advisory lock's, and `MIGRATION_LOCK_WAIT_MS` bounds it.
|
|
99
|
-
migrate: {
|
|
100
|
-
max: 1,
|
|
101
|
-
statementTimeoutMs: 0,
|
|
102
|
-
idleTimeoutMs: 10_000,
|
|
103
|
-
lockTimeoutMs: 3_000,
|
|
104
|
-
acquireTimeoutMs: 0,
|
|
105
|
-
},
|
|
106
|
-
replicator: {
|
|
107
|
-
max: 4,
|
|
108
|
-
statementTimeoutMs: 0,
|
|
109
|
-
idleTimeoutMs: 60_000,
|
|
110
|
-
lockTimeoutMs: 0,
|
|
111
|
-
acquireTimeoutMs: 0,
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
export function poolProfileFor(role: Role = resolveRole()): PoolProfile {
|
|
116
|
-
return POOL_PROFILES[role];
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** The one pool knob an operator can turn without a rebuild. Layered over the role default. */
|
|
120
|
-
export const POOL_MAX_ENV = 'DATABASE_POOL_MAX';
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* `DATABASE_POOL_MAX`, or nothing. `POOL_PROFILES` is frozen into the build, so before this the
|
|
124
|
-
* only way to change a fleet's connection count was to ship a new image — and 400 `web` pods at
|
|
125
|
-
* `max: 20` is 8,000 backends against a `max_connections` of 450. An unparseable value **refuses**
|
|
126
|
-
* rather than falling back: a fleet that ignored the number it was given is the failure the
|
|
127
|
-
* variable exists to prevent, and it would only be found in `pg_stat_activity` at 3am.
|
|
128
|
-
*/
|
|
129
|
-
export function poolMaxFromEnv(): Partial<PoolProfile> {
|
|
130
|
-
const raw = process.env[POOL_MAX_ENV];
|
|
131
|
-
if (raw === undefined || raw.trim() === '') return {};
|
|
132
|
-
const max = Number(raw);
|
|
133
|
-
if (!Number.isSafeInteger(max) || max < 1) throw poolMaxInvalid(raw);
|
|
134
|
-
return { max };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */
|
|
138
|
-
interface BunSqlReserved {
|
|
139
|
-
unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
|
|
140
|
-
release(): void;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** The slice of `Bun.SQL` we use. Declared structurally so this package has no dependency. */
|
|
144
|
-
interface BunSqlDriver {
|
|
145
|
-
unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
|
|
146
|
-
reserve(): Promise<BunSqlReserved>;
|
|
147
|
-
close(options?: { readonly timeout?: number }): Promise<void>;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
type BunSqlFactory = new (url: string, options?: Readonly<Record<string, unknown>>) => BunSqlDriver;
|
|
151
|
-
|
|
152
|
-
function bunSqlFactory(): BunSqlFactory {
|
|
153
|
-
const host = globalThis as unknown as { readonly Bun?: { readonly SQL?: unknown } };
|
|
154
|
-
const factory = host.Bun?.SQL;
|
|
155
|
-
if (typeof factory !== 'function') {
|
|
156
|
-
throw dbUnavailable('Bun.SQL is unavailable — this package requires Bun >= 1.3');
|
|
157
|
-
}
|
|
158
|
-
return factory as BunSqlFactory;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
45
|
export interface PostgresClientOptions {
|
|
162
46
|
readonly url?: string | undefined;
|
|
163
47
|
readonly role?: Role | undefined;
|
|
@@ -165,103 +49,6 @@ export interface PostgresClientOptions {
|
|
|
165
49
|
readonly applicationName?: string | undefined;
|
|
166
50
|
}
|
|
167
51
|
|
|
168
|
-
function connectionUrl(options: PostgresClientOptions, profile: PoolProfile): string {
|
|
169
|
-
const raw = options.url ?? process.env['DATABASE_URL'];
|
|
170
|
-
if (raw === undefined || raw === '') {
|
|
171
|
-
throw dbUnavailable('DATABASE_URL is not set, so there is no database to connect to');
|
|
172
|
-
}
|
|
173
|
-
let url: URL;
|
|
174
|
-
try {
|
|
175
|
-
url = new URL(raw);
|
|
176
|
-
} catch (error) {
|
|
177
|
-
throw dbUnavailable(`DATABASE_URL is not a valid url: ${raw}`, error);
|
|
178
|
-
}
|
|
179
|
-
// libpq `options` is the portable way to pin a statement timeout for every pooled connection —
|
|
180
|
-
// MERGED into the operator's own, never assigned over it, and emitted for every role including
|
|
181
|
-
// the two whose bound is 0. `set` here dropped a `?options=-c search_path=app` on `web`, `sync`,
|
|
182
|
-
// `worker` and `scheduler` and kept it on `migrate` and `replicator`, so the role that runs the
|
|
183
|
-
// migrations and the role that serves the traffic read different schemas. 0 is a value, not a
|
|
184
|
-
// silence: it is `migrate` saying it may take as long as it takes, and left unsaid a server-side
|
|
185
|
-
// `alter database ... set statement_timeout` kills the one role that must outlive it.
|
|
186
|
-
// `application_name` is a LABEL, not a bound: 'ultimate' is a DEFAULT, and a default may not
|
|
187
|
-
// overwrite what the operator wrote — `?application_name=billing-api` is the filter their
|
|
188
|
-
// `pg_stat_activity` query, their pooler rule and their audit rule all match on, and losing it
|
|
189
|
-
// is silent. Both spellings count, or the URL parameter and a `-c application_name=` in
|
|
190
|
-
// `options` disagree and which one the backend honours is argument order nobody here measured.
|
|
191
|
-
const named = options.applicationName;
|
|
192
|
-
const settings: Record<string, string> = {
|
|
193
|
-
statement_timeout: String(profile.statementTimeoutMs),
|
|
194
|
-
};
|
|
195
|
-
const inOptions = declaresLibpqOption(url.searchParams.get('options'), 'application_name');
|
|
196
|
-
// An explicit `applicationName` is a deliberate call by the role that opened the pool, so it
|
|
197
|
-
// wins. Only then is the setting named to the merge, and only when the operator wrote the other
|
|
198
|
-
// spelling: `mergeLibpqOptions` drops their assignment before appending, so the two cannot
|
|
199
|
-
// disagree — and a URL with no assignment in it keeps the exact `options` it always had.
|
|
200
|
-
if (named !== undefined && inOptions) settings['application_name'] = named;
|
|
201
|
-
const declared = url.searchParams.has('application_name') || inOptions;
|
|
202
|
-
url.searchParams.set('options', mergeLibpqOptions(url.searchParams.get('options'), settings));
|
|
203
|
-
if (named !== undefined) url.searchParams.set('application_name', named);
|
|
204
|
-
else if (!declared) url.searchParams.set('application_name', 'ultimate');
|
|
205
|
-
return url.toString();
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function rowsOf<T>(result: unknown): readonly T[] {
|
|
209
|
-
return Array.isArray(result) ? (result as readonly T[]) : [];
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// The command tag only when it counted something, exactly like `rowsOf` in `pglite.ts` — one rule
|
|
213
|
-
// across both drivers, so `execute()` and the observer's event cannot answer differently for the
|
|
214
|
-
// same statement depending on which database is behind them. A driver that tags a read `0` while
|
|
215
|
-
// returning rows would otherwise report 0 here and the row count there.
|
|
216
|
-
function affectedBy(result: unknown): number {
|
|
217
|
-
if (!Array.isArray(result)) return 0;
|
|
218
|
-
const count = (result as { count?: unknown }).count;
|
|
219
|
-
return typeof count === 'number' && count > 0 ? count : result.length;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* `pool.reserve()` under a deadline. Without one an exhausted pool does not fail, it **queues** —
|
|
224
|
-
* so a slow endpoint filling all 20 slots turns every later request, `/readyz`'s `select 1`
|
|
225
|
-
* included, into a wait with no end and no error, and the pod is killed for being unready rather
|
|
226
|
-
* than answering 503 for the requests it cannot serve.
|
|
227
|
-
*
|
|
228
|
-
* The losing reservation is released, never dropped: the pool hands out a connection whenever one
|
|
229
|
-
* frees, deadline or no deadline, and a pin nobody holds is a connection nobody gets back. That is
|
|
230
|
-
* the whole reason this is not a bare `Promise.race`.
|
|
231
|
-
*/
|
|
232
|
-
async function reserveWithin(
|
|
233
|
-
pool: Pick<BunSqlDriver, 'reserve'>,
|
|
234
|
-
profile: PoolProfile,
|
|
235
|
-
): Promise<BunSqlReserved> {
|
|
236
|
-
const budget = profile.acquireTimeoutMs;
|
|
237
|
-
if (budget <= 0) return pool.reserve();
|
|
238
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
239
|
-
let expired = false;
|
|
240
|
-
const pending = pool.reserve();
|
|
241
|
-
try {
|
|
242
|
-
return await Promise.race([
|
|
243
|
-
pending,
|
|
244
|
-
new Promise<never>((_resolve, reject) => {
|
|
245
|
-
timer = setTimeout(() => {
|
|
246
|
-
expired = true;
|
|
247
|
-
reject(poolAcquireTimeout(budget, profile.max));
|
|
248
|
-
}, budget);
|
|
249
|
-
// The deadline must not be what keeps a finished process alive.
|
|
250
|
-
timer.unref?.();
|
|
251
|
-
}),
|
|
252
|
-
]);
|
|
253
|
-
} finally {
|
|
254
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
255
|
-
// Attached unconditionally so a rejection arriving after we gave up is handled, not unhandled.
|
|
256
|
-
void pending.then(
|
|
257
|
-
(late) => {
|
|
258
|
-
if (expired) late.release();
|
|
259
|
-
},
|
|
260
|
-
() => undefined,
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
52
|
export interface PostgresClient extends ReservableClient {
|
|
266
53
|
readonly profile: PoolProfile;
|
|
267
54
|
ping(): Promise<void>;
|
|
@@ -271,7 +58,10 @@ export interface PostgresClient extends ReservableClient {
|
|
|
271
58
|
/** Lazily connects: the pool opens on the first statement, never at import. */
|
|
272
59
|
export function createPostgresClient(options: PostgresClientOptions = {}): PostgresClient {
|
|
273
60
|
const role = options.role ?? resolveRole();
|
|
274
|
-
const profile: PoolProfile =
|
|
61
|
+
const profile: PoolProfile = assertPoolProfile({
|
|
62
|
+
...poolProfileFor(role),
|
|
63
|
+
...(options.profile ?? {}),
|
|
64
|
+
});
|
|
275
65
|
let driver: BunSqlDriver | undefined;
|
|
276
66
|
|
|
277
67
|
function connect(): BunSqlDriver {
|
|
@@ -282,76 +72,6 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
282
72
|
return driver;
|
|
283
73
|
}
|
|
284
74
|
|
|
285
|
-
/** The send itself: one statement on one handle, every driver failure typed on the way out. */
|
|
286
|
-
async function sendOn(
|
|
287
|
-
driver: Pick<BunSqlDriver, 'unsafe'>,
|
|
288
|
-
fragment: SqlFragment,
|
|
289
|
-
): Promise<unknown> {
|
|
290
|
-
try {
|
|
291
|
-
return await driver.unsafe(fragment.text, fragment.values);
|
|
292
|
-
} catch (error) {
|
|
293
|
-
// `driverError`, not `dbUnavailable`: the SQLSTATE has always been on this error and nothing
|
|
294
|
-
// read it, so a `23505` from two clicks racing a signup told the operator the database was
|
|
295
|
-
// unreachable and paged on-call for an outage that never happened. Everything the table does
|
|
296
|
-
// not classify is still `X_DB_UNAVAILABLE`, byte for byte.
|
|
297
|
-
throw driverError(`statement failed: ${fragment.text.slice(0, 120)}`, error);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* The funnel — pooled and pinned statements both arrive here, which is why the observer hangs
|
|
303
|
-
* off this one function and nowhere else. Uninstalled it costs one property read and one
|
|
304
|
-
* branch: no clock read, no span, no event object, and `sendOn` receives exactly the call `runOn`
|
|
305
|
-
* made before the seam existed (axiom 6).
|
|
306
|
-
*/
|
|
307
|
-
async function runOn(
|
|
308
|
-
driver: Pick<BunSqlDriver, 'unsafe'>,
|
|
309
|
-
fragment: SqlFragment,
|
|
310
|
-
): Promise<unknown> {
|
|
311
|
-
const observer = statementObserver();
|
|
312
|
-
if (observer === undefined) return sendOn(driver, fragment);
|
|
313
|
-
// Read here, not by the consumer: the scope is gone by the time a per-request detector judges
|
|
314
|
-
// what it collected, so the reason has to be captured with the statement it defends.
|
|
315
|
-
const expected = expectedQueryLoopReason();
|
|
316
|
-
// Same moment, same argument: `postgresRepo` is several frames and a microtask above this one,
|
|
317
|
-
// and what it knows — the entity and the operation — is what turns fifty identical `select`s
|
|
318
|
-
// into "50× findById on members". Absent for hand-written SQL, a migration, a health probe.
|
|
319
|
-
const attribution = statementAttribution();
|
|
320
|
-
const started = performance.now();
|
|
321
|
-
let result: unknown;
|
|
322
|
-
try {
|
|
323
|
-
// The span wraps the send and nothing else, so its duration is the statement's and the
|
|
324
|
-
// observer's own work is not charged to the database.
|
|
325
|
-
result = await withStatementSpan(fragment.text, () => sendOn(driver, fragment));
|
|
326
|
-
} catch (error) {
|
|
327
|
-
// A statement that failed is still a statement: fifty identical timeouts are an N+1 of
|
|
328
|
-
// timeouts. The error is already `X_DB_UNAVAILABLE`, so the event carries what the caller
|
|
329
|
-
// is about to be thrown — and an observer that throws here replaces it, which is why
|
|
330
|
-
// `observe.ts` says a reporting-only observer must not throw.
|
|
331
|
-
observer.onStatement({
|
|
332
|
-
text: fragment.text,
|
|
333
|
-
values: fragment.values,
|
|
334
|
-
durationMs: performance.now() - started,
|
|
335
|
-
rows: 0,
|
|
336
|
-
error,
|
|
337
|
-
attribution,
|
|
338
|
-
expected,
|
|
339
|
-
});
|
|
340
|
-
throw error;
|
|
341
|
-
}
|
|
342
|
-
// Outside the `try` deliberately: a throw from `onStatement` is the observer's, not the
|
|
343
|
-
// database's, and catching it above would report a statement that succeeded as failed.
|
|
344
|
-
observer.onStatement({
|
|
345
|
-
text: fragment.text,
|
|
346
|
-
values: fragment.values,
|
|
347
|
-
durationMs: performance.now() - started,
|
|
348
|
-
rows: affectedBy(result),
|
|
349
|
-
attribution,
|
|
350
|
-
expected,
|
|
351
|
-
});
|
|
352
|
-
return result;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
75
|
async function run(fragment: SqlFragment): Promise<unknown> {
|
|
356
76
|
return runOn(connect(), fragment);
|
|
357
77
|
}
|
|
@@ -455,27 +175,3 @@ export function baseClient(): DbClient {
|
|
|
455
175
|
export function db(): DbClient {
|
|
456
176
|
return currentTx() ?? baseClient();
|
|
457
177
|
}
|
|
458
|
-
|
|
459
|
-
/** Named `Db*` because `@ultimat3/core` already exports a `HealthReport` for the lifecycle. */
|
|
460
|
-
export interface DbHealthReport {
|
|
461
|
-
readonly ok: boolean;
|
|
462
|
-
readonly latencyMs: number;
|
|
463
|
-
readonly error?: string | undefined;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** Backs `/readyz` for every role. Never throws — the probe wants a report, not an exception. */
|
|
467
|
-
export async function checkDb(client: DbClient = baseClient()): Promise<DbHealthReport> {
|
|
468
|
-
const started = performance.now();
|
|
469
|
-
try {
|
|
470
|
-
await client.query(sql`select 1`);
|
|
471
|
-
return { ok: true, latencyMs: Math.round(performance.now() - started) };
|
|
472
|
-
} catch (error) {
|
|
473
|
-
return {
|
|
474
|
-
ok: false,
|
|
475
|
-
latencyMs: Math.round(performance.now() - started),
|
|
476
|
-
// `renderThrowable`, never `error.message`: the probe wants a report, and a render that
|
|
477
|
-
// throws is an exception out of `/readyz` — the one caller that cannot catch it.
|
|
478
|
-
error: renderThrowable(error),
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Single responsibility: turning `DATABASE_URL` plus a resolved pool profile into the connection
|
|
2
|
+
// string the driver opens — the libpq `options` merge and the `application_name` label. Split from
|
|
3
|
+
// `client.ts` because which settings reach a connection is a rule, not a step of connecting.
|
|
4
|
+
|
|
5
|
+
import { describeValue } from '@ultimat3/core';
|
|
6
|
+
import { DbError, dbUnavailable } from './errors';
|
|
7
|
+
import { declaresLibpqOption, mergeLibpqOptions } from './libpq-options';
|
|
8
|
+
import type { PoolProfile } from './pool-profile';
|
|
9
|
+
|
|
10
|
+
export interface ConnectionUrlOptions {
|
|
11
|
+
readonly url?: string | undefined;
|
|
12
|
+
readonly applicationName?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The schemes on which `Bun.SQL` opens a POSTGRES connection — measured against bun 1.4.0, never
|
|
17
|
+
* assumed. `postgres:` and `postgresql:` both answer `adapter: 'postgres'`; `pg:`, `tcp:` and
|
|
18
|
+
* `postgresql+ssl:` are refused by the driver itself (`Unsupported protocol: … Supported adapters:
|
|
19
|
+
* "postgres", "sqlite", "mysql", "mariadb"`), so excluding them costs a capability nobody has.
|
|
20
|
+
* The two that matter are the ones the driver ACCEPTS and this package cannot speak: `mysql:`,
|
|
21
|
+
* `mariadb:`, `sqlite:` and `file:` open a different engine, and every statement generated here is
|
|
22
|
+
* Postgres — a pool that connects and then answers a syntax error to the migration is strictly
|
|
23
|
+
* worse than one that refuses at boot.
|
|
24
|
+
*/
|
|
25
|
+
const POSTGRES_SCHEMES: ReadonlySet<string> = new Set(['postgres:', 'postgresql:']);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The received scheme is deliberately NOT named, which is the one place this refusal differs from
|
|
29
|
+
* every other "errors are instructions" case in the package. `new URL()` accepts a scheme-less
|
|
30
|
+
* string by reading the first token as the scheme, and that token is exactly the value this must
|
|
31
|
+
* not echo: `db.internal:5432/app` parses with `db.internal:` — the HOST — as its protocol, and
|
|
32
|
+
* `app:hunter2@db.internal/app`, the same typo copied one dashboard field over, parses with the
|
|
33
|
+
* USERNAME as its protocol. Naming "the scheme" therefore puts a host or a credential in the boot
|
|
34
|
+
* log AND the `--json` payload, where the logger has no key left to redact it by (see the block in
|
|
35
|
+
* the parse `catch` below). The REQUIRED scheme is a constant and carries the whole instruction, so
|
|
36
|
+
* nothing actionable is lost by withholding the received one; `describeValue` keeps the shape, so
|
|
37
|
+
* an empty variable is still told apart from a truncated one.
|
|
38
|
+
*/
|
|
39
|
+
const schemeUnsupported = (raw: string): DbError =>
|
|
40
|
+
new DbError({
|
|
41
|
+
code: 'X_DB_UNAVAILABLE',
|
|
42
|
+
cause:
|
|
43
|
+
'DATABASE_URL does not name a postgres:// or postgresql:// url: ' +
|
|
44
|
+
`received ${describeValue(raw)}`,
|
|
45
|
+
fix:
|
|
46
|
+
'set DATABASE_URL to postgres://user@host:5432/database — a value with no scheme parses ' +
|
|
47
|
+
'as a url whose scheme is its own first token — or run `x dev` to use the embedded PGlite',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export function connectionUrl(options: ConnectionUrlOptions, profile: PoolProfile): string {
|
|
51
|
+
const raw = options.url ?? process.env['DATABASE_URL'];
|
|
52
|
+
if (raw === undefined || raw === '') {
|
|
53
|
+
throw dbUnavailable('DATABASE_URL is not set, so there is no database to connect to');
|
|
54
|
+
}
|
|
55
|
+
let url: URL;
|
|
56
|
+
try {
|
|
57
|
+
url = new URL(raw);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// The SHAPE of the rejected value, never the value. A connection string is
|
|
60
|
+
// `user:password@host` by construction, and this `cause` is the boot log line AND the `--json`
|
|
61
|
+
// payload — the logger redacts `fields` by key, so a password baked into a message has no key
|
|
62
|
+
// left to redact it by. `@ultimat3/core`'s `defineEnv` reached the identical conclusion about
|
|
63
|
+
// the identical variable (`packages/core/src/env.ts`); this is the same rule at the second
|
|
64
|
+
// reader, not a second rule. The value still has a printer: `x env check`, through
|
|
65
|
+
// `maskedEnvValues`.
|
|
66
|
+
throw dbUnavailable(`DATABASE_URL is not a valid url: received ${describeValue(raw)}`, error);
|
|
67
|
+
}
|
|
68
|
+
// Screened here because `Bun.SQL` will not screen it: measured on bun 1.4.0, it reads
|
|
69
|
+
// `db.internal:5432/app` as host `db.internal`, port 5432, database `app` and opens a Postgres
|
|
70
|
+
// pool on it, so the first symptom is a connect failure at the first QUERY, in another process
|
|
71
|
+
// phase, worded by the driver and naming neither `DATABASE_URL` nor the missing scheme. Worse,
|
|
72
|
+
// `sqlite://./dev.db` succeeds outright on a different engine. A boot-time refusal is the only
|
|
73
|
+
// point at which the value is still nameable.
|
|
74
|
+
if (!POSTGRES_SCHEMES.has(url.protocol)) throw schemeUnsupported(raw);
|
|
75
|
+
// libpq `options` is the portable way to pin a statement timeout for every pooled connection —
|
|
76
|
+
// MERGED into the operator's own, never assigned over it, and emitted for every role including
|
|
77
|
+
// the two whose bound is 0. `set` here dropped a `?options=-c search_path=app` on `web`, `sync`,
|
|
78
|
+
// `worker` and `scheduler` and kept it on `migrate` and `replicator`, so the role that runs the
|
|
79
|
+
// migrations and the role that serves the traffic read different schemas. 0 is a value, not a
|
|
80
|
+
// silence: it is `migrate` saying it may take as long as it takes, and left unsaid a server-side
|
|
81
|
+
// `alter database ... set statement_timeout` kills the one role that must outlive it.
|
|
82
|
+
// `application_name` is a LABEL, not a bound: 'ultimate' is a DEFAULT, and a default may not
|
|
83
|
+
// overwrite what the operator wrote — `?application_name=billing-api` is the filter their
|
|
84
|
+
// `pg_stat_activity` query, their pooler rule and their audit rule all match on, and losing it
|
|
85
|
+
// is silent. Both spellings count, or the URL parameter and a `-c application_name=` in
|
|
86
|
+
// `options` disagree and which one the backend honours is argument order nobody here measured.
|
|
87
|
+
const named = options.applicationName;
|
|
88
|
+
const settings: Record<string, string> = {
|
|
89
|
+
statement_timeout: String(profile.statementTimeoutMs),
|
|
90
|
+
};
|
|
91
|
+
const inOptions = declaresLibpqOption(url.searchParams.get('options'), 'application_name');
|
|
92
|
+
// An explicit `applicationName` is a deliberate call by the role that opened the pool, so it
|
|
93
|
+
// wins. Only then is the setting named to the merge, and only when the operator wrote the other
|
|
94
|
+
// spelling: `mergeLibpqOptions` drops their assignment before appending, so the two cannot
|
|
95
|
+
// disagree — and a URL with no assignment in it keeps the exact `options` it always had.
|
|
96
|
+
if (named !== undefined && inOptions) settings['application_name'] = named;
|
|
97
|
+
const declared = url.searchParams.has('application_name') || inOptions;
|
|
98
|
+
url.searchParams.set('options', mergeLibpqOptions(url.searchParams.get('options'), settings));
|
|
99
|
+
if (named !== undefined) url.searchParams.set('application_name', named);
|
|
100
|
+
else if (!declared) url.searchParams.set('application_name', 'ultimate');
|
|
101
|
+
return url.toString();
|
|
102
|
+
}
|
package/src/db-health.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Single responsibility: the database's readiness answer — one `select 1` timed and reported, never
|
|
2
|
+
// thrown. Split from `client.ts` because a probe's report is a different job from opening a pool,
|
|
3
|
+
// and every role's `/readyz` reads this one and nothing else of the client.
|
|
4
|
+
|
|
5
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { baseClient, type DbClient } from './client';
|
|
7
|
+
import { sql } from './sql';
|
|
8
|
+
|
|
9
|
+
/** Named `Db*` because `@ultimat3/core` already exports a `HealthReport` for the lifecycle. */
|
|
10
|
+
export interface DbHealthReport {
|
|
11
|
+
readonly ok: boolean;
|
|
12
|
+
readonly latencyMs: number;
|
|
13
|
+
readonly error?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Backs `/readyz` for every role. Never throws — the probe wants a report, not an exception. */
|
|
17
|
+
export async function checkDb(client: DbClient = baseClient()): Promise<DbHealthReport> {
|
|
18
|
+
const started = performance.now();
|
|
19
|
+
try {
|
|
20
|
+
await client.query(sql`select 1`);
|
|
21
|
+
return { ok: true, latencyMs: Math.round(performance.now() - started) };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
latencyMs: Math.round(performance.now() - started),
|
|
26
|
+
// `renderThrowable`, never `error.message`: the probe wants a report, and a render that
|
|
27
|
+
// throws is an exception out of `/readyz` — the one caller that cannot catch it.
|
|
28
|
+
error: renderThrowable(error),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/default-client.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// framework decides a process's database topology from the environment, and `client.ts` is at the
|
|
4
4
|
// line ceiling.
|
|
5
5
|
|
|
6
|
-
import { createPostgresClient, type DbClient
|
|
6
|
+
import { createPostgresClient, type DbClient } from './client';
|
|
7
|
+
import { poolMaxFromEnv } from './pool-profile';
|
|
7
8
|
import { replicatedClient } from './replica-client';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Single responsibility: the `X_DB_DRIFT` constructor — the error form of the finding
|
|
2
|
+
// `drift-findings.ts`'s `unexpectedColumn` reports, thrown where a caller has no report to hand
|
|
3
|
+
// back. Split out of `errors.ts` for the import it needs and nothing else: the fix line puts a
|
|
4
|
+
// column name into a shell command, so it screens through `shellInertIdentifier` (`sql.ts`) —
|
|
5
|
+
// and `sql.ts` imports `errors.ts`, so this cannot live there without a cycle around the module
|
|
6
|
+
// whose evaluation registers every code. The code is still declared, titled and registered in
|
|
7
|
+
// `errors.ts`, exactly as `migration-errors.ts` and `invariant-errors.ts` are.
|
|
8
|
+
|
|
9
|
+
import { DbError } from './errors';
|
|
10
|
+
import { shellInertIdentifier } from './sql';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync; that
|
|
14
|
+
* one screens the column through the same `@ultimat3/db` export, so the two lines are the same
|
|
15
|
+
* text on both sides of the tier seam.
|
|
16
|
+
*
|
|
17
|
+
* The column name is the CATALOG's, so it is data: whoever can add a column picks the text that
|
|
18
|
+
* lands here, and `x db gen "add C"` puts it inside SHELL DOUBLE QUOTES, where `$(…)` and a
|
|
19
|
+
* backtick substitute before `x` is reached at all. The argument is a migration DESCRIPTION and
|
|
20
|
+
* not an identifier, so no quoted form makes a hostile name safe to pass — a name the screen
|
|
21
|
+
* refuses is left OUT of the command rather than escaped into it. The command still runs and
|
|
22
|
+
* still generates the migration; the name is read off `cause` and `meta`, which are prose nobody
|
|
23
|
+
* pastes.
|
|
24
|
+
*/
|
|
25
|
+
export const dbDrift = (tableName: string, columnName: string): DbError =>
|
|
26
|
+
new DbError({
|
|
27
|
+
code: 'X_DB_DRIFT',
|
|
28
|
+
cause: `table "${tableName}" has column "${columnName}" not present in any migration`,
|
|
29
|
+
fix:
|
|
30
|
+
shellInertIdentifier(columnName) === null
|
|
31
|
+
? 'x db gen "add the column named in this error" # its name carries a backtick, a ' +
|
|
32
|
+
'dollar sign, a quote, a backslash or whitespace, so it is in the cause and not in ' +
|
|
33
|
+
'this command'
|
|
34
|
+
: `x db gen "add ${columnName}"`,
|
|
35
|
+
meta: { table: tableName, column: columnName },
|
|
36
|
+
});
|