@ultimat3/db 2.0.0 → 3.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.
- package/CLAUDE.md +38 -0
- package/README.md +16 -4
- package/package.json +2 -2
- package/src/branch.ts +11 -1
- package/src/client.ts +18 -6
- package/src/drift.ts +7 -1
- package/src/errors.ts +25 -1
- package/src/index.ts +1 -0
- package/src/libpq-options.ts +76 -0
- package/src/migrate.ts +12 -2
package/CLAUDE.md
CHANGED
|
@@ -11,6 +11,7 @@ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` o
|
|
|
11
11
|
| SQL | `sql` binds `$n`; anything non-scalar and non-fragment throws `X_SQL_UNSAFE` |
|
|
12
12
|
| Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point |
|
|
13
13
|
| SQLSTATE | one reader, `sqlState()` (`sqlstate.ts`). Never read `error.code` for a SQLSTATE |
|
|
14
|
+
| Reading a caught value | `renderThrowable()` from core; never `error instanceof Error ? error.message : String(error)` — both halves RUN app code (a `Proxy` trap, `Symbol.toPrimitive`) and `checkDb` backs `/readyz`, where a render that throws is an exception in place of the report the kubelet asked for |
|
|
14
15
|
| Errors | subclass `DbError`; never `throw new Error` **in source**. A test simulating a *database* failure throws `dbUnavailable()`; a test simulating the *caller's body* failing throws a bare `Error` on purpose — an arbitrary throw is exactly what rollback and disposal must survive, and a `DbError` there would prove the narrower thing |
|
|
15
16
|
| New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` |
|
|
16
17
|
| Exports | explicit in `src/index.ts`; no `export *` |
|
|
@@ -318,6 +319,29 @@ block is the join of that fix with the engine it ships through — an entity des
|
|
|
318
319
|
`generateMigration`, applied by `migrate()` itself against a real server, columns confirmed against
|
|
319
320
|
`pg_indexes`, rather than either half alone.
|
|
320
321
|
|
|
322
|
+
**The ledger audit asks one question — does this build ship every migration the ledger records?**
|
|
323
|
+
`auditLedger`'s `foreign` filter is `!known.has(row.id)` and nothing else, `As of 2026-08`. It used
|
|
324
|
+
to also require `row.app_version !== appVersion`, which switched the audit OFF wherever the two
|
|
325
|
+
agree: `runningAppVersion()` answers `dev` for every development build, so a migration applied by an
|
|
326
|
+
earlier `dev` build and since deleted was invisible, and `expectedSchema` (`drift.ts`) then dropped
|
|
327
|
+
its table from the comparison — `x db drift` answering `ok: true` against a database that still has
|
|
328
|
+
the table. The version is a detail of the ANSWER and lives in the cause, never in the predicate.
|
|
329
|
+
|
|
330
|
+
**`rollback({ steps })` refuses anything that is not a positive safe integer, before the lock.**
|
|
331
|
+
`steps` reaches `slice(0, steps)`, where a negative count counts from the END: `steps: -1` selected
|
|
332
|
+
every applied migration but the newest and reversed four of five. `X_INVARIANT` (core's generic
|
|
333
|
+
code, borrowed in `DB_BORROWED_ERROR_CODES` the way `@ultimat3/money`'s `roundRatio` borrows it —
|
|
334
|
+
a bad argument is not a fact about the ledger), thrown by `rollbackStepsInvalid` before the advisory
|
|
335
|
+
lock is taken and before the ledger is read. Same discipline as `poolMaxInvalid`: a number this
|
|
336
|
+
build cannot honour is refused, never reinterpreted.
|
|
337
|
+
|
|
338
|
+
**`reapBranches` skips a `createdAt` it cannot parse; it never reads one as infinitely old.**
|
|
339
|
+
`NaN > cutoff` is `false`, which is the same answer "older than the cutoff" gives — so a
|
|
340
|
+
`COMMENT ON DATABASE` that was truncated or hand-edited used to be a database DROPPED on the next
|
|
341
|
+
nightly sweep whatever `maxAgeMs` said. `Date.parse` + `Number.isFinite`, the discipline
|
|
342
|
+
`@ultimat3/seo`'s `feed-dates.ts` applies to the same question. (Distinct from the open
|
|
343
|
+
source-blindness of the reaper, issue #133.)
|
|
344
|
+
|
|
321
345
|
**One send is one statement, so `migrate()` and `rollback()` split the script.** `tx.execute(raw(
|
|
322
346
|
migration.up))` on a text holding two commands is where the two drivers disagreed, and the
|
|
323
347
|
disagreement is the whole reason this is a bug rather than a preference: `pglite.ts` calls
|
|
@@ -562,6 +586,20 @@ itself all reach the caller, and every caller must handle that. Layers 3–4 (pr
|
|
|
562
586
|
policy) live in `@ultimat3/mcp`, which must still never import this package — the CLI wires the
|
|
563
587
|
two together.
|
|
564
588
|
|
|
589
|
+
**`libpq-options.ts` merges the framework's `options` into the operator's, and `connectionUrl` may
|
|
590
|
+
not `set` that key again.** `DATABASE_URL` is the operator's file: `url.searchParams.set('options',
|
|
591
|
+
…)` REPLACED whatever they had written, and only on the roles whose `statementTimeoutMs` is
|
|
592
|
+
non-zero — so `?options=-c search_path=app` survived on `migrate` and `replicator` and was dropped
|
|
593
|
+
on `web`, `sync`, `worker` and `scheduler`, i.e. the role that runs the migrations and the role that
|
|
594
|
+
serves the traffic looked at different schemas with nothing reporting it. Precedence is **the
|
|
595
|
+
framework wins on the names it sets, the operator keeps every other flag**, and it is enforced by
|
|
596
|
+
removing those names from the operator's tokens before appending, never by position: "the last `-c`
|
|
597
|
+
wins" is backend argument-order behaviour nobody here measured. The bound is emitted for all six
|
|
598
|
+
roles including the two whose value is `0` — `0` is `migrate` saying it may take as long as it
|
|
599
|
+
takes, and left unsaid an `alter database … set statement_timeout` on the server kills the one role
|
|
600
|
+
that must outlive it. The splitter honours libpq's backslash escape, so a `search_path=two\ words`
|
|
601
|
+
survives the round trip whole.
|
|
602
|
+
|
|
565
603
|
```bash
|
|
566
604
|
bun test # from packages/db
|
|
567
605
|
bun run typecheck
|
package/README.md
CHANGED
|
@@ -137,10 +137,12 @@ X_DB_DRIFT: schema differs from migrations
|
|
|
137
137
|
| live table, no migration | `table "T" is not present in any migration` | `x db gen "add T"` |
|
|
138
138
|
| migrated table, not live | `table "T" is declared by migrations but does not exist` | `x db migrate` |
|
|
139
139
|
|
|
140
|
-
`checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate`
|
|
141
|
-
them and exits non-zero; a `ROLE=migrate` container
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
`checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` renders
|
|
141
|
+
them all as findings and exits non-zero; a `ROLE=migrate` container throws the first one
|
|
142
|
+
(`assertNoDrift`, in `runRole`) and exits non-zero too, because the release phase has one channel —
|
|
143
|
+
the exit code — and a deploy that rolled on past a schema nobody can reconstruct is the failure
|
|
144
|
+
drift exists to catch. There is no `x db drift`, and `x verify`'s `drift` step is the *source*
|
|
145
|
+
detector (`checkSourceDrift`), which needs no database and never calls this.
|
|
144
146
|
|
|
145
147
|
## The embedded database
|
|
146
148
|
|
|
@@ -198,6 +200,16 @@ does reach the backend: `client.live.test.ts` asserts `current_setting('statemen
|
|
|
198
200
|
is the only reading a DSN test cannot fake. `Bun.SQL` is reached lazily, so importing this package
|
|
199
201
|
never opens a socket.
|
|
200
202
|
|
|
203
|
+
**That setting is MERGED into the operator's own `options`, never assigned over them** (`As of
|
|
204
|
+
2026-08`). `?options=-c search_path=app` in `DATABASE_URL` survives on every role, and the role's
|
|
205
|
+
`statement_timeout` is appended to it; if the URL sets `statement_timeout` itself, the **role
|
|
206
|
+
wins** — it is a bound the pool is sized around — and every other flag is kept. It is emitted for
|
|
207
|
+
all six roles, `migrate`'s and `replicator`'s `0` included: `0` is "this role may take as long as
|
|
208
|
+
it takes", and left unsaid a server-side `alter database … set statement_timeout` would kill the
|
|
209
|
+
one role that has to outlive it. Before this, `set` replaced the whole value and only on the roles
|
|
210
|
+
with a non-zero timeout, so a `search_path` survived on `migrate` and vanished on `web` — the role
|
|
211
|
+
that runs the migrations and the role that serves the traffic reading different schemas.
|
|
212
|
+
|
|
201
213
|
**`DATABASE_POOL_MAX` overrides `max`** (`As of 2026-08`), and it is the only pool knob an operator
|
|
202
214
|
can turn without shipping an image — 400 `web` pods × the frozen `max: 20` is 8,000 backends. A
|
|
203
215
|
value that is not a positive integer refuses at boot rather than falling back.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "3.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/branch.ts
CHANGED
|
@@ -134,7 +134,17 @@ export async function reapBranches(options: ReapOptions): Promise<readonly strin
|
|
|
134
134
|
const dropped: string[] = [];
|
|
135
135
|
for (const branch of branches) {
|
|
136
136
|
if (branch.createdAt === null) continue;
|
|
137
|
-
|
|
137
|
+
const createdAtMs = Date.parse(branch.createdAt);
|
|
138
|
+
// `NaN > cutoff` is `false`, which is the same answer "older than the cutoff" gives — so a
|
|
139
|
+
// truncated or hand-edited comment used to be a database DROPPED on the next sweep, whatever
|
|
140
|
+
// `maxAgeMs` said. An age nothing can read is not an old age.
|
|
141
|
+
if (!Number.isFinite(createdAtMs)) continue;
|
|
142
|
+
// Finite is not enough: `'2026-08-18T10:00'` parses as LOCAL time, so a truncated comment
|
|
143
|
+
// names an instant hours from the one it reads as, and the sweep acts on a date nobody wrote.
|
|
144
|
+
// `createBranch` writes `toISOString()` and nothing else does, so a value that does not round
|
|
145
|
+
// trip through it is not ours — there is no legitimate non-canonical comment to strand.
|
|
146
|
+
if (new Date(createdAtMs).toISOString() !== branch.createdAt) continue;
|
|
147
|
+
if (createdAtMs > cutoff) continue;
|
|
138
148
|
await dropBranch(branch.name, options);
|
|
139
149
|
dropped.push(branch.name);
|
|
140
150
|
}
|
package/src/client.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// pool like a `web` process behind a CDN. `Bun.SQL` is reached lazily so importing this module
|
|
4
4
|
// never opens a socket (the CLI imports it to print help).
|
|
5
5
|
|
|
6
|
-
import { type Role, resolveRole } from '@ultimat3/core';
|
|
6
|
+
import { type Role, renderThrowable, resolveRole } from '@ultimat3/core';
|
|
7
7
|
import { statementAttribution } from './attribution';
|
|
8
8
|
import { DbError, dbUnavailable, driverError, poolAcquireTimeout, poolMaxInvalid } from './errors';
|
|
9
9
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
10
|
+
import { mergeLibpqOptions } from './libpq-options';
|
|
10
11
|
import { statementObserver } from './observe';
|
|
11
12
|
import { type SqlFragment, sql } from './sql';
|
|
12
13
|
import { withStatementSpan } from './statement-span';
|
|
@@ -172,10 +173,19 @@ function connectionUrl(options: PostgresClientOptions, profile: PoolProfile): st
|
|
|
172
173
|
} catch (error) {
|
|
173
174
|
throw dbUnavailable(`DATABASE_URL is not a valid url: ${raw}`, error);
|
|
174
175
|
}
|
|
175
|
-
// libpq `options` is the portable way to pin a statement timeout for every pooled connection
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
// libpq `options` is the portable way to pin a statement timeout for every pooled connection —
|
|
177
|
+
// MERGED into the operator's own, never assigned over it, and emitted for every role including
|
|
178
|
+
// the two whose bound is 0. `set` here dropped a `?options=-c search_path=app` on `web`, `sync`,
|
|
179
|
+
// `worker` and `scheduler` and kept it on `migrate` and `replicator`, so the role that runs the
|
|
180
|
+
// migrations and the role that serves the traffic read different schemas. 0 is a value, not a
|
|
181
|
+
// silence: it is `migrate` saying it may take as long as it takes, and left unsaid a server-side
|
|
182
|
+
// `alter database ... set statement_timeout` kills the one role that must outlive it.
|
|
183
|
+
url.searchParams.set(
|
|
184
|
+
'options',
|
|
185
|
+
mergeLibpqOptions(url.searchParams.get('options'), {
|
|
186
|
+
statement_timeout: String(profile.statementTimeoutMs),
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
179
189
|
url.searchParams.set('application_name', options.applicationName ?? 'ultimate');
|
|
180
190
|
return url.toString();
|
|
181
191
|
}
|
|
@@ -447,7 +457,9 @@ export async function checkDb(client: DbClient = baseClient()): Promise<DbHealth
|
|
|
447
457
|
return {
|
|
448
458
|
ok: false,
|
|
449
459
|
latencyMs: Math.round(performance.now() - started),
|
|
450
|
-
|
|
460
|
+
// `renderThrowable`, never `error.message`: the probe wants a report, and a render that
|
|
461
|
+
// throws is an exception out of `/readyz` — the one caller that cannot catch it.
|
|
462
|
+
error: renderThrowable(error),
|
|
451
463
|
};
|
|
452
464
|
}
|
|
453
465
|
}
|
package/src/drift.ts
CHANGED
|
@@ -294,7 +294,13 @@ export function driftError(difference: DriftDifference): DbError {
|
|
|
294
294
|
});
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
-
/**
|
|
297
|
+
/**
|
|
298
|
+
* Throws the first difference. The one caller is the release phase — `runRole` in `@ultimat3/cli`
|
|
299
|
+
* under `ROLE=migrate`, where the exit code is the only channel a container has. `x db migrate`
|
|
300
|
+
* and `x db reset` hold the same report and render every difference as a finding instead
|
|
301
|
+
* (`driftFindings`), and `x verify`'s `drift` step is the *source* detector (`checkSourceDrift`),
|
|
302
|
+
* which never reaches this function. There is no `x db drift` command.
|
|
303
|
+
*/
|
|
298
304
|
export function assertNoDrift(report: DriftReport): void {
|
|
299
305
|
const first = report.differences[0];
|
|
300
306
|
if (first !== undefined) throw driftError(first);
|
package/src/errors.ts
CHANGED
|
@@ -32,8 +32,17 @@ export const DB_OWNED_ERROR_CODES = [
|
|
|
32
32
|
* `@ultimat3/core`'s. Never titled here, never registered here. `X_ENV_MISSING` is core's word for
|
|
33
33
|
* "a variable this process was given is missing or invalid", and `DATABASE_POOL_MAX` is one — a
|
|
34
34
|
* db-local code for it would be a second answer to a question core already answers.
|
|
35
|
+
*
|
|
36
|
+
* `X_INVARIANT` is core's own "the generic code, for checks that have no dedicated code yet"
|
|
37
|
+
* (`assert()` in `core/src/assert.ts`), borrowed the same way `@ultimat3/money`'s `roundRatio`
|
|
38
|
+
* borrows it: an argument a caller built wrong is not a fact about the ledger or the schema, so
|
|
39
|
+
* none of the `X_MIGRATION_*` codes above describes one.
|
|
35
40
|
*/
|
|
36
|
-
export const DB_BORROWED_ERROR_CODES = [
|
|
41
|
+
export const DB_BORROWED_ERROR_CODES = [
|
|
42
|
+
'X_NOT_IMPLEMENTED',
|
|
43
|
+
'X_ENV_MISSING',
|
|
44
|
+
'X_INVARIANT',
|
|
45
|
+
] as const;
|
|
37
46
|
|
|
38
47
|
/** Every code db can throw: the ones it owns plus the ones it borrows. */
|
|
39
48
|
export const DB_ERROR_CODES = [...DB_OWNED_ERROR_CODES, ...DB_BORROWED_ERROR_CODES] as const;
|
|
@@ -249,6 +258,21 @@ export const migrationConflict = (cause: string, fix: string): DbError =>
|
|
|
249
258
|
export const migrationIrreversible = (cause: string, fix: string): DbError =>
|
|
250
259
|
new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
|
|
251
260
|
|
|
261
|
+
/**
|
|
262
|
+
* A rollback step count this build cannot honour. `steps` reaches `Array.prototype.slice`, where a
|
|
263
|
+
* negative count counts from the END: `steps: -1` selected every applied migration except the
|
|
264
|
+
* newest and reversed four of five, which is the one class of mistake a rollback cannot undo.
|
|
265
|
+
* Refused rather than coerced, exactly as `DATABASE_POOL_MAX` is — a number silently reinterpreted
|
|
266
|
+
* as a different one is the failure a validated argument exists to prevent.
|
|
267
|
+
*/
|
|
268
|
+
export const rollbackStepsInvalid = (received: number): DbError =>
|
|
269
|
+
new DbError({
|
|
270
|
+
code: 'X_INVARIANT',
|
|
271
|
+
cause: `rollback was asked to reverse ${String(received)} migrations, which is not a positive integer`,
|
|
272
|
+
fix: 'rollback({ migrations, steps: 1 }) # a whole number of migrations, newest first',
|
|
273
|
+
meta: { steps: received },
|
|
274
|
+
});
|
|
275
|
+
|
|
252
276
|
/**
|
|
253
277
|
* `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
|
|
254
278
|
* every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Single responsibility: merging the framework's own libpq `options` into whatever the operator
|
|
2
|
+
// already put in `DATABASE_URL`. A connection string is the operator's file, not the framework's,
|
|
3
|
+
// and `searchParams.set` on a key they may have written is a silent overwrite of their setting.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* libpq hands `options` to the backend as command-line arguments, split on whitespace with a
|
|
7
|
+
* backslash escaping the next character. Escapes are kept intact, so re-joining the tokens
|
|
8
|
+
* reproduces the operator's string byte for byte.
|
|
9
|
+
*/
|
|
10
|
+
export function splitLibpqOptions(options: string): readonly string[] {
|
|
11
|
+
const tokens: string[] = [];
|
|
12
|
+
let current = '';
|
|
13
|
+
let open = false;
|
|
14
|
+
for (let index = 0; index < options.length; index += 1) {
|
|
15
|
+
const char = options[index] ?? '';
|
|
16
|
+
const escaped = options[index + 1];
|
|
17
|
+
if (char === '\\' && escaped !== undefined) {
|
|
18
|
+
current += `\\${escaped}`;
|
|
19
|
+
open = true;
|
|
20
|
+
index += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (char.trim() === '') {
|
|
24
|
+
if (open) tokens.push(current);
|
|
25
|
+
current = '';
|
|
26
|
+
open = false;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
current += char;
|
|
30
|
+
open = true;
|
|
31
|
+
}
|
|
32
|
+
if (open) tokens.push(current);
|
|
33
|
+
return tokens;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The three spellings a backend accepts for one GUC on the command line: `-c name=value` as two
|
|
38
|
+
* arguments, `-cname=value` as one, and `--name=value` (where a hyphen in the name reads as an
|
|
39
|
+
* underscore). A bare `name=value` token is the second half of the first spelling.
|
|
40
|
+
*/
|
|
41
|
+
const ASSIGNS = (name: string): RegExp => new RegExp(`^(?:-c|--)?${name.replaceAll('_', '[_-]')}=`);
|
|
42
|
+
|
|
43
|
+
/** Drops every assignment of `name`, and the `-c` that introduced it. */
|
|
44
|
+
function without(tokens: readonly string[], name: string): readonly string[] {
|
|
45
|
+
const assigns = ASSIGNS(name);
|
|
46
|
+
const kept: string[] = [];
|
|
47
|
+
for (const token of tokens) {
|
|
48
|
+
if (!assigns.test(token)) {
|
|
49
|
+
kept.push(token);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (kept.at(-1) === '-c') kept.pop();
|
|
53
|
+
}
|
|
54
|
+
return kept;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The operator's `options` with the framework's settings merged in.
|
|
59
|
+
*
|
|
60
|
+
* **Precedence: the framework wins on the settings it names, the operator keeps everything else.**
|
|
61
|
+
* A role's `statement_timeout` is a safety bound the role is sized around — `web`'s 10s is what
|
|
62
|
+
* stops a slow endpoint holding all 20 pool slots — so a value in the URL may not raise it; but a
|
|
63
|
+
* `search_path`, an `application_name` or a `-c` an operator added is theirs and must survive.
|
|
64
|
+
* Enforced by removing the framework's own names before appending, never by position: relying on
|
|
65
|
+
* "the last `-c` wins" would make the bound depend on backend argument order nobody here measured.
|
|
66
|
+
*/
|
|
67
|
+
export function mergeLibpqOptions(
|
|
68
|
+
existing: string | null,
|
|
69
|
+
settings: Readonly<Record<string, string>>,
|
|
70
|
+
): string {
|
|
71
|
+
let tokens = splitLibpqOptions(existing ?? '');
|
|
72
|
+
for (const [name, value] of Object.entries(settings)) {
|
|
73
|
+
tokens = [...without(tokens, name), '-c', `${name}=${value}`];
|
|
74
|
+
}
|
|
75
|
+
return tokens.join(' ');
|
|
76
|
+
}
|
package/src/migrate.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
isReservable,
|
|
12
12
|
poolProfileFor,
|
|
13
13
|
} from './client';
|
|
14
|
-
import { migrateConcurrent, migrationConflict } from './errors';
|
|
14
|
+
import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './errors';
|
|
15
15
|
import { expectedQueryLoop } from './expected-loop';
|
|
16
16
|
import type { SchemaDescription } from './introspect';
|
|
17
17
|
import { raw, sql } from './sql';
|
|
@@ -149,7 +149,13 @@ export function auditLedger(
|
|
|
149
149
|
): void {
|
|
150
150
|
const known = new Map(migrations.map((migration) => [migration.id, migration]));
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
// The predicate is "this build does not ship it" and NOTHING else. It used to also require
|
|
153
|
+
// `row.app_version !== appVersion`, which switched the audit off wherever the two agree —
|
|
154
|
+
// `runningAppVersion()` answers `dev` for every development build, so a migration applied by an
|
|
155
|
+
// earlier `dev` build and since deleted was invisible here, and `expectedSchema` then dropped
|
|
156
|
+
// its table from the drift comparison: `ok: true` against a database that still has the table.
|
|
157
|
+
// The version is a detail of the ANSWER, so it moved into the cause.
|
|
158
|
+
const foreign = ledger.filter((row) => !known.has(row.id));
|
|
153
159
|
const first = foreign[0];
|
|
154
160
|
if (first !== undefined) {
|
|
155
161
|
throw migrationConflict(
|
|
@@ -374,6 +380,7 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
|
|
|
374
380
|
export interface RollbackOptions {
|
|
375
381
|
readonly migrations: readonly Migration[];
|
|
376
382
|
readonly client?: DbClient | undefined;
|
|
383
|
+
/** How many applied migrations to reverse, newest first. A positive integer; defaults to 1. */
|
|
377
384
|
readonly steps?: number | undefined;
|
|
378
385
|
/** Skip the advisory lock. Only `x db branch` does this, against a private database. */
|
|
379
386
|
readonly lock?: boolean | undefined;
|
|
@@ -387,6 +394,9 @@ export interface RollbackOptions {
|
|
|
387
394
|
export async function rollback(options: RollbackOptions): Promise<readonly string[]> {
|
|
388
395
|
const client = options.client ?? baseClient();
|
|
389
396
|
const steps = options.steps ?? 1;
|
|
397
|
+
// Before the lock and before the ledger read: `slice(0, -1)` is "all but the newest", not
|
|
398
|
+
// "one fewer", so an unvalidated count reverses migrations nobody asked about.
|
|
399
|
+
if (!Number.isSafeInteger(steps) || steps < 1) throw rollbackStepsInvalid(steps);
|
|
390
400
|
const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
|
|
391
401
|
const known = new Map(options.migrations.map((migration) => [migration.id, migration]));
|
|
392
402
|
|