@jcbuisson/express-x 3.1.6 → 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.6",
3
+ "version": "3.1.12",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/server.mjs",
@@ -29,8 +29,8 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@electric-sql/pglite": "^0.4.5",
32
- "@jcbuisson/express-x-client": "^3.1.6",
33
- "@jcbuisson/express-x-drizzle": "^3.1.6",
32
+ "@jcbuisson/express-x-client": "^3.1.11",
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/src/server.mjs CHANGED
@@ -46,6 +46,17 @@ export class Mutex {
46
46
 
47
47
  ////////////////////////// SYNC ALGORITHM (common to all offline plugins) //////////////////////////
48
48
 
49
+ // This sync algorithm uses the metadata table as its only source of truth for existence and recency. Manual SQL changes
50
+ // create a split reality — the data table and the metadata table diverge — and the algorithm's set-intersection logic
51
+ // misidentifies the divergence as intentional client-side changes that must be propagated.
52
+ // If you need to seed or manipulate data outside the framework, you must also write the corresponding rows into the
53
+ // metadata table with valid created_at/updated_at/deleted_at values.
54
+
55
+ // The metadata table is a tombstone log that grows monotonically. Every record ever created leaves a permanent entry. For
56
+ // long-lived applications with high churn this is worth planning for: you can only safely purge a tombstone once you're certain no
57
+ // client still holds a copy of that record (i.e., every client has synced at least once after the deletion). There's currently no
58
+ // pruning mechanism in the framework.
59
+
49
60
  export function computeSyncResult(databaseValuesDict, clientMetadataDict, databaseMetadataDict) {
50
61
  const onlyDatabaseIds = new Set()
51
62
  const onlyClientIds = new Set()
@@ -79,11 +90,18 @@ export function computeSyncResult(databaseValuesDict, clientMetadataDict, databa
79
90
 
80
91
  for (const uid of databaseAndClientIds) {
81
92
  const clientMetaData = clientMetadataDict[uid]
93
+ const databaseMetaData = databaseMetadataDict[uid] || { uid, created_at: null }
82
94
  if (clientMetaData.deleted_at) {
83
- deleteDatabase.push(uid)
84
- deleteClient.push([uid, clientMetaData.deleted_at])
95
+ const clientDeletedAt = new Date(clientMetaData.deleted_at)
96
+ const databaseUpdatedAt = new Date(databaseMetaData.updated_at || databaseMetaData.created_at)
97
+ const diff = clientDeletedAt - databaseUpdatedAt
98
+ if (diff >= 0) {
99
+ deleteDatabase.push(uid)
100
+ deleteClient.push([uid, clientMetaData.deleted_at])
101
+ } else {
102
+ updateClient.push([databaseValuesDict[uid], databaseMetaData])
103
+ }
85
104
  } else {
86
- const databaseMetaData = databaseMetadataDict[uid] || { uid, created_at: null }
87
105
  const clientUpdatedAt = new Date(clientMetaData.updated_at || clientMetaData.created_at)
88
106
  const databaseUpdatedAt = new Date(databaseMetaData.updated_at || databaseMetaData.created_at)
89
107
  const diff = clientUpdatedAt - databaseUpdatedAt
@@ -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'
@@ -308,6 +309,41 @@ describe('Full offline-first client ↔ server protocol', () => {
308
309
  }
309
310
  })
310
311
 
312
+ test('record in both, DB created_at newer → client metadata is fully replaced', async () => {
313
+ const modelName = `model${++dbCounter}`
314
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
315
+
316
+ // Server is newer by created_at only; updated_at is null.
317
+ await db.insert(modelTable).values({ uid: 's1', label: 'server-created-later' })
318
+ await db.insert(metaTable).values({ uid: 's1', created_at: T2 })
319
+
320
+ const { clientApp, cleanup } = await createTestContext(
321
+ serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
322
+ { useOfflinePlugin: true },
323
+ )
324
+
325
+ try {
326
+ const model = clientApp.createOfflineModel(modelName, ['label'])
327
+ await model.db.values.add({ uid: 's1', label: 'client-created-earlier' })
328
+ await model.db.metadata.add({ uid: 's1', created_at: T1 })
329
+ await model.addSynchroWhere({})
330
+
331
+ await model.synchronizeAll()
332
+
333
+ const s1 = await model.db.values.get('s1')
334
+ const meta = await model.db.metadata.get('s1')
335
+ assert.equal(s1.label, 'server-created-later')
336
+ assert.equal(new Date(meta.created_at).getTime(), T2.getTime())
337
+
338
+ await model.synchronizeAll()
339
+ const s1Again = await model.db.values.get('s1')
340
+ assert.equal(s1Again.label, 'server-created-later')
341
+ } finally {
342
+ await cleanup()
343
+ pglite.close()
344
+ }
345
+ })
346
+
311
347
  test('one failed updateWithMeta does not abort remaining updateDatabase entries', async () => {
312
348
  const modelName = `model${++dbCounter}`
313
349
  const serverUpdated = {}
@@ -315,7 +351,7 @@ describe('Full offline-first client ↔ server protocol', () => {
315
351
  const { clientApp, cleanup } = await createTestContext(serverApp => {
316
352
  serverApp.createService('sync', {
317
353
  // Tell the client both u1 and u2 need to be pushed (client is newer for both)
318
- go: async (mn, where, cutoff, clientMetadataDict) => ({
354
+ go: async (mn, where, clientMetadataDict) => ({
319
355
  addClient: [], updateClient: [], deleteClient: [], addDatabase: [],
320
356
  updateDatabase: [ clientMetadataDict['u1'], clientMetadataDict['u2'] ],
321
357
  }),
@@ -489,6 +525,102 @@ describe('Full offline-first client ↔ server protocol', () => {
489
525
  pglite.close()
490
526
  })
491
527
 
528
+ test('reconnect keeps disconnectedDate until automatic sync completes', async () => {
529
+ const modelName = `model${++dbCounter}`
530
+ const serverRows = []
531
+ let resolveSyncStarted
532
+ let releaseSync
533
+ let resolveCreate
534
+ const syncStarted = new Promise(resolve => { resolveSyncStarted = resolve })
535
+ const syncRelease = new Promise(resolve => { releaseSync = resolve })
536
+ const createDone = new Promise(resolve => { resolveCreate = resolve })
537
+
538
+ function registerServices(app, { slowSync = false } = {}) {
539
+ app.createService(modelName, {
540
+ createWithMeta: async (uid, data, created_at) => {
541
+ serverRows.push({ uid, ...data })
542
+ resolveCreate()
543
+ return [{ uid, ...data }, { uid, created_at, updated_at: null, deleted_at: null }]
544
+ },
545
+ updateWithMeta: async () => {},
546
+ deleteWithMeta: async () => {},
547
+ findMany: async () => [],
548
+ })
549
+ app.createService('sync', {
550
+ go: async (_modelName, _where, clientMetadataDict) => {
551
+ if (slowSync) {
552
+ resolveSyncStarted()
553
+ await syncRelease
554
+ }
555
+ return {
556
+ addClient: [],
557
+ updateClient: [],
558
+ deleteClient: [],
559
+ addDatabase: Object.values(clientMetadataDict),
560
+ updateDatabase: [],
561
+ }
562
+ },
563
+ })
564
+ }
565
+
566
+ const serverApp1 = expressX({})
567
+ registerServices(serverApp1)
568
+ await new Promise(resolve => serverApp1.httpServer.listen(0, resolve))
569
+ const port = serverApp1.httpServer.address().port
570
+
571
+ const socket = ioc(`http://localhost:${port}`, {
572
+ transports: ['websocket'],
573
+ autoConnect: false,
574
+ })
575
+ const clientApp = createClient(socket, { debug: false })
576
+ offlinePlugin(clientApp)
577
+ socket.connect()
578
+ await new Promise((resolve, reject) => {
579
+ socket.on('connect', resolve)
580
+ socket.on('connect_error', reject)
581
+ })
582
+
583
+ const model = clientApp.createOfflineModel(modelName, ['label'])
584
+ await model.addSynchroWhere({})
585
+ await model.synchronizeAll()
586
+
587
+ const disconnected = new Promise(resolve => socket.once('disconnect', resolve))
588
+ serverApp1.io.disconnectSockets(true)
589
+ await new Promise(resolve => serverApp1.io.close(resolve))
590
+ await disconnected
591
+
592
+ await model.db.values.add({ uid: 'reconnect-1', label: 'from reconnect' })
593
+ await model.db.metadata.add({ uid: 'reconnect-1', created_at: T1 })
594
+
595
+ const serverApp2 = expressX({})
596
+ registerServices(serverApp2, { slowSync: true })
597
+ await new Promise(resolve => serverApp2.httpServer.listen(port, resolve))
598
+
599
+ const reconnected = new Promise((resolve, reject) => {
600
+ const timer = setTimeout(() => reject(new Error('reconnect timeout')), 5000)
601
+ socket.once('connect', () => { clearTimeout(timer); resolve() })
602
+ })
603
+ socket.connect()
604
+ await reconnected
605
+ await syncStarted
606
+
607
+ assert.ok(clientApp.disconnectedDate, 'disconnect marker should remain while reconnect sync is running')
608
+ assert.equal(serverRows.length, 0, 'offline row should not be pushed before sync is released')
609
+
610
+ releaseSync()
611
+ await createDone
612
+
613
+ for (let i = 0; i < 50 && clientApp.disconnectedDate; i++) {
614
+ await new Promise(resolve => setTimeout(resolve, 10))
615
+ }
616
+
617
+ assert.equal(clientApp.disconnectedDate, null, 'disconnect marker should clear after reconnect sync completes')
618
+ assert.deepEqual(serverRows, [{ uid: 'reconnect-1', label: 'from reconnect' }])
619
+
620
+ socket.disconnect()
621
+ await new Promise(resolve => serverApp2.io.close(resolve))
622
+ })
623
+
492
624
  test('updateWithMeta pub/sub handler tolerates undefined value (concurrent delete race)', async () => {
493
625
  // updateWithMeta does `const [value] = UPDATE ... RETURNING`. If the model row
494
626
  // was deleted by a concurrent operation between the sync's findMany snapshot and
@@ -1115,6 +1247,132 @@ describe('Full offline-first client ↔ server protocol', () => {
1115
1247
  }
1116
1248
  })
1117
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
+
1118
1376
  test('update() rollback clears stale updated_at from metadata', async () => {
1119
1377
  // Optimistic update sets updated_at = now in metadata before the server responds.
1120
1378
  // On rejection the rollback does db.metadata.update(uid, previousMetadata), but
@@ -99,6 +99,26 @@ describe('computeSyncResult', () => {
99
99
  assert.deepEqual(result.deleteClient, [['g', T1]])
100
100
  })
101
101
 
102
+ test('record in both, client delete newer than DB update → delete on DB and notify client', () => {
103
+ const value = { uid: 'h', label: 'server-old' }
104
+ const dbMeta = { uid: 'h', created_at: T0, updated_at: T1 }
105
+ const clientMeta = { uid: 'h', created_at: T0, deleted_at: T2 }
106
+ const result = computeSyncResult({ h: value }, { h: clientMeta }, { h: dbMeta })
107
+ assert.deepEqual(result.deleteDatabase, ['h'])
108
+ assert.deepEqual(result.deleteClient, [['h', T2]])
109
+ assert.deepEqual(result.updateClient, [])
110
+ })
111
+
112
+ test('record in both, DB update newer than client delete → restore client from DB', () => {
113
+ const value = { uid: 'i', label: 'server-new' }
114
+ const dbMeta = { uid: 'i', created_at: T0, updated_at: T2 }
115
+ const clientMeta = { uid: 'i', created_at: T0, deleted_at: T1 }
116
+ const result = computeSyncResult({ i: value }, { i: clientMeta }, { i: dbMeta })
117
+ assert.deepEqual(result.updateClient, [[value, dbMeta]])
118
+ assert.deepEqual(result.deleteDatabase, [])
119
+ assert.deepEqual(result.deleteClient, [])
120
+ })
121
+
102
122
  test('mixed scenario: one of each case', () => {
103
123
  const dbOnly = { uid: 'db', label: 'db-only' }
104
124
  const dbOnlyMeta = { uid: 'db', created_at: T0 }