@ultimat3/db 29.4.0 → 30.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 +74 -110
- package/README.md +163 -15
- package/package.json +13 -2
- package/src/app-relation.ts +7 -2
- package/src/catalog-fold.ts +5 -0
- package/src/catalog-objects.ts +20 -24
- package/src/catalog-policies.ts +81 -0
- package/src/catalog-relations.ts +31 -10
- package/src/catalog.ts +40 -1
- package/src/check-ddl.ts +34 -7
- package/src/client.ts +132 -48
- package/src/column-alter.ts +42 -1
- package/src/concurrent-index.ts +20 -0
- package/src/concurrent-plan.ts +135 -17
- package/src/connection-url.ts +16 -4
- package/src/db-diagnostics-deadline.ts +43 -0
- package/src/db-diagnostics-redact.ts +58 -0
- package/src/db-diagnostics-rows.ts +166 -0
- package/src/db-diagnostics-sql.ts +92 -0
- package/src/db-diagnostics.ts +178 -0
- package/src/destructive.ts +17 -3
- package/src/diagnostics-entry.ts +15 -0
- package/src/drift-errors.ts +9 -4
- package/src/drift-findings.ts +47 -62
- package/src/drift-missing.ts +203 -0
- package/src/drift-row-security.ts +88 -0
- package/src/drift-triggers.ts +54 -0
- package/src/drift.ts +88 -21
- package/src/entity-shape.ts +67 -0
- package/src/errors.ts +77 -9
- package/src/expected-loop.ts +34 -4
- package/src/failure-store-pg.ts +157 -0
- package/src/foreign-key-plan.ts +75 -23
- package/src/foreign-key.ts +76 -5
- package/src/generate-counter.ts +85 -0
- package/src/generate-history.ts +142 -0
- package/src/generate-row-security.ts +51 -0
- package/src/generate-snapshot.ts +82 -0
- package/src/generate-steps.ts +6 -1
- package/src/generate-triggers.ts +119 -0
- package/src/generate-view.ts +97 -0
- package/src/generate.ts +105 -79
- package/src/index.ts +129 -6
- package/src/introspect-catalog.ts +26 -6
- package/src/introspect-rows.ts +82 -0
- package/src/introspect.ts +108 -50
- package/src/keyset.ts +182 -0
- package/src/lifecycle-errors.ts +15 -0
- package/src/local-settings.ts +38 -0
- package/src/marker-line.ts +12 -4
- package/src/migrate-lock-retry.ts +60 -0
- package/src/migrate-rollback.ts +45 -6
- package/src/migrate.ts +45 -4
- package/src/migration-errors.ts +27 -1
- package/src/migration-ledger.ts +38 -4
- package/src/migration-step.ts +50 -22
- package/src/partition-order.ts +56 -0
- package/src/partitioned-tables.ts +24 -0
- package/src/pool-gate.ts +120 -0
- package/src/pool-listeners.ts +143 -0
- package/src/pool-pins.ts +68 -0
- package/src/pool-profile.ts +23 -7
- package/src/pool-reserve.ts +11 -4
- package/src/pool-retire.ts +22 -0
- package/src/pool-sweep.ts +115 -0
- package/src/pool-timer.ts +10 -0
- package/src/pooler-fix.ts +53 -0
- package/src/pooler.ts +161 -0
- package/src/read-transaction.ts +59 -0
- package/src/readonly-query.ts +4 -4
- package/src/replica-client.ts +54 -22
- package/src/replica-position.ts +89 -0
- package/src/replica-scope.ts +25 -4
- package/src/retired.ts +131 -0
- package/src/retype-dependents.ts +2 -0
- package/src/rolling-errors.ts +97 -0
- package/src/rolling.ts +212 -0
- package/src/row-secure-client.ts +54 -0
- package/src/row-security-catalog.ts +26 -0
- package/src/row-security-migrator.ts +48 -0
- package/src/row-security-plan.ts +58 -0
- package/src/row-security-role.ts +60 -0
- package/src/row-security-roles.ts +64 -0
- package/src/row-security.ts +59 -0
- package/src/same-tenant.ts +173 -0
- package/src/schema-dump-policies.ts +26 -0
- package/src/schema-dump-table.ts +21 -6
- package/src/schema-dump.ts +13 -2
- package/src/snapshot-parse.ts +42 -2
- package/src/sqlstate.ts +4 -0
- package/src/statement-bound.ts +82 -0
- package/src/transaction-errors.ts +14 -0
- package/src/transaction-guard.ts +40 -0
- package/src/transaction-settings.ts +24 -0
- package/src/transaction.ts +74 -19
- package/src/ungeneratable.ts +30 -0
package/src/concurrent-plan.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
// Single responsibility: the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Single responsibility: the work `x db gen` moves OUT of the migration's transaction into a step
|
|
2
|
+
// of its own — `concurrently: true` builds and index removals (`index-plan.ts` decides which), plus
|
|
3
|
+
// live-table foreign keys and CHECK validation (`foreign-key-plan.ts`, `check-ddl.ts`), each an
|
|
4
|
+
// outage under a lock that stops writes. It writes the statements and the intermediate snapshot.
|
|
5
5
|
|
|
6
6
|
import type { IndexDescriptionLike } from './entity-shape';
|
|
7
|
+
import {
|
|
8
|
+
dropForeignKeyIfExists,
|
|
9
|
+
replaceForeignKeyNotValid,
|
|
10
|
+
validateConstraint,
|
|
11
|
+
} from './foreign-key';
|
|
7
12
|
import type { Plan } from './foreign-key-plan';
|
|
8
13
|
import {
|
|
9
14
|
asDeclared,
|
|
@@ -12,7 +17,7 @@ import {
|
|
|
12
17
|
indexShape,
|
|
13
18
|
mayBeConstraintBacked,
|
|
14
19
|
} from './index-ddl';
|
|
15
|
-
import type { IndexDescription, SchemaDescription } from './introspect';
|
|
20
|
+
import type { ForeignKeyDescription, IndexDescription, SchemaDescription } from './introspect';
|
|
16
21
|
import { identifier } from './sql';
|
|
17
22
|
|
|
18
23
|
/**
|
|
@@ -23,18 +28,42 @@ export interface DeferredIndex {
|
|
|
23
28
|
readonly table: string;
|
|
24
29
|
readonly name: string;
|
|
25
30
|
readonly recorded?: IndexDescription | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* What the step builds, absent for a removal: a foreign key over exactly these columns cannot be
|
|
33
|
+
* added before the step, or the transactional step answers `42830` (`targetIndexDeferred`).
|
|
34
|
+
*/
|
|
35
|
+
readonly builds?: { readonly columns: readonly string[]; readonly unique: boolean } | undefined;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
39
|
+
* One key the non-transactional step changes: `recorded` is what the transactional step's snapshot
|
|
40
|
+
* still records under this name (absent for a new key). The `DeferredIndex` of a constraint, and
|
|
41
|
+
* for the same reason: a failed second step must not leave a ledger claiming a key the catalog lacks.
|
|
42
|
+
*/
|
|
43
|
+
export interface DeferredKey {
|
|
44
|
+
readonly table: string;
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly recorded?: ForeignKeyDescription | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The non-transactional step's statements, in phases, and what each changes for the snapshot.
|
|
51
|
+
* `up`/`down` EXPAND (every build), `keys` adds each key `not valid`, `validate` scans the rows they
|
|
52
|
+
* let stand, and `contractKeys` then `contract` CONTRACT (every removal): each phase finishes before
|
|
53
|
+
* the next starts, so a replacement exists before what it replaces goes — across tables too, since
|
|
54
|
+
* the phases are joined once, at assembly (`stepStatements`).
|
|
33
55
|
*/
|
|
34
56
|
export interface ConcurrentPlan extends Plan {
|
|
35
57
|
readonly contract: string[];
|
|
36
58
|
readonly contractDown: string[];
|
|
37
59
|
readonly deferred: DeferredIndex[];
|
|
60
|
+
readonly keys: string[];
|
|
61
|
+
readonly keysDown: string[];
|
|
62
|
+
readonly validate: string[];
|
|
63
|
+
/** A dropped key goes before a dropped index: the key may be what still needs that index. */
|
|
64
|
+
readonly contractKeys: string[];
|
|
65
|
+
readonly contractKeysDown: string[];
|
|
66
|
+
readonly deferredKeys: DeferredKey[];
|
|
38
67
|
}
|
|
39
68
|
|
|
40
69
|
export const emptyConcurrentPlan = (): ConcurrentPlan => ({
|
|
@@ -43,19 +72,31 @@ export const emptyConcurrentPlan = (): ConcurrentPlan => ({
|
|
|
43
72
|
contract: [],
|
|
44
73
|
contractDown: [],
|
|
45
74
|
deferred: [],
|
|
75
|
+
keys: [],
|
|
76
|
+
keysDown: [],
|
|
77
|
+
validate: [],
|
|
78
|
+
contractKeys: [],
|
|
79
|
+
contractKeysDown: [],
|
|
80
|
+
deferredKeys: [],
|
|
46
81
|
});
|
|
47
82
|
|
|
48
83
|
/**
|
|
49
|
-
* The step's script in apply order —
|
|
50
|
-
*
|
|
84
|
+
* The step's script in apply order — index builds, key adds, validates, contract — and its reversal:
|
|
85
|
+
* what was removed is restored FIRST, then what is new dropped, so the reversal has no gap either.
|
|
86
|
+
* A `validate` has no reversal: un-validating a constraint nobody asked to weaken undoes nothing.
|
|
51
87
|
*/
|
|
52
88
|
export function stepStatements(plan: ConcurrentPlan): {
|
|
53
89
|
readonly up: readonly string[];
|
|
54
90
|
readonly down: readonly string[];
|
|
55
91
|
} {
|
|
56
92
|
return {
|
|
57
|
-
up: [...plan.up, ...plan.contract],
|
|
58
|
-
down: [
|
|
93
|
+
up: [...plan.up, ...plan.keys, ...plan.validate, ...plan.contractKeys, ...plan.contract],
|
|
94
|
+
down: [
|
|
95
|
+
...plan.down,
|
|
96
|
+
...plan.keysDown,
|
|
97
|
+
...plan.contractKeysDown,
|
|
98
|
+
...plan.contractDown,
|
|
99
|
+
].reverse(),
|
|
59
100
|
};
|
|
60
101
|
}
|
|
61
102
|
|
|
@@ -67,7 +108,75 @@ export function buildConcurrently(
|
|
|
67
108
|
): void {
|
|
68
109
|
concurrent.up.push(createIndex(table, index, true));
|
|
69
110
|
concurrent.down.push(dropIndexConcurrently(index.name));
|
|
70
|
-
concurrent.deferred.push({ table, name: index.name });
|
|
111
|
+
concurrent.deferred.push({ table, name: index.name, builds: builtBy(index) });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const builtBy = (index: IndexDescriptionLike): NonNullable<DeferredIndex['builds']> => ({
|
|
115
|
+
columns: index.columns,
|
|
116
|
+
unique: index.unique,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether the unique index a key over `columns` of `table` needs is one this step builds. Column
|
|
121
|
+
* sets, not lists: Postgres matches a key to a unique index whichever way round either spells them.
|
|
122
|
+
*/
|
|
123
|
+
export function targetIndexDeferred(
|
|
124
|
+
concurrent: ConcurrentPlan,
|
|
125
|
+
table: string,
|
|
126
|
+
columns: readonly string[],
|
|
127
|
+
): boolean {
|
|
128
|
+
const wanted = new Set(columns);
|
|
129
|
+
return concurrent.deferred.some(
|
|
130
|
+
({ table: owner, builds }) =>
|
|
131
|
+
owner === table &&
|
|
132
|
+
builds?.unique === true &&
|
|
133
|
+
builds.columns.length === wanted.size &&
|
|
134
|
+
builds.columns.every((column) => wanted.has(column)),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Add a key without stopping writes: `not valid` then `validate`, both in the deferred step.
|
|
140
|
+
* `recorded` is the key this one replaces under the SAME name, which the one ALTER swaps with no
|
|
141
|
+
* gap; a key under another name stays up until `contractKey`, in the contract phase.
|
|
142
|
+
*/
|
|
143
|
+
export function deferKey(
|
|
144
|
+
table: string,
|
|
145
|
+
key: ForeignKeyDescription,
|
|
146
|
+
recorded: ForeignKeyDescription | undefined,
|
|
147
|
+
concurrent: ConcurrentPlan,
|
|
148
|
+
): void {
|
|
149
|
+
concurrent.keys.push(replaceForeignKeyNotValid(table, key));
|
|
150
|
+
concurrent.validate.push(validateConstraint(table, key.name));
|
|
151
|
+
// Pushed forwards and read backwards, like every `down`: the rebuild lands before its validate.
|
|
152
|
+
if (recorded === undefined) {
|
|
153
|
+
concurrent.keysDown.push(dropForeignKeyIfExists(table, key.name));
|
|
154
|
+
} else {
|
|
155
|
+
concurrent.keysDown.push(
|
|
156
|
+
validateConstraint(table, key.name),
|
|
157
|
+
replaceForeignKeyNotValid(table, recorded),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
concurrent.deferredKeys.push({ table, name: key.name, recorded });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A recorded key the step's new keys replace: dropped last, restored first on the way back. */
|
|
164
|
+
export function contractKey(
|
|
165
|
+
table: string,
|
|
166
|
+
recorded: ForeignKeyDescription,
|
|
167
|
+
concurrent: ConcurrentPlan,
|
|
168
|
+
): void {
|
|
169
|
+
concurrent.contractKeys.push(dropForeignKeyIfExists(table, recorded.name));
|
|
170
|
+
concurrent.contractKeysDown.push(
|
|
171
|
+
validateConstraint(table, recorded.name),
|
|
172
|
+
replaceForeignKeyNotValid(table, recorded),
|
|
173
|
+
);
|
|
174
|
+
concurrent.deferredKeys.push({ table, name: recorded.name, recorded });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A constraint the transaction added `not valid`: its scan runs here, after the commit. */
|
|
178
|
+
export function deferValidate(table: string, name: string, concurrent: ConcurrentPlan): void {
|
|
179
|
+
concurrent.validate.push(validateConstraint(table, name));
|
|
71
180
|
}
|
|
72
181
|
|
|
73
182
|
/**
|
|
@@ -145,7 +254,7 @@ export function redefineConcurrently(
|
|
|
145
254
|
createIndex(table, asDeclared(recorded), true),
|
|
146
255
|
dropIndexConcurrently(index.name),
|
|
147
256
|
);
|
|
148
|
-
concurrent.deferred.push({ table, name: index.name, recorded });
|
|
257
|
+
concurrent.deferred.push({ table, name: index.name, recorded, builds: builtBy(index) });
|
|
149
258
|
}
|
|
150
259
|
|
|
151
260
|
/**
|
|
@@ -153,23 +262,32 @@ export function redefineConcurrently(
|
|
|
153
262
|
* final snapshot with every deferred index put back the way it was — gone if it is new, its
|
|
154
263
|
* recorded definition if it is removed or rebuilt. The sidecar is "the schema this migration leaves
|
|
155
264
|
* behind", and a database that applied the first step alone holds exactly this: the first step
|
|
156
|
-
* removes no index and unhooks no constraint, so every recorded one is still there.
|
|
265
|
+
* removes no index and unhooks no constraint, so every recorded one is still there. A deferred key
|
|
266
|
+
* reads the same way: lacking while new, its recorded definition while replaced or contracted.
|
|
157
267
|
*/
|
|
158
268
|
export function intermediateSnapshot(
|
|
159
269
|
final: SchemaDescription,
|
|
160
270
|
deferred: readonly DeferredIndex[],
|
|
271
|
+
deferredKeys: readonly DeferredKey[] = [],
|
|
161
272
|
): SchemaDescription {
|
|
162
273
|
return {
|
|
274
|
+
...final,
|
|
163
275
|
tables: final.tables.map((table) => {
|
|
164
276
|
const mine = deferred.filter((each) => each.table === table.name);
|
|
165
|
-
|
|
277
|
+
const keys = deferredKeys.filter((each) => each.table === table.name);
|
|
278
|
+
if (mine.length === 0 && keys.length === 0) return table;
|
|
166
279
|
const names = new Set(mine.map((each) => each.name));
|
|
280
|
+
const keyNames = new Set(keys.map((each) => each.name));
|
|
167
281
|
return {
|
|
168
282
|
...table,
|
|
169
283
|
indexes: [
|
|
170
284
|
...table.indexes.filter((index) => !names.has(index.name)),
|
|
171
285
|
...mine.flatMap((each) => (each.recorded === undefined ? [] : [each.recorded])),
|
|
172
286
|
],
|
|
287
|
+
foreignKeys: [
|
|
288
|
+
...table.foreignKeys.filter((key) => !keyNames.has(key.name)),
|
|
289
|
+
...keys.flatMap((each) => (each.recorded === undefined ? [] : [each.recorded])),
|
|
290
|
+
].sort((a, b) => (a.name < b.name ? -1 : 1)),
|
|
173
291
|
};
|
|
174
292
|
}),
|
|
175
293
|
};
|
package/src/connection-url.ts
CHANGED
|
@@ -6,10 +6,13 @@ import { describeValue } from '@ultimat3/core';
|
|
|
6
6
|
import { DbError, dbUnavailable } from './errors';
|
|
7
7
|
import { declaresLibpqOption, mergeLibpqOptions } from './libpq-options';
|
|
8
8
|
import type { PoolProfile } from './pool-profile';
|
|
9
|
+
import type { PoolerMode } from './pooler';
|
|
9
10
|
|
|
10
11
|
export interface ConnectionUrlOptions {
|
|
11
12
|
readonly url?: string | undefined;
|
|
12
13
|
readonly applicationName?: string | undefined;
|
|
14
|
+
/** A pooler in the path: the bounds are the server's (`alter role`), and `options` carries none. */
|
|
15
|
+
readonly pooler?: PoolerMode | undefined;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -85,9 +88,15 @@ export function connectionUrl(options: ConnectionUrlOptions, profile: PoolProfil
|
|
|
85
88
|
// is silent. Both spellings count, or the URL parameter and a `-c application_name=` in
|
|
86
89
|
// `options` disagree and which one the backend honours is argument order nobody here measured.
|
|
87
90
|
const named = options.applicationName;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
// A pooler refuses these at startup or drops them in silence, so with one named neither is sent
|
|
92
|
+
// and the bound moves to `alter role`, applied at the pooler's own server login.
|
|
93
|
+
const settings: Record<string, string> =
|
|
94
|
+
options.pooler === undefined
|
|
95
|
+
? {
|
|
96
|
+
statement_timeout: String(profile.statementTimeoutMs),
|
|
97
|
+
idle_in_transaction_session_timeout: String(profile.idleInTransactionMs),
|
|
98
|
+
}
|
|
99
|
+
: {};
|
|
91
100
|
const inOptions = declaresLibpqOption(url.searchParams.get('options'), 'application_name');
|
|
92
101
|
// An explicit `applicationName` is a deliberate call by the role that opened the pool, so it
|
|
93
102
|
// wins. Only then is the setting named to the merge, and only when the operator wrote the other
|
|
@@ -95,7 +104,10 @@ export function connectionUrl(options: ConnectionUrlOptions, profile: PoolProfil
|
|
|
95
104
|
// disagree — and a URL with no assignment in it keeps the exact `options` it always had.
|
|
96
105
|
if (named !== undefined && inOptions) settings['application_name'] = named;
|
|
97
106
|
const declared = url.searchParams.has('application_name') || inOptions;
|
|
98
|
-
|
|
107
|
+
const merged = mergeLibpqOptions(url.searchParams.get('options'), settings);
|
|
108
|
+
// An empty `options` is a parameter all the same, and it is the parameter a pooler is asked about.
|
|
109
|
+
if (merged === '') url.searchParams.delete('options');
|
|
110
|
+
else url.searchParams.set('options', merged);
|
|
99
111
|
if (named !== undefined) url.searchParams.set('application_name', named);
|
|
100
112
|
else if (!declared) url.searchParams.set('application_name', 'ultimate');
|
|
101
113
|
return url.toString();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Single responsibility: stop WAITING for a diagnostic read the server never answers. PGlite applies
|
|
2
|
+
// `statement_timeout` and cannot fire it mid-scan, so on the embedded database the 2 s bound is no
|
|
3
|
+
// bound; this one is the client's, and it abandons the wait, not the statement.
|
|
4
|
+
|
|
5
|
+
import type { Scheduler } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** What `within` answers when the deadline came first. */
|
|
8
|
+
export const EXPIRED: unique symbol = Symbol('diagnostics.expired');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `work`'s answer, or `EXPIRED` once `ms` pass. A rejection arriving after the deadline is
|
|
12
|
+
* handled here, never left unhandled; the statement itself runs on until its driver ends it.
|
|
13
|
+
*/
|
|
14
|
+
export async function within<T>(
|
|
15
|
+
work: Promise<T>,
|
|
16
|
+
ms: number,
|
|
17
|
+
schedule: Scheduler,
|
|
18
|
+
): Promise<T | typeof EXPIRED> {
|
|
19
|
+
let cancel: () => void = () => undefined;
|
|
20
|
+
const expired = new Promise<typeof EXPIRED>((resolve) => {
|
|
21
|
+
cancel = schedule(() => resolve(EXPIRED), ms);
|
|
22
|
+
});
|
|
23
|
+
work.catch(() => undefined);
|
|
24
|
+
try {
|
|
25
|
+
return await Promise.race([work, expired]);
|
|
26
|
+
} finally {
|
|
27
|
+
cancel();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A deadline that, once passed, stays passed: the whole command's budget. */
|
|
32
|
+
export interface CommandDeadline {
|
|
33
|
+
passed(): boolean;
|
|
34
|
+
release(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function commandDeadline(ms: number, schedule: Scheduler): CommandDeadline {
|
|
38
|
+
let passed = false;
|
|
39
|
+
const cancel = schedule(() => {
|
|
40
|
+
passed = true;
|
|
41
|
+
}, ms);
|
|
42
|
+
return { passed: () => passed, release: cancel };
|
|
43
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Single responsibility: a session's SQL text reduced to its shape before `x db diagnostics` prints
|
|
2
|
+
// it. `pg_stat_activity.query` is the text a client sent, literals and all, so a row value an
|
|
3
|
+
// unparameterised statement carried would otherwise reach a terminal, a log or an agent.
|
|
4
|
+
|
|
5
|
+
import { noiseAt } from './sql-scan';
|
|
6
|
+
|
|
7
|
+
/** Characters kept after redaction: enough to recognise a statement, never a whole report. */
|
|
8
|
+
export const QUERY_TEXT_MAX = 300;
|
|
9
|
+
|
|
10
|
+
/** Digits with Postgres 16's `_` separators between them (`1_234_567`). */
|
|
11
|
+
const DIGITS = String.raw`\d(?:_?\d)*`;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A numeric literal standing alone: not part of a name (`t1`), not a placeholder (`$1`), not a
|
|
15
|
+
* type modifier read off an identifier (`int4`). Lookbehind keeps the preceding character. The
|
|
16
|
+
* Postgres 16 spellings are one literal each: `0x`/`0o`/`0b` with any digits and `_` after it,
|
|
17
|
+
* and `_` between digits in the whole, fractional and exponent parts.
|
|
18
|
+
*/
|
|
19
|
+
const NUMERIC = new RegExp(
|
|
20
|
+
String.raw`(?<![A-Za-z0-9_$."])(?:0[xob][0-9a-f_]+|(?:${DIGITS}(?:\.(?:${DIGITS})?)?|\.${DIGITS})(?:e[+-]?${DIGITS})?)`,
|
|
21
|
+
'gi',
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Literals (string, escape string, dollar-quoted, numeric) become `?`, comments go, quoted
|
|
26
|
+
* identifiers and `$n` placeholders stay. Walked with `sql-scan.ts`'s `noiseAt`, the package's one
|
|
27
|
+
* lexer, so a `--` inside a literal is data here exactly as it is to the destructive rail. A
|
|
28
|
+
* literal the server truncated mid-way runs to the end of the text and is redacted whole.
|
|
29
|
+
*/
|
|
30
|
+
export function redactQueryText(text: string): string {
|
|
31
|
+
let out = '';
|
|
32
|
+
let plain = '';
|
|
33
|
+
const flush = (): void => {
|
|
34
|
+
out += plain.replace(NUMERIC, '?');
|
|
35
|
+
plain = '';
|
|
36
|
+
};
|
|
37
|
+
let index = 0;
|
|
38
|
+
while (index < text.length) {
|
|
39
|
+
const noise = noiseAt(text, index);
|
|
40
|
+
if (noise === null) {
|
|
41
|
+
plain += text[index];
|
|
42
|
+
index += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
flush();
|
|
46
|
+
if (noise.kind === 'identifier') out += text.slice(index, noise.end);
|
|
47
|
+
else if (noise.kind === 'string' || noise.kind === 'dollar-body') out += '?';
|
|
48
|
+
else out += ' ';
|
|
49
|
+
index = noise.end;
|
|
50
|
+
}
|
|
51
|
+
flush();
|
|
52
|
+
// An escape-string prefix (`E'…'`, `X'…'`) is left as a lone letter before its `?`.
|
|
53
|
+
const shaped = out
|
|
54
|
+
.replace(/(?<![A-Za-z0-9_$])[ebxun]&?\?/gi, '?')
|
|
55
|
+
.replace(/\s+/g, ' ')
|
|
56
|
+
.trim();
|
|
57
|
+
return shaped.length > QUERY_TEXT_MAX ? `${shaped.slice(0, QUERY_TEXT_MAX)}…` : shaped;
|
|
58
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Single responsibility: one catalog row, as the driver handed it, into `dbDiagnostics`' typed
|
|
2
|
+
// row. Column names stay the SQL's (`db-diagnostics-sql.ts`); the fields are what `--json` prints.
|
|
3
|
+
// The two drivers disagree on `int8` (text, bigint or number), so every count is read here, once.
|
|
4
|
+
|
|
5
|
+
type Raw = Readonly<Record<string, unknown>>;
|
|
6
|
+
type Redact = (text: string) => string;
|
|
7
|
+
|
|
8
|
+
/** A count or an age in seconds, whichever type the driver chose; `null` when the server had none. */
|
|
9
|
+
const count = (value: unknown): number | null => {
|
|
10
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
11
|
+
if (typeof value === 'bigint') return Number(value);
|
|
12
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const text = (value: unknown): string | null => (typeof value === 'string' ? value : null);
|
|
20
|
+
|
|
21
|
+
const flag = (value: unknown): boolean | null => (typeof value === 'boolean' ? value : null);
|
|
22
|
+
|
|
23
|
+
/** Statement text, shaped by the caller's redactor: never sent out as the server stored it. */
|
|
24
|
+
const statement = (value: unknown, redact: Redact): string | null => {
|
|
25
|
+
const raw = text(value);
|
|
26
|
+
return raw === null ? null : redact(raw);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface BlockerRow {
|
|
30
|
+
readonly pid: number | null;
|
|
31
|
+
readonly blockedBy: number | null;
|
|
32
|
+
readonly waitEventType: string | null;
|
|
33
|
+
readonly waitEvent: string | null;
|
|
34
|
+
readonly waitingSeconds: number | null;
|
|
35
|
+
readonly query: string | null;
|
|
36
|
+
readonly blockerState: string | null;
|
|
37
|
+
readonly blockerTransactionSeconds: number | null;
|
|
38
|
+
readonly blockerQuery: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const blockerRow = (raw: Raw, redact: Redact): BlockerRow => ({
|
|
42
|
+
pid: count(raw['pid']),
|
|
43
|
+
blockedBy: count(raw['blocked_by']),
|
|
44
|
+
waitEventType: text(raw['wait_event_type']),
|
|
45
|
+
waitEvent: text(raw['wait_event']),
|
|
46
|
+
waitingSeconds: count(raw['waiting_seconds']),
|
|
47
|
+
query: statement(raw['query'], redact),
|
|
48
|
+
blockerState: text(raw['blocker_state']),
|
|
49
|
+
blockerTransactionSeconds: count(raw['blocker_transaction_seconds']),
|
|
50
|
+
blockerQuery: statement(raw['blocker_query'], redact),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export interface ActivityRow {
|
|
54
|
+
readonly pid: number | null;
|
|
55
|
+
readonly role: string | null;
|
|
56
|
+
readonly applicationName: string | null;
|
|
57
|
+
readonly state: string | null;
|
|
58
|
+
readonly waitEventType: string | null;
|
|
59
|
+
readonly waitEvent: string | null;
|
|
60
|
+
readonly transactionSeconds: number | null;
|
|
61
|
+
readonly querySeconds: number | null;
|
|
62
|
+
/** False when this role lacks `pg_read_all_stats` for that session: a fact, not an error. */
|
|
63
|
+
readonly queryVisible: boolean;
|
|
64
|
+
readonly query: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const activityRow = (raw: Raw, redact: Redact): ActivityRow => {
|
|
68
|
+
const queryVisible = flag(raw['query_visible']) ?? false;
|
|
69
|
+
return {
|
|
70
|
+
pid: count(raw['pid']),
|
|
71
|
+
role: text(raw['role']),
|
|
72
|
+
applicationName: text(raw['application_name']),
|
|
73
|
+
state: text(raw['state']),
|
|
74
|
+
waitEventType: text(raw['wait_event_type']),
|
|
75
|
+
waitEvent: text(raw['wait_event']),
|
|
76
|
+
transactionSeconds: count(raw['transaction_seconds']),
|
|
77
|
+
querySeconds: count(raw['query_seconds']),
|
|
78
|
+
queryVisible,
|
|
79
|
+
query: queryVisible ? statement(raw['query'], redact) : null,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export interface SlowQueryRow {
|
|
84
|
+
readonly queryId: string | null;
|
|
85
|
+
readonly calls: number | null;
|
|
86
|
+
readonly totalMs: number | null;
|
|
87
|
+
readonly meanMs: number | null;
|
|
88
|
+
readonly rows: number | null;
|
|
89
|
+
readonly query: string | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const slowQueryRow = (raw: Raw, redact: Redact): SlowQueryRow => ({
|
|
93
|
+
queryId: text(raw['query_id']),
|
|
94
|
+
calls: count(raw['calls']),
|
|
95
|
+
totalMs: count(raw['total_ms']),
|
|
96
|
+
meanMs: count(raw['mean_ms']),
|
|
97
|
+
rows: count(raw['rows']),
|
|
98
|
+
query: statement(raw['query'], redact),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export interface UnusedIndexRow {
|
|
102
|
+
readonly schema: string | null;
|
|
103
|
+
readonly table: string | null;
|
|
104
|
+
readonly index: string | null;
|
|
105
|
+
readonly sizeBytes: number | null;
|
|
106
|
+
/** Seconds since this database's counters were reset; `null` = never reset. */
|
|
107
|
+
readonly statsAgeSeconds: number | null;
|
|
108
|
+
/** Counters are per server: a replica's zero says nothing of the primary's scans. */
|
|
109
|
+
readonly replica: boolean | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const unusedIndexRow = (raw: Raw): UnusedIndexRow => ({
|
|
113
|
+
schema: text(raw['schema']),
|
|
114
|
+
table: text(raw['table_name']),
|
|
115
|
+
index: text(raw['index_name']),
|
|
116
|
+
sizeBytes: count(raw['size_bytes']),
|
|
117
|
+
statsAgeSeconds: count(raw['stats_age_seconds']),
|
|
118
|
+
replica: flag(raw['replica']),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export interface InvalidIndexRow {
|
|
122
|
+
readonly schema: string | null;
|
|
123
|
+
readonly table: string | null;
|
|
124
|
+
readonly index: string | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const invalidIndexRow = (raw: Raw): InvalidIndexRow => ({
|
|
128
|
+
schema: text(raw['schema']),
|
|
129
|
+
table: text(raw['table_name']),
|
|
130
|
+
index: text(raw['index_name']),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export interface NotValidConstraintRow {
|
|
134
|
+
readonly schema: string | null;
|
|
135
|
+
readonly table: string | null;
|
|
136
|
+
readonly constraint: string | null;
|
|
137
|
+
/** `check`, `foreign-key`, or Postgres' own one-letter `contype` for anything else. */
|
|
138
|
+
readonly kind: string | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const notValidConstraintRow = (raw: Raw): NotValidConstraintRow => ({
|
|
142
|
+
schema: text(raw['schema']),
|
|
143
|
+
table: text(raw['table_name']),
|
|
144
|
+
constraint: text(raw['constraint_name']),
|
|
145
|
+
kind: text(raw['kind']),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export interface DeadTupleRow {
|
|
149
|
+
readonly schema: string | null;
|
|
150
|
+
readonly table: string | null;
|
|
151
|
+
readonly live: number | null;
|
|
152
|
+
readonly dead: number | null;
|
|
153
|
+
readonly autovacuumAgeSeconds: number | null;
|
|
154
|
+
readonly vacuumAgeSeconds: number | null;
|
|
155
|
+
readonly analyzeAgeSeconds: number | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const deadTupleRow = (raw: Raw): DeadTupleRow => ({
|
|
159
|
+
schema: text(raw['schema']),
|
|
160
|
+
table: text(raw['table_name']),
|
|
161
|
+
live: count(raw['live']),
|
|
162
|
+
dead: count(raw['dead']),
|
|
163
|
+
autovacuumAgeSeconds: count(raw['autovacuum_age_seconds']),
|
|
164
|
+
vacuumAgeSeconds: count(raw['vacuum_age_seconds']),
|
|
165
|
+
analyzeAgeSeconds: count(raw['analyze_age_seconds']),
|
|
166
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Single responsibility: the seven catalog reads behind `dbDiagnostics`, one constant statement
|
|
2
|
+
// each. Catalog and statistics views only — no statement here reads a user table, so no row value
|
|
3
|
+
// can be in an answer; the one free text (`query`) is redacted by the caller before it leaves.
|
|
4
|
+
|
|
5
|
+
/** Every database name filter is `current_database()`: a shared server's other tenants are not ours. */
|
|
6
|
+
const OUR_DATABASE = 'datname = current_database()';
|
|
7
|
+
|
|
8
|
+
/** Waiting sessions and the pid each one waits on, longest wait first. */
|
|
9
|
+
export const BLOCKERS_SQL = `select w.pid as pid, b.pid as blocked_by, w.wait_event_type as wait_event_type,
|
|
10
|
+
w.wait_event as wait_event, round(extract(epoch from now() - w.query_start))::int as waiting_seconds,
|
|
11
|
+
w.query as query, b.state as blocker_state,
|
|
12
|
+
round(extract(epoch from now() - b.xact_start))::int as blocker_transaction_seconds,
|
|
13
|
+
b.query as blocker_query
|
|
14
|
+
from pg_stat_activity w
|
|
15
|
+
cross join lateral unnest(pg_blocking_pids(w.pid)) as blocker(pid)
|
|
16
|
+
join pg_stat_activity b on b.pid = blocker.pid
|
|
17
|
+
where w.${OUR_DATABASE}
|
|
18
|
+
order by waiting_seconds desc nulls last, w.pid`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every client session that is not idle, oldest transaction first. A session another role owns
|
|
22
|
+
* reads `<insufficient privilege>` without `pg_read_all_stats`: reported as `query_visible`.
|
|
23
|
+
*/
|
|
24
|
+
export const ACTIVITY_SQL = `select pid, usename as role, application_name, state,
|
|
25
|
+
wait_event_type, wait_event,
|
|
26
|
+
round(extract(epoch from now() - xact_start))::int as transaction_seconds,
|
|
27
|
+
round(extract(epoch from now() - query_start))::int as query_seconds,
|
|
28
|
+
query is distinct from '<insufficient privilege>' as query_visible, query
|
|
29
|
+
from pg_stat_activity
|
|
30
|
+
where ${OUR_DATABASE} and pid <> pg_backend_pid() and backend_type = 'client backend'
|
|
31
|
+
and coalesce(state, '') <> 'idle'
|
|
32
|
+
order by xact_start nulls last, pid`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `pg_stat_statements` is an extension: on a server without it this is `42P01` and the section is
|
|
36
|
+
* reported unavailable. Its text is what the server normalised; the caller redacts it anyway, since
|
|
37
|
+
* a utility statement is stored as sent.
|
|
38
|
+
*/
|
|
39
|
+
export const SLOW_QUERIES_SQL = `select queryid::text as query_id, calls,
|
|
40
|
+
round(total_exec_time::numeric, 1)::float8 as total_ms,
|
|
41
|
+
round(mean_exec_time::numeric, 2)::float8 as mean_ms, rows, query
|
|
42
|
+
from pg_stat_statements
|
|
43
|
+
where dbid = (select oid from pg_database where ${OUR_DATABASE})
|
|
44
|
+
order by total_exec_time desc`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Never scanned since the counters were reset. Unique and primary indexes are excluded: they
|
|
48
|
+
* enforce, and a scan count does not measure that. The counters' age and the server's recovery
|
|
49
|
+
* state ride on each row, because a count since yesterday, or on a replica, is not "unused".
|
|
50
|
+
*/
|
|
51
|
+
export const UNUSED_INDEXES_SQL = `select s.schemaname as schema, s.relname as table_name,
|
|
52
|
+
s.indexrelname as index_name, pg_relation_size(s.indexrelid) as size_bytes,
|
|
53
|
+
(select round(extract(epoch from now() - d.stats_reset))::int
|
|
54
|
+
from pg_stat_database d where d.${OUR_DATABASE}) as stats_age_seconds,
|
|
55
|
+
pg_is_in_recovery() as replica
|
|
56
|
+
from pg_stat_user_indexes s
|
|
57
|
+
join pg_index i on i.indexrelid = s.indexrelid
|
|
58
|
+
where s.idx_scan = 0 and not i.indisunique and not i.indisprimary
|
|
59
|
+
order by pg_relation_size(s.indexrelid) desc, s.indexrelname`;
|
|
60
|
+
|
|
61
|
+
const USER_SCHEMA = (column: string): string =>
|
|
62
|
+
`${column} not in ('pg_catalog', 'information_schema') and ${column} not like 'pg\\_toast%'`;
|
|
63
|
+
|
|
64
|
+
/** An index a failed `create index concurrently` left behind: written to, never read from. */
|
|
65
|
+
export const INVALID_INDEXES_SQL = `select n.nspname as schema, t.relname as table_name,
|
|
66
|
+
c.relname as index_name
|
|
67
|
+
from pg_index i
|
|
68
|
+
join pg_class c on c.oid = i.indexrelid
|
|
69
|
+
join pg_class t on t.oid = i.indrelid
|
|
70
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
71
|
+
where not i.indisvalid and ${USER_SCHEMA('n.nspname')}
|
|
72
|
+
order by n.nspname, t.relname, c.relname`;
|
|
73
|
+
|
|
74
|
+
/** Added `not valid` and never validated: new rows are checked, old ones are not. Names only. */
|
|
75
|
+
export const NOT_VALID_CONSTRAINTS_SQL = `select n.nspname as schema, t.relname as table_name,
|
|
76
|
+
c.conname as constraint_name,
|
|
77
|
+
case c.contype when 'c' then 'check' when 'f' then 'foreign-key' else c.contype::text end as kind
|
|
78
|
+
from pg_constraint c
|
|
79
|
+
join pg_class t on t.oid = c.conrelid
|
|
80
|
+
join pg_namespace n on n.oid = t.relnamespace
|
|
81
|
+
where not c.convalidated and ${USER_SCHEMA('n.nspname')}
|
|
82
|
+
order by n.nspname, t.relname, c.conname`;
|
|
83
|
+
|
|
84
|
+
/** Counts and ages from the statistics collector; no estimate of bloat, which would be a guess. */
|
|
85
|
+
export const DEAD_TUPLES_SQL = `select schemaname as schema, relname as table_name,
|
|
86
|
+
n_live_tup as live, n_dead_tup as dead,
|
|
87
|
+
round(extract(epoch from now() - last_autovacuum))::int as autovacuum_age_seconds,
|
|
88
|
+
round(extract(epoch from now() - greatest(last_vacuum, last_autovacuum)))::int as vacuum_age_seconds,
|
|
89
|
+
round(extract(epoch from now() - greatest(last_analyze, last_autoanalyze)))::int as analyze_age_seconds
|
|
90
|
+
from pg_stat_user_tables
|
|
91
|
+
where n_dead_tup > 0
|
|
92
|
+
order by n_dead_tup desc, relname`;
|