@jcbuisson/express-x 3.0.4 → 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,106 +4,102 @@ 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
- ## Architecture
18
+ ## Development Commands
16
19
 
17
- ### Single Entry Point
18
- - `/src/server.mjs` (625 lines) is the complete framework implementation. This is the only source file.
19
- - Exports the main `expressX()` factory function and various utilities/plugins.
20
+ ```bash
21
+ # Install dependencies
22
+ npm install
20
23
 
21
- ### Core Concepts
24
+ # Run integration tests (round-trip + offline sync via real sockets/PGlite/Dexie)
25
+ npm test
22
26
 
23
- 1. **Services**: Named groups of callable methods. Created via `app.createService(name, methods)`. Each method receives a `context` object containing metadata about the call (transport, socket, service/method names, args).
27
+ # Run unit tests for the sync algorithm only
28
+ node --import tsx/esm --test test/sync.test.mjs
24
29
 
25
- 2. **Context Object**: Contains execution metadata:
26
- - `app`, `socket`, `serviceName`, `methodName`, `args`, `result`
27
- - `caller` ('client' or 'server'), `transport` ('ws')
28
- - Modified by hooks before/after method execution
30
+ # Run both test files
31
+ node --import tsx/esm --test --test-force-exit test/offline.test.mjs test/sync.test.mjs
29
32
 
30
- 3. **Hooks**: Pre/post-execution functions that can modify context or validate requests. Attached to services via `service.hooks()`. Support method-specific and "all methods" variants:
31
- ```js
32
- service.hooks({
33
- before: { methodName: [hook1, hook2], all: [globalHook] },
34
- after: { methodName: [hook], all: [globalHook] }
35
- })
36
- ```
33
+ # Run a single named test (partial match on test name)
34
+ node --import tsx/esm --test --test-force-exit --test-name-pattern="service call" test/offline.test.mjs
35
+ ```
37
36
 
38
- 4. **Channels & Pub/Sub**: Enable real-time broadcasting:
39
- - Channels are Socket.io rooms with names
40
- - Services attach a `publishFunction` via `service.publish()` that determines which channels receive events after method execution
41
- - Clients join/subscribe to channels via `app.joinChannel(name, socket)`
42
- - Events are published as `service-event` messages containing `{name, action, result}`
37
+ No build step the source is run directly as ES modules.
43
38
 
44
- 5. **WebSocket Protocol**: Single `client-request` event from client → `client-response` event from server, with uid-based request/response correlation. Errors are serialized and sent back to client.
39
+ ## Architecture
45
40
 
46
- ### Key Plugins
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.
47
44
 
48
- - **reloadPlugin**: Manages connection recovery across page reloads/reconnects by caching socket data and rooms.
49
- - **offlinePlugin**: Implements offline-first CRUD operations with Prisma. Provides `sync.go()` service method that reconciles client cache with database using timestamps (created_at, updated_at, deleted_at).
45
+ ### Related Packages (also authored here, published separately)
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, ...)`.
50
48
 
51
- ### Common Hook Patterns (Exported Utilities)
52
- - `addTimestamp(field)`: Adds ISO timestamp to result
53
- - `hashPassword(field)`: Hashes password using bcryptjs
54
- - `protect(field)`: Removes sensitive fields from results
49
+ ### Core Concepts
55
50
 
56
- ### Error Handling
57
- - `EXError(code, message)`: Custom error class with error codes. Method errors are caught, serialized, and sent to client as rejected promises.
51
+ 1. **Services**: Named groups of callable methods. Created via `app.createService(name, methods)`. Each method is wrapped to support hooks and pub/sub.
58
52
 
59
- ## Development Commands
53
+ 2. **Context Object**: Passed to every hooked method — contains `app`, `socket`, `serviceName`, `methodName`, `args`, `result`, `caller` ('client'|'server'), `transport` ('ws').
60
54
 
61
- ```bash
62
- # Install dependencies
63
- npm install
55
+ 3. **Hooks**: Pre/post-execution filters attached via `service.hooks({ before: { methodName: [...], all: [...] }, after: {...} })`. Hooks run: `before.all` → `before.method` → method → `after.method` → `after.all`.
64
56
 
65
- # Prisma (optional, if using database)
66
- npm run generate # Generate Prisma client
67
- npm run migrate # Run Prisma migrations
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)`.
68
58
 
69
- # No dedicated test, build, or lint scripts
70
- # Manual testing via client-server examples in README
71
- ```
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.
72
60
 
73
- ## Key Dependencies
74
- - **express** (^4.18.2): HTTP server framework
75
- - **socket.io** (^4.6.0): WebSocket server
76
- - **bcryptjs** (^2.4.3): Password hashing
77
- - **config** (^3.3.9): Configuration management
78
- - Peer: Prisma for database CRUD services (optional)
61
+ ### Offline Sync Architecture
79
62
 
80
- ## File Structure
81
- ```
82
- express-x/
83
- src/server.mjs # Complete framework (625 lines)
84
- package.json # v3.0.3, ES module type
85
- README.md # Getting started + examples
86
- .vscode/launch.json # Debug config
87
- ```
63
+ The sync system reconciles a client Dexie cache with a server database. The protocol:
64
+
65
+ 1. Client calls `sync.go(modelName, where, cutoffDate, clientMetadataDict)` with its local metadata snapshot.
66
+ 2. Server computes diff using `computeSyncResult` (exported from `server.mjs`, pure function, no I/O).
67
+ 3. Server returns `{ addClient, updateClient, deleteClient, addDatabase, updateDatabase }`.
68
+ 4. Client applies each batch: puts addClient records into Dexie, deletes deleteClient, updates updateClient, then calls `createWithMeta`/`updateWithMeta` for addDatabase/updateDatabase.
88
69
 
