@ultimat3/db 29.4.0 → 30.1.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,10 +1,16 @@
1
1
  // Single responsibility: recognise a ledger an OLDER build is being rolled back onto — rows the
2
- // newer build applied, every one of them newer than anything this build ships — so the migrate
3
- // role lets a rollback through instead of failing the deploy that is trying to undo a bad one.
2
+ // newer build applied, every one of them newer than anything this build ships — and decide whether
3
+ // the migrate role lets that rollback through: yes over additive rows, no over destructive ones.
4
4
 
5
+ import { renderFixShellArg } from '@ultimat3/core';
6
+ import { isDestructiveMigration } from './destructive';
7
+ import { DbError } from './errors';
5
8
  import type { LedgerRow, Migration } from './migration-ledger';
6
9
  import { pendingMigrations } from './migration-ledger';
7
10
 
11
+ /** The operator's written override: the destructive migration ids they checked, comma-separated. */
12
+ export const ROLLBACK_ALLOW_DESTRUCTIVE_ENV = 'ROLLBACK_ALLOW_DESTRUCTIVE';
13
+
8
14
  /** A rollback: the rows this build does not know (all newer), and the ledger it does. */
9
15
  export interface LedgerAhead {
10
16
  /** The rows a newer build applied, oldest first. Never empty. */
@@ -13,6 +19,15 @@ export interface LedgerAhead {
13
19
  readonly known: readonly LedgerRow[];
14
20
  }
15
21
 
22
+ export interface LedgerAheadOptions {
23
+ /** `ROLLBACK_ALLOW_DESTRUCTIVE`'s value; read from the environment when not given. */
24
+ readonly allow?: string | undefined;
25
+ }
26
+
27
+ /** What the ledger records at apply: whether this migration's `up` destroys data. */
28
+ export const destructiveBit = (migration: Migration): boolean =>
29
+ isDestructiveMigration(migration.up);
30
+
16
31
  /**
17
32
  * The ledger split, when it is exactly a rollback, else `undefined` and the caller's audit refuses
18
33
  * the unknown rows as it always has. Three conditions, all required:
@@ -25,13 +40,15 @@ export interface LedgerAhead {
25
40
  * - the build ships at least one migration. An image that lost its migrations directory is not
26
41
  * "older than every row", and reading it as one would wave through the image that is broken.
27
42
  *
28
- * Nothing is applied either way: the schema the newer build left is additive over this one when
29
- * the release followed expand/contract (docs/ops/06-runbooks.md), and when it did not, no
30
- * migration this build ships could put it back.
43
+ * Nothing is applied either way. Over ADDITIVE rows the older build runs on the newer schema
44
+ * (expand/contract, docs/ops/06-runbooks.md). Over a row the newer build recorded as destructive
45
+ * it may not — a dropped column it still selects is the second outage — so that rollback is
46
+ * refused (`X_MIGRATION_ROLLBACK_DESTRUCTIVE`) unless the operator named every such row.
31
47
  */
32
48
  export function ledgerAheadOfBuild(
33
49
  ledger: readonly LedgerRow[],
34
50
  migrations: readonly Migration[],
51
+ options: LedgerAheadOptions = {},
35
52
  ): LedgerAhead | undefined {
36
53
  if (migrations.length === 0) return undefined;
37
54
  const shipped = new Set(migrations.map((migration) => migration.id));
@@ -40,5 +57,27 @@ export function ledgerAheadOfBuild(
40
57
  if (unknown.length === 0 || unknown.some((row) => row.id <= newest)) return undefined;
41
58
  const known = ledger.filter((row) => shipped.has(row.id));
42
59
  if (pendingMigrations(known, migrations).length > 0) return undefined;
43
- return { ahead: [...unknown].sort((a, b) => (a.id < b.id ? -1 : 1)), known };
60
+ const ahead = [...unknown].sort((a, b) => (a.id < b.id ? -1 : 1));
61
+ assertRollbackSafe(ahead, options.allow ?? Bun.env[ROLLBACK_ALLOW_DESTRUCTIVE_ENV]);
62
+ return { ahead, known };
63
+ }
64
+
65
+ /** Every destructive row ahead must be named by the override; a null bit is not refused. */
66
+ function assertRollbackSafe(ahead: readonly LedgerRow[], allow: string | undefined): void {
67
+ const named = new Set(
68
+ (allow ?? '')
69
+ .split(',')
70
+ .map((id) => id.trim())
71
+ .filter((id) => id !== ''),
72
+ );
73
+ const refused = ahead.filter((row) => row.destructive === true && !named.has(row.id));
74
+ if (refused.length === 0) return;
75
+ const ids = refused.map((row) => row.id);
76
+ const all = ahead.filter((row) => row.destructive === true).map((row) => row.id);
77
+ throw new DbError({
78
+ code: 'X_MIGRATION_ROLLBACK_DESTRUCTIVE',
79
+ cause: `the ledger holds ${ids.length} destructive migration(s) a newer build applied (${ids.join(', ')}) and this build predates them — it may read a column or table that no longer exists`,
80
+ fix: `x deploy --image <the newer image> --json # roll forward; or, after checking this build runs on the newer schema: ${ROLLBACK_ALLOW_DESTRUCTIVE_ENV}=${renderFixShellArg(all.join(','), '<migration id>')} on the migrate role`,
81
+ meta: { refused: ids, appVersions: [...new Set(refused.map((row) => row.app_version))] },
82
+ });
44
83
  }
package/src/migrate.ts CHANGED
@@ -3,10 +3,18 @@
3
3
  // app-version fence is the `migrate` role's contract — a pod must refuse to migrate a database
4
4
  // another build already owns, because the alternative is two schemas racing during a rollout.
5
5
 
6
- import { appVersion, finiteCount, logger, renderFixShellArg } from '@ultimat3/core';
6
+ import {
7
+ appVersion,
8
+ finiteCount,
9
+ isProduction,
10
+ logger,
11
+ type Random,
12
+ renderFixShellArg,
13
+ } from '@ultimat3/core';
7
14
  import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
8
15
  import { expectedQueryLoop } from './expected-loop';
9
- import { ledgerAheadOfBuild } from './migrate-rollback';
16
+ import { MIGRATION_LOCK_RETRIES } from './migrate-lock-retry';
17
+ import { destructiveBit, ledgerAheadOfBuild } from './migrate-rollback';
10
18
  import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './migration-errors';
11
19
  import type { Migration } from './migration-ledger';
12
20
  import {
@@ -19,6 +27,8 @@ import {
19
27
  } from './migration-ledger';
20
28
  import { applyMigrationStep } from './migration-step';
21
29
  import { poolProfileFor } from './pool-profile';
30
+ import { retireAndContractPending } from './rolling';
31
+ import { retireAndContractTogether } from './rolling-errors';
22
32
  import { raw, sql } from './sql';
23
33
 
24
34
  export type { LedgerRow, Migration } from './migration-ledger';
@@ -81,6 +91,13 @@ export interface MigrateOptions {
81
91
  readonly lockWaitMs?: number | undefined;
82
92
  /** `SET LOCAL lock_timeout` per migration. Defaults to the `migrate` role's profile. */
83
93
  readonly lockTimeoutMs?: number | undefined;
94
+ /** Attempts past the first when `lock_timeout` fires (`migrate-lock-retry.ts`). Defaults to 5. */
95
+ readonly lockRetries?: number | undefined;
96
+ /** Seams for the wait between those attempts; a test reads the schedule. */
97
+ readonly lockRetrySleep?: ((ms: number) => Promise<void>) | undefined;
98
+ readonly lockRetryRandom?: Random | undefined;
99
+ /** Whether a retire and its drop may land in one deploy. Defaults to core's `isProduction()`. */
100
+ readonly production?: boolean | undefined;
84
101
  }
85
102
 
86
103
  /**
@@ -192,10 +209,15 @@ function migrationLockTimeoutMs(explicit: number | undefined): number {
192
209
  );
193
210
  }
194
211
 
212
+ /** Screened beside `lockTimeoutMs`, before the advisory lock, for the reason given there. */
213
+ const migrationLockRetries = (explicit: number | undefined): number =>
214
+ finiteCount('migrate', 'lockRetries', explicit ?? MIGRATION_LOCK_RETRIES, 0);
215
+
195
216
  export async function migrate(options: MigrateOptions): Promise<MigrationReport> {
196
217
  const client = options.client ?? baseClient();
197
218
  const appVersion = runningAppVersion(options.appVersion);
198
219
  const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
220
+ const retries = migrationLockRetries(options.lockRetries);
199
221
  const started = performance.now();
200
222
 
201
223
  return withAdvisoryLock(
@@ -222,6 +244,13 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
222
244
  }
223
245
 
224
246
  const pending = pendingMigrations(ledger, options.migrations);
247
+ // Before the first BEGIN, so nothing is half done. Only where a previous release can be
248
+ // running: a non-empty ledger in production. A fresh database, a template or a branch has no
249
+ // old pods to break and replays both freely.
250
+ if (ledger.length > 0 && (options.production ?? isProduction())) {
251
+ const pair = retireAndContractPending(pending);
252
+ if (pair !== undefined) throw retireAndContractTogether(pair);
253
+ }
225
254
  // A statement per migration and a transaction per migration is the point, not an N+1 to batch:
226
255
  // one failed `up` must leave the ledger describing exactly the migrations that did run, and a
227
256
  // batch commits or loses all of them together. Declared here so a diagnostic reports the loops
@@ -240,12 +269,20 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
240
269
  up: migration.up,
241
270
  script: migration.up,
242
271
  lockTimeoutMs,
272
+ lockRetry: {
273
+ retries,
274
+ sleep: options.lockRetrySleep,
275
+ random: options.lockRetryRandom,
276
+ },
277
+ // The bit an OLDER build reads on a rollback (`migrate-rollback.ts`): recorded here,
278
+ // by the build that knows the SQL, because the one rolled back to never sees it.
243
279
  record: async (client) => {
244
280
  const durationMs = Math.round(performance.now() - at);
245
281
  await client.execute(sql`
246
- insert into ${raw(LEDGER_TABLE)} (id, name, checksum, app_version, duration_ms)
282
+ insert into ${raw(LEDGER_TABLE)}
283
+ (id, name, checksum, app_version, duration_ms, destructive)
247
284
  values (${migration.id}, ${migration.name}, ${migrationChecksum(migration)},
248
- ${appVersion}, ${durationMs})
285
+ ${appVersion}, ${durationMs}, ${destructiveBit(migration)})
249
286
  `);
250
287
  },
251
288
  });
@@ -284,6 +321,8 @@ export interface RollbackOptions {
284
321
  readonly lockWaitMs?: number | undefined;
285
322
  /** `SET LOCAL lock_timeout` per reversal. Defaults to the `migrate` role's profile. */
286
323
  readonly lockTimeoutMs?: number | undefined;
324
+ /** Attempts past the first when `lock_timeout` fires. Defaults to 5, as `migrate`'s does. */
325
+ readonly lockRetries?: number | undefined;
287
326
  }
288
327
 
289
328
  /** Reverse the newest `steps` applied migrations. `x db rollback`. */
@@ -294,6 +333,7 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
294
333
  // "one fewer", so an unvalidated count reverses migrations nobody asked about.
295
334
  if (!Number.isSafeInteger(steps) || steps < 1) throw rollbackStepsInvalid(steps);
296
335
  const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
336
+ const retries = migrationLockRetries(options.lockRetries);
297
337
  const known = new Map(options.migrations.map((migration) => [migration.id, migration]));
298
338
 
299
339
  // The same lock `migrate` takes, because the race is the same one: a rollback reversing the id a
@@ -333,6 +373,7 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
333
373
  up: migration.up,
334
374
  script: migration.down,
335
375
  lockTimeoutMs,
376
+ lockRetry: { retries },
336
377
  record: async (client) => {
337
378
  await client.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
338
379
  },
@@ -1,6 +1,6 @@
1
1
  // Single responsibility: the refusals a MIGRATION earns — the lock it could not take, a ledger that
2
2
  // disagrees with this build, a plan that cannot be reversed, a plan that destroys rows without
3
- // saying so, a snapshot that was never written, and a view standing in a retype's way. Split out of
3
+ // saying so, a snapshot never written, a view in a retype's way, a concurrent build. Split out of
4
4
  // `errors.ts` only because that file reached the 500-line ceiling, exactly as `invariant-errors.ts`
5
5
  // was: every code below is still declared, titled and registered there, and `DbError` is still the
6
6
  // one class. One direction only — nothing here is imported back.
@@ -176,3 +176,29 @@ export const migrationConcurrentInTransaction = (id: string, statement: string):
176
176
  fix: `sed -i '1i -- transaction: false' packages/db/migrations/${renderFixShellArg(id, '<id>')}.sql # before it is applied — or move the statement into a migration of its own`,
177
177
  meta: { id },
178
178
  });
