@ultimat3/db 15.0.0 → 17.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 +249 -22
- package/README.md +3 -2
- package/package.json +2 -2
- package/src/bun-sql.ts +32 -0
- package/src/client.ts +14 -232
- package/src/connection-url.ts +60 -0
- package/src/db-health.ts +31 -0
- package/src/default-client.ts +2 -1
- package/src/dependent-view.ts +224 -0
- 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 +2 -109
- package/src/foreign-key-plan.ts +35 -14
- package/src/foreign-key.ts +33 -0
- package/src/generate.ts +36 -48
- package/src/generated-column.ts +31 -6
- package/src/index-ddl.ts +51 -0
- package/src/index-plan.ts +119 -0
- package/src/index.ts +24 -21
- package/src/migrate.ts +30 -12
- package/src/migration-errors.ts +132 -0
- 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/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +64 -10
package/src/client.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
// Single responsibility: the Postgres
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Single responsibility: the pooled Postgres client and the ambient `db()` handle — one statement
|
|
2
|
+
// funnel, 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`, so importing this module never opens a socket.
|
|
5
5
|
|
|
6
|
-
import { type Role,
|
|
6
|
+
import { type Role, resolveRole } from '@ultimat3/core';
|
|
7
7
|
import { statementAttribution } from './attribution';
|
|
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,
|
|
13
|
+
import { DbError, driverError } from './errors';
|
|
12
14
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
13
|
-
import { declaresLibpqOption, mergeLibpqOptions } from './libpq-options';
|
|
14
15
|
import { statementObserver } from './observe';
|
|
16
|
+
import { assertPoolProfile, type PoolProfile, poolProfileFor } from './pool-profile';
|
|
17
|
+
import { reserveWithin } from './pool-reserve';
|
|
15
18
|
import { type SqlFragment, sql } from './sql';
|
|
16
19
|
import { withStatementSpan } from './statement-span';
|
|
17
20
|
import { currentTx } from './transaction';
|
|
@@ -41,123 +44,6 @@ export function isReservable(client: DbClient): client is ReservableClient {
|
|
|
41
44
|
return typeof (client as Partial<ReservableClient>).reserve === 'function';
|
|
42
45
|
}
|
|
43
46
|
|
|
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
47
|
export interface PostgresClientOptions {
|
|
162
48
|
readonly url?: string | undefined;
|
|
163
49
|
readonly role?: Role | undefined;
|
|
@@ -165,46 +51,6 @@ export interface PostgresClientOptions {
|
|
|
165
51
|
readonly applicationName?: string | undefined;
|
|
166
52
|
}
|
|
167
53
|
|
|
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
54
|
function rowsOf<T>(result: unknown): readonly T[] {
|
|
209
55
|
return Array.isArray(result) ? (result as readonly T[]) : [];
|
|
210
56
|
}
|
|
@@ -219,49 +65,6 @@ function affectedBy(result: unknown): number {
|
|
|
219
65
|
return typeof count === 'number' && count > 0 ? count : result.length;
|
|
220
66
|
}
|
|
221
67
|
|
|
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
68
|
export interface PostgresClient extends ReservableClient {
|
|
266
69
|
readonly profile: PoolProfile;
|
|
267
70
|
ping(): Promise<void>;
|
|
@@ -271,7 +74,10 @@ export interface PostgresClient extends ReservableClient {
|
|
|
271
74
|
/** Lazily connects: the pool opens on the first statement, never at import. */
|
|
272
75
|
export function createPostgresClient(options: PostgresClientOptions = {}): PostgresClient {
|
|
273
76
|
const role = options.role ?? resolveRole();
|
|
274
|
-
const profile: PoolProfile =
|
|
77
|
+
const profile: PoolProfile = assertPoolProfile({
|
|
78
|
+
...poolProfileFor(role),
|
|
79
|
+
...(options.profile ?? {}),
|
|
80
|
+
});
|
|
275
81
|
let driver: BunSqlDriver | undefined;
|
|
276
82
|
|
|
277
83
|
function connect(): BunSqlDriver {
|
|
@@ -455,27 +261,3 @@ export function baseClient(): DbClient {
|
|
|
455
261
|
export function db(): DbClient {
|
|
456
262
|
return currentTx() ?? baseClient();
|
|
457
263
|
}
|
|
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,60 @@
|
|
|
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 { 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
|
+
export function connectionUrl(options: ConnectionUrlOptions, profile: PoolProfile): string {
|
|
16
|
+
const raw = options.url ?? process.env['DATABASE_URL'];
|
|
17
|
+
if (raw === undefined || raw === '') {
|
|
18
|
+
throw dbUnavailable('DATABASE_URL is not set, so there is no database to connect to');
|
|
19
|
+
}
|
|
20
|
+
let url: URL;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(raw);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
// The SHAPE of the rejected value, never the value. A connection string is
|
|
25
|
+
// `user:password@host` by construction, and this `cause` is the boot log line AND the `--json`
|
|
26
|
+
// payload — the logger redacts `fields` by key, so a password baked into a message has no key
|
|
27
|
+
// left to redact it by. `@ultimat3/core`'s `defineEnv` reached the identical conclusion about
|
|
28
|
+
// the identical variable (`packages/core/src/env.ts`); this is the same rule at the second
|
|
29
|
+
// reader, not a second rule. The value still has a printer: `x env check`, through
|
|
30
|
+
// `maskedEnvValues`.
|
|
31
|
+
throw dbUnavailable(`DATABASE_URL is not a valid url: received ${describeValue(raw)}`, error);
|
|
32
|
+
}
|
|
33
|
+
// libpq `options` is the portable way to pin a statement timeout for every pooled connection —
|
|
34
|
+
// MERGED into the operator's own, never assigned over it, and emitted for every role including
|
|
35
|
+
// the two whose bound is 0. `set` here dropped a `?options=-c search_path=app` on `web`, `sync`,
|
|
36
|
+
// `worker` and `scheduler` and kept it on `migrate` and `replicator`, so the role that runs the
|
|
37
|
+
// migrations and the role that serves the traffic read different schemas. 0 is a value, not a
|
|
38
|
+
// silence: it is `migrate` saying it may take as long as it takes, and left unsaid a server-side
|
|
39
|
+
// `alter database ... set statement_timeout` kills the one role that must outlive it.
|
|
40
|
+
// `application_name` is a LABEL, not a bound: 'ultimate' is a DEFAULT, and a default may not
|
|
41
|
+
// overwrite what the operator wrote — `?application_name=billing-api` is the filter their
|
|
42
|
+
// `pg_stat_activity` query, their pooler rule and their audit rule all match on, and losing it
|
|
43
|
+
// is silent. Both spellings count, or the URL parameter and a `-c application_name=` in
|
|
44
|
+
// `options` disagree and which one the backend honours is argument order nobody here measured.
|
|
45
|
+
const named = options.applicationName;
|
|
46
|
+
const settings: Record<string, string> = {
|
|
47
|
+
statement_timeout: String(profile.statementTimeoutMs),
|
|
48
|
+
};
|
|
49
|
+
const inOptions = declaresLibpqOption(url.searchParams.get('options'), 'application_name');
|
|
50
|
+
// An explicit `applicationName` is a deliberate call by the role that opened the pool, so it
|
|
51
|
+
// wins. Only then is the setting named to the merge, and only when the operator wrote the other
|
|
52
|
+
// spelling: `mergeLibpqOptions` drops their assignment before appending, so the two cannot
|
|
53
|
+
// disagree — and a URL with no assignment in it keeps the exact `options` it always had.
|
|
54
|
+
if (named !== undefined && inOptions) settings['application_name'] = named;
|
|
55
|
+
const declared = url.searchParams.has('application_name') || inOptions;
|
|
56
|
+
url.searchParams.set('options', mergeLibpqOptions(url.searchParams.get('options'), settings));
|
|
57
|
+
if (named !== undefined) url.searchParams.set('application_name', named);
|
|
58
|
+
else if (!declared) url.searchParams.set('application_name', 'ultimate');
|
|
59
|
+
return url.toString();
|
|
60
|
+
}
|
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,224 @@
|
|
|
1
|
+
// Single responsibility: refuse a migration whose `alter column … type` a VIEW is compiled against,
|
|
2
|
+
// before the statement is sent — and name the view, the column and the statement that recreates it.
|
|
3
|
+
//
|
|
4
|
+
// **This is the honest ceiling for views, and the reason it is not in the generator.** `x db gen`
|
|
5
|
+
// runs with no database open; `SchemaDescription` has no field for a view; `introspect()` reads
|
|
6
|
+
// none by construction (`app-relation.ts` excludes every non-table relation); and no `entity()` can
|
|
7
|
+
// declare one. So nothing the generator reads knows a view exists, and a `GenerateOptions.views`
|
|
8
|
+
// with no caller to fill it is the declared-and-never-wired defect this release exists to
|
|
9
|
+
// eliminate. What DOES have a connection is `migrate()`, one statement before the abort — and the
|
|
10
|
+
// catalog answers the question exactly, including for a view no migration in this repo wrote.
|
|
11
|
+
//
|
|
12
|
+
// Measured on 18.4: `alter table "dv_docs" alter column "rank" type text using "rank"::text` under
|
|
13
|
+
// a view selecting that column answers `0A000 cannot alter type of a column used by a view or
|
|
14
|
+
// rule`, with `rule _RETURN on view dv_docs_published depends on column "rank"` in a DETAIL field
|
|
15
|
+
// nothing printed — surfaced as `X_DB_UNAVAILABLE: cannot reach the database`, whose registered
|
|
16
|
+
// `fix:` says to set `DATABASE_URL`.
|
|
17
|
+
//
|
|
18
|
+
// It does not repair anything and does not claim to: the deploy still stops. What it replaces is
|
|
19
|
+
// wrong advice about a healthy database with the two statements that unblock it.
|
|
20
|
+
|
|
21
|
+
import type { DbClient } from './client';
|
|
22
|
+
import { migrationViewDepends } from './migration-errors';
|
|
23
|
+
import { identifier, join, sql } from './sql';
|
|
24
|
+
import { IDENTIFIER_PART, noiseAt } from './sql-scan';
|
|
25
|
+
import { statementsOf } from './statement-split';
|
|
26
|
+
|
|
27
|
+
/** One `alter table <table> alter column <column> type …`, as the catalog spells both names. */
|
|
28
|
+
export interface RetypeTarget {
|
|
29
|
+
readonly table: string;
|
|
30
|
+
readonly column: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One name in a statement. Called a WORD and not the obvious lexer noun deliberately:
|
|
35
|
+
* `scripts/secret-compare.ts` reads a comparison whose operand is NAMED like a credential, and
|
|
36
|
+
* that noun is one of the names it reads — a `.text === spelling` under it is indistinguishable
|
|
37
|
+
* from an auth check to a static rule that has only the name to go on.
|
|
38
|
+
*/
|
|
39
|
+
interface SqlWord {
|
|
40
|
+
readonly text: string;
|
|
41
|
+
/** A quoted name is never a keyword — `"type"` is a column called type, not the clause. */
|
|
42
|
+
readonly quoted: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The names in one statement, in order, folded the way Postgres folds them: an unquoted identifier
|
|
47
|
+
* to lower case, a quoted one verbatim. Comments, string literals and dollar-quoted bodies
|
|
48
|
+
* contribute nothing, through this package's one lexer — `-- alter column` is prose and
|
|
49
|
+
* `'alter column'` is data.
|
|
50
|
+
*/
|
|
51
|
+
function wordsOf(statement: string): readonly SqlWord[] {
|
|
52
|
+
const words: SqlWord[] = [];
|
|
53
|
+
let at = 0;
|
|
54
|
+
while (at < statement.length) {
|
|
55
|
+
const noise = noiseAt(statement, at);
|
|
56
|
+
if (noise !== null) {
|
|
57
|
+
if (noise.kind === 'identifier') {
|
|
58
|
+
words.push({ text: statement.slice(at + 1, noise.end - 1), quoted: true });
|
|
59
|
+
}
|
|
60
|
+
at = noise.end;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!IDENTIFIER_PART.test(statement[at] ?? '')) {
|
|
64
|
+
at += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
let end = at;
|
|
68
|
+
while (end < statement.length && IDENTIFIER_PART.test(statement[end] ?? '')) end += 1;
|
|
69
|
+
words.push({ text: statement.slice(at, end).toLowerCase(), quoted: false });
|
|
70
|
+
at = end;
|
|
71
|
+
}
|
|
72
|
+
return words;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const keyword = (word: SqlWord | undefined, spelling: string): boolean =>
|
|
76
|
+
word !== undefined && !word.quoted && word.text === spelling;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every column this script retypes. Narrow ON PURPOSE — `alter table <t> … alter [column] <c> type`
|
|
80
|
+
* and nothing else — because a miss costs exactly what happens today (the server's own `0A000`,
|
|
81
|
+
* one statement later) while a false positive costs a catalog read and a refusal on a migration
|
|
82
|
+
* that would have applied. Every retype `generateMigration` emits is this shape; a hand-written
|
|
83
|
+
* `ALTER TABLE ONLY t …` is not, and is deliberately left to the server.
|
|
84
|
+
*/
|
|
85
|
+
export function retypeTargets(script: string): readonly RetypeTarget[] {
|
|
86
|
+
const targets: RetypeTarget[] = [];
|
|
87
|
+
for (const statement of statementsOf(script)) {
|
|
88
|
+
const words = wordsOf(statement);
|
|
89
|
+
const table = words[2];
|
|
90
|
+
if (!keyword(words[0], 'alter') || !keyword(words[1], 'table') || table === undefined) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (let index = 3; index < words.length; index += 1) {
|
|
94
|
+
if (!keyword(words[index], 'alter')) continue;
|
|
95
|
+
const at = keyword(words[index + 1], 'column') ? index + 2 : index + 1;
|
|
96
|
+
const column = words[at];
|
|
97
|
+
if (column === undefined || !keyword(words[at + 1], 'type')) continue;
|
|
98
|
+
targets.push({ table: table.text, column: column.text });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return targets;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface ViewRow {
|
|
105
|
+
readonly view_name: string;
|
|
106
|
+
readonly table_name: string;
|
|
107
|
+
readonly column_name: string;
|
|
108
|
+
readonly definition: string;
|
|
109
|
+
/** `v` or `m`. A MATERIALISED view needs different DDL to drop and to recreate. */
|
|
110
|
+
readonly relkind: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `pg_depend` -> `pg_rewrite` is the only edge that records this: a view depends on a column
|
|
115
|
+
* through its `_RETURN` rule, never through a row in `pg_class` alone. Materialised views are
|
|
116
|
+
* included (`relkind = 'm'`) because they carry the same rule and fail the same way.
|
|
117
|
+
*
|
|
118
|
+
* One round trip for every target, `in` over both name lists, and the exact pairing filtered in
|
|
119
|
+
* the caller — a per-target query would be a loop of statements inside the migration's own
|
|
120
|
+
* transaction, and a cross-product read is cheap where a false pair is not.
|
|
121
|
+
*/
|
|
122
|
+
async function dependentViews(
|
|
123
|
+
client: DbClient,
|
|
124
|
+
targets: readonly RetypeTarget[],
|
|
125
|
+
): Promise<readonly ViewRow[]> {
|
|
126
|
+
const tables = join(
|
|
127
|
+
[...new Set(targets.map((target) => target.table))].map((name) => sql`${name}`),
|
|
128
|
+
);
|
|
129
|
+
const columns = join(
|
|
130
|
+
[...new Set(targets.map((target) => target.column))].map((name) => sql`${name}`),
|
|
131
|
+
);
|
|
132
|
+
return client.query<ViewRow>(sql`
|
|
133
|
+
select distinct v.relname as view_name, c.relname as table_name, a.attname as column_name,
|
|
134
|
+
pg_get_viewdef(v.oid, true) as definition, v.relkind as relkind
|
|
135
|
+
from pg_depend d
|
|
136
|
+
join pg_rewrite r on r.oid = d.objid and d.classid = 'pg_rewrite'::regclass
|
|
137
|
+
join pg_class v on v.oid = r.ev_class
|
|
138
|
+
join pg_class c on c.oid = d.refobjid and d.refclassid = 'pg_class'::regclass
|
|
139
|
+
join pg_attribute a on a.attrelid = c.oid and a.attnum = d.refobjsubid
|
|
140
|
+
where v.relkind in ('v', 'm') and v.oid <> c.oid
|
|
141
|
+
and c.relname in (${tables}) and a.attname in (${columns})
|
|
142
|
+
order by v.relname
|
|
143
|
+
`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* One SQL statement as a single argv word for `psql -c`.
|
|
148
|
+
*
|
|
149
|
+
* SINGLE quotes, unlike `migrationConflict`'s `-c "…"`: `identifier()` writes the view's name in
|
|
150
|
+
* DOUBLE quotes, so a double-quoted shell word would end at the name. The definition is the
|
|
151
|
+
* server's own text and may hold a `'` of its own — `where status = 'published'` — so the one
|
|
152
|
+
* escape a POSIX shell has for it is spelled out here. This is not the SQL literal escape
|
|
153
|
+
* (`sql.ts`'s `literal()`, the tree's one copy of that); nothing below is sent to a server.
|
|
154
|
+
*/
|
|
155
|
+
const shellArg = (statement: string): string => `'${statement.replaceAll("'", `'\\''`)}'`;
|
|
156
|
+
|
|
157
|
+
/** The invocation `migrationConflict` already writes, with the statement as its own argv word. */
|
|
158
|
+
const psql = (statement: string): string => `psql "$DATABASE_URL" -c ${shellArg(statement)}`;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The two statements that unblock the deploy, as one line an operator pastes.
|
|
162
|
+
*
|
|
163
|
+
* It leads with the command to RUN and carries the follow-up in a `#` comment, the shape
|
|
164
|
+
* `migrateConcurrent` and `migrationSnapshotMissing` already write. It used to lead with bare DDL
|
|
165
|
+
* and a `#`: `#` is not a comment in Postgres, so psql read the whole line and failed on it, while
|
|
166
|
+
* a shell read `drop` as a program that does not exist. Neither reader could run it (axiom 4).
|
|
167
|
+
*
|
|
168
|
+
* `identifier()` REFUSES a name holding a quote, a space or a backslash — all three legal inside a
|
|
169
|
+
* quoted Postgres name — and a `fix:` may not throw: the rule `rebuildForeignKey` already states,
|
|
170
|
+
* with the same shape. A refusal that raised `X_SQL_UNSAFE` in place of the finding would hand the
|
|
171
|
+
* operator an exception where a verdict was asked for, over a view name that is perfectly legal.
|
|
172
|
+
* The fallback still leads with a command that runs — a psql session — because quoting that name
|
|
173
|
+
* is the one step this package will not do twice: `identifier()` is its only identifier writer.
|
|
174
|
+
*
|
|
175
|
+
* The definition is collapsed to one line because `pg_get_viewdef(oid, true)` pretty-prints across
|
|
176
|
+
* several and a `fix:` is read as a command.
|
|
177
|
+
*
|
|
178
|
+
* `relkind` decides the DDL and is not cosmetic: `dependentViews` deliberately selects `'m'` as
|
|
179
|
+
* well as `'v'`, and Postgres refuses `drop view` on a materialised one — `WRONG_OBJECT_TYPE`,
|
|
180
|
+
* "use DROP MATERIALIZED VIEW". So the one case the query went out of its way to include was the
|
|
181
|
+
* one whose `fix:` could not run. `pg_get_viewdef` answers the SELECT for both kinds, so only the
|
|
182
|
+
* two keywords differ; a matview's indexes and its `WITH DATA` population are NOT carried, and
|
|
183
|
+
* the fix says so rather than implying the recreate is complete.
|
|
184
|
+
*/
|
|
185
|
+
function restoreView(view: string, definition: string, relkind: string): string {
|
|
186
|
+
const body = definition.replace(/\s+/g, ' ').replace(/;\s*$/, '').trim();
|
|
187
|
+
const materialised = relkind === 'm';
|
|
188
|
+
const kind = materialised ? 'materialized view' : 'view';
|
|
189
|
+
const note = materialised
|
|
190
|
+
? ' # then re-create its indexes: a matview keeps none of them across a drop'
|
|
191
|
+
: '';
|
|
192
|
+
try {
|
|
193
|
+
const name = identifier(view).text;
|
|
194
|
+
return (
|
|
195
|
+
`${psql(`drop ${kind} ${name}`)} # then x db migrate, then: ` +
|
|
196
|
+
`${psql(`create ${kind} ${name} as ${body}`)}${note}`
|
|
197
|
+
);
|
|
198
|
+
} catch {
|
|
199
|
+
return (
|
|
200
|
+
`psql "$DATABASE_URL" # quote the ${kind} name ${JSON.stringify(view)} yourself, then: ` +
|
|
201
|
+
`drop ${kind} <name>; \\q; x db migrate; and create it again as: create ${kind} <name> as ${body}${note}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Refuse before the ALTER, or return having sent nothing at all. A script that retypes no column
|
|
208
|
+
* costs one text scan and no round trip, which is every migration an app writes that is not a
|
|
209
|
+
* retype.
|
|
210
|
+
*/
|
|
211
|
+
export async function refuseDependentViews(client: DbClient, script: string): Promise<void> {
|
|
212
|
+
const targets = retypeTargets(script);
|
|
213
|
+
if (targets.length === 0) return;
|
|
214
|
+
const wanted = new Set(targets.map((target) => `${target.table}.${target.column}`));
|
|
215
|
+
for (const row of await dependentViews(client, targets)) {
|
|
216
|
+
if (!wanted.has(`${row.table_name}.${row.column_name}`)) continue;
|
|
217
|
+
throw migrationViewDepends(
|
|
218
|
+
row.view_name,
|
|
219
|
+
row.table_name,
|
|
220
|
+
row.column_name,
|
|
221
|
+
restoreView(row.view_name, row.definition, row.relkind),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -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
|
+
});
|