@ultimat3/db 22.15.0 → 24.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.
Files changed (43) hide show
  1. package/CLAUDE.md +91 -56
  2. package/README.md +121 -6
  3. package/package.json +5 -3
  4. package/src/array-parameter.ts +38 -1
  5. package/src/bound-parameters.ts +22 -3
  6. package/src/bun-sql.ts +12 -0
  7. package/src/catalog-fold.ts +116 -0
  8. package/src/catalog-objects.ts +229 -0
  9. package/src/catalog-relations.ts +184 -0
  10. package/src/catalog.ts +174 -0
  11. package/src/client.ts +57 -9
  12. package/src/commit-tag.ts +21 -0
  13. package/src/dependent-view.ts +6 -4
  14. package/src/drift-errors.ts +3 -3
  15. package/src/drift-findings.ts +213 -60
  16. package/src/drift.ts +19 -13
  17. package/src/dump-drift.ts +142 -0
  18. package/src/errors.ts +8 -7
  19. package/src/foreign-key.ts +0 -34
  20. package/src/generate.ts +5 -0
  21. package/src/index.ts +9 -3
  22. package/src/introspect-catalog.ts +171 -0
  23. package/src/introspect.ts +45 -8
  24. package/src/listen.ts +62 -0
  25. package/src/migrate.ts +3 -3
  26. package/src/object-drift.ts +162 -0
  27. package/src/pglite-branch.ts +6 -6
  28. package/src/pglite-extensions.ts +112 -0
  29. package/src/pglite-package.ts +11 -0
  30. package/src/pglite-snapshot.ts +121 -0
  31. package/src/pglite.ts +146 -11
  32. package/src/pool-gauge.ts +70 -0
  33. package/src/primary-key.ts +180 -0
  34. package/src/schema-dump-entry.ts +39 -0
  35. package/src/schema-dump-table.ts +75 -0
  36. package/src/schema-dump.ts +192 -0
  37. package/src/schema-load.ts +119 -0
  38. package/src/sibling-turn.ts +49 -0
  39. package/src/sqlstate.ts +33 -10
  40. package/src/statement-funnel.ts +16 -5
  41. package/src/transaction-errors.ts +66 -0
  42. package/src/transaction-options.ts +122 -0
  43. package/src/transaction.ts +131 -121