179
+
180
+ /**
181
+ * A concurrent index build on a PARTITIONED parent, which Postgres refuses (`0A000`) — reached as
182
+ * a bare `X_DB_STATEMENT_FAILED` that named the refusal and not the sequence that replaces it.
183
+ * `sourceError` keeps the server's own error, SQLSTATE included. The sequence is pg_dump's: each
184
+ * partition built concurrently, the parent's index `on only` (instant, no rows), then attached.
185
+ */
186
+ export const migrationConcurrentPartitioned = (
187
+ id: string,
188
+ statement: string,
189
+ sourceError: unknown,
190
+ ): DbError =>
191
+ new DbError({
192
+ code: 'X_MIGRATION_CONCURRENT_PARTITIONED',
193
+ cause:
194
+ `migration "${id}" builds an index concurrently on a partitioned table, which Postgres ` +
195
+ `refuses (0A000): ${statementExcerpt(statement)}`,
196
+ // Three pieces, each opening with the statement it is: the parent's index is attached to,
197
+ // never built over, so `on only` is instant and valid once every partition's is attached.
198
+ fix:
199
+ 'CREATE INDEX CONCURRENTLY <index>_<partition> ON <partition> (<columns>); # per partition, then ' +
200
+ 'CREATE INDEX <index> ON ONLY <parent> (<columns>); # then, per partition, ' +
201
+ 'ALTER INDEX <index> ATTACH PARTITION <index>_<partition>; # in a -- transaction: false migration',
202
+ meta: { id, sqlState: '0A000' },
203
+ sourceError,
204
+ });
@@ -3,15 +3,18 @@
3
3
  // Split from `migrate.ts`, which keeps the lock, the apply loop and the rollback; `migrate.ts`
