@jcbuisson/express-x 3.1.6 → 3.1.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x",
3
- "version": "3.1.6",
3
+ "version": "3.1.11",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/server.mjs",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@electric-sql/pglite": "^0.4.5",
32
- "@jcbuisson/express-x-client": "^3.1.6",
32
+ "@jcbuisson/express-x-client": "^3.1.11",
33
33
  "@jcbuisson/express-x-drizzle": "^3.1.6",
34
34
  "@vueuse/core": "^14.3.0",
35
35
  "dexie": "^4.4.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
@@ -308,6 +308,41 @@ describe('Full offline-first client ↔ server protocol', () => {
308
308
  }
309
309
  })
310
310
 
311
+ test('record in both, DB created_at newer → client metadata is fully replaced', async () => {
312
+ const modelName = `model${++dbCounter}`
313
+ const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
314
+
315
+ // Server is newer by created_at only; updated_at is null.
316
+ await db.insert(modelTable).values({ uid: 's1', label: 'server-created-later' })
317
+ await db.insert(metaTable).values({ uid: 's1', created_at: T2 })
318
+
319
+ const { clientApp, cleanup } = await createTestContext(
320
+ serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
321
+ { useOfflinePlugin: true },
322
+ )
323
+
324
+ try {
325
+ const model = clientApp.createOfflineModel(modelName, ['label'])
326
+ await model.db.values.add({ uid: 's1', label: 'client-created-earlier' })
327
+ await model.db.metadata.add({ uid: 's1', created_at: T1 })
328
+ await model.addSynchroWhere({})
329
+
330
+ await model.synchronizeAll()
331
+
332
+ const s1 = await model.db.values.get('s1')
333
+ const meta = await model.db.metadata.get('s1')
334
+ assert.equal(s1.label, 'server-created-later')
335
+ assert.equal(new Date(meta.created_at).getTime(), T2.getTime())
336
+
337
+ await model.synchronizeAll()
338
+ const s1Again = await model.db.values.get('s1')
339
+ assert.equal(s1Again.label, 'server-created-later')
340
+ } finally {
341
+ await cleanup()
342
+ pglite.close()
343
+ }
344
+ })
345
+
311
346
  test('one failed updateWithMeta does not abort remaining updateDatabase entries', async () => {
312
347
  const modelName = `model${++dbCounter}`
313
348
  const serverUpdated = {}
@@ -489,6 +524,102 @@ describe('Full offline-first client ↔ server protocol', () => {
489
524
  pglite.close()
490
525
  })
491
526
 
527
+ test('reconnect keeps disconnectedDate until automatic sync completes', async () => {
528
+ const modelName = `model${++dbCounter}`
529
+ const serverRows = []
530
+ let resolveSyncStarted
531
+ let releaseSync
532
+ let resolveCreate
533
+ const syncStarted = new Promise(resolve => { resolveSyncStarted = resolve })
534
+ const syncRelease = new Promise(resolve => { releaseSync = resolve })
535
+ const createDone = new Promise(resolve => { resolveCreate = resolve })
536
+
537
+ function registerServices(app, { slowSync = false } = {}) {
538
+ app.createService(modelName, {
539
+ createWithMeta: async (uid, data, created_at) => {
540
+ serverRows.push({ uid, ...data })
541
+ resolveCreate()
542
+ return [{ uid, ...data }, { uid, created_at, updated_at: null, deleted_at: null }]
543
+ },
544
+ updateWithMeta: async () => {},
545
+ deleteWithMeta: async () => {},
546
+ findMany: async () => [],
547
+ })
548
+ app.createService('sync', {
549
+ go: async (_modelName, _where, _cutoffDate, clientMetadataDict) => {
550
+ if (slowSync) {
551
+ resolveSyncStarted()
552
+ await syncRelease
553
+ }
554
+ return {
555
+ addClient: [],
556
+ updateClient: [],
557
+ deleteClient: [],
558
+ addDatabase: Object.values(clientMetadataDict),
559
+ updateDatabase: [],
560
+ }
561
+ },
562
+ })
563
+ }
564
+
565
+ const serverApp1 = expressX({})
566
+ registerServices(serverApp1)
567
+ await new Promise(resolve => serverApp1.httpServer.listen(0, resolve))
568
+ const port = serverApp1.httpServer.address().port
569
+
570
+ const socket = ioc(`http://localhost:${port}`, {
571
+ transports: ['websocket'],
572
+ autoConnect: false,
573
+ })
574
+ const clientApp = createClient(socket, { debug: false })
575
+ offlinePlugin(clientApp)
576
+ socket.connect()
577
+ await new Promise((resolve, reject) => {
578
+ socket.on('connect', resolve)
579
+ socket.on('connect_error', reject)
580
+ })
581
+
582
+ const model = clientApp.createOfflineModel(modelName, ['label'])
583
+ await model.addSynchroWhere({})
584
+ await model.synchronizeAll()
585
+
586
+ const disconnected = new Promise(resolve => socket.once('disconnect', resolve))
587
+ serverApp1.io.disconnectSockets(true)
588
+ await new Promise(resolve => serverApp1.io.close(resolve))
589
+ await disconnected
590
+
591
+ await model.db.values.add({ uid: 'reconnect-1', label: 'from reconnect' })
592
+ await model.db.metadata.add({ uid: 'reconnect-1', created_at: T1 })
593
+
594
+ const serverApp2 = expressX({})
595
+ registerServices(serverApp2, { slowSync: true })
596
+ await new Promise(resolve => serverApp2.httpServer.listen(port, resolve))
597
+
598
+ const reconnected = new Promise((resolve, reject) => {
599
+ const timer = setTimeout(() => reject(new Error('reconnect timeout')), 5000)
600
+ socket.once('connect', () => { clearTimeout(timer); resolve() })
601
+ })
602
+ socket.connect()
603
+ await reconnected
604
+ await syncStarted
605
+
606
+ assert.ok(clientApp.disconnectedDate, 'disconnect cutoff should remain while reconnect sync is running')
607
+ assert.equal(serverRows.length, 0, 'offline row should not be pushed before sync is released')
608
+
609
+ releaseSync()
610
+ await createDone
611
+
612
+ for (let i = 0; i < 50 && clientApp.disconnectedDate; i++) {
613
+ await new Promise(resolve => setTimeout(resolve, 10))
614
+ }
615
+
616
+ assert.equal(clientApp.disconnectedDate, null, 'disconnect cutoff should clear after reconnect sync completes')
617
+ assert.deepEqual(serverRows, [{ uid: 'reconnect-1', label: 'from reconnect' }])
618
+
619
+ socket.disconnect()
620
+ await new Promise(resolve => serverApp2.io.close(resolve))
621
+ })
622
+
492
623
  test('updateWithMeta pub/sub handler tolerates undefined value (concurrent delete race)', async () => {
493
624
  // updateWithMeta does `const [value] = UPDATE ... RETURNING`. If the model row
494
625
  // was deleted by a concurrent operation between the sync's findMany snapshot and
@@ -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 }