@ultimat3/db 29.3.2 → 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 (98) hide show
  1. package/CLAUDE.md +78 -116
  2. package/README.md +199 -16
  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 +157 -0
  14. package/src/concurrent-plan.ts +294 -0
  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 +21 -35
  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 +75 -0
  30. package/src/errors.ts +80 -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 +99 -0
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +128 -89
  43. package/src/index-ddl.ts +23 -35
  44. package/src/index-plan.ts +35 -14
  45. package/src/index.ts +136 -6
  46. package/src/introspect-catalog.ts +26 -6
  47. package/src/introspect-rows.ts +82 -0
  48. package/src/introspect.ts +108 -50
  49. package/src/keyset.ts +182 -0
  50. package/src/lifecycle-errors.ts +15 -0
  51. package/src/local-settings.ts +38 -0
  52. package/src/marker-line.ts +47 -0
  53. package/src/migrate-lock-retry.ts +60 -0
  54. package/src/migrate-rollback.ts +45 -6
  55. package/src/migrate.ts +67 -69
  56. package/src/migration-errors.ts +48 -1
  57. package/src/migration-ledger.ts +38 -4
  58. package/src/migration-step.ts +192 -0
  59. package/src/partition-order.ts +56 -0
  60. package/src/partitioned-tables.ts +24 -0
  61. package/src/pool-gate.ts +120 -0
  62. package/src/pool-listeners.ts +143 -0
  63. package/src/pool-pins.ts +68 -0
  64. package/src/pool-profile.ts +23 -7
  65. package/src/pool-reserve.ts +11 -4
  66. package/src/pool-retire.ts +22 -0
  67. package/src/pool-sweep.ts +115 -0
  68. package/src/pool-timer.ts +10 -0
  69. package/src/pooler-fix.ts +53 -0
  70. package/src/pooler.ts +161 -0
  71. package/src/read-transaction.ts +59 -0
  72. package/src/readonly-query.ts +4 -4
  73. package/src/replica-client.ts +54 -22
  74. package/src/replica-position.ts +89 -0
  75. package/src/replica-scope.ts +25 -4
  76. package/src/retired.ts +131 -0
  77. package/src/retype-dependents.ts +2 -0
  78. package/src/rolling-errors.ts +97 -0
  79. package/src/rolling.ts +212 -0
  80. package/src/row-secure-client.ts +54 -0
  81. package/src/row-security-catalog.ts +26 -0
  82. package/src/row-security-migrator.ts +48 -0
  83. package/src/row-security-plan.ts +58 -0
  84. package/src/row-security-role.ts +60 -0
  85. package/src/row-security-roles.ts +64 -0
  86. package/src/row-security.ts +59 -0
  87. package/src/same-tenant.ts +173 -0
  88. package/src/schema-dump-policies.ts +26 -0
  89. package/src/schema-dump-table.ts +21 -6
  90. package/src/schema-dump.ts +13 -2
  91. package/src/snapshot-parse.ts +42 -2
  92. package/src/sqlstate.ts +4 -0
  93. package/src/statement-bound.ts +82 -0
  94. package/src/transaction-errors.ts +14 -0
  95. package/src/transaction-guard.ts +40 -0
  96. package/src/transaction-settings.ts +24 -0
  97. package/src/transaction.ts +74 -19
  98. package/src/ungeneratable.ts +30 -0
@@ -26,6 +26,47 @@ function screened(column: string, expression: string): string {
26
26
  return expression;
27
27
  }
28
28
 
