@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 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` (used as `cutoffDate` for sync)
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, cutoffDate, clientMetadataDict)` service. The response contains five buckets (`addClient`, `updateClient`, `deleteClient`, `addDatabase`, `updateDatabase`) that are applied in order.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-client",
3
- "version": "3.1.11",
3
+ "version": "3.1.13",
4
4
  "type": "module",
5
5
  "description": "Client library for ExpressX framework",
6
6
  "main": "src/client.mts",
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, { updated_at: previousMetadata.updated_at ?? null })
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
- if (isNew && app.isConnected) {
326
- return synchronize(modelName, db.values, db.metadata, where, app.disconnectedDate)
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, app.disconnectedDate, db.whereList)
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
- if (disconnectedDate) {
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, cutoffDate) {
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, cutoffDate, clientMetadataDict)
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, cutoffDate, whereDb) {
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
- await synchronize(modelName, idbValues, idbMetadata, where, cutoffDate)
611
+ if (syncWhere) await syncWhere(where)
612
+ else await synchronize(modelName, idbValues, idbMetadata, where)
557
613
  }
558
614
  }
559
615