Skip to content

Commit bb269ce

Browse files
committed
feat(pglite): client-factory seam + pglite-test drop-in getConnections
- pgsql-client: add registerPgClientFactory/defaultPgClientFactory seam (mirror of pg-cache pool seam); PgClient routes its underlying client through it. Default = new pg.Client, fully backward-compatible. - pglite-adapter: extract shared query runner; add createPgliteClient so the same in-process session backs both pool and client seams. - pglite-test: new drop-in getConnections backed by in-process PGlite (no server/createdb/psql). SharedTxn ref-counts transaction control so the standard two-client beforeEach/afterEach savepoint harness works over one PGlite session. Seeds via seed.pgpm(). 5 tests: deploy, per-test isolation, RLS role/JWT switching incl. WITH CHECK. - CI: add postgres/pglite-test to the no-services unit-tests (pglite) batch.
1 parent 8e00052 commit bb269ce

28 files changed

Lines changed: 756 additions & 62 deletions

File tree

.github/workflows/run-tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ jobs:
9595
- batch: graphql
9696
packages: 'graphql/query graphql/codegen'
9797
- batch: pglite
98-
packages: 'postgres/pglite-adapter'
98+
packages: 'postgres/pglite-adapter postgres/pglite-test'
9999

100100
steps:
101101
- name: Download workspace

pnpm-lock.yaml

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

poc/pglite/DESIGN.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,11 @@ single-writer backend, but is not required for this rollout.
175175

176176
1. **`pg-cache` driver seam + registry** (default = pg; zero behavior change). ✅ **DONE**`registerPgPoolFactory` / `defaultPgPoolFactory`, 19 unit tests, CI green.
177177
2. **`@pgpmjs/pglite-adapter`** (in-process driver, all pglite deps here). ✅ **DONE**`registerPglite()` registers a PGlite-backed `QueryablePool`; 6 tests deploy→verify→revert an unmodified pgpm plan into in-process PGlite (no socket), with `useTransaction: true`.
178-
3. **`pglite-test`** drop-in `getConnections` (instance-per-test). Needs a matching client-factory seam in `pgsql-client` + a PGlite-aware admin, because `pgsql-test` bypasses `pg-cache` (`new Pool`/`new Client` directly + `createdb`/`psql`).
179-
4. Convert this PoC's CI job to use the adapter instead of the socket shim.
178+
2b. **`pgsql-client` client-factory seam** (default = `new pg.Client`; zero behavior change). ✅ **DONE**`registerPgClientFactory` / `defaultPgClientFactory` / `getActivePgClientFactory`, mirroring the `pg-cache` pool seam; `PgClient` routes its underlying client through it; 4 unit tests. Needed because `pgsql-test`/`pgsql-client` build `pg.Client`s directly rather than via `getPgPool`.
179+
3. **`pglite-test`** drop-in `getConnections` (instance-per-suite). ✅ **DONE** — composes `registerPglite()` (pool seam) + `registerPgClientFactory` (client seam) so `pg`/`db` share one in-process PGlite session; a `SharedTxn` ref-counter keeps the standard two-client `beforeEach`/`afterEach` savepoint harness working over a single session; seeds via `seed.pgpm()`; no `createdb`/`psql`. 5 tests cover deploy, per-test isolation, and RLS role/JWT switching (incl. `WITH CHECK`). No separate PGlite-aware `DbAdmin` subclass was needed — the instance *is* the database and extension/role bootstrap runs via `pglite.extensionSql`.
180+
4. This PoC's socket-shim CI job (`pglite-poc.yaml`) is **kept as-is** — it's a standalone, out-of-workspace demonstration of the `pg-gateway` wire-protocol path (own lockfile, published `@pgpmjs/core`). The canonical in-process path is now proven by the `@pgpmjs/pglite-adapter` and `pglite-test` jest suites in `run-tests.yaml` (no-services tier).
180181
5. (Optional) Core transaction-client fix — only if a *multi-connection* single-writer backend is ever targeted; the in-process adapter does not need it.
182+
6. (Future) `pgpm init --pglite` scaffolder — CLI-layer convenience; no PGlite dep in core.
181183

182184
Each shipped step is independently testable; only step 1 touches core, and it adds
183185
no PGlite dependency to it. All `@electric-sql/pglite*` deps live in
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { PGlite } from '@electric-sql/pglite';
2+
3+
import { boundQuery, type PgResultLike } from './runner';
4+
5+
/**
6+
* The minimal node-`pg` `Client` surface that `pgsql-client`'s `PgClient` uses:
7+
* `connect()`, `query()` and `end()`. A real `pg.Client` structurally satisfies
8+
* it, so registering a factory that returns this is transparent to consumers.
9+
*/
10+
export interface QueryablePgClient {
11+
connect(): Promise<void>;
12+
query(text: any, values?: any[]): Promise<PgResultLike>;
13+
end(): Promise<void>;
14+
}
15+
16+
/**
17+
* Build a `pg.Client`-shaped object backed by an existing PGlite instance.
18+
*
19+
* Every client returned for the same instance shares that single in-process
20+
* session, so transaction control (`BEGIN`/`SAVEPOINT`/`ROLLBACK`/`COMMIT`) is
21+
* shared too. `end()` is a no-op: the instance lifecycle is owned by whoever
22+
* created it (see `registerPglite`), not by an individual client.
23+
*/
24+
export const createPgliteClient = (db: PGlite): QueryablePgClient => {
25+
const query = boundQuery(db);
26+
return {
27+
connect: async () => {
28+
await db.waitReady;
29+
},
30+
query,
31+
end: async () => {}
32+
};
33+
};