29
+ /** What the column should hold as a default: one this generator cannot render stays as it is. */
30
+ export function wantedDefault(
31
+ column: ColumnDescriptionLike,
32
+ recorded: ColumnDescription,
33
+ ): string | null {
34
+ return hasUnrenderedDefault(column) ? recorded.default : defaultExpression(column);
35
+ }
36
+
37
+ /**
38
+ * The ALTER of a retype, with the column's default out of its way. Postgres converts a default with
39
+ * the column and refuses when no assignment cast exists (`42804`), whatever the new default is, so
40
+ * it is dropped first and set after: a type change is the one move that has to span the default.
41
+ * `down` is reversed at assembly, so what it pushes here runs last to first: drop the new default,
42
+ * retype back, restore the old one. With no default on either side, this is the bare ALTER.
43
+ */
44
+ export function retypeOverDefault(
45
+ table: string,
46
+ column: ColumnDescriptionLike,
47
+ recorded: ColumnDescription,
48
+ plan: Plan,
49
+ up: string,
50
+ down: string,
51
+ ): void {
52
+ const alter = `alter table ${identifier(table).text} alter column ${identifier(column.column).text}`;
53
+ const drop = `${alter} drop default;`;
54
+ const set = (expression: string): string =>
55
+ `${alter} set default ${screened(column.column, expression)};`;
56
+ const wanted = wantedDefault(column, recorded);
57
+ const held = recorded.default;
58
+ if (held !== null) {
59
+ plan.up.push(drop);
60
+ plan.down.push(set(held));
61
+ }
62
+ plan.up.push(up);
63
+ plan.down.push(down);
64
+ if (wanted !== null) {
65
+ plan.up.push(set(wanted));
66
+ plan.down.push(drop);
67
+ }
68
+ }
69
+
29
70
  /**
30
71
  * `set default` / `drop default` and `drop not null`, each with its reverse in `down` — the shape
31
72
  * `redefineIndex` (`index-ddl.ts`) gives a moved index. Becoming NOT NULL is deliberately NOT a
@@ -45,7 +86,7 @@ export function alterColumnInPlace(
45
86
  ): void {
46
87
  const { table } = entity;
47
88
  const alter = `alter table ${identifier(table).text} alter column ${identifier(column.column).text}`;
48
- const wanted = hasUnrenderedDefault(column) ? recorded.default : defaultExpression(column);
89
+ const wanted = wantedDefault(column, recorded);
49
90
  const held = recorded.default;
50
91
  if (wanted !== held) {
51
92
  const set = (expression: string | null): string =>
@@ -0,0 +1,157 @@
1
+ // Single responsibility: what makes a migration NON-TRANSACTIONAL, and which of its statements are
2
+ // the concurrent index builds that need it. `create index concurrently` and `drop index
3
+ // concurrently` are refused inside a transaction block (`25001`), so the one marker line below is
4
+ // how a migration says "run me statement by statement, outside one" — read by `migration-step.ts`.
5
+
6
+ import { hasMarkerLine } from './marker-line';
7
+ import { IDENTIFIER_CHAR, noiseAt } from './sql-scan';
8
+ import { sqlState } from './sqlstate';
9
+ import { statementsOf } from './statement-split';
10
+
11
+ /**
12
+ * The line a migration carries to run outside a transaction. Written into `up` by `x db gen` for
13
+ * the step that holds every concurrent index build, and it governs BOTH halves of the file: the
14
+ * `down` of a concurrent build is a concurrent drop, refused inside a transaction just the same.
15
+ */
16
+ export const NO_TRANSACTION_MARKER = '-- transaction: false';
17
+
18
+ /** The whole comment, as `-- destructive: true` is matched: a mention declares nothing. */
19
+ const MARKER_COMMENT = /^--[ \t]*transaction:[ \t]*false[ \t]*\r?\n?$/i;
20
+
21
+ /** Whether this migration's `up` declares it runs outside a transaction. */
22
+ export const isNonTransactional = (up: string): boolean => hasMarkerLine(up, MARKER_COMMENT);
23
+
24
+ interface Token {
25
+ readonly text: string;
26
+ /** A word to compare, lower-cased — `undefined` for a quoted identifier or a punctuation mark. */
27
+ readonly word: string | undefined;
28
+ }
29
+
30
+ const WHITESPACE = /\s/;
31
+
32
+ /**
33
+ * The first `limit` tokens of one statement: comments skipped, a quoted identifier kept whole and
34
+ * verbatim, a literal or dollar body ending the read — no statement this file recognises has one
35
+ * before the name it is after. `sql-scan.ts` is the lexer, so a `"create"` is a name and not a verb.
36
+ */
37
+ function leadingTokens(statement: string, limit: number): readonly Token[] {
38
+ const tokens: Token[] = [];
39
+ let index = 0;
40
+ while (index < statement.length && tokens.length < limit) {
41
+ const noise = noiseAt(statement, index);
42
+ if (noise !== null) {
43
+ if (noise.kind === 'string' || noise.kind === 'dollar-body') break;
44
+ if (noise.kind === 'identifier') {
45
+ tokens.push({ text: statement.slice(index, noise.end), word: undefined });
46
+ }
47
+ index = noise.end;
48
+ continue;
49
+ }
50
+ const char = statement[index] ?? '';
51
+ if (WHITESPACE.test(char)) {
52
+ index += 1;
53
+ continue;
54
+ }
55
+ let end = index + 1;
56
+ if (IDENTIFIER_CHAR.test(char)) {
57
+ while (end < statement.length && IDENTIFIER_CHAR.test(statement[end] ?? '')) end += 1;
58
+ }
59
+ const text = statement.slice(index, end);
60
+ tokens.push({ text, word: IDENTIFIER_CHAR.test(char) ? text.toLowerCase() : undefined });
61
+ index = end;
62
+ }
63
+ return tokens;
64
+ }
65
+
66
+ /**
67
+ * The index a `create [unique] index concurrently [if not exists] <name>` builds, spelled exactly
68
+ * as the statement spells it (`"runs_status_idx"`, `public.runs_status_idx`) — the text
69
+ * `to_regclass()` resolves and `drop index concurrently if exists` accepts. `null` for every other
70
+ * statement, and for an unnamed build: Postgres picks that name, so there is nothing to look up.
71
+ */
72
+ export function concurrentIndexTarget(statement: string): string | null {
73
+ const tokens = leadingTokens(statement, 12);
74
+ const word = (at: number): string | undefined => tokens[at]?.word;
75
+ let at = 0;
76
+ if (word(at) !== 'create') return null;
77
+ at += word(at + 1) === 'unique' ? 2 : 1;
78
+ if (word(at) !== 'index' || word(at + 1) !== 'concurrently') return null;
79
+ at += 2;
80
+ if (word(at) === 'if' && word(at + 1) === 'not' && word(at + 2) === 'exists') at += 3;
81
+ const name = tokens[at];
82
+ if (name === undefined || name.word === 'on') return null;
83
+ const dot = tokens[at + 1];
84
+ const tail = tokens[at + 2];
85
+ if (dot?.text === '.' && tail !== undefined) return `${name.text}.${tail.text}`;
86
+ return name.text;
87
+ }
88
+
89
+ /**
90
+ * Whether one statement is an index build or drop Postgres refuses inside a transaction block:
91
+ * `create [unique] index concurrently`, `drop index concurrently`, `reindex … concurrently`.
92
+ */
93
+ export function isConcurrentIndexStatement(statement: string): boolean {
94
+ const words = leadingTokens(statement, 8).map((token) => token.word);
95
+ const [first, second, third, fourth] = words;
96
+ if (first === 'create') {
97
+ return second === 'unique'
98
+ ? third === 'index' && fourth === 'concurrently'
99
+ : second === 'index' && third === 'concurrently';
100
+ }
101
+ if (first === 'drop') return second === 'index' && third === 'concurrently';
102
+ return first === 'reindex' && reindexIsConcurrent(statement);
103
+ }
104
+
105
+ /** `feature_not_supported` — what Postgres answers a concurrent build on a partitioned parent. */
106
+ const FEATURE_NOT_SUPPORTED = '0A000';
107
+
108
+ /**
109
+ * Whether `error` is Postgres refusing THIS statement as a concurrent build on a partitioned table:
110
+ * a `create [unique] index concurrently` answered `0A000`. Read off the SQLSTATE, never the
111
+ * message, which follows the server's `lc_messages`. `migration-step.ts` turns it into
112
+ * `X_MIGRATION_CONCURRENT_PARTITIONED`, whose fix is the sequence that replaces the build.
113
+ */
114
+ export function isPartitionedBuildRefusal(statement: string, error: unknown): boolean {
115
+ const [first, second, third, fourth] = leadingTokens(statement, 4).map((token) => token.word);
116
+ const build =
117
+ first === 'create' &&
118
+ (second === 'unique'
119
+ ? third === 'index' && fourth === 'concurrently'
120
+ : second === 'index' && third === 'concurrently');
121
+ return build && sqlState(error) === FEATURE_NOT_SUPPORTED;
122
+ }
123
+
124
+ /** The values that switch a boolean `REINDEX` option OFF — Postgres' own spellings of false. */
125
+ const OFF = new Set(['false', 'off', '0', 'no']);
126
+
127
+ /**
128
+ * `REINDEX [ ( option [, …] ) ] { INDEX | TABLE | SCHEMA | DATABASE | SYSTEM } [ CONCURRENTLY ] name`
129
+ * — read whole, never through a fixed window: the option list has no length bound, and
130
+ * `CONCURRENTLY [ boolean ]` may itself be one of the options.
131
+ */
132
+ function reindexIsConcurrent(statement: string): boolean {
133
+ const tokens = leadingTokens(statement, Number.POSITIVE_INFINITY);
134
+ let at = 1;
135
+ if (tokens[at]?.text === '(') {
136
+ let option: (string | undefined)[] = [];
137
+ const settle = (): boolean => option[0] === 'concurrently' && !OFF.has(option[1] ?? '');
138
+ for (at += 1; at < tokens.length; at += 1) {
139
+ const text = tokens[at]?.text;
140
+ if (text === ')' || text === ',') {
141
+ if (settle()) return true;
142
+ option = [];
143
+ if (text === ')') break;
144
+ continue;
145
+ }
146
+ option.push(tokens[at]?.word);
147
+ }
148
+ at += 1;
149
+ }
150
+ // The object kind, then the modifier — never the name after it, so `index "concurrently"` and
151
+ // `index concurrently_idx` are not concurrent.
152
+ return tokens[at + 1]?.word === 'concurrently';
153
+ }
154
+
155
+ /** The first statement of `script` that cannot run inside a transaction, or `undefined`. */
156
+ export const firstConcurrentStatement = (script: string): string | undefined =>
157
+ statementsOf(script).find(isConcurrentIndexStatement);
@@ -0,0 +1,294 @@
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
+
6
+ import type { IndexDescriptionLike } from './entity-shape';
7
+ import {
8
+ dropForeignKeyIfExists,
9
+ replaceForeignKeyNotValid,
10
+ validateConstraint,
11
+ } from './foreign-key';
12
+ import type { Plan } from './foreign-key-plan';
13
+ import {
14
+ asDeclared,
15
+ createIndex,
16
+ dropIndexConcurrently,
17
+ indexShape,
18
+ mayBeConstraintBacked,
19
+ } from './index-ddl';
20
+ import type { ForeignKeyDescription, IndexDescription, SchemaDescription } from './introspect';
21
+ import { identifier } from './sql';
22
+
23
+ /**
24
+ * One index the non-transactional step changes: `recorded` is what stood there before it (absent
25
+ * for a new build) — what the transactional step's snapshot still records (`intermediateSnapshot`).
26
+ */
27
+ export interface DeferredIndex {
28
+ readonly table: string;
29
+ readonly name: string;
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;
36
+ }
37
+
38
+ /**
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`).
55
+ */
56
+ export interface ConcurrentPlan extends Plan {
57
+ readonly contract: string[];
58
+ readonly contractDown: string[];
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[];
67
+ }
68
+
69
+ export const emptyConcurrentPlan = (): ConcurrentPlan => ({
70
+ up: [],
71
+ down: [],
72
+ contract: [],
73
+ contractDown: [],
74
+ deferred: [],
75
+ keys: [],
76
+ keysDown: [],
77
+ validate: [],
78
+ contractKeys: [],
79
+ contractKeysDown: [],
80
+ deferredKeys: [],
81
+ });
82
+
83
+ /**
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.
87
+ */
88
+ export function stepStatements(plan: ConcurrentPlan): {
89
+ readonly up: readonly string[];
90
+ readonly down: readonly string[];
91
+ } {
92
+ return {
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(),
100
+ };
101
+ }
102
+
103
+ /** A declared index this migration builds new, concurrently, with its concurrent drop as `down`. */
104
+ export function buildConcurrently(
105
+ table: string,
106
+ index: IndexDescriptionLike,
107
+ concurrent: ConcurrentPlan,
108
+ ): void {
109
+ concurrent.up.push(createIndex(table, index, true));
110
+ concurrent.down.push(dropIndexConcurrently(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));
180
+ }
181
+
182
+ /**
183
+ * Drop a RECORDED index whose kind this generator cannot know, in statements that are correct on
184
+ * both databases it cannot tell apart.
185
+ *
186
+ * `TableDescription` carries no discriminator, and it cannot be given one that would help: the
187
+ * SAME declaration reaches the server as a CONSTRAINT or as an INDEX depending on which migration
188
+ * created it. A `unique` column on a table `createTable` writes emits `create table … slug text
189
+ * unique`, and Postgres backs that with a constraint named `posts_slug_key`; the same column
190
+ * gaining `unique` later takes `create unique index "posts_slug_key"` and is a plain index.
191
+ * `snapshotOf` records both as `{ unique: true, primary: false }`.
192
+ *
193
+ * Measured on 18.4 (`index-removal.live.test.ts`), which is why the pair and not a guess:
194
+ *
195
+ * | statement | on a constraint's index | on a plain index |
196
+ * |----------------------------------------------|----------------------------|------------------|
197
+ * | `drop index [if exists] "n"` | **2BP01** — not suppressed | ok |
198
+ * | `drop index concurrently if exists "n"` | **2BP01** — not suppressed | ok |
199
+ * | `alter table … drop constraint if exists` | drops it, index and all | notice, no-op |
200
+ *
201
+ * So the shape `mayBeConstraintBacked` cannot rule out is first unhooked by the ALTER, and the
202
+ * concurrent drop after it is then a no-op notice or the whole job. Constraint first: reversed,
203
+ * the drop reaches a constraint's index and is the 2BP01 this exists to avoid.
204
+ *
205
+ * Both in the SAME non-transactional step, and in the phase `into` names. Never the ALTER in the
206
+ * transactional step before it: that step's `down` cannot restore a constraint, so a rollback after
207
+ * a failed concurrent step left the table with no uniqueness at all — and it would unhook the old
208
+ * key before a concurrent replacement existed, letting in the duplicates that then fail its build.
209
+ * The ALTER takes ACCESS EXCLUSIVE, so the runner gives a statement like it a transaction of its
210
+ * own under `lock_timeout` (`migration-step.ts`); the concurrent drop stays unbounded.
211
+ */
212
+ function dropRecordedConcurrently(table: string, recorded: IndexDescription, into: string[]): void {
213
+ if (mayBeConstraintBacked(recorded)) {
214
+ into.push(
215
+ `alter table ${identifier(table).text} drop constraint if exists ${identifier(recorded.name).text};`,
216
+ );
217
+ }
218
+ into.push(dropIndexConcurrently(recorded.name));
219
+ }
220
+
221
+ /**
222
+ * A recorded index no entity declares any more: dropped concurrently, and restored by `down` —
223
+ * concurrently too — from what the SNAPSHOT recorded, never from the entity that stopped declaring it.
224
+ */
225
+ export function removeConcurrently(
226
+ table: string,
227
+ recorded: IndexDescription,
228
+ concurrent: ConcurrentPlan,
229
+ ): void {
230
+ dropRecordedConcurrently(table, recorded, concurrent.contract);
231
+ concurrent.contractDown.push(createIndex(table, asDeclared(recorded), true));
232
+ concurrent.deferred.push({ table, name: recorded.name, recorded });
233
+ }
234
+
235
+ /**
236
+ * A `concurrently: true` index whose definition moved: dropped and rebuilt, both concurrently —
237
+ * `redefineIndex`'s pair outside the transaction. Between the two statements the table has neither
238
+ * definition; for a unique index that is a window with no uniqueness, the price of never blocking a
239
+ * write (name the new shape differently to build it beside the old one instead).
240
+ */
241
+ export function redefineConcurrently(
242
+ table: string,
243
+ index: IndexDescriptionLike,
244
+ recorded: IndexDescription,
245
+ concurrent: ConcurrentPlan,
246
+ ): void {
247
+ if (indexShape(index) === indexShape(recorded)) return;
248
+ // One name, so no expand → contract is possible: the drop must precede the build, in the
249
+ // expand phase, in that order.
250
+ dropRecordedConcurrently(table, recorded, concurrent.up);
251
+ concurrent.up.push(createIndex(table, index, true));
252
+ // Pushed forwards and read backwards, as `redefineIndex`'s: the recorded definition lands last.
253
+ concurrent.down.push(
254
+ createIndex(table, asDeclared(recorded), true),
255
+ dropIndexConcurrently(index.name),
256
+ );
257
+ concurrent.deferred.push({ table, name: index.name, recorded, builds: builtBy(index) });
258
+ }
259
+
260
+ /**
261
+ * The schema the TRANSACTIONAL step leaves behind when a non-transactional one follows it: the
262
+ * final snapshot with every deferred index put back the way it was — gone if it is new, its
263
+ * recorded definition if it is removed or rebuilt. The sidecar is "the schema this migration leaves
264
+ * behind", and a database that applied the first step alone holds exactly this: the first step
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.
267
+ */
268
+ export function intermediateSnapshot(
269
+ final: SchemaDescription,
270
+ deferred: readonly DeferredIndex[],
271
+ deferredKeys: readonly DeferredKey[] = [],
272
+ ): SchemaDescription {
273
+ return {
274
+ ...final,
275
+ tables: final.tables.map((table) => {
276
+ const mine = deferred.filter((each) => each.table === table.name);
277
+ const keys = deferredKeys.filter((each) => each.table === table.name);
278
+ if (mine.length === 0 && keys.length === 0) return table;
279
+ const names = new Set(mine.map((each) => each.name));
280
+ const keyNames = new Set(keys.map((each) => each.name));
281
+ return {
282
+ ...table,
283
+ indexes: [
284
+ ...table.indexes.filter((index) => !names.has(index.name)),
285
+ ...mine.flatMap((each) => (each.recorded === undefined ? [] : [each.recorded])),
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)),
291
+ };
292
+ }),
293
+ };
294
+ }
@@ -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
+ }