@ultimat3/db 22.15.0 → 24.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 +91 -56
- package/README.md +121 -6
- package/package.json +5 -3
- package/src/array-parameter.ts +38 -1
- package/src/bound-parameters.ts +22 -3
- package/src/bun-sql.ts +12 -0
- package/src/catalog-fold.ts +116 -0
- package/src/catalog-objects.ts +229 -0
- package/src/catalog-relations.ts +184 -0
- package/src/catalog.ts +174 -0
- package/src/client.ts +57 -9
- package/src/commit-tag.ts +21 -0
- package/src/dependent-view.ts +6 -4
- package/src/drift-errors.ts +3 -3
- package/src/drift-findings.ts +213 -60
- package/src/drift.ts +19 -13
- package/src/dump-drift.ts +142 -0
- package/src/errors.ts +8 -7
- package/src/foreign-key.ts +0 -34
- package/src/generate.ts +5 -0
- package/src/index.ts +9 -3
- package/src/introspect-catalog.ts +171 -0
- package/src/introspect.ts +45 -8
- package/src/listen.ts +62 -0
- package/src/migrate.ts +3 -3
- package/src/object-drift.ts +162 -0
- package/src/pglite-branch.ts +6 -6
- package/src/pglite-extensions.ts +112 -0
- package/src/pglite-package.ts +11 -0
- package/src/pglite-snapshot.ts +121 -0
- package/src/pglite.ts +146 -11
- package/src/pool-gauge.ts +70 -0
- package/src/primary-key.ts +180 -0
- package/src/schema-dump-entry.ts +39 -0
- package/src/schema-dump-table.ts +75 -0
- package/src/schema-dump.ts +192 -0
- package/src/schema-load.ts +119 -0
- package/src/sibling-turn.ts +49 -0
- package/src/sqlstate.ts +33 -10
- package/src/statement-funnel.ts +16 -5
- package/src/transaction-errors.ts +66 -0
- package/src/transaction-options.ts +122 -0
- package/src/transaction.ts +131 -121
package/src/client.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `Bun.SQL` slice in `bun-sql.ts` and the observed statement funnel in `statement-funnel.ts`, so
|
|
5
5
|
// importing this module never opens a socket.
|
|
6
6
|
|
|
7
|
-
import { type Role, resolveRole } from '@ultimat3/core';
|
|
7
|
+
import { logger, type Role, renderThrowable, resolveRole } from '@ultimat3/core';
|
|
8
8
|
import {
|
|
9
9
|
type BunSqlDriver,
|
|
10
10
|
type BunSqlReserved,
|
|
@@ -17,6 +17,13 @@ import { connectionUrl } from './connection-url';
|
|
|
17
17
|
// module evaluation, and both sides are `function` declarations, so hoisting covers the TDZ.
|
|
18
18
|
import { defaultClient } from './default-client';
|
|
19
19
|
import { DbError, drainTimeout, driverError } from './errors';
|
|
20
|
+
import {
|
|
21
|
+
assertListenChannel,
|
|
22
|
+
type DbSubscription,
|
|
23
|
+
type ListeningClient,
|
|
24
|
+
listenUnsupported,
|
|
25
|
+
} from './listen';
|
|
26
|
+
import { type PoolDemand, trackPool } from './pool-gauge';
|
|
20
27
|
import { assertPoolProfile, type PoolProfile, poolProfileFor } from './pool-profile';
|
|
21
28
|
import { reserveWithin } from './pool-reserve';
|
|
22
29
|
import { type SqlFragment, sql } from './sql';
|
|
@@ -55,7 +62,7 @@ export interface PostgresClientOptions {
|
|
|
55
62
|
readonly applicationName?: string | undefined;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
export interface PostgresClient extends ReservableClient {
|
|
65
|
+
export interface PostgresClient extends ReservableClient, ListeningClient {
|
|
59
66
|
readonly profile: PoolProfile;
|
|
60
67
|
ping(): Promise<void>;
|
|
61
68
|
close(): Promise<void>;
|
|
@@ -68,18 +75,28 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
68
75
|
...poolProfileFor(role),
|
|
69
76
|
...(options.profile ?? {}),
|
|
70
77
|
});
|
|
71
|
-
|
|
78
|
+
// The driver and what `db_pool_in_use` / `db_pool_waiting` are derived from (`pool-gauge.ts`),
|
|
79
|
+
// as ONE value: a pool still draining after `close()` settles its own work against its own
|
|
80
|
+
// counter, never against the pool that replaced it. Built only once the driver exists, so a
|
|
81
|
+
// connection string that cannot be built registers no pool in `db_pool_max`.
|
|
82
|
+
let driver: { readonly pool: BunSqlDriver; readonly demand: PoolDemand } | undefined;
|
|
72
83
|
|
|
73
|
-
function connect(): BunSqlDriver {
|
|
84
|
+
function connect(): { readonly pool: BunSqlDriver; readonly demand: PoolDemand } {
|
|
74
85
|
if (driver !== undefined) return driver;
|
|
75
86
|
const url = connectionUrl(options, profile);
|
|
76
87
|
const Factory = bunSqlFactory();
|
|
77
|
-
driver = new Factory(url, bunSqlPoolOptions(profile));
|
|
88
|
+
driver = { pool: new Factory(url, bunSqlPoolOptions(profile)), demand: trackPool(profile.max) };
|
|
78
89
|
return driver;
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
async function run(fragment: SqlFragment): Promise<unknown> {
|
|
82
|
-
|
|
93
|
+
const { pool, demand } = connect();
|
|
94
|
+
demand.enter();
|
|
95
|
+
try {
|
|
96
|
+
return await runOn(pool, fragment);
|
|
97
|
+
} finally {
|
|
98
|
+
demand.leave();
|
|
99
|
+
}
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
const client: PostgresClient = {
|
|
@@ -99,11 +116,14 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
99
116
|
// (`ERR_POSTGRES_UNSAFE_TRANSACTION`), and a BEGIN that landed on a different connection
|
|
100
117
|
// than the statement after it would not be a transaction at all — which is exactly what
|
|
101
118
|
// `withTransaction` and `readOnlyQuery` depend on being true.
|
|
102
|
-
const pool = connect();
|
|
119
|
+
const { pool, demand } = connect();
|
|
103
120
|
let reserved: BunSqlReserved;
|
|
121
|
+
// Counted from the ASK: a pin queued behind a full pool is exactly what `waiting` reports.
|
|
122
|
+
demand.enter();
|
|
104
123
|
try {
|
|
105
124
|
reserved = await reserveWithin(pool, profile);
|
|
106
125
|
} catch (error) {
|
|
126
|
+
demand.leave();
|
|
107
127
|
// Acquiring the pin is the one step that runs outside `runOn`, so an exhausted or
|
|
108
128
|
// unreachable pool would escape as an untyped driver error — and `readOnlyQuery` reaches
|
|
109
129
|
// this line before its first statement, which is how MCP ends up returning something
|
|
@@ -126,6 +146,7 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
126
146
|
const release = (): void => {
|
|
127
147
|
if (!held) return;
|
|
128
148
|
held = false;
|
|
149
|
+
demand.leave();
|
|
129
150
|
// Total by construction — `releaseReserved` owns the reason (`bun-sql.ts`).
|
|
130
151
|
releaseReserved(reserved);
|
|
131
152
|
};
|
|
@@ -137,6 +158,30 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
137
158
|
[Symbol.dispose]: release,
|
|
138
159
|
};
|
|
139
160
|
},
|
|
161
|
+
async listen(channel, onNotify, onListening): Promise<DbSubscription> {
|
|
162
|
+
assertListenChannel(channel);
|
|
163
|
+
const { pool } = connect();
|
|
164
|
+
if (pool.listen === undefined) throw listenUnsupported('this Bun.SQL');
|
|
165
|
+
let held: { unlisten(): Promise<void> };
|
|
166
|
+
try {
|
|
167
|
+
// The driver's own session, never a pin out of the pool: a reserved connection holds the
|
|
168
|
+
// LISTEN and surfaces no notification, and it would cost the pool a slot for good.
|
|
169
|
+
held = await pool.listen(channel, onNotify, onListening);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw driverError(`LISTEN ${channel}`, error);
|
|
172
|
+
}
|
|
173
|
+
let ended: Promise<void> | undefined;
|
|
174
|
+
return {
|
|
175
|
+
unlisten: () => {
|
|
176
|
+
// Best-effort, the rule `releaseReserved` states: the session this would end may be
|
|
177
|
+
// gone with the pool already, and that is the outcome asked for.
|
|
178
|
+
ended ??= held.unlisten().catch((error: unknown) => {
|
|
179
|
+
logger.debug('db.unlisten_failed', { error: renderThrowable(error) });
|
|
180
|
+
});
|
|
181
|
+
return ended;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
},
|
|
140
185
|
async ping(): Promise<void> {
|
|
141
186
|
await client.query(sql`select 1`);
|
|
142
187
|
},
|
|
@@ -146,9 +191,12 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
146
191
|
// after it would fail for a reason no caller can see. Clearing first also means a
|
|
147
192
|
// `connect()` racing the await opens a fresh pool instead of joining the one draining. The
|
|
148
193
|
// rejection still reaches the caller — a shutdown that could not drain wants to know.
|
|
149
|
-
const
|
|
194
|
+
const closing = driver;
|
|
150
195
|
driver = undefined;
|
|
151
|
-
if (
|
|
196
|
+
if (closing === undefined) return;
|
|
197
|
+
// Only THIS pool's counter leaves the totals; its in-flight work settles against it.
|
|
198
|
+
closing.demand.close();
|
|
199
|
+
const { pool } = closing;
|
|
152
200
|
// BOUNDED, `As of 2026-08-27`, and through the driver's OWN option rather than a race here.
|
|
153
201
|
// This was a bare `await pool.close()`, and `Bun.SQL`'s `end()` waits on an outstanding
|
|
154
202
|
// reserved connection without ever giving up — measured three runs per case on Bun 1.3.14
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Single responsibility: the server's answer to `COMMIT`, read. Postgres answers a COMMIT on an
|
|
2
|
+
// aborted transaction with the command tag `ROLLBACK` and NO error, so a driver that reports only
|
|
3
|
+
// rejections calls a rolled-back unit of work committed. Both funnels (`statement-funnel.ts`,
|
|
4
|
+
// `pglite.ts`) ask here, so every COMMIT in the process is covered and not only `withTransaction`'s.
|
|
5
|
+
|
|
6
|
+
import { stringField } from '@ultimat3/core';
|
|
7
|
+
import { transactionAborted } from './transaction-errors';
|
|
8
|
+
|
|
9
|
+
/** `COMMIT` and its alias `END`, as the first word. Only consulted once the tag already disagrees. */
|
|
10
|
+
const COMMIT_STATEMENT = /^\s*(?:commit|end)\b/i;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Throws `X_DB_TRANSACTION_ABORTED` when `text` asked for a commit and the tag says `ROLLBACK`.
|
|
14
|
+
* The tag is read first: one property read per statement on the path every statement takes, and
|
|
15
|
+
* the regular expression runs only for a statement that really was answered `ROLLBACK`. Both
|
|
16
|
+
* drivers carry the tag on `command` — measured on Bun.SQL against Postgres 17 and on PGlite.
|
|
17
|
+
*/
|
|
18
|
+
export function refuseRolledBackCommit(text: string, result: unknown): void {
|
|
19
|
+
if (stringField(result, 'command') !== 'ROLLBACK') return;
|
|
20
|
+
if (COMMIT_STATEMENT.test(text)) throw transactionAborted();
|
|
21
|
+
}
|
package/src/dependent-view.ts
CHANGED
|
@@ -139,6 +139,7 @@ async function dependentViews(
|
|
|
139
139
|
join pg_attribute a on a.attrelid = c.oid and a.attnum = d.refobjsubid
|
|
140
140
|
where v.relkind in ('v', 'm') and v.oid <> c.oid
|
|
141
141
|
and c.relname in (${tables}) and a.attname in (${columns})
|
|
142
|
+
and pg_table_is_visible(c.oid)
|
|
142
143
|
order by v.relname
|
|
143
144
|
`);
|
|
144
145
|
}
|
|
@@ -155,7 +156,8 @@ async function dependentViews(
|
|
|
155
156
|
const shellArg = (statement: string): string => `'${statement.replaceAll("'", `'\\''`)}'`;
|
|
156
157
|
|
|
157
158
|
/** The invocation `migrationConflict` already writes, with the statement as its own argv word. */
|
|
158
|
-
const
|
|
159
|
+
export const psqlCommand = (statement: string): string =>
|
|
160
|
+
`psql "$DATABASE_URL" -c ${shellArg(statement)}`;
|
|
159
161
|
|
|
160
162
|
/**
|
|
161
163
|
* The two statements that unblock the deploy, as one line an operator pastes.
|
|
@@ -166,7 +168,7 @@ const psql = (statement: string): string => `psql "$DATABASE_URL" -c ${shellArg(
|
|
|
166
168
|
* a shell read `drop` as a program that does not exist. Neither reader could run it (axiom 4).
|
|
167
169
|
*
|
|
168
170
|
* `identifier()` REFUSES a name holding a quote, a space or a backslash — all three legal inside a
|
|
169
|
-
* quoted Postgres name — and a `fix:` may not throw: the rule `
|
|
171
|
+
* quoted Postgres name — and a `fix:` may not throw: the rule `changedForeignKey` (`drift-findings.ts`) states,
|
|
170
172
|
* with the same shape. A refusal that raised `X_SQL_UNSAFE` in place of the finding would hand the
|
|
171
173
|
* operator an exception where a verdict was asked for, over a view name that is perfectly legal.
|
|
172
174
|
* The fallback still leads with a command that runs — a psql session — because quoting that name
|
|
@@ -192,8 +194,8 @@ function restoreView(view: string, definition: string, relkind: string): string
|
|
|
192
194
|
try {
|
|
193
195
|
const name = identifier(view).text;
|
|
194
196
|
return (
|
|
195
|
-
`${
|
|
196
|
-
`${
|
|
197
|
+
`${psqlCommand(`drop ${kind} ${name}`)} # then x db migrate, then: ` +
|
|
198
|
+
`${psqlCommand(`create ${kind} ${name} as ${body}`)}${note}`
|
|
197
199
|
);
|
|
198
200
|
} catch {
|
|
199
201
|
return (
|
package/src/drift-errors.ts
CHANGED
|
@@ -10,9 +10,9 @@ import { DbError } from './errors';
|
|
|
10
10
|
import { shellInertIdentifier } from './sql';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* The contract's pinned wording
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* The contract's pinned wording, and the ONE definition of it: `@ultimat3/entity` carried a second
|
|
14
|
+
* `dbDrift()` held in step by a comment and a test, deleted `As of 2026-10-02`. Drift is this
|
|
15
|
+
* package's to raise.
|
|
16
16
|
*
|
|
17
17
|
* The column name is the CATALOG's, so it is data: whoever can add a column picks the text that
|
|
18
18
|
* lands here, and `x db gen "add C"` puts it inside SHELL DOUBLE QUOTES, where `$(…)` and a
|
package/src/drift-findings.ts
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
// Single responsibility: what a schema difference is CALLED and what its `fix:`
|
|
2
|
-
// constructor per `DriftKind`, and nothing that compares anything
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// The rendered `X_DB_DRIFT` output is byte-for-byte pinned by the framework contract and
|
|
7
|
-
// duplicated in `@ultimat3/entity` — do not reword a `cause` without changing both.
|
|
8
|
-
//
|
|
9
|
-
// Two rules run through every one of them. A `fix:` is a command the reader can RUN: `x db
|
|
10
|
-
// migrate` where the migration has not been applied, and the statement itself where it has, since
|
|
11
|
-
// re-running the migrator applies nothing a ledger row already claims. And a difference names the
|
|
12
|
-
// declared side's own spelling, never the catalog's, because the catalog's is Postgres' rewriting.
|
|
1
|
+
// Single responsibility: what a schema difference is CALLED and what its `fix:` says — one
|
|
2
|
+
// constructor per `DriftKind`, and nothing that compares anything (`drift.ts` decides whether two
|
|
3
|
+
// schemas disagree). A `fix:` is ONE command a shell runs, and a difference names the declared
|
|
4
|
+
// side's spelling, never the catalog's — which is Postgres' rewriting.
|
|
13
5
|
|
|
14
|
-
import {
|
|
6
|
+
import { psqlCommand } from './dependent-view';
|
|
7
|
+
import { addForeignKey, dropForeignKey, onDeleteRule } from './foreign-key';
|
|
15
8
|
import type { CheckDescription, ForeignKeyDescription } from './introspect';
|
|
16
9
|
import type { Migration } from './migrate';
|
|
10
|
+
import { addPrimaryKey, dropPrimaryKey } from './primary-key';
|
|
17
11
|
import { shellInertIdentifier } from './sql';
|
|
18
12
|
|
|
19
13
|
export type DriftKind =
|
|
@@ -25,9 +19,13 @@ export type DriftKind =
|
|
|
25
19
|
| 'unknown-schema'
|
|
26
20
|
| 'missing-index'
|
|
27
21
|
| 'changed-index'
|
|
22
|
+
| 'changed-primary-key'
|
|
28
23
|
| 'missing-check'
|
|
29
24
|
| 'missing-foreign-key'
|
|
30
|
-
| 'changed-foreign-key'
|
|
25
|
+
| 'changed-foreign-key'
|
|
26
|
+
// Constructed in `object-drift.ts`: a trigger, function, view, type or sequence in the live
|
|
27
|
+
// database that replaying the migrations does not create.
|
|
28
|
+
| 'unexpected-object';
|
|
31
29
|
|
|
32
30
|
export interface DriftDifference {
|
|
33
31
|
readonly kind: DriftKind;
|
|
@@ -42,6 +40,66 @@ export interface DriftReport {
|
|
|
42
40
|
readonly differences: readonly DriftDifference[];
|
|
43
41
|
}
|
|
44
42
|
|
|
43
|
+
const RE_CHECK = 'then x db migrate, which re-checks';
|
|
44
|
+
|
|
45
|
+
/** `set not null` is refused by the server while a row still holds NULL, and says so here. */
|
|
46
|
+
const NULLS_FIRST = `refused while a row holds NULL there, so backfill those first; ${RE_CHECK}`;
|
|
47
|
+
|
|
48
|
+
const CARRIES =
|
|
49
|
+
'a name in this difference carries a backtick, a dollar sign, a quote, a backslash or whitespace';
|
|
50
|
+
|
|
51
|
+
const UNSPELLABLE = `${CARRIES}, so no statement here can spell it`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `x db migrate` is the fix where a migration has not been applied. Where it has, re-running the
|
|
55
|
+
* migrator applies nothing a ledger row already claims, so the fix is the statement itself —
|
|
56
|
+
* a repair made against THIS database, as one line a shell runs: the statement is `psql`'s
|
|
57
|
+
* argument (`psqlCommand`), never bare DDL beside a `#` — `#` is not a comment to Postgres and
|
|
58
|
+
* `alter` is not a program to a shell, so neither reader could run that line (axiom 4). Against
|
|
59
|
+
* this database and never "in a new migration": drift means this database left the migrations,
|
|
60
|
+
* and a migration would re-apply the repair to every database that is already right.
|
|
61
|
+
*/
|
|
62
|
+
const repair = (path: string, statements: string, note = RE_CHECK): string =>
|
|
63
|
+
`${psqlCommand(`${path}${statements}`)} # ${note}`;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The same repair when no statement can be written — a name the screen refuses. Still a command
|
|
67
|
+
* that runs: a psql session, with what to do in it as the comment. No name rides in it, hostile
|
|
68
|
+
* or not; the `cause` holds them, and nobody pastes a cause.
|
|
69
|
+
*/
|
|
70
|
+
export const byHand = (steps: string, why = UNSPELLABLE): string =>
|
|
71
|
+
`psql "$DATABASE_URL" # ${steps}, \\q, ${RE_CHECK} — ${why}`;
|
|
72
|
+
|
|
73
|
+
/** The schema Postgres resolves an unqualified name in when a session sets nothing. */
|
|
74
|
+
const DEFAULT_SCHEMA = 'public';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What puts a statement in the schema its table was READ from: nothing for the default one — the
|
|
78
|
+
* text every app has seen — and `set search_path` in the same psql word for any other, so the
|
|
79
|
+
* table, and every table the statement references, resolves there. A `psql "$DATABASE_URL"`
|
|
80
|
+
* session starts on its own search_path: unqualified, `alter table "posts"` for a table in
|
|
81
|
+
* `tenant_a` fails, or lands on a same-named table in `public`. `null` for a schema no statement
|
|
82
|
+
* can spell.
|
|
83
|
+
*/
|
|
84
|
+
const pathTo = (schema: string): string | null => {
|
|
85
|
+
if (schema === DEFAULT_SCHEMA) return '';
|
|
86
|
+
const name = shellInertIdentifier(schema);
|
|
87
|
+
return name === null ? null : `set search_path = ${name}; `;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** A table as a psql PATTERN or a statement outside `pathTo`: qualified unless the default schema. */
|
|
91
|
+
const qualified = (schema: string, table: string): string | null => {
|
|
92
|
+
const name = shellInertIdentifier(table);
|
|
93
|
+
if (name === null) return null;
|
|
94
|
+
if (schema === DEFAULT_SCHEMA) return name;
|
|
95
|
+
const space = shellInertIdentifier(schema);
|
|
96
|
+
return space === null ? null : `${space}.${name}`;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Every name inert in a shell AND writable as an identifier — the one screen, asked of each. */
|
|
100
|
+
const spellable = (names: readonly string[]): boolean =>
|
|
101
|
+
names.every((name) => shellInertIdentifier(name) !== null);
|
|
102
|
+
|
|
45
103
|
/**
|
|
46
104
|
* The one `fix:` here whose second layer no quoting closes. `x db gen "add C"` puts the column
|
|
47
105
|
* inside SHELL DOUBLE QUOTES, where `$(…)` and a backtick substitute before `x` is reached at all
|
|
@@ -90,13 +148,16 @@ export function missingColumn(table: string, column: string): DriftDifference {
|
|
|
90
148
|
*
|
|
91
149
|
* `x db gen` is deliberately not the fix: it diffs types and indexes and has never emitted a
|
|
92
150
|
* `set not null`, so naming it would send a reader to a command that generates an empty migration.
|
|
151
|
+
* The fix is the statement, run against this database (`repair`).
|
|
93
152
|
*/
|
|
94
153
|
export function changedColumn(
|
|
154
|
+
schema: string,
|
|
95
155
|
table: string,
|
|
96
156
|
column: string,
|
|
97
157
|
liveNullable: boolean,
|
|
98
158
|
): DriftDifference {
|
|
99
159
|
const clause = liveNullable ? 'set not null' : 'drop not null';
|
|
160
|
+
const path = pathTo(schema);
|
|
100
161
|
const relation = shellInertIdentifier(table);
|
|
101
162
|
const attribute = shellInertIdentifier(column);
|
|
102
163
|
return {
|
|
@@ -106,16 +167,15 @@ export function changedColumn(
|
|
|
106
167
|
cause: liveNullable
|
|
107
168
|
? `table "${table}" allows NULL in column "${column}" that migrations declare not null`
|
|
108
169
|
: `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`,
|
|
109
|
-
// Both identifiers are the catalog's, so both go through the one screen.
|
|
110
|
-
// column as the thing it could not spell, which is what tells this line apart from
|
|
111
|
-
// `missingCheck`'s refusal in a report that carries both.
|
|
170
|
+
// Both identifiers are the catalog's, so both go through the one screen.
|
|
112
171
|
fix:
|
|
113
|
-
relation === null || attribute === null
|
|
114
|
-
?
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
172
|
+
path === null || relation === null || attribute === null
|
|
173
|
+
? byHand(`alter column … ${clause} on the column this difference names`)
|
|
174
|
+
: repair(
|
|
175
|
+
path,
|
|
176
|
+
`alter table ${relation} alter column ${attribute} ${clause};`,
|
|
177
|
+
liveNullable ? NULLS_FIRST : RE_CHECK,
|
|
178
|
+
),
|
|
119
179
|
};
|
|
120
180
|
}
|
|
121
181
|
|
|
@@ -131,22 +191,32 @@ export function changedColumn(
|
|
|
131
191
|
* the relation is already there, and `x db migrate` then accepts a table its own SQL creates), or
|
|
132
192
|
* nothing owns it and it should not be in this schema. No migration PATH is named: where an app
|
|
133
193
|
* keeps its migrations is the CLI's fact, not this package's.
|
|
194
|
+
*
|
|
195
|
+
* Two repairs and one line, so the line leads with the command neither repair can skip — `\\d` on
|
|
196
|
+
* the table, through `psqlCommand`, which is what keeps a `'` in the name inside its shell word.
|
|
134
197
|
*/
|
|
135
|
-
export function unexpectedTable(table: string): DriftDifference {
|
|
136
|
-
const name =
|
|
198
|
+
export function unexpectedTable(schema: string, table: string): DriftDifference {
|
|
199
|
+
const name = qualified(schema, table);
|
|
200
|
+
// The comment repeats the name only when it holds no `'`: a shell that does not read `#` as a
|
|
201
|
+
// comment (interactive zsh, by default) would open a quote on one. The command is safe either
|
|
202
|
+
// way — `psqlCommand` escapes it inside its own word.
|
|
203
|
+
const spoken = name === null || name.includes("'") ? 'this table' : name;
|
|
137
204
|
return {
|
|
138
205
|
kind: 'unexpected-table',
|
|
139
206
|
table,
|
|
140
207
|
column: null,
|
|
141
208
|
cause: `table "${table}" is not present in any migration`,
|
|
209
|
+
// The command is the harmless one — it SHOWS the table, which either repair needs first — and
|
|
210
|
+
// the two repairs are its comment.
|
|
142
211
|
fix:
|
|
143
212
|
name === null
|
|
144
|
-
?
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
213
|
+
? byHand(
|
|
214
|
+
`inspect the table this difference names with \\d, then claim it in a migration ` +
|
|
215
|
+
'with create table if not exists or drop it',
|
|
216
|
+
)
|
|
217
|
+
: `${psqlCommand(`\\d ${name}`)} # nothing declares it: put create table if not exists ` +
|
|
218
|
+
`${spoken} (…) in a migration, then x db migrate — or, if nothing owns it, run ` +
|
|
219
|
+
`drop table ${spoken}; here`,
|
|
150
220
|
};
|
|
151
221
|
}
|
|
152
222
|
|
|
@@ -173,7 +243,7 @@ export function unknownSchema(migrations: readonly Migration[]): DriftDifference
|
|
|
173
243
|
// id off the file and derives the name from it — so whoever can add a file to the migrations
|
|
174
244
|
// directory picks what a reader pastes, and `$(…)` and a backtick substitute before `git` or `x`
|
|
175
245
|
// is reached. The same screen `unexpectedColumn` and `changedColumn` already ran, on the one
|
|
176
|
-
// finding in this file that skipped it. Degraded to
|
|
246
|
+
// finding in this file that skipped it. Degraded to a read-only command rather than escaped: a glob is not an
|
|
177
247
|
// identifier and a migration description is not one either, so neither has a quoted form that
|
|
178
248
|
// makes a hostile name safe. An EMPTY id is inert by construction and keeps its glob — that is
|
|
179
249
|
// "no migrations at all", not a name this function refused to spell.
|
|
@@ -193,10 +263,10 @@ export function unknownSchema(migrations: readonly Migration[]): DriftDifference
|
|
|
193
263
|
fix: spellable
|
|
194
264
|
? `git checkout -- "*${id}.snapshot.json" # or, if it was never written: ` +
|
|
195
265
|
`delete migration "${id}" and rerun x db gen "${name}"`
|
|
196
|
-
: '
|
|
197
|
-
'migration and rerun x db gen with its description — the
|
|
198
|
-
"difference's cause carries a backtick, a dollar sign, a
|
|
199
|
-
'whitespace in its file name, so no command here can spell it',
|
|
266
|
+
: 'git status --short -- "*.snapshot.json" # shows the sidecar that is gone: git ' +
|
|
267
|
+
'checkout it, or delete that migration and rerun x db gen with its description — the ' +
|
|
268
|
+
"migration named in this difference's cause carries a backtick, a dollar sign, a " +
|
|
269
|
+
'quote, a backslash or whitespace in its file name, so no command here can spell it',
|
|
200
270
|
};
|
|
201
271
|
}
|
|
202
272
|
|
|
@@ -229,12 +299,17 @@ export function changedIndex(table: string, index: string, detail: string): Drif
|
|
|
229
299
|
* 'published'::text])))` — so a text comparison reports drift on a correct database forever, and
|
|
230
300
|
* normalising it is an expression parser competing with the server's. Presence is not text.
|
|
231
301
|
*
|
|
232
|
-
* The `fix` is the statement, not `x db migrate`: the migration that declares this
|
|
233
|
-
* already in the ledger, so re-running the migrator applies nothing. Same reasoning
|
|
234
|
-
* `changedColumn` and `changedForeignKey` — the declared side holds the author's own spelling
|
|
235
|
-
* the predicate, which is what makes an executable fix possible at all.
|
|
302
|
+
* The `fix` is the statement (`repair`), not `x db migrate`: the migration that declares this
|
|
303
|
+
* constraint is already in the ledger, so re-running the migrator applies nothing. Same reasoning
|
|
304
|
+
* as `changedColumn` and `changedForeignKey` — the declared side holds the author's own spelling
|
|
305
|
+
* of the predicate, which is what makes an executable fix possible at all.
|
|
236
306
|
*/
|
|
237
|
-
export function missingCheck(
|
|
307
|
+
export function missingCheck(
|
|
308
|
+
schema: string,
|
|
309
|
+
table: string,
|
|
310
|
+
check: CheckDescription,
|
|
311
|
+
): DriftDifference {
|
|
312
|
+
const path = pathTo(schema);
|
|
238
313
|
const relation = shellInertIdentifier(table);
|
|
239
314
|
const constraint = shellInertIdentifier(check.name);
|
|
240
315
|
return {
|
|
@@ -242,23 +317,18 @@ export function missingCheck(table: string, check: CheckDescription): DriftDiffe
|
|
|
242
317
|
table,
|
|
243
318
|
column: null,
|
|
244
319
|
cause: `table "${table}" is missing check constraint "${check.name}" that migrations declare`,
|
|
245
|
-
// The command rides on the same line as the statement, and not only because `check` is a
|
|
246
|
-
// banned advice word the `errors` gate demands a command beside: writing the migration is half
|
|
247
|
-
// the repair and applying it is the other half, and `changedColumn`'s bare `# in a new
|
|
248
|
-
// migration` leaves the second half to be guessed.
|
|
249
|
-
//
|
|
250
320
|
// Both NAMES go through the one screen; the EXPRESSION deliberately does not, and cannot. It
|
|
251
321
|
// is a predicate, so no screen could accept `status in ('draft', 'published')` and reject a
|
|
252
322
|
// second statement — and it is the DECLARED side's own text, out of the author's migration,
|
|
253
|
-
// where both names are the catalog's and a sidecar's.
|
|
254
|
-
//
|
|
323
|
+
// where both names are the catalog's and a sidecar's. What `psqlCommand` does close is the
|
|
324
|
+
// shell layer: the statement is one single-quoted word, so nothing in the predicate expands.
|
|
255
325
|
fix:
|
|
256
|
-
relation === null || constraint === null
|
|
257
|
-
? 'add the constraint
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
326
|
+
path === null || relation === null || constraint === null
|
|
327
|
+
? byHand('add the check constraint this difference names back')
|
|
328
|
+
: repair(
|
|
329
|
+
path,
|
|
330
|
+
`alter table ${relation} add constraint ${constraint} check (${check.expression});`,
|
|
331
|
+
),
|
|
262
332
|
};
|
|
263
333
|
}
|
|
264
334
|
|
|
@@ -279,26 +349,109 @@ export function missingForeignKey(table: string, key: ForeignKeyDescription): Dr
|
|
|
279
349
|
* — reported apart from `missing-foreign-key` because it is a different repair: the constraint is
|
|
280
350
|
* there, and what changed is what happens to the child rows.
|
|
281
351
|
*
|
|
282
|
-
* The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add
|
|
283
|
-
* alone is `42710` on a name already taken, and no `x db gen` diff emits either
|
|
284
|
-
*
|
|
285
|
-
*
|
|
352
|
+
* The `fix` is the pair (`repair`), not `x db migrate`: a rule cannot be altered in place, `add
|
|
353
|
+
* constraint` alone is `42710` on a name already taken, and no `x db gen` diff emits either
|
|
354
|
+
* statement. Same reasoning as `changedColumn`.
|
|
355
|
+
*
|
|
356
|
+
* `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s, so every name is
|
|
357
|
+
* screened and the two writers are ASKED whether they can write the pair — never a second copy of
|
|
358
|
+
* their rules beside them. `identifier()` refuses a name holding a quote, a space or a backslash
|
|
359
|
+
* and `addForeignKey` refuses an `on delete` rule Postgres does not have: right for DDL this
|
|
360
|
+
* package SENDS, wrong for a `fix:`. `diffSchema` is documented pure and total, so a pair it
|
|
361
|
+
* cannot write is a psql session and a sentence, never a throw.
|
|
362
|
+
*
|
|
363
|
+
* The CAUSE names the constraint the database holds, whatever it is called: a refused name is out
|
|
364
|
+
* of the command, and the cause is where a reader still finds which key this is.
|
|
286
365
|
*/
|
|
287
366
|
export function changedForeignKey(
|
|
367
|
+
schema: string,
|
|
288
368
|
table: string,
|
|
289
369
|
declared: ForeignKeyDescription,
|
|
290
370
|
held: ForeignKeyDescription,
|
|
291
371
|
): DriftDifference {
|
|
292
372
|
const rule = onDeleteRule(held.onDelete);
|
|
373
|
+
const rebuilt = (): string => {
|
|
374
|
+
const steps =
|
|
375
|
+
'drop the foreign key this difference names and add it back with the on delete rule ' +
|
|
376
|
+
'migrations declare';
|
|
377
|
+
const names = [
|
|
378
|
+
table,
|
|
379
|
+
held.name,
|
|
380
|
+
declared.name,
|
|
381
|
+
declared.referencedTable,
|
|
382
|
+
...declared.columns,
|
|
383
|
+
...declared.referencedColumns,
|
|
384
|
+
];
|
|
385
|
+
const path = pathTo(schema);
|
|
386
|
+
if (path === null || !spellable(names)) return byHand(steps);
|
|
387
|
+
try {
|
|
388
|
+
return repair(path, `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`);
|
|
389
|
+
} catch {
|
|
390
|
+
return byHand(steps, 'the rule migrations declare is not one Postgres has');
|
|
391
|
+
}
|
|
392
|
+
};
|
|
293
393
|
return {
|
|
294
394
|
kind: 'changed-foreign-key',
|
|
295
395
|
table,
|
|
296
396
|
column: null,
|
|
297
397
|
cause:
|
|
298
|
-
`foreign key on "${table}" (${declared.columns.join(', ')}) to ` +
|
|
398
|
+
`foreign key "${held.name}" on "${table}" (${declared.columns.join(', ')}) to ` +
|
|
299
399
|
`"${declared.referencedTable}" ` +
|
|
300
400
|
`${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` +
|
|
301
401
|
'migrations declare',
|
|
302
|
-
fix:
|
|
402
|
+
fix: rebuilt(),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const PRIMARY_KEY_BY_HAND = byHand(
|
|
407
|
+
'drop the primary key this database holds, add the one migrations declare',
|
|
408
|
+
`${CARRIES} or is too long, so no statement here can spell it`,
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const keyText = (columns: readonly string[]): string =>
|
|
412
|
+
columns.length === 0 ? 'no primary key' : `primary key (${columns.join(', ')})`;
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The two sides key the table differently — a different column list, a different ORDER, or a key
|
|
416
|
+
* on one side only. Its own kind rather than `changed-index` on `<table>_pkey`: that finding's fix
|
|
417
|
+
* is `x db migrate`, and the migration declaring this key is already in the ledger, so re-running
|
|
418
|
+
* the migrator applies nothing.
|
|
419
|
+
*
|
|
420
|
+
* The fix is ONE command a shell runs — the pair as `psql`'s argument (`repair`).
|
|
421
|
+
*
|
|
422
|
+
* `held` is the constraint the DATABASE holds — the live primary index's name, which is the
|
|
423
|
+
* constraint's — because that is the one a `drop constraint` has to spell. The writers are asked
|
|
424
|
+
* whether they can write each statement and a refusal degrades the whole line to prose, the rule
|
|
425
|
+
* `changedForeignKey` states: every name on the live side is the catalog's.
|
|
426
|
+
*/
|
|
427
|
+
export function changedPrimaryKey(
|
|
428
|
+
schema: string,
|
|
429
|
+
table: string,
|
|
430
|
+
live: readonly string[],
|
|
431
|
+
held: string | undefined,
|
|
432
|
+
declared: readonly string[],
|
|
433
|
+
): DriftDifference {
|
|
434
|
+
const statements = (): string => {
|
|
435
|
+
const names = [table, ...declared, ...(held === undefined ? [] : [held])];
|
|
436
|
+
const path = pathTo(schema);
|
|
437
|
+
if (path === null || !spellable(names)) return PRIMARY_KEY_BY_HAND;
|
|
438
|
+
try {
|
|
439
|
+
const parts: string[] = [];
|
|
440
|
+
if (held !== undefined) parts.push(dropPrimaryKey(table, held, false));
|
|
441
|
+
if (declared.length > 0) parts.push(addPrimaryKey(table, declared));
|
|
442
|
+
return repair(path, parts.join(' '));
|
|
443
|
+
} catch {
|
|
444
|
+
return PRIMARY_KEY_BY_HAND;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
return {
|
|
448
|
+
kind: 'changed-primary-key',
|
|
449
|
+
table,
|
|
450
|
+
column: null,
|
|
451
|
+
cause:
|
|
452
|
+
`table "${table}" has ${keyText(live)}${held === undefined ? '' : ` as constraint "${held}"`}, ` +
|
|
453
|
+
'and migrations declare ' +
|
|
454
|
+
(declared.length === 0 ? 'none' : `(${declared.join(', ')})`),
|
|
455
|
+
fix: statements(),
|
|
303
456
|
};
|
|
304
457
|
}
|