@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 +16 -18
- package/README.md +36 -1
- package/package.json +2 -2
- package/src/concurrent-index.ts +137 -0
- package/src/concurrent-plan.ts +176 -0
- package/src/destructive.ts +4 -32
- package/src/entity-shape.ts +8 -0
- package/src/errors.ts +3 -0
- package/src/generate-steps.ts +94 -0
- package/src/generate.ts +25 -12
- package/src/index-ddl.ts +23 -35
- package/src/index-plan.ts +35 -14
- package/src/index.ts +7 -0
- package/src/marker-line.ts +39 -0
- package/src/migrate.ts +22 -65
- package/src/migration-errors.ts +21 -0
- package/src/migration-step.ts +164 -0
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
|
|
117
|
-
|
|
118
|
-
|
|
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;
|
|
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)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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`):
|
|
173
|
-
|
|
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
|
|
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
|
+
"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.
|
|
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
|
+
}
|
package/src/destructive.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/entity-shape.ts
CHANGED
|
@@ -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
|
+
}
|