@ultimat3/db 22.14.0 → 23.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 +39 -1
- package/README.md +81 -0
- package/package.json +5 -3
- package/src/bun-sql.ts +12 -0
- package/src/catalog-fold.ts +113 -0
- package/src/catalog-objects.ts +200 -0
- package/src/catalog-relations.ts +184 -0
- package/src/catalog.ts +166 -0
- package/src/client.ts +57 -9
- package/src/drift-findings.ts +4 -1
- package/src/dump-drift.ts +142 -0
- package/src/errors.ts +2 -0
- package/src/index.ts +4 -0
- package/src/introspect-catalog.ts +154 -0
- package/src/listen.ts +62 -0
- package/src/object-drift.ts +105 -0
- 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 +133 -10
- package/src/pool-gauge.ts +70 -0
- package/src/schema-dump-entry.ts +39 -0
- package/src/schema-dump-table.ts +72 -0
- package/src/schema-dump.ts +192 -0
- package/src/schema-load.ts +119 -0
- package/src/sqlstate.ts +3 -0
|
@@ -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,166 @@
|
|
|
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
|
+
/** The stored generation expression, when `attgenerated` says the column has one. */
|
|
60
|
+
readonly generated: string | null;
|
|
61
|
+
/** `always` / `by default`, with the identity sequence's own options. */
|
|
62
|
+
readonly identity: {
|
|
63
|
+
readonly mode: 'always' | 'by default';
|
|
64
|
+
readonly sequence: CatalogSequence;
|
|
65
|
+
} | null;
|
|
66
|
+
/** Only when it differs from the type's own collation. */
|
|
67
|
+
readonly collation: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type CatalogReplicaIdentity =
|
|
71
|
+
| { readonly kind: 'default' | 'full' | 'nothing' }
|
|
72
|
+
| { readonly kind: 'index'; readonly index: string };
|
|
73
|
+
|
|
74
|
+
export interface CatalogTable {
|
|
75
|
+
readonly name: string;
|
|
76
|
+
readonly unlogged: boolean;
|
|
77
|
+
/** `reloptions` joined — `fillfactor=70` — or `null`. */
|
|
78
|
+
readonly options: string | null;
|
|
79
|
+
/** In `attnum` order. */
|
|
80
|
+
readonly columns: readonly CatalogColumn[];
|
|
81
|
+
/** Primary key, unique, check and exclusion constraints — never a foreign key. */
|
|
82
|
+
readonly constraints: readonly CatalogConstraint[];
|
|
83
|
+
readonly sequences: readonly CatalogOwnedSequence[];
|
|
84
|
+
readonly replicaIdentity: CatalogReplicaIdentity;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** An index no constraint backs; a constraint's own index is created by the constraint. */
|
|
88
|
+
export interface CatalogIndex {
|
|
89
|
+
/** The relation it is on — a table, or a materialized view. */
|
|
90
|
+
readonly table: string;
|
|
91
|
+
readonly name: string;
|
|
92
|
+
/** `pg_get_indexdef` verbatim. */
|
|
93
|
+
readonly definition: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CatalogForeignKey extends CatalogConstraint {
|
|
97
|
+
readonly table: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CatalogView {
|
|
101
|
+
readonly name: string;
|
|
102
|
+
readonly materialized: boolean;
|
|
103
|
+
readonly options: string | null;
|
|
104
|
+
/** `pg_get_viewdef` verbatim, trailing `;` and all. */
|
|
105
|
+
readonly definition: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface CatalogFunction {
|
|
109
|
+
readonly name: string;
|
|
110
|
+
/** `pg_get_function_identity_arguments` — what tells two overloads apart. */
|
|
111
|
+
readonly arguments: string;
|
|
112
|
+
/** `pg_get_functiondef` verbatim. */
|
|
113
|
+
readonly definition: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface CatalogTrigger {
|
|
117
|
+
readonly table: string;
|
|
118
|
+
readonly name: string;
|
|
119
|
+
/** `pg_get_triggerdef` verbatim. */
|
|
120
|
+
readonly definition: string;
|
|
121
|
+
/** `tgenabled`: `O` origin (the default), `D` disabled, `R` replica, `A` always. */
|
|
122
|
+
readonly enabled: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* An object in the schema that the dump has no statement for. Named rather than dropped: a dump
|
|
127
|
+
* that silently omitted a row-security policy would load into a database that is not the one the
|
|
128
|
+
* migrations build, and the load-equals-replay check could not see it, because both sides of that
|
|
129
|
+
* comparison would be blind in the same place.
|
|
130
|
+
*/
|
|
131
|
+
export interface CatalogUnrendered {
|
|
132
|
+
readonly kind: string;
|
|
133
|
+
readonly name: string;
|
|
134
|
+
/** The table it hangs off, when it has one. */
|
|
135
|
+
readonly table: string | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface CatalogDescription {
|
|
139
|
+
readonly schema: string;
|
|
140
|
+
readonly extensions: readonly CatalogExtension[];
|
|
141
|
+
readonly types: readonly CatalogType[];
|
|
142
|
+
/** Sequences no column owns. */
|
|
143
|
+
readonly sequences: readonly CatalogSequence[];
|
|
144
|
+
readonly tables: readonly CatalogTable[];
|
|
145
|
+
readonly indexes: readonly CatalogIndex[];
|
|
146
|
+
readonly foreignKeys: readonly CatalogForeignKey[];
|
|
147
|
+
readonly views: readonly CatalogView[];
|
|
148
|
+
readonly functions: readonly CatalogFunction[];
|
|
149
|
+
readonly triggers: readonly CatalogTrigger[];
|
|
150
|
+
readonly unrendered: readonly CatalogUnrendered[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Every list empty — the base a test or a caller spreads its one interesting object over. */
|
|
154
|
+
export const emptyCatalog = (schema = 'public'): CatalogDescription => ({
|
|
155
|
+
schema,
|
|
156
|
+
extensions: [],
|
|
157
|
+
types: [],
|
|
158
|
+
sequences: [],
|
|
159
|
+
tables: [],
|
|
160
|
+
indexes: [],
|
|
161
|
+
foreignKeys: [],
|
|
162
|
+
views: [],
|
|
163
|
+
functions: [],
|
|
164
|
+
triggers: [],
|
|
165
|
+
unrendered: [],
|
|
166
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `Bun.SQL` slice in `bun-sql.ts` and the observed statement funnel in `statement-funnel.ts`, so
|
|
5
5
|
// importing this module never opens a socket.
|
|
6
6
|
|
|
7
|
-
import { type Role, resolveRole } from '@ultimat3/core';
|
|
7
|
+
import { logger, type Role, renderThrowable, resolveRole } from '@ultimat3/core';
|
|
8
8
|
import {
|
|
9
9
|
type BunSqlDriver,
|
|
10
10
|
type BunSqlReserved,
|
|
@@ -17,6 +17,13 @@ import { connectionUrl } from './connection-url';
|
|
|
17
17
|
// module evaluation, and both sides are `function` declarations, so hoisting covers the TDZ.
|
|
18
18
|
import { defaultClient } from './default-client';
|
|
19
19
|
import { DbError, drainTimeout, driverError } from './errors';
|
|
20
|
+
import {
|
|
21
|
+
assertListenChannel,
|
|
22
|
+
type DbSubscription,
|
|
23
|
+
type ListeningClient,
|
|
24
|
+
listenUnsupported,
|
|
25
|
+
} from './listen';
|
|
26
|
+
import { type PoolDemand, trackPool } from './pool-gauge';
|
|
20
27
|
import { assertPoolProfile, type PoolProfile, poolProfileFor } from './pool-profile';
|
|
21
28
|
import { reserveWithin } from './pool-reserve';
|
|
22
29
|
import { type SqlFragment, sql } from './sql';
|
|
@@ -55,7 +62,7 @@ export interface PostgresClientOptions {
|
|
|
55
62
|
readonly applicationName?: string | undefined;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
export interface PostgresClient extends ReservableClient {
|
|
65
|
+
export interface PostgresClient extends ReservableClient, ListeningClient {
|
|
59
66
|
readonly profile: PoolProfile;
|
|
60
67
|
ping(): Promise<void>;
|
|
61
68
|
close(): Promise<void>;
|
|
@@ -68,18 +75,28 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
68
75
|
...poolProfileFor(role),
|
|
69
76
|
...(options.profile ?? {}),
|
|
70
77
|
});
|
|
71
|
-
|
|
78
|
+
// The driver and what `db_pool_in_use` / `db_pool_waiting` are derived from (`pool-gauge.ts`),
|
|
79
|
+
// as ONE value: a pool still draining after `close()` settles its own work against its own
|
|
80
|
+
// counter, never against the pool that replaced it. Built only once the driver exists, so a
|
|
81
|
+
// connection string that cannot be built registers no pool in `db_pool_max`.
|
|
82
|
+
let driver: { readonly pool: BunSqlDriver; readonly demand: PoolDemand } | undefined;
|
|
72
83
|
|
|
73
|
-
function connect(): BunSqlDriver {
|
|
84
|
+
function connect(): { readonly pool: BunSqlDriver; readonly demand: PoolDemand } {
|
|
74
85
|
if (driver !== undefined) return driver;
|
|
75
86
|
const url = connectionUrl(options, profile);
|
|
76
87
|
const Factory = bunSqlFactory();
|
|
77
|
-
driver = new Factory(url, bunSqlPoolOptions(profile));
|
|
88
|
+
driver = { pool: new Factory(url, bunSqlPoolOptions(profile)), demand: trackPool(profile.max) };
|
|
78
89
|
return driver;
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
async function run(fragment: SqlFragment): Promise<unknown> {
|
|
82
|
-
|
|
93
|
+
const { pool, demand } = connect();
|
|
94
|
+
demand.enter();
|
|
95
|
+
try {
|
|
96
|
+
return await runOn(pool, fragment);
|
|
97
|
+
} finally {
|
|
98
|
+
demand.leave();
|
|
99
|
+
}
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
const client: PostgresClient = {
|
|
@@ -99,11 +116,14 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
99
116
|
// (`ERR_POSTGRES_UNSAFE_TRANSACTION`), and a BEGIN that landed on a different connection
|
|
100
117
|
// than the statement after it would not be a transaction at all — which is exactly what
|
|
101
118
|
// `withTransaction` and `readOnlyQuery` depend on being true.
|
|
102
|
-
const pool = connect();
|
|
119
|
+
const { pool, demand } = connect();
|
|
103
120
|
let reserved: BunSqlReserved;
|
|
121
|
+
// Counted from the ASK: a pin queued behind a full pool is exactly what `waiting` reports.
|
|
122
|
+
demand.enter();
|
|
104
123
|
try {
|
|
105
124
|
reserved = await reserveWithin(pool, profile);
|
|
106
125
|
} catch (error) {
|
|
126
|
+
demand.leave();
|
|
107
127
|
// Acquiring the pin is the one step that runs outside `runOn`, so an exhausted or
|
|
108
128
|
// unreachable pool would escape as an untyped driver error — and `readOnlyQuery` reaches
|
|
109
129
|
// this line before its first statement, which is how MCP ends up returning something
|
|
@@ -126,6 +146,7 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
126
146
|
const release = (): void => {
|
|
127
147
|
if (!held) return;
|
|
128
148
|
held = false;
|
|
149
|
+
demand.leave();
|
|
129
150
|
// Total by construction — `releaseReserved` owns the reason (`bun-sql.ts`).
|
|
130
151
|
releaseReserved(reserved);
|
|
131
152
|
};
|
|
@@ -137,6 +158,30 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
137
158
|
[Symbol.dispose]: release,
|
|
138
159
|
};
|
|
139
160
|
},
|
|
161
|
+
async listen(channel, onNotify, onListening): Promise<DbSubscription> {
|
|
162
|
+
assertListenChannel(channel);
|
|
163
|
+
const { pool } = connect();
|
|
164
|
+
if (pool.listen === undefined) throw listenUnsupported('this Bun.SQL');
|
|
165
|
+
let held: { unlisten(): Promise<void> };
|
|
166
|
+
try {
|
|
167
|
+
// The driver's own session, never a pin out of the pool: a reserved connection holds the
|
|
168
|
+
// LISTEN and surfaces no notification, and it would cost the pool a slot for good.
|
|
169
|
+
held = await pool.listen(channel, onNotify, onListening);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw driverError(`LISTEN ${channel}`, error);
|
|
172
|
+
}
|
|
173
|
+
let ended: Promise<void> | undefined;
|
|
174
|
+
return {
|
|
175
|
+
unlisten: () => {
|
|
176
|
+
// Best-effort, the rule `releaseReserved` states: the session this would end may be
|
|
177
|
+
// gone with the pool already, and that is the outcome asked for.
|
|
178
|
+
ended ??= held.unlisten().catch((error: unknown) => {
|
|
179
|
+
logger.debug('db.unlisten_failed', { error: renderThrowable(error) });
|
|
180
|
+
});
|
|
181
|
+
return ended;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
},
|
|
140
185
|
async ping(): Promise<void> {
|
|
141
186
|
await client.query(sql`select 1`);
|
|
142
187
|
},
|
|
@@ -146,9 +191,12 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
146
191
|
// after it would fail for a reason no caller can see. Clearing first also means a
|
|
147
192
|
// `connect()` racing the await opens a fresh pool instead of joining the one draining. The
|
|
148
193
|
// rejection still reaches the caller — a shutdown that could not drain wants to know.
|
|
149
|
-
const
|
|
194
|
+
const closing = driver;
|
|
150
195
|
driver = undefined;
|
|
151
|
-
if (
|
|
196
|
+
if (closing === undefined) return;
|
|
197
|
+
// Only THIS pool's counter leaves the totals; its in-flight work settles against it.
|
|
198
|
+
closing.demand.close();
|
|
199
|
+
const { pool } = closing;
|
|
152
200
|
// BOUNDED, `As of 2026-08-27`, and through the driver's OWN option rather than a race here.
|
|
153
201
|
// This was a bare `await pool.close()`, and `Bun.SQL`'s `end()` waits on an outstanding
|
|
154
202
|
// reserved connection without ever giving up — measured three runs per case on Bun 1.3.14
|
package/src/drift-findings.ts
CHANGED
|
@@ -27,7 +27,10 @@ export type DriftKind =
|
|
|
27
27
|
| 'changed-index'
|
|
28
28
|
| 'missing-check'
|
|
29
29
|
| 'missing-foreign-key'
|
|
30
|
-
| 'changed-foreign-key'
|
|
30
|
+
| 'changed-foreign-key'
|
|
31
|
+
// Constructed in `object-drift.ts`: a trigger, function, view, type or sequence in the live
|
|
32
|
+
// database that replaying the migrations does not create.
|
|
33
|
+
| 'unexpected-object';
|
|
31
34
|
|
|
32
35
|
export interface DriftDifference {
|
|
33
36
|
readonly kind: DriftKind;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Single responsibility: `X_SCHEMA_DUMP_DRIFT` — what it means for a committed schema dump to
|
|
2
|
+
// disagree with the one the migrations produce, and how each disagreement reads. Pure: files in,
|
|
3
|
+
// differences out. Which directory the dump lives in is the caller's fact; paths here are relative.
|
|
4
|
+
|
|
5
|
+
import { DbError } from './errors';
|
|
6
|
+
import type { SchemaDumpFile } from './schema-dump';
|
|
7
|
+
|
|
8
|
+
export type SchemaDumpDifferenceKind =
|
|
9
|
+
| 'missing-file'
|
|
10
|
+
| 'changed-file'
|
|
11
|
+
| 'unexpected-file'
|
|
12
|
+
| 'unloadable'
|
|
13
|
+
| 'reload-differs';
|
|
14
|
+
|
|
15
|
+
export interface SchemaDumpDifference {
|
|
16
|
+
readonly kind: SchemaDumpDifferenceKind;
|
|
17
|
+
/** Relative to the dump directory. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly cause: string;
|
|
20
|
+
readonly fix: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Regenerating is the whole repair for a dump that is merely behind. */
|
|
24
|
+
export const SCHEMA_DUMP_FIX = 'x db gen';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* For a dump that regenerating cannot repair. It still starts with the command, because the first
|
|
28
|
+
* thing to rule out is a dump that is merely stale — and it says what the second failure means,
|
|
29
|
+
* since nothing the app's author can run fixes a statement the dump renders wrong.
|
|
30
|
+
*/
|
|
31
|
+
const roundTripFix = (path: string): string =>
|
|
32
|
+
`x db gen # and when the regenerated dump is refused the same way, ${path} holds an object the dump cannot round-trip: report it with that file and the migration that creates it`;
|
|
33
|
+
|
|
34
|
+
const byPath = (a: SchemaDumpDifference, b: SchemaDumpDifference): number =>
|
|
35
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The committed files against the rendered ones, byte for byte, in both directions: a file the
|
|
39
|
+
* migrations produce and nobody committed, one whose bytes differ, and one on disk that nothing
|
|
40
|
+
* renders. The last matters as much as the first — a stray `04_tables/old.sql` is a table an
|
|
41
|
+
* agent reading the directory believes exists.
|
|
42
|
+
*/
|
|
43
|
+
export function compareSchemaDump(
|
|
44
|
+
committed: readonly SchemaDumpFile[],
|
|
45
|
+
rendered: readonly SchemaDumpFile[],
|
|
46
|
+
): readonly SchemaDumpDifference[] {
|
|
47
|
+
const onDisk = new Map(committed.map((file) => [file.path, file.content]));
|
|
48
|
+
const produced = new Set(rendered.map((file) => file.path));
|
|
49
|
+
const differences: SchemaDumpDifference[] = [];
|
|
50
|
+
for (const file of rendered) {
|
|
51
|
+
const held = onDisk.get(file.path);
|
|
52
|
+
if (held === file.content) continue;
|
|
53
|
+
differences.push(
|
|
54
|
+
held === undefined
|
|
55
|
+
? {
|
|
56
|
+
kind: 'missing-file',
|
|
57
|
+
path: file.path,
|
|
58
|
+
cause: `schema dump file ${file.path} is what the migrations produce and is not committed`,
|
|
59
|
+
fix: SCHEMA_DUMP_FIX,
|
|
60
|
+
}
|
|
61
|
+
: {
|
|
62
|
+
kind: 'changed-file',
|
|
63
|
+
path: file.path,
|
|
64
|
+
cause: `schema dump file ${file.path} differs from what the migrations produce`,
|
|
65
|
+
fix: SCHEMA_DUMP_FIX,
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
for (const file of committed) {
|
|
70
|
+
if (produced.has(file.path)) continue;
|
|
71
|
+
differences.push({
|
|
72
|
+
kind: 'unexpected-file',
|
|
73
|
+
path: file.path,
|
|
74
|
+
cause: `schema dump file ${file.path} describes nothing the migrations produce`,
|
|
75
|
+
fix: SCHEMA_DUMP_FIX,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return differences.sort(byPath);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load equals replay, as a comparison: the dump of the migrated database against the dump of a
|
|
83
|
+
* database built by loading that dump. Any difference means the files are not a cache of the
|
|
84
|
+
* migrations but a second, lossy source. One finding, naming the first file that came back
|
|
85
|
+
* different — the rest follow from it.
|
|
86
|
+
*
|
|
87
|
+
* Asked only of a dump that claims to be whole. One that names objects in `unrendered.sql` has
|
|
88
|
+
* already said it is not, and its caller does not ask.
|
|
89
|
+
*/
|
|
90
|
+
export function reloadDifferences(
|
|
91
|
+
replayed: readonly SchemaDumpFile[],
|
|
92
|
+
reloaded: readonly SchemaDumpFile[],
|
|
93
|
+
): readonly SchemaDumpDifference[] {
|
|
94
|
+
const first = compareSchemaDump(reloaded, replayed)[0];
|
|
95
|
+
if (first === undefined) return [];
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
kind: 'reload-differs',
|
|
99
|
+
path: first.path,
|
|
100
|
+
cause: `a database loaded from the schema dump is not the one the migrations build: ${first.path} comes back different`,
|
|
101
|
+
fix: roundTripFix(first.path),
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A dump file the database refused, with what the database said about it. */
|
|
107
|
+
export const unloadableDump = (path: string, detail: string): SchemaDumpDifference => ({
|
|
108
|
+
kind: 'unloadable',
|
|
109
|
+
path,
|
|
110
|
+
cause: `schema dump file ${path} does not load: ${detail}`,
|
|
111
|
+
fix: roundTripFix(path),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const schemaDumpDrift = (difference: SchemaDumpDifference): DbError =>
|
|
115
|
+
new DbError({
|
|
116
|
+
code: 'X_SCHEMA_DUMP_DRIFT',
|
|
117
|
+
cause: difference.cause,
|
|
118
|
+
fix: difference.fix,
|
|
119
|
+
meta: { kind: difference.kind, path: difference.path },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const DIFFERENCE_KINDS: readonly string[] = [
|
|
123
|
+
'missing-file',
|
|
124
|
+
'changed-file',
|
|
125
|
+
'unexpected-file',
|
|
126
|
+
'unloadable',
|
|
127
|
+
'reload-differs',
|
|
128
|
+
] satisfies readonly SchemaDumpDifferenceKind[];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The difference a thrown `X_SCHEMA_DUMP_DRIFT` was built from, or `undefined` for anything else.
|
|
132
|
+
* `loadSchemaDump` throws; a caller that reports findings wants the value back, and reading it
|
|
133
|
+
* off the error here keeps `meta`'s shape a fact of this file alone.
|
|
134
|
+
*/
|
|
135
|
+
export function schemaDumpDifferenceOf(error: unknown): SchemaDumpDifference | undefined {
|
|
136
|
+
if (!(error instanceof DbError) || error.code !== 'X_SCHEMA_DUMP_DRIFT') return undefined;
|
|
137
|
+
const kind = error.meta?.['kind'];
|
|
138
|
+
const path = error.meta?.['path'];
|
|
139
|
+
if (typeof kind !== 'string' || typeof path !== 'string') return undefined;
|
|
140
|
+
if (!DIFFERENCE_KINDS.includes(kind)) return undefined;
|
|
141
|
+
return { kind: kind as SchemaDumpDifferenceKind, path, cause: error.cause, fix: error.fix };
|
|
142
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -37,6 +37,7 @@ export const DB_OWNED_ERROR_CODES = [
|
|
|
37
37
|
'X_MIGRATE_CONCURRENT',
|
|
38
38
|
'X_SQL_UNSAFE',
|
|
39
39
|
'X_BRANCH_EXISTS',
|
|
40
|
+
'X_SCHEMA_DUMP_DRIFT',
|
|
40
41
|
] as const;
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -81,6 +82,7 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
|
|
|
81
82
|
X_MIGRATION_VIEW_DEPENDS: 'a view is compiled against a column this migration retypes',
|
|
82
83
|
X_SQL_UNSAFE: 'SQL was built by string interpolation',
|
|
83
84
|
X_BRANCH_EXISTS: 'that branch database already exists',
|
|
85
|
+
X_SCHEMA_DUMP_DRIFT: 'the committed schema dump is not what the migrations produce',
|
|
84
86
|
};
|
|
85
87
|
|
|
86
88
|
// Registered unconditionally, in one call, so a second package claiming one of db's codes fails
|
package/src/index.ts
CHANGED
|
@@ -112,6 +112,8 @@ export {
|
|
|
112
112
|
uniqueColumns,
|
|
113
113
|
} from './invariant-ddl';
|
|
114
114
|
export { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
|
|
115
|
+
export type { DbSubscription, ListeningClient } from './listen';
|
|
116
|
+
export { canListen } from './listen';
|
|
115
117
|
export type {
|
|
116
118
|
AppliedMigration,
|
|
117
119
|
LedgerRow,
|
|
@@ -166,6 +168,8 @@ export {
|
|
|
166
168
|
} from './pglite';
|
|
167
169
|
export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
|
|
168
170
|
export { branchPglite, pgliteBranchDir } from './pglite-branch';
|
|
171
|
+
export type { LinkedExtensions, PgliteExtensionLoader } from './pglite-extensions';
|
|
172
|
+
export { linkPgliteExtensions } from './pglite-extensions';
|
|
169
173
|
export type { PoolProfile } from './pool-profile';
|
|
170
174
|
export { POOL_MAX_ENV, POOL_PROFILES, poolProfileFor } from './pool-profile';
|
|
171
175
|
export type { ReadOnlyQueryOptions, ReadOnlyQueryResult } from './readonly-query';
|