@ultimat3/db 29.3.2 → 30.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 +78 -116
- package/README.md +199 -16
- package/package.json +13 -2
- package/src/app-relation.ts +7 -2
- package/src/catalog-fold.ts +5 -0
- package/src/catalog-objects.ts +20 -24
- package/src/catalog-policies.ts +81 -0
- package/src/catalog-relations.ts +31 -10
- package/src/catalog.ts +40 -1
- package/src/check-ddl.ts +34 -7
- package/src/client.ts +132 -48
- package/src/column-alter.ts +42 -1
- package/src/concurrent-index.ts +157 -0
- package/src/concurrent-plan.ts +294 -0
- package/src/connection-url.ts +16 -4
- package/src/db-diagnostics-deadline.ts +43 -0
- package/src/db-diagnostics-redact.ts +58 -0
- package/src/db-diagnostics-rows.ts +166 -0
- package/src/db-diagnostics-sql.ts +92 -0
- package/src/db-diagnostics.ts +178 -0
- package/src/destructive.ts +21 -35
- package/src/diagnostics-entry.ts +15 -0
- package/src/drift-errors.ts +9 -4
- package/src/drift-findings.ts +47 -62
- package/src/drift-missing.ts +203 -0
- package/src/drift-row-security.ts +88 -0
- package/src/drift-triggers.ts +54 -0
- package/src/drift.ts +88 -21
- package/src/entity-shape.ts +75 -0
- package/src/errors.ts +80 -9
- package/src/expected-loop.ts +34 -4
- package/src/failure-store-pg.ts +157 -0
- package/src/foreign-key-plan.ts +75 -23
- package/src/foreign-key.ts +76 -5
- package/src/generate-counter.ts +85 -0
- package/src/generate-history.ts +142 -0
- package/src/generate-row-security.ts +51 -0
- package/src/generate-snapshot.ts +82 -0
- package/src/generate-steps.ts +99 -0
- package/src/generate-triggers.ts +119 -0
- package/src/generate-view.ts +97 -0
- package/src/generate.ts +128 -89
- package/src/index-ddl.ts +23 -35
- package/src/index-plan.ts +35 -14
- package/src/index.ts +136 -6
- package/src/introspect-catalog.ts +26 -6
- package/src/introspect-rows.ts +82 -0
- package/src/introspect.ts +108 -50
- package/src/keyset.ts +182 -0
- package/src/lifecycle-errors.ts +15 -0
- package/src/local-settings.ts +38 -0
- package/src/marker-line.ts +47 -0
- package/src/migrate-lock-retry.ts +60 -0
- package/src/migrate-rollback.ts +45 -6
- package/src/migrate.ts +67 -69
- package/src/migration-errors.ts +48 -1
- package/src/migration-ledger.ts +38 -4
- package/src/migration-step.ts +192 -0
- package/src/partition-order.ts +56 -0
- package/src/partitioned-tables.ts +24 -0
- package/src/pool-gate.ts +120 -0
- package/src/pool-listeners.ts +143 -0
- package/src/pool-pins.ts +68 -0
- package/src/pool-profile.ts +23 -7
- package/src/pool-reserve.ts +11 -4
- package/src/pool-retire.ts +22 -0
- package/src/pool-sweep.ts +115 -0
- package/src/pool-timer.ts +10 -0
- package/src/pooler-fix.ts +53 -0
- package/src/pooler.ts +161 -0
- package/src/read-transaction.ts +59 -0
- package/src/readonly-query.ts +4 -4
- package/src/replica-client.ts +54 -22
- package/src/replica-position.ts +89 -0
- package/src/replica-scope.ts +25 -4
- package/src/retired.ts +131 -0
- package/src/retype-dependents.ts +2 -0
- package/src/rolling-errors.ts +97 -0
- package/src/rolling.ts +212 -0
- package/src/row-secure-client.ts +54 -0
- package/src/row-security-catalog.ts +26 -0
- package/src/row-security-migrator.ts +48 -0
- package/src/row-security-plan.ts +58 -0
- package/src/row-security-role.ts +60 -0
- package/src/row-security-roles.ts +64 -0
- package/src/row-security.ts +59 -0
- package/src/same-tenant.ts +173 -0
- package/src/schema-dump-policies.ts +26 -0
- package/src/schema-dump-table.ts +21 -6
- package/src/schema-dump.ts +13 -2
- package/src/snapshot-parse.ts +42 -2
- package/src/sqlstate.ts +4 -0
- package/src/statement-bound.ts +82 -0
- package/src/transaction-errors.ts +14 -0
- package/src/transaction-guard.ts +40 -0
- package/src/transaction-settings.ts +24 -0
- package/src/transaction.ts +74 -19
- package/src/ungeneratable.ts +30 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Single responsibility: turn one diff into the migration FILES it needs — one transactional step,
|
|
2
|
+
// one non-transactional step, or the first followed by the second. Split from `generate.ts`, which
|
|
3
|
+
// plans the statements; this file decides which step each list is and what each step records.
|
|
4
|
+
|
|
5
|
+
import { NO_TRANSACTION_MARKER } from './concurrent-index';
|
|
6
|
+
import { type ConcurrentPlan, intermediateSnapshot, stepStatements } from './concurrent-plan';
|
|
7
|
+
import { isDestructiveMigration } from './destructive';
|
|
8
|
+
import type { GeneratedMigration } from './generate';
|
|
9
|
+
import type { SchemaDescription } from './introspect';
|
|
10
|
+
import { type UnrenderedDeclaration, unrenderedComment } from './unrendered';
|
|
11
|
+
|
|
12
|
+
export interface DiffSteps {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly name: string;
|
|
15
|
+
/** The transactional step's statements, in apply order. */
|
|
16
|
+
readonly up: readonly string[];
|
|
17
|
+
/** Its reversal, already in apply order. */
|
|
18
|
+
readonly down: readonly string[];
|
|
19
|
+
/** Everything that must run outside a transaction, after `up`. */
|
|
20
|
+
readonly concurrent: ConcurrentPlan;
|
|
21
|
+
/** The schema the whole diff leaves behind. */
|
|
22
|
+
readonly snapshot: SchemaDescription;
|
|
23
|
+
readonly unrendered: readonly UnrenderedDeclaration[];
|
|
24
|
+
/** `--rolling-unsafe` was asked for and this diff dropped a column that was never retired. */
|
|
25
|
+
readonly rollingUnsafe?: boolean | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One file's worth. `destructive` is read off `up` by the gate's own classifier, never assembled. */
|
|
29
|
+
function migrationOf(
|
|
30
|
+
fields: Omit<GeneratedMigration, 'fileName' | 'destructive' | 'followUp'>,
|
|
31
|
+
): GeneratedMigration {
|
|
32
|
+
return {
|
|
33
|
+
...fields,
|
|
34
|
+
fileName: `migrations/${fields.id}.sql`,
|
|
35
|
+
destructive: isDestructiveMigration(fields.up),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The step files of one diff.
|
|
41
|
+
*
|
|
42
|
+
* The unrendered report sits at the TOP of the first step's `up` and never onto an empty one:
|
|
43
|
+
* `@ultimat3/cli`'s `generateAppMigration` reads an empty `up` as "nothing changed". In a
|
|
44
|
+
* non-transactional step the marker comes first — it is the header line the runner reads.
|
|
45
|
+
*
|
|
46
|
+
* Why TWO files and never one file in two halves: a migration is one ledger row. A file whose
|
|
47
|
+
* transactional half committed and whose concurrent half then failed is either recorded (and the
|
|
48
|
+
* index never built) or not (and the next run re-applies the committed half: `42P07`). Two files
|
|
49
|
+
* are two rows, and the second is idempotent by construction (`if not exists` / `if exists`, an
|
|
50
|
+
* INVALID leftover dropped first), so a failure in it is re-run, never repaired by hand.
|
|
51
|
+
*/
|
|
52
|
+
export function assembleSteps(diff: DiffSteps): GeneratedMigration {
|
|
53
|
+
const { id, name, snapshot, unrendered } = diff;
|
|
54
|
+
const body = diff.up.join('\n');
|
|
55
|
+
const step = stepStatements(diff.concurrent);
|
|
56
|
+
const later = step.up.join('\n');
|
|
57
|
+
const laterDown = step.down.join('\n');
|
|
58
|
+
const outside = (report: readonly UnrenderedDeclaration[]): string =>
|
|
59
|
+
`${NO_TRANSACTION_MARKER}\n${unrenderedComment(report)}${later}`;
|
|
60
|
+
if (body.length === 0 && later.length > 0) {
|
|
61
|
+
// Only index work: the non-transactional step IS the migration, under the plain id.
|
|
62
|
+
return migrationOf({
|
|
63
|
+
id,
|
|
64
|
+
name,
|
|
65
|
+
up: outside(unrendered),
|
|
66
|
+
down: laterDown,
|
|
67
|
+
snapshot,
|
|
68
|
+
unrendered,
|
|
69
|
+
transactional: false,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const first = migrationOf({
|
|
73
|
+
id,
|
|
74
|
+
name,
|
|
75
|
+
up: body.length === 0 ? body : unrenderedComment(unrendered) + body,
|
|
76
|
+
down: diff.down.join('\n'),
|
|
77
|
+
// What the transactional step alone leaves behind, when a second one follows it.
|
|
78
|
+
snapshot:
|
|
79
|
+
later.length === 0
|
|
80
|
+
? snapshot
|
|
81
|
+
: intermediateSnapshot(snapshot, diff.concurrent.deferred, diff.concurrent.deferredKeys),
|
|
82
|
+
unrendered,
|
|
83
|
+
transactional: true,
|
|
84
|
+
...(diff.rollingUnsafe === true ? { rollingUnsafe: true } : {}),
|
|
85
|
+
});
|
|
86
|
+
if (later.length === 0) return first;
|
|
87
|
+
// `_concurrently` sorts right after the plain id — as a ledger id, an `.sql` and every sidecar —
|
|
88
|
+
// so expand (the transaction) always lands before contract (the concurrent drops).
|
|
89
|
+
const followUp = migrationOf({
|
|
90
|
+
id: `${id}_concurrently`,
|
|
91
|
+
name: `${name} concurrently`,
|
|
92
|
+
up: outside([]),
|
|
93
|
+
down: laterDown,
|
|
94
|
+
snapshot,
|
|
95
|
+
unrendered: [],
|
|
96
|
+
transactional: false,
|
|
97
|
+
});
|
|
98
|
+
return { ...first, followUp };
|
|
99
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Single responsibility: an entity's lifecycle triggers — history, its guard, counters — as one
|
|
2
|
+
// list per table, planned in both directions against what the newest snapshot recorded, and written
|
|
3
|
+
// onto the snapshot so each is a one-time statement. The SQL of each is its own file's.
|
|
4
|
+
|
|
5
|
+
import type { EntityDescriptionLike } from './entity-shape';
|
|
6
|
+
import type { Plan } from './foreign-key-plan';
|
|
7
|
+
import { counterTrigger, recountSql } from './generate-counter';
|
|
8
|
+
import { historyTriggers } from './generate-history';
|
|
9
|
+
import type { SchemaDescription, TableDescription, TriggerRecord } from './introspect';
|
|
10
|
+
import { findTable } from './introspect';
|
|
11
|
+
import { CROSS_TENANT_SETTING } from './row-security';
|
|
12
|
+
import { identifier } from './sql';
|
|
13
|
+
|
|
14
|
+
/** Every declared lifecycle trigger, by the table it sits on, in declaration order. */
|
|
15
|
+
export function declaredTriggers(
|
|
16
|
+
entities: readonly EntityDescriptionLike[],
|
|
17
|
+
): ReadonlyMap<string, readonly TriggerRecord[]> {
|
|
18
|
+
const byTable = new Map<string, TriggerRecord[]>();
|
|
19
|
+
const add = (table: string, trigger: TriggerRecord): void => {
|
|
20
|
+
const list = byTable.get(table) ?? [];
|
|
21
|
+
list.push(trigger);
|
|
22
|
+
byTable.set(table, list);
|
|
23
|
+
};
|
|
24
|
+
for (const entity of entities) {
|
|
25
|
+
if (entity.history !== undefined) {
|
|
26
|
+
for (const [table, trigger] of historyTriggers(entity.table, entity.history)) {
|
|
27
|
+
add(table, trigger);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const counter of entity.counts ?? [])
|
|
31
|
+
add(counter.table, counterTrigger(entity.table, counter));
|
|
32
|
+
}
|
|
33
|
+
return byTable;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dropSql = (table: string, name: string): string =>
|
|
37
|
+
`drop trigger if exists ${identifier(name).text} on ${identifier(table).text};`;
|
|
38
|
+
|
|
39
|
+
const same = (left: TriggerRecord, right: TriggerRecord): boolean =>
|
|
40
|
+
left.install.join('\n') === right.install.join('\n');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The recount a NEW counter starts with, so a table that already holds rows is true from the first
|
|
44
|
+
* write. Cross-tenant for the one statement: under forced row security an unset tenant matches no
|
|
45
|
+
* row, and the recount would update nothing in silence.
|
|
46
|
+
*/
|
|
47
|
+
function recounts(
|
|
48
|
+
entities: readonly EntityDescriptionLike[],
|
|
49
|
+
added: ReadonlySet<string>,
|
|
50
|
+
): string[] {
|
|
51
|
+
const out: string[] = [];
|
|
52
|
+
for (const entity of entities) {
|
|
53
|
+
for (const counter of entity.counts ?? []) {
|
|
54
|
+
const trigger = counterTrigger(entity.table, counter);
|
|
55
|
+
if (!added.has(`${counter.table}.${trigger.name}`)) continue;
|
|
56
|
+
out.push(`select set_config('${CROSS_TENANT_SETTING}', 'on', true);`);
|
|
57
|
+
out.push(recountSql(entity.table, counter));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Both directions, after every table exists. Declared and not recorded (or recorded with other
|
|
65
|
+
* statements): install, and `down` drops it — or reinstalls what was recorded. Recorded and no
|
|
66
|
+
* longer declared: drop, and `down` reinstalls it. A table dropped outright needs nothing: its
|
|
67
|
+
* triggers go with it. Functions are never dropped: one with no trigger does nothing.
|
|
68
|
+
*/
|
|
69
|
+
export function triggerPlan(
|
|
70
|
+
plan: Plan,
|
|
71
|
+
entities: readonly EntityDescriptionLike[],
|
|
72
|
+
current: SchemaDescription,
|
|
73
|
+
): void {
|
|
74
|
+
const declared = declaredTriggers(entities);
|
|
75
|
+
const tables = new Set(entities.map((entity) => entity.table));
|
|
76
|
+
const added = new Set<string>();
|
|
77
|
+
for (const [table, triggers] of declared) {
|
|
78
|
+
const recorded = findTable(current, table)?.triggers ?? [];
|
|
79
|
+
for (const trigger of triggers) {
|
|
80
|
+
const before = recorded.find((each) => each.name === trigger.name);
|
|
81
|
+
if (before !== undefined && same(before, trigger)) continue;
|
|
82
|
+
plan.up.push(...trigger.install);
|
|
83
|
+
// `down` is reversed whole: pushed in apply order, reversed back into it.
|
|
84
|
+
if (before === undefined) plan.down.push(dropSql(table, trigger.name));
|
|
85
|
+
else plan.down.push(...[...before.install].reverse());
|
|
86
|
+
added.add(`${table}.${trigger.name}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const table of current.tables) {
|
|
90
|
+
if (!tables.has(table.name)) continue;
|
|
91
|
+
const wanted = declared.get(table.name) ?? [];
|
|
92
|
+
for (const recorded of table.triggers ?? []) {
|
|
93
|
+
if (wanted.some((each) => each.name === recorded.name)) continue;
|
|
94
|
+
plan.up.push(dropSql(table.name, recorded.name));
|
|
95
|
+
plan.down.push(...[...recorded.install].reverse());
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
plan.up.push(...recounts(entities, added));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The snapshot, each table carrying the triggers declared on it — absent where there are none. */
|
|
102
|
+
export function recordTriggers(
|
|
103
|
+
snapshot: SchemaDescription,
|
|
104
|
+
entities: readonly EntityDescriptionLike[],
|
|
105
|
+
): SchemaDescription {
|
|
106
|
+
const declared = declaredTriggers(entities);
|
|
107
|
+
const retained = new Set(
|
|
108
|
+
entities.filter((entity) => entity.retain !== undefined).map((entity) => entity.table),
|
|
109
|
+
);
|
|
110
|
+
const tables = snapshot.tables.map((table): TableDescription => {
|
|
111
|
+
const triggers = declared.get(table.name);
|
|
112
|
+
return {
|
|
113
|
+
...table,
|
|
114
|
+
...(triggers === undefined ? {} : { triggers }),
|
|
115
|
+
...(retained.has(table.name) ? { retain: true as const } : {}),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
return { ...snapshot, tables };
|
|
119
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Single responsibility: the views `x db gen` writes — `entity(name, { view: { sql } })` emitted
|
|
2
|
+
// as `create [materialized] view`, recorded in the snapshot, and re-emitted whole when anything
|
|
3
|
+
// they could depend on moves. Split from `generate.ts`, which hands every table to its own arms and
|
|
4
|
+
// every view to this file, so no table arm (keys, row security, triggers) ever sees a view.
|
|
5
|
+
|
|
6
|
+
import type { EntityDescriptionLike } from './entity-shape';
|
|
7
|
+
import type { Plan } from './foreign-key-plan';
|
|
8
|
+
import { createIndex } from './index-ddl';
|
|
9
|
+
import type { SchemaDescription, ViewDescription } from './introspect';
|
|
10
|
+
import { declaredIndexes } from './invariant-ddl';
|
|
11
|
+
import { identifier } from './sql';
|
|
12
|
+
|
|
13
|
+
/** Tables first, views second — the partition every arm of `generateMigration` reads. */
|
|
14
|
+
export function splitViews(entities: readonly EntityDescriptionLike[]): {
|
|
15
|
+
readonly tables: readonly EntityDescriptionLike[];
|
|
16
|
+
readonly views: readonly EntityDescriptionLike[];
|
|
17
|
+
} {
|
|
18
|
+
return {
|
|
19
|
+
tables: entities.filter((entity) => entity.view === undefined),
|
|
20
|
+
views: entities.filter((entity) => entity.view !== undefined),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The author's query as one statement body: no trailing `;`, no trailing whitespace. */
|
|
25
|
+
const bodyOf = (sql: string): string => sql.trim().replace(/;\s*$/, '');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a declared view records: its statements, verbatim — the comparison `x db gen` makes next
|
|
29
|
+
* time is between two of these, so a changed query, a changed index or `materialized` flipping is
|
|
30
|
+
* a different list and a rebuild. Only a MATERIALIZED view carries indexes; Postgres has none for
|
|
31
|
+
* a plain one (`entity()` refuses the declaration first).
|
|
32
|
+
*/
|
|
33
|
+
export function viewDescriptionOf(entity: EntityDescriptionLike): ViewDescription {
|
|
34
|
+
const materialized = entity.view?.materialized === true;
|
|
35
|
+
const kind = materialized ? 'materialized view' : 'view';
|
|
36
|
+
const create = [
|
|
37
|
+
`create ${kind} ${identifier(entity.table).text} as ${bodyOf(entity.view?.sql ?? '')};`,
|
|
38
|
+
...(materialized
|
|
39
|
+
? declaredIndexes(entity).map((index) => createIndex(entity.table, index))
|
|
40
|
+
: []),
|
|
41
|
+
];
|
|
42
|
+
return { name: entity.table, ...(materialized ? { materialized: true as const } : {}), create };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const drop = (view: ViewDescription): string =>
|
|
46
|
+
`drop ${view.materialized === true ? 'materialized view' : 'view'} if exists ${identifier(view.name).text};`;
|
|
47
|
+
|
|
48
|
+
const same = (left: readonly ViewDescription[], right: readonly ViewDescription[]): boolean =>
|
|
49
|
+
left.length === right.length &&
|
|
50
|
+
left.every((view, at) => JSON.stringify(view) === JSON.stringify(right[at]));
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether the table statements of this migration can break a view compiled against them: a
|
|
54
|
+
* retype is `0A000` under a view and a dropped column or table is `2BP01`. The words are the ones
|
|
55
|
+
* `generate.ts` itself writes, so this reads its own output and never an author's SQL.
|
|
56
|
+
*/
|
|
57
|
+
export const touchesViewSources = (up: readonly string[]): boolean =>
|
|
58
|
+
up.some((statement) =>
|
|
59
|
+
/\balter column\b.*\btype\b|\bdrop column\b|^drop table\b/.test(statement),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/** What a view rebuild contributes: drops ahead of every table statement, creates after them. */
|
|
63
|
+
export interface ViewPlan {
|
|
64
|
+
readonly before: Plan;
|
|
65
|
+
readonly after: Plan;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every recorded view is dropped (last declared first) and every declared one created (in
|
|
70
|
+
* declaration order — a view reading another view is declared after it) whenever the list moved
|
|
71
|
+
* or the tables under it did. Whole, never one view: which views read which is in their SQL, and
|
|
72
|
+
* a rebuild of the one that changed would leave its dependents compiled against the old one —
|
|
73
|
+
* `X_MIGRATION_VIEW_DEPENDS` is what that looks like at deploy. A materialized view comes back
|
|
74
|
+
* populated, so a rebuild costs one refresh, and nothing at all when nothing moved.
|
|
75
|
+
*/
|
|
76
|
+
export function viewPlan(
|
|
77
|
+
views: readonly EntityDescriptionLike[],
|
|
78
|
+
current: SchemaDescription,
|
|
79
|
+
tableUp: readonly string[],
|
|
80
|
+
): ViewPlan {
|
|
81
|
+
const wanted = views.map(viewDescriptionOf);
|
|
82
|
+
const recorded = current.views ?? [];
|
|
83
|
+
const before: Plan = { up: [], down: [] };
|
|
84
|
+
const after: Plan = { up: [], down: [] };
|
|
85
|
+
if (same(wanted, recorded) && !(recorded.length > 0 && touchesViewSources(tableUp))) {
|
|
86
|
+
return { before, after };
|
|
87
|
+
}
|
|
88
|
+
for (const view of [...recorded].reverse()) before.up.push(drop(view));
|
|
89
|
+
// `down` is reversed whole by the caller, so it is written backwards here: what comes out is
|
|
90
|
+
// every recorded view recreated in its own order, after every table is back.
|
|
91
|
+
before.down.push(...recorded.flatMap((view) => view.create).reverse());
|
|
92
|
+
for (const view of wanted) {
|
|
93
|
+
after.up.push(...view.create);
|
|
94
|
+
after.down.push(drop(view));
|
|
95
|
+
}
|
|
96
|
+
return { before, after };
|
|
97
|
+
}
|