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