@jcbuisson/express-x 3.1.11 → 3.1.12
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 +1 -1
- package/package.json +2 -2
- package/test/offline.test.mjs +131 -4
package/CLAUDE.md
CHANGED
|
@@ -61,7 +61,7 @@ No build step — the source is run directly as ES modules.
|
|
|
61
61
|
|
|
62
62
|
The sync system reconciles a client Dexie cache with a server database. The protocol:
|
|
63
63
|
|
|
64
|
-
1. Client calls `sync.go(modelName, where,
|
|
64
|
+
1. Client calls `sync.go(modelName, where, clientMetadataDict)` with its local metadata snapshot.
|
|
65
65
|
2. Server computes diff using `computeSyncResult` (exported from `server.mjs`, pure function, no I/O).
|
|
66
66
|
3. Server returns `{ addClient, updateClient, deleteClient, addDatabase, updateDatabase }`.
|
|
67
67
|
4. Client applies each batch: puts addClient records into Dexie, deletes deleteClient, updates updateClient, then calls `createWithMeta`/`updateWithMeta` for addDatabase/updateDatabase.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jcbuisson/express-x",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.12",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.mjs",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@electric-sql/pglite": "^0.4.5",
|
|
32
32
|
"@jcbuisson/express-x-client": "^3.1.11",
|
|
33
|
-
"@jcbuisson/express-x-drizzle": "^3.1.
|
|
33
|
+
"@jcbuisson/express-x-drizzle": "^3.1.11",
|
|
34
34
|
"@vueuse/core": "^14.3.0",
|
|
35
35
|
"dexie": "^4.4.2",
|
|
36
36
|
"drizzle-orm": "^0.45.2",
|
package/test/offline.test.mjs
CHANGED
|
@@ -8,6 +8,7 @@ process.on('unhandledRejection', (reason, promise) => {
|
|
|
8
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
|
+
import { firstValueFrom } from 'rxjs'
|
|
11
12
|
import { PGlite } from '@electric-sql/pglite'
|
|
12
13
|
import { drizzle } from 'drizzle-orm/pglite'
|
|
13
14
|
import { pgTable, text, timestamp, integer } from 'drizzle-orm/pg-core'
|
|
@@ -350,7 +351,7 @@ describe('Full offline-first client ↔ server protocol', () => {
|
|
|
350
351
|
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
351
352
|
serverApp.createService('sync', {
|
|
352
353
|
// Tell the client both u1 and u2 need to be pushed (client is newer for both)
|
|
353
|
-
go: async (mn, where,
|
|
354
|
+
go: async (mn, where, clientMetadataDict) => ({
|
|
354
355
|
addClient: [], updateClient: [], deleteClient: [], addDatabase: [],
|
|
355
356
|
updateDatabase: [ clientMetadataDict['u1'], clientMetadataDict['u2'] ],
|
|
356
357
|
}),
|
|
@@ -546,7 +547,7 @@ describe('Full offline-first client ↔ server protocol', () => {
|
|
|
546
547
|
findMany: async () => [],
|
|
547
548
|
})
|
|
548
549
|
app.createService('sync', {
|
|
549
|
-
go: async (_modelName, _where,
|
|
550
|
+
go: async (_modelName, _where, clientMetadataDict) => {
|
|
550
551
|
if (slowSync) {
|
|
551
552
|
resolveSyncStarted()
|
|
552
553
|
await syncRelease
|
|
@@ -603,7 +604,7 @@ describe('Full offline-first client ↔ server protocol', () => {
|
|
|
603
604
|
await reconnected
|
|
604
605
|
await syncStarted
|
|
605
606
|
|
|
606
|
-
assert.ok(clientApp.disconnectedDate, 'disconnect
|
|
607
|
+
assert.ok(clientApp.disconnectedDate, 'disconnect marker should remain while reconnect sync is running')
|
|
607
608
|
assert.equal(serverRows.length, 0, 'offline row should not be pushed before sync is released')
|
|
608
609
|
|
|
609
610
|
releaseSync()
|
|
@@ -613,7 +614,7 @@ describe('Full offline-first client ↔ server protocol', () => {
|
|
|
613
614
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
614
615
|
}
|
|
615
616
|
|
|
616
|
-
assert.equal(clientApp.disconnectedDate, null, 'disconnect
|
|
617
|
+
assert.equal(clientApp.disconnectedDate, null, 'disconnect marker should clear after reconnect sync completes')
|
|
617
618
|
assert.deepEqual(serverRows, [{ uid: 'reconnect-1', label: 'from reconnect' }])
|
|
618
619
|
|
|
619
620
|
socket.disconnect()
|
|
@@ -1246,6 +1247,132 @@ describe('Full offline-first client ↔ server protocol', () => {
|
|
|
1246
1247
|
}
|
|
1247
1248
|
})
|
|
1248
1249
|
|
|
1250
|
+
test('sync.go serializes overlapping where scopes on the server', async () => {
|
|
1251
|
+
const modelName = `model${++dbCounter}`
|
|
1252
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1253
|
+
|
|
1254
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'x' })
|
|
1255
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
1256
|
+
|
|
1257
|
+
const serverApp = expressX({})
|
|
1258
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
1259
|
+
|
|
1260
|
+
const modelService = serverApp.service(modelName)
|
|
1261
|
+
const originalFindMany = modelService.findMany
|
|
1262
|
+
let releaseBroadFindMany
|
|
1263
|
+
let broadFindManyEntered
|
|
1264
|
+
let narrowFindManyEntered = false
|
|
1265
|
+
const broadFindManyStarted = new Promise(resolve => { broadFindManyEntered = resolve })
|
|
1266
|
+
const broadFindManyRelease = new Promise(resolve => { releaseBroadFindMany = resolve })
|
|
1267
|
+
|
|
1268
|
+
modelService.findMany = async (where) => {
|
|
1269
|
+
if (Object.keys(where).length === 0) {
|
|
1270
|
+
broadFindManyEntered()
|
|
1271
|
+
await broadFindManyRelease
|
|
1272
|
+
} else {
|
|
1273
|
+
narrowFindManyEntered = true
|
|
1274
|
+
}
|
|
1275
|
+
return originalFindMany(where)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
try {
|
|
1279
|
+
const broadSync = serverApp.service('sync').go(modelName, {}, {})
|
|
1280
|
+
await broadFindManyStarted
|
|
1281
|
+
|
|
1282
|
+
const narrowSync = serverApp.service('sync').go(modelName, { label: 'x' }, {})
|
|
1283
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
1284
|
+
assert.equal(narrowFindManyEntered, false, 'narrow overlapping sync must wait for broad sync')
|
|
1285
|
+
|
|
1286
|
+
releaseBroadFindMany()
|
|
1287
|
+
await Promise.all([broadSync, narrowSync])
|
|
1288
|
+
assert.equal(narrowFindManyEntered, true, 'narrow sync should run after broad sync releases')
|
|
1289
|
+
} finally {
|
|
1290
|
+
pglite.close()
|
|
1291
|
+
}
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
test('getObservable syncs persisted where scopes on a new client session', async () => {
|
|
1295
|
+
// If whereList was left in IndexedDB by a previous run, addSynchroDBWhere()
|
|
1296
|
+
// returns false. getObservable() must still sync once for this app session;
|
|
1297
|
+
// otherwise the first emission can be stale forever until a manual sync/reconnect.
|
|
1298
|
+
const modelName = `model${++dbCounter}`
|
|
1299
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1300
|
+
|
|
1301
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'from-server' })
|
|
1302
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
1303
|
+
|
|
1304
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1305
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1306
|
+
{ useOfflinePlugin: true },
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
try {
|
|
1310
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1311
|
+
await model.db.whereList.add({ sortedjson: '{}' })
|
|
1312
|
+
|
|
1313
|
+
const rows = await firstValueFrom(model.getObservable({}))
|
|
1314
|
+
|
|
1315
|
+
assert.deepEqual(rows.map(row => row.uid), ['r1'])
|
|
1316
|
+
} finally {
|
|
1317
|
+
await cleanup()
|
|
1318
|
+
pglite.close()
|
|
1319
|
+
}
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
test('initial connect syncs scopes registered while the client started offline', async () => {
|
|
1323
|
+
const modelName = `model${++dbCounter}`
|
|
1324
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1325
|
+
|
|
1326
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'from-server' })
|
|
1327
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
1328
|
+
|
|
1329
|
+
const serverApp = expressX({})
|
|
1330
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
1331
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
1332
|
+
const port = serverApp.httpServer.address().port
|
|
1333
|
+
|
|
1334
|
+
const socket = ioc(`http://localhost:${port}`, {
|
|
1335
|
+
transports: ['websocket'],
|
|
1336
|
+
autoConnect: false,
|
|
1337
|
+
})
|
|
1338
|
+
const clientApp = createClient(socket, { debug: false })
|
|
1339
|
+
offlinePlugin(clientApp)
|
|
1340
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1341
|
+
let subscription
|
|
1342
|
+
|
|
1343
|
+
try {
|
|
1344
|
+
await model.db.whereList.add({ sortedjson: '{}' })
|
|
1345
|
+
|
|
1346
|
+
const observedServerRow = new Promise((resolve, reject) => {
|
|
1347
|
+
const timeout = setTimeout(() => reject(new Error('timed out waiting for synced row')), 1000)
|
|
1348
|
+
subscription = model.getObservable({}).subscribe({
|
|
1349
|
+
next: rows => {
|
|
1350
|
+
if (rows.some(row => row.uid === 'r1')) {
|
|
1351
|
+
clearTimeout(timeout)
|
|
1352
|
+
subscription.unsubscribe()
|
|
1353
|
+
resolve(rows)
|
|
1354
|
+
}
|
|
1355
|
+
},
|
|
1356
|
+
error: reject,
|
|
1357
|
+
})
|
|
1358
|
+
})
|
|
1359
|
+
|
|
1360
|
+
socket.connect()
|
|
1361
|
+
await new Promise((resolve, reject) => {
|
|
1362
|
+
socket.on('connect', resolve)
|
|
1363
|
+
socket.on('connect_error', reject)
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
const rows = await observedServerRow
|
|
1367
|
+
assert.deepEqual(rows.map(row => row.uid), ['r1'])
|
|
1368
|
+
} finally {
|
|
1369
|
+
if (subscription) subscription.unsubscribe()
|
|
1370
|
+
socket.disconnect()
|
|
1371
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
1372
|
+
pglite.close()
|
|
1373
|
+
}
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1249
1376
|
test('update() rollback clears stale updated_at from metadata', async () => {
|
|
1250
1377
|
// Optimistic update sets updated_at = now in metadata before the server responds.
|
|
1251
1378
|
// On rejection the rollback does db.metadata.update(uid, previousMetadata), but
|