89
- ## Implementation Notes
70
+ **Two offline plugin implementations:**
71
+ - `offlinePlugin` in `server.mjs` — Prisma-based (legacy, uses `prisma.$transaction`).
72
+ - `drizzleOfflinePlugin` from `@jcbuisson/express-x-drizzle` — Drizzle ORM-based, used in all tests.
90
73
 
91
- 1. **Context Execution**: Service methods are wrapped with hook execution:
92
- - All `before.all` hooks → method-specific `before` hooks → method execution → method-specific `after` hooks → all `after.all` hooks
93
- - Hooks receive context and can throw errors (which propagate to client)
74
+ **`computeSyncResult(databaseValuesDict, clientMetadataDict, databaseMetadataDict)`** is the pure sync algorithm exported from `server.mjs`. It uses `null` (not `new Date()`) as the fallback `created_at` for missing metadata, so a record without metadata is treated as older than any client record, preventing infinite `updateClient` loops.
94
75
 
95
- 2. **Event Publishing**: After a service method completes, `publishFunction` is called with the context. It returns an array of channel names. The result is broadcast to all sockets in those channels as a `service-event`.
76
+ ### Key Plugins
77
+ - **reloadPlugin**: Caches socket data/rooms on disconnect; restores them on reconnect via `cnx-transfer` socket event.
78
+ - **offlinePlugin (Prisma)**: CRUD services + `sync.go` for offline-first with Prisma.
79
+ - **drizzleOfflinePlugin**: Same pattern using Drizzle ORM + PGlite-compatible transactions.
96
80
 
97
- 3. **Connection State**: Socket.io's built-in `socket.data` object stores per-connection state. Socket.io's connection recovery feature (2-minute default) restores socket.id, rooms, and data on network reconnects.
81
+ ### Exported Utilities
82
+ - `Mutex` — simple async mutex used by sync and offline plugins.
83
+ - `truncateString` — log truncation helper.
84
+ - `addTimestamp(field)`, `hashPassword(field)`, `protect(field)` — common hook factories.
85
+ - `EXError(code, message)` — custom error class; `code` is serialized and sent to the client.
86
+ - `computeSyncResult` — pure sync diff algorithm.
98
87
 
99
- 4. **Offline Sync**: The `offlinePlugin` uses transaction-safe Prisma operations and a mutex to ensure consistent synchronization. Clients track operation timestamps and the sync algorithm compares created_at/updated_at to determine add/update/delete operations.
88
+ ## Test Structure
100
89
 
101
- 5. **Logging**: App-level logging via `app.log(severity, message)`. If no logger is configured, falls back to console.log.
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.
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).
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/`.
102
94
 
103
95
  ## Configuration
104
96
 
105
- ExpressX accepts an optional config object (first parameter to `expressX(config)`):
106
- - `config.WS_PATH` (default: '/socket.io/'): Socket.io path
97
+ `expressX(config)` accepts:
98
+ - `config.WS_PATH` (default: `'/socket.io/'`): Socket.io path
107
99
  - `config.maxDisconnectionDuration` (default: 2 min): Connection recovery window
108
- - Any config is accessible server-side via `app.get('config')`
100
+ - Accessible server-side as `app.get('config')`
109
101
 
102
+ ## Key Dependencies
103
+ - **express** / **socket.io**: HTTP + WebSocket server
104
+ - **bcryptjs**: Password hashing (hook utility)
105
+ - Dev: **@electric-sql/pglite** (in-memory Postgres for tests), **drizzle-orm**, **fake-indexeddb** (Dexie in Node), **tsx** (runs `.mts` client source directly), **dexie**, **rxjs**, **uuid**, **@vueuse/core**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x",
3
- "version": "3.0.4",
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": {
@@ -30,7 +30,7 @@
30
30
  "devDependencies": {
31
31
  "@electric-sql/pglite": "^0.4.5",
32
32
  "@jcbuisson/express-x-client": "^3.0.5",
33
- "@jcbuisson/express-x-drizzle": "^1.0.5",
33
+ "@jcbuisson/express-x-drizzle": "^1.0.7",
34
34
  "@vueuse/core": "^14.3.0",
35
35
  "dexie": "^4.4.2",
36
36
  "drizzle-orm": "^0.45.2",
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 ──────────────────────────────────────────────────────
@@ -89,7 +89,7 @@ async function createTestContext(registerServices, { useOfflinePlugin = false }
89
89
 
90
90
  // ─────────────────────────────────────────────────────────────────────────────
91
91
 
92
- describe('Full client ↔ server protocol', () => {
92
+ describe('Full offline-first client ↔ server protocol', () => {
93
93
 
94
94
  test('service call is routed through client-request / client-response', async () => {
95
95
  const { clientApp, cleanup } = await createTestContext(serverApp => {
@@ -128,7 +128,7 @@ describe('Full 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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')
@@ -1,11 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(git -C /Users/chris/workspaces/NPM/express-x log --oneline --all)",
5
- "Bash(git *)",
6
- "Bash(npm scripts *)",
7
- "Bash(npm test *)",
8
- "Bash(npm install *)"
9
- ]
10
- }
11
- }