Skip to content

Commit 51b27a1

Browse files
committed
docs: catalog all pglite-vs-pgsql-test deviations for boilerplate
1 parent 6429d4b commit 51b27a1

2 files changed

Lines changed: 197 additions & 0 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ afterEach(async () => { await db.afterEach(); await pg.afterEach(); });
7373
Under the hood, `pglite-test` composes the same seams the rest of the stack uses:
7474
`@pgpmjs/pglite-adapter`'s `registerPglite()` points `pg-cache` at PGlite (so `seed.pgpm()` deploys into it), and `pgsql-client`'s client-factory seam routes the test client at the same in-process session. Because PGlite is a single session, transaction control is ref-counted so the standard two-client `beforeEach`/`afterEach` harness works unchanged.
7575

76+
> **Building a boilerplate from this repo?** See
77+
> [`docs/pglite-vs-pgsql-test.md`](docs/pglite-vs-pgsql-test.md) — a full catalog of
78+
> every deviation from a standard `pgsql-test` project (deps, `--experimental-vm-modules`,
79+
> WASM cold-start timeouts, role creation, extension provisioning, services-free CI).
80+
7681
### Notes specific to PGlite
7782

7883
- **In-memory by default.** Persist with `getConnections({ pglite: { dataDir: './.pglite' } })`.

docs/pglite-vs-pgsql-test.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# PGlite vs. a standard `pgsql-test` suite
2+
3+
This repo was scaffolded like a normal pgpm project (`pgpm init workspace` +
4+
`pgpm init`) and then wired to test against **in-process PGlite** instead of a
5+
Postgres server. This file catalogs **every deviation** from a standard
6+
`pgsql-test` project, so the differences can be baked into a boilerplate
7+
(`pglite/*` family, or a dedicated `pgpm-pglite-boilerplates`).
8+
9+
Each item notes whether it's **required** (the suite won't work without it) or a
10+
**convenience/robustness** choice.
11+
12+
---
13+
14+
## 1. Dependencies (required)
15+
16+
Swap the test framework and add the PGlite runtime as dev deps:
17+
18+
| Standard `pgsql-test` | PGlite suite |
19+
| --- | --- |
20+
| `pgsql-test` | `pglite-test` |
21+
|| `@pgpmjs/pglite-adapter` |
22+
|| `@electric-sql/pglite` |
23+
| — (only for pgvector) | `@electric-sql/pglite-pgvector` |
24+
25+
`@electric-sql/pglite` is a **peer dependency** of `pglite-test` /
26+
`@pgpmjs/pglite-adapter`, so it must be present in the consuming project.
27+
`@electric-sql/pglite-pgvector` is only needed for the vector extension.
28+
29+
Test import site changes from `from 'pgsql-test'` to `from 'pglite-test'`; the
30+
`getConnections` / `PgTestClient` / `seed` API is otherwise identical.
31+
32+
## 2. Jest must run under `--experimental-vm-modules` (required)
33+
34+
PGlite loads a WASM module via dynamic ESM `import`, so Jest needs VM modules
35+
enabled. Set it in every package's scripts:
36+
37+
```jsonc
38+
"scripts": {
39+
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
40+
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
41+
}
42+
```
43+
44+
## 3. Generous timeouts for WASM cold-start (required on CI)
45+
46+
The first `getConnections()` compiles/loads the PGlite WASM module. On a cold CI
47+
runner this can exceed **Jest's default 5s hook timeout**, which makes
48+
`beforeAll` fail and `teardown` come back `undefined` (the failure we hit — the
49+
deploy logs actually land *after* the timeout). Two guards, applied everywhere:
50+
51+
```ts
52+
beforeAll(async () => {
53+
({ pg, db, teardown } = await getConnections(/* ... */));
54+
}, 120000); // explicit hook timeout
55+
```
56+
57+
```js
58+
// jest.config.js
59+
module.exports = {
60+
// ...
61+
testTimeout: 120000, // WASM cold-start room
62+
};
63+
```
64+
65+
Loading a WASM **extension** (e.g. pgvector) is meaningfully slower than the
66+
bare instance, so vector suites especially need this. **This should be a default
67+
in any pglite-test boilerplate.**
68+
69+
## 4. Roles are not auto-created (required, today)
70+
71+
On a real server `pgsql-test` bootstraps app roles (`anonymous` /
72+
`authenticated` / `administrator`) via `DbAdmin.createUserRole()` as part of
73+
`createdb`. PGlite has no `createdb` — the instance *is* the database — so that
74+
bootstrap never runs and **PGlite boots as a single superuser with no app
75+
roles**.
76+
77+
Any role used via `setContext({ role })` (and note `db`'s default context role
78+
is `anonymous`) must be created first, through `extensionSql`:
79+
80+
```ts
81+
await getConnections(
82+
{ pglite: { extensionSql: ['CREATE ROLE authenticated;'] } },
83+
[seed.pgpm(__dirname + '/..')]
84+
);
85+
```
86+
87+
The same `CREATE ROLE ... NOLOGIN` / `GRANT` statements our server bootstrap
88+
uses work verbatim in PGlite (it's real Postgres) — only the `LOGIN PASSWORD`
89+
second-connection bits are superfluous in-process.
90+
91+
> **Boilerplate opportunity:** a default-role bootstrap in `pglite-test` (create
92+
> the group roles from `DEFAULT_ROLE_MAPPING`, `NOLOGIN`, idempotent) would make
93+
> it a true drop-in and remove this line. Until shipped, the boilerplate creates
94+
> the roles it uses explicitly.
95+
96+
## 5. Extensions are provisioned out-of-band (required for extensions)
97+
98+
pgpm's `cleanSql` strips `CREATE EXTENSION` from migrations, and PGlite
99+
extensions are WASM modules that must be registered at construction. So an
100+
extension like pgvector needs three things wired together:
101+
102+
1. the module's migration keeps its `CREATE EXTENSION vector;` (deploy SQL) and
103+
the `.control` file lists it in `requires`;
104+
2. the WASM module is registered at construction: `pglite: { extensions: { vector } }`;
105+
3. it's installed at bootstrap: `pglite: { extensionSql: ['CREATE EXTENSION IF NOT EXISTS vector;'] }`.
106+
107+
```ts
108+
import { vector } from '@electric-sql/pglite-pgvector';
109+
110+
await getConnections(
111+
{
112+
pglite: {
113+
extensions: { vector },
114+
extensionSql: ['CREATE EXTENSION IF NOT EXISTS vector;'],
115+
},
116+
},
117+
[seed.pgpm(__dirname + '/..')]
118+
);
119+
```
120+
121+
On a real server the same `CREATE EXTENSION vector` just installs the native
122+
extension — no `extensions` registration needed.
123+
124+
## 6. Single-session transaction model (behavioral difference)
125+
126+
On a server, `pg` and `db` are two independent connections; PGlite is **one
127+
in-process session** shared by both clients. `pglite-test` routes both through a
128+
ref-counting coordinator (`SharedTxn`) so exactly one
129+
`BEGIN`/`SAVEPOINT`/`ROLLBACK`/`COMMIT` runs per test. Consequences:
130+
131+
- The standard `beforeEach`/`afterEach` harness works **unchanged**, including
132+
per-test savepoint rollback and transaction-local `setContext`.
133+
- `pg` and `db` **share** the session, so they see each other's uncommitted
134+
writes within a test (on a server they're isolated). Fine for the usual "seed
135+
as `pg`, assert as `db`" flow; only matters if a test deliberately relies on
136+
cross-connection isolation.
137+
- `publish()` (commit-and-continue mid-test) is **not supported** under the
138+
shared-session coordinator.
139+
140+
## 7. In-memory by default (convenience)
141+
142+
`getConnections()` defaults to an **in-memory** PGlite (no `dataDir`). Persist
143+
opt-in with `getConnections({ pglite: { dataDir: './.pglite' } })`.
144+
145+
## 8. CI drops all services (the headline win)
146+
147+
A server-backed suite needs a Postgres (or Supabase/Docker/MinIO) service, env
148+
vars (`PGHOST`/`PGPORT`/...), `pgpm tune`, and `admin-users bootstrap`. The
149+
PGlite CI needs **none of it** — the whole job is:
150+
151+
```yaml
152+
steps:
153+
- uses: actions/checkout@v4
154+
- uses: pnpm/action-setup@v4
155+
with: { version: 10 }
156+
- uses: actions/setup-node@v4
157+
with: { node-version: '20', cache: 'pnpm' }
158+
- run: pnpm install --frozen-lockfile
159+
- run: cd ./packages/${{ matrix.package }} && pnpm test
160+
```
161+
162+
No `services:` block, no service readiness wait, no global `pgpm` install, no
163+
role bootstrap. This is the main reason a PGlite boilerplate is attractive.
164+
165+
## 9. `pgpm.json` needs no role mapping (simplification)
166+
167+
The server/supabase suite maps roles in `pgpm.json` (`db.roles`, `useLocksForRoles`).
168+
The PGlite suite's `pgpm.json` is just the workspace manifest (`{"packages": ["packages/*"]}`);
169+
roles are handled per-suite via `extensionSql` (see §4).
170+
171+
---
172+
173+
## What stays exactly the same
174+
175+
- Package layout: `pgpm.plan`, `*.control`, `Makefile`, `deploy/` `verify/`
176+
`revert/`, `sql/`.
177+
- The migration SQL itself — the same `deploy`/`verify`/`revert` scripts run on a
178+
server via `pgsql-test` and in-process via `pglite-test`.
179+
- `getConnections` / `PgTestClient` / `seed.pgpm()` API and the
180+
`beforeEach`/`afterEach` hook pattern.
181+
- Root tooling: `pnpm-workspace.yaml`, `lerna.json`, `tsconfig.json`,
182+
`eslint.config.js`, `.prettierrc.json`.
183+
184+
## Boilerplate checklist (derived from the above)
185+
186+
- [ ] deps: `pglite-test`, `@pgpmjs/pglite-adapter`, `@electric-sql/pglite` (+ `@electric-sql/pglite-pgvector` for a vector variant)
187+
- [ ] `test` scripts prefixed with `NODE_OPTIONS=--experimental-vm-modules`
188+
- [ ] `beforeAll(..., 120000)` + `testTimeout: 120000`
189+
- [ ] roles created via `pglite.extensionSql` (until default-role bootstrap ships)
190+
- [ ] extensions: `pglite.extensions` + `CREATE EXTENSION` in `extensionSql`, kept in migration + `.control`
191+
- [ ] services-free CI workflow
192+
- [ ] minimal `pgpm.json` (no `db.roles`)

0 commit comments

Comments
 (0)