4
4
  // re-exports every name here, so its public surface is unchanged.
5
5
 
6
+ import { isFixShellSafe } from '@ultimat3/core';
6
7
  import type { DbClient } from './client';
7
8
  import type { SchemaDescription } from './introspect';
8
9
  import { migrationConflict } from './migration-errors';
9
- import { migrationNameArg } from './primary-key';
10
10
  import { raw, sql } from './sql';
11
11
  import { SQLSTATE, sqlState } from './sqlstate';
12
12
 
13
13
  export const LEDGER_TABLE = 'x_migrations';
14
14
 
15
+ /** Where `x new` puts an app's migrations: this package reads rows, the CLI reads the directory. */
16
+ const APP_MIGRATIONS_DIR = 'packages/db/migrations';
17
+
15
18
  export interface Migration {
16
19
  /** Sort key and primary key. `20260726120000_add_publish_at`. */
17
20
  readonly id: string;
@@ -31,6 +34,13 @@ export interface LedgerRow {
31
34
  readonly applied_at: string;
32
35
  readonly app_version: string;
33
36
  readonly duration_ms: number;
37
+ /**
38
+ * Whether this migration's `up` destroys data, recorded at apply (`destructiveBit`). `null` on a
39
+ * row applied before the ledger kept the bit: the framework cannot know, and says so
40
+ * (`migrate-rollback.ts` warns rather than refuses over it). Optional so a row a caller builds
41
+ * by hand still types; `readLedger` always answers it.
42
+ */
43
+ readonly destructive?: boolean | null | undefined;
34
44
  }
35
45
 
36
46
  /**
@@ -58,6 +68,11 @@ export async function ensureLedger(client: DbClient): Promise<void> {
58
68
  duration_ms integer not null
59
69
  )
60
70
  `);
71
+ // Added, never part of the create: a ledger every earlier release made has no such column, and
72
+ // its rows keep `null` — "applied before the bit was recorded", never a guessed `false`.
73
+ await client.execute(sql`
74
+ alter table ${raw(LEDGER_TABLE)} add column if not exists destructive boolean
75
+ `);
61
76
  }
62
77
 
63
78
  /**
@@ -76,10 +91,16 @@ export function isLedgerMissing(error: unknown): boolean {
76
91
  return sqlState(error) === SQLSTATE.undefinedTable;
77
92
  }
78
93
 
94
+ /**
95
+ * The bit is read through the row's JSON, not by name: `x db status`, the drift step and the MCP
96
+ * dry run read a ledger `ensureLedger` has not touched, and naming a column an older release's
97
+ * ledger lacks would turn every one of those reads into `42703`. Absent reads `null`.
98
+ */
79
99
  export async function readLedger(client: DbClient): Promise<readonly LedgerRow[]> {
80
100
  return client.query<LedgerRow>(sql`
81
- select id, name, checksum, applied_at, app_version, duration_ms
82
- from ${raw(LEDGER_TABLE)}
101
+ select id, name, checksum, applied_at, app_version, duration_ms,
102
+ (to_jsonb(ledger) ->> 'destructive')::boolean as destructive
103
+ from ${raw(LEDGER_TABLE)} as ledger
83
104
  order by id
84
105
  `);
85
106
  }
@@ -122,11 +143,24 @@ export function auditLedger(
122
143
  if (checksum === row.checksum) continue;
123
144
  throw migrationConflict(
124
145
  `migration "${row.id}" was applied with checksum ${row.checksum} but now hashes ${checksum}`,
125
- `x db gen ${migrationNameArg(`fix ${migration.name}`)} # never edit an applied migration, add a new one`,
146
+ editedMigrationFix(row.id, row.checksum),
126
147
  );
127
148
  }
128
149
  }
