|
| 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