@jcbuisson/express-x-client 3.0.3 → 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 +123 -110
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-client",
3
- "version": "3.0.3",
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
@@ -136,13 +137,14 @@ export function createClient(socket, options={}) {
136
137
  return new Proxy(service, handler)
137
138
  }
138
139
 
140
+ //-------------------- APPLICATION-LEVEL EVENTS --------------------
141
+
139
142
  // There is a need for application-wide events sent outside any service method call, for example when backend state changes
140
143
  // without front-end interactions
141
144
  socket.on('app-event', ({ type, value }) => {
142
145
  if (options.debug) console.log('app-event', type, value)
143
- if (!type2appHandler[type]) type2appHandler[type] = {}
144
146
  const handler = type2appHandler[type]
145
- if (handler) handler(value)
147
+ if (typeof handler === 'function') handler(value)
146
148
  })
147
149
 
148
150
  // add a handler for application-wide events
@@ -150,28 +152,17 @@ export function createClient(socket, options={}) {
150
152
  type2appHandler[type] = handler
151
153
  }
152
154
 
153
- function connect() {
154
- if (options.debug) console.log('connecting...')
155
- socket.connect()
156
- }
157
-
158
- function disconnect() {
159
- if (options.debug) console.log('disconnecting...')
160
- socket.disconnect()
161
- }
162
-
163
155
  const app = {
164
156
  configure,
165
157
  addConnectListener,
166
158
  removeConnectListener,
167
159
  addDisconnectListener,
168
- removeDisonnectListener,
160
+ removeDisconnectListener,
169
161
  addErrorListener,
170
162
  removeErrorListener,
171
163
 
172
164
  service,
173
165
  on,
174
- connect, disconnect,
175
166
  }
176
167
 
177
168
  return app
@@ -179,7 +170,7 @@ export function createClient(socket, options={}) {
179
170
 
180
171
 
181
172
  ////////////////////////// RELOAD PLUGIN //////////////////////////
182
- // enrich `app` with listeners handling socket data transfer on page reload
173
+ // Enrich `app` with listeners handling socket data transfer on page reload
183
174
 
184
175
  export async function reloadPlugin(app) {
185
176
 
@@ -188,19 +179,12 @@ export async function reloadPlugin(app) {
188
179
  app.addConnectListener(async (socket) => {
189
180
  const socketId = socket.id
190
181
  console.log('connect', socketId)
191
- // handle reconnections & reloads
192
- // look for a previously stored connection id
193
182
  const prevSocketId = cnxid.value
194
183
  if (prevSocketId) {
195
- // it's a connection after a reload/refresh
196
- // ask server to transfer all data from connection `prevSocketId` to connection `socketId`
197
184
  console.log('cnx-transfer', prevSocketId, 'to', socketId)
198
185
  await socket.emit('cnx-transfer', prevSocketId, socketId)
199
- // update connection id
200
186
  cnxid.value = socketId
201
-
202
187
  } else {
203
- // set connection id
204
188
  cnxid.value = socketId
205
189
  }
206
190
 
@@ -210,30 +194,23 @@ export async function reloadPlugin(app) {
210
194
 
211
195
  socket.on('cnx-transfer-error', async (fromSocketId, toSocketId) => {
212
196
  console.log('ERR ERR!!!', fromSocketId, toSocketId)
213
- // appState.value.unrecoverableError = true
214
197
  })
215
198
  })
216
199
  }
217
200
 
218
201
 
219
202
  ////////////////////////// OFFLINE PLUGIN //////////////////////////
220
- // 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
221
204
 
222
205
  export function offlinePlugin(app) {
223
206
 
207
+ const modelSyncFunctions = []
208
+
224
209
  function createOfflineModel(modelName, fields) {
225
210
 
226
211
  const dbName = modelName;
227
212
  const db = getOrCreateDB(dbName, fields);
228
213
 
229
- db.open().then(() => {
230
- // console.log('db ready', dbName, modelName)
231
- });
232
-
233
- db.values.hook("updating", (changes, primaryKey, previousValue) => {
234
- // console.log("CHANGES", primaryKey, changes, previousValue);
235
- });
236
-
237
214
  const reset = async () => {
238
215
  console.log('reset', modelName);
239
216
  await db.whereList.clear();
@@ -252,20 +229,30 @@ export function offlinePlugin(app) {
252
229
 
253
230
  app.service(modelName).on('updateWithMeta', async ([value, meta]) => {
254
231
  console.log(`${modelName} EVENT updateWithMeta`, value);
255
- 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);
256
237
  await db.metadata.put(meta);
257
238
  });
258
239
 
259
240
  app.service(modelName).on('deleteWithMeta', async ([value, meta]) => {
260
241
  console.log(`${modelName} EVENT deleteWithMeta`, value)
261
- await db.values.delete(value.uid)
262
- 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)
263
249
  });
264
250
 
265
251
 
266
252
  ///////////// CREATE/UPDATE/REMOVE /////////////
267
253
 
268
254
  async function create(data) {
255
+ // in offline-first context, uid is created client-side, since server may not be accessible
269
256
  const uid = uuidv7()
270
257
  // optimistic update
271
258
  const now = new Date()
@@ -278,6 +265,7 @@ export function offlinePlugin(app) {
278
265
  console.log(`*** err sync ${modelName} create`, err)
279
266
  // rollback
280
267
  await db.values.delete(uid)
268
+ await db.metadata.delete(uid)
281
269
  })
282
270
  }
283
271
  return await db.values.get(uid)
@@ -298,8 +286,11 @@ export function offlinePlugin(app) {
298
286
  // rollback
299
287
  delete previousValue.uid
300
288
  await db.values.update(uid, previousValue)
301
- delete previousMetadata.uid
302
- 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 })
303
294
  })
304
295
  }
305
296
  return await db.values.get(uid)
@@ -337,7 +328,6 @@ export function offlinePlugin(app) {
337
328
 
338
329
  function getObservable(where = {}) {
339
330
  addSynchroWhere(where).then((isNew: boolean) => {
340
- // console.log('getObservable addSynchroWhere', modelName, where, isNew);
341
331
  if (isNew && app.isConnected) {
342
332
  synchronize(modelName, db.values, db.metadata, where, app.disconnectedDate)
343
333
  }
@@ -378,6 +368,8 @@ export function offlinePlugin(app) {
378
368
  }
379
369
  })
380
370
 
371
+ modelSyncFunctions.push(synchronizeAll)
372
+
381
373
  return {
382
374
  db, reset,
383
375
  create, update, remove,
@@ -391,8 +383,11 @@ export function offlinePlugin(app) {
391
383
  app.addConnectListener(async (_socket) => {
392
384
  app.connectedDate = new Date()
393
385
  console.log('onConnect', app.connectedDate)
394
- app.disconnectedDate = null
395
386
  app.isConnected = true
387
+ if (app.disconnectedDate) {
388
+ modelSyncFunctions.forEach(sync => sync())
389
+ }
390
+ app.disconnectedDate = null
396
391
  })
397
392
 
398
393
  app.addDisconnectListener(async (_socket) => {
@@ -410,7 +405,6 @@ export function offlinePlugin(app) {
410
405
  await mutex.acquire()
411
406
  console.log('synchronize', modelName, where)
412
407
 
413
- let toAdd = []
414
408
  try {
415
409
  const requestPredicate = wherePredicate(where)
416
410
 
@@ -429,41 +423,51 @@ export function offlinePlugin(app) {
429
423
  }
430
424
 
431
425
  // call sync service on `where` perimeter
432
- const syncResult = await app.service('sync').go(modelName, where, cutoffDate, clientMetadataDict)
433
- toAdd = syncResult.toAdd
434
- const { toUpdate, toDelete, addDatabase, updateDatabase } = syncResult
435
- 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)
436
429
 
437
430
  // 1- add missing elements in indexedDB cache
438
- // Use a single transaction for all adds to ensure atomicity
439
- 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) {
440
436
  await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
441
- for (const [value, metaData] of toAdd) {
442
- await idbValues.add(value)
443
- 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)
444
444
  }
445
445
  })
446
446
  }
447
447
  // 2- delete elements from indexedDB cache
448
- for (const [uid, deleted_at] of toDelete) {
448
+ for (const [uid] of deleteClient) {
449
449
  await idbValues.delete(uid)
450
- await idbMetadata.update(uid, { deleted_at })
450
+ await idbMetadata.delete(uid)
451
451
  }
452
- // 3- update elements of cache
453
- for (const elt of toUpdate) {
454
- // get full value of element to update
455
- 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 }
456
455
  delete value.uid
457
456
  delete value.__deleted__
458
457
  await idbValues.update(elt.uid, value)
459
- const metadata = await idbMetadata.get(elt.uid)
460
- await idbMetadata.update(elt.uid, { updated_at: metadata.updated_at })
458
+ await idbMetadata.update(elt.uid, { updated_at: serverMeta.updated_at })
461
459
  }
462
460
 
463
461
  // 4- create elements of `addDatabase` with full data from cache
464
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
465
468
  const fullValue = await idbValues.get(elt.uid)
466
469
  const meta = await idbMetadata.get(elt.uid)
470
+ if (fullValue == null) continue // record deleted concurrently
467
471
  delete fullValue.uid
468
472
  delete fullValue.__deleted__
469
473
  try {
@@ -478,19 +482,17 @@ export function offlinePlugin(app) {
478
482
 
479
483
  // 5- update elements of `updateDatabase` with full data from cache
480
484
  for (const elt of updateDatabase) {
485
+ if (elt.uid == null) continue
481
486
  const fullValue = await idbValues.get(elt.uid)
482
487
  const meta = await idbMetadata.get(elt.uid)
488
+ if (fullValue == null) continue // record deleted concurrently
483
489
  delete fullValue.uid
484
490
  delete fullValue.__deleted__
485
491
  try {
486
492
  await app.service(modelName).updateWithMeta(elt.uid, fullValue, meta.updated_at)
487
493
  } catch(err) {
488
494
  console.log("*** err sync user updateDatabase", err)
489
- // rollback
490
- const previousDatabaseValue = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
491
- const previousDatabaseMetadata = await app.service('metadata').findUnique({ where:{ uid: elt.uid }})
492
- await idbValues.update(elt.uid, previousDatabaseValue)
493
- await idbMetadata.update(elt.uid, previousDatabaseMetadata)
495
+ // Leave client's local version intact; it will be retried on the next sync.
494
496
  }
495
497
  }
496
498
  } catch(err) {
@@ -500,30 +502,20 @@ export function offlinePlugin(app) {
500
502
  }
501
503
  }
502
504
 
503
- function wherePredicate(where) {
504
- return (elt) => {
505
- for (const [attr, value] of Object.entries(where)) {
506
- const eltAttrValue = elt[attr]
507
-
508
- if (typeof(value) === 'string' || typeof(value) === 'number') {
509
- // 'attr = value' clause
510
- if (eltAttrValue !== value) return false
511
-
512
- } else if (typeof(value) === 'object') {
513
- // 'attr = { lt/lte/gt/gte: value }' clause
514
- if (value.lte) {
515
- if (eltAttrValue > value.lte) return false
516
- } else if (value.lt) {
517
- if (eltAttrValue >= value.lt) return false
518
- } else if (value.gte) {
519
- if (eltAttrValue < value.gte) return false
520
- } else if (value.gt) {
521
- if (eltAttrValue <= value.gt) return false
522
- }
523
- }
524
- }
525
- 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);
526
517
  }
518
+ return dbInstances.get(dbName);
527
519
  }
528
520
 
529
521
  async function getWhereList(whereDb) {
@@ -568,22 +560,6 @@ export function offlinePlugin(app) {
568
560
  }
569
561
  }
570
562
 
571
- // Singleton map to reuse Dexie instances per database name
572
- const dbInstances = new Map();
573
-
574
- function getOrCreateDB(dbName: string, fields: string[]) {
575
- if (!dbInstances.has(dbName)) {
576
- const db = new Dexie(dbName);
577
- db.version(1).stores({
578
- whereList: "sortedjson",
579
- values: ['uid', '__deleted__', ...fields].join(','),
580
- metadata: "uid, created_at, updated_at, deleted_at",
581
- });
582
- dbInstances.set(dbName, db);
583
- }
584
- return dbInstances.get(dbName);
585
- }
586
-
587
563
  // enrich `app` with new methods and attributes
588
564
  return Object.assign(app, {
589
565
  createOfflineModel,
@@ -604,7 +580,6 @@ function generateUID(length) {
604
580
  return uid
605
581
  }
606
582
 
607
-
608
583
  function stringifyWithSortedKeys(obj, space = null) {
609
584
  return JSON.stringify(obj, (key, value) => {
610
585
  // If the value is a plain object (not an array, null, or other object type like Date)
@@ -646,10 +621,49 @@ export class Mutex {
646
621
  }
647
622
  }
648
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
+
649
655
  function isSubset(subset, fullObject) {
650
- // return Object.entries(subset).some(([key, value]) => fullObject[key] === value)
651
656
  for (const key in fullObject) {
652
- 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
+ }
653
667
  }
654
668
  return true
655
669
  }
@@ -663,4 +677,3 @@ function isSubsetAmong(subset, fullObjectList) {
663
677
  return fullObjectList.some(fullObject => isSubset(subset, fullObject));
664
678
  }
665
679
  // console.log('isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}])=true', isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}]))
666
-