postgres/pglite-adapter/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { getActivePgPoolFactory, registerPgPoolFactory } from 'pg-cache';
33

44
import { createPglitePool } from './pool';
55

6+
export { createPgliteClient, type QueryablePgClient } from './client';
67
export { createPglitePool } from './pool';
8+
export type { PgResultLike } from './runner';
79

810
export interface PgliteAdapterOptions {
911
/** Persist to a directory (default: in-memory). */

postgres/pglite-adapter/src/pool.ts

Lines changed: 5 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,7 @@
11
import type { PGlite } from '@electric-sql/pglite';
22
import type { QueryableClient, QueryablePool } from 'pg-cache';
33

4-
/**
5-
* A node-`pg`-shaped result. pgpm only reads `rows` and (occasionally)
6-
* `rowCount`, so we normalize PGlite's `{ rows, affectedRows, fields }` onto
7-
* that shape.
8-
*/
9-
interface PgResultLike {
10-
rows: any[];
11-
rowCount: number;
12-
fields: any[];
13-
}
14-
15-
const toResult = (r: any): PgResultLike => {
16-
const rows = r?.rows ?? [];
17-
const rowCount = typeof r?.affectedRows === 'number' ? r.affectedRows : rows.length;
18-
return { rows, rowCount, fields: r?.fields ?? [] };
19-
};
20-
21-
/**
22-
* Run a statement against the single in-process PGlite engine.
23-
*
24-
* PGlite mirrors node-`pg`'s two protocols:
25-
* - parameterized (`$1`, ...) -> `db.query` (extended protocol, single statement)
26-
* - no parameters -> `db.exec` (simple protocol, supports the multi-statement
27-
* bootstrap SQL pgpm runs in `initialize()`), returning the last result.
28-
*/
29-
const run = async (db: PGlite, text: string, values?: any[]): Promise<PgResultLike> => {
30-
if (values && values.length > 0) {
31-
return toResult(await db.query(text, values));
32-
}
33-
const results = await db.exec(text);
34-
const last = Array.isArray(results) && results.length ? results[results.length - 1] : undefined;
35-
return toResult(last);
36-
};
37-
38-
/** Normalize the two call shapes pgpm/pg accept: `(text, values)` or `({ text, values })`. */
39-
const normalize = (first: any, second?: any[]): { text: string; values?: any[] } => {
40-
if (first && typeof first === 'object' && 'text' in first) {
41-
return { text: first.text, values: first.values };
42-
}
43-
return { text: first, values: second };
44-
};
4+
import { boundQuery } from './runner';
455

466
/**
477
* Build a `QueryablePool` backed by an existing PGlite instance.
@@ -55,27 +15,22 @@ const normalize = (first: any, second?: any[]): { text: string; values?: any[] }
5515
*
5616
* `end()` intentionally does NOT close the PGlite instance: the same instance
5717
* may back several cached pool wrappers (pg-cache keys by database name), and
58-
* its lifecycle is owned by whoever created it (see `createPglite`).
18+
* its lifecycle is owned by whoever created it (see `registerPglite`).
5919
*/
6020
export const createPglitePool = (db: PGlite): QueryablePool => {
6121
let ended = false;
22+
const query = boundQuery(db);
6223

6324
const client: QueryableClient = {
64-
query: (text: any, values?: any[]) => {
65-
const q = normalize(text, values);
66-
return run(db, q.text, q.values);
67-
},
25+
query,
6826
release: () => {}
6927
};
7028

7129
const pool = {
7230
get ended() {
7331
return ended;
7432
},
75-
query: (text: any, values?: any[]) => {
76-
const q = normalize(text, values);
77-
return run(db, q.text, q.values);
78-
},
33+
query,
7934
connect: async () => client,
8035
end: async () => {
8136
ended = true;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { PGlite } from '@electric-sql/pglite';
2+
3+
/**
4+
* A node-`pg`-shaped result. pgpm / pgsql-client read `rows` and (occasionally)
5+
* `rowCount`, so we normalize PGlite's `{ rows, affectedRows, fields }` onto that
6+
* shape.
7+
*/
8+
export interface PgResultLike {
9+
rows: any[];
10+
rowCount: number;
11+
fields: any[];
12+
}
13+
14+
const toResult = (r: any): PgResultLike => {
15+
const rows = r?.rows ?? [];
16+
const rowCount = typeof r?.affectedRows === 'number' ? r.affectedRows : rows.length;
17+
return { rows, rowCount, fields: r?.fields ?? [] };
18+
};
19+
20+
/**
21+
* Run a statement against the single in-process PGlite engine.
22+
*
23+
* PGlite mirrors node-`pg`'s two protocols:
24+
* - parameterized (`$1`, ...) -> `db.query` (extended protocol, single statement)
25+
* - no parameters -> `db.exec` (simple protocol, supports the multi-statement
26+
* bootstrap SQL pgpm runs in `initialize()`), returning the last result.
27+
*/
28+
export const run = async (
29+
db: PGlite,
30+
text: string,
31+
values?: any[]
32+
): Promise<PgResultLike> => {
33+
if (values && values.length > 0) {
34+
return toResult(await db.query(text, values));
35+
}
36+
const results = await db.exec(text);
37+
const last = Array.isArray(results) && results.length ? results[results.length - 1] : undefined;
38+
return toResult(last);
39+
};
40+
41+
/** Normalize the two call shapes pg accepts: `(text, values)` or `({ text, values })`. */
42+
export const normalize = (first: any, second?: any[]): { text: string; values?: any[] } => {
43+
if (first && typeof first === 'object' && 'text' in first) {
44+
return { text: first.text, values: first.values };
45+
}
46+
return { text: first, values: second };
47+
};
48+
49+
/** A bound `(text, values | { text, values })` query function over a PGlite instance. */
50+
export const boundQuery =
51+
(db: PGlite) =>
52+
(text: any, values?: any[]): Promise<PgResultLike> => {
53+
const q = normalize(text, values);
54+
return run(db, q.text, q.values);
55+
};

postgres/pglite-test/README.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# pglite-test
2+
3+
A drop-in [`pgsql-test`](../pgsql-test) `getConnections()` backed by an in-process
4+
[**PGlite**](https://github.com/electric-sql/pglite) instance (WASM Postgres) —
5+
**no Postgres server, no `createdb`, no `psql`, no TCP.**
6+
7+
It follows the same pattern as `drizzle-orm-test` / `supabase-test`: compose the
8+
existing seams instead of forking the base.
9+
10+
- [`@pgpmjs/pglite-adapter`](../pglite-adapter)'s `registerPglite()` routes
11+
`pg-cache`'s `getPgPool()` at PGlite, so `seed.pgpm()` deploys your module into
12+
it with the unmodified pgpm engine.
13+
- `pgsql-client`'s client-factory seam routes `PgTestClient`'s underlying
14+
`pg.Client` at the same PGlite session.
15+
16+
## Usage
17+
18+
```typescript
19+
import { getConnections, PgTestClient } from 'pglite-test';
20+
21+
let pg: PgTestClient;
22+
let db: PgTestClient;
23+
let teardown: () => Promise<void>;
24+
25+
beforeAll(async () => {
26+
({ pg, db, teardown } = await getConnections());
27+
});
28+
29+
afterAll(async () => {
30+
await teardown();
31+
});
32+
33+
beforeEach(async () => {
34+
await pg.beforeEach();
35+
await db.beforeEach();
36+
});
37+
38+
afterEach(async () => {
39+
await db.afterEach();
40+
await pg.afterEach();
41+
});
42+
43+
it('queries the pgpm-deployed schema', async () => {
44+
const { rows } = await db.query('SELECT count(*)::int AS n FROM app.users');
45+
expect(rows[0].n).toBe(0);
46+
});
47+
```
48+
49+
Jest must run with `NODE_OPTIONS=--experimental-vm-modules` (PGlite loads a WASM
50+
module); the package's `test` script sets this.
51+
52+
## Single-session model
53+
54+
PGlite is one in-process session, so `pg` and `db` share it. That differs from
55+
`pgsql-test` (two authenticated connections on a real server):
56+
57+
- Transaction control is **ref-counted** (`SharedTxn`) so the standard
58+
two-client `beforeEach`/`afterEach` harness emits exactly one
59+
`BEGIN`/`SAVEPOINT`/`ROLLBACK`/`COMMIT` per test. The single-client (`db` only)
60+
pattern also works.
61+
- Role-based RLS uses `setContext({ role })` (i.e. `SET LOCAL role`) on the shared
62+
session rather than separate authenticated connections. Any role you switch to
63+
must exist — create it via `pglite.extensionSql` (e.g.
64+
`['CREATE ROLE authenticated;']`).
65+
- `publish()` (commit-and-continue) is not supported under the shared-session
66+
coordinator.
67+
68+
## Options
69+
70+
```typescript
71+
await getConnections(
72+
{
73+
pglite: {
74+
dataDir: undefined, // in-memory by default
75+
extensions: { vector }, // WASM extensions (e.g. pglite-pgvector)
76+
extensionSql: [ // run once after ready
77+
'CREATE EXTENSION IF NOT EXISTS vector;',
78+
'CREATE ROLE authenticated;'
79+
]
80+
}
81+
},
82+
[seed.pgpm()] // default seed adapter
83+
);
84+
```

0 commit comments

Comments
 (0)