@ultimat3/db 29.4.0 → 30.1.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.
Files changed (96) hide show
  1. package/CLAUDE.md +74 -110
  2. package/README.md +163 -15
  3. package/package.json +13 -2
  4. package/src/app-relation.ts +7 -2
  5. package/src/catalog-fold.ts +5 -0
  6. package/src/catalog-objects.ts +20 -24
  7. package/src/catalog-policies.ts +81 -0
  8. package/src/catalog-relations.ts +31 -10
  9. package/src/catalog.ts +40 -1
  10. package/src/check-ddl.ts +34 -7
  11. package/src/client.ts +132 -48
  12. package/src/column-alter.ts +42 -1
  13. package/src/concurrent-index.ts +20 -0
  14. package/src/concurrent-plan.ts +135 -17
  15. package/src/connection-url.ts +16 -4
  16. package/src/db-diagnostics-deadline.ts +43 -0
  17. package/src/db-diagnostics-redact.ts +58 -0
  18. package/src/db-diagnostics-rows.ts +166 -0
  19. package/src/db-diagnostics-sql.ts +92 -0
  20. package/src/db-diagnostics.ts +178 -0
  21. package/src/destructive.ts +17 -3
  22. package/src/diagnostics-entry.ts +15 -0
  23. package/src/drift-errors.ts +9 -4
  24. package/src/drift-findings.ts +47 -62
  25. package/src/drift-missing.ts +203 -0
  26. package/src/drift-row-security.ts +88 -0
  27. package/src/drift-triggers.ts +54 -0
  28. package/src/drift.ts +88 -21
  29. package/src/entity-shape.ts +67 -0
  30. package/src/errors.ts +77 -9
  31. package/src/expected-loop.ts +34 -4
  32. package/src/failure-store-pg.ts +157 -0
  33. package/src/foreign-key-plan.ts +75 -23
  34. package/src/foreign-key.ts +76 -5
  35. package/src/generate-counter.ts +85 -0
  36. package/src/generate-history.ts +142 -0
  37. package/src/generate-row-security.ts +51 -0
  38. package/src/generate-snapshot.ts +82 -0
  39. package/src/generate-steps.ts +6 -1
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +105 -79
  43. package/src/index.ts +129 -6
  44. package/src/introspect-catalog.ts +26 -6
  45. package/src/introspect-rows.ts +82 -0
  46. package/src/introspect.ts +108 -50
  47. package/src/keyset.ts +182 -0
  48. package/src/lifecycle-errors.ts +15 -0
  49. package/src/local-settings.ts +38 -0
  50. package/src/marker-line.ts +12 -4
  51. package/src/migrate-lock-retry.ts +60 -0
  52. package/src/migrate-rollback.ts +45 -6
  53. package/src/migrate.ts +45 -4
  54. package/src/migration-errors.ts +27 -1
  55. package/src/migration-ledger.ts +38 -4
  56. package/src/migration-step.ts +50 -22
  57. package/src/partition-order.ts +56 -0
  58. package/src/partitioned-tables.ts +24 -0
  59. package/src/pool-gate.ts +120 -0
  60. package/src/pool-listeners.ts +143 -0
  61. package/src/pool-pins.ts +68 -0
  62. package/src/pool-profile.ts +23 -7
  63. package/src/pool-reserve.ts +11 -4
  64. package/src/pool-retire.ts +22 -0
  65. package/src/pool-sweep.ts +115 -0
  66. package/src/pool-timer.ts +10 -0
  67. package/src/pooler-fix.ts +53 -0
  68. package/src/pooler.ts +161 -0
  69. package/src/read-transaction.ts +59 -0
  70. package/src/readonly-query.ts +4 -4
  71. package/src/replica-client.ts +54 -22
  72. package/src/replica-position.ts +89 -0
  73. package/src/replica-scope.ts +25 -4
  74. package/src/retired.ts +131 -0
  75. package/src/retype-dependents.ts +2 -0
  76. package/src/rolling-errors.ts +97 -0
  77. package/src/rolling.ts +212 -0
  78. package/src/row-secure-client.ts +54 -0
  79. package/src/row-security-catalog.ts +26 -0
  80. package/src/row-security-migrator.ts +48 -0
  81. package/src/row-security-plan.ts +58 -0
  82. package/src/row-security-role.ts +60 -0
  83. package/src/row-security-roles.ts +64 -0
  84. package/src/row-security.ts +59 -0
  85. package/src/same-tenant.ts +173 -0
  86. package/src/schema-dump-policies.ts +26 -0
  87. package/src/schema-dump-table.ts +21 -6
  88. package/src/schema-dump.ts +13 -2
  89. package/src/snapshot-parse.ts +42 -2
  90. package/src/sqlstate.ts +4 -0
  91. package/src/statement-bound.ts +82 -0
  92. package/src/transaction-errors.ts +14 -0
  93. package/src/transaction-guard.ts +40 -0
  94. package/src/transaction-settings.ts +24 -0
  95. package/src/transaction.ts +74 -19
  96. package/src/ungeneratable.ts +30 -0
@@ -0,0 +1,51 @@
1
+ // Single responsibility: the row-security half of one `x db gen` — the flag and each tenant table's
2
+ // physical column in, `row-security-plan.ts`'s input out, typed off the entity's own column. Kept
3
+ // out of `generate.ts` so the diff loop reads one call, the way the append-only arm does.
4
+
5
+ import type { EntityDescriptionLike } from './entity-shape';
6
+ import type { SchemaDescription } from './introspect';
7
+ import type { RowSecurityPlanInput, TenantTable } from './row-security-plan';
8
+ import { sqlType } from './sql-type';
9
+
10
+ /** One tenant table as `@ultimat3/entity`'s `describeTenancy()` names it: physical names only. */
11
+ export interface TenantColumnLike {
12
+ readonly table: string;
13
+ readonly column: string;
14
+ }
15
+
16
+ /** `GenerateOptions.rowSecurity`: `database.rowSecurity`, and which column each tenant table is scoped by. */
17
+ export interface RowSecurityOption {
18
+ readonly enabled: boolean;
19
+ readonly tenants: readonly TenantColumnLike[];
20
+ }
21
+
22
+ const OFF: RowSecurityOption = { enabled: false, tenants: [] };
23
+
24
+ /**
25
+ * Tenant tables this migration still declares, typed by the SQL the generator itself gives the
26
+ * column — one spelling of the type for the column and the policy's cast. A recorded table this
27
+ * migration drops is left out of `recorded`: `drop table` takes its policy with it, and a
28
+ * `drop policy` after that would name a table that no longer exists.
29
+ */
30
+ export function rowSecurityInput(
31
+ entities: readonly EntityDescriptionLike[],
32
+ current: SchemaDescription,
33
+ option: RowSecurityOption = OFF,
34
+ ): RowSecurityPlanInput {
35
+ // A view carries no policy of its own: Postgres reads its source table's (`generate-view.ts`).
36
+ const declared = new Map(
37
+ entities.filter((entity) => entity.view === undefined).map((entity) => [entity.table, entity]),
38
+ );
39
+ const tenantTables = option.tenants.flatMap((tenant): TenantTable[] => {
40
+ const column = declared.get(tenant.table)?.columns.find((c) => c.column === tenant.column);
41
+ return column === undefined
42
+ ? []
43
+ : [{ table: tenant.table, column: tenant.column, type: sqlType(column.kind) }];
44
+ });
45
+ const recorded = new Set(
46
+ current.tables
47
+ .filter((table) => table.rowSecurity === true && declared.has(table.name))
48
+ .map((table) => table.name),
49
+ );
50
+ return { enabled: option.enabled, tenantTables, recorded };
51
+ }
@@ -0,0 +1,82 @@
1
+ // Single responsibility: the snapshot a migration records — what the entities declare, in the
2
+ // vocabulary `introspect.ts` reads, plus the one-time facts a parameter decides (replica identity,
3
+ // row security). Split from `generate.ts`, which diffs against it; re-exported there.
4
+
5
+ import { declaredChecks } from './check-ddl';
6
+ import { defaultExpression } from './column-default';
7
+ import type { EntityDescriptionLike } from './entity-shape';
8
+ import { foreignKeysOf } from './foreign-key-plan';
9
+ import { splitViews, viewDescriptionOf } from './generate-view';
10
+ import type { ColumnDescription, SchemaDescription, TableDescription } from './introspect';
11
+ import { declaredIndexes } from './invariant-ddl';
12
+ import { withSameTenantIndexes } from './same-tenant';
13
+ import { sqlType } from './sql-type';
14
+
15
+ /** No table named, which is what every caller outside `generateMigration` describes. */
16
+ const NONE: ReadonlySet<string> = new Set();
17
+
18
+ export function snapshotOf(
19
+ entities: readonly EntityDescriptionLike[],
20
+ replicaIdentityFull: ReadonlySet<string> = NONE,
21
+ rowSecurity: ReadonlySet<string> = NONE,
22
+ ): SchemaDescription {
23
+ // A view is recorded as its statements (`generate-view.ts`), never as a table it is not.
24
+ const split = splitViews(entities);
25
+ const tables = [...withSameTenantIndexes(split.tables)]
26
+ .sort((a, b) => (a.table < b.table ? -1 : 1))
27
+ .map((entity): TableDescription => {
28
+ const columns: ColumnDescription[] = [...entity.columns]
29
+ .sort((a, b) => (a.column < b.column ? -1 : 1))
30
+ .map((column, index) => ({
31
+ name: column.column,
32
+ dataType: sqlType(column.kind),
33
+ nullable: !column.notNull,
34
+ default: defaultExpression(column),
35
+ position: index + 1,
36
+ // Only when one was declared — absent stays absent, so no snapshot written before this
37
+ // field existed gains a key and no app's sidecar regenerates over a fact already true.
38
+ ...(column.generated === undefined ? {} : { generated: column.generated }),
39
+ }));
40
+ const checks = declaredChecks(entity);
41
+ return {
42
+ schema: 'public',
43
+ name: entity.table,
44
+ columns,
45
+ primaryKey: [...entity.primaryKey],
46
+ // Whole, never partly: a snapshot that recorded the name and dropped the predicate made
47
+ // the next generation blind to a `where` or an `order` changing, and a partial index
48
+ // silently kept as a total one is a constraint the entity no longer declares.
49
+ //
50
+ // `declaredIndexes`, so a `unique` invariant's index is recorded exactly like an entity's
51
+ // own — a statement emitted and not recorded is `42P07` on the very next `x db gen`.
52
+ indexes: declaredIndexes(entity).map((index) => ({
53
+ name: index.name,
54
+ columns: [...index.columns],
55
+ unique: index.unique,
56
+ primary: false,
57
+ where: index.where,
58
+ order: index.order,
59
+ // Only when one was declared. Writing `using: 'btree'` out for every index would rewrite
60
+ // every sidecar in every app on the next `x db gen` — a diff on every file for a fact
61
+ // that was already true, which `indexMethodOf` reads out of the absence anyway.
62
+ ...(index.using === undefined ? {} : { using: index.using }),
63
+ })),
64
+ foreignKeys: foreignKeysOf(entity),
65
+ // Absent, never `[]`, on a table declaring none: a sidecar that predates this field must
66
+ // read as "nothing recorded" so the next generation adds the constraints the database is
67
+ // genuinely missing — the rule `using` and `generated` already state one field up.
68
+ ...(checks.length === 0 ? {} : { checks }),
69
+ // The same rule once more: `true` or absent, never `false`. This is what makes the ALTER
70
+ // beside it a one-time statement rather than a line every `x db gen` writes again.
71
+ ...(replicaIdentityFull.has(entity.table) ? { replicaIdentityFull: true as const } : {}),
72
+ // `true` or absent once more — the record that makes the trigger a one-time statement.
73
+ ...(entity.appendOnly === true ? { appendOnly: true as const } : {}),
74
+ // And again: the record that makes the tenant policy a one-time statement.
75
+ ...(rowSecurity.has(entity.table) ? { rowSecurity: true as const } : {}),
76
+ };
77
+ });
78
+ // `views` absent when none is declared, so no snapshot written before views existed moves.
79
+ return split.views.length === 0
80
+ ? { tables }
81
+ : { tables, views: split.views.map(viewDescriptionOf) };
82
+ }
@@ -21,6 +21,8 @@ export interface DiffSteps {
21
21
  /** The schema the whole diff leaves behind. */
22
22
  readonly snapshot: SchemaDescription;
23
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;
24
26
  }
25
27
 
26
28
  /** One file's worth. `destructive` is read off `up` by the gate's own classifier, never assembled. */
@@ -74,9 +76,12 @@ export function assembleSteps(diff: DiffSteps): GeneratedMigration {
74
76
  down: diff.down.join('\n'),
75
77
  // What the transactional step alone leaves behind, when a second one follows it.
76
78
  snapshot:
77
- later.length === 0 ? snapshot : intermediateSnapshot(snapshot, diff.concurrent.deferred),
79
+ later.length === 0
80
+ ? snapshot
81
+ : intermediateSnapshot(snapshot, diff.concurrent.deferred, diff.concurrent.deferredKeys),
78
82
  unrendered,
79
83
  transactional: true,
84
+ ...(diff.rollingUnsafe === true ? { rollingUnsafe: true } : {}),
80
85
  });
81
86
  if (later.length === 0) return first;
82
87
  // `_concurrently` sorts right after the plain id — as a ledger id, an `.sql` and every sidecar —
@@ -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
+ }