@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
@@ -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.
@@ -8,6 +8,7 @@
8
8
  import { renderFixShellArg } from '@ultimat3/core';
9
9
  import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
10
10
  import { DbError } from './errors';
11
+ import { statementExcerpt } from './statement-excerpt';
11
12
 
12
13
  /**
13
14
  * The migration advisory lock was still held when the wait ran out. `pg_advisory_lock` blocks with
@@ -155,3 +156,49 @@ export const migrationDestructive = (
155
156
  fix: `add the line "${DESTRUCTIVE_MARKER}" to ${file}, or regenerate it: x db gen "<name>" --allow-destructive`,
156
157
  meta: { file, kind: first.kind, statements: more + 1 },
157
158
  });
159
+
160
+ /**
161
+ * A concurrent index statement in a migration the runner would wrap in a transaction, refused
162
+ * BEFORE `BEGIN` — Postgres answers `25001` only after the transaction is open, in words that name
163
+ * neither the migration nor the marker that fixes it. `-- transaction: false` is the marker
164
+ * (`concurrent-index.ts`); the path is the one directory `x db gen` writes, `packages/db/migrations`.
165
+ * The id is FILENAME text, so it reaches the command through `renderFixShellArg`.
166
+ */
167
+ export const migrationConcurrentInTransaction = (id: string, statement: string): DbError =>
168
+ new DbError({
169
+ code: 'X_MIGRATION_CONCURRENT_IN_TRANSACTION',
170
+ cause:
171
+ `migration "${id}" runs inside a transaction and holds a statement Postgres refuses there ` +
172
+ `(25001): ${statementExcerpt(statement)}`,
173
+ // The marker is spelled out rather than spliced: it is `NO_TRANSACTION_MARKER`, and
174
+ // `concurrent-index.ts` imports nothing from here, but a constant in the command position is
175
+ // one the shell-argument screen cannot tell from caller input.
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
+ meta: { id },
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
@@ -0,0 +1,192 @@
1
+ // Single responsibility: apply ONE migration script and its ledger write — inside a transaction,
2
+ // or, for a migration marked `-- transaction: false`, statement by statement outside one. Split
3
+ // from `migrate.ts`, which keeps the lock, the ledger audit and the loops over pending migrations;
4
+ // `migrate()` and `rollback()` both come through here, so both directions take the same path.
5
+
6
+ import { logger } from '@ultimat3/core';
7
+ import type { DbClient } from './client';
8
+ import {
9
+ concurrentIndexTarget,
10
+ firstConcurrentStatement,
11
+ isConcurrentIndexStatement,
12
+ isNonTransactional,
13
+ isPartitionedBuildRefusal,
14
+ } from './concurrent-index';
15
+ import { refuseDependentViews } from './dependent-view';
16
+ import { type LockRetryPolicy, withLockRetry } from './migrate-lock-retry';
17
+ import {
18
+ migrationConcurrentInTransaction,
19
+ migrationConcurrentPartitioned,
20
+ } from './migration-errors';
21
+ import { raw, sql } from './sql';
22
+ import { statementsOf } from './statement-split';
23
+ import { withTransaction } from './transaction';
24
+
25
+ export interface MigrationStep {
26
+ readonly id: string;
27
+ /** The migration's `up` — where the marker lives, whichever direction runs. */
28
+ readonly up: string;
29
+ /** What runs: `up` forwards, `down` on a rollback. */
30
+ readonly script: string;
31
+ /** `SET LOCAL lock_timeout` — every transaction, and no concurrent statement. `0` sets none. */
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;
35
+ /** The ledger write, on the client the statements ran on — after the last of them succeeded. */
36
+ readonly record: (client: DbClient) => Promise<void>;
37
+ }
38
+
39
+ /**
40
+ * A migration's `up` and `down` are **scripts**, and one send is one statement — because the two
41
+ * drivers disagree about anything else. PGlite's `query()` is the extended protocol always and
42
+ * answers `cannot insert multiple commands into a prepared statement`; `Bun.SQL.unsafe` degrades
43
+ * to the simple protocol when no value is bound and applies the same script, which is a fact about
44
+ * bun 1.3.14 and not a contract. An empty script sends nothing at all, which is how a no-op
45
+ * migration reaches its ledger row.
46
+ */
47
+ async function applyScript(tx: DbClient, script: string): Promise<void> {
48
+ for (const statement of statementsOf(script)) await tx.execute(raw(statement));
49
+ }
50
+
51
+ /**
52
+ * Bound how long this migration will queue behind a lock it cannot take.
53
+ *
54
+ * `alter table … add column` needs `ACCESS EXCLUSIVE`. A long `SELECT` holding `ACCESS SHARE`
55
+ * makes it wait — and because Postgres' lock queue is FIFO, **every subsequent query on that table
56
+ * queues behind the ALTER**. The `migrate` profile runs `statement_timeout = 0` deliberately, so
57
+ * without this the migrator waits forever and the app is down on one table for as long as the
58
+ * reporting query runs. `lock_timeout` bounds the *wait* alone and never the work.
59
+ *
60
+ * `SET LOCAL`, inside the migration's own transaction: it reverts at COMMIT, so a value chosen for
61
+ * DDL never leaks onto the session the next migration runs on. The failure it produces is `55P03`,
62
+ * typed as `X_DB_LOCK_TIMEOUT` by `driverError`, whose fix is `x db diagnostics --json`.
63
+ */
64
+ async function setLockTimeout(tx: DbClient, lockTimeoutMs: number): Promise<void> {
65
+ if (lockTimeoutMs <= 0) return;
66
+ // `SET LOCAL` takes no parameter placeholder, and the value is a validated integer of ours.
67
+ await tx.execute(raw(`SET LOCAL lock_timeout = ${Math.round(lockTimeoutMs)}`));
68
+ }
69
+
70
+ /**
71
+ * An interrupted `create index concurrently` leaves the index behind marked INVALID: never used by
72
+ * the planner, still maintained on every write, and — the part that bites — still holding the
73
+ * name, so `if not exists` would skip the rebuild and record the step as done. So before each
74
+ * named concurrent build: an INVALID index under that name is dropped (concurrently) first.
75
+ *
76
+ * `to_regclass` resolves the name exactly as the statement will — quoting and `search_path`
77
+ * included — and answers `null` for a missing one. The name is the statement's own text, which
78
+ * this runner is about to send anyway, so the drop adds no surface the build did not have.
79
+ */
80
+ async function dropInvalidLeftover(session: DbClient, statement: string): Promise<void> {
81
+ const name = concurrentIndexTarget(statement);
82
+ if (name === null) return;
83
+ const row = await session.one<{ valid: boolean }>(
84
+ sql`select i.indisvalid as valid from pg_index i where i.indexrelid = to_regclass(${name})`,
85
+ );
86
+ if (row === null || row.valid) return;
87
+ logger.warn('ultimate migrate dropping an invalid index left by an interrupted build', {
88
+ index: name,
89
+ cause: `index ${name} is INVALID — a concurrent build of it failed or was interrupted`,
90
+ fix: 'x db migrate --json # already running: the leftover is dropped and built again',
91
+ });
92
+ await session.execute(raw(`drop index concurrently if exists ${name}`));
93
+ }
94
+
95
+ /**
96
+ * Outside a transaction, one statement at a time. A concurrent index statement runs bare, with no
97
+ * `lock_timeout`: the concurrent forms take SHARE UPDATE EXCLUSIVE, which queues no read and no
98
+ * write, and the long waits they do make — for every transaction older than the build — are lock
99
+ * waits too, so the 3 s bound would turn a slow-but-harmless build into an INVALID index.
100
+ *
101
+ * Every other statement — the `alter table … drop constraint` a UNIQUE removal needs, a hand-written
102
+ * `drop table` — can queue the table, so it gets a transaction of its own under the same
103
+ * `SET LOCAL lock_timeout` a transactional migration runs with. Only what Postgres refuses inside a
104
+ * transaction (`vacuum`, `create database`, …: `AUTOCOMMIT_ONLY`) runs autocommitted.
105
+ *
106
+ * The step must be idempotent, since a failure leaves what already ran in place and no ledger
107
+ * row: `x db gen` writes only `if not exists` / `if exists` forms here.
108
+ */
109
+ /**
110
+ * The statements Postgres refuses inside a transaction block (25001): they run autocommitted. Every
111
+ * other statement under the marker — an `alter table`, a `drop table`, any DDL that can queue the
112
+ * table behind a long reader — runs in a short transaction under the migration's `lock_timeout`.
113
+ */
114
+ const AUTOCOMMIT_ONLY =
115
+ /^\s*(?:vacuum|create\s+database|drop\s+database|alter\s+system|create\s+tablespace|drop\s+tablespace|reindex\s+(?:\(.*\)\s*)?(?:system|database)\b|create\s+subscription|drop\s+subscription|alter\s+subscription)\b/i;
116
+ /** `cluster` with no table re-clusters every table: refused in a transaction. One table's is not. */
117
+ const BARE_CLUSTER = /^\s*cluster(?:\s+verbose)?\s*;?\s*$/i;
118
+
119
+ /** A statement's text past its leading `--` lines (the step's own marker rides the first one). */
120
+ const withoutLeadingComments = (statement: string): string =>
121
+ statement.replace(/^(?:\s*--[^\n]*(?:\n|$))+/, '');
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
+
131
+ async function applyOutsideTransaction(session: DbClient, step: MigrationStep): Promise<void> {
132
+ await refuseDependentViews(session, step.script);
133
+ for (const statement of statementsOf(step.script)) {
134
+ if (isConcurrentIndexStatement(statement)) {
135
+ await dropInvalidLeftover(session, 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
+ });
143
+ continue;
144
+ }
145
+ // Postgres refuses these inside a transaction (25001): autocommitted, as the marker promises.
146
+ const bare = withoutLeadingComments(statement);
147
+ if (AUTOCOMMIT_ONLY.test(bare) || BARE_CLUSTER.test(bare)) {
148
+ await session.execute(raw(statement));
149
+ continue;
150
+ }
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
+ ),
161
+ );
162
+ }
163
+ await step.record(session);
164
+ }
165
+
166
+ /** Apply one migration script and record it, on the migrator's own pinned session. */
167
+ export async function applyMigrationStep(session: DbClient, step: MigrationStep): Promise<void> {
168
+ if (isNonTransactional(step.up)) return applyOutsideTransaction(session, step);
169
+ // Before `BEGIN`, never after the failure: inside the transaction Postgres' 25001 names neither
170
+ // the migration nor the marker that fixes it.
171
+ const refused = firstConcurrentStatement(step.script);
172
+ if (refused !== undefined) {
173
+ throw migrationConcurrentInTransaction(step.id, refused);
174
+ }
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
+ ),
191
+ );
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
+ }