@ultimat3/db 23.0.0 → 25.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 +83 -85
- package/README.md +88 -27
- package/package.json +3 -3
- package/src/array-parameter.ts +38 -1
- package/src/bound-parameters.ts +22 -3
- package/src/catalog-fold.ts +4 -1
- package/src/catalog-objects.ts +29 -0
- package/src/catalog.ts +10 -2
- package/src/client.ts +2 -2
- package/src/column-alter.ts +9 -3
- package/src/commit-tag.ts +21 -0
- package/src/default-client.ts +4 -4
- package/src/dependent-view.ts +7 -5
- package/src/destructive.ts +1 -1
- package/src/drift-append-only.ts +53 -0
- package/src/drift-errors.ts +3 -3
- package/src/drift-findings.ts +211 -59
- package/src/drift.ts +33 -19
- package/src/entity-shape.ts +5 -0
- package/src/errors.ts +12 -8
- package/src/fake.ts +1 -1
- package/src/foreign-key.ts +0 -34
- package/src/generate-append-only.ts +146 -0
- package/src/generate.ts +20 -6
- package/src/index.ts +11 -9
- package/src/introspect-catalog.ts +19 -2
- package/src/introspect.ts +92 -12
- package/src/migrate-rollback.ts +44 -0
- package/src/migrate.ts +48 -158
- package/src/migration-ledger.ts +167 -0
- package/src/object-drift.ts +77 -20
- package/src/pglite-branch.ts +7 -7
- package/src/pglite.ts +16 -4
- package/src/pool-profile.ts +1 -1
- package/src/primary-key.ts +210 -0
- package/src/schema-dump-table.ts +4 -1
- package/src/sibling-turn.ts +49 -0
- package/src/snapshot-parse.ts +9 -3
- package/src/sqlstate.ts +30 -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/drift-fixtures.ts +0 -23
- package/src/fake-pglite.ts +0 -32
- package/src/fake-reservable.ts +0 -50
package/src/array-parameter.ts
CHANGED
|
@@ -28,18 +28,45 @@ import { assert } from '@ultimat3/core';
|
|
|
28
28
|
* backslash, or leading/trailing whitespace the parser would strip — plus the empty string, which
|
|
29
29
|
* unquoted is not an element at all.
|
|
30
30
|
*/
|
|
31
|
+
const hexOf = (bytes: Uint8Array): string =>
|
|
32
|
+
Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A `Date` as the instant Postgres reads, or `X_INVARIANT` for one that holds no instant.
|
|
36
|
+
* `toISOString()` on an Invalid Date is a bare `RangeError`, and inside the funnel that became
|
|
37
|
+
* "cannot reach the database". `position` names the parameter when the caller knows it.
|
|
38
|
+
*/
|
|
39
|
+
export function instantText(value: Date, position?: number): string {
|
|
40
|
+
assert(
|
|
41
|
+
!Number.isNaN(value.getTime()),
|
|
42
|
+
`${position === undefined ? 'an array element' : `parameter $${position}`} is an Invalid Date, which names no instant Postgres could store`,
|
|
43
|
+
'new Date(input) answers Invalid Date for text it cannot parse — parse it with t.date first, or bind null: Number.isNaN(value.getTime()) ? null : value',
|
|
44
|
+
);
|
|
45
|
+
return value.toISOString();
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
function element(value: unknown): string {
|
|
32
49
|
if (value === null || value === undefined) return 'NULL';
|
|
33
50
|
// A Date is ALWAYS quoted, even though an ISO-8601 instant carries no character the grammar
|
|
34
51
|
// reads as structure. A timestamp element is conventionally quoted, and the alternative is a
|
|
35
52
|
// rule that holds only while nothing ever renders a timestamp with a space in it.
|
|
36
|
-
if (value instanceof Date) return `"${value
|
|
53
|
+
if (value instanceof Date) return `"${instantText(value)}"`;
|
|
54
|
+
// BYTEA's hex form, never `String(bytes)` — that is `1,2,3`, three elements where one was bound,
|
|
55
|
+
// and no error anywhere. The backslash is doubled because a quoted element reads `\\` as one.
|
|
56
|
+
if (value instanceof Uint8Array) return `"\\\\x${hexOf(value)}"`;
|
|
37
57
|
const text = String(value);
|
|
38
58
|
const structural = /[{},"\\\s]/.test(text) || text.length === 0 || text.toUpperCase() === 'NULL';
|
|
39
59
|
if (!structural) return text;
|
|
40
60
|
return `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
41
61
|
}
|
|
42
62
|
|
|
63
|
+
/** The extents along a value's FIRST path, `2x3` for two rows of three — `''` for a scalar. */
|
|
64
|
+
function extentsOf(value: unknown): string {
|
|
65
|
+
if (!Array.isArray(value)) return '';
|
|
66
|
+
const inner = extentsOf(value[0]);
|
|
67
|
+
return inner === '' ? String(value.length) : `${value.length}x${inner}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
/**
|
|
44
71
|
* The array literal for one parameter: `{a,b,c}`, elements escaped.
|
|
45
72
|
*
|
|
@@ -71,5 +98,15 @@ export function pgArrayLiteral(values: readonly unknown[]): string {
|
|
|
71
98
|
`a nested array parameter is ragged — its rows are ${nested.map((row) => row.length).join(', ')} long, and Postgres has no jagged array`,
|
|
72
99
|
'give every row the same length, or bind one array per row',
|
|
73
100
|
);
|
|
101
|
+
// Every extent, not only this level's: `[[['a']], [['b', 'c']]]` is two rows of one element each,
|
|
102
|
+
// each rectangular on its own, and `{{{a}},{{b,c}}}` is the same 22P02 — measured on 17. Each
|
|
103
|
+
// row is checked against itself by the recursion below, so comparing one path's extents per row
|
|
104
|
+
// is comparing all of them.
|
|
105
|
+
const depth = extentsOf(nested[0]);
|
|
106
|
+
assert(
|
|
107
|
+
nested.every((row) => extentsOf(row) === depth),
|
|
108
|
+
`a nested array parameter is ragged below its first level — its rows have the extents ${nested.map((row) => extentsOf(row)).join(' | ')}, and Postgres has no jagged array`,
|
|
109
|
+
'give every branch the same length at every depth, or bind one array per row',
|
|
110
|
+
);
|
|
74
111
|
return `{${values.map((value) => (Array.isArray(value) ? pgArrayLiteral(value) : element(value))).join(',')}}`;
|
|
75
112
|
}
|
package/src/bound-parameters.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// described type to go by the driver sent `Date.prototype.toString()`, a local-zone string
|
|
5
5
|
// Postgres refuses (`22007`), so every entity write carrying a timestamp failed.
|
|
6
6
|
|
|
7
|
-
import { pgArrayLiteral } from './array-parameter';
|
|
7
|
+
import { instantText, pgArrayLiteral } from './array-parameter';
|
|
8
8
|
|
|
9
9
|
const needsEncoding = (value: unknown): boolean => Array.isArray(value) || value instanceof Date;
|
|
10
10
|
|
|
@@ -12,12 +12,31 @@ const needsEncoding = (value: unknown): boolean => Array.isArray(value) || value
|
|
|
12
12
|
* A NEW ARRAY ONLY WHEN SOMETHING CHANGED — every statement in the process passes through here, so
|
|
13
13
|
* the common path is one `some` and the caller's own array, byte for byte (axiom 6). A
|
|
14
14
|
* `Uint8Array` is BYTEA, never an array: `Array.isArray` answers `false` for a typed array.
|
|
15
|
+
*
|
|
16
|
+
* It REFUSES what cannot be sent — a ragged array, an Invalid Date — with `X_INVARIANT`, so the
|
|
17
|
+
* funnel calls it BEFORE the driver's `try`: inside it, a refusal was re-wrapped as a driver
|
|
18
|
+
* failure and read "cannot reach the database".
|
|
15
19
|
*/
|
|
20
|
+
/**
|
|
21
|
+
* The refusals alone, for a driver that does its own encoding. PGlite renders an array and a
|
|
22
|
+
* `Date` correctly, so `pglite.ts` sends the caller's values untouched — but what cannot be sent
|
|
23
|
+
* must be refused alike on both funnels: an Invalid Date (its serializer answers a bare
|
|
24
|
+
* `RangeError`) and a ragged or mixed-depth array, which the pooled path refuses in
|
|
25
|
+
* `pgArrayLiteral`. That function IS the shape rule, so it is asked and its literal discarded
|
|
26
|
+
* rather than restated here; only a statement that binds an array pays for it.
|
|
27
|
+
*/
|
|
28
|
+
export function refuseUnsendable(values: readonly unknown[]): void {
|
|
29
|
+
for (const [index, value] of values.entries()) {
|
|
30
|
+
if (value instanceof Date) instantText(value, index + 1);
|
|
31
|
+
else if (Array.isArray(value)) pgArrayLiteral(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
export function encodeBoundParameters(values: readonly unknown[]): readonly unknown[] {
|
|
17
36
|
if (!values.some(needsEncoding)) return values;
|
|
18
|
-
return values.map((value) => {
|
|
37
|
+
return values.map((value, index) => {
|
|
19
38
|
if (Array.isArray(value)) return pgArrayLiteral(value);
|
|
20
|
-
if (value instanceof Date) return value
|
|
39
|
+
if (value instanceof Date) return instantText(value, index + 1);
|
|
21
40
|
return value;
|
|
22
41
|
});
|
|
23
42
|
}
|
package/src/catalog-fold.ts
CHANGED
|
@@ -51,7 +51,10 @@ function columnOf(row: ColumnRow, sequences: readonly SequenceRow[]): CatalogCol
|
|
|
51
51
|
type: row.type,
|
|
52
52
|
notNull: row.not_null,
|
|
53
53
|
default: generated ? null : row.expression,
|
|
54
|
-
generated:
|
|
54
|
+
generated:
|
|
55
|
+
generated && row.expression !== null
|
|
56
|
+
? { expression: row.expression, storage: row.generated === 'v' ? 'virtual' : 'stored' }
|
|
57
|
+
: null,
|
|
55
58
|
identity:
|
|
56
59
|
identity === undefined
|
|
57
60
|
? null
|
package/src/catalog-objects.ts
CHANGED
|
@@ -148,6 +148,15 @@ export const triggerRows = (client: DbClient, schema: string): Promise<readonly
|
|
|
148
148
|
* What exists in the schema and the dump cannot spell. One query, one closed list — a kind added
|
|
149
149
|
* here is a kind the dump admits it does not carry, and a kind rendered later leaves this list in
|
|
150
150
|
* the same diff.
|
|
151
|
+
*
|
|
152
|
+
* The last four are facts ABOUT an object the dump does render, and each was absent from both the
|
|
153
|
+
* dump and this list: `create statistics`, `force row level security` (a second flag beside
|
|
154
|
+
* `relrowsecurity`), a column whose `set storage` departs from its type's own, and a materialized
|
|
155
|
+
* view created `with no data` — which the dump's `create materialized view` would populate. A
|
|
156
|
+
* load-equals-replay check cannot see any of them, because both sides are this same reading.
|
|
157
|
+
*
|
|
158
|
+
* A trigger on a relation the dump does not create is the one kind NOT read here: which relations
|
|
159
|
+
* are rendered is the fold's answer, so `introspectCatalog` names those itself.
|
|
151
160
|
*/
|
|
152
161
|
export const unrenderedRows = (
|
|
153
162
|
client: DbClient,
|
|
@@ -194,6 +203,26 @@ export const unrenderedRows = (
|
|
|
194
203
|
from pg_rewrite r
|
|
195
204
|
join pg_class c on c.oid = r.ev_class
|
|
196
205
|
where r.rulename <> '_RETURN'
|
|
206
|
+
union all
|
|
207
|
+
select 'extended statistics', s.stxname, c.relname, s.stxnamespace
|
|
208
|
+
from pg_statistic_ext s
|
|
209
|
+
join pg_class c on c.oid = s.stxrelid
|
|
210
|
+
union all
|
|
211
|
+
select 'forced row security', c.relname, c.relname, c.relnamespace
|
|
212
|
+
from pg_class c
|
|
213
|
+
where c.relforcerowsecurity
|
|
214
|
+
union all
|
|
215
|
+
select 'column storage', a.attname, c.relname, c.relnamespace
|
|
216
|
+
from pg_attribute a
|
|
217
|
+
join pg_class c on c.oid = a.attrelid
|
|
218
|
+
join pg_type t on t.oid = a.atttypid
|
|
219
|
+
where c.relkind = 'r' and a.attnum > 0 and not a.attisdropped
|
|
220
|
+
and a.attstorage <> t.typstorage
|
|
221
|
+
and ${notExtensionOwned('pg_class', 'c.oid')}
|
|
222
|
+
union all
|
|
223
|
+
select 'unpopulated materialized view', c.relname, null::text, c.relnamespace
|
|
224
|
+
from pg_class c
|
|
225
|
+
where c.relkind = 'm' and not c.relispopulated
|
|
197
226
|
) objects
|
|
198
227
|
join pg_namespace n on n.oid = objects.namespace
|
|
199
228
|
where n.nspname = ${schema}
|
package/src/catalog.ts
CHANGED
|
@@ -56,8 +56,16 @@ export interface CatalogColumn {
|
|
|
56
56
|
readonly notNull: boolean;
|
|
57
57
|
/** `pg_get_expr` of the default; `null` when there is none or the column is generated. */
|
|
58
58
|
readonly default: string | null;
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The generation expression and HOW it is kept, when `attgenerated` says the column has one:
|
|
61
|
+
* `s` is `stored`, `v` (Postgres 18) is `virtual` — computed on read, nothing on disk. Both
|
|
62
|
+
* halves, because a dump that spelled every one `stored` loaded a virtual column as a stored
|
|
63
|
+
* one and round-tripped "equal": both sides of that comparison were this reading.
|
|
64
|
+
*/
|
|
65
|
+
readonly generated: {
|
|
66
|
+
readonly expression: string;
|
|
67
|
+
readonly storage: 'stored' | 'virtual';
|
|
68
|
+
} | null;
|
|
61
69
|
/** `always` / `by default`, with the identity sequence's own options. */
|
|
62
70
|
readonly identity: {
|
|
63
71
|
readonly mode: 'always' | 'by default';
|
package/src/client.ts
CHANGED
|
@@ -69,7 +69,7 @@ export interface PostgresClient extends ReservableClient, ListeningClient {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** Lazily connects: the pool opens on the first statement, never at import. */
|
|
72
|
-
export function
|
|
72
|
+
export function postgresClient(options: PostgresClientOptions = {}): PostgresClient {
|
|
73
73
|
const role = options.role ?? resolveRole();
|
|
74
74
|
const profile: PoolProfile = assertPoolProfile({
|
|
75
75
|
...poolProfileFor(role),
|
|
@@ -247,7 +247,7 @@ export function setDbClient(client: DbClient | undefined): void {
|
|
|
247
247
|
*
|
|
248
248
|
* The role default is layered under `DATABASE_POOL_MAX`, because this is the one place the process
|
|
249
249
|
* builds its own client and therefore the only place an operator's value can reach one:
|
|
250
|
-
* `
|
|
250
|
+
* `postgresClient` has always taken a `profile` override and nothing in a running app passed
|
|
251
251
|
* it, so `POOL_PROFILES` was the last word in a deployed image. `default-client.ts` owns what gets
|
|
252
252
|
* built — one pool, or a primary and a replica when `DATABASE_REPLICA_URL` names one.
|
|
253
253
|
*/
|
package/src/column-alter.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import { defaultExpression, hasUnrenderedDefault } from './column-default';
|
|
8
8
|
import { columnDefaultUnsafe } from './ddl-errors';
|
|
9
|
-
import type { ColumnDescriptionLike } from './entity-shape';
|
|
9
|
+
import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
|
|
10
10
|
import type { Plan } from './foreign-key-plan';
|
|
11
|
+
import { appendOnlyBackfillRefused } from './generate-append-only';
|
|
11
12
|
import type { ColumnDescription } from './introspect';
|
|
12
13
|
import { identifier } from './sql';
|
|
13
14
|
import { statementsOf } from './statement-split';
|
|
@@ -29,18 +30,20 @@ function screened(column: string, expression: string): string {
|
|
|
29
30
|
* `set default` / `drop default` and `drop not null`, each with its reverse in `down` — the shape
|
|
30
31
|
* `redefineIndex` (`index-ddl.ts`) gives a moved index. Becoming NOT NULL is deliberately NOT a
|
|
31
32
|
* bare `set not null`: rows already holding `NULL` make it fail inside `ROLE=migrate`, so it gets
|
|
32
|
-
* the expand/contract note `diffTable` writes for a NOT NULL column added to a populated table
|
|
33
|
+
* the expand/contract note `diffTable` writes for a NOT NULL column added to a populated table —
|
|
34
|
+
* refused on an append-only table, whose backfill the trigger would refuse.
|
|
33
35
|
*
|
|
34
36
|
* A default this generator cannot render (`hasUnrenderedDefault`) moves nothing: dropping the one
|
|
35
37
|
* the database holds would lose a rule the entity still states, and `unrenderedOf` already reports
|
|
36
38
|
* it at the top of `up`.
|
|
37
39
|
*/
|
|
38
40
|
export function alterColumnInPlace(
|
|
39
|
-
|
|
41
|
+
entity: EntityDescriptionLike,
|
|
40
42
|
column: ColumnDescriptionLike,
|
|
41
43
|
recorded: ColumnDescription,
|
|
42
44
|
plan: Plan,
|
|
43
45
|
): void {
|
|
46
|
+
const { table } = entity;
|
|
44
47
|
const alter = `alter table ${identifier(table).text} alter column ${identifier(column.column).text}`;
|
|
45
48
|
const wanted = hasUnrenderedDefault(column) ? recorded.default : defaultExpression(column);
|
|
46
49
|
const held = recorded.default;
|
|
@@ -59,6 +62,9 @@ export function alterColumnInPlace(
|
|
|
59
62
|
plan.down.push(`${alter} set not null;`);
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
65
|
+
// That backfill is an UPDATE, and an append-only table's trigger refuses every one. Refused even
|
|
66
|
+
// with a default now declared: `set default` fills no row that already holds NULL.
|
|
67
|
+
if (entity.appendOnly === true) throw appendOnlyBackfillRefused(entity, column, 'made-not-null');
|
|
62
68
|
// `drop not null` in `down` is a no-op on a column that never became NOT NULL, so the reverse is
|
|
63
69
|
// right whether or not the backfill and its `set not null` were ever run.
|
|
64
70
|
plan.up.push(`-- backfill ${identifier(column.column).text}, then: ${alter} set not null;`);
|
|
@@ -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/default-client.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// framework decides a process's database topology from the environment, and `client.ts` is at the
|
|
4
4
|
// line ceiling.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { type DbClient, postgresClient } from './client';
|
|
7
7
|
import { poolMaxFromEnv } from './pool-profile';
|
|
8
8
|
import { replicatedClient } from './replica-client';
|
|
9
9
|
|
|
@@ -18,7 +18,7 @@ import { replicatedClient } from './replica-client';
|
|
|
18
18
|
export const REPLICA_URL_ENV = 'DATABASE_REPLICA_URL';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Composed rather than folded into `
|
|
21
|
+
* Composed rather than folded into `postgresClient`, on purpose: `migrate`, `x db branch` and
|
|
22
22
|
* every test build a client that must be exactly one pool, and a second pool reachable through the
|
|
23
23
|
* same factory would be a second thing `reserve()`, `close()` and `ping()` each have to mean two
|
|
24
24
|
* ways.
|
|
@@ -29,8 +29,8 @@ export const REPLICA_URL_ENV = 'DATABASE_REPLICA_URL';
|
|
|
29
29
|
*/
|
|
30
30
|
export function defaultClient(): DbClient {
|
|
31
31
|
const profile = poolMaxFromEnv();
|
|
32
|
-
const primary =
|
|
32
|
+
const primary = postgresClient({ profile });
|
|
33
33
|
const replicaUrl = process.env[REPLICA_URL_ENV];
|
|
34
34
|
if (replicaUrl === undefined || replicaUrl.trim() === '') return primary;
|
|
35
|
-
return replicatedClient(primary,
|
|
35
|
+
return replicatedClient(primary, postgresClient({ url: replicaUrl, profile }));
|
|
36
36
|
}
|
package/src/dependent-view.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// before the statement is sent — and name the view, the column and the statement that recreates it.
|
|
3
3
|
//
|
|
4
4
|
// **This is the honest ceiling for views, and the reason it is not in the generator.** `x db gen`
|
|
5
|
-
// runs with no database open; `SchemaDescription` has no field for a view; `
|
|
5
|
+
// runs with no database open; `SchemaDescription` has no field for a view; `introspectSchema()` reads
|
|
6
6
|
// none by construction (`app-relation.ts` excludes every non-table relation); and no `entity()` can
|
|
7
7
|
// declare one. So nothing the generator reads knows a view exists, and a `GenerateOptions.views`
|
|
8
8
|
// with no caller to fill it is the declared-and-never-wired defect this release exists to
|
|
@@ -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/destructive.ts
CHANGED
|
@@ -112,4 +112,4 @@ export function destructiveStatements(up: string): readonly DestructiveStatement
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/** Whether `up` destroys data at all — what `x db gen` writes the marker from. */
|
|
115
|
-
export const
|
|
115
|
+
export const isDestructiveMigration = (up: string): boolean => destructiveStatements(up).length > 0;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Single responsibility: whether an `appendOnly` table still carries the trigger that refuses UPDATE
|
|
2
|
+
// and DELETE, and what to say when it does not. The repository refuses above the driver whatever
|
|
3
|
+
// the database holds, so a dropped trigger is invisible to every test — only the catalog can say.
|
|
4
|
+
|
|
5
|
+
import { byHand, type DriftDifference, pathTo, repair } from './drift-findings';
|
|
6
|
+
import {
|
|
7
|
+
APPEND_ONLY_FUNCTION_SQL,
|
|
8
|
+
APPEND_ONLY_TRIGGER,
|
|
9
|
+
installAppendOnlyTriggerSql,
|
|
10
|
+
} from './generate-append-only';
|
|
11
|
+
import type { TableDescription } from './introspect';
|
|
12
|
+
import { shellInertIdentifier } from './sql';
|
|
13
|
+
|
|
14
|
+
/** The code `driftError` raises for this kind, exported so the tests assert the one literal. */
|
|
15
|
+
export const APPEND_ONLY_DRIFT_CODE = 'X_APPEND_ONLY_TRIGGER_MISSING';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The constructor. The fix is the two statements `x db gen` wrote, run against THIS database:
|
|
19
|
+
* the migration that added the trigger is already in the ledger, so `x db migrate` applies nothing
|
|
20
|
+
* (`repair`'s argument). Drop-if-exists before create, because a DISABLED trigger counts as missing
|
|
21
|
+
* here yet still exists, and a bare `create trigger` would fail on it. The function is redefined too — `create or replace` — because a hand that
|
|
22
|
+
* dropped the trigger may have dropped the function with it.
|
|
23
|
+
*/
|
|
24
|
+
export function missingAppendOnlyTrigger(schema: string, table: string): DriftDifference {
|
|
25
|
+
const path = pathTo(schema);
|
|
26
|
+
const spellable = shellInertIdentifier(table) !== null;
|
|
27
|
+
return {
|
|
28
|
+
kind: 'missing-append-only-trigger',
|
|
29
|
+
table,
|
|
30
|
+
column: null,
|
|
31
|
+
cause:
|
|
32
|
+
`table "${table}" is declared appendOnly, and its trigger ${APPEND_ONLY_TRIGGER} is missing ` +
|
|
33
|
+
'or disabled — raw SQL can UPDATE and DELETE its rows; only the repository still refuses',
|
|
34
|
+
fix:
|
|
35
|
+
path === null || !spellable
|
|
36
|
+
? byHand('re-create the append-only function and trigger this difference names')
|
|
37
|
+
: repair(path, [APPEND_ONLY_FUNCTION_SQL, ...installAppendOnlyTriggerSql(table)].join(' ')),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Judged only where both halves were asked: the snapshot declares the table append-only AND the
|
|
43
|
+
* catalog was read (`triggerNames` present). Either absent says nothing — a sidecar that predates
|
|
44
|
+
* the field, or a description nobody introspected — exactly as `compareChecks` reads its pair.
|
|
45
|
+
*/
|
|
46
|
+
export function compareAppendOnly(
|
|
47
|
+
live: TableDescription,
|
|
48
|
+
expected: TableDescription,
|
|
49
|
+
): DriftDifference[] {
|
|
50
|
+
if (expected.appendOnly !== true || live.triggerNames === undefined) return [];
|
|
51
|
+
if (live.triggerNames.includes(APPEND_ONLY_TRIGGER)) return [];
|
|
52
|
+
return [missingAppendOnlyTrigger(live.schema, live.name)];
|
|
53
|
+
}
|
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
|