@jcbuisson/express-x 3.0.5 → 3.1.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/CLAUDE.md CHANGED
@@ -4,13 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
4
 
5
5
  ## Project Overview
6
6
 
7
- **express-x** is a full-stack framework that unifies backend and frontend communication through WebSocket-based services. It's published as `@jcbuisson/express-x` on npm.
7
+ **express-x** is a full-stack framework that unifies backend and frontend communication through WebSocket-based services.
8
+ It's published as `@jcbuisson/express-x` on npm.
8
9
 
9
- Core concept: ExpressX is a thin wrapper around Express.js and Socket.io that provides a service-oriented, real-time RPC pattern. Clients call server-side service methods via WebSocket and receive results as promises. Services can publish events to channels, enabling pub/sub-style real-time updates.
10
+ Core concept: ExpressX is a thin wrapper around Express.js and Socket.io that provides a service-oriented, real-time RPC pattern.
11
+ Clients call server-side service methods via WebSocket and receive results as promises.
12
+ Services can publish events to channels, enabling pub/sub-style real-time updates.
10
13
 
11
14
  **Repository**: https://github.com/jcbuisson/express-x
12
15
  **Documentation**: https://expressx.jcbuisson.dev
13
- **Version**: 3.0.3 (ES modules)
16
+ **Version**: 3.1.0 (ES modules)
14
17
 
15
18
  ## Development Commands
16
19
 
@@ -25,22 +28,23 @@ npm test
25
28
  node --import tsx/esm --test test/sync.test.mjs
26
29
 
27
30
  # Run both test files
28
- node --import tsx/esm --test --test-force-exit test/round-trip.test.mjs test/sync.test.mjs
31
+ node --import tsx/esm --test --test-force-exit test/offline.test.mjs test/sync.test.mjs
29
32
 
30
33
  # Run a single named test (partial match on test name)
