@jcbuisson/express-x-client 3.1.11 → 3.1.13
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 +2 -2
- package/package.json +1 -1
- package/src/client.mts +73 -17
package/CLAUDE.md
CHANGED
|
@@ -35,7 +35,7 @@ Enriches `app` with offline-first IndexedDB CRUD via Dexie. Call `app.createOffl
|
|
|
35
35
|
This plugin also adds three dynamic attributes to `app`:
|
|
36
36
|
- `app.isConnected` — boolean, updated on socket connect/disconnect
|
|
37
37
|
- `app.connectedDate` — `Date` of the last connection, or `null`
|
|
38
|
-
- `app.disconnectedDate` — `Date` of the last disconnection, or `null`
|
|
38
|
+
- `app.disconnectedDate` — `Date` of the last disconnection, or `null`
|
|
39
39
|
|
|
40
40
|
Each model maintains three Dexie stores under the same DB name:
|
|
41
41
|
- `values` — the actual records (indexed on `uid`, `__deleted__`)
|
|
@@ -47,7 +47,7 @@ Each model maintains three Dexie stores under the same DB name:
|
|
|
47
47
|
|
|
48
48
|
**Optimistic writes**: `create`, `update`, `remove` write to IndexedDB immediately, then call the server service method. On server error they roll back the local change.
|
|
49
49
|
|
|
50
|
-
**Sync on reconnect**: When the socket reconnects, every registered model calls `synchronizeAll`, which iterates its `whereList` and calls the server's `sync.go(modelName, where,
|
|
50
|
+
**Sync on reconnect**: When the socket reconnects, every registered model calls `synchronizeAll`, which iterates its `whereList` and calls the server's `sync.go(modelName, where, clientMetadataDict)` service. The response contains five buckets (`addClient`, `updateClient`, `deleteClient`, `addDatabase`, `updateDatabase`) that are applied in order.
|
|
51
51
|
|
|
52
52
|
**Real-time observables**: `getObservable(where)` returns an RxJS `Observable` backed by Dexie's `liveQuery`. It also registers the `where` in `whereList` and triggers a sync if it is a new, unregistered filter. Vue component lifecycle cleanup is handled by `tryOnScopeDispose`.
|
|
53
53
|
|
package/package.json
CHANGED
package/src/client.mts
CHANGED
|
@@ -184,6 +184,8 @@ export function offlinePlugin(app) {
|
|
|
184
184
|
|
|
185
185
|
const dbName = modelName;
|
|
186
186
|
const db = getOrCreateDB(dbName, fields);
|
|
187
|
+
const synchronizedWhereKeys = new Set();
|
|
188
|
+
const synchronizeWherePromises = new Map();
|
|
187
189
|
|
|
188
190
|
const reset = async () => {
|
|
189
191
|
console.log('reset', modelName);
|
|
@@ -231,10 +233,16 @@ export function offlinePlugin(app) {
|
|
|
231
233
|
// optimistic update
|
|
232
234
|
const now = new Date()
|
|
233
235
|
await db.values.add({ uid, ...data })
|
|
234
|
-
await db.metadata.add({ uid, created_at: now })
|
|
236
|
+
await db.metadata.add({ uid, created_at: now, __dirty__: true })
|
|
235
237
|
// execute on server, asynchronously, if connection is active
|
|
236
238
|
if (app.isConnected) {
|
|
237
239
|
app.service(modelName).createWithMeta(uid, data, now)
|
|
240
|
+
.then(async result => {
|
|
241
|
+
const [value, meta] = Array.isArray(result) ? result : []
|
|
242
|
+
if (value?.uid) await db.values.put(value)
|
|
243
|
+
if (meta?.uid) await db.metadata.put({ ...meta, __dirty__: false })
|
|
244
|
+
else await db.metadata.update(uid, { __dirty__: false })
|
|
245
|
+
})
|
|
238
246
|
.catch(async err => {
|
|
239
247
|
console.log(`*** err sync ${modelName} create`, err)
|
|
240
248
|
// rollback
|
|
@@ -251,10 +259,16 @@ export function offlinePlugin(app) {
|
|
|
251
259
|
// optimistic update of cache
|
|
252
260
|
const now = new Date()
|
|
253
261
|
await db.values.update(uid, data)
|
|
254
|
-
await db.metadata.update(uid, { updated_at: now })
|
|
262
|
+
await db.metadata.update(uid, { updated_at: now, __dirty__: true })
|
|
255
263
|
// execute on server, asynchronously, if connection is active
|
|
256
264
|
if (app.isConnected) {
|
|
257
265
|
app.service(modelName).updateWithMeta(uid, data, now)
|
|
266
|
+
.then(async result => {
|
|
267
|
+
const [value, meta] = Array.isArray(result) ? result : []
|
|
268
|
+
if (value?.uid) await db.values.put(value)
|
|
269
|
+
if (meta?.uid) await db.metadata.put({ ...meta, __dirty__: false })
|
|
270
|
+
else await db.metadata.update(uid, { __dirty__: false })
|
|
271
|
+
})
|
|
258
272
|
.catch(async err => {
|
|
259
273
|
console.log(`*** err sync ${modelName} update`, err)
|
|
260
274
|
// rollback
|
|
@@ -264,7 +278,10 @@ export function offlinePlugin(app) {
|
|
|
264
278
|
// Restoring the full previousMetadata snapshot would overwrite any
|
|
265
279
|
// deleted_at that remove() set while the socket round-trip was in flight,
|
|
266
280
|
// silently un-deleting the record.
|
|
267
|
-
await db.metadata.update(uid, {
|
|
281
|
+
await db.metadata.update(uid, {
|
|
282
|
+
updated_at: previousMetadata.updated_at ?? null,
|
|
283
|
+
__dirty__: previousMetadata.__dirty__ ?? false,
|
|
284
|
+
})
|
|
268
285
|
})
|
|
269
286
|
}
|
|
270
287
|
return await db.values.get(uid)
|
|
@@ -274,15 +291,20 @@ export function offlinePlugin(app) {
|
|
|
274
291
|
const deleted_at = new Date()
|
|
275
292
|
// optimistic delete in cache
|
|
276
293
|
await db.values.update(uid, { __deleted__: true })
|
|
277
|
-
await db.metadata.update(uid, { deleted_at })
|
|
294
|
+
await db.metadata.update(uid, { deleted_at, __dirty__: true })
|
|
278
295
|
// and in database, if connected
|
|
279
296
|
if (app.isConnected) {
|
|
280
297
|
app.service(modelName).deleteWithMeta(uid, deleted_at)
|
|
298
|
+
.then(async result => {
|
|
299
|
+
const [, meta] = Array.isArray(result) ? result : []
|
|
300
|
+
if (meta?.uid) await db.metadata.put({ ...meta, __dirty__: false })
|
|
301
|
+
else await db.metadata.update(uid, { __dirty__: false })
|
|
302
|
+
})
|
|
281
303
|
.catch(async err => {
|
|
282
304
|
console.log(`*** err sync ${modelName} remove`, err)
|
|
283
305
|
// rollback
|
|
284
306
|
await db.values.update(uid, { __deleted__: null })
|
|
285
|
-
await db.metadata.update(uid, { deleted_at: null })
|
|
307
|
+
await db.metadata.update(uid, { deleted_at: null, __dirty__: false })
|
|
286
308
|
})
|
|
287
309
|
}
|
|
288
310
|
}
|
|
@@ -322,8 +344,9 @@ export function offlinePlugin(app) {
|
|
|
322
344
|
// behavior as before.
|
|
323
345
|
return defer(() => {
|
|
324
346
|
const ready = addSynchroWhere(where).then((isNew: boolean) => {
|
|
325
|
-
|
|
326
|
-
|
|
347
|
+
const whereKey = stringifyWithSortedKeys(where)
|
|
348
|
+
if (app.isConnected && (isNew || !synchronizedWhereKeys.has(whereKey))) {
|
|
349
|
+
return synchronizeWhere(where)
|
|
327
350
|
}
|
|
328
351
|
})
|
|
329
352
|
return from(ready).pipe(switchMap(() => liveQuery$))
|
|
@@ -341,11 +364,27 @@ export function offlinePlugin(app) {
|
|
|
341
364
|
function removeSynchroWhere(where: object) {
|
|
342
365
|
console.log('removeSynchroWhere', dbName, modelName, where)
|
|
343
366
|
count -= 1
|
|
367
|
+
synchronizedWhereKeys.delete(stringifyWithSortedKeys(where))
|
|
344
368
|
return removeSynchroDBWhere(where, db.whereList)
|
|
345
369
|
}
|
|
346
370
|
|
|
347
371
|
async function synchronizeAll() {
|
|
348
|
-
await synchronizeModelWhereList(modelName, db.values, db.metadata,
|
|
372
|
+
await synchronizeModelWhereList(modelName, db.values, db.metadata, db.whereList, synchronizeWhere)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function synchronizeWhere(where) {
|
|
376
|
+
const whereKey = stringifyWithSortedKeys(where)
|
|
377
|
+
if (!synchronizeWherePromises.has(whereKey)) {
|
|
378
|
+
const promise = synchronize(modelName, db.values, db.metadata, where)
|
|
379
|
+
.then(() => {
|
|
380
|
+
synchronizedWhereKeys.add(whereKey)
|
|
381
|
+
})
|
|
382
|
+
.finally(() => {
|
|
383
|
+
synchronizeWherePromises.delete(whereKey)
|
|
384
|
+
})
|
|
385
|
+
synchronizeWherePromises.set(whereKey, promise)
|
|
386
|
+
}
|
|
387
|
+
return synchronizeWherePromises.get(whereKey)
|
|
349
388
|
}
|
|
350
389
|
|
|
351
390
|
// Automatically clean up when the component using this composable unmounts
|
|
@@ -369,12 +408,16 @@ export function offlinePlugin(app) {
|
|
|
369
408
|
}
|
|
370
409
|
}
|
|
371
410
|
|
|
411
|
+
let hasConnected = false
|
|
412
|
+
|
|
372
413
|
app.addConnectListener(async (_socket) => {
|
|
373
414
|
app.connectedDate = new Date()
|
|
374
415
|
console.log('onConnect', app.connectedDate)
|
|
375
416
|
app.isConnected = true
|
|
376
417
|
const disconnectedDate = app.disconnectedDate
|
|
377
|
-
|
|
418
|
+
const isInitialConnect = !hasConnected
|
|
419
|
+
hasConnected = true
|
|
420
|
+
if (disconnectedDate || isInitialConnect) {
|
|
378
421
|
const results = await Promise.allSettled(modelSyncFunctions.map(sync => sync()))
|
|
379
422
|
const failures = results.filter(result => result.status === 'rejected')
|
|
380
423
|
if (failures.length > 0) {
|
|
@@ -396,7 +439,7 @@ export function offlinePlugin(app) {
|
|
|
396
439
|
const mutex = new Mutex()
|
|
397
440
|
|
|
398
441
|
// ex: where = { uid: 'azer' }
|
|
399
|
-
async function synchronize(modelName, idbValues, idbMetadata, where
|
|
442
|
+
async function synchronize(modelName, idbValues, idbMetadata, where) {
|
|
400
443
|
await mutex.acquire()
|
|
401
444
|
console.log('synchronize', modelName, where)
|
|
402
445
|
|
|
@@ -416,10 +459,16 @@ export function offlinePlugin(app) {
|
|
|
416
459
|
clientMetadataDict[value.uid] = {}
|
|
417
460
|
}
|
|
418
461
|
}
|
|
462
|
+
const dirtyMetadataList = await idbMetadata.filter(metadata => metadata.__dirty__).toArray()
|
|
463
|
+
for (const metadata of dirtyMetadataList) {
|
|
464
|
+
if (metadata.uid in clientMetadataDict) continue
|
|
465
|
+
const value = await idbValues.get(metadata.uid)
|
|
466
|
+
if (value || metadata.deleted_at) clientMetadataDict[metadata.uid] = metadata
|
|
467
|
+
}
|
|
419
468
|
|
|
420
469
|
// call sync service on `where` perimeter
|
|
421
470
|
const { addClient, updateClient, deleteClient, addDatabase, updateDatabase } =
|
|
422
|
-
await app.service('sync').go(modelName, where,
|
|
471
|
+
await app.service('sync').go(modelName, where, clientMetadataDict)
|
|
423
472
|
console.log('-> service.sync', modelName, where, addClient, updateClient, deleteClient, addDatabase, updateDatabase)
|
|
424
473
|
|
|
425
474
|
// 1- add missing elements in indexedDB cache
|
|
@@ -435,7 +484,7 @@ export function offlinePlugin(app) {
|
|
|
435
484
|
// add() would throw ConstraintError and abort the entire transaction,
|
|
436
485
|
// silently dropping every other addClient record in the batch.
|
|
437
486
|
await idbValues.put(value)
|
|
438
|
-
await idbMetadata.put(metaData)
|
|
487
|
+
await idbMetadata.put({ ...metaData, __dirty__: false })
|
|
439
488
|
}
|
|
440
489
|
})
|
|
441
490
|
}
|
|
@@ -453,7 +502,7 @@ export function offlinePlugin(app) {
|
|
|
453
502
|
const value = { ...elt }
|
|
454
503
|
delete value.__deleted__
|
|
455
504
|
await idbValues.put(value)
|
|
456
|
-
await idbMetadata.put({ uid: elt.uid, ...serverMeta })
|
|
505
|
+
await idbMetadata.put({ uid: elt.uid, ...serverMeta, __dirty__: false })
|
|
457
506
|
}
|
|
458
507
|
|
|
459
508
|
// 4- create elements of `addDatabase` with full data from cache
|
|
@@ -468,7 +517,10 @@ export function offlinePlugin(app) {
|
|
|
468
517
|
delete fullValue.uid
|
|
469
518
|
delete fullValue.__deleted__
|
|
470
519
|
try {
|
|
471
|
-
await app.service(modelName).createWithMeta(elt.uid, fullValue, elt.created_at)
|
|
520
|
+
const result = await app.service(modelName).createWithMeta(elt.uid, fullValue, elt.created_at)
|
|
521
|
+
const serverMeta = Array.isArray(result) ? result[1] : null
|
|
522
|
+
if (serverMeta?.uid) await idbMetadata.put({ ...serverMeta, __dirty__: false })
|
|
523
|
+
else await idbMetadata.update(elt.uid, { __dirty__: false })
|
|
472
524
|
} catch(err) {
|
|
473
525
|
console.log("*** err sync user addDatabase", err, elt.uid, fullValue, elt.created_at)
|
|
474
526
|
// rollback
|
|
@@ -485,7 +537,10 @@ export function offlinePlugin(app) {
|
|
|
485
537
|
delete fullValue.uid
|
|
486
538
|
delete fullValue.__deleted__
|
|
487
539
|
try {
|
|
488
|
-
await app.service(modelName).updateWithMeta(elt.uid, fullValue, elt.updated_at)
|
|
540
|
+
const result = await app.service(modelName).updateWithMeta(elt.uid, fullValue, elt.updated_at)
|
|
541
|
+
const serverMeta = Array.isArray(result) ? result[1] : null
|
|
542
|
+
if (serverMeta?.uid) await idbMetadata.put({ ...serverMeta, __dirty__: false })
|
|
543
|
+
else await idbMetadata.update(elt.uid, { __dirty__: false })
|
|
489
544
|
} catch(err) {
|
|
490
545
|
console.log("*** err sync user updateDatabase", err)
|
|
491
546
|
// Leave client's local version intact; it will be retried on the next sync.
|
|
@@ -550,10 +605,11 @@ export function offlinePlugin(app) {
|
|
|
550
605
|
}
|
|
551
606
|
}
|
|
552
607
|
|
|
553
|
-
async function synchronizeModelWhereList(modelName, idbValues, idbMetadata,
|
|
608
|
+
async function synchronizeModelWhereList(modelName, idbValues, idbMetadata, whereDb, syncWhere = null) {
|
|
554
609
|
const whereList = await getWhereList(whereDb)
|
|
555
610
|
for (const where of whereList) {
|
|
556
|
-
|
|
611
|
+
if (syncWhere) await syncWhere(where)
|
|
612
|
+
else await synchronize(modelName, idbValues, idbMetadata, where)
|
|
557
613
|
}
|
|
558
614
|
}
|
|
559
615
|
|