@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 +2 -2
- package/src/server.mjs +21 -3
- package/test/offline.test.mjs +131 -0
- package/test/sync.test.mjs +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jcbuisson/express-x",
|
|
3
|
-
"version": "3.1.
|
|
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.
|
|
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
|
-
|
|
84
|
-
|
|
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
|
package/test/offline.test.mjs
CHANGED
|
@@ -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
|
package/test/sync.test.mjs
CHANGED
|
@@ -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 }
|