Skip to content

Commit 18f0e15

Browse files
committed
docs(pglite): design for pluggable driver (keep pglite deps out of core)
1 parent 015de9b commit 18f0e15

1 file changed

Lines changed: 178 additions & 0 deletions

File tree

poc/pglite/DESIGN.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# PGlite as a pluggable driver — design proposal
2+
3+
Goal: support PGlite across pgpm + the test frameworks **without putting any
4+
`@electric-sql/pglite*` dependency into `@pgpmjs/core`** (or `pg-cache` /
5+
`pgsql-client`). PGlite should be a **plugin**, mirroring how `drizzle-orm-test`
6+
and `supabase-test` already extend the stack without forking it.
7+
8+
## TL;DR recommendation
9+
10+
Introduce a tiny **driver seam** at the one place everything funnels through —
11+
the connection factory — and ship the PGlite implementation as a **separate
12+
package** that owns all the PGlite deps:
13+
14+
- **`pg-cache`** gains a driver registry: `registerPgDriver(factory)`. Default
15+
driver = today's `new pg.Pool(...)`. Core keeps depending only on `pg`.
16+
- **`@pgpmjs/pglite-adapter`** (new, standalone) implements a driver that backs
17+
the pool/client interface with an **in-process PGlite instance** (no socket, no
18+
TCP). This is the *only* package that imports `@electric-sql/pglite*`.
19+
- **`pglite-test`** (new) is a drop-in `getConnections()` wrapper — exactly like
20+
`drizzle-orm-test` — that swaps the create-database-per-test model for a
21+
fresh-PGlite-instance-per-test model.
22+
- One small **correctness fix** in `@pgpmjs/core` (transaction-scoped reads must
23+
use the transaction client) makes single-connection backends work; it's a no-op
24+
for real `pg`.
25+
26+
Nothing PGlite-specific lands in core. The PoC in this folder already proves the
27+
engine works against PGlite; this is how to productionize it cleanly.
28+
29+
## The precedent already in the repo
30+
31+
The stack already has two "alternative backend/behavior" packages that plug in
32+
without touching the base:
33+
34+
- **`SeedAdapter`** (`postgres/pgsql-test/src/seed/types.ts`) — a one-method
35+
interface `{ seed(ctx): Promise<void> }`, composed and injected into
36+
`getConnections(cn, seedAdapters)`. `seed.pgpm()`, `seed.sqlfile()`, `seed.fn()`.
37+
- **`drizzle-orm-test` / `supabase-test`** — thin packages that re-export a
38+
`getConnections()` that calls `pgsql-test`'s and then adapts the result
39+
(e.g. `proxyClientQuery(db)` monkey-patches `db.client.query`). They add a whole
40+
ORM integration **without forking pgsql-test**.
41+
42+
PGlite should follow the same shape: a driver interface in the low-level packages,
43+
an adapter package that implements it, and a `*-test` wrapper for the test layer.
44+
45+
## Where the stack hard-codes `pg` (the seams)
46+
47+
1. **`pg-cache` — the connection factory (the key seam).**
48+
`getPgPool(config)` does `new pg.Pool({ connectionString, ...poolConfig })`,
49+
cached by database name (`postgres/pg-cache/src/pg.ts:41`). *Everything*
50+
upstream gets its pool here.
51+
52+
2. **`@pgpmjs/core` `PgpmMigrate`** (`pgpm/core/src/migrate/client.ts`).
53+
`import { Pool } from 'pg'`, `this.pool = getPgPool(config)`; all work routes
54+
through `this.pool.query(...)` and `withTransaction(pool, ...)` which calls
55+
`pool.connect()` (`migrate/utils/transaction.ts`). The engine only ever needs
56+
`pool.query()` and `pool.connect() → { query, release }`.
57+
58+
3. **`pgsql-client`**`PgClient` does `new Client({...})` from `pg`
59+
(`src/client.ts:24`); `DbAdmin` shells out to `createdb` / `dropdb` / `psql`
60+
via `execSync` and installs extensions with
61+
`psql -c 'CREATE EXTENSION ...'` (`src/admin.ts`). This subprocess layer is the
62+
part with no PGlite equivalent.
63+
64+
4. **`pgsql-test`**`getConnections()` (`src/connect.ts`) orchestrates
65+
create-role → createdb/template → `installExtensions` → seed adapters →
66+
per-test transaction rollback. This is the drop-in seam the `*-test` packages
67+
wrap.
68+
69+
## Proposed design
70+
71+
### 1. A minimal driver interface (lives in `pg-cache`, deps: only `pg` types)
72+
73+
Core needs a tiny slice of the `pg.Pool` surface. Define it and a registry:
74+
75+
```ts
76+
// pg-cache/src/driver.ts (no pglite import anywhere here)
77+
export interface QueryablePool {
78+
query(text: string, values?: any[]): Promise<QueryResult>;
79+
connect(): Promise<QueryableClient>; // dedicated connection for a txn
80+
end(): Promise<void>;
81+
}
82+
export interface QueryableClient {
83+
query(text: string, values?: any[]): Promise<QueryResult>;
84+
release(): void;
85+
}
86+
export type PgDriver = (config: PgConfig & { pool?: PgPoolConfig }) => QueryablePool;
87+
88+
let activeDriver: PgDriver | undefined; // default = pg
89+
export const registerPgDriver = (d: PgDriver | undefined) => { activeDriver = d; };
90+
```
91+
92+
`getPgPool` picks the driver instead of hard-constructing a `pg.Pool`:
93+
94+
```ts
95+
export const getPgPool = (cfg) => {
96+
if (pgCache.has(key)) return pgCache.get(key);
97+
const pool = (activeDriver ?? defaultPgDriver)(cfg); // defaultPgDriver = new pg.Pool
98+
pgCache.set(key, pool);
99+
return pool;
100+
};
101+
```
102+
103+
`pg.Pool` already satisfies `QueryablePool`, so the default path is unchanged and
104+
`@pgpmjs/core` still only imports `pg`.
105+
106+
### 2. `@pgpmjs/pglite-adapter` (new, standalone — owns ALL pglite deps)
107+
108+
Implements a `PgDriver` backed by an in-process PGlite instance using PGlite's own
109+
JS API (`db.query`, `db.transaction`) — **no `pglite-socket`, no TCP**:
110+
111+
```ts
112+
import { PGlite } from '@electric-sql/pglite';
113+
export function pgliteDriver(opts?: { dataDir?; extensions? }): { register(): void } {
114+
const db = new PGlite({ dataDir: opts?.dataDir, extensions: opts?.extensions });
115+
const pool: QueryablePool = {
116+
query: (t, v) => db.query(t, v),
117+
connect: async () => {
118+
// a "client" that pins to a single PGlite transaction (see fix #4)
119+
return makeTxBoundClient(db);
120+
},
121+
end: () => db.close(),
122+
};
123+
return { register: () => registerPgDriver(() => pool) };
124+
}
125+
```
126+
127+
This package is the *only* one that lists `@electric-sql/pglite` (and optional
128+
extension packages like `@electric-sql/pglite-pgvector`) in its `dependencies`.
129+
130+
### 3. `pglite-test` (new — the `getConnections` wrapper, like drizzle-orm-test)
131+
132+
PGlite has no `createdb`/templates and one superuser, so the test model changes
133+
from *database-per-test* to *instance-per-test* (fresh in-memory PGlite is fast):
134+
135+
```ts
136+
export async function getConnections(cn?, seedAdapters?) {
137+
const { register } = pgliteDriver({ extensions: cn?.extensions });
138+
register(); // route pg-cache through PGlite
139+
// reuse pgsql-test seeding/rollback, but skip createdb/role/template steps
140+
// (superuser-only, single db) via a PGlite-aware admin
141+
...
142+
}
143+
```
144+
145+
Extensions are provisioned out-of-band here (pgpm's `cleanSql` strips
146+
`CREATE EXTENSION` from migrations by design), mirroring
147+
`DbAdmin.installExtensions()` — the adapter runs `CREATE EXTENSION` at instance
148+
bootstrap and registers the WASM ext in JS at construction.
149+
150+
### 4. One correctness fix in `@pgpmjs/core` (benign for pg, required for PGlite)
151+
152+
Today the deploy/revert loop opens a transaction on one pooled connection but calls
153+
`isDeployed()` on `this.pool.query` — a *different* connection. On a
154+
single-connection backend that second query can't run concurrently with the open
155+
transaction. Fix: thread the transaction's client through so all in-transaction
156+
reads use `context.client` (fall back to the pool only when not in a transaction).
157+
This is invisible to `pg` (pool has many connections) and unblocks any
158+
single-connection driver. It also removes the PoC's `useTransaction:false`
159+
workaround.
160+
161+
## What stays out of scope
162+
163+
- RLS multi-role connections, GraphQL/PostGraphile, LISTEN/NOTIFY cache
164+
invalidation, Knative jobs — none are pgpm concerns.
165+
- Extension availability is a **per-module** matter, not pgpm's: pgvector / pg_trgm
166+
/ citext / ltree / uuid_ossp are available; pg_cron / pg_partman / BM25 are not
167+
(no WASM / no background workers), PostGIS experimental.
168+
169+
## Suggested rollout
170+
171+
1. **`pg-cache` driver seam + registry** (default = pg; zero behavior change). Land + prove green.
172+
2. **Core transaction-client fix** (drop `useTransaction:false` from the PoC).
173+
3. **`@pgpmjs/pglite-adapter`** (in-process driver, all pglite deps here).
174+
4. **`pglite-test`** drop-in `getConnections` (instance-per-test) + a PGlite-aware admin.
175+
5. Convert this PoC's CI job to use `pglite-test` instead of the socket shim.
176+
177+
Each step is independently shippable and testable; only step 1–2 touch core, and
178+
neither adds a PGlite dependency to it.

0 commit comments

Comments
 (0)