@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.
Files changed (96) hide show
  1. package/CLAUDE.md +74 -110
  2. package/README.md +163 -15
  3. package/package.json +13 -2
  4. package/src/app-relation.ts +7 -2
  5. package/src/catalog-fold.ts +5 -0
  6. package/src/catalog-objects.ts +20 -24
  7. package/src/catalog-policies.ts +81 -0
  8. package/src/catalog-relations.ts +31 -10
  9. package/src/catalog.ts +40 -1
  10. package/src/check-ddl.ts +34 -7
  11. package/src/client.ts +132 -48
  12. package/src/column-alter.ts +42 -1
  13. package/src/concurrent-index.ts +20 -0
  14. package/src/concurrent-plan.ts +135 -17
  15. package/src/connection-url.ts +16 -4
  16. package/src/db-diagnostics-deadline.ts +43 -0
  17. package/src/db-diagnostics-redact.ts +58 -0
  18. package/src/db-diagnostics-rows.ts +166 -0
  19. package/src/db-diagnostics-sql.ts +92 -0
  20. package/src/db-diagnostics.ts +178 -0
  21. package/src/destructive.ts +17 -3
  22. package/src/diagnostics-entry.ts +15 -0
  23. package/src/drift-errors.ts +9 -4
  24. package/src/drift-findings.ts +47 -62
  25. package/src/drift-missing.ts +203 -0
  26. package/src/drift-row-security.ts +88 -0
  27. package/src/drift-triggers.ts +54 -0
  28. package/src/drift.ts +88 -21
  29. package/src/entity-shape.ts +67 -0
  30. package/src/errors.ts +77 -9
  31. package/src/expected-loop.ts +34 -4
  32. package/src/failure-store-pg.ts +157 -0
  33. package/src/foreign-key-plan.ts +75 -23
  34. package/src/foreign-key.ts +76 -5
  35. package/src/generate-counter.ts +85 -0
  36. package/src/generate-history.ts +142 -0
  37. package/src/generate-row-security.ts +51 -0
  38. package/src/generate-snapshot.ts +82 -0
  39. package/src/generate-steps.ts +6 -1
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +105 -79
  43. package/src/index.ts +129 -6
  44. package/src/introspect-catalog.ts +26 -6
  45. package/src/introspect-rows.ts +82 -0
  46. package/src/introspect.ts +108 -50
  47. package/src/keyset.ts +182 -0
  48. package/src/lifecycle-errors.ts +15 -0
  49. package/src/local-settings.ts +38 -0
  50. package/src/marker-line.ts +12 -4
  51. package/src/migrate-lock-retry.ts +60 -0
  52. package/src/migrate-rollback.ts +45 -6
  53. package/src/migrate.ts +45 -4
  54. package/src/migration-errors.ts +27 -1
  55. package/src/migration-ledger.ts +38 -4
  56. package/src/migration-step.ts +50 -22
  57. package/src/partition-order.ts +56 -0
  58. package/src/partitioned-tables.ts +24 -0
  59. package/src/pool-gate.ts +120 -0
  60. package/src/pool-listeners.ts +143 -0
  61. package/src/pool-pins.ts +68 -0
  62. package/src/pool-profile.ts +23 -7
  63. package/src/pool-reserve.ts +11 -4
  64. package/src/pool-retire.ts +22 -0
  65. package/src/pool-sweep.ts +115 -0
  66. package/src/pool-timer.ts +10 -0
  67. package/src/pooler-fix.ts +53 -0
  68. package/src/pooler.ts +161 -0
  69. package/src/read-transaction.ts +59 -0
  70. package/src/readonly-query.ts +4 -4
  71. package/src/replica-client.ts +54 -22
  72. package/src/replica-position.ts +89 -0
  73. package/src/replica-scope.ts +25 -4
  74. package/src/retired.ts +131 -0
  75. package/src/retype-dependents.ts +2 -0
  76. package/src/rolling-errors.ts +97 -0
  77. package/src/rolling.ts +212 -0
  78. package/src/row-secure-client.ts +54 -0
  79. package/src/row-security-catalog.ts +26 -0
  80. package/src/row-security-migrator.ts +48 -0
  81. package/src/row-security-plan.ts +58 -0
  82. package/src/row-security-role.ts +60 -0
  83. package/src/row-security-roles.ts +64 -0
  84. package/src/row-security.ts +59 -0
  85. package/src/same-tenant.ts +173 -0
  86. package/src/schema-dump-policies.ts +26 -0
  87. package/src/schema-dump-table.ts +21 -6
  88. package/src/schema-dump.ts +13 -2
  89. package/src/snapshot-parse.ts +42 -2
  90. package/src/sqlstate.ts +4 -0
  91. package/src/statement-bound.ts +82 -0
  92. package/src/transaction-errors.ts +14 -0
  93. package/src/transaction-guard.ts +40 -0
  94. package/src/transaction-settings.ts +24 -0
  95. package/src/transaction.ts +74 -19
  96. package/src/ungeneratable.ts +30 -0
@@ -1,9 +1,14 @@
1
- // Single responsibility: the index work `x db gen` moves OUT of the migration's transaction into a
2
- // step of its own — every `concurrently: true` build and every index REMOVAL, whose concurrent
3
- // forms queue no read and no write, and which Postgres runs only outside a transaction block.
4
- // `index-plan.ts` decides which; this file writes the statements and the intermediate snapshot.
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
- * The non-transactional step's statements, in two phases, and what each changes for the snapshot.
30
- * `up`/`down` EXPAND (every build), `contract`/`contractDown` CONTRACT (every removal): the step
31
- * runs all of the first before any of the second, so a replacement exists before what it replaces
32
- * goes — across tables too, since the phases are joined once, at assembly (`stepStatements`).
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 — expand, then contract — and its reversal: the removed
50
- * indexes are restored FIRST, then the new ones dropped, so the reversal has no gap either.
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: [...plan.down, ...plan.contractDown].reverse(),
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
- if (mine.length === 0) return table;
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
  };
@@ -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
- const settings: Record<string, string> = {
89
- statement_timeout: String(profile.statementTimeoutMs),
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
- url.searchParams.set('options', mergeLibpqOptions(url.searchParams.get('options'), settings));
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`;