@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 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, cutoffDate, clientMetadataDict)` with its local metadata snapshot.
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.11",
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.6",
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",
@@ -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, cutoff, clientMetadataDict) => ({
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, _cutoffDate, clientMetadataDict) => {
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 cutoff should remain while reconnect sync is running')
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 cutoff should clear after reconnect sync completes')
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