@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 +1 -1
- package/package.json +3 -3
- package/src/server.mjs +21 -3
- package/test/offline.test.mjs +259 -1
- package/test/sync.test.mjs +20 -0
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",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@electric-sql/pglite": "^0.4.5",
|
|
32
|
-
"@jcbuisson/express-x-client": "^3.1.
|
|
33
|
-
"@jcbuisson/express-x-drizzle": "^3.1.
|
|
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
|
-
|
|
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
|
@@ -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,
|
|
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
|
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 }
|