@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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Single responsibility: the trigger an `entity({ appendOnly: true })` table carries, emitted once.
|
|
2
|
+
// The repository refuses update and delete above the driver; this is the same guarantee held by the
|
|
3
|
+
// DATABASE, so raw SQL, a second app on the same schema and a hand-written driver are refused too.
|
|
4
|
+
// Recorded on the snapshot (`appendOnly: true`), which is what stops it being re-emitted.
|
|
5
|
+
|
|
6
|
+
import { renderFixLiteral } from '@ultimat3/core';
|
|
7
|
+
import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
|
|
8
|
+
import { DbError } from './errors';
|
|
9
|
+
import type { Plan } from './foreign-key-plan';
|
|
10
|
+
import { findTable, type SchemaDescription } from './introspect';
|
|
11
|
+
import { identifier } from './sql';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The trigger's name — the SAME on every table, because a trigger name is unique per table, never
|
|
15
|
+
* per schema. A per-table name (`<table>_append_only`) would pass 63 bytes on a long table name,
|
|
16
|
+
* Postgres would truncate it in silence, and drift would compare the untruncated name forever.
|
|
17
|
+
*/
|
|
18
|
+
export const APPEND_ONLY_TRIGGER = 'ultimate_append_only';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One function for every append-only table: `tg_table_name` says which table refused. Not `x_`
|
|
22
|
+
* prefixed — that namespace is framework bookkeeping created at boot (`FRAMEWORK_TABLE_PREFIX`), and
|
|
23
|
+
* this function is created by the app's own migrations, so the schema dump files it with them.
|
|
24
|
+
*/
|
|
25
|
+
export const APPEND_ONLY_FUNCTION = 'ultimate_refuse_append_only';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `create or replace`, so every migration that adds a trigger may define it again: no migration has
|
|
29
|
+
* to know whether an earlier one already did, and a database where it was dropped by hand gets it
|
|
30
|
+
* back on the next one. One line, because the drift repair (`drift-append-only.ts`) is the same
|
|
31
|
+
* text as one `psql -c` word.
|
|
32
|
+
*
|
|
33
|
+
* The message LEADS with the code an app searches for, and the SQLSTATE is `23001`
|
|
34
|
+
* (`restrict_violation`): class 23 is "an integrity constraint refused this", which is what an
|
|
35
|
+
* append-only table is — so a caller that already treats class 23 as terminal, never retried,
|
|
36
|
+
* treats this the same. These bytes are in migrations on disk; changing them changes nothing that
|
|
37
|
+
* already shipped.
|
|
38
|
+
*/
|
|
39
|
+
export const APPEND_ONLY_FUNCTION_SQL =
|
|
40
|
+
`create or replace function ${identifier(APPEND_ONLY_FUNCTION).text}() returns trigger ` +
|
|
41
|
+
'language plpgsql as $append_only$ begin raise exception ' +
|
|
42
|
+
"'X_ENTITY_APPEND_ONLY: % on %.% is refused, the table is append-only', " +
|
|
43
|
+
"tg_op, tg_table_schema, tg_table_name using errcode = '23001', hint = 'insert a new row " +
|
|
44
|
+
"instead; to allow rewrites, remove appendOnly from the entity and run x db gen'; end; " +
|
|
45
|
+
'$append_only$;';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `before update or delete … for each row`: a row-level BEFORE trigger raises before the row moves,
|
|
49
|
+
* and it covers `insert … on conflict do update` too — the conflict arm IS an update. `truncate` is
|
|
50
|
+
* deliberately not refused: it is no row-level write, `destructive.ts` already gates it in a
|
|
51
|
+
* migration, and the testing package's reusable database empties every table with it.
|
|
52
|
+
*/
|
|
53
|
+
export const appendOnlyTriggerSql = (table: string): string =>
|
|
54
|
+
`create trigger ${identifier(APPEND_ONLY_TRIGGER).text} before update or delete on ` +
|
|
55
|
+
`${identifier(table).text} for each row execute function ` +
|
|
56
|
+
`${identifier(APPEND_ONLY_FUNCTION).text}();`;
|
|
57
|
+
|
|
58
|
+
/** `if exists`: a revert must not fail on a database where the trigger was already dropped by hand. */
|
|
59
|
+
export const dropAppendOnlyTriggerSql = (table: string): string =>
|
|
60
|
+
`drop trigger if exists ${identifier(APPEND_ONLY_TRIGGER).text} on ${identifier(table).text};`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Installing it on a table that may already hold one: drop-if-exists, then create. A DISABLED
|
|
64
|
+
* trigger of this name is drift (`drift-append-only.ts`) yet still exists, so a bare `create
|
|
65
|
+
* trigger` would fail on it (`42710`) and leave the table unprotected. Idempotent for both a
|
|
66
|
+
* missing and a disabled trigger — the drift repair and an existing table's migration both use it.
|
|
67
|
+
*/
|
|
68
|
+
export const installAppendOnlyTriggerSql = (table: string): readonly string[] => [
|
|
69
|
+
dropAppendOnlyTriggerSql(table),
|
|
70
|
+
appendOnlyTriggerSql(table),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** Whether the recorded schema says this table carries the trigger. Absent is "not recorded". */
|
|
74
|
+
const recorded = (current: SchemaDescription, table: string): boolean =>
|
|
75
|
+
findTable(current, table)?.appendOnly === true;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Both directions. Declared and not recorded: define the function (once per migration) and add the
|
|
79
|
+
* trigger; its `down` drops the trigger, except on a table this migration creates, whose `down` is
|
|
80
|
+
* already `drop table`. Recorded and no longer declared: drop it, and `down` puts it back.
|
|
81
|
+
*
|
|
82
|
+
* A table dropped outright needs nothing — the trigger goes with it, and the snapshot stops naming
|
|
83
|
+
* the table. The function is never dropped: other tables may still call it, and a function left with
|
|
84
|
+
* no trigger refuses nothing.
|
|
85
|
+
*/
|
|
86
|
+
export function appendOnlyPlan(
|
|
87
|
+
plan: Plan,
|
|
88
|
+
entities: readonly EntityDescriptionLike[],
|
|
89
|
+
current: SchemaDescription,
|
|
90
|
+
created: ReadonlySet<string>,
|
|
91
|
+
): void {
|
|
92
|
+
const changed = entities.filter(
|
|
93
|
+
(entity) => (entity.appendOnly === true) !== recorded(current, entity.table),
|
|
94
|
+
);
|
|
95
|
+
const added = changed.filter((entity) => entity.appendOnly === true).map((e) => e.table);
|
|
96
|
+
const removed = changed.filter((entity) => entity.appendOnly !== true).map((e) => e.table);
|
|
97
|
+
if (added.length > 0) plan.up.push(APPEND_ONLY_FUNCTION_SQL);
|
|
98
|
+
for (const table of added) {
|
|
99
|
+
// A table this migration creates holds no trigger yet, and its `down` is `drop table`.
|
|
100
|
+
if (created.has(table)) {
|
|
101
|
+
plan.up.push(appendOnlyTriggerSql(table));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
plan.up.push(...installAppendOnlyTriggerSql(table));
|
|
105
|
+
plan.down.push(dropAppendOnlyTriggerSql(table));
|
|
106
|
+
}
|
|
107
|
+
for (const table of removed) {
|
|
108
|
+
plan.up.push(dropAppendOnlyTriggerSql(table));
|
|
109
|
+
// Reversed whole: pushed create-then-drop so `down` runs drop-if-exists, then create.
|
|
110
|
+
plan.down.push(...[...installAppendOnlyTriggerSql(table)].reverse());
|
|
111
|
+
}
|
|
112
|
+
// `down` is reversed whole, so the function pushed LAST here runs FIRST there — before the
|
|
113
|
+
// triggers that call it are re-created.
|
|
114
|
+
if (removed.length > 0) plan.down.push(APPEND_ONLY_FUNCTION_SQL);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Which arm met the column: a new one, or an existing one turned NOT NULL. */
|
|
118
|
+
export type AppendOnlyBackfillArm = 'added' | 'made-not-null';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A column an append-only table must hold NOT NULL with no default to fill it. Both arms of the
|
|
122
|
+
* diff would emit `-- backfill …, then: set not null` — `diffTable` for a new column,
|
|
123
|
+
* `alterColumnInPlace` for an existing one — and on this table that backfill is an UPDATE the
|
|
124
|
+
* trigger refuses: an instruction nobody can carry out, leaving the column nullable forever.
|
|
125
|
+
* Refused at generation instead; a default is the one way every row gets a value with no UPDATE.
|
|
126
|
+
*/
|
|
127
|
+
export const appendOnlyBackfillRefused = (
|
|
128
|
+
entity: EntityDescriptionLike,
|
|
129
|
+
column: ColumnDescriptionLike,
|
|
130
|
+
arm: AppendOnlyBackfillArm,
|
|
131
|
+
): DbError =>
|
|
132
|
+
new DbError({
|
|
133
|
+
code: 'X_MIGRATION_APPEND_ONLY_BACKFILL',
|
|
134
|
+
cause:
|
|
135
|
+
`${identifier(entity.table).text} is append-only and ` +
|
|
136
|
+
`${arm === 'added' ? 'gains NOT NULL column' : 'turns NOT NULL its column'} ` +
|
|
137
|
+
`${identifier(column.column).text} with no default: existing rows could only be given a ` +
|
|
138
|
+
'value by an UPDATE, which the append-only trigger refuses, so x db gen cannot write a ' +
|
|
139
|
+
'migration that ends with the column NOT NULL',
|
|
140
|
+
// A default fills a column as it is ADDED; it fills no NULL an existing column already holds.
|
|
141
|
+
fix:
|
|
142
|
+
arm === 'added'
|
|
143
|
+
? `entity(${renderFixLiteral(entity.name, "'<name>'")}, { columns: { ${renderFixLiteral(column.property, "'<col>'")}: <builder>.default(<value>) } }) # then re-run x db gen`
|
|
144
|
+
: `entity(${renderFixLiteral(entity.name, "'<name>'")}, { columns: { ${renderFixLiteral(column.property, "'<col>'")}: <builder>.nullable() } }) # keep it nullable, or add a NEW column with .default(<value>); then re-run x db gen`,
|
|
145
|
+
meta: { table: entity.table, column: column.column, arm },
|
|
146
|
+
});
|
package/src/generate.ts
CHANGED
|
@@ -7,10 +7,11 @@ import { systemClock } from '@ultimat3/core';
|
|
|
7
7
|
import { checkClauses, checkPlan, declaredChecks } from './check-ddl';
|
|
8
8
|
import { alterColumnInPlace } from './column-alter';
|
|
9
9
|
import { defaultExpression } from './column-default';
|
|
10
|
-
import {
|
|
10
|
+
import { isDestructiveMigration } from './destructive';
|
|
11
11
|
import { dropOrder } from './drop-order';
|
|
12
12
|
import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
|
|
13
13
|
import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
|
|
14
|
+
import { appendOnlyBackfillRefused, appendOnlyPlan } from './generate-append-only';
|
|
14
15
|
import type { Regeneration } from './generated-column';
|
|
15
16
|
import { generatedClause, isGenerated, regenerate } from './generated-column';
|
|
16
17
|
import { createIndex, impliedByColumnClause } from './index-ddl';
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
} from './introspect';
|
|
24
25
|
import { declaredIndexes } from './invariant-ddl';
|
|
25
26
|
import { migrationIrreversible } from './migration-errors';
|
|
27
|
+
import { addChangedKey, dropChangedKey, migrationNameArg } from './primary-key';
|
|
26
28
|
import type { ReplicaIdentityInput } from './replica-identity';
|
|
27
29
|
import { replicaIdentityFullAfter, replicaIdentityPlan } from './replica-identity';
|
|
28
30
|
import type { MovedAside } from './retype-dependents';
|
|
@@ -111,6 +113,8 @@ export function snapshotOf(
|
|
|
111
113
|
// The same rule once more: `true` or absent, never `false`. This is what makes the ALTER
|
|
112
114
|
// beside it a one-time statement rather than a line every `x db gen` writes again.
|
|
113
115
|
...(replicaIdentityFull.has(entity.table) ? { replicaIdentityFull: true as const } : {}),
|
|
116
|
+
// `true` or absent once more — the record that makes the trigger a one-time statement.
|
|
117
|
+
...(entity.appendOnly === true ? { appendOnly: true as const } : {}),
|
|
114
118
|
};
|
|
115
119
|
});
|
|
116
120
|
return { tables };
|
|
@@ -198,7 +202,7 @@ function diffTable(
|
|
|
198
202
|
} else {
|
|
199
203
|
// After any retype, so a new default is set against the column's new type. A rebuilt
|
|
200
204
|
// column was re-added carrying its whole clause, so it has nothing left to move.
|
|
201
|
-
alterColumnInPlace(entity
|
|
205
|
+
alterColumnInPlace(entity, column, recorded, plan);
|
|
202
206
|
}
|
|
203
207
|
continue;
|
|
204
208
|
}
|
|
@@ -211,6 +215,9 @@ function diffTable(
|
|
|
211
215
|
// one statement — measured. Emitting it nullable would leave a `-- backfill` comment naming a
|
|
212
216
|
// step nobody can perform, since a generated column cannot be written to.
|
|
213
217
|
const nullable = column.notNull && !isGenerated(column) && defaultExpression(column) === null;
|
|
218
|
+
// That follow-up is an UPDATE, and an append-only table's trigger refuses every one.
|
|
219
|
+
if (nullable && entity.appendOnly === true)
|
|
220
|
+
throw appendOnlyBackfillRefused(entity, column, 'added');
|
|
214
221
|
const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
|
|
215
222
|
plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
|
|
216
223
|
if (nullable) {
|
|
@@ -324,6 +331,9 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
324
331
|
created.add(entity.table);
|
|
325
332
|
continue;
|
|
326
333
|
}
|
|
334
|
+
// The key first and last, around every column statement of the table (`primary-key.ts`): the
|
|
335
|
+
// snapshot below records `entity.primaryKey`, and until this arm existed nothing produced it.
|
|
336
|
+
dropChangedKey(entity, live, current, plan, options.name);
|
|
327
337
|
diffTable(entity, live, plan, retypedIn(retyped, entity.table));
|
|
328
338
|
const kept = new Set(entity.columns.map((column) => column.column));
|
|
329
339
|
for (const column of live.columns) {
|
|
@@ -331,7 +341,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
331
341
|
if (options.allowDestructive !== true) {
|
|
332
342
|
throw migrationIrreversible(
|
|
333
343
|
`dropping "${entity.table}"."${column.name}" discards its rows and cannot be undone`,
|
|
334
|
-
`x db gen
|
|
344
|
+
`x db gen ${migrationNameArg(options.name)} --allow-destructive # or keep the column and deprecate it`,
|
|
335
345
|
);
|
|
336
346
|
}
|
|
337
347
|
plan.up.push(
|
|
@@ -343,6 +353,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
343
353
|
' -- data is not restored',
|
|
344
354
|
);
|
|
345
355
|
}
|
|
356
|
+
addChangedKey(entity, live, plan, options.name);
|
|
346
357
|
}
|
|
347
358
|
|
|
348
359
|
const order = dropOrder(current.tables.filter((table) => !wanted.has(table.name)));
|
|
@@ -350,7 +361,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
350
361
|
if (options.allowDestructive !== true) {
|
|
351
362
|
throw migrationIrreversible(
|
|
352
363
|
`dropping table "${table.name}" discards every row and cannot be undone`,
|
|
353
|
-
`x db gen
|
|
364
|
+
`x db gen ${migrationNameArg(options.name)} --allow-destructive # or delete the entity in two releases`,
|
|
354
365
|
);
|
|
355
366
|
}
|
|
356
367
|
}
|
|
@@ -368,6 +379,9 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
368
379
|
plan.up.push(...constraints.up);
|
|
369
380
|
plan.down.push(...constraints.down);
|
|
370
381
|
|
|
382
|
+
// After every table exists, so a `create table` in this same migration is already above it.
|
|
383
|
+
appendOnlyPlan(plan, options.entities, current, created);
|
|
384
|
+
|
|
371
385
|
// Dead last in `up`, and it is the only placement that is right for every arm: the table has to
|
|
372
386
|
// exist, and a `create table` in this same migration is the reason it might not. It is ordered
|
|
373
387
|
// against nothing else — replica identity constrains no column, no index and no constraint — so
|
|
@@ -383,7 +397,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
383
397
|
const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
|
|
384
398
|
// At the TOP of `up`, so what is MISSING is the first thing read — and a line comment, so it is
|
|
385
399
|
// noise to every reader that matters: `statementsOf` drops a chunk of comments alone,
|
|
386
|
-
// `stripSqlNoise` blanks it before `
|
|
400
|
+
// `stripSqlNoise` blanks it before `isDestructiveMigration` looks for a verb, and the server ignores it.
|
|
387
401
|
//
|
|
388
402
|
// Never onto an EMPTY diff. `@ultimat3/cli`'s `generateAppMigration` reads `up.trim().length` as
|
|
389
403
|
// "nothing changed" and re-records the hash sidecar instead of writing a file; a comment there
|
|
@@ -406,7 +420,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
406
420
|
// ends have been retyped back, which is every other statement in the script.
|
|
407
421
|
down: [...preAlters.down, ...plan.down].reverse().join('\n'),
|
|
408
422
|
snapshot: snapshotOf(options.entities, replicaIdentityFullAfter(replicaIdentity)),
|
|
409
|
-
destructive:
|
|
423
|
+
destructive: isDestructiveMigration(up),
|
|
410
424
|
unrendered,
|
|
411
425
|
};
|
|
412
426
|
}
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type {
|
|
|
27
27
|
PostgresClientOptions,
|
|
28
28
|
ReservableClient,
|
|
29
29
|
} from './client';
|
|
30
|
-
export { baseClient,
|
|
30
|
+
export { baseClient, db, isReservable, postgresClient, setDbClient } from './client';
|
|
31
31
|
export type { ColumnDefaultLike } from './column-default';
|
|
32
32
|
export { defaultExpression } from './column-default';
|
|
33
33
|
export type { DbHealthReport } from './db-health';
|
|
@@ -44,12 +44,12 @@ export {
|
|
|
44
44
|
DESTRUCTIVE_MARKER,
|
|
45
45
|
destructiveStatements,
|
|
46
46
|
hasDestructiveMarker,
|
|
47
|
-
|
|
47
|
+
isDestructiveMigration,
|
|
48
48
|
} from './destructive';
|
|
49
49
|
export type { DriftDifference, DriftKind, DriftOptions, DriftReport } from './drift';
|
|
50
50
|
export {
|
|
51
51
|
appTables,
|
|
52
|
-
|
|
52
|
+
assertNoSchemaDrift,
|
|
53
53
|
checkDrift,
|
|
54
54
|
declaredSchema,
|
|
55
55
|
diffSchema,
|
|
@@ -72,7 +72,6 @@ export {
|
|
|
72
72
|
DB_ERROR_RETRY,
|
|
73
73
|
DB_ERROR_TITLES,
|
|
74
74
|
DbError,
|
|
75
|
-
dbNotImplemented,
|
|
76
75
|
dbUnavailable,
|
|
77
76
|
driverError,
|
|
78
77
|
identifierUnsafe,
|
|
@@ -84,7 +83,7 @@ export {
|
|
|
84
83
|
} from './errors';
|
|
85
84
|
export { expectedQueryLoop, expectedQueryLoopReason } from './expected-loop';
|
|
86
85
|
export type { RecordedStatement, RecordingClient, StubResponse } from './fake';
|
|
87
|
-
export {
|
|
86
|
+
export { recordingClient } from './fake';
|
|
88
87
|
export type { GeneratedMigration, GenerateOptions } from './generate';
|
|
89
88
|
export { generateMigration, migrationStamp, slugify, snapshotOf } from './generate';
|
|
90
89
|
export type { IndexMethod } from './index-method';
|
|
@@ -104,7 +103,7 @@ export type {
|
|
|
104
103
|
SchemaDescription,
|
|
105
104
|
TableDescription,
|
|
106
105
|
} from './introspect';
|
|
107
|
-
export { buildSchema, findTable,
|
|
106
|
+
export { buildSchema, findTable, introspectSchema } from './introspect';
|
|
108
107
|
export {
|
|
109
108
|
constraintNameFor,
|
|
110
109
|
declaredIndexes,
|
|
@@ -158,12 +157,12 @@ export type {
|
|
|
158
157
|
PgliteResult,
|
|
159
158
|
} from './pglite';
|
|
160
159
|
export {
|
|
161
|
-
createPgliteClient,
|
|
162
160
|
loadPgliteDriver,
|
|
163
161
|
PGLITE_FIX,
|
|
164
162
|
PGLITE_MEMORY,
|
|
165
163
|
PGLITE_MISSING,
|
|
166
164
|
PGLITE_PACKAGE,
|
|
165
|
+
pgliteClient,
|
|
167
166
|
pgliteDataDir,
|
|
168
167
|
} from './pglite';
|
|
169
168
|
export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
|
|
@@ -185,6 +184,7 @@ export { BREAKER_COOLDOWN_MS, BREAKER_FAILURES, replicatedClient } from './repli
|
|
|
185
184
|
export { type DbNode, isPlainRead } from './replica-route';
|
|
186
185
|
export type { ReplicaScope } from './replica-scope';
|
|
187
186
|
export { markScopeWrote, replicaScope, withReplicaReads } from './replica-scope';
|
|
187
|
+
export { SIBLING_SCOPE_WAIT_MS } from './sibling-turn';
|
|
188
188
|
export { snapshotJson } from './snapshot-json';
|
|
189
189
|
export { parseSnapshot } from './snapshot-parse';
|
|
190
190
|
export type { SqlFragment } from './sql';
|
|
@@ -203,8 +203,10 @@ export { DB_SQLSTATE_CODES, isRetryableState, SQLSTATE, sqlState, sqlStateCode }
|
|
|
203
203
|
export { statementFingerprint, statementKind, statementVerb } from './statement-shape';
|
|
204
204
|
export { STATEMENT_ATTRIBUTE } from './statement-span';
|
|
205
205
|
export { statementsOf } from './statement-split';
|
|
206
|
-
export
|
|
207
|
-
export {
|
|
206
|
+
export { currentTx, liveTxConnection, withTransaction } from './transaction';
|
|
207
|
+
export { commitUnknown, siblingScopeTimeout, transactionAborted } from './transaction-errors';
|
|
208
|
+
export type { DbTx, IsolationLevel, TransactionOptions } from './transaction-options';
|
|
209
|
+
export { beginStatement } from './transaction-options';
|
|
208
210
|
export type { GeneratableForm } from './ungeneratable';
|
|
209
211
|
export { GENERATABLE_FORMS, ungeneratableStatements } from './ungeneratable';
|
|
210
212
|
export type { UnrenderedDeclaration } from './unrendered';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Single responsibility: read the WHOLE schema out of `pg_catalog` — tables and everything that is
|
|
2
|
-
// not a table — into one `CatalogDescription`, sorted in JS. `
|
|
2
|
+
// not a table — into one `CatalogDescription`, sorted in JS. `introspectSchema()` beside it stays the
|
|
3
3
|
// entity-vocabulary reading drift compares to a snapshot; this is the catalog's own spelling, the
|
|
4
4
|
// input of the schema dump, and comparable only to another reading of itself.
|
|
5
5
|
|
|
@@ -79,6 +79,12 @@ export async function introspectCatalog(
|
|
|
79
79
|
const unrendered = await unrenderedRows(client, schema);
|
|
80
80
|
const known = new Set(tables.map((table) => table.name));
|
|
81
81
|
const materialized = new Set(views.filter((view) => view.kind === 'm').map((view) => view.name));
|
|
82
|
+
// A trigger loads only onto a relation the dump creates: a plain table, or a view (`instead
|
|
83
|
+
// of`). One on a partitioned table or a partition was filed under `09_triggers/` for a table no
|
|
84
|
+
// file creates, and the load refused the whole dump with `X_SCHEMA_DUMP_DRIFT`.
|
|
85
|
+
const viewNames = new Set(views.map((view) => view.name));
|
|
86
|
+
const loadable = (trigger: { readonly table_name: string }): boolean =>
|
|
87
|
+
known.has(trigger.table_name) || viewNames.has(trigger.table_name);
|
|
82
88
|
|
|
83
89
|
return {
|
|
84
90
|
schema,
|
|
@@ -129,6 +135,7 @@ export async function introspectCatalog(
|
|
|
129
135
|
),
|
|
130
136
|
),
|
|
131
137
|
triggers: triggers
|
|
138
|
+
.filter(loadable)
|
|
132
139
|
.map((row) => ({
|
|
133
140
|
table: row.table_name,
|
|
134
141
|
name: row.name,
|
|
@@ -141,7 +148,17 @@ export async function introspectCatalog(
|
|
|
141
148
|
(trigger) => trigger.name,
|
|
142
149
|
),
|
|
143
150
|
),
|
|
144
|
-
unrendered:
|
|
151
|
+
unrendered: [
|
|
152
|
+
...unrendered,
|
|
153
|
+
// Named rather than dropped, the rule `CatalogUnrendered` states.
|
|
154
|
+
...triggers
|
|
155
|
+
.filter((trigger) => !loadable(trigger))
|
|
156
|
+
.map((trigger) => ({
|
|
157
|
+
kind: 'trigger',
|
|
158
|
+
name: trigger.name,
|
|
159
|
+
table_name: trigger.table_name,
|
|
160
|
+
})),
|
|
161
|
+
]
|
|
145
162
|
.map((row) => ({ kind: row.kind, name: row.name, table: row.table_name }))
|
|
146
163
|
.sort(
|
|
147
164
|
by(
|
package/src/introspect.ts
CHANGED
|
@@ -27,7 +27,11 @@ export interface ColumnDescription {
|
|
|
27
27
|
|
|
28
28
|
export interface IndexDescription {
|
|
29
29
|
readonly name: string;
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Key columns in **index key order** — the order the planner sorts by, never `attnum`. An
|
|
32
|
+
* EXPRESSION key read from the catalog is its definition in parentheses (`(lower(title))`), in
|
|
33
|
+
* its own position: marked, so it can never be taken for a column, and never dropped.
|
|
34
|
+
*/
|
|
31
35
|
readonly columns: readonly string[];
|
|
32
36
|
readonly unique: boolean;
|
|
33
37
|
readonly primary: boolean;
|
|
@@ -100,7 +104,7 @@ export interface TableDescription {
|
|
|
100
104
|
* handed a catalog value by accident.
|
|
101
105
|
*
|
|
102
106
|
* `snapshotOf` never writes it and `parseSnapshot` never reads it, so a sidecar carries `checks`
|
|
103
|
-
* alone. `
|
|
107
|
+
* alone. `introspectSchema()` always answers with it, `[]` included: absent therefore means "nobody
|
|
104
108
|
* asked the catalog", which is what keeps `compareChecks` silent on a description that never
|
|
105
109
|
* read one instead of reporting every declared constraint as missing.
|
|
106
110
|
*/
|
|
@@ -115,12 +119,25 @@ export interface TableDescription {
|
|
|
115
119
|
* table an app has would rewrite every sidecar in the tree on the next `x db gen` for a fact that
|
|
116
120
|
* was already true — the argument `IndexDescription.using` makes about `btree`.
|
|
117
121
|
*
|
|
118
|
-
* `
|
|
122
|
+
* `introspectSchema()` never answers it. The catalog's half is `pg_class.relreplident`, which
|
|
119
123
|
* `@ultimat3/realtime`'s preflight already reads at the only moment it matters; a second reader
|
|
120
124
|
* here would let a `diffSchema` compare a declaration against a catalog value, which is the
|
|
121
125
|
* mistake `checks` and `checkNames` exist as two fields to prevent.
|
|
122
126
|
*/
|
|
123
127
|
readonly replicaIdentityFull?: true | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* That a migration gave this table the append-only trigger (`generate-append-only.ts`). The
|
|
130
|
+
* SNAPSHOT's half: `true` or absent, never `false`, for the reason `replicaIdentityFull` gives.
|
|
131
|
+
* `introspectSchema()` never writes it — the catalog's half is `triggerNames`.
|
|
132
|
+
*/
|
|
133
|
+
readonly appendOnly?: true | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* The CATALOG's half of `appendOnly`: the names of the ENABLED, non-internal triggers on the
|
|
136
|
+
* table (`tgenabled` `O` or `A` — a disabled or replica-only trigger refuses nothing in an
|
|
137
|
+
* ordinary session, so it is not held). A separate field for the reason `checkNames` is one.
|
|
138
|
+
* Written by `introspectSchema()` always, `[]` included; absent means nobody asked the catalog.
|
|
139
|
+
*/
|
|
140
|
+
readonly triggerNames?: readonly string[] | undefined;
|
|
124
141
|
}
|
|
125
142
|
|
|
126
143
|
export interface SchemaDescription {
|
|
@@ -175,9 +192,17 @@ interface CheckRow {
|
|
|
175
192
|
readonly constraint_name: string;
|
|
176
193
|
}
|
|
177
194
|
|
|
195
|
+
/** An enabled trigger's NAME — the half of `appendOnly` the catalog can answer. */
|
|
196
|
+
interface TriggerNameRow {
|
|
197
|
+
readonly table_name: string;
|
|
198
|
+
readonly trigger_name: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
178
201
|
const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : 1);
|
|
179
202
|
|
|
180
|
-
export async function
|
|
203
|
+
export async function introspectSchema(
|
|
204
|
+
options: IntrospectOptions = {},
|
|
205
|
+
): Promise<SchemaDescription> {
|
|
181
206
|
const client = options.client ?? db();
|
|
182
207
|
const schema = options.schema ?? 'public';
|
|
183
208
|
// Asked first, and unconditionally: everything below reads `information_schema`, which admits a
|
|
@@ -188,17 +213,45 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
188
213
|
...(await nonAppRelations(client, schema)),
|
|
189
214
|
];
|
|
190
215
|
|
|
216
|
+
// The type is `format_type`, as `catalog-relations.ts` reads it: `information_schema.data_type`
|
|
217
|
+
// answers `numeric` for `numeric(12,2)`, `ARRAY` for `text[]` and `USER-DEFINED` for an enum, and
|
|
218
|
+
// `ColumnDescription.dataType` has always been documented as the first of each pair. Still FROM
|
|
219
|
+
// the view, which decides which columns this role may see.
|
|
191
220
|
const columns = await client.query<ColumnRow>(sql`
|
|
192
|
-
select
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
221
|
+
select
|
|
222
|
+
c.table_name,
|
|
223
|
+
c.column_name,
|
|
224
|
+
coalesce(
|
|
225
|
+
(
|
|
226
|
+
select format_type(a.atttypid, a.atttypmod)
|
|
227
|
+
from pg_attribute a
|
|
228
|
+
join pg_class r on r.oid = a.attrelid
|
|
229
|
+
join pg_namespace n on n.oid = r.relnamespace
|
|
230
|
+
where n.nspname = c.table_schema
|
|
231
|
+
and r.relname = c.table_name
|
|
232
|
+
and a.attname = c.column_name
|
|
233
|
+
and a.attnum > 0
|
|
234
|
+
and not a.attisdropped
|
|
235
|
+
),
|
|
236
|
+
c.data_type
|
|
237
|
+
) as data_type,
|
|
238
|
+
c.is_nullable,
|
|
239
|
+
c.column_default,
|
|
240
|
+
c.ordinal_position
|
|
241
|
+
from information_schema.columns c
|
|
242
|
+
where c.table_schema = ${schema}
|
|
243
|
+
order by c.table_name, c.ordinal_position
|
|
196
244
|
`);
|
|
197
245
|
|
|
198
246
|
// Ordered by the index's own key position, never by `attnum`: `indkey` IS the order the planner
|
|
199
247
|
// sorts by, and a composite index on `(created_at, org_id)` whose columns were declared the
|
|
200
248
|
// other way round came back reversed — a description that reads correct and compares wrong.
|
|
201
249
|
// `indnkeyatts` drops INCLUDE payload columns, which are stored, not keyed.
|
|
250
|
+
//
|
|
251
|
+
// A LEFT join on `pg_attribute`: an expression key has `attnum = 0` and no attribute row, so an
|
|
252
|
+
// inner join dropped it and `(id, lower(title))` read back as `(id)` — an index rebuilt by hand
|
|
253
|
+
// with an extra expression key compared equal to the declared one. It is kept in its position
|
|
254
|
+
// and MARKED by its parentheses, which no declared column name carries.
|
|
202
255
|
const indexes = await client.query<IndexRow>(sql`
|
|
203
256
|
select
|
|
204
257
|
t.relname as table_name,
|
|
@@ -207,7 +260,10 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
207
260
|
ix.indisprimary as is_primary,
|
|
208
261
|
pg_get_expr(ix.indpred, ix.indrelid) as predicate,
|
|
209
262
|
am.amname as method,
|
|
210
|
-
array_agg(
|
|
263
|
+
array_agg(
|
|
264
|
+
coalesce(a.attname::text, '(' || pg_get_indexdef(ix.indexrelid, k.ord::int, false) || ')')
|
|
265
|
+
order by k.ord
|
|
266
|
+
) as columns,
|
|
211
267
|
bool_and((ix.indoption[k.ord - 1] & 1) = 1) as descending
|
|
212
268
|
from pg_class t
|
|
213
269
|
join pg_namespace n on n.oid = t.relnamespace
|
|
@@ -215,9 +271,11 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
215
271
|
join pg_class i on i.oid = ix.indexrelid
|
|
216
272
|
join pg_am am on am.oid = i.relam
|
|
217
273
|
cross join lateral unnest(ix.indkey::smallint[]) with ordinality as k(attnum, ord)
|
|
218
|
-
join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum
|
|
274
|
+
left join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum and k.attnum > 0
|
|
219
275
|
where n.nspname = ${schema} and t.relkind = 'r' and k.ord <= ix.indnkeyatts
|
|
220
|
-
group by
|
|
276
|
+
group by
|
|
277
|
+
t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid,
|
|
278
|
+
ix.indexrelid, am.amname
|
|
221
279
|
order by t.relname, i.relname
|
|
222
280
|
`);
|
|
223
281
|
|
|
@@ -260,7 +318,19 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
260
318
|
order by src.relname, c.conname
|
|
261
319
|
`);
|
|
262
320
|
|
|
263
|
-
|
|
321
|
+
// Names only, as `checks` above: an append-only table is judged by whether its trigger is THERE
|
|
322
|
+
// and firing, never by `pg_get_triggerdef`'s text. `tgisinternal` is a foreign key's own.
|
|
323
|
+
const triggers = await client.query<TriggerNameRow>(sql`
|
|
324
|
+
select src.relname as table_name, t.tgname as trigger_name
|
|
325
|
+
from pg_trigger t
|
|
326
|
+
join pg_class src on src.oid = t.tgrelid
|
|
327
|
+
join pg_namespace n on n.oid = src.relnamespace
|
|
328
|
+
where not t.tgisinternal and t.tgenabled in ('O', 'A')
|
|
329
|
+
and n.nspname = ${schema} and src.relkind = 'r'
|
|
330
|
+
order by src.relname, t.tgname
|
|
331
|
+
`);
|
|
332
|
+
|
|
333
|
+
return buildSchema(schema, excluded, columns, indexes, foreignKeys, checks, triggers);
|
|
264
334
|
}
|
|
265
335
|
|
|
266
336
|
/** Pure, so the row -> description mapping is testable without a database. */
|
|
@@ -271,6 +341,8 @@ export function buildSchema(
|
|
|
271
341
|
indexes: readonly IndexRow[],
|
|
272
342
|
foreignKeys: readonly ForeignKeyRow[],
|
|
273
343
|
checks: readonly CheckRow[] = [],
|
|
344
|
+
/** Absent leaves `triggerNames` off every table: nobody asked the catalog. */
|
|
345
|
+
triggers?: readonly TriggerNameRow[],
|
|
274
346
|
): SchemaDescription {
|
|
275
347
|
const names = [...new Set(columns.map((row) => row.table_name))]
|
|
276
348
|
.filter((name) => !excluded.includes(name))
|
|
@@ -322,6 +394,14 @@ export function buildSchema(
|
|
|
322
394
|
.filter((row) => row.table_name === name)
|
|
323
395
|
.map((row) => row.constraint_name)
|
|
324
396
|
.sort(),
|
|
397
|
+
...(triggers === undefined
|
|
398
|
+
? {}
|
|
399
|
+
: {
|
|
400
|
+
triggerNames: triggers
|
|
401
|
+
.filter((row) => row.table_name === name)
|
|
402
|
+
.map((row) => row.trigger_name)
|
|
403
|
+
.sort(),
|
|
404
|
+
}),
|
|
325
405
|
};
|
|
326
406
|
});
|
|
327
407
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Single responsibility: recognise a ledger an OLDER build is being rolled back onto — rows the
|
|
2
|
+
// newer build applied, every one of them newer than anything this build ships — so the migrate
|
|
3
|
+
// role lets a rollback through instead of failing the deploy that is trying to undo a bad one.
|
|
4
|
+
|
|
5
|
+
import type { LedgerRow, Migration } from './migration-ledger';
|
|
6
|
+
import { pendingMigrations } from './migration-ledger';
|
|
7
|
+
|
|
8
|
+
/** A rollback: the rows this build does not know (all newer), and the ledger it does. */
|
|
9
|
+
export interface LedgerAhead {
|
|
10
|
+
/** The rows a newer build applied, oldest first. Never empty. */
|
|
11
|
+
readonly ahead: readonly LedgerRow[];
|
|
12
|
+
/** Every other row — the part this build's own audit still runs over. */
|
|
13
|
+
readonly known: readonly LedgerRow[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The ledger split, when it is exactly a rollback, else `undefined` and the caller's audit refuses
|
|
18
|
+
* the unknown rows as it always has. Three conditions, all required:
|
|
19
|
+
*
|
|
20
|
+
* - every row this build does not ship sorts AFTER the newest migration it does ship — the ids are
|
|
21
|
+
* `<stamp>_<name>`, the order `pendingMigrations` applies them in. One unknown row between two
|
|
22
|
+
* shipped ones is a deleted or renamed migration, not a newer build's work;
|
|
23
|
+
* - this build has nothing left to apply. A pending migration beside newer rows means the database
|
|
24
|
+
* never held this build's schema: two branches, not a rollback;
|
|
25
|
+
* - the build ships at least one migration. An image that lost its migrations directory is not
|
|
26
|
+
* "older than every row", and reading it as one would wave through the image that is broken.
|
|
27
|
+
*
|
|
28
|
+
* Nothing is applied either way: the schema the newer build left is additive over this one when
|
|
29
|
+
* the release followed expand/contract (docs/ops/06-runbooks.md), and when it did not, no
|
|
30
|
+
* migration this build ships could put it back.
|
|
31
|
+
*/
|
|
32
|
+
export function ledgerAheadOfBuild(
|
|
33
|
+
ledger: readonly LedgerRow[],
|
|
34
|
+
migrations: readonly Migration[],
|
|
35
|
+
): LedgerAhead | undefined {
|
|
36
|
+
if (migrations.length === 0) return undefined;
|
|
37
|
+
const shipped = new Set(migrations.map((migration) => migration.id));
|
|
38
|
+
const newest = [...shipped].sort().at(-1) ?? '';
|
|
39
|
+
const unknown = ledger.filter((row) => !shipped.has(row.id));
|
|
40
|
+
if (unknown.length === 0 || unknown.some((row) => row.id <= newest)) return undefined;
|
|
41
|
+
const known = ledger.filter((row) => shipped.has(row.id));
|
|
42
|
+
if (pendingMigrations(known, migrations).length > 0) return undefined;
|
|
43
|
+
return { ahead: [...unknown].sort((a, b) => (a.id < b.id ? -1 : 1)), known };
|
|
44
|
+
}
|