@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.
- package/CLAUDE.md +91 -56
- package/README.md +121 -6
- package/package.json +5 -3
- package/src/array-parameter.ts +38 -1
- package/src/bound-parameters.ts +22 -3
- package/src/bun-sql.ts +12 -0
- package/src/catalog-fold.ts +116 -0
- package/src/catalog-objects.ts +229 -0
- package/src/catalog-relations.ts +184 -0
- package/src/catalog.ts +174 -0
- package/src/client.ts +57 -9
- package/src/commit-tag.ts +21 -0
- package/src/dependent-view.ts +6 -4
- package/src/drift-errors.ts +3 -3
- package/src/drift-findings.ts +213 -60
- package/src/drift.ts +19 -13
- package/src/dump-drift.ts +142 -0
- package/src/errors.ts +8 -7
- package/src/foreign-key.ts +0 -34
- package/src/generate.ts +5 -0
- package/src/index.ts +9 -3
- package/src/introspect-catalog.ts +171 -0
- package/src/introspect.ts +45 -8
- package/src/listen.ts +62 -0
- package/src/migrate.ts +3 -3
- package/src/object-drift.ts +162 -0
- package/src/pglite-branch.ts +6 -6
- package/src/pglite-extensions.ts +112 -0
- package/src/pglite-package.ts +11 -0
- package/src/pglite-snapshot.ts +121 -0
- package/src/pglite.ts +146 -11
- package/src/pool-gauge.ts +70 -0
- package/src/primary-key.ts +180 -0
- package/src/schema-dump-entry.ts +39 -0
- package/src/schema-dump-table.ts +75 -0
- package/src/schema-dump.ts +192 -0
- package/src/schema-load.ts +119 -0
- package/src/sibling-turn.ts +49 -0
- package/src/sqlstate.ts +33 -10
- package/src/statement-funnel.ts +16 -5
- package/src/transaction-errors.ts +66 -0
- package/src/transaction-options.ts +122 -0
- package/src/transaction.ts +131 -121
|
@@ -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
|
+
});
|