@ultimat3/db 24.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 +28 -27
- package/README.md +46 -19
- package/package.json +3 -3
- package/src/client.ts +2 -2
- package/src/column-alter.ts +9 -3
- package/src/default-client.ts +4 -4
- package/src/dependent-view.ts +1 -1
- package/src/destructive.ts +1 -1
- package/src/drift-append-only.ts +53 -0
- package/src/drift-findings.ts +4 -2
- package/src/drift.ts +14 -6
- package/src/entity-shape.ts +5 -0
- package/src/errors.ts +6 -1
- package/src/fake.ts +1 -1
- package/src/generate-append-only.ts +146 -0
- package/src/generate.ts +16 -7
- package/src/index.ts +6 -6
- package/src/introspect-catalog.ts +1 -1
- package/src/introspect.ts +47 -4
- package/src/migrate-rollback.ts +44 -0
- package/src/migrate.ts +44 -154
- package/src/migration-ledger.ts +167 -0
- package/src/pglite-branch.ts +1 -1
- package/src/pglite.ts +3 -3
- package/src/pool-profile.ts +1 -1
- package/src/primary-key.ts +34 -4
- package/src/snapshot-parse.ts +9 -3
- package/src/drift-fixtures.ts +0 -23
- package/src/fake-pglite.ts +0 -32
- package/src/fake-reservable.ts +0 -50
package/src/errors.ts
CHANGED
|
@@ -41,6 +41,8 @@ export const DB_OWNED_ERROR_CODES = [
|
|
|
41
41
|
'X_DB_TRANSACTION_ABORTED',
|
|
42
42
|
'X_DB_COMMIT_UNKNOWN',
|
|
43
43
|
'X_DB_SIBLING_SCOPE_TIMEOUT',
|
|
44
|
+
'X_APPEND_ONLY_TRIGGER_MISSING',
|
|
45
|
+
'X_MIGRATION_APPEND_ONLY_BACKFILL',
|
|
44
46
|
] as const;
|
|
45
47
|
|
|
46
48
|
/**
|
|
@@ -89,6 +91,9 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
|
|
|
89
91
|
X_DB_TRANSACTION_ABORTED: 'the server rolled the transaction back',
|
|
90
92
|
X_DB_COMMIT_UNKNOWN: 'the connection was lost while COMMIT was in flight',
|
|
91
93
|
X_DB_SIBLING_SCOPE_TIMEOUT: 'a nested transaction scope waited too long for its sibling',
|
|
94
|
+
X_APPEND_ONLY_TRIGGER_MISSING: 'an append-only table has lost its refusing trigger',
|
|
95
|
+
X_MIGRATION_APPEND_ONLY_BACKFILL:
|
|
96
|
+
'a NOT NULL column added to an append-only table needs a default',
|
|
92
97
|
};
|
|
93
98
|
|
|
94
99
|
// Registered unconditionally, in one call, so a second package claiming one of db's codes fails
|
|
@@ -285,7 +290,7 @@ export const drainTimeout = (ms: number, role: string): DbError =>
|
|
|
285
290
|
new DbError({
|
|
286
291
|
code: 'X_DB_DRAIN_TIMEOUT',
|
|
287
292
|
cause: `the ${role} pool still held connections after ${String(ms)}ms, so close() stopped waiting`,
|
|
288
|
-
fix: `find the statement that will not finish — psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'" — or raise drainTimeoutMs in
|
|
293
|
+
fix: `find the statement that will not finish — psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'" — or raise drainTimeoutMs in postgresClient({ profile }) for the ${role} role`,
|
|
289
294
|
meta: { drainTimeoutMs: ms, role },
|
|
290
295
|
});
|
|
291
296
|
|
package/src/fake.ts
CHANGED
|
@@ -49,7 +49,7 @@ const DEFAULT_STUBS: readonly Stub[] = [
|
|
|
49
49
|
{ match: /pg_try_advisory_lock/, response: { rows: [{ locked: true }] } },
|
|
50
50
|
];
|
|
51
51
|
|
|
52
|
-
export function
|
|
52
|
+
export function recordingClient(): RecordingClient {
|
|
53
53
|
const statements: RecordedStatement[] = [];
|
|
54
54
|
const stubs: Stub[] = [...DEFAULT_STUBS];
|
|
55
55
|
|
|
@@ -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,7 +24,7 @@ import {
|
|
|
23
24
|
} from './introspect';
|
|
24
25
|
import { declaredIndexes } from './invariant-ddl';
|
|
25
26
|
import { migrationIrreversible } from './migration-errors';
|
|
26
|
-
import { addChangedKey, dropChangedKey } from './primary-key';
|
|
27
|
+
import { addChangedKey, dropChangedKey, migrationNameArg } from './primary-key';
|
|
27
28
|
import type { ReplicaIdentityInput } from './replica-identity';
|
|
28
29
|
import { replicaIdentityFullAfter, replicaIdentityPlan } from './replica-identity';
|
|
29
30
|
import type { MovedAside } from './retype-dependents';
|
|
@@ -112,6 +113,8 @@ export function snapshotOf(
|
|
|
112
113
|
// The same rule once more: `true` or absent, never `false`. This is what makes the ALTER
|
|
113
114
|
// beside it a one-time statement rather than a line every `x db gen` writes again.
|
|
114
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 } : {}),
|
|
115
118
|
};
|
|
116
119
|
});
|
|
117
120
|
return { tables };
|
|
@@ -199,7 +202,7 @@ function diffTable(
|
|
|
199
202
|
} else {
|
|
200
203
|
// After any retype, so a new default is set against the column's new type. A rebuilt
|
|
201
204
|
// column was re-added carrying its whole clause, so it has nothing left to move.
|
|
202
|
-
alterColumnInPlace(entity
|
|
205
|
+
alterColumnInPlace(entity, column, recorded, plan);
|
|
203
206
|
}
|
|
204
207
|
continue;
|
|
205
208
|
}
|
|
@@ -212,6 +215,9 @@ function diffTable(
|
|
|
212
215
|
// one statement — measured. Emitting it nullable would leave a `-- backfill` comment naming a
|
|
213
216
|
// step nobody can perform, since a generated column cannot be written to.
|
|
214
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');
|
|
215
221
|
const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
|
|
216
222
|
plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
|
|
217
223
|
if (nullable) {
|
|
@@ -335,7 +341,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
335
341
|
if (options.allowDestructive !== true) {
|
|
336
342
|
throw migrationIrreversible(
|
|
337
343
|
`dropping "${entity.table}"."${column.name}" discards its rows and cannot be undone`,
|
|
338
|
-
`x db gen
|
|
344
|
+
`x db gen ${migrationNameArg(options.name)} --allow-destructive # or keep the column and deprecate it`,
|
|
339
345
|
);
|
|
340
346
|
}
|
|
341
347
|
plan.up.push(
|
|
@@ -355,7 +361,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
355
361
|
if (options.allowDestructive !== true) {
|
|
356
362
|
throw migrationIrreversible(
|
|
357
363
|
`dropping table "${table.name}" discards every row and cannot be undone`,
|
|
358
|
-
`x db gen
|
|
364
|
+
`x db gen ${migrationNameArg(options.name)} --allow-destructive # or delete the entity in two releases`,
|
|
359
365
|
);
|
|
360
366
|
}
|
|
361
367
|
}
|
|
@@ -373,6 +379,9 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
373
379
|
plan.up.push(...constraints.up);
|
|
374
380
|
plan.down.push(...constraints.down);
|
|
375
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
|
+
|
|
376
385
|
// Dead last in `up`, and it is the only placement that is right for every arm: the table has to
|
|
377
386
|
// exist, and a `create table` in this same migration is the reason it might not. It is ordered
|
|
378
387
|
// against nothing else — replica identity constrains no column, no index and no constraint — so
|
|
@@ -388,7 +397,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
388
397
|
const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
|
|
389
398
|
// At the TOP of `up`, so what is MISSING is the first thing read — and a line comment, so it is
|
|
390
399
|
// noise to every reader that matters: `statementsOf` drops a chunk of comments alone,
|
|
391
|
-
// `stripSqlNoise` blanks it before `
|
|
400
|
+
// `stripSqlNoise` blanks it before `isDestructiveMigration` looks for a verb, and the server ignores it.
|
|
392
401
|
//
|
|
393
402
|
// Never onto an EMPTY diff. `@ultimat3/cli`'s `generateAppMigration` reads `up.trim().length` as
|
|
394
403
|
// "nothing changed" and re-records the hash sidecar instead of writing a file; a comment there
|
|
@@ -411,7 +420,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
411
420
|
// ends have been retyped back, which is every other statement in the script.
|
|
412
421
|
down: [...preAlters.down, ...plan.down].reverse().join('\n'),
|
|
413
422
|
snapshot: snapshotOf(options.entities, replicaIdentityFullAfter(replicaIdentity)),
|
|
414
|
-
destructive:
|
|
423
|
+
destructive: isDestructiveMigration(up),
|
|
415
424
|
unrendered,
|
|
416
425
|
};
|
|
417
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,
|
|
@@ -83,7 +83,7 @@ export {
|
|
|
83
83
|
} from './errors';
|
|
84
84
|
export { expectedQueryLoop, expectedQueryLoopReason } from './expected-loop';
|
|
85
85
|
export type { RecordedStatement, RecordingClient, StubResponse } from './fake';
|
|
86
|
-
export {
|
|
86
|
+
export { recordingClient } from './fake';
|
|
87
87
|
export type { GeneratedMigration, GenerateOptions } from './generate';
|
|
88
88
|
export { generateMigration, migrationStamp, slugify, snapshotOf } from './generate';
|
|
89
89
|
export type { IndexMethod } from './index-method';
|
|
@@ -103,7 +103,7 @@ export type {
|
|
|
103
103
|
SchemaDescription,
|
|
104
104
|
TableDescription,
|
|
105
105
|
} from './introspect';
|
|
106
|
-
export { buildSchema, findTable,
|
|
106
|
+
export { buildSchema, findTable, introspectSchema } from './introspect';
|
|
107
107
|
export {
|
|
108
108
|
constraintNameFor,
|
|
109
109
|
declaredIndexes,
|
|
@@ -157,12 +157,12 @@ export type {
|
|
|
157
157
|
PgliteResult,
|
|
158
158
|
} from './pglite';
|
|
159
159
|
export {
|
|
160
|
-
createPgliteClient,
|
|
161
160
|
loadPgliteDriver,
|
|
162
161
|
PGLITE_FIX,
|
|
163
162
|
PGLITE_MEMORY,
|
|
164
163
|
PGLITE_MISSING,
|
|
165
164
|
PGLITE_PACKAGE,
|
|
165
|
+
pgliteClient,
|
|
166
166
|
pgliteDataDir,
|
|
167
167
|
} from './pglite';
|
|
168
168
|
export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
|
|
@@ -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
|
|
package/src/introspect.ts
CHANGED
|
@@ -104,7 +104,7 @@ export interface TableDescription {
|
|
|
104
104
|
* handed a catalog value by accident.
|
|
105
105
|
*
|
|
106
106
|
* `snapshotOf` never writes it and `parseSnapshot` never reads it, so a sidecar carries `checks`
|
|
107
|
-
* alone. `
|
|
107
|
+
* alone. `introspectSchema()` always answers with it, `[]` included: absent therefore means "nobody
|
|
108
108
|
* asked the catalog", which is what keeps `compareChecks` silent on a description that never
|
|
109
109
|
* read one instead of reporting every declared constraint as missing.
|
|
110
110
|
*/
|
|
@@ -119,12 +119,25 @@ export interface TableDescription {
|
|
|
119
119
|
* table an app has would rewrite every sidecar in the tree on the next `x db gen` for a fact that
|
|
120
120
|
* was already true — the argument `IndexDescription.using` makes about `btree`.
|
|
121
121
|
*
|
|
122
|
-
* `
|
|
122
|
+
* `introspectSchema()` never answers it. The catalog's half is `pg_class.relreplident`, which
|
|
123
123
|
* `@ultimat3/realtime`'s preflight already reads at the only moment it matters; a second reader
|
|
124
124
|
* here would let a `diffSchema` compare a declaration against a catalog value, which is the
|
|
125
125
|
* mistake `checks` and `checkNames` exist as two fields to prevent.
|
|
126
126
|
*/
|
|
127
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;
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
export interface SchemaDescription {
|
|
@@ -179,9 +192,17 @@ interface CheckRow {
|
|
|
179
192
|
readonly constraint_name: string;
|
|
180
193
|
}
|
|
181
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
|
+
|
|
182
201
|
const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : 1);
|
|
183
202
|
|
|
184
|
-
export async function
|
|
203
|
+
export async function introspectSchema(
|
|
204
|
+
options: IntrospectOptions = {},
|
|
205
|
+
): Promise<SchemaDescription> {
|
|
185
206
|
const client = options.client ?? db();
|
|
186
207
|
const schema = options.schema ?? 'public';
|
|
187
208
|
// Asked first, and unconditionally: everything below reads `information_schema`, which admits a
|
|
@@ -297,7 +318,19 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
297
318
|
order by src.relname, c.conname
|
|
298
319
|
`);
|
|
299
320
|
|
|
300
|
-
|
|
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);
|
|
301
334
|
}
|
|
302
335
|
|
|
303
336
|
/** Pure, so the row -> description mapping is testable without a database. */
|
|
@@ -308,6 +341,8 @@ export function buildSchema(
|
|
|
308
341
|
indexes: readonly IndexRow[],
|
|
309
342
|
foreignKeys: readonly ForeignKeyRow[],
|
|
310
343
|
checks: readonly CheckRow[] = [],
|
|
344
|
+
/** Absent leaves `triggerNames` off every table: nobody asked the catalog. */
|
|
345
|
+
triggers?: readonly TriggerNameRow[],
|
|
311
346
|
): SchemaDescription {
|
|
312
347
|
const names = [...new Set(columns.map((row) => row.table_name))]
|
|
313
348
|
.filter((name) => !excluded.includes(name))
|
|
@@ -359,6 +394,14 @@ export function buildSchema(
|
|
|
359
394
|
.filter((row) => row.table_name === name)
|
|
360
395
|
.map((row) => row.constraint_name)
|
|
361
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
|
+
}),
|
|
362
405
|
};
|
|
363
406
|
});
|
|
364
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
|
+
}
|