@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 +66 -70
- package/package.json +3 -3
- package/src/server.mjs +10 -26
- package/test/{round-trip.test.mjs → offline.test.mjs} +44 -22
- package/test/sync.test.mjs +1 -1
- package/.claude/settings.local.json +0 -11
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.
|
|
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.
|
|
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
|
|
16
|
+
**Version**: 3.1.0 (ES modules)
|
|
14
17
|
|
|
15
|
-
##
|
|
18
|
+
## Development Commands
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
```bash
|
|
21
|
+
# Install dependencies
|
|
22
|
+
npm install
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
# Run integration tests (round-trip + offline sync via real sockets/PGlite/Dexie)
|
|
25
|
+
npm test
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
# Run unit tests for the sync algorithm only
|
|
28
|
+
node --import tsx/esm --test test/sync.test.mjs
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
## Architecture
|
|
45
40
|
|
|
46
|
-
###
|
|
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
|
-
|
|
49
|
-
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
2. **Context Object**: Passed to every hooked method — contains `app`, `socket`, `serviceName`, `methodName`, `args`, `result`, `caller` ('client'|'server'), `transport` ('ws').
|
|
60
54
|
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
## Test Structure
|
|
100
89
|
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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/
|
|
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.
|
|
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
|
-
*
|
|
184
|
+
* Respond via socket.io acknowledgment callback
|
|
189
185
|
*/
|
|
190
|
-
socket.on('client-request', async ({
|
|
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 ${
|
|
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 ${
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/test/sync.test.mjs
CHANGED
|
@@ -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/
|
|
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')
|