129
150
 
151
+ /**
152
+ * Restore first. `x db gen` was this line, and it exits 0 and writes the edited SQL into the schema
153
+ * dump, with `drift` green over it. `git checkout -- <file>` was the next, and a COMMITTED edit
154
+ * restores the same wrong blob, so the line lists the file's revisions and the ledger's checksum
155
+ * names the one applied. The id is a file name and the checksum a ledger value: either one the
156
+ * shell would read is left out (the directory, then the cause) rather than spliced.
157
+ */
158
+ const editedMigrationFix = (id: string, checksum: string): string => {
159
+ const path = isFixShellSafe(id) ? `${APP_MIGRATIONS_DIR}/${id}.sql` : APP_MIGRATIONS_DIR;
160
+ const applied = isFixShellSafe(checksum) ? `checksum ${checksum}` : 'the checksum in the cause';
161
+ return `git log --format='%h %ad %s' --date=short -- ${path} # restore the revision applied with ${applied}: git checkout <rev> -- ${path} and never edit an applied migration; then x db gen "<change>"`;
162
+ };
163
+
130
164
  /**
131
165
  * The migration id is the DATABASE's own text — whoever can write a ledger row picks what lands in
132
166
  * a line an operator pastes — and it goes inside SHELL DOUBLE QUOTES, where `$(…)` and a backtick
@@ -10,9 +10,14 @@ import {
10
10
  firstConcurrentStatement,
11
11
  isConcurrentIndexStatement,
12
12
  isNonTransactional,
13
+ isPartitionedBuildRefusal,
13
14
  } from './concurrent-index';
14
15
  import { refuseDependentViews } from './dependent-view';
15
- import { migrationConcurrentInTransaction } from './migration-errors';
16
+ import { type LockRetryPolicy, withLockRetry } from './migrate-lock-retry';
17
+ import {
18
+ migrationConcurrentInTransaction,
19
+ migrationConcurrentPartitioned,
20
+ } from './migration-errors';
16
21
  import { raw, sql } from './sql';
17
22
  import { statementsOf } from './statement-split';
18
23
  import { withTransaction } from './transaction';
@@ -25,6 +30,8 @@ export interface MigrationStep {
25
30
  readonly script: string;
26
31
  /** `SET LOCAL lock_timeout` — every transaction, and no concurrent statement. `0` sets none. */
27
32
  readonly lockTimeoutMs: number;
33
+ /** What a `lock_timeout` firing is worth (`migrate-lock-retry.ts`). Absent is no second attempt. */
34
+ readonly lockRetry?: Omit<LockRetryPolicy, 'lockTimeoutMs'> | undefined;
28
35
  /** The ledger write, on the client the statements ran on — after the last of them succeeded. */
29
36
  readonly record: (client: DbClient) => Promise<void>;
30
37
  }
@@ -52,7 +59,7 @@ async function applyScript(tx: DbClient, script: string): Promise<void> {
52
59
  *
53
60
  * `SET LOCAL`, inside the migration's own transaction: it reverts at COMMIT, so a value chosen for
54
61
  * DDL never leaks onto the session the next migration runs on. The failure it produces is `55P03`,
55
- * typed as `X_DB_LOCK_TIMEOUT` by `driverError` with the `pg_stat_activity` read as its fix.
62
+ * typed as `X_DB_LOCK_TIMEOUT` by `driverError`, whose fix is `x db diagnostics --json`.
56
63
  */
57
64
  async function setLockTimeout(tx: DbClient, lockTimeoutMs: number): Promise<void> {
58
65
  if (lockTimeoutMs <= 0) return;
@@ -113,12 +120,26 @@ const BARE_CLUSTER = /^\s*cluster(?:\s+verbose)?\s*;?\s*$/i;
113
120
  const withoutLeadingComments = (statement: string): string =>
114
121
  statement.replace(/^(?:\s*--[^\n]*(?:\n|$))+/, '');
115
122
 
123
+ /** The one retry wrap, whichever unit the step's kind makes safe to send again. */
124
+ const retrying = (step: MigrationStep, attempt: () => Promise<void>): Promise<void> =>
125
+ withLockRetry(
126
+ step.id,
127
+ { ...(step.lockRetry ?? { retries: 0 }), lockTimeoutMs: step.lockTimeoutMs },
128
+ attempt,
129
+ );
130
+
116
131
  async function applyOutsideTransaction(session: DbClient, step: MigrationStep): Promise<void> {
117
132
  await refuseDependentViews(session, step.script);
118
133
  for (const statement of statementsOf(step.script)) {
119
134
  if (isConcurrentIndexStatement(statement)) {
120
135
  await dropInvalidLeftover(session, statement);
121
- await session.execute(raw(statement));
136
+ await session.execute(raw(statement)).catch((error: unknown) => {
137
+ // `0A000` on a partitioned parent: the instruction is the sequence that replaces it.
138
+ if (isPartitionedBuildRefusal(statement, error)) {
139
+ throw migrationConcurrentPartitioned(step.id, statement, error);
140
+ }
141
+ throw error;
142
+ });
122
143
  continue;
123
144
  }
124
145
  // Postgres refuses these inside a transaction (25001): autocommitted, as the marker promises.
@@ -127,12 +148,16 @@ async function applyOutsideTransaction(session: DbClient, step: MigrationStep):
127
148
  await session.execute(raw(statement));
128
149
  continue;
129
150
  }
130
- await withTransaction(
131
- async (tx) => {
132
- await setLockTimeout(tx, step.lockTimeoutMs);
133
- await tx.execute(raw(statement));
134
- },
135
- { client: session },
151
+ // This statement and no earlier one: what already ran stays run. From the top would re-swap
152
+ // every validated key to `not valid` and scan it again, and repeat a hand-written first line.
153
+ await retrying(step, () =>
154
+ withTransaction(
155
+ async (tx) => {
156
+ await setLockTimeout(tx, step.lockTimeoutMs);
157
+ await tx.execute(raw(statement));
158
+ },
159
+ { client: session },
160
+ ),
136
161
  );
137
162
  }
138
163
  await step.record(session);
@@ -147,18 +172,21 @@ export async function applyMigrationStep(session: DbClient, step: MigrationStep)
147
172
  if (refused !== undefined) {
148
173
  throw migrationConcurrentInTransaction(step.id, refused);
149
174
  }
150
- await withTransaction(
151
- async (tx) => {
152
- await setLockTimeout(tx, step.lockTimeoutMs);
153
- // Before the first statement, never after the failure: a view compiled against a column
154
- // this script retypes is `0A000` from the server with the view named only in a DETAIL field
155
- // nothing prints (`dependent-view.ts`).
156
- await refuseDependentViews(tx, step.script);
157
- await applyScript(tx, step.script);
158
- await step.record(tx);
159
- },
160
- // The lock's own session: a migration applied on another connection is not covered by the
161
- // lock at all, and on `ROLE=migrate` there is no other connection to apply it on.
162
- { client: session },
175
+ // Whole: the failed attempt rolled back, so nothing it did is left to repeat.
176
+ await retrying(step, () =>
177
+ withTransaction(
178
+ async (tx) => {
179
+ await setLockTimeout(tx, step.lockTimeoutMs);
180
+ // Before the first statement, never after the failure: a view compiled against a column
181
+ // this script retypes is `0A000` from the server with the view named only in a DETAIL field
182
+ // nothing prints (`dependent-view.ts`).
183
+ await refuseDependentViews(tx, step.script);
184
+ await applyScript(tx, step.script);
185
+ await step.record(tx);
186
+ },
187
+ // The lock's own session: a migration applied on another connection is not covered by the
188
+ // lock at all, and on `ROLE=migrate` there is no other connection to apply it on.
189
+ { client: session },
190
+ ),
163
191
  );
164
192
  }
@@ -0,0 +1,56 @@
1
+ // Single responsibility: the two facts a partitioned table changes about the schema dump — the
2
+ // order its tables load in (parent before partition, every one in the ROOT's file) and the index
3
+ // definition its parent's index can be rebuilt from. Pure; `schema-dump.ts` places the statements.
4
+
5
+ import type { CatalogTable } from './catalog';
6
+
7
+ /** A table and the file it is written to: its own, or its partition root's. */
8
+ export interface PlacedTable {
9
+ readonly table: CatalogTable;
10
+ readonly file: string;
11
+ }
12
+
13
+ /**
14
+ * Every table once, each partition after the table it is a partition of, and filed under its root
15
+ * — so `04_tables/events.sql` creates `events` and then each month, whatever the months are
16
+ * called. Name order would create `a_2026_11` before `events`. A partition whose parent is not in
17
+ * the list (another schema's) is filed under itself, after every family.
18
+ */
19
+ export function inLoadOrder(tables: readonly CatalogTable[]): readonly PlacedTable[] {
20
+ const byName = new Map(tables.map((table) => [table.name, table]));
21
+ const children = new Map<string, CatalogTable[]>();
22
+ for (const table of tables) {
23
+ const parent = table.partitionOf?.table;
24
+ if (parent === undefined || !byName.has(parent)) continue;
25
+ const siblings = children.get(parent) ?? [];
26
+ siblings.push(table);
27
+ children.set(parent, siblings);
28
+ }
29
+ const placed: PlacedTable[] = [];
30
+ const seen = new Set<string>();
31
+ const family = (root: CatalogTable): void => {
32
+ // Breadth first: a sub-partition comes after every partition of the level above it.
33
+ const queue = [root];
34
+ for (let at = 0; at < queue.length; at += 1) {
35
+ const table = queue[at];
36
+ if (table === undefined || seen.has(table.name)) continue;
37
+ seen.add(table.name);
38
+ placed.push({ table, file: root.name });
39
+ queue.push(...(children.get(table.name) ?? []));
40
+ }
41
+ };
42
+ for (const table of tables) {
43
+ const parent = table.partitionOf?.table;
44
+ if (parent === undefined || !byName.has(parent)) family(table);
45
+ }
46
+ return placed;
47
+ }
48
+
49
+ /**
50
+ * `pg_get_indexdef` spells a partitioned table's index `ON ONLY` — the form that builds the
51
+ * parent's index alone, INVALID until every partition's is attached. The dump renders no
52
+ * partition's attached index, so it loads the parent's without `ONLY`, which builds them all.
53
+ */
54
+ const ON_ONLY = /^(CREATE (?:UNIQUE )?INDEX (?:"(?:[^"]|"")*"|\S+) ON )ONLY /;
55
+
56
+ export const indexDefinitionOf = (definition: string): string => definition.replace(ON_ONLY, '$1');
@@ -0,0 +1,24 @@
1
+ // Single responsibility: name the PARTITIONED parents in a schema (`relkind = 'p'`), so a table
2
+ // description can say it is one. The partitions themselves are not app schema and never reach
3
+ // here: `nonAppRelations()` excludes every `relispartition` row, a sub-partition's parent included.
4
+
5
+ import type { DbClient } from './client';
6
+ import { sql } from './sql';
7
+
8
+ interface PartitionedRow {
9
+ readonly name: string;
10
+ }
11
+
12
+ /** The partitioned tables of `schema` that are not themselves a partition — the ones migrations declare. */
13
+ export async function partitionedTables(
14
+ client: DbClient,
15
+ schema: string,
16
+ ): Promise<readonly string[]> {
17
+ const rows = await client.query<PartitionedRow>(sql`
18
+ select c.relname as name
19
+ from pg_class c
20
+ join pg_namespace n on n.oid = c.relnamespace
21
+ where n.nspname = ${schema} and c.relkind = 'p' and not c.relispartition
22
+ `);
23
+ return rows.map((row) => row.name);
24
+ }
@@ -0,0 +1,120 @@
1
+ // Single responsibility: how many units of work may be out on the pool at once, and how long the
2
+ // next one waits. `Bun.SQL` queues past `max` with no bound and publishes no occupancy, so this counts
3
+ // in front of it: a statement and a pin each hold one slot, and a full pool refuses on one deadline.
4
+
5
+ import type { Scheduler } from '@ultimat3/core';
6
+ import { type DbError, poolAcquireTimeout } from './errors';
7
+ import type { PoolDemand } from './pool-gauge';
8
+ import { unrefSchedule } from './pool-timer';
9
+
10
+ export interface PoolGate {
11
+ /**
12
+ * Take a slot. `undefined` when one was free, so the path that never waits pays no promise and
13
+ * sends in the caller's own turn. Otherwise resolves `true` when the slot arrives, `false` when
14
+ * this pool retired first, and rejects past `acquireTimeoutMs` (`X_DB_POOL_EXHAUSTED`). `0` waits
15
+ * forever.
16
+ */
17
+ enter(acquireTimeoutMs: number): Promise<boolean> | undefined;
18
+ /** Give a slot back. The oldest waiter inherits it, so a newcomer never overtakes the queue. */
19
+ leave(): void;
20
+ /** This pool is gone but the database may not be: everyone queued asks the next pool. */
21
+ handOverWaiting(): void;
22
+ /** Reject everyone still queued. Slots already held are untouched. */
23
+ refuseWaiting(reason: DbError): void;
24
+ }
25
+
26
+ /** What `takeSlot` needs of a pool: the gate that admits to it and the counter that reports it. */
27
+ export interface GatedPool {
28
+ readonly gate: PoolGate;
29
+ readonly demand: PoolDemand;
30
+ }
31
+
32
+ /**
33
+ * A slot on the current pool, counted from the ask: a statement queued behind a full pool is exactly
34
+ * what `db_pool_waiting` reports. Answers the pool itself when a slot was free, so a caller checking
35
+ * `instanceof Promise` sends in its own turn. A retirement during the wait re-asks the pool that
36
+ * replaced it, with a fresh deadline: the work was never sent, so it can only be late, never doubled.
37
+ */
38
+ export function takeSlot<P extends GatedPool>(
39
+ connect: () => P,
40
+ acquireTimeoutMs: number,
41
+ ): P | Promise<P> {
42
+ const asked = connect();
43
+ asked.demand.enter();
44
+ const wait = asked.gate.enter(acquireTimeoutMs);
45
+ if (wait === undefined) return asked;
46
+ return (async () => {
47
+ let admitted: boolean;
48
+ try {
49
+ admitted = await wait;
50
+ } catch (error) {
51
+ asked.demand.leave();
52
+ throw error;
53
+ }
54
+ if (admitted) return asked;
55
+ asked.demand.leave();
56
+ return takeSlot(connect, acquireTimeoutMs);
57
+ })();
58
+ }
59
+
60
+ /** The other half of `takeSlot`. */
61
+ export function freeSlot(pool: GatedPool): void {
62
+ pool.gate.leave();
63
+ pool.demand.leave();
64
+ }
65
+
66
+ interface Waiter {
67
+ readonly settle: (admitted: boolean) => void;
68
+ readonly refuse: (reason: DbError) => void;
69
+ disarm: () => void;
70
+ }
71
+
72
+ export function poolGate(max: number, schedule: Scheduler = unrefSchedule): PoolGate {
73
+ let out = 0;
74
+ // A Set: FIFO by insertion, and a waiter past its deadline leaves from the middle in O(1).
75
+ const waiting = new Set<Waiter>();
76
+
77
+ return {
78
+ enter(acquireTimeoutMs) {
79
+ // `leave` hands a slot over rather than freeing it, so a free slot means nobody is queued.
80
+ if (out < max) {
81
+ out += 1;
82
+ return undefined;
83
+ }
84
+ return new Promise<boolean>((settle, refuse) => {
85
+ const waiter: Waiter = { settle, refuse, disarm: () => undefined };
86
+ waiting.add(waiter);
87
+ if (acquireTimeoutMs <= 0) return;
88
+ waiter.disarm = schedule(() => {
89
+ waiting.delete(waiter);
90
+ refuse(poolAcquireTimeout(acquireTimeoutMs, max));
91
+ }, acquireTimeoutMs);
92
+ });
93
+ },
94
+ leave() {
95
+ const next = waiting.values().next();
96
+ if (next.done === true) {
97
+ out = Math.max(0, out - 1);
98
+ return;
99
+ }
100
+ // Handed over: `out` does not move, the slot changes hands.
101
+ waiting.delete(next.value);
102
+ next.value.disarm();
103
+ next.value.settle(true);
104
+ },
105
+ handOverWaiting() {
106
+ for (const waiter of waiting) {
107
+ waiter.disarm();
108
+ waiter.settle(false);
109
+ }
110
+ waiting.clear();
111
+ },
112
+ refuseWaiting(reason) {
113
+ for (const waiter of waiting) {
114
+ waiter.disarm();
115
+ waiter.refuse(reason);
116
+ }
117
+ waiting.clear();
118
+ },
119
+ };
120
+ }