@ultimat3/db 29.3.1 → 29.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -109,19 +109,22 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
109
109
  `MIGRATION_LOCK_WAIT_MS`, then `X_MIGRATE_CONCURRENT`), declared with `expectedQueryLoop`;
110
110
  `recordingClient` stubs the lock as `locked: true`.
111
111
  - **`lock_timeout` is the migration's** (`SET LOCAL` inside each migration's transaction, from the
112
- `migrate` profile's 3 s).
112
+ `migrate` profile's 3 s). `migration-step.ts` applies a script.
113
+ - **`-- transaction: false` runs a migration outside a transaction** (`concurrent-index.ts`, a marker
114
+ on `up` that governs `down`): a concurrent statement bare and unbounded, any other in its own tx; an
115
+ INVALID index holding a concurrent build's name is dropped first. A concurrent statement in a
116
+ transactional migration is `X_MIGRATION_CONCURRENT_IN_TRANSACTION`, before `BEGIN`.
113
117
  - **The advisory lock is held by one pinned session, and `migrate()`/`rollback()` run every statement
114
118
  on it.** `lock: false` reserves nothing and takes no lock; no shipped path passes it
115
119
  (`migrate-pin.test.ts`). `migrate.live.test.ts` pins concurrent and failed-midway runs.
116
- - **One send is one statement**: `applyScript` sends `statementsOf(script)` one at a time inside the
117
- same transaction. **`statement-split.ts` is the only splitter** (a left-to-right scan; `$1` is never a
118
- `$tag$`; `\` escapes only inside `E''`; comment-only chunks dropped). **`sql-scan.ts` is the one
120
+ - **One send is one statement**: `applyScript` sends `statementsOf(script)` one at a time.
121
+ **`statement-split.ts` is the only splitter** (a left-to-right scan; `$1` is never a `$tag$`; `\`
122
+ escapes only inside `E''`; comment-only chunks dropped). **`sql-scan.ts` is the one
119
123
  lexer** (`noiseAt`, source order; a `$tag$` needs separating from the identifier before it);
120
124
  `sql-noise.ts` holds `stripSqlNoise`.
121
125
  - **`destructive.ts` decides WHAT is destructive**: only `up`, a closed list of four (`drop table`,
122
126
  `drop column`, `truncate`, `alter column … type`), decided on blanked text and reported on the
123
- original; the `-- destructive: true` marker is a top-level line comment (`hasDestructiveMarker`
124
- walks `sql-scan.ts`). `X_MIGRATION_DESTRUCTIVE` (ship) and `X_MIGRATION_IRREVERSIBLE` (generate) are
127
+ original; a marker is a top-level line comment (`marker-line.ts`). `X_MIGRATION_DESTRUCTIVE` (ship) and `X_MIGRATION_IRREVERSIBLE` (generate) are
125
128
  two questions.
126
129
  - **The ledger audit asks one question** — `auditLedger`'s `foreign` filter is `!known.has(row.id)`;
127
130
  the app version lives in the cause.
@@ -142,10 +145,11 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
142
145
  refuses); absent is `btree` through one function; `snapshotOf` records `using` only when declared;
143
146
  `indexMethodSql` re-derives the literal (`X_SQL_UNSAFE` default); a unique or ordered GIN is
144
147
  `X_INVARIANT`. `introspectSchema()` reads `pg_am` (`introspect-embedded.test.ts`).
145
- - **`index-plan.ts` walks both directions** (declared first, removed last). `dropRecordedIndex` emits
146
- `alter table … drop constraint if exists` then `drop index` for a shape a constraint could back
147
- (`mayBeConstraintBacked`); four names are skipped (primary, moved aside, rebuilt, over a dropped
148
- column). `index-ddl.ts` writes the statements; `index-removal.live.test.ts`.
148
+ - **`index-plan.ts` walks both directions** (declared first, removed last); four names are skipped
149
+ (primary, moved aside, rebuilt, over a dropped column). **A removal and a `concurrently: true`
150
+ build on a live table go to the non-transactional step** (`concurrent-plan.ts`; `generate-steps.ts`
151
+ makes it `<id>_concurrently` or the whole migration); a shape a constraint could back
152
+ (`mayBeConstraintBacked`) is unhooked in the step, after its builds. Never recorded (history).
149
153
  - **An entity's INVARIANTS reach the DDL** (`invariant-ddl.ts`): a `check` is a named constraint, a
150
154
  `unique` a unique INDEX on the one `declaredIndexes` list, an `assert` nothing; `checks` is recorded
151
155
  absent-never-`[]`; the name `<table>_<name>_<check|key>` is re-derived, bounded at 63 bytes and
@@ -169,12 +173,8 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
169
173
  (`retype-keys.ts`): the retype set is derived once for the whole schema (`retypedColumns`), drops go
170
174
  in `preAlters` at the top of `up`, re-adding is `foreignKeyPlan`'s, both `breaksOn` ends are needed
171
175
  (`generate-retype-key.live.test.ts`). `sql-type.ts` reads `SQL_TYPES` with `Object.hasOwn`.
172
- - **A generated column** (`generated-column.ts`): the clause right after the type; generated-and-
173
- defaulted refused; an expression change is `set expression as (…)`; a retype has no `using`; a NOT
174
- NULL add is one statement; generated → plain is `drop expression`; plain → generated rebuilds
175
- (`rebuilt`) and moves dependents aside, its own type change does not. `introspectSchema` never reads
176
- `generation_expression`.
177
- `generate-generated-{column,rebuild}.live.test.ts`.
176
+ - **A generated column** (`generated-column.ts`): plain → generated rebuilds (`rebuilt`); the rest of
177
+ its rules are in `docs/history/db.md` (2026-10-31). `generate-generated-{column,rebuild}.live.test.ts`.
178
178
  - **`REPLICA IDENTITY FULL` is a PARAMETER** (`GenerateOptions.replicaIdentityFull`, from the CLI's
179
179
  `db-generate.ts`; `replica-identity.ts`): recorded `true` or absent, as a union; last in `up`; an
180
180
  undeclared name skipped; `down` reverts it unless this migration created the table.
@@ -218,8 +218,6 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
218
218
  `x_migrations` by default. **`app-relation.ts`**: `nonAppRelations(client, schema)` — extension
219
219
  ownership from `pg_depend` (`deptype = 'e'`) plus views, materialised views and foreign tables —
220
220
  merged into `excluded` unconditionally.
221
- - **`unexpectedTable`'s `fix:` never names `x db gen`**: `psql -c '\d "T"'`, commented with the two
222
- repairs (claim it with `create table if not exists`, or drop it).
223
221
  - **`dbDrift()` lives in `drift-errors.ts`** (it needs `shellInertIdentifier`) and is the only one —
224
222
  `@ultimat3/entity`'s copy is deleted. `errors.ts` registers `DB_ERROR_TITLES` unconditionally.
225
223
  - `drift-findings.ts` holds every `DriftDifference` constructor and `DriftKind`; `drift.ts` keeps the
package/README.md CHANGED
@@ -50,6 +50,7 @@ await withTransaction(async (tx) => {
50
50
  | `declaredChecks()` / `checkClauses()` / `checkPlan()` / `columnChecks()` / `columnCheckName()` / `columnNamesConstraint()` | `As of 2026-08-25`: **every** CHECK a table declares — a column's own (`enumerated()`'s value set, `tz()`'s IANA whitelist, `locale()`'s tags, money's currency pattern and scale bound) and an invariant's — on ONE list, so `createTable`, `diffTable` and `snapshotOf` agree about what exists. A column's check reached `create table` **inline and anonymous** and nothing else: the snapshot recorded none and the diff had no arm, so a value added to `enumerated()` generated no migration and a regenerated ENUM column came back as bare `text`. The name is `<table>_<column>_check` because that is the name **Postgres itself mints** for the old anonymous form — measured — so the repair lands on the constraint an already-generated database is holding; `checkPlan` emits `drop constraint if exists` before the `add` for exactly that column, because a bare add is `42710` there and a no-op everywhere else |
51
51
  | `defaultExpression()` / `ColumnDefaultLike` | `As of 2026-08-25`: a column's `default` as SQL. A DECLARED default (`{ kind: 'value', value }`) wins; `gen_random_uuid()` and `now()` stay as the inference for a description that carries only `hasDefault` |
52
52
  | `unrenderedOf()` / `unrenderedComment()` / `UnrenderedDeclaration` | `As of 2026-08-25`: what the generator could **not** write, on `GeneratedMigration.unrendered` and as a `-- UNRENDERED` block at the top of a non-empty `up`. A generator that emits less than the declaration in silence is the defect the whole file exists against, and `x verify`'s `drift` step reads a source hash — it never reads the SQL, so the loss was green. **`unrenderedOf(entities, current)` takes the recorded schema**, required and nullable: a rule declared as an `assert` reaches no SQL by design and is no loss on its own, but one whose CHECK a previous migration RECORDED is dropped by this run and reported by nothing — five in `examples/dummy`, and `@ultimat3/cli`'s `repairFix` then offered `x db gen "drop <name>"` as the repair for the loss that command performs |
53
+ | `isNonTransactional()` / `NO_TRANSACTION_MARKER` / `isConcurrentIndexStatement()` / `concurrentIndexTarget()` | `As of 2026-10-31`: the `-- transaction: false` marker a migration carries to run outside a transaction, and the concurrent index statements that need it → [Indexes built concurrently](#indexes-built-concurrently-and-every-index-drop) |
53
54
  | `destructiveStatements()` / `hasDestructiveMarker()` / `isDestructiveMigration()` / `DESTRUCTIVE_MARKER` | `As of 2026-08`: the destructive-SQL rail — does this `up` drop, truncate or retype, and does the file declare it with `-- destructive: true`? One classifier, read by `x db gen` when it writes the marker and by `x verify` when it demands one |
54
55
  | `stripSqlNoise()` | comments, literals, dollar-quoted bodies and quoted identifiers blanked **in source order**, so a reader sees the operation and not the prose. Shared by `readOnlyQuery()` and the destructive rail |
55
56
  | `introspectSchema()` | live schema → `SchemaDescription`. **App tables only**, `As of 2026-08-24`: a relation an extension owns (`pg_depend`, `deptype = 'e'`) and anything that is not an ordinary or partitioned table are excluded before the fold, and an explicit `exclude` cannot bring them back |
@@ -156,6 +157,38 @@ layer 1 covers only what existed at grant time.
156
157
  | a method that moved | drop and recreate — Postgres cannot alter one in place |
157
158
  | drift | a declared GIN against a live btree is `changed-index`, and the live method is reported by the name the catalog gave it |
158
159
 
160
+ ## Indexes built concurrently, and every index drop
161
+
162
+ `As of 2026-10-31`. A plain `create index` holds SHARE on the table for the whole build — every
163
+ insert, update and delete waits — and a plain `drop index` takes ACCESS EXCLUSIVE, so reads wait
164
+ too. `create index concurrently` / `drop index concurrently` take SHARE UPDATE EXCLUSIVE, which
165
+ queues neither, and Postgres refuses both inside a transaction block (`25001`).
166
+
167
+ ```ts
168
+ import { entity, text, uuid } from '@ultimat3/entity';
169
+
170
+ entity('runs', {
171
+ columns: { id: uuid().primaryKey(), status: text({ max: 20 }), bank: text({ max: 40 }) },
172
+ indexes: [{ on: ['status'], concurrently: true }],
173
+ });
174
+ ```
175
+
176
+ | | |
177
+ |---|---|
178
+ | declared | `IndexDescriptionLike.concurrently` — `true` or absent. HOW the index is built, never WHAT it is: not in its name, not in the snapshot, and switching it on or off for an existing index generates nothing |
179
+ | emitted | `create index concurrently if not exists "runs_status_idx" on "runs" ("status");`, down `drop index concurrently if exists "runs_status_idx";` |
180
+ | removed index | **always** `drop index concurrently if exists`, whether or not it was built concurrently — down rebuilds it concurrently from the recorded definition. A total unique btree (a shape a UNIQUE constraint may back, where `drop index concurrently` is `2BP01`) is first unhooked by `alter table … drop constraint if exists` in the same step, right before its drop — never in an earlier transaction, whose rollback could not restore it |
181
+ | moved definition | a `concurrently: true` index whose columns, predicate, order or method moved is dropped and rebuilt, both concurrently — between the two the table has neither (name the new shape differently to build it beside the old one) |
182
+ | where it does not apply | an index on a table the same migration creates (empty, invisible until COMMIT), and one over a column a retype or a rebuild already holds ACCESS EXCLUSIVE for: both stay in the transaction |
183
+ | the files | no concurrent work: ONE transactional migration, as before. Concurrent work only: ONE migration marked `-- transaction: false`. Both: TWO — `<id>.sql` (the transaction) and `<id>_concurrently.sql` (outside it), sorting in that order, each with its own snapshot and hash. Expand → contract: inside the step every build precedes every drop and constraint unhook, so a renamed key's replacement exists before the old key goes |
184
+ | the runner | `migrate()` / `rollback()` run a `-- transaction: false` migration one statement at a time: a concurrent statement bare, with **no** `lock_timeout` (a concurrent build waits for every older transaction, and that wait is a lock wait); any other statement in a short transaction of its own under the migration's `lock_timeout`. The ledger row is written after the last statement |
185
+ | an interrupted build | leaves an INVALID index holding the name. Before each named `create index concurrently`, the runner reads `pg_index.indisvalid` through `to_regclass(<name>)` and drops an invalid one concurrently first; every statement is `if [not] exists`, so a failed step is simply re-run |
186
+ | refused | a concurrent statement in a migration WITHOUT the marker: `X_MIGRATION_CONCURRENT_IN_TRANSACTION`, before `BEGIN` |
187
+ | destructive | never — an index holds no row. A column or table drop in the same diff is still marked |
188
+
189
+ To drop an index on a hot table: delete it from the entity's `indexes`, `x db gen "drop <name>"`,
190
+ commit what it writes, ship. The drop is its own non-transactional migration.
191
+
159
192
  ## Read replicas
160
193
 
161
194
  Opt in twice, and the second one is why it is safe.
@@ -455,7 +488,9 @@ and the lock — for hours, so a deploy hook sat inside one statement printing n
455
488
  `helm upgrade --wait` blocked and `backoffLimit` never fired, because a job that never finishes
456
489
  never fails. It then ensures `x_migrations`
457
490
  (`id, name, checksum, applied_at, app_version, duration_ms`), audits, then applies each pending
458
- migration inside its own transaction. It refuses **before applying anything** when:
491
+ migration inside its own transaction — or, for one marked `-- transaction: false`, one
492
+ autocommitted statement at a time ([Indexes built concurrently](#indexes-built-concurrently-and-every-index-drop)).
493
+ It refuses **before applying anything** when:
459
494
 
460
495
  - the ledger records a migration this build does not ship **and** its `app_version` differs from
461
496
  the running one — another version owns the database; or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "29.3.1",
3
+ "version": "29.4.0",
4
4
  "description": "Postgres access, transactions, migrations and drift detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/core": "29.3.1"
36
+ "@ultimat3/core": "29.4.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@electric-sql/pglite": ">=0.5.0"
@@ -0,0 +1,137 @@
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 { statementsOf } from './statement-split';
9
+
10
+ /**
11
+ * The line a migration carries to run outside a transaction. Written into `up` by `x db gen` for
12
+ * the step that holds every concurrent index build, and it governs BOTH halves of the file: the
13
+ * `down` of a concurrent build is a concurrent drop, refused inside a transaction just the same.
14
+ */
15
+ export const NO_TRANSACTION_MARKER = '-- transaction: false';
16
+
17
+ /** The whole comment, as `-- destructive: true` is matched: a mention declares nothing. */
18
+ const MARKER_COMMENT = /^--[ \t]*transaction:[ \t]*false[ \t]*\r?\n?$/i;
19
+
20
+ /** Whether this migration's `up` declares it runs outside a transaction. */
21
+ export const isNonTransactional = (up: string): boolean => hasMarkerLine(up, MARKER_COMMENT);
22
+
23
+ interface Token {
24
+ readonly text: string;
25
+ /** A word to compare, lower-cased — `undefined` for a quoted identifier or a punctuation mark. */
26
+ readonly word: string | undefined;
27
+ }
28
+
29
+ const WHITESPACE = /\s/;
30
+
31
+ /**
32
+ * The first `limit` tokens of one statement: comments skipped, a quoted identifier kept whole and
33
+ * verbatim, a literal or dollar body ending the read — no statement this file recognises has one
34
+ * before the name it is after. `sql-scan.ts` is the lexer, so a `"create"` is a name and not a verb.
35
+ */
36
+ function leadingTokens(statement: string, limit: number): readonly Token[] {
37
+ const tokens: Token[] = [];
38
+ let index = 0;
39
+ while (index < statement.length && tokens.length < limit) {
40
+ const noise = noiseAt(statement, index);
41
+ if (noise !== null) {
42
+ if (noise.kind === 'string' || noise.kind === 'dollar-body') break;
43
+ if (noise.kind === 'identifier') {
44
+ tokens.push({ text: statement.slice(index, noise.end), word: undefined });
45
+ }
46
+ index = noise.end;
47
+ continue;
48
+ }
49
+ const char = statement[index] ?? '';
50
+ if (WHITESPACE.test(char)) {
51
+ index += 1;
52
+ continue;
53
+ }
54
+ let end = index + 1;
55
+ if (IDENTIFIER_CHAR.test(char)) {
56
+ while (end < statement.length && IDENTIFIER_CHAR.test(statement[end] ?? '')) end += 1;
57
+ }
58
+ const text = statement.slice(index, end);
59
+ tokens.push({ text, word: IDENTIFIER_CHAR.test(char) ? text.toLowerCase() : undefined });
60
+ index = end;
61
+ }
62
+ return tokens;
63
+ }
64
+
65
+ /**
66
+ * The index a `create [unique] index concurrently [if not exists] <name>` builds, spelled exactly
67
+ * as the statement spells it (`"runs_status_idx"`, `public.runs_status_idx`) — the text
68
+ * `to_regclass()` resolves and `drop index concurrently if exists` accepts. `null` for every other
69
+ * statement, and for an unnamed build: Postgres picks that name, so there is nothing to look up.
70
+ */
71
+ export function concurrentIndexTarget(statement: string): string | null {
72
+ const tokens = leadingTokens(statement, 12);
73
+ const word = (at: number): string | undefined => tokens[at]?.word;
74
+ let at = 0;
75
+ if (word(at) !== 'create') return null;
76
+ at += word(at + 1) === 'unique' ? 2 : 1;
77
+ if (word(at) !== 'index' || word(at + 1) !== 'concurrently') return null;
78
+ at += 2;
79
+ if (word(at) === 'if' && word(at + 1) === 'not' && word(at + 2) === 'exists') at += 3;
80
+ const name = tokens[at];
81
+ if (name === undefined || name.word === 'on') return null;
82
+ const dot = tokens[at + 1];
83
+ const tail = tokens[at + 2];
84
+ if (dot?.text === '.' && tail !== undefined) return `${name.text}.${tail.text}`;
85
+ return name.text;
86
+ }
87
+
88
+ /**
89
+ * Whether one statement is an index build or drop Postgres refuses inside a transaction block:
90
+ * `create [unique] index concurrently`, `drop index concurrently`, `reindex … concurrently`.
91
+ */
92
+ export function isConcurrentIndexStatement(statement: string): boolean {
93
+ const words = leadingTokens(statement, 8).map((token) => token.word);
94
+ const [first, second, third, fourth] = words;
95
+ if (first === 'create') {
96
+ return second === 'unique'
97
+ ? third === 'index' && fourth === 'concurrently'
98
+ : second === 'index' && third === 'concurrently';
99
+ }
100
+ if (first === 'drop') return second === 'index' && third === 'concurrently';
101
+ return first === 'reindex' && reindexIsConcurrent(statement);
102
+ }
103
+
104
+ /** The values that switch a boolean `REINDEX` option OFF — Postgres' own spellings of false. */
105
+ const OFF = new Set(['false', 'off', '0', 'no']);
106
+
107
+ /**
108
+ * `REINDEX [ ( option [, …] ) ] { INDEX | TABLE | SCHEMA | DATABASE | SYSTEM } [ CONCURRENTLY ] name`
109
+ * — read whole, never through a fixed window: the option list has no length bound, and
110
+ * `CONCURRENTLY [ boolean ]` may itself be one of the options.
111
+ */
112
+ function reindexIsConcurrent(statement: string): boolean {
113
+ const tokens = leadingTokens(statement, Number.POSITIVE_INFINITY);
114
+ let at = 1;
115
+ if (tokens[at]?.text === '(') {
116
+ let option: (string | undefined)[] = [];
117
+ const settle = (): boolean => option[0] === 'concurrently' && !OFF.has(option[1] ?? '');
118
+ for (at += 1; at < tokens.length; at += 1) {
119
+ const text = tokens[at]?.text;
120
+ if (text === ')' || text === ',') {
121
+ if (settle()) return true;
122
+ option = [];
123
+ if (text === ')') break;
124
+ continue;
125
+ }
126
+ option.push(tokens[at]?.word);
127
+ }
128
+ at += 1;
129
+ }
130
+ // The object kind, then the modifier — never the name after it, so `index "concurrently"` and
131
+ // `index concurrently_idx` are not concurrent.
132
+ return tokens[at + 1]?.word === 'concurrently';
133
+ }
134
+
135
+ /** The first statement of `script` that cannot run inside a transaction, or `undefined`. */
136
+ export const firstConcurrentStatement = (script: string): string | undefined =>
137
+ statementsOf(script).find(isConcurrentIndexStatement);
@@ -0,0 +1,176 @@
1
+ // Single responsibility: the index work `x db gen` moves OUT of the migration's transaction into a
2
+ // step of its own — every `concurrently: true` build and every index REMOVAL, whose concurrent
3
+ // forms queue no read and no write, and which Postgres runs only outside a transaction block.
4
+ // `index-plan.ts` decides which; this file writes the statements and the intermediate snapshot.
5
+
6
+ import type { IndexDescriptionLike } from './entity-shape';
7
+ import type { Plan } from './foreign-key-plan';
8
+ import {
9
+ asDeclared,
10
+ createIndex,
11
+ dropIndexConcurrently,
12
+ indexShape,
13
+ mayBeConstraintBacked,
14
+ } from './index-ddl';
15
+ import type { IndexDescription, SchemaDescription } from './introspect';
16
+ import { identifier } from './sql';
17
+
18
+ /**
19
+ * One index the non-transactional step changes: `recorded` is what stood there before it (absent
20
+ * for a new build) — what the transactional step's snapshot still records (`intermediateSnapshot`).
21
+ */
22
+ export interface DeferredIndex {
23
+ readonly table: string;
24
+ readonly name: string;
25
+ readonly recorded?: IndexDescription | undefined;
26
+ }
27
+
28
+ /**
29
+ * The non-transactional step's statements, in two phases, and what each changes for the snapshot.
30
+ * `up`/`down` EXPAND (every build), `contract`/`contractDown` CONTRACT (every removal): the step
31
+ * runs all of the first before any of the second, so a replacement exists before what it replaces
32
+ * goes — across tables too, since the phases are joined once, at assembly (`stepStatements`).
33
+ */
34
+ export interface ConcurrentPlan extends Plan {
35
+ readonly contract: string[];
36
+ readonly contractDown: string[];
37
+ readonly deferred: DeferredIndex[];
38
+ }
39
+
40
+ export const emptyConcurrentPlan = (): ConcurrentPlan => ({
41
+ up: [],
42
+ down: [],
43
+ contract: [],
44
+ contractDown: [],
45
+ deferred: [],
46
+ });
47
+
48
+ /**
49
+ * The step's script in apply order — expand, then contract — and its reversal: the removed
50
+ * indexes are restored FIRST, then the new ones dropped, so the reversal has no gap either.
51
+ */
52
+ export function stepStatements(plan: ConcurrentPlan): {
53
+ readonly up: readonly string[];
54
+ readonly down: readonly string[];
55
+ } {
56
+ return {
57
+ up: [...plan.up, ...plan.contract],
58
+ down: [...plan.down, ...plan.contractDown].reverse(),
59
+ };
60
+ }
61
+
62
+ /** A declared index this migration builds new, concurrently, with its concurrent drop as `down`. */
63
+ export function buildConcurrently(
64
+ table: string,
65
+ index: IndexDescriptionLike,
66
+ concurrent: ConcurrentPlan,
67
+ ): void {
68
+ concurrent.up.push(createIndex(table, index, true));
69
+ concurrent.down.push(dropIndexConcurrently(index.name));
70
+ concurrent.deferred.push({ table, name: index.name });
71
+ }
72
+
73
+ /**
74
+ * Drop a RECORDED index whose kind this generator cannot know, in statements that are correct on
75
+ * both databases it cannot tell apart.
76
+ *
77
+ * `TableDescription` carries no discriminator, and it cannot be given one that would help: the
78
+ * SAME declaration reaches the server as a CONSTRAINT or as an INDEX depending on which migration
79
+ * created it. A `unique` column on a table `createTable` writes emits `create table … slug text
80
+ * unique`, and Postgres backs that with a constraint named `posts_slug_key`; the same column
81
+ * gaining `unique` later takes `create unique index "posts_slug_key"` and is a plain index.
82
+ * `snapshotOf` records both as `{ unique: true, primary: false }`.
83
+ *
84
+ * Measured on 18.4 (`index-removal.live.test.ts`), which is why the pair and not a guess:
85
+ *
86
+ * | statement | on a constraint's index | on a plain index |
87
+ * |----------------------------------------------|----------------------------|------------------|
88
+ * | `drop index [if exists] "n"` | **2BP01** — not suppressed | ok |
89
+ * | `drop index concurrently if exists "n"` | **2BP01** — not suppressed | ok |
90
+ * | `alter table … drop constraint if exists` | drops it, index and all | notice, no-op |
91
+ *
92
+ * So the shape `mayBeConstraintBacked` cannot rule out is first unhooked by the ALTER, and the
93
+ * concurrent drop after it is then a no-op notice or the whole job. Constraint first: reversed,
94
+ * the drop reaches a constraint's index and is the 2BP01 this exists to avoid.
95
+ *
96
+ * Both in the SAME non-transactional step, and in the phase `into` names. Never the ALTER in the
97
+ * transactional step before it: that step's `down` cannot restore a constraint, so a rollback after
98
+ * a failed concurrent step left the table with no uniqueness at all — and it would unhook the old
99
+ * key before a concurrent replacement existed, letting in the duplicates that then fail its build.
100
+ * The ALTER takes ACCESS EXCLUSIVE, so the runner gives a statement like it a transaction of its
101
+ * own under `lock_timeout` (`migration-step.ts`); the concurrent drop stays unbounded.
102
+ */
103
+ function dropRecordedConcurrently(table: string, recorded: IndexDescription, into: string[]): void {
104
+ if (mayBeConstraintBacked(recorded)) {
105
+ into.push(
106
+ `alter table ${identifier(table).text} drop constraint if exists ${identifier(recorded.name).text};`,
107
+ );
108
+ }
109
+ into.push(dropIndexConcurrently(recorded.name));
110
+ }
111
+
112
+ /**
113
+ * A recorded index no entity declares any more: dropped concurrently, and restored by `down` —
114
+ * concurrently too — from what the SNAPSHOT recorded, never from the entity that stopped declaring it.
115
+ */
116
+ export function removeConcurrently(
117
+ table: string,
118
+ recorded: IndexDescription,
119
+ concurrent: ConcurrentPlan,
120
+ ): void {
121
+ dropRecordedConcurrently(table, recorded, concurrent.contract);
122
+ concurrent.contractDown.push(createIndex(table, asDeclared(recorded), true));
123
+ concurrent.deferred.push({ table, name: recorded.name, recorded });
124
+ }
125
+
126
+ /**
127
+ * A `concurrently: true` index whose definition moved: dropped and rebuilt, both concurrently —
128
+ * `redefineIndex`'s pair outside the transaction. Between the two statements the table has neither
129
+ * definition; for a unique index that is a window with no uniqueness, the price of never blocking a
130
+ * write (name the new shape differently to build it beside the old one instead).
131
+ */
132
+ export function redefineConcurrently(
133
+ table: string,
134
+ index: IndexDescriptionLike,
135
+ recorded: IndexDescription,
136
+ concurrent: ConcurrentPlan,
137
+ ): void {
138
+ if (indexShape(index) === indexShape(recorded)) return;
139
+ // One name, so no expand → contract is possible: the drop must precede the build, in the
140
+ // expand phase, in that order.
141
+ dropRecordedConcurrently(table, recorded, concurrent.up);
142
+ concurrent.up.push(createIndex(table, index, true));
143
+ // Pushed forwards and read backwards, as `redefineIndex`'s: the recorded definition lands last.
144
+ concurrent.down.push(
145
+ createIndex(table, asDeclared(recorded), true),
146
+ dropIndexConcurrently(index.name),
147
+ );
148
+ concurrent.deferred.push({ table, name: index.name, recorded });
149
+ }
150
+
151
+ /**
152
+ * The schema the TRANSACTIONAL step leaves behind when a non-transactional one follows it: the
153
+ * final snapshot with every deferred index put back the way it was — gone if it is new, its
154
+ * recorded definition if it is removed or rebuilt. The sidecar is "the schema this migration leaves
155
+ * behind", and a database that applied the first step alone holds exactly this: the first step
156
+ * removes no index and unhooks no constraint, so every recorded one is still there.
157
+ */
158
+ export function intermediateSnapshot(
159
+ final: SchemaDescription,
160
+ deferred: readonly DeferredIndex[],
161
+ ): SchemaDescription {
162
+ return {
163
+ tables: final.tables.map((table) => {
164
+ const mine = deferred.filter((each) => each.table === table.name);
165
+ if (mine.length === 0) return table;
166
+ const names = new Set(mine.map((each) => each.name));
167
+ return {
168
+ ...table,
169
+ indexes: [
170
+ ...table.indexes.filter((index) => !names.has(index.name)),
171
+ ...mine.flatMap((each) => (each.recorded === undefined ? [] : [each.recorded])),
172
+ ],
173
+ };
174
+ }),
175
+ };
176
+ }
@@ -3,8 +3,8 @@
3
3
  // an *undeclared* drop is not. Only `up` is ever asked: reversing `create table` is `drop table`,
4
4
  // so a rail reading `down` would mark every migration ever generated, and a mark on all is none.
5
5
 
6
+ import { hasMarkerLine } from './marker-line';
6
7
  import { stripSqlNoise } from './sql-noise';
7
- import { noiseAt } from './sql-scan';
8
8
  import { statementExcerpt } from './statement-excerpt';
9
9
  import { statementsOf } from './statement-split';
10
10
 
@@ -18,40 +18,12 @@ export const DESTRUCTIVE_MARKER = '-- destructive: true';
18
18
  */
19
19
  const MARKER_COMMENT = /^--[ \t]*destructive:[ \t]*true[ \t]*\r?\n?$/i;
20
20
 
21
- /** Whether only spaces and tabs separate `index` from the start of its line. */
22
- function startsLine(sql: string, index: number): boolean {
23
- for (let at = index - 1; at >= 0; at -= 1) {
24
- const char = sql[at];
25
- if (char === '\n') return true;
26
- if (char !== ' ' && char !== '\t') return false;
27
- }
28
- return true;
29
- }
30
-
31
21
  /**
32
- * Declared only where a reader would see it: a top-level `--` line of its own.
33
- *
22
+ * Declared only where a reader would see it: a top-level `--` line of its own (`marker-line.ts`).
34
23
  * A regex over the raw file matched the marker inside `/* … *\/` and inside a dollar-quoted body,
35
- * where it declares nothing and no reviewer reading the diff would call it a declaration — yet it
36
- * bought the file past `x verify`. Scanning is what tells a comment from a comment about a
37
- * comment; the marker is a lexical fact, not a substring.
24
+ * where it declares nothing — yet it bought the file past `x verify`.
38
25
  */
39
- export function hasDestructiveMarker(sql: string): boolean {
40
- let index = 0;
41
- while (index < sql.length) {
42
- const noise = noiseAt(sql, index);
43
- if (noise === null) {
44
- index += 1;
45
- continue;
46
- }
47
- const text = sql.slice(index, noise.end);
48
- if (noise.kind === 'line-comment' && startsLine(sql, index) && MARKER_COMMENT.test(text)) {
49
- return true;
50
- }
51
- index = noise.end;
52
- }
53
- return false;
54
- }
26
+ export const hasDestructiveMarker = (sql: string): boolean => hasMarkerLine(sql, MARKER_COMMENT);
55
27
 
56
28
  export type DestructiveKind = 'drop-table' | 'drop-column' | 'retype-column' | 'truncate';
57
29
 
@@ -97,6 +97,14 @@ export interface IndexDescriptionLike {
97
97
  * the declarer; this package cannot import it, so the method crosses the seam structurally.
98
98
  */
99
99
  readonly using?: IndexMethod | undefined;
100
+ /**
101
+ * `indexes: [{ on, concurrently: true }]`: build it with `create index concurrently` in a
102
+ * migration of its own that runs OUTSIDE a transaction (`concurrent-index.ts`), so a hot table
103
+ * keeps taking writes while it builds. HOW the index is built, never WHAT it is — so it is not
104
+ * part of the name, not recorded in a snapshot and never a reason to rebuild one. Absent is the
105
+ * plain, transactional `create index` every index was before it existed.
106
+ */
107
+ readonly concurrently?: boolean | undefined;
100
108
  }
101
109
 
102
110
  /** Structurally assignment-compatible with `@ultimat3/entity`'s `EntityDescription`. */
package/src/errors.ts CHANGED
@@ -43,6 +43,7 @@ export const DB_OWNED_ERROR_CODES = [
43
43
  'X_DB_SIBLING_SCOPE_TIMEOUT',
44
44
  'X_APPEND_ONLY_TRIGGER_MISSING',
45
45
  'X_MIGRATION_APPEND_ONLY_BACKFILL',
46
+ 'X_MIGRATION_CONCURRENT_IN_TRANSACTION',
46
47
  ] as const;
47
48
 
48
49
  /**
@@ -94,6 +95,8 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
94
95
  X_APPEND_ONLY_TRIGGER_MISSING: 'an append-only table has lost its refusing trigger',
95
96
  X_MIGRATION_APPEND_ONLY_BACKFILL:
96
97
  'a NOT NULL column added to an append-only table needs a default',
98
+ X_MIGRATION_CONCURRENT_IN_TRANSACTION:
99
+ 'a concurrent index statement is in a migration that runs inside a transaction',
97
100
  };
98
101
 
99
102
  // Registered unconditionally, in one call, so a second package claiming one of db's codes fails
@@ -0,0 +1,94 @@
1
+ // Single responsibility: turn one diff into the migration FILES it needs — one transactional step,
2
+ // one non-transactional step, or the first followed by the second. Split from `generate.ts`, which
3
+ // plans the statements; this file decides which step each list is and what each step records.
4
+
5
+ import { NO_TRANSACTION_MARKER } from './concurrent-index';
6
+ import { type ConcurrentPlan, intermediateSnapshot, stepStatements } from './concurrent-plan';
7
+ import { isDestructiveMigration } from './destructive';
8
+ import type { GeneratedMigration } from './generate';
9
+ import type { SchemaDescription } from './introspect';
10
+ import { type UnrenderedDeclaration, unrenderedComment } from './unrendered';
11
+
12
+ export interface DiffSteps {
13
+ readonly id: string;
14
+ readonly name: string;
15
+ /** The transactional step's statements, in apply order. */
16
+ readonly up: readonly string[];
17
+ /** Its reversal, already in apply order. */
18
+ readonly down: readonly string[];
19
+ /** Everything that must run outside a transaction, after `up`. */
20
+ readonly concurrent: ConcurrentPlan;
21
+ /** The schema the whole diff leaves behind. */
22
+ readonly snapshot: SchemaDescription;
23
+ readonly unrendered: readonly UnrenderedDeclaration[];
24
+ }
25
+
26
+ /** One file's worth. `destructive` is read off `up` by the gate's own classifier, never assembled. */
27
+ function migrationOf(
28
+ fields: Omit<GeneratedMigration, 'fileName' | 'destructive' | 'followUp'>,
29
+ ): GeneratedMigration {
30
+ return {
31
+ ...fields,
32
+ fileName: `migrations/${fields.id}.sql`,
33
+ destructive: isDestructiveMigration(fields.up),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * The step files of one diff.
39
+ *
40
+ * The unrendered report sits at the TOP of the first step's `up` and never onto an empty one:
41
+ * `@ultimat3/cli`'s `generateAppMigration` reads an empty `up` as "nothing changed". In a
42
+ * non-transactional step the marker comes first — it is the header line the runner reads.
43
+ *
44
+ * Why TWO files and never one file in two halves: a migration is one ledger row. A file whose
45
+ * transactional half committed and whose concurrent half then failed is either recorded (and the
46
+ * index never built) or not (and the next run re-applies the committed half: `42P07`). Two files
47
+ * are two rows, and the second is idempotent by construction (`if not exists` / `if exists`, an
48
+ * INVALID leftover dropped first), so a failure in it is re-run, never repaired by hand.
49
+ */
50
+ export function assembleSteps(diff: DiffSteps): GeneratedMigration {
51
+ const { id, name, snapshot, unrendered } = diff;
52
+ const body = diff.up.join('\n');
53
+ const step = stepStatements(diff.concurrent);
54
+ const later = step.up.join('\n');
55
+ const laterDown = step.down.join('\n');
56
+ const outside = (report: readonly UnrenderedDeclaration[]): string =>
57
+ `${NO_TRANSACTION_MARKER}\n${unrenderedComment(report)}${later}`;
58
+ if (body.length === 0 && later.length > 0) {
59
+ // Only index work: the non-transactional step IS the migration, under the plain id.
60
+ return migrationOf({
61
+ id,
62
+ name,
63
+ up: outside(unrendered),
64
+ down: laterDown,
65
+ snapshot,
66
+ unrendered,
67
+ transactional: false,
68
+ });
69
+ }
70
+ const first = migrationOf({
71
+ id,
72
+ name,
73
+ up: body.length === 0 ? body : unrenderedComment(unrendered) + body,
74
+ down: diff.down.join('\n'),
75
+ // What the transactional step alone leaves behind, when a second one follows it.
76
+ snapshot:
77
+ later.length === 0 ? snapshot : intermediateSnapshot(snapshot, diff.concurrent.deferred),
78
+ unrendered,
79
+ transactional: true,
80
+ });
81
+ if (later.length === 0) return first;
82
+ // `_concurrently` sorts right after the plain id — as a ledger id, an `.sql` and every sidecar —
83
+ // so expand (the transaction) always lands before contract (the concurrent drops).
84
+ const followUp = migrationOf({
85
+ id: `${id}_concurrently`,
86
+ name: `${name} concurrently`,
87
+ up: outside([]),
88
+ down: laterDown,
89
+ snapshot,
90
+ unrendered: [],
91
+ transactional: false,
92
+ });
93
+ return { ...first, followUp };
94
+ }