@@ -0,0 +1,116 @@
1
+ // Single responsibility: catalog ROWS into one table's description — its columns in ordinal order,
2
+ // its constraints ranked, the sequences its `serial` and identity columns own. Pure; the queries
3
+ // are `catalog-relations.ts`'s and the orchestration is `introspect-catalog.ts`'s.
4
+
5
+ import type {
6
+ CatalogColumn,
7
+ CatalogOwnedSequence,
8
+ CatalogReplicaIdentity,
9
+ CatalogSequence,
10
+ CatalogTable,
11
+ } from './catalog';
12
+ import type { ColumnRow, ConstraintRow, SequenceRow, TableRow } from './catalog-relations';
13
+
14
+ /**
15
+ * By UTF-16 code unit, never `localeCompare` and never SQL's `order by`: both follow a locale, and
16
+ * the same database must produce the same bytes on a laptop and in CI.
17
+ */
18
+ export const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
19
+
20
+ export const by =
21
+ <T>(...keys: readonly ((value: T) => string)[]) =>
22
+ (a: T, b: T): number => {
23
+ for (const key of keys) {
24
+ const order = byCodeUnit(key(a), key(b));
25
+ if (order !== 0) return order;
26
+ }
27
+ return 0;
28
+ };
29
+
30
+ export const sequenceOf = (row: SequenceRow): CatalogSequence => ({
31
+ name: row.name,
32
+ dataType: row.data_type,
33
+ start: row.seq_start,
34
+ increment: row.seq_increment,
35
+ min: row.seq_min,
36
+ max: row.seq_max,
37
+ cache: row.seq_cache,
38
+ cycle: row.seq_cycle,
39
+ });
40
+
41
+ function columnOf(row: ColumnRow, sequences: readonly SequenceRow[]): CatalogColumn {
42
+ const identity = sequences.find(
43
+ (sequence) =>
44
+ sequence.ownership === 'i' &&
45
+ sequence.owner_table === row.table_name &&
46
+ sequence.owner_column === row.name,
47
+ );
48
+ const generated = row.generated !== '' && row.generated !== null;
49
+ return {
50
+ name: row.name,
51
+ type: row.type,
52
+ notNull: row.not_null,
53
+ default: generated ? null : row.expression,
54
+ generated:
55
+ generated && row.expression !== null
56
+ ? { expression: row.expression, storage: row.generated === 'v' ? 'virtual' : 'stored' }
57
+ : null,
58
+ identity:
59
+ identity === undefined
60
+ ? null
61
+ : { mode: row.identity === 'a' ? 'always' : 'by default', sequence: sequenceOf(identity) },
62
+ collation: row.collation,
63
+ };
64
+ }
65
+
66
+ function replicaIdentityOf(row: TableRow): CatalogReplicaIdentity {
67
+ if (row.replica_identity === 'f') return { kind: 'full' };
68
+ if (row.replica_identity === 'n') return { kind: 'nothing' };
69
+ if (row.replica_identity === 'i' && row.replica_index !== null)
70
+ return { kind: 'index', index: row.replica_index };
71
+ return { kind: 'default' };
72
+ }
73
+
74
+ /** Primary key first, then unique, check, exclusion — each group by name. */
75
+ const CONSTRAINT_RANK: ReadonlyMap<string, string> = new Map([
76
+ ['p', '0'],
77
+ ['u', '1'],
78
+ ['c', '2'],
79
+ ['x', '3'],
80
+ ]);
81
+
82
+ export function tableOf(
83
+ row: TableRow,
84
+ columns: readonly ColumnRow[],
85
+ constraints: readonly ConstraintRow[],
86
+ sequences: readonly SequenceRow[],
87
+ ): CatalogTable {
88
+ return {
89
+ name: row.name,
90
+ unlogged: row.persistence === 'u',
91
+ options: row.options,
92
+ columns: columns
93
+ .filter((column) => column.table_name === row.name)
94
+ .sort((a, b) => a.position - b.position)
95
+ .map((column) => columnOf(column, sequences)),
96
+ constraints: constraints
97
+ .filter((constraint) => constraint.table_name === row.name && constraint.type !== 'f')
98
+ .sort(
99
+ by(
100
+ (c) => CONSTRAINT_RANK.get(c.type) ?? '9',
101
+ (c) => c.name,
102
+ ),
103
+ )
104
+ .map((constraint) => ({ name: constraint.name, definition: constraint.definition })),
105
+ sequences: sequences
106
+ .filter((sequence) => sequence.ownership === 'a' && sequence.owner_table === row.name)
107
+ .sort(by((sequence) => sequence.name))
108
+ .map(
109
+ (sequence): CatalogOwnedSequence => ({
110
+ ...sequenceOf(sequence),
111
+ column: sequence.owner_column ?? '',
112
+ }),
113
+ ),
114
+ replicaIdentity: replicaIdentityOf(row),
115
+ };
116
+ }
@@ -0,0 +1,229 @@
1
+ // Single responsibility: the catalog queries for every object that is NOT a table — extensions,
2
+ // enum and domain types, views, functions, triggers — plus the list of objects the schema dump has
3
+ // no statement for. Flat rows; `introspect-catalog.ts` folds and sorts them.
4
+
5
+ import { notExtensionOwned } from './catalog-relations';
6
+ import type { DbClient } from './client';
7
+ import { sql } from './sql';
8
+
9
+ export interface NameRow {
10
+ readonly name: string;
11
+ }
12
+
13
+ export interface EnumRow {
14
+ readonly name: string;
15
+ readonly label: string;
16
+ readonly position: number;
17
+ }
18
+
19
+ export interface DomainRow {
20
+ readonly name: string;
21
+ readonly base_type: string;
22
+ readonly not_null: boolean;
23
+ readonly expression: string | null;
24
+ }
25
+
26
+ export interface DomainCheckRow {
27
+ readonly domain_name: string;
28
+ readonly name: string;
29
+ readonly definition: string;
30
+ }
31
+
32
+ export interface ViewRow {
33
+ readonly name: string;
34
+ readonly kind: string;
35
+ readonly options: string | null;
36
+ readonly definition: string;
37
+ }
38
+
39
+ export interface FunctionRow {
40
+ readonly name: string;
41
+ readonly arguments: string;
42
+ readonly definition: string;
43
+ }
44
+
45
+ export interface TriggerRow {
46
+ readonly table_name: string;
47
+ readonly name: string;
48
+ readonly definition: string;
49
+ readonly enabled: string;
50
+ }
51
+
52
+ export interface UnrenderedRow {
53
+ readonly kind: string;
54
+ readonly name: string;
55
+ readonly table_name: string | null;
56
+ }
57
+
58
+ /**
59
+ * Database-wide, so no schema filter. `plpgsql` is left out: every database has it, `create
60
+ * extension` on it is a no-op, and a dump line that can never differ is noise in every app.
61
+ */
62
+ export const extensionRows = (client: DbClient): Promise<readonly NameRow[]> =>
63
+ client.query<NameRow>(sql`select extname as name from pg_extension where extname <> 'plpgsql'`);
64
+
65
+ export const enumRows = (client: DbClient, schema: string): Promise<readonly EnumRow[]> =>
66
+ client.query<EnumRow>(sql`
67
+ select t.typname as name, e.enumlabel as label, e.enumsortorder::float8 as position
68
+ from pg_enum e
69
+ join pg_type t on t.oid = e.enumtypid
70
+ join pg_namespace n on n.oid = t.typnamespace
71
+ where n.nspname = ${schema} and ${notExtensionOwned('pg_type', 't.oid')}
72
+ `);
73
+
74
+ export const domainRows = (client: DbClient, schema: string): Promise<readonly DomainRow[]> =>
75
+ client.query<DomainRow>(sql`
76
+ select
77
+ t.typname as name,
78
+ format_type(t.typbasetype, t.typtypmod) as base_type,
79
+ t.typnotnull as not_null,
80
+ t.typdefault as expression
81
+ from pg_type t
82
+ join pg_namespace n on n.oid = t.typnamespace
83
+ where n.nspname = ${schema} and t.typtype = 'd' and ${notExtensionOwned('pg_type', 't.oid')}
84
+ `);
85
+
86
+ export const domainCheckRows = (
87
+ client: DbClient,
88
+ schema: string,
89
+ ): Promise<readonly DomainCheckRow[]> =>
90
+ client.query<DomainCheckRow>(sql`
91
+ select t.typname as domain_name, k.conname as name, pg_get_constraintdef(k.oid) as definition
92
+ from pg_constraint k
93
+ join pg_type t on t.oid = k.contypid
94
+ join pg_namespace n on n.oid = t.typnamespace
95
+ where n.nspname = ${schema} and k.contype = 'c'
96
+ `);
97
+
98
+ /** The non-pretty `pg_get_viewdef`: the pretty form's line breaks follow a width, not the query. */
99
+ export const viewRows = (client: DbClient, schema: string): Promise<readonly ViewRow[]> =>
100
+ client.query<ViewRow>(sql`
101
+ select
102
+ c.relname as name,
103
+ c.relkind as kind,
104
+ array_to_string(c.reloptions, ', ') as options,
105
+ pg_get_viewdef(c.oid) as definition
106
+ from pg_class c
107
+ join pg_namespace n on n.oid = c.relnamespace
108
+ where n.nspname = ${schema}
109
+ and c.relkind in ('v', 'm')
110
+ and ${notExtensionOwned('pg_class', 'c.oid')}
111
+ `);
112
+
113
+ /** Functions, procedures and window functions. An aggregate is refused by `pg_get_functiondef`. */
114
+ export const functionRows = (client: DbClient, schema: string): Promise<readonly FunctionRow[]> =>
115
+ client.query<FunctionRow>(sql`
116
+ select
117
+ p.proname as name,
118
+ pg_get_function_identity_arguments(p.oid) as arguments,
119
+ pg_get_functiondef(p.oid) as definition
120
+ from pg_proc p
121
+ join pg_namespace n on n.oid = p.pronamespace
122
+ where n.nspname = ${schema}
123
+ and p.prokind in ('f', 'p', 'w')
124
+ and ${notExtensionOwned('pg_proc', 'p.oid')}
125
+ `);
126
+
127
+ /**
128
+ * `not tgisinternal`: a foreign key's own enforcement triggers are the constraint's, not the app's.
129
+ * A trigger on an extension-owned table is skipped with its table: the dump creates no such table,
130
+ * so its `09_triggers/` file could never load.
131
+ */
132
+ export const triggerRows = (client: DbClient, schema: string): Promise<readonly TriggerRow[]> =>
133
+ client.query<TriggerRow>(sql`
134
+ select
135
+ c.relname as table_name,
136
+ t.tgname as name,
137
+ pg_get_triggerdef(t.oid) as definition,
138
+ t.tgenabled as enabled
139
+ from pg_trigger t
140
+ join pg_class c on c.oid = t.tgrelid
141
+ join pg_namespace n on n.oid = c.relnamespace
142
+ where n.nspname = ${schema}
143
+ and not t.tgisinternal
144
+ and ${notExtensionOwned('pg_class', 'c.oid')}
145
+ `);
146
+
147
+ /**
148
+ * What exists in the schema and the dump cannot spell. One query, one closed list — a kind added
149
+ * here is a kind the dump admits it does not carry, and a kind rendered later leaves this list in
150
+ * the same diff.
151
+ *
152
+ * The last four are facts ABOUT an object the dump does render, and each was absent from both the
153
+ * dump and this list: `create statistics`, `force row level security` (a second flag beside
154
+ * `relrowsecurity`), a column whose `set storage` departs from its type's own, and a materialized
155
+ * view created `with no data` — which the dump's `create materialized view` would populate. A
156
+ * load-equals-replay check cannot see any of them, because both sides are this same reading.
157
+ *
158
+ * A trigger on a relation the dump does not create is the one kind NOT read here: which relations
159
+ * are rendered is the fold's answer, so `introspectCatalog` names those itself.
160
+ */
161
+ export const unrenderedRows = (
162
+ client: DbClient,
163
+ schema: string,
164
+ ): Promise<readonly UnrenderedRow[]> =>
165
+ client.query<UnrenderedRow>(sql`
166
+ select kind, name, table_name from (
167
+ select
168
+ case
169
+ when c.relkind = 'p' then 'partitioned table'
170
+ when c.relkind = 'f' then 'foreign table'
171
+ when c.relkind = 'c' then 'composite type'
172
+ else 'partition or inheritance child'
173
+ end as kind,
174
+ c.relname as name,
175
+ null::text as table_name,
176
+ c.relnamespace as namespace
177
+ from pg_class c
178
+ where (
179
+ c.relkind in ('p', 'f', 'c')
180
+ or (c.relkind = 'r' and (
181
+ c.relispartition or exists (select 1 from pg_inherits h where h.inhrelid = c.oid)
182
+ ))
183
+ )
184
+ and ${notExtensionOwned('pg_class', 'c.oid')}
185
+ union all
186
+ select 'range type', t.typname, null::text, t.typnamespace
187
+ from pg_type t
188
+ where t.typtype = 'r' and ${notExtensionOwned('pg_type', 't.oid')}
189
+ union all
190
+ select 'aggregate', p.proname, null::text, p.pronamespace
191
+ from pg_proc p
192
+ where p.prokind = 'a' and ${notExtensionOwned('pg_proc', 'p.oid')}
193
+ union all
194
+ select 'row security policy', pol.polname, c.relname, c.relnamespace
195
+ from pg_policy pol
196
+ join pg_class c on c.oid = pol.polrelid
197
+ union all
198
+ select 'row security', c.relname, c.relname, c.relnamespace
199
+ from pg_class c
200
+ where c.relrowsecurity
201
+ union all
202
+ select 'rule', r.rulename, c.relname, c.relnamespace
203
+ from pg_rewrite r
204
+ join pg_class c on c.oid = r.ev_class
205
+ where r.rulename <> '_RETURN'
206
+ union all
207
+ select 'extended statistics', s.stxname, c.relname, s.stxnamespace
208
+ from pg_statistic_ext s
209
+ join pg_class c on c.oid = s.stxrelid
210
+ union all
211
+ select 'forced row security', c.relname, c.relname, c.relnamespace
212
+ from pg_class c
213
+ where c.relforcerowsecurity
214
+ union all
215
+ select 'column storage', a.attname, c.relname, c.relnamespace
216
+ from pg_attribute a
217
+ join pg_class c on c.oid = a.attrelid
218
+ join pg_type t on t.oid = a.atttypid
219
+ where c.relkind = 'r' and a.attnum > 0 and not a.attisdropped
220
+ and a.attstorage <> t.typstorage
221
+ and ${notExtensionOwned('pg_class', 'c.oid')}
222
+ union all
223
+ select 'unpopulated materialized view', c.relname, null::text, c.relnamespace
224
+ from pg_class c
225
+ where c.relkind = 'm' and not c.relispopulated
226
+ ) objects
227
+ join pg_namespace n on n.oid = objects.namespace
228
+ where n.nspname = ${schema}
229
+ `);
@@ -0,0 +1,184 @@
1
+ // Single responsibility: the catalog queries for what a TABLE is — the relation, its columns, its
2
+ // constraints, its sequences and its indexes — as flat rows. Nothing here folds or sorts:
3
+ // `introspect-catalog.ts` does, in JS, because `order by` on a name follows the server's collation
4
+ // and two servers do not share one.
5
+
6
+ import type { DbClient } from './client';
7
+ import { raw, type SqlFragment, sql } from './sql';
8
+
9
+ /**
10
+ * "No extension owns this object" — `pg_depend`'s `deptype = 'e'` row, the rule `app-relation.ts`
11
+ * already holds for relations, asked of whichever catalog the object lives in. Both arguments are
12
+ * literals at every call site, never data.
13
+ */
14
+ export const notExtensionOwned = (
15
+ catalog: 'pg_class' | 'pg_type' | 'pg_proc',
16
+ oid: string,
17
+ ): SqlFragment =>
18
+ raw(
19
+ `not exists (select 1 from pg_depend e where e.classid = '${catalog}'::regclass ` +
20
+ `and e.objid = ${oid} and e.refclassid = 'pg_extension'::regclass and e.deptype = 'e')`,
21
+ );
22
+
23
+ export interface TableRow {
24
+ readonly name: string;
25
+ readonly persistence: string;
26
+ readonly replica_identity: string;
27
+ readonly replica_index: string | null;
28
+ readonly options: string | null;
29
+ }
30
+
31
+ export interface ColumnRow {
32
+ readonly table_name: string;
33
+ readonly name: string;
34
+ readonly position: number;
35
+ readonly type: string;
36
+ readonly not_null: boolean;
37
+ readonly identity: string;
38
+ readonly generated: string;
39
+ readonly expression: string | null;
40
+ readonly collation: string | null;
41
+ }
42
+
43
+ export interface ConstraintRow {
44
+ readonly table_name: string;
45
+ readonly name: string;
46
+ readonly type: string;
47
+ readonly definition: string;
48
+ }
49
+
50
+ export interface SequenceRow {
51
+ readonly name: string;
52
+ readonly data_type: string;
53
+ readonly seq_start: string;
54
+ readonly seq_increment: string;
55
+ readonly seq_min: string;
56
+ readonly seq_max: string;
57
+ readonly seq_cache: string;
58
+ readonly seq_cycle: boolean;
59
+ /** `a` — a `serial` column owns it; `i` — an identity column does; `null` — nothing does. */
60
+ readonly ownership: string | null;
61
+ readonly owner_table: string | null;
62
+ readonly owner_column: string | null;
63
+ }
64
+
65
+ export interface IndexRow {
66
+ readonly table_name: string;
67
+ readonly name: string;
68
+ readonly definition: string;
69
+ }
70
+
71
+ /**
72
+ * Plain tables only. A partition, an inheritance child and a partitioned parent are `relkind`
73
+ * `r`/`p` rows a `create table (…)` cannot rebuild, so they are named by `unrenderedRows`
74
+ * (`catalog-objects.ts`) instead of rendered wrong here.
75
+ */
76
+ export const tableRows = (client: DbClient, schema: string): Promise<readonly TableRow[]> =>
77
+ client.query<TableRow>(sql`
78
+ select
79
+ c.relname as name,
80
+ c.relpersistence as persistence,
81
+ c.relreplident as replica_identity,
82
+ (
83
+ select i.relname
84
+ from pg_index x
85
+ join pg_class i on i.oid = x.indexrelid
86
+ where x.indrelid = c.oid and x.indisreplident
87
+ ) as replica_index,
88
+ array_to_string(c.reloptions, ', ') as options
89
+ from pg_class c
90
+ join pg_namespace n on n.oid = c.relnamespace
91
+ where n.nspname = ${schema}
92
+ and c.relkind = 'r'
93
+ and not c.relispartition
94
+ and not exists (select 1 from pg_inherits h where h.inhrelid = c.oid)
95
+ and ${notExtensionOwned('pg_class', 'c.oid')}
96
+ `);
97
+
98
+ /** `format_type` and `pg_get_expr`, never `information_schema`: that view answers `ARRAY` and `USER-DEFINED`. */
99
+ export const columnRows = (client: DbClient, schema: string): Promise<readonly ColumnRow[]> =>
100
+ client.query<ColumnRow>(sql`
101
+ select
102
+ c.relname as table_name,
103
+ a.attname as name,
104
+ a.attnum as position,
105
+ format_type(a.atttypid, a.atttypmod) as type,
106
+ a.attnotnull as not_null,
107
+ a.attidentity as identity,
108
+ a.attgenerated as generated,
109
+ pg_get_expr(d.adbin, d.adrelid) as expression,
110
+ case when a.attcollation <> t.typcollation then co.collname end as collation
111
+ from pg_attribute a
112
+ join pg_class c on c.oid = a.attrelid
113
+ join pg_namespace n on n.oid = c.relnamespace
114
+ join pg_type t on t.oid = a.atttypid
115
+ left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
116
+ left join pg_collation co on co.oid = a.attcollation
117
+ where n.nspname = ${schema} and c.relkind = 'r' and a.attnum > 0 and not a.attisdropped
118
+ `);
119
+
120
+ /**
121
+ * Every constraint a table carries, foreign keys included — the fold routes those to their own
122
+ * directory. `contype = 'n'` (a NOT NULL recorded as a constraint, Postgres 18) is left out: the
123
+ * column's own `not null` already says it, and reading it would make the dump differ by server.
124
+ */
125
+ export const constraintRows = (
126
+ client: DbClient,
127
+ schema: string,
128
+ ): Promise<readonly ConstraintRow[]> =>
129
+ client.query<ConstraintRow>(sql`
130
+ select
131
+ c.relname as table_name,
132
+ k.conname as name,
133
+ k.contype as type,
134
+ pg_get_constraintdef(k.oid) as definition
135
+ from pg_constraint k
136
+ join pg_class c on c.oid = k.conrelid
137
+ join pg_namespace n on n.oid = c.relnamespace
138
+ where n.nspname = ${schema} and k.contype in ('p', 'u', 'c', 'x', 'f')
139
+ `);
140
+
141
+ /** `::text` on every bound: a `bigint` is a string on one driver and a number on the other. */
142
+ export const sequenceRows = (client: DbClient, schema: string): Promise<readonly SequenceRow[]> =>
143
+ client.query<SequenceRow>(sql`
144
+ select
145
+ c.relname as name,
146
+ format_type(s.seqtypid, null) as data_type,
147
+ s.seqstart::text as seq_start,
148
+ s.seqincrement::text as seq_increment,
149
+ s.seqmin::text as seq_min,
150
+ s.seqmax::text as seq_max,
151
+ s.seqcache::text as seq_cache,
152
+ s.seqcycle as seq_cycle,
153
+ d.deptype as ownership,
154
+ t.relname as owner_table,
155
+ a.attname as owner_column
156
+ from pg_sequence s
157
+ join pg_class c on c.oid = s.seqrelid
158
+ join pg_namespace n on n.oid = c.relnamespace
159
+ left join pg_depend d
160
+ on d.classid = 'pg_class'::regclass and d.objid = c.oid
161
+ and d.refclassid = 'pg_class'::regclass and d.deptype in ('a', 'i')
162
+ left join pg_class t on t.oid = d.refobjid
163
+ left join pg_attribute a on a.attrelid = d.refobjid and a.attnum = d.refobjsubid
164
+ where n.nspname = ${schema} and ${notExtensionOwned('pg_class', 'c.oid')}
165
+ `);
166
+
167
+ /** Indexes no constraint backs, on a table or a materialized view. */
168
+ export const indexRows = (client: DbClient, schema: string): Promise<readonly IndexRow[]> =>
169
+ client.query<IndexRow>(sql`
170
+ select
171
+ t.relname as table_name,
172
+ i.relname as name,
173
+ pg_get_indexdef(x.indexrelid) as definition
174
+ from pg_index x
175
+ join pg_class i on i.oid = x.indexrelid
176
+ join pg_class t on t.oid = x.indrelid
177
+ join pg_namespace n on n.oid = t.relnamespace
178
+ where n.nspname = ${schema}
179
+ and t.relkind in ('r', 'm')
180
+ and not exists (
181
+ select 1 from pg_constraint k
182
+ where k.conindid = x.indexrelid and k.contype in ('p', 'u', 'x')
183
+ )
184
+ `);
package/src/catalog.ts ADDED
@@ -0,0 +1,174 @@
1
+ // Single responsibility: the SHAPE of a whole schema as the catalog holds it — every object kind
2
+ // the schema dump renders, in Postgres' own spelling. Distinct from `SchemaDescription`
3
+ // (`introspect.ts`) on purpose: that one is the entity vocabulary a snapshot is diffed in, and a
4
+ // catalog spelling can never compare equal to a generated one. This one is only ever compared to
5
+ // itself, which is why it may carry `pg_get_*def` text verbatim.
6
+
7
+ export interface CatalogExtension {
8
+ readonly name: string;
9
+ }
10
+
11
+ export interface CatalogEnum {
12
+ readonly kind: 'enum';
13
+ readonly name: string;
14
+ /** In `enumsortorder` — the order `create type … as enum` must repeat. */
15
+ readonly labels: readonly string[];
16
+ }
17
+
18
+ export interface CatalogConstraint {
19
+ readonly name: string;
20
+ /** `pg_get_constraintdef` verbatim: `CHECK ((x > 0))`, `PRIMARY KEY (id)`, `FOREIGN KEY …`. */
21
+ readonly definition: string;
22
+ }
23
+
24
+ export interface CatalogDomain {
25
+ readonly kind: 'domain';
26
+ readonly name: string;
27
+ readonly baseType: string;
28
+ readonly notNull: boolean;
29
+ readonly default: string | null;
30
+ readonly checks: readonly CatalogConstraint[];
31
+ }
32
+
33
+ export type CatalogType = CatalogEnum | CatalogDomain;
34
+
35
+ /** Every option spelled, defaults included: a clause left out is a clause two servers may default apart. */
36
+ export interface CatalogSequence {
37
+ readonly name: string;
38
+ readonly dataType: string;
39
+ readonly start: string;
40
+ readonly increment: string;
41
+ readonly min: string;
42
+ readonly max: string;
43
+ readonly cache: string;
44
+ readonly cycle: boolean;
45
+ }
46
+
47
+ /** A sequence a `serial` column owns: created before its table, tied to the column after. */
48
+ export interface CatalogOwnedSequence extends CatalogSequence {
49
+ readonly column: string;
50
+ }
51
+
52
+ export interface CatalogColumn {
53
+ readonly name: string;
54
+ /** `format_type` — `character varying(120)`, `numeric(12,2)`, `timestamp with time zone`. */
55
+ readonly type: string;
56
+ readonly notNull: boolean;
57
+ /** `pg_get_expr` of the default; `null` when there is none or the column is generated. */
58
+ readonly default: string | null;
59
+ /**
60
+ * The generation expression and HOW it is kept, when `attgenerated` says the column has one:
61
+ * `s` is `stored`, `v` (Postgres 18) is `virtual` — computed on read, nothing on disk. Both
62
+ * halves, because a dump that spelled every one `stored` loaded a virtual column as a stored
63
+ * one and round-tripped "equal": both sides of that comparison were this reading.
64
+ */
65
+ readonly generated: {
66
+ readonly expression: string;
67
+ readonly storage: 'stored' | 'virtual';
68
+ } | null;
69
+ /** `always` / `by default`, with the identity sequence's own options. */
70
+ readonly identity: {
71
+ readonly mode: 'always' | 'by default';
72
+ readonly sequence: CatalogSequence;
73
+ } | null;
74
+ /** Only when it differs from the type's own collation. */
75
+ readonly collation: string | null;
76
+ }
77
+
78
+ export type CatalogReplicaIdentity =
79
+ | { readonly kind: 'default' | 'full' | 'nothing' }
80
+ | { readonly kind: 'index'; readonly index: string };
81
+
82
+ export interface CatalogTable {
83
+ readonly name: string;
84
+ readonly unlogged: boolean;
85
+ /** `reloptions` joined — `fillfactor=70` — or `null`. */
86
+ readonly options: string | null;
87
+ /** In `attnum` order. */
88
+ readonly columns: readonly CatalogColumn[];
89
+ /** Primary key, unique, check and exclusion constraints — never a foreign key. */
90
+ readonly constraints: readonly CatalogConstraint[];
91
+ readonly sequences: readonly CatalogOwnedSequence[];
92
+ readonly replicaIdentity: CatalogReplicaIdentity;
93
+ }
94
+
95
+ /** An index no constraint backs; a constraint's own index is created by the constraint. */
96
+ export interface CatalogIndex {
97
+ /** The relation it is on — a table, or a materialized view. */
98
+ readonly table: string;
99
+ readonly name: string;
100
+ /** `pg_get_indexdef` verbatim. */
101
+ readonly definition: string;
102
+ }
103
+
104
+ export interface CatalogForeignKey extends CatalogConstraint {
105
+ readonly table: string;
106
+ }
107
+
108
+ export interface CatalogView {
109
+ readonly name: string;
110
+ readonly materialized: boolean;
111
+ readonly options: string | null;
112
+ /** `pg_get_viewdef` verbatim, trailing `;` and all. */
113
+ readonly definition: string;
114
+ }
115
+
116
+ export interface CatalogFunction {
117
+ readonly name: string;
118
+ /** `pg_get_function_identity_arguments` — what tells two overloads apart. */
119
+ readonly arguments: string;
120
+ /** `pg_get_functiondef` verbatim. */
121
+ readonly definition: string;
122
+ }
123
+
124
+ export interface CatalogTrigger {
125
+ readonly table: string;
126
+ readonly name: string;
127
+ /** `pg_get_triggerdef` verbatim. */
128
+ readonly definition: string;
129
+ /** `tgenabled`: `O` origin (the default), `D` disabled, `R` replica, `A` always. */
130
+ readonly enabled: string;
131
+ }
132
+
133
+ /**
134
+ * An object in the schema that the dump has no statement for. Named rather than dropped: a dump
135
+ * that silently omitted a row-security policy would load into a database that is not the one the
136
+ * migrations build, and the load-equals-replay check could not see it, because both sides of that
137
+ * comparison would be blind in the same place.
138
+ */
139
+ export interface CatalogUnrendered {
140
+ readonly kind: string;
141
+ readonly name: string;
142
+ /** The table it hangs off, when it has one. */
143
+ readonly table: string | null;
144
+ }
145
+
146
+ export interface CatalogDescription {
147
+ readonly schema: string;
148
+ readonly extensions: readonly CatalogExtension[];
149
+ readonly types: readonly CatalogType[];
150
+ /** Sequences no column owns. */
151
+ readonly sequences: readonly CatalogSequence[];
152
+ readonly tables: readonly CatalogTable[];
153
+ readonly indexes: readonly CatalogIndex[];
154
+ readonly foreignKeys: readonly CatalogForeignKey[];
155
+ readonly views: readonly CatalogView[];
156
+ readonly functions: readonly CatalogFunction[];
157
+ readonly triggers: readonly CatalogTrigger[];
158
+ readonly unrendered: readonly CatalogUnrendered[];
159
+ }
160
+
161
+ /** Every list empty — the base a test or a caller spreads its one interesting object over. */
162
+ export const emptyCatalog = (schema = 'public'): CatalogDescription => ({
163
+ schema,
164
+ extensions: [],
165
+ types: [],
166
+ sequences: [],
167
+ tables: [],
168
+ indexes: [],
169
+ foreignKeys: [],
170
+ views: [],
171
+ functions: [],
172
+ triggers: [],
173
+ unrendered: [],
174
+ });