@jcbuisson/express-x-client 3.0.2 → 3.0.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/client.mts +121 -99
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-client",
3
- "version": "3.0.2",
3
+ "version": "3.0.5",
4
4
  "type": "module",
5
5
  "description": "Client library for ExpressX framework",
6
6
  "main": "src/client.mts",
package/src/client.mts CHANGED
@@ -49,21 +49,21 @@ export function createClient(socket, options={}) {
49
49
  connectListeners.push(func)
50
50
  }
51
51
  function removeConnectListener(func) {
52
- connectListeners = connectListeners.filter(f !== func)
52
+ connectListeners = connectListeners.filter(f => f !== func)
53
53
  }
54
54
 
55
55
  function addDisconnectListener(func) {
56
56
  disconnectListeners.push(func)
57
57
  }
58
- function removeDisonnectListener(func) {
59
- disconnectListeners = disconnectListeners.filter(f !== func)
58
+ function removeDisconnectListener(func) {
59
+ disconnectListeners = disconnectListeners.filter(f => f !== func)
60
60
  }
61
61
 
62
62
  function addErrorListener(func) {
63
63
  errorListeners.push(func)
64
64
  }
65
65
  function removeErrorListener(func) {
66
- errorListeners = errorListeners.filter(f !== func)
66
+ errorListeners = errorListeners.filter(f => f !== func)
67
67
  }
68
68
 
69
69
  // on receiving response from service request
@@ -95,10 +95,11 @@ export function createClient(socket, options={}) {
95
95
  waitingPromisesByUid[uid] = [resolve, reject]
96
96
  // a timeout may also reject the promise
97
97
  if (serviceOptions.timeout && !serviceOptions.volatile) {
98
- setTimeout(() => {
98
+ const timer = setTimeout(() => {
99
99
  delete waitingPromisesByUid[uid]
100
100
  reject(`Error: timeout on service '${name}', action '${action}', args: ${JSON.stringify(args)}`)
101
101
  }, serviceOptions.timeout)
102
+ if (timer.unref) timer.unref(); // so it doesn't prevent process exit for tests (Node.js only — no-op in browsers)
102
103
  }
103
104
  })
104
105
  // send request to server through websocket
@@ -142,9 +143,8 @@ export function createClient(socket, options={}) {
142
143
  // without front-end interactions
143
144
  socket.on('app-event', ({ type, value }) => {
144
145
  if (options.debug) console.log('app-event', type, value)
145
- if (!type2appHandler[type]) type2appHandler[type] = {}
146
146
  const handler = type2appHandler[type]
147
- if (handler) handler(value)
147
+ if (typeof handler === 'function') handler(value)
148
148
  })
149
149
 
150
150
  // add a handler for application-wide events
@@ -157,7 +157,7 @@ export function createClient(socket, options={}) {
157
157
  addConnectListener,
158
158
  removeConnectListener,
159
159
  addDisconnectListener,
160
- removeDisonnectListener,
160
+ removeDisconnectListener,
161
161
  addErrorListener,
162
162
  removeErrorListener,
163
163
 
@@ -170,7 +170,7 @@ export function createClient(socket, options={}) {
170
170
 
171
171
 
172
172
  ////////////////////////// RELOAD PLUGIN //////////////////////////
173
- // enrich `app` with listeners handling socket data transfer on page reload
173
+ // Enrich `app` with listeners handling socket data transfer on page reload
174
174
 
175
175
  export async function reloadPlugin(app) {
176
176
 
@@ -179,19 +179,12 @@ export async function reloadPlugin(app) {
179
179
  app.addConnectListener(async (socket) => {
180
180
  const socketId = socket.id
181
181
  console.log('connect', socketId)
182
- // handle reconnections & reloads
183
- // look for a previously stored connection id
184
182
  const prevSocketId = cnxid.value
185
183
  if (prevSocketId) {
186
- // it's a connection after a reload/refresh
187
- // ask server to transfer all data from connection `prevSocketId` to connection `socketId`
188
184
  console.log('cnx-transfer', prevSocketId, 'to', socketId)
189
185
  await socket.emit('cnx-transfer', prevSocketId, socketId)
190
- // update connection id
191
186
  cnxid.value = socketId
192
-
193
187
  } else {
194
- // set connection id
195
188
  cnxid.value = socketId
196
189
  }
197
190
 
@@ -201,30 +194,23 @@ export async function reloadPlugin(app) {
201
194
 
202
195
  socket.on('cnx-transfer-error', async (fromSocketId, toSocketId) => {
203
196
  console.log('ERR ERR!!!', fromSocketId, toSocketId)
204
- // appState.value.unrecoverableError = true
205
197
  })
206
198
  })
207
199
  }
208
200
 
209
201
 
210
202
  ////////////////////////// OFFLINE PLUGIN //////////////////////////
211
- // enrich `app` with methods, attributes and listeners to handle offline-first database access
203
+ // Enrich `app` with methods, attributes and listeners to handle offline-first crud database access
212
204
 
213
205
  export function offlinePlugin(app) {
214
206
 
207
+ const modelSyncFunctions = []
208
+
215
209
  function createOfflineModel(modelName, fields) {
216
210
 
217
211
  const dbName = modelName;
218
212
  const db = getOrCreateDB(dbName, fields);
219
213
 
220
- db.open().then(() => {
221
- // console.log('db ready', dbName, modelName)
222
- });
223
-
224
- db.values.hook("updating", (changes, primaryKey, previousValue) => {
225
- // console.log("CHANGES", primaryKey, changes, previousValue);
226
- });
227
-
228
214
  const reset = async () => {
229
215
  console.log('reset', modelName);
230
216
  await db.whereList.clear();
@@ -243,20 +229,30 @@ export function offlinePlugin(app) {
243
229
 
244
230
  app.service(modelName).on('updateWithMeta', async ([value, meta]) => {
245
231
  console.log(`${modelName} EVENT updateWithMeta`, value);
246
- await db.values.put(value);
232
+ // value may be undefined when the server's UPDATE RETURNING yielded 0 rows
233
+ // (concurrent delete race: record was removed between the sync's findMany
234
+ // snapshot and the actual UPDATE). Guard to avoid a TypeError crash that
235
+ // would prevent db.metadata.put(meta) from running.
236
+ if (value?.uid) await db.values.put(value);
247
237
  await db.metadata.put(meta);
248
238
  });
249
239
 
250
240
  app.service(modelName).on('deleteWithMeta', async ([value, meta]) => {
251
241
  console.log(`${modelName} EVENT deleteWithMeta`, value)
252
- await db.values.delete(value.uid)
253
- await db.metadata.put(meta)
242
+ // value may be undefined when the server's DELETE RETURNING yielded 0 rows
243
+ // (double-delete race). Guard before accessing .uid.
244
+ if (value?.uid) await db.values.delete(value.uid)
245
+ // delete, not put: synchronize() step 2 also deletes idbMetadata for the same
246
+ // uid. If the pub/sub handler fires AFTER step 2, put() would re-create the
247
+ // metadata row as a permanent orphan. delete() is idempotent regardless of order.
248
+ await db.metadata.delete(meta.uid)
254
249
  });
255
250
 
256
251
 
257
252
  ///////////// CREATE/UPDATE/REMOVE /////////////
258
253
 
259
254
  async function create(data) {
255
+ // in offline-first context, uid is created client-side, since server may not be accessible
260
256
  const uid = uuidv7()
261
257
  // optimistic update
262
258
  const now = new Date()
@@ -269,6 +265,7 @@ export function offlinePlugin(app) {
269
265
  console.log(`*** err sync ${modelName} create`, err)
270
266
  // rollback
271
267
  await db.values.delete(uid)
268
+ await db.metadata.delete(uid)
272
269
  })
273
270
  }
274
271
  return await db.values.get(uid)
@@ -289,8 +286,11 @@ export function offlinePlugin(app) {
289
286
  // rollback
290
287
  delete previousValue.uid
291
288
  await db.values.update(uid, previousValue)
292
- delete previousMetadata.uid
293
- await db.metadata.update(uid, previousMetadata)
289
+ // Only restore updated_at — the optimistic write only touched that field.
290
+ // Restoring the full previousMetadata snapshot would overwrite any
291
+ // deleted_at that remove() set while the socket round-trip was in flight,
292
+ // silently un-deleting the record.
293
+ await db.metadata.update(uid, { updated_at: previousMetadata.updated_at ?? null })
294
294
  })
295
295
  }
296
296
  return await db.values.get(uid)
@@ -328,7 +328,6 @@ export function offlinePlugin(app) {
328
328
 
329
329
  function getObservable(where = {}) {
330
330
  addSynchroWhere(where).then((isNew: boolean) => {
331
- // console.log('getObservable addSynchroWhere', modelName, where, isNew);
332
331
  if (isNew && app.isConnected) {
333
332
  synchronize(modelName, db.values, db.metadata, where, app.disconnectedDate)
334
333
  }
@@ -369,6 +368,8 @@ export function offlinePlugin(app) {
369
368
  }
370
369
  })
371
370
 
371
+ modelSyncFunctions.push(synchronizeAll)
372
+
372
373
  return {
373
374
  db, reset,
374
375
  create, update, remove,
@@ -382,8 +383,11 @@ export function offlinePlugin(app) {
382
383
  app.addConnectListener(async (_socket) => {
383
384
  app.connectedDate = new Date()
384
385
  console.log('onConnect', app.connectedDate)
385
- app.disconnectedDate = null
386
386
  app.isConnected = true
387
+ if (app.disconnectedDate) {
388
+ modelSyncFunctions.forEach(sync => sync())
389
+ }
390
+ app.disconnectedDate = null
387
391
  })
388
392
 
389
393
  app.addDisconnectListener(async (_socket) => {
@@ -401,7 +405,6 @@ export function offlinePlugin(app) {
401
405
  await mutex.acquire()
402
406
  console.log('synchronize', modelName, where)
403
407
 
404
- let toAdd = []
405
408
  try {
406
409
  const requestPredicate = wherePredicate(where)
407
410
 
@@ -420,41 +423,51 @@ export function offlinePlugin(app) {
420
423
  }
421
424
 
422
425
  // call sync service on `where` perimeter
423
- const syncResult = await app.service('sync').go(modelName, where, cutoffDate, clientMetadataDict)
424
- toAdd = syncResult.toAdd
425
- const { toUpdate, toDelete, addDatabase, updateDatabase } = syncResult
426
- console.log('-> service.sync', modelName, where, toAdd, toUpdate, toDelete, addDatabase, updateDatabase)
426
+ const { addClient, updateClient, deleteClient, addDatabase, updateDatabase } =
427
+ await app.service('sync').go(modelName, where, cutoffDate, clientMetadataDict)
428
+ console.log('-> service.sync', modelName, where, addClient, updateClient, deleteClient, addDatabase, updateDatabase)
427
429
 
428
430
  // 1- add missing elements in indexedDB cache
429
- // Use a single transaction for all adds to ensure atomicity
430
- if (toAdd.length > 0) {
431
+ // Use a single transaction for all adds to ensure atomicity.
432
+ // put() instead of add() for metadata: a deleteWithMeta pub/sub event leaves
433
+ // an orphaned metadata row (value deleted, metadata kept with deleted_at).
434
+ // add() would throw a ConstraintError on that orphan; put() upserts safely.
435
+ if (addClient.length > 0) {
431
436
  await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
432
- for (const [value, metaData] of toAdd) {
433
- await idbValues.add(value)
434
- await idbMetadata.add(metaData)
437
+ for (const [value, metaData] of addClient) {
438
+ // put() instead of add(): if create() ran concurrently and added this
439
+ // uid to Dexie between the idbValues.filter snapshot and this step,
440
+ // add() would throw ConstraintError and abort the entire transaction,
441
+ // silently dropping every other addClient record in the batch.
442
+ await idbValues.put(value)
443
+ await idbMetadata.put(metaData)
435
444
  }
436
445
  })
437
446
  }
438
447
  // 2- delete elements from indexedDB cache
439
- for (const [uid, deleted_at] of toDelete) {
448
+ for (const [uid] of deleteClient) {
440
449
  await idbValues.delete(uid)
441
- await idbMetadata.update(uid, { deleted_at })
450
+ await idbMetadata.delete(uid)
442
451
  }
443
- // 3- update elements of cache
444
- for (const elt of toUpdate) {
445
- // get full value of element to update
446
- const value = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
452
+ // 3- update elements of cache with server's newer version
453
+ for (const [elt, serverMeta] of updateClient) {
454
+ const value = { ...elt }
447
455
  delete value.uid
448
456
  delete value.__deleted__
449
457
  await idbValues.update(elt.uid, value)
450
- const metadata = await idbMetadata.get(elt.uid)
451
- await idbMetadata.update(elt.uid, { updated_at: metadata.updated_at })
458
+ await idbMetadata.update(elt.uid, { updated_at: serverMeta.updated_at })
452
459
  }
453
460
 
454
461
  // 4- create elements of `addDatabase` with full data from cache
455
462
  for (const elt of addDatabase) {
463
+ // elt.uid is undefined when the clientMetadataDict fallback {} was used
464
+ // (record exists in idbValues but metadata is missing). Guard before the
465
+ // get() call: idbValues.get(undefined) itself throws before fullValue is
466
+ // assigned, so checking fullValue == null afterwards is too late.
467
+ if (elt.uid == null) continue
456
468
  const fullValue = await idbValues.get(elt.uid)
457
469
  const meta = await idbMetadata.get(elt.uid)
470
+ if (fullValue == null) continue // record deleted concurrently
458
471
  delete fullValue.uid
459
472
  delete fullValue.__deleted__
460
473
  try {
@@ -469,19 +482,17 @@ export function offlinePlugin(app) {
469
482
 
470
483
  // 5- update elements of `updateDatabase` with full data from cache
471
484
  for (const elt of updateDatabase) {
485
+ if (elt.uid == null) continue
472
486
  const fullValue = await idbValues.get(elt.uid)
473
487
  const meta = await idbMetadata.get(elt.uid)
488
+ if (fullValue == null) continue // record deleted concurrently
474
489
  delete fullValue.uid
475
490
  delete fullValue.__deleted__
476
491
  try {
477
492
  await app.service(modelName).updateWithMeta(elt.uid, fullValue, meta.updated_at)
478
493
  } catch(err) {
479
494
  console.log("*** err sync user updateDatabase", err)
480
- // rollback
481
- const previousDatabaseValue = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
482
- const previousDatabaseMetadata = await app.service('metadata').findUnique({ where:{ uid: elt.uid }})
483
- await idbValues.update(elt.uid, previousDatabaseValue)
484
- await idbMetadata.update(elt.uid, previousDatabaseMetadata)
495
+ // Leave client's local version intact; it will be retried on the next sync.
485
496
  }
486
497
  }
487
498
  } catch(err) {
@@ -491,30 +502,20 @@ export function offlinePlugin(app) {
491
502
  }
492
503
  }
493
504
 
494
- function wherePredicate(where) {
495
- return (elt) => {
496
- for (const [attr, value] of Object.entries(where)) {
497
- const eltAttrValue = elt[attr]
498
-
499
- if (typeof(value) === 'string' || typeof(value) === 'number') {
500
- // 'attr = value' clause
501
- if (eltAttrValue !== value) return false
502
-
503
- } else if (typeof(value) === 'object') {
504
- // 'attr = { lt/lte/gt/gte: value }' clause
505
- if (value.lte) {
506
- if (eltAttrValue > value.lte) return false
507
- } else if (value.lt) {
508
- if (eltAttrValue >= value.lt) return false
509
- } else if (value.gte) {
510
- if (eltAttrValue < value.gte) return false
511
- } else if (value.gt) {
512
- if (eltAttrValue <= value.gt) return false
513
- }
514
- }
515
- }
516
- return true
505
+ // Singleton map to reuse Dexie instances per database name
506
+ const dbInstances = new Map();
507
+
508
+ function getOrCreateDB(dbName: string, fields: string[]) {
509
+ if (!dbInstances.has(dbName)) {
510
+ const db = new Dexie(dbName);
511
+ db.version(1).stores({
512
+ whereList: "sortedjson",
513
+ values: ['uid', '__deleted__', ...fields].join(','),
514
+ metadata: "uid, created_at, updated_at, deleted_at",
515
+ });
516
+ dbInstances.set(dbName, db);
517
517
  }
518
+ return dbInstances.get(dbName);
518
519
  }
519
520
 
520
521
  async function getWhereList(whereDb) {
@@ -559,22 +560,6 @@ export function offlinePlugin(app) {
559
560
  }
560
561
  }
561
562
 
562
- // Singleton map to reuse Dexie instances per database name
563
- const dbInstances = new Map();
564
-
565
- function getOrCreateDB(dbName: string, fields: string[]) {
566
- if (!dbInstances.has(dbName)) {
567
- const db = new Dexie(dbName);
568
- db.version(1).stores({
569
- whereList: "sortedjson",
570
- values: ['uid', '__deleted__', ...fields].join(','),
571
- metadata: "uid, created_at, updated_at, deleted_at",
572
- });
573
- dbInstances.set(dbName, db);
574
- }
575
- return dbInstances.get(dbName);
576
- }
577
-
578
563
  // enrich `app` with new methods and attributes
579
564
  return Object.assign(app, {
580
565
  createOfflineModel,
@@ -595,7 +580,6 @@ function generateUID(length) {
595
580
  return uid
596
581
  }
597
582
 
598
-
599
583
  function stringifyWithSortedKeys(obj, space = null) {
600
584
  return JSON.stringify(obj, (key, value) => {
601
585
  // If the value is a plain object (not an array, null, or other object type like Date)
@@ -637,10 +621,49 @@ export class Mutex {
637
621
  }
638
622
  }
639
623
 
624
+ function wherePredicate(where) {
625
+ return (elt) => {
626
+ for (const [attr, value] of Object.entries(where)) {
627
+ const eltAttrValue = elt[attr]
628
+
629
+ if (typeof(value) === 'string' || typeof(value) === 'number' || typeof(value) === 'boolean') {
630
+ // 'attr = value' clause
631
+ if (eltAttrValue !== value) return false
632
+
633
+ } else if (value === null) {
634
+ // 'attr = null' clause
635
+ if (eltAttrValue !== null) return false
636
+
637
+ } else if (typeof(value) === 'object') {
638
+ // 'attr = { lt/lte/gt/gte: value }' clause — all bounds apply.
639
+ // A missing (undefined) or null field never satisfies a range constraint,
640
+ // consistent with SQL NULL behaviour (NULL op anything = NULL = unknown).
641
+ // JS coerces null → 0 so range guards like `null > 10` silently pass;
642
+ // undefined coerces to NaN and all NaN comparisons return false — both
643
+ // must be excluded explicitly.
644
+ if (eltAttrValue === undefined || eltAttrValue === null) return false
645
+ if ('lte' in value && eltAttrValue > value.lte) return false
646
+ if ('lt' in value && eltAttrValue >= value.lt) return false
647
+ if ('gte' in value && eltAttrValue < value.gte) return false
648
+ if ('gt' in value && eltAttrValue <= value.gt) return false
649
+ }
650
+ }
651
+ return true
652
+ }
653
+ }
654
+
640
655
  function isSubset(subset, fullObject) {
641
- // return Object.entries(subset).some(([key, value]) => fullObject[key] === value)
642
656
  for (const key in fullObject) {
643
- if (fullObject[key] !== subset[key]) return false
657
+ const fVal = fullObject[key]
658
+ const sVal = subset[key]
659
+ // Primitive values: use reference/value equality (works for string, number, boolean).
660
+ // Object values (e.g. range operators { gte: 1 }): use structural equality via
661
+ // sorted JSON so that two freshly-created identical objects compare as equal.
662
+ if (typeof fVal === 'object' && fVal !== null) {
663
+ if (stringifyWithSortedKeys(fVal) !== stringifyWithSortedKeys(sVal)) return false
664
+ } else {
665
+ if (fVal !== sVal) return false
666
+ }
644
667
  }
645
668
  return true
646
669
  }
@@ -654,4 +677,3 @@ function isSubsetAmong(subset, fullObjectList) {
654
677
  return fullObjectList.some(fullObject => isSubset(subset, fullObject));
655
678
  }
656
679
  // console.log('isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}])=true', isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}]))
657
-