31
- node --import tsx/esm --test --test-force-exit --test-name-pattern="service call" test/round-trip.test.mjs
34
+ node --import tsx/esm --test --test-force-exit --test-name-pattern="service call" test/offline.test.mjs
32
35
  ```
33
36
 
34
37
  No build step — the source is run directly as ES modules.
35
38
 
36
39
  ## Architecture
37
40
 
38
- ### Single Source File
39
- - `src/server.mjs` is the complete server-side framework. All exports live here.
41
+ ### Source Files
42
+ - `src/server.mjs` complete server-side framework; all exports live here.
43
+ - `src/client.mjs` — local mirror of the client-side library (`@jcbuisson/express-x-client`). Used for development and to track changes before publishing.
40
44
 
41
45
  ### Related Packages (also authored here, published separately)
42
- - **`@jcbuisson/express-x-client`** (`node_modules/@jcbuisson/express-x-client/src/client.mts`) — client-side library: `createClient`, `offlinePlugin`, `Mutex`, `wherePredicate`. TypeScript source consumed directly via `tsx`.
43
- - **`@jcbuisson/express-x-drizzle`** (`node_modules/@jcbuisson/express-x-drizzle/src/drizzle-plugins.mjs`) — Drizzle ORM variant of the offline plugin. The installed version may lag behind local development; the authoritative Drizzle plugin logic for tests is wired via `serverApp.configure(drizzleOfflinePlugin, ...)`.
46
+ - **`@jcbuisson/express-x-client`** (`node_modules/@jcbuisson/express-x-client/src/client.mts`) — published client library: `createClient`, `offlinePlugin`, `Mutex`, `wherePredicate`. TypeScript source consumed directly via `tsx`. `src/client.mjs` is the local development counterpart.
47
+ - **`@jcbuisson/express-x-drizzle`** (`node_modules/@jcbuisson/express-x-drizzle/src/drizzle-plugins.mjs`) — Drizzle ORM offline plugin. The installed version may lag behind local development; tests wire it via `serverApp.configure(drizzleOfflinePlugin, ...)`.
44
48
 
45
49
  ### Core Concepts
46
50
 
@@ -52,7 +56,7 @@ No build step — the source is run directly as ES modules.
52
56
 
53
57
  4. **Channels & Pub/Sub**: Socket.io rooms. After a service method completes, `service.publishFunction(context)` returns channel names; the result is broadcast as `service-event` to all sockets in those rooms. Clients subscribe with `app.service(name).on(action, handler)`.
54
58
 
55
- 5. **WebSocket Protocol**: Client emits `client-request {uid, name, action, args}` → server emits `client-response {uid, result|error}`. Requests are correlated by `uid`.
59
+ 5. **WebSocket Protocol**: Client calls `socket.timeout(ms).emitWithAck('client-request', { name, action, args })` → server handler receives `({ name, action, args }, ack)` and responds via `ack({ result })` or `ack({ error })`. Correlation is handled by Socket.io's built-in acknowledgment mechanism — no custom uid tracking.
56
60
 
57
61
  ### Offline Sync Architecture
58
62
 
@@ -83,9 +87,11 @@ The sync system reconciles a client Dexie cache with a server database. The prot
83
87
 
84
88
  ## Test Structure
85
89
 
86
- - `test/round-trip.test.mjs` — Integration tests. Spins up a real Express/Socket.io server on a random port, connects via `socket.io-client`, uses PGlite (in-memory Postgres) + Drizzle + Dexie (via `fake-indexeddb`). Tests cover the full client↔server sync protocol, pub/sub, rollbacks, and edge cases. Each test gets an isolated PGlite instance and unique model name.
90
+ - `test/offline.test.mjs` — Integration tests. Spins up a real Express/Socket.io server on a random port, connects via `socket.io-client`, uses PGlite (in-memory Postgres) + Drizzle + Dexie (via `fake-indexeddb`). Tests cover the full client↔server sync protocol, pub/sub, rollbacks, and edge cases. Each test gets an isolated PGlite instance and unique model name.
87
91
  - `test/sync.test.mjs` — Unit tests for `computeSyncResult` only. Imports from `#root/src/drizzle-plugins.mjs` (the `#root/*` alias maps to the repo root via `package.json` imports).
88
92
 
93
+ Each integration test uses `createTestContext(registerServices, opts)` which starts a server on a random port, connects a client, and returns a `cleanup()` function. Tests import from the published npm packages (`@jcbuisson/express-x`, `@jcbuisson/express-x-client`, `@jcbuisson/express-x-drizzle`), not from local `src/`.
94
+
89
95
  ## Configuration
90
96
 
91
97
  `expressX(config)` accepts:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x",
3
- "version": "3.0.5",
3
+ "version": "3.1.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/server.mjs",
@@ -18,7 +18,7 @@
18
18
  "#root/*": "./*"
19
19
  },
20
20
  "scripts": {
21
- "test": "node --import tsx/esm --test --test-force-exit test/round-trip.test.mjs"
21
+ "test": "node --import tsx/esm --test --test-force-exit test/offline.test.mjs test/sync.test.mjs"
22
22
  },
23
23
  "keywords": [],
24
24
  "dependencies": {
package/src/server.mjs CHANGED
@@ -6,10 +6,6 @@ import bcrypt from 'bcryptjs'
6
6
 
7
7
 
8
8
 
9
- // UTILISER L'ACKNOWLEDGEMENT : https://socket.io/docs/v4/#acknowledgements
10
-
11
-
12
-
13
9
 
14
10
  ////////////////////////// UTILITIES //////////////////////////
15
11
 
@@ -185,11 +181,11 @@ export function expressX(config) {
185
181
 
186
182
  /*
187
183
  * Handle websocket client request
188
- * Emit in return a 'client-response' message
184
+ * Respond via socket.io acknowledgment callback
189
185
  */
190
- socket.on('client-request', async ({ uid, name, action, args }) => {
186
+ socket.on('client-request', async ({ name, action, args }, ack) => {
191
187
  const trimmedArgs = args ? JSON.stringify(args).slice(0, 300) : ''
192
- app.log('verbose', `client-request ${uid} ${name} ${action} ${trimmedArgs}`)
188
+ app.log('verbose', `client-request ${name} ${action} ${trimmedArgs}`)
193
189
  if (name in services) {
194
190
  const service = services[name]
195
191
  try {
@@ -200,30 +196,21 @@ export function expressX(config) {
200
196
  caller: 'client',
201
197
  transport: 'ws',
202
198
  socket,
203
- // connectionId,
204
199
  serviceName: name,
205
200
  methodName: action,
206
201
  args,
207
202
  }
208
203
 
209
204
  try {
210
- // call method with context
211
- if (name === 'highlighted_part') {
212
- console.log('')
213
- }
214
205
  const result = await serviceMethod(context, ...args)
215
206
 
216
207
  const trimmedResult = result ? JSON.stringify(result).slice(0, 300) : ''
217
- app.log('verbose', `client-response ${uid} ${trimmedResult}`)
218
- socket.emit('client-response', {
219
- uid,
220
- result,
221
- })
208
+ app.log('verbose', `client-response ${trimmedResult}`)
209
+ ack({ result })
222
210
  } catch(err) {
223
211
  console.log('!!!!!!error', 'name', name, 'action', action, 'args', args, 'err.code', err.code, 'err.message', err.message)
224
212
  app.log('verbose', err.stack)
225
- socket.emit('client-response', {
226
- uid,
213
+ ack({
227
214
  error: {
228
215
  code: err.code || 'unknown-error',
229
216
  message: err.message,
@@ -232,8 +219,7 @@ export function expressX(config) {
232
219
  })
233
220
  }
234
221
  } else {
235
- socket.emit('client-response', {
236
- uid,
222
+ ack({
237
223
  error: {
238
224
  code: 'missing-method',
239
225
  message: `there is no method named '${action}' for service '${name}'`,
@@ -243,18 +229,16 @@ export function expressX(config) {
243
229
  } catch(err) {
244
230
  console.log('err', err)
245
231
  app.log('verbose', err.stack)
246
- socket.emit('client-response', {
247
- uid,
232
+ ack({
248
233
  error: {
249
234
  code: err.code || 'unknown-error',
250
235
  message: err.message,
251
236
  stack: err.stack,
252
- }
237
+ }
253
238
  })
254
239
  }
255
240
  } else {
256
- socket.emit('client-response', {
257
- uid,
241
+ ack({
258
242
  error: {
259
243
  code: 'missing-service',
260
244
  message: `there is no service named '${name}'`,
@@ -5,7 +5,7 @@ process.on('unhandledRejection', (reason, promise) => {
5
5
  console.error('UNHANDLED REJECTION:', reason)
6
6
  })
7
7
 
8
- import { test, describe } from 'node:test'
8
+ import { test, describe, after } from 'node:test'
9
9
  import assert from 'node:assert/strict'
10
10
  import { io as ioc } from 'socket.io-client'
11
11
  import { PGlite } from '@electric-sql/pglite'
@@ -53,7 +53,7 @@ async function createTestDb(modelName) {
53
53
  uid: text('uid').primaryKey(),
54
54
  label: text('label').notNull(),
55
55
  })
56
- return { db, metaTable, modelTable }
56
+ return { pglite, db, metaTable, modelTable }
57
57
  }
58
58
 
59
59
  // ─── Test context helper ──────────────────────────────────────────────────────
@@ -128,7 +128,7 @@ describe('Full offline-first client ↔ server protocol', () => {
128
128
 
129
129
  test('sync.go through socket: server records pulled into real Dexie', async () => {
130
130
  const modelName = `model${++dbCounter}`
131
- const { db, metaTable, modelTable } = await createTestDb(modelName)
131
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
132
132
 
133
133
  await db.insert(modelTable).values({ uid: 'r1', label: 'Vacances' })
134
134
  await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
@@ -148,12 +148,13 @@ describe('Full offline-first client ↔ server protocol', () => {
148
148
  assert.equal(r1.label, 'Vacances')
149
149
  } finally {
150
150
  await cleanup()
151
+ pglite.close()
151
152
  }
152
153
  })
153
154
 
154
155
  test('sync.go through socket: local Dexie record pushed to server', async () => {
155
156
  const modelName = `model${++dbCounter}`
156
- const { db, metaTable, modelTable } = await createTestDb(modelName)
157
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
157
158
 
158
159
  const { clientApp, cleanup } = await createTestContext(
159
160
  serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
@@ -172,12 +173,13 @@ describe('Full offline-first client ↔ server protocol', () => {
172
173
  assert.equal(rows[0].label, 'Formation')
173
174
  } finally {
174
175
  await cleanup()
176
+ pglite.close()
175
177
  }
176
178
  })
177
179
 
178
180
  test('record only on client, deleted → ignored on both sides', async () => {
179
181
  const modelName = `model${++dbCounter}`
180
- const { db, metaTable, modelTable } = await createTestDb(modelName)
182
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
181
183
 
182
184
  const { clientApp, cleanup } = await createTestContext(
183
185
  serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
@@ -197,6 +199,7 @@ describe('Full offline-first client ↔ server protocol', () => {
197
199
  assert.ok(!d1, 'Dexie should no longer hold the deleted record')
198
200
  } finally {
199
201
  await cleanup()
202
+ pglite.close()
200
203
  }
201
204
  })
202
205
 
@@ -242,7 +245,7 @@ describe('Full offline-first client ↔ server protocol', () => {
242
245
 
243
246
  test('deleted record is fully removed from Dexie metadata after sync', async () => {
244
247
  const modelName = `model${++dbCounter}`
245
- const { db, metaTable, modelTable } = await createTestDb(modelName)
248
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
246
249
 
247
250
  const { clientApp, cleanup } = await createTestContext(
248
251
  serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
@@ -266,12 +269,13 @@ describe('Full offline-first client ↔ server protocol', () => {
266
269
  assert.ok(!d1Meta, 'Dexie metadata should also be removed for deleted record')
267
270
  } finally {
268
271
  await cleanup()
272
+ pglite.close()
269
273
  }
270
274
  })
271
275
 
272
276
  test('record in both, DB newer → client cache is updated with server value', async () => {
273
277
  const modelName = `model${++dbCounter}`
274
- const { db, metaTable, modelTable } = await createTestDb(modelName)
278
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
275
279
 
276
280
  // Server has s1 at T2 (newer than client)
277
281
  await db.insert(modelTable).values({ uid: 's1', label: 'server-v2' })
@@ -300,6 +304,7 @@ describe('Full offline-first client ↔ server protocol', () => {
300
304
  assert.equal(s1Again.label, 'server-v2', 'second sync should not overwrite client value')
301
305
  } finally {
302
306
  await cleanup()
307
+ pglite.close()
303
308
  }
304
309
  })
305
310
 
@@ -346,7 +351,7 @@ describe('Full offline-first client ↔ server protocol', () => {
346
351
 
347
352
  test('record in both, client newer → server is updated via socket', async () => {
348
353
  const modelName = `model${++dbCounter}`
349
- const { db, metaTable, modelTable } = await createTestDb(modelName)
354
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
350
355
 
351
356
  await db.insert(modelTable).values({ uid: 'u1', label: 'old' })
352
357
  await db.insert(metaTable).values({ uid: 'u1', created_at: T0, updated_at: T1 })
@@ -369,12 +374,13 @@ describe('Full offline-first client ↔ server protocol', () => {
369
374
  assert.equal(clientValue.label, 'new', 'client Dexie should be unchanged')
370
375
  } finally {
371
376
  await cleanup()
377
+ pglite.close()
372
378
  }
373
379
  })
374
380
 
375
381
  test('record deleted on server while client was offline is re-created on reconnect', async () => {
376
382
  const modelName = `model${++dbCounter}`
377
- const { db, metaTable, modelTable } = await createTestDb(modelName)
383
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
378
384
 
379
385
  // r1 exists on server initially
380
386
  await db.insert(modelTable).values({ uid: 'r1', label: 'original' })
@@ -407,12 +413,13 @@ describe('Full offline-first client ↔ server protocol', () => {
407
413
  assert.ok(r1, 'client Dexie should still have r1 after sync')
408
414
  } finally {
409
415
  await cleanup()
416
+ pglite.close()
410
417
  }
411
418
  })
412
419
 
413
420
  test('offline changes are synced after server restart', async () => {
414
421
  const modelName = `model${++dbCounter}`
415
- const { db, metaTable, modelTable } = await createTestDb(modelName)
422
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
416
423
 
417
424
  // ─ Phase 1: start server, connect, register synchro scope ─
418
425
  const serverApp1 = expressX({})
@@ -479,6 +486,7 @@ describe('Full offline-first client ↔ server protocol', () => {
479
486
 
480
487
  socket.disconnect()
481
488
  await new Promise(resolve => serverApp2.io.close(resolve))
489
+ pglite.close()
482
490
  })
483
491
 
484
492
  test('updateWithMeta pub/sub handler tolerates undefined value (concurrent delete race)', async () => {
@@ -690,7 +698,7 @@ describe('Full offline-first client ↔ server protocol', () => {
690
698
  // other addClient records in the same batch.
691
699
  // Fix: use idbValues.put() (upsert) so a pre-existing uid is handled gracefully.
692
700
  const modelName = `model${++dbCounter}`
693
- const { db, metaTable, modelTable } = await createTestDb(modelName)
701
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
694
702
 
695
703
  await db.insert(modelTable).values({ uid: 'r1', label: 'server-r1' })
696
704
  await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
@@ -729,6 +737,7 @@ describe('Full offline-first client ↔ server protocol', () => {
729
737
  assert.equal(r2.label, 'server-r2')
730
738
  } finally {
731
739
  await cleanup()
740
+ pglite.close()
732
741
  }
733
742
  })
734
743
 
@@ -740,7 +749,7 @@ describe('Full offline-first client ↔ server protocol', () => {
740
749
  // Fix: use null instead of new Date() so the client's version "wins" once
741
750
  // (updateDatabase), the server gets metadata, and subsequent syncs see diff=0.
742
751
  const modelName = `model${++dbCounter}`
743
- const { db, metaTable, modelTable } = await createTestDb(modelName)
752
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
744
753
 
745
754
  // Server has s1 in model table but intentionally NO metadata row
746
755
  await db.insert(modelTable).values({ uid: 's1', label: 'server-label' })
@@ -774,6 +783,7 @@ describe('Full offline-first client ↔ server protocol', () => {
774
783
  assert.ok(s1Meta, 'server metadata must be created after the first sync (updateDatabase + upsert)')
775
784
  } finally {
776
785
  await cleanup()
786
+ pglite.close()
777
787
  }
778
788
  })
779
789
 
@@ -848,7 +858,7 @@ describe('Full offline-first client ↔ server protocol', () => {
848
858
  // TypeError that escapes to the outer try/catch, aborting the entire sync and
849
859
  // silently skipping every remaining addDatabase / updateDatabase entry.
850
860
  const modelName = `model${++dbCounter}`
851
- const { db, metaTable, modelTable } = await createTestDb(modelName)
861
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
852
862
 
853
863
  const { clientApp, cleanup } = await createTestContext(
854
864
  serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
@@ -876,6 +886,7 @@ describe('Full offline-first client ↔ server protocol', () => {
876
886
  'a2 must be pushed to the server even though a1 has missing metadata')
877
887
  } finally {
878
888
  await cleanup()
889
+ pglite.close()
879
890
  }
880
891
  })
881
892
 
@@ -887,7 +898,7 @@ describe('Full offline-first client ↔ server protocol', () => {
887
898
  // That record enters clientMetadataDict, appears as addDatabase to the server,
888
899
  // and if score is NOT NULL the insert fails, rolling back the Dexie record.
889
900
  const modelName = `model${++dbCounter}`
890
- const { db, metaTable, modelTable } = await createTestDb(modelName)
901
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
891
902
 
892
903
  const { clientApp, cleanup } = await createTestContext(
893
904
  serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
@@ -909,6 +920,7 @@ describe('Full offline-first client ↔ server protocol', () => {
909
920
  assert.ok( uids.includes('five-score'), 'score=5 must match { lte: 10 }')
910
921
  } finally {
911
922
  await cleanup()
923
+ pglite.close()
912
924
  }
913
925
  })
914
926
 
@@ -921,7 +933,7 @@ describe('Full offline-first client ↔ server protocol', () => {
921
933
  // the client includes the record in clientMetadataDict → addDatabase → if the
922
934
  // column is NOT NULL the insert fails → rollback deletes the record from Dexie.
923
935
  const modelName = `model${++dbCounter}`
924
- const { db, metaTable, modelTable } = await createTestDb(modelName)
936
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
925
937
 
926
938
  const { clientApp, cleanup } = await createTestContext(
927
939
  serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
@@ -942,6 +954,7 @@ describe('Full offline-first client ↔ server protocol', () => {
942
954
  assert.ok( uids.includes('r2'), 'record with score=5 must match { gte: 1 }')
943
955
  } finally {
944
956
  await cleanup()
957
+ pglite.close()
945
958
  }
946
959
  })
947
960
 
@@ -988,6 +1001,7 @@ describe('Full offline-first client ↔ server protocol', () => {
988
1001
  }
989
1002
  } finally {
990
1003
  await cleanup()
1004
+ pglite.close()
991
1005
  }
992
1006
  })
993
1007
 
@@ -1045,6 +1059,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1045
1059
  assert.ok(!uids.includes('hi'), 'score=15 is above range and must NOT be in Dexie')
1046
1060
  } finally {
1047
1061
  await cleanup()
1062
+ pglite.close()
1048
1063
  }
1049
1064
  })
1050
1065
 
@@ -1096,6 +1111,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1096
1111
  assert.ok( uids.includes('hi'), 'score=7 should be pulled (≥ 5)')
1097
1112
  } finally {
1098
1113
  await cleanup()
1114
+ pglite.close()
1099
1115
  }
1100
1116
  })
1101
1117
 
@@ -1154,7 +1170,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1154
1170
  // model table → throws → addDatabase catch deletes the record from Dexie.
1155
1171
  // Fix: model INSERT should use ON CONFLICT DO UPDATE just like the metadata INSERT.
1156
1172
  const modelName = `model${++dbCounter}`
1157
- const { db, metaTable, modelTable } = await createTestDb(modelName)
1173
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
1158
1174
 
1159
1175
  // Server already has r1 (createWithMeta_A already landed)
1160
1176
  await db.insert(modelTable).values({ uid: 'r1', label: 'original' })
@@ -1186,6 +1202,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1186
1202
  assert.ok(r1, 'client Dexie should retain r1 after idempotent createWithMeta')
1187
1203
  } finally {
1188
1204
  await cleanup()
1205
+ pglite.close()
1189
1206
  }
1190
1207
  })
1191
1208
 
@@ -1196,7 +1213,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1196
1213
  // throws a ConstraintError (PK already taken by the orphan), aborting the
1197
1214
  // entire addClient transaction — the record never arrives in the client cache.
1198
1215
  const modelName = `model${++dbCounter}`
1199
- const { db, metaTable, modelTable } = await createTestDb(modelName)
1216
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
1200
1217
 
1201
1218
  await db.insert(modelTable).values({ uid: 'r1', label: 'from-server' })
1202
1219
  await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
@@ -1221,6 +1238,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1221
1238
  assert.equal(r1.label, 'from-server')
1222
1239
  } finally {
1223
1240
  await cleanup()
1241
+ pglite.close()
1224
1242
  }
1225
1243
  })
1226
1244
 
@@ -1229,7 +1247,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1229
1247
  // nor the null branch nor the object branch — so boolean where-values are silently
1230
1248
  // ignored and every record passes, sending wrong records into clientMetadataDict.
1231
1249
  const modelName = `model${++dbCounter}`
1232
- const { db, metaTable, modelTable } = await createTestDb(modelName)
1250
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
1233
1251
 
1234
1252
  const { clientApp, cleanup } = await createTestContext(
1235
1253
  serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
@@ -1249,6 +1267,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1249
1267
  assert.ok(!uids.includes('r2'), 'active=false should NOT match { active: true }')
1250
1268
  } finally {
1251
1269
  await cleanup()
1270
+ pglite.close()
1252
1271
  }
1253
1272
  })
1254
1273
 
@@ -1259,7 +1278,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1259
1278
  // with user_uid === null. In synchronize() this causes records with a non-null
1260
1279
  // user_uid to appear as addDatabase, hit a PK conflict, and get deleted.
1261
1280
  const modelName = `model${++dbCounter}`
1262
- const { db, metaTable, modelTable } = await createTestDb(modelName)
1281
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
1263
1282
 
1264
1283
  const { clientApp, cleanup } = await createTestContext(
1265
1284
  serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
@@ -1279,6 +1298,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1279
1298
  assert.ok( uids.includes('r2'), 'record with user_uid=null should match { user_uid: null }')
1280
1299
  } finally {
1281
1300
  await cleanup()
1301
+ pglite.close()
1282
1302
  }
1283
1303
  })
1284
1304
 
@@ -1289,7 +1309,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1289
1309
  // and the sync pushes them to the server where they fail with PK conflicts,
1290
1310
  // then the rollback deletes them from Dexie (data loss).
1291
1311
  const modelName = `model${++dbCounter}`
1292
- const { db, metaTable, modelTable } = await createTestDb(modelName)
1312
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
1293
1313
 
1294
1314
  const { clientApp, cleanup } = await createTestContext(
1295
1315
  serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
@@ -1312,12 +1332,13 @@ describe('Full offline-first client ↔ server protocol', () => {
1312
1332
  assert.ok( uids.includes('zero'), 'score=0 should match { lte: 0 }')
1313
1333
  } finally {
1314
1334
  await cleanup()
1335
+ pglite.close()
1315
1336
  }
1316
1337
  })
1317
1338
 
1318
1339
  test('pub/sub createWithMeta event correctly updates a second client\'s Dexie', async () => {
1319
1340
  const modelName = `model${++dbCounter}`
1320
- const { db, metaTable, modelTable } = await createTestDb(modelName)
1341
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
1321
1342
 
1322
1343
  // Server with pub/sub: every client joins 'all' and createWithMeta broadcasts there
1323
1344
  const serverApp = expressX({})
@@ -1362,6 +1383,7 @@ describe('Full offline-first client ↔ server protocol', () => {
1362
1383
  socketA.disconnect()
1363
1384
  socketB.disconnect()
1364
1385
  await new Promise(resolve => serverApp.io.close(resolve))
1386
+ pglite.close()
1365
1387
  }
1366
1388
  })
1367
1389
 
@@ -1,7 +1,7 @@
1
1
  import { test, describe, mock } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
 
4
- import { computeSyncResult } from '#root/src/drizzle-plugins.mjs'
4
+ import { computeSyncResult } from '#root/src/server.mjs'
5
5
 
6
6
  const T0 = new Date('2026-01-01T00:00:00Z')
7
7
  const T1 = new Date('2026-01-02T00:00:00Z')