prisma-pglite-bridge 0.4.0 → 0.5.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/README.md CHANGED
@@ -15,88 +15,129 @@ pnpm add -D prisma-pglite-bridge @electric-sql/pglite @prisma/adapter-pg pg
15
15
  The last three are peer dependencies you may already have.
16
16
  TypeScript users also need `@types/pg`.
17
17
 
18
+ ### Bridge fs-sync policy
19
+
20
+ The adapter defaults `syncToFs` to `'auto'`:
21
+
22
+ - in-memory PGlite (`new PGlite()` or `memory://...`) resolves to `false`
23
+ - persistent `dataDir` usage resolves to `true`
24
+
25
+ That keeps bridge-heavy test workloads on the lower-memory fast path
26
+ without changing durability defaults for persistent databases.
27
+ If you use a custom `fs`, set `syncToFs` explicitly because the
28
+ adapter cannot infer whether that storage is durable.
29
+
18
30
  ## Quickstart
19
31
 
20
32
  ```typescript
33
+ import { PGlite } from '@electric-sql/pglite';
21
34
  import { createPgliteAdapter } from 'prisma-pglite-bridge';
22
35
  import { PrismaClient } from '@prisma/client';
23
36
 
24
- const { adapter, resetDb } = await createPgliteAdapter();
37
+ const pglite = new PGlite();
38
+ const { adapter, resetDb } = await createPgliteAdapter({
39
+ pglite,
40
+ migrationsPath: './prisma/migrations',
41
+ });
25
42
  const prisma = new PrismaClient({ adapter });
26
43
 
27
- // Per-test isolation (optional)
28
44
  beforeEach(() => resetDb());
29
45
  ```
30
46
 
31
- That's it. Schema is auto-discovered from `prisma.config.ts`
32
- and migration files (run `prisma migrate dev` first if you
33
- haven't already). No Docker, no database server — works
34
- in GitHub Actions, GitLab CI, and any environment where
35
- Node.js runs.
47
+ Call `resetDb()` in `beforeEach` to wipe all data between tests.
48
+ Skip it if your tests are read-only or you want state to carry
49
+ over.
50
+
51
+ That's it. Run `prisma migrate dev` first to generate migration
52
+ files. No Docker, no database server — works in GitHub Actions,
53
+ GitLab CI, and any environment where Node.js runs.
36
54
 
37
55
  ## Schema Resolution
38
56
 
39
- `createPgliteAdapter()` resolves schema SQL in this order:
57
+ When you pass any of `sql`, `migrationsPath`, or `configRoot`,
58
+ `createPgliteAdapter` applies schema SQL. Resolution order:
40
59
 
41
60
  1. **`sql` option** — pre-generated SQL string, applied directly
42
61
  2. **`migrationsPath` option** — reads migration files from the
43
62
  given directory
44
63
  3. **Auto-discovered migrations** — uses `@prisma/config` to find
45
- migration files (same resolution as `prisma migrate dev`).
46
- Requires `prisma` to be installed (which provides
47
- `@prisma/config` as a transitive dependency).
48
-
49
- If no migration files are found, it throws with a message to run
50
- `prisma migrate dev` first.
64
+ migration files (same resolution as `prisma migrate dev`),
65
+ triggered by passing `configRoot`. Requires `prisma` to be
66
+ installed (which provides `@prisma/config` as a transitive
67
+ dependency).
68
+
69
+ When none of these options is provided, no SQL is applied — the
70
+ PGlite instance is assumed to already hold the schema (useful
71
+ for reopening a persistent `dataDir`).
72
+
73
+ Schema SQL — whether inline via `sql` or loaded from `migrationsPath`
74
+ — is executed verbatim with no checksum or signature verification.
75
+ Ensure the source is trusted and version-controlled. Do not compose
76
+ it from environment variables, network input, or any value that
77
+ crosses a trust boundary, and keep the migrations directory
78
+ writable only by trusted processes.
51
79
 
52
80
  ## API
53
81
 
54
- ### `createPgliteAdapter(options?)`
82
+ ### `createPgliteAdapter(options)`
55
83
 
56
- Creates a Prisma adapter backed by an in-process PGlite instance.
84
+ Creates a Prisma adapter backed by a caller-supplied PGlite
85
+ instance.
57
86
 
58
87
  ```typescript
59
- const { adapter, pglite, resetDb, close } = await createPgliteAdapter({
60
- // All optional — migrations auto-discovered from prisma.config.ts
88
+ const pglite = new PGlite(/* dataDir, extensions, ... */);
89
+
90
+ const { adapter, resetDb, close, stats } = await createPgliteAdapter({
91
+ pglite, // required — caller owns lifecycle
61
92
  migrationsPath: './prisma/migrations', // or:
62
93
  sql: 'CREATE TABLE ...', // (first match wins, see Schema Resolution)
63
94
  configRoot: '../..', // monorepo: where to find prisma.config.ts
64
- dataDir: './data/pglite', // omit for in-memory
65
- extensions: {}, // PGlite extensions
66
- max: 5, // pool connections (default: 5)
95
+ max: 1, // pool connections (default: 1, see "Pool sizing" below)
96
+ statsLevel: 'off', // 'off' | 'basic' | 'full' (default: 'off')
67
97
  });
68
98
  ```
69
99
 
70
100
  Returns:
71
101
 
72
102
  - `adapter` — pass to `new PrismaClient({ adapter })`
73
- - `pglite` — the underlying PGlite instance for direct SQL,
74
- snapshots, or extension access
75
- - `resetDb()` truncates all user tables, resets session state
76
- (`RESET ALL`, `DEALLOCATE ALL`). Call in `beforeEach` for
77
- per-test isolation. Note: this clears all data including seed
78
- data — re-seed after reset if needed.
79
- - `close()` — shuts down pool and PGlite. Not needed in tests
80
- (process exit handles it). Use in long-running scripts or dev
81
- servers.
82
-
83
- ### `createPool(options?)`
103
+ - `resetDb()` — truncates all user tables and discards
104
+ session-local state via `DISCARD ALL` (for example `SET`
105
+ variables, prepared statements, temp tables, and `LISTEN`
106
+ registrations). Call in `beforeEach` for per-test isolation.
107
+ Note: this clears all data including seed data — re-seed after
108
+ reset if needed.
109
+ - `close()` — shuts down the pool. The caller-supplied PGlite
110
+ instance is not closed you own its lifecycle. Not needed in
111
+ tests (process exit handles it); use in long-running scripts
112
+ or dev servers.
113
+ - `stats()` — returns telemetry when `statsLevel` is `'basic'` or
114
+ `'full'`, else `undefined`. See [Stats collection](#stats-collection).
115
+ - `adapterId` — a unique `symbol` identifying this adapter. Use it
116
+ to filter events from the public
117
+ [diagnostics channels](#diagnostics-channels) when multiple
118
+ adapters share a process.
119
+
120
+ ### `createPool(options)`
84
121
 
85
122
  Lower-level escape hatch. Creates a `pg.Pool` backed by PGlite
86
- without automatic schema resolution — useful for custom Prisma
87
- setups, other ORMs, or raw SQL.
123
+ without schema handling — useful for custom Prisma setups,
124
+ other ORMs, or raw SQL.
88
125
 
89
126
  ```typescript
127
+ import { PGlite } from '@electric-sql/pglite';
90
128
  import { createPool } from 'prisma-pglite-bridge';
91
129
  import { PrismaPg } from '@prisma/adapter-pg';
92
130
 
93
- const { pool, pglite, close } = await createPool();
131
+ const pglite = new PGlite();
132
+ const { pool, close } = await createPool({ pglite });
94
133
  const adapter = new PrismaPg(pool);
95
134
  ```
96
135
 
97
- Returns `pool` (pg.Pool), `pglite` (the underlying PGlite
98
- instance), and `close()`. Accepts `dataDir`, `extensions`, `max`,
99
- and `pglite` (bring your own pre-configured PGlite instance).
136
+ Returns `pool` (pg.Pool), `adapterId` (a unique `symbol` for
137
+ [diagnostics channel](#diagnostics-channels) filtering), and
138
+ `close()` (which shuts down the pool only — the caller-supplied
139
+ PGlite instance is not closed). Accepts `pglite` (required),
140
+ `max`, and `adapterId`.
100
141
 
101
142
  ### `PGliteBridge`
102
143
 
@@ -142,10 +183,15 @@ the in-memory PGlite version:
142
183
 
143
184
  ```typescript
144
185
  // vitest.setup.ts
186
+ import { PGlite } from '@electric-sql/pglite';
145
187
  import { createPgliteAdapter } from 'prisma-pglite-bridge';
146
188
  import { PrismaClient } from '@prisma/client';
147
189
 
148
- const { adapter, resetDb } = await createPgliteAdapter();
190
+ const pglite = new PGlite();
191
+ const { adapter, resetDb } = await createPgliteAdapter({
192
+ pglite,
193
+ migrationsPath: './prisma/migrations',
194
+ });
149
195
  export const testPrisma = new PrismaClient({ adapter });
150
196
 
151
197
  vi.mock('./lib/prisma', () => ({ prisma: testPrisma }));
@@ -172,6 +218,7 @@ the top level, not inside `beforeAll`:
172
218
 
173
219
  ```typescript
174
220
  // jest.setup.ts
221
+ const { PGlite } = require('@electric-sql/pglite');
175
222
  const { createPgliteAdapter } = require('prisma-pglite-bridge');
176
223
  const { PrismaClient } = require('@prisma/client');
177
224
 
@@ -183,7 +230,11 @@ jest.mock('./lib/prisma', () => ({
183
230
  }));
184
231
 
185
232
  beforeAll(async () => {
186
- const result = await createPgliteAdapter();
233
+ const pglite = new PGlite();
234
+ const result = await createPgliteAdapter({
235
+ pglite,
236
+ migrationsPath: './prisma/migrations',
237
+ });
187
238
  testPrisma = new PrismaClient({ adapter: result.adapter });
188
239
  resetDb = result.resetDb;
189
240
  });
@@ -196,6 +247,7 @@ beforeEach(() => resetDb());
196
247
  If your code accepts `PrismaClient` as a parameter:
197
248
 
198
249
  ```typescript
250
+ import { PGlite } from '@electric-sql/pglite';
199
251
  import { createPgliteAdapter, type ResetDbFn } from 'prisma-pglite-bridge';
200
252
  import { PrismaClient } from '@prisma/client';
201
253
  import { beforeAll, beforeEach, it, expect } from 'vitest';
@@ -204,7 +256,11 @@ let prisma: PrismaClient;
204
256
  let resetDb: ResetDbFn;
205
257
 
206
258
  beforeAll(async () => {
207
- const result = await createPgliteAdapter();
259
+ const pglite = new PGlite();
260
+ const result = await createPgliteAdapter({
261
+ pglite,
262
+ migrationsPath: './prisma/migrations',
263
+ });
208
264
  prisma = new PrismaClient({ adapter: result.adapter });
209
265
  resetDb = result.resetDb;
210
266
  });
@@ -243,6 +299,7 @@ if (import.meta.url === new URL(process.argv[1]!, 'file:').href) {
243
299
  Then reuse it in tests:
244
300
 
245
301
  ```typescript
302
+ import { PGlite } from '@electric-sql/pglite';
246
303
  import { createPgliteAdapter, type ResetDbFn } from 'prisma-pglite-bridge';
247
304
  import { PrismaClient } from '@prisma/client';
248
305
  import { seed } from '../prisma/seed';
@@ -251,7 +308,11 @@ let prisma: PrismaClient;
251
308
  let resetDb: ResetDbFn;
252
309
 
253
310
  beforeAll(async () => {
254
- const result = await createPgliteAdapter();
311
+ const pglite = new PGlite();
312
+ const result = await createPgliteAdapter({
313
+ pglite,
314
+ migrationsPath: './prisma/migrations',
315
+ });
255
316
  prisma = new PrismaClient({ adapter: result.adapter });
256
317
  resetDb = result.resetDb;
257
318
  await seed(prisma);
@@ -270,12 +331,15 @@ If your schema uses `uuid-ossp`, `pgcrypto`, or other extensions,
270
331
  pass them via the `extensions` option:
271
332
 
272
333
  ```typescript
334
+ import { PGlite } from '@electric-sql/pglite';
273
335
  import { createPgliteAdapter } from 'prisma-pglite-bridge';
274
336
  import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp';
275
337
  import { pgcrypto } from '@electric-sql/pglite/contrib/pgcrypto';
276
338
 
339
+ const pglite = new PGlite({ extensions: { uuid_ossp, pgcrypto } });
277
340
  const { adapter } = await createPgliteAdapter({
278
- extensions: { uuid_ossp, pgcrypto },
341
+ pglite,
342
+ migrationsPath: './prisma/migrations',
279
343
  });
280
344
  ```
281
345
 
@@ -285,8 +349,18 @@ for the full list.
285
349
 
286
350
  ### Pre-generated SQL (fastest)
287
351
 
352
+ The `sql` option runs verbatim with no sandbox or checksum. Compose
353
+ it from trusted, version-controlled source only — never from
354
+ environment variables, network input, or values that cross a trust
355
+ boundary. See [Schema Resolution](#schema-resolution) for the
356
+ full source-of-trust guidance.
357
+
288
358
  ```typescript
359
+ import { PGlite } from '@electric-sql/pglite';
360
+
361
+ const pglite = new PGlite();
289
362
  const { adapter } = await createPgliteAdapter({
363
+ pglite,
290
364
  sql: `
291
365
  CREATE TABLE "User" (id text PRIMARY KEY, name text NOT NULL);
292
366
  CREATE TABLE "Post" (
@@ -300,30 +374,43 @@ const { adapter } = await createPgliteAdapter({
300
374
 
301
375
  ### Persistent dev database (optional)
302
376
 
303
- By default, prisma-pglite-bridge runs entirely in memory — the database
377
+ By default, PGlite runs entirely in memory — the database
304
378
  disappears when the process exits. This is ideal for tests. If you
305
379
  want data to survive restarts (local development, prototyping),
306
- pass a `dataDir`:
380
+ pass a `dataDir` when constructing PGlite, and only apply
381
+ migrations on first run:
307
382
 
308
383
  ```typescript
384
+ import { existsSync } from 'node:fs';
385
+ import { join } from 'node:path';
386
+ import { PGlite } from '@electric-sql/pglite';
387
+
388
+ const dataDir = './data/pglite';
389
+ const firstRun = !existsSync(join(dataDir, 'PG_VERSION'));
390
+
391
+ const pglite = new PGlite(dataDir);
309
392
  const { adapter, close } = await createPgliteAdapter({
310
- dataDir: './data/pglite',
393
+ pglite,
394
+ ...(firstRun ? { migrationsPath: './prisma/migrations' } : {}),
311
395
  });
312
396
  const prisma = new PrismaClient({ adapter });
313
-
314
- // Data persists across restarts. Schema is only applied on first run
315
- // (PGlite detects an existing PGDATA directory). Delete the data
316
- // directory after schema changes to pick up new migrations.
317
397
  ```
318
398
 
319
- **Add `data/pglite/` to `.gitignore`.** This gives you a local
320
- PostgreSQL without Docker useful for offline development or
321
- environments where installing PostgreSQL is impractical.
399
+ **Add `data/pglite/` to `.gitignore`.** Delete the data directory
400
+ after schema changes to pick up new migrations. This gives you a
401
+ local PostgreSQL without Docker useful for offline development
402
+ or environments where installing PostgreSQL is impractical.
322
403
 
323
404
  ### Long-running script with clean shutdown
324
405
 
325
406
  ```typescript
326
- const { adapter, close } = await createPgliteAdapter();
407
+ import { PGlite } from '@electric-sql/pglite';
408
+
409
+ const pglite = new PGlite();
410
+ const { adapter, close } = await createPgliteAdapter({
411
+ pglite,
412
+ migrationsPath: './prisma/migrations',
413
+ });
327
414
  const prisma = new PrismaClient({ adapter });
328
415
 
329
416
  try {
@@ -331,9 +418,141 @@ try {
331
418
  } finally {
332
419
  await prisma.$disconnect();
333
420
  await close();
421
+ await pglite.close();
334
422
  }
335
423
  ```
336
424
 
425
+ ## Stats collection
426
+
427
+ For most developers, this is the easiest way to see how the bridge
428
+ performed in tests.
429
+
430
+ Enable `statsLevel` when creating the adapter, run your tests, then
431
+ call `await stats()` at the end. You get one snapshot with the main
432
+ things you usually care about: query counts, timing percentiles,
433
+ database size, and, at `'full'`, process RSS and session-lock wait
434
+ times.
435
+
436
+ This is the built-in, low-friction path for test diagnostics. It is
437
+ useful for CI cost insight, perf tuning, and understanding test-suite
438
+ behavior without wiring up a separate metrics pipeline. **Off by
439
+ default**; the hot path stays effectively zero-cost as long as no
440
+ external consumer subscribes to the public
441
+ [diagnostics channels](#diagnostics-channels).
442
+
443
+ ```typescript
444
+ import { PGlite } from '@electric-sql/pglite';
445
+
446
+ const pglite = new PGlite();
447
+ const { adapter, stats, close } = await createPgliteAdapter({
448
+ pglite,
449
+ migrationsPath: './prisma/migrations',
450
+ statsLevel: 'basic', // or 'full'
451
+ });
452
+ const prisma = new PrismaClient({ adapter });
453
+
454
+ afterAll(async () => {
455
+ await prisma.$disconnect();
456
+ await close();
457
+ const s = await stats();
458
+ if (s) console.log(s);
459
+ await pglite.close();
460
+ });
461
+ ```
462
+
463
+ `stats()` returns `Promise<Stats | undefined>` — `undefined` when
464
+ `statsLevel` is `'off'` (or omitted). Safe to call before or after
465
+ `close()`; post-close reads return frozen values from the moment
466
+ `close()` was invoked.
467
+
468
+ If you need live per-query or per-lock-wait events instead of a final
469
+ snapshot, use the public [diagnostics channels](#diagnostics-channels)
470
+ described below. That path is more flexible, but also more advanced.
471
+
472
+ ### Levels
473
+
474
+ **`'basic'`** — timing and counters:
475
+
476
+ - `durationMs` — adapter lifetime (frozen at `close()`, drain
477
+ excluded)
478
+ - `schemaSetupMs` — one-time cost of applying migration SQL
479
+ - `queryCount`, `failedQueryCount` — WASM round-trips (a Prisma
480
+ extended-query pipeline is one round-trip, not five). Lifetime
481
+ counters.
482
+ - `totalQueryMs`, `avgQueryMs` — lifetime sum and mean of query
483
+ durations
484
+ - `recentP50QueryMs`, `recentP95QueryMs`, `recentMaxQueryMs` —
485
+ nearest-rank percentiles (no interpolation) over the most recent
486
+ ~10,000 queries. On long-lived adapters these describe a different
487
+ population than `avgQueryMs`.
488
+ - `resetDbCalls` — counts `resetDb()` attempts
489
+ - `dbSizeBytes` — `pg_database_size(current_database())`, cached
490
+ at close
491
+
492
+ **`'full'`** — adds:
493
+
494
+ - `processRssPeakBytes` — process-wide RSS peak, read from
495
+ `process.resourceUsage().maxRSS` (kernel-tracked, lossless) at
496
+ the moment `stats()` is called. Contaminated if unrelated work
497
+ shares the process — use as an ordering signal, not an absolute
498
+ measurement. `undefined` on runtimes without
499
+ `process.resourceUsage` (Bun, Deno, edge workers).
500
+ - `totalSessionLockWaitMs`, `sessionLockAcquisitionCount`,
501
+ `avgSessionLockWaitMs`, `maxSessionLockWaitMs` — session-lock
502
+ contention across pool connections
503
+
504
+ `statsLevel` is echoed on the returned object. Any field typed
505
+ `T | undefined` in the returned `Stats` is the exhaustive list of
506
+ fields that can be missing — `dbSizeBytes` if `pg_database_size`
507
+ rejects, `processRssPeakBytes` on runtimes without
508
+ `process.resourceUsage`. Every other field is always defined.
509
+
510
+ ## Diagnostics channels
511
+
512
+ The bridge publishes per-query and per-lock-wait events to
513
+ [`node:diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html)
514
+ channels. Built-in adapter stats are updated directly by the bridge
515
+ when `statsLevel` is `'basic'` or `'full'`; external consumers (OpenTelemetry, APM,
516
+ custom loggers) can subscribe directly without touching the bridge
517
+ API.
518
+
519
+ Publication is gated by `channel.hasSubscribers`, so when nobody
520
+ is listening the hot path pays no timing or payload cost.
521
+ Subscribing opts you in to that work.
522
+
523
+ ```typescript
524
+ import diagnostics_channel from 'node:diagnostics_channel';
525
+ import {
526
+ createPgliteAdapter,
527
+ QUERY_CHANNEL,
528
+ type QueryEvent,
529
+ } from 'prisma-pglite-bridge';
530
+
531
+ const { adapterId } = await createPgliteAdapter({ /* ... */ });
532
+
533
+ const listener = (msg: unknown) => {
534
+ const e = msg as QueryEvent;
535
+ if (e.adapterId !== adapterId) return;
536
+ myMetrics.record('db.query', e.durationMs, { ok: e.succeeded });
537
+ };
538
+ diagnostics_channel.channel(QUERY_CHANNEL).subscribe(listener);
539
+ ```
540
+
541
+ Channels:
542
+
543
+ - `QUERY_CHANNEL` (`prisma-pglite-bridge:query`) — every
544
+ whole-query boundary. Payload: `{ adapterId: symbol; durationMs:
545
+ number; succeeded: boolean }`. `succeeded` is `false` for both
546
+ thrown errors and protocol-level `ErrorResponse` frames.
547
+ - `LOCK_WAIT_CHANNEL` (`prisma-pglite-bridge:lock-wait`) — every
548
+ session-lock acquisition. Payload: `{ adapterId: symbol;
549
+ durationMs: number }`. `durationMs` is how long the acquirer
550
+ waited before the lock was granted.
551
+
552
+ Filter on `adapterId` to isolate events when multiple adapters
553
+ share a process. Obtain it from the `createPgliteAdapter()` or
554
+ `createPool()` return value.
555
+
337
556
  ## Limitations
338
557
 
339
558
  - **Node.js 20+ only** — requires `node:stream` and `node:fs`.
@@ -344,12 +563,42 @@ try {
344
563
  - **Single PostgreSQL session** — PGlite runs in single-user mode.
345
564
  All pool connections share one session. A `SessionLock` serializes
346
565
  transactions (one at a time), but `SET` variables leak between
347
- connections within a single test. `resetDb()` clears this between
348
- tests via `RESET ALL` and `DEALLOCATE ALL`.
566
+ connections within a single test. `resetDb()` clears more of this
567
+ between tests via `DISCARD ALL`.
349
568
  - **Migration files required** — run `prisma migrate dev` once to
350
569
  generate migration files, or pass schema SQL directly via the
351
570
  `sql` option.
352
571
 
572
+ ## Troubleshooting
573
+
574
+ ### `this.pglite.execProtocolRawStream is not a function`
575
+
576
+ The bridge uses PGlite 0.4's streaming protocol API. Some packages
577
+ in the Prisma ecosystem (e.g. `@prisma/dev`) still pin
578
+ `@electric-sql/pglite` to 0.3.x, which pnpm will install alongside
579
+ 0.4 — and the bridge can end up with the older copy.
580
+
581
+ Check your tree:
582
+
583
+ ```sh
584
+ pnpm why @electric-sql/pglite
585
+ ```
586
+
587
+ If you see more than one version, force a single 0.4.x via
588
+ `pnpm.overrides` in your project's `package.json`:
589
+
590
+ ```json
591
+ {
592
+ "pnpm": {
593
+ "overrides": {
594
+ "@electric-sql/pglite": "^0.4.4"
595
+ }
596
+ }
597
+ }
598
+ ```
599
+
600
+ Then `pnpm install`.
601
+
353
602
  ## License
354
603
 
355
604
  MIT