@jcbuisson/express-x-client 2.2.1 → 3.0.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-client",
3
- "version": "2.2.1",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "description": "Client library for ExpressX framework",
6
6
  "main": "src/index.mjs",
package/src/index.mjs CHANGED
@@ -1,3 +1,14 @@
1
+ import Dexie from "dexie";
2
+ import { from } from 'rxjs';
3
+ import { distinctUntilChanged } from 'rxjs/operators';
4
+ import { liveQuery } from "dexie";
5
+ // uuidv7 are monotonically increasing and much improve database performance amid B-tree indexes
6
+ import { v7 as uuidv7 } from 'uuid';
7
+ import { tryOnScopeDispose } from '@vueuse/core';
8
+ import { useSessionStorage } from '@vueuse/core'
9
+
10
+
11
+ ////////////////////////// UTILITIES //////////////////////////
1
12
 
2
13
  function generateUID(length) {
3
14
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
@@ -11,7 +22,9 @@ function generateUID(length) {
11
22
  }
12
23
 
13
24
 
14
- export default function expressXClient(socket, options={}) {
25
+ ////////////////////////// EXPRESSX //////////////////////////
26
+
27
+ export function createClient(socket, options={}) {
15
28
  if (options.debug === undefined) options.debug = false
16
29
 
17
30
  const waitingPromisesByUid = {}
@@ -21,22 +34,26 @@ export default function expressXClient(socket, options={}) {
21
34
  let disconnectListeners = []
22
35
  let errorListeners = []
23
36
 
37
+ function configure(callback, ...args) {
38
+ callback(app, ...args)
39
+ }
40
+
24
41
  socket.on("connect", async () => {
25
- console.log("socket connected", socket.id)
42
+ if (options.debug) console.log("socket connected", socket.id)
26
43
  for (const func of connectListeners) {
27
44
  func(socket)
28
45
  }
29
46
  })
30
47
 
31
48
  socket.on("connect_error", async (err) => {
32
- console.log("socket connection error", socket.id)
49
+ if (options.debug) console.log("socket connection error", socket.id)
33
50
  for (const func of errorListeners) {
34
51
  func(socket)
35
52
  }
36
53
  })
37
54
 
38
55
  socket.on("disconnect", async () => {
39
- console.log("socket disconnected", socket.id)
56
+ if (options.debug) console.log("socket disconnected", socket.id)
40
57
  for (const func of disconnectListeners) {
41
58
  func(socket)
42
59
  }
@@ -133,7 +150,7 @@ export default function expressXClient(socket, options={}) {
133
150
  return new Proxy(service, handler)
134
151
  }
135
152
 
136
- /////////////// APPLICATION-LEVEL EVENTS /////////////////
153
+ //-------------------- APPLICATION-LEVEL EVENTS --------------------
137
154
 
138
155
  // There is a need for application-wide events sent outside any service method call, for example when backend state changes
139
156
  // without front-end interactions
@@ -141,17 +158,16 @@ export default function expressXClient(socket, options={}) {
141
158
  if (options.debug) console.log('app-event', type, value)
142
159
  if (!type2appHandler[type]) type2appHandler[type] = {}
143
160
  const handler = type2appHandler[type]
144
- console.log('handler', handler)
145
161
  if (handler) handler(value)
146
162
  })
147
163
 
148
164
  // add a handler for application-wide events
149
165
  function on(type, handler) {
150
166
  type2appHandler[type] = handler
151
- console.log('type2appHandler[type]', type2appHandler[type])
152
167
  }
153
168
 
154
- return {
169
+ const app = {
170
+ configure,
155
171
  addConnectListener,
156
172
  removeConnectListener,
157
173
  addDisconnectListener,
@@ -162,4 +178,480 @@ export default function expressXClient(socket, options={}) {
162
178
  service,
163
179
  on,
164
180
  }
181
+
182
+ return app
183
+ }
184
+
185
+
186
+ ////////////////////////// RELOAD PLUGIN //////////////////////////
187
+ // enrich `app` with listeners handling socket data transfer on page reload
188
+
189
+ export async function reloadPlugin(app) {
190
+
191
+ const cnxid = useSessionStorage('cnxid', '')
192
+
193
+ app.addConnectListener(async (socket) => {
194
+ const socketId = socket.id
195
+ console.log('connect', socketId)
196
+ // handle reconnections & reloads
197
+ // look for a previously stored connection id
198
+ const prevSocketId = cnxid.value
199
+ if (prevSocketId) {
200
+ // it's a connection after a reload/refresh
201
+ // ask server to transfer all data from connection `prevSocketId` to connection `socketId`
202
+ console.log('cnx-transfer', prevSocketId, 'to', socketId)
203
+ await socket.emit('cnx-transfer', prevSocketId, socketId)
204
+ // update connection id
205
+ cnxid.value = socketId
206
+
207
+ } else {
208
+ // set connection id
209
+ cnxid.value = socketId
210
+ }
211
+
212
+ socket.on('cnx-transfer-ack', async (fromSocketId, toSocketId) => {
213
+ console.log('ACK ACK!!!', fromSocketId, toSocketId)
214
+ })
215
+
216
+ socket.on('cnx-transfer-error', async (fromSocketId, toSocketId) => {
217
+ console.log('ERR ERR!!!', fromSocketId, toSocketId)
218
+ // appState.value.unrecoverableError = true
219
+ })
220
+ })
221
+ }
222
+
223
+
224
+ ////////////////////////// OFFLINE PLUGIN //////////////////////////
225
+ // enrich `app` with methods, attributes and listeners to handle offline-first database access
226
+
227
+ export function offlinePlugin(app) {
228
+
229
+ function createOfflineModel(modelName, fields) {
230
+
231
+ const dbName = modelName;
232
+ const db = getOrCreateDB(dbName, fields);
233
+
234
+ db.open().then(() => {
235
+ // console.log('db ready', dbName, modelName)
236
+ });
237
+
238
+ db.values.hook("updating", (changes, primaryKey, previousValue) => {
239
+ // console.log("CHANGES", primaryKey, changes, previousValue);
240
+ });
241
+
242
+ const reset = async () => {
243
+ console.log('reset', modelName);
244
+ await db.whereList.clear();
245
+ await db.values.clear();
246
+ await db.metadata.clear();
247
+ };
248
+
249
+
250
+ ///////////// PUB / SUB /////////////
251
+
252
+ app.service(modelName).on('createWithMeta', async ([value, meta]) => {
253
+ console.log(`${modelName} EVENT createWithMeta`, value);
254
+ await db.values.put(value);
255
+ await db.metadata.put(meta);
256
+ });
257
+
258
+ app.service(modelName).on('updateWithMeta', async ([value, meta]) => {
259
+ console.log(`${modelName} EVENT updateWithMeta`, value);
260
+ await db.values.put(value);
261
+ await db.metadata.put(meta);
262
+ });
263
+
264
+ app.service(modelName).on('deleteWithMeta', async ([value, meta]) => {
265
+ console.log(`${modelName} EVENT deleteWithMeta`, value)
266
+ await db.values.delete(value.uid)
267
+ await db.metadata.put(meta)
268
+ });
269
+
270
+
271
+ ///////////// CREATE/UPDATE/REMOVE /////////////
272
+
273
+ async function create(data) {
274
+ const uid = uuidv7()
275
+ // optimistic update
276
+ const now = new Date()
277
+ await db.values.add({ uid, ...data })
278
+ await db.metadata.add({ uid, created_at: now })
279
+ // execute on server, asynchronously, if connection is active
280
+ if (app.isConnected) {
281
+ app.service(modelName).createWithMeta(uid, data, now)
282
+ .catch(async err => {
283
+ console.log(`*** err sync ${modelName} create`, err)
284
+ // rollback
285
+ await db.values.delete(uid)
286
+ })
287
+ }
288
+ return await db.values.get(uid)
289
+ }
290
+
291
+ const update = async (uid: string, data: object) => {
292
+ const previousValue = { ...(await db.values.get(uid)) }
293
+ const previousMetadata = { ...(await db.metadata.get(uid)) }
294
+ // optimistic update of cache
295
+ const now = new Date()
296
+ await db.values.update(uid, data)
297
+ await db.metadata.update(uid, { updated_at: now })
298
+ // execute on server, asynchronously, if connection is active
299
+ if (app.isConnected) {
300
+ app.service(modelName).updateWithMeta(uid, data, now)
301
+ .catch(async err => {
302
+ console.log(`*** err sync ${modelName} update`, err)
303
+ // rollback
304
+ delete previousValue.uid
305
+ await db.values.update(uid, previousValue)
306
+ delete previousMetadata.uid
307
+ await db.metadata.update(uid, previousMetadata)
308
+ })
309
+ }
310
+ return await db.values.get(uid)
311
+ }
312
+
313
+ const remove = async (uid: string) => {
314
+ const deleted_at = new Date()
315
+ // optimistic delete in cache
316
+ await db.values.update(uid, { __deleted__: true })
317
+ await db.metadata.update(uid, { deleted_at })
318
+ // and in database, if connected
319
+ if (app.isConnected) {
320
+ app.service(modelName).deleteWithMeta(uid, deleted_at)
321
+ .catch(async err => {
322
+ console.log(`*** err sync ${modelName} remove`, err)
323
+ // rollback
324
+ await db.values.update(uid, { __deleted__: null })
325
+ await db.metadata.update(uid, { deleted_at: null })
326
+ })
327
+ }
328
+ }
329
+
330
+ ///////////// DIRECT CACHE ACCESS /////////////
331
+
332
+ function findByUID(uid) {
333
+ return db.values.get(uid)
334
+ }
335
+
336
+ function findWhere(where = {}) {
337
+ const predicate = wherePredicate(where)
338
+ return db.values.filter(value => !value.__deleted__ && predicate(value)).toArray()
339
+ }
340
+
341
+ ///////////// REAL-TIME OBSERVABLE /////////////
342
+
343
+ function getObservable(where = {}) {
344
+ addSynchroWhere(where).then((isNew: boolean) => {
345
+ // console.log('getObservable addSynchroWhere', modelName, where, isNew);
346
+ if (isNew && app.isConnected) {
347
+ synchronize(modelName, db.values, db.metadata, where, app.disconnectedDate)
348
+ }
349
+ })
350
+ const predicate = wherePredicate(where)
351
+ return from(liveQuery(() => db.values.filter(value => !value.__deleted__ && predicate(value)).toArray())).pipe(
352
+ distinctUntilChanged((prev, curr) => {
353
+ // Deep equality check to prevent unnecessary emissions (in particular on database write)
354
+ return JSON.stringify(prev) === JSON.stringify(curr)
355
+ })
356
+ )
357
+ }
358
+
359
+ let count = 0;
360
+
361
+ function addSynchroWhere(where: object) {
362
+ const promise = addSynchroDBWhere(where, db.whereList)
363
+ promise.then(isNew => isNew && count++ && console.log(`addSynchroWhere (${count})`, dbName, modelName, where))
364
+ return promise
365
+ }
366
+
367
+ function removeSynchroWhere(where: object) {
368
+ console.log('removeSynchroWhere', dbName, modelName, where)
369
+ count -= 1
370
+ return removeSynchroDBWhere(where, db.whereList)
371
+ }
372
+
373
+ // async function synchronizeAll() {
374
+ // await synchronizeModelWhereList(app, modelName, db.values, db.metadata, app.getDisconnectedDate(), db.whereList)
375
+ // }
376
+
377
+ // Automatically clean up when the component using this composable unmounts
378
+ tryOnScopeDispose(async () => {
379
+ console.log('CLEANING', dbName, modelName)
380
+ const whereList = await db.whereList.toArray()
381
+ for (const where of whereList) {
382
+ removeSynchroWhere(JSON.parse(where.sortedjson))
383
+ }
384
+ })
385
+
386
+ return {
387
+ db, reset,
388
+ create, update, remove,
389
+ findByUID, findWhere,
390
+ getObservable,
391
+ // synchronizeAll,
392
+ addSynchroWhere,
393
+ }
394
+ }
395
+
396
+ app.addConnectListener(async (_socket) => {
397
+ app.connectedDate = new Date()
398
+ console.log('onConnect', app.connectedDate)
399
+ app.disconnectedDate = null
400
+ app.isConnected = true
401
+ })
402
+
403
+ app.addDisconnectListener(async (_socket) => {
404
+ app.connectedDate = null
405
+ app.disconnectedDate = new Date()
406
+ console.log('onDisconnect', app.disconnectedDate)
407
+ app.isConnected = false
408
+ })
409
+
410
+
411
+ const mutex = new Mutex()
412
+
413
+ // ex: where = { uid: 'azer' }
414
+ async function synchronize(app, modelName, idbValues, idbMetadata, where, cutoffDate) {
415
+ await mutex.acquire()
416
+ console.log('synchronize', modelName, where)
417
+
418
+ let toAdd = []
419
+ try {
420
+ const requestPredicate = wherePredicate(where)
421
+
422
+ // collect meta-data of local values
423
+ // NOTE: __delete__ on values allows to collect metadata from cache-deleted values
424
+ const valueList = await idbValues.filter(requestPredicate).toArray()
425
+ const clientMetadataDict = {}
426
+ for (const value of valueList) {
427
+ const metadata = await idbMetadata.get(value.uid)
428
+ if (metadata) {
429
+ clientMetadataDict[value.uid] = metadata
430
+ } else {
431
+ // should not happen
432
+ clientMetadataDict[value.uid] = {}
433
+ }
434
+ }
435
+
436
+ // call sync service on `where` perimeter
437
+ const syncResult = await app.service('sync').go(modelName, where, cutoffDate, clientMetadataDict)
438
+ toAdd = syncResult.toAdd
439
+ const { toUpdate, toDelete, addDatabase, updateDatabase } = syncResult
440
+ console.log('-> service.sync', modelName, where, toAdd, toUpdate, toDelete, addDatabase, updateDatabase)
441
+
442
+ // 1- add missing elements in indexedDB cache
443
+ // Use a single transaction for all adds to ensure atomicity
444
+ if (toAdd.length > 0) {
445
+ await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
446
+ for (const [value, metaData] of toAdd) {
447
+ await idbValues.add(value)
448
+ await idbMetadata.add(metaData)
449
+ }
450
+ })
451
+ }
452
+ // 2- delete elements from indexedDB cache
453
+ for (const [uid, deleted_at] of toDelete) {
454
+ await idbValues.delete(uid)
455
+ await idbMetadata.update(uid, { deleted_at })
456
+ }
457
+ // 3- update elements of cache
458
+ for (const elt of toUpdate) {
459
+ // get full value of element to update
460
+ const value = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
461
+ delete value.uid
462
+ delete value.__deleted__
463
+ await idbValues.update(elt.uid, value)
464
+ const metadata = await idbMetadata.get(elt.uid)
465
+ await idbMetadata.update(elt.uid, { updated_at: metadata.updated_at })
466
+ }
467
+
468
+ // 4- create elements of `addDatabase` with full data from cache
469
+ for (const elt of addDatabase) {
470
+ const fullValue = await idbValues.get(elt.uid)
471
+ const meta = await idbMetadata.get(elt.uid)
472
+ delete fullValue.uid
473
+ delete fullValue.__deleted__
474
+ try {
475
+ await app.service(modelName).createWithMeta(elt.uid, fullValue, meta.created_at)
476
+ } catch(err) {
477
+ console.log("*** err sync user addDatabase", err, elt.uid, fullValue, meta.created_at)
478
+ // rollback
479
+ await idbValues.delete(elt.uid)
480
+ await idbMetadata.delete(elt.uid)
481
+ }
482
+ }
483
+
484
+ // 5- update elements of `updateDatabase` with full data from cache
485
+ for (const elt of updateDatabase) {
486
+ const fullValue = await idbValues.get(elt.uid)
487
+ const meta = await idbMetadata.get(elt.uid)
488
+ delete fullValue.uid
489
+ delete fullValue.__deleted__
490
+ try {
491
+ await app.service(modelName).updateWithMeta(elt.uid, fullValue, meta.updated_at)
492
+ } catch(err) {
493
+ console.log("*** err sync user updateDatabase", err)
494
+ // rollback
495
+ const previousDatabaseValue = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
496
+ const previousDatabaseMetadata = await app.service('metadata').findUnique({ where:{ uid: elt.uid }})
497
+ await idbValues.update(elt.uid, previousDatabaseValue)
498
+ await idbMetadata.update(elt.uid, previousDatabaseMetadata)
499
+ }
500
+ }
501
+ } catch(err) {
502
+ console.log('err synchronize', modelName, where, err)
503
+ } finally {
504
+ mutex.release()
505
+ }
506
+ }
507
+
508
+ function wherePredicate(where) {
509
+ return (elt) => {
510
+ for (const [attr, value] of Object.entries(where)) {
511
+ const eltAttrValue = elt[attr]
512
+
513
+ if (typeof(value) === 'string' || typeof(value) === 'number') {
514
+ // 'attr = value' clause
515
+ if (eltAttrValue !== value) return false
516
+
517
+ } else if (typeof(value) === 'object') {
518
+ // 'attr = { lt/lte/gt/gte: value }' clause
519
+ if (value.lte) {
520
+ if (eltAttrValue > value.lte) return false
521
+ } else if (value.lt) {
522
+ if (eltAttrValue >= value.lt) return false
523
+ } else if (value.gte) {
524
+ if (eltAttrValue < value.gte) return false
525
+ } else if (value.gt) {
526
+ if (eltAttrValue <= value.gt) return false
527
+ }
528
+ }
529
+ }
530
+ return true
531
+ }
532
+ }
533
+
534
+ async function getWhereList(whereDb) {
535
+ const list = await whereDb.toArray()
536
+ return list.map(elt => JSON.parse(elt.sortedjson))
537
+ }
538
+
539
+ async function addSynchroDBWhere(where, whereDb) {
540
+ await mutex.acquire()
541
+ let modified = false
542
+ try {
543
+ const whereList = await getWhereList(whereDb)
544
+ if (!isSubsetAmong(where, whereList)) {
545
+ // sortedjson is used as a unique standardized representation of a 'where' object ; it is used both as key and value in 'wheredb' database
546
+ await whereDb.add({ sortedjson: stringifyWithSortedKeys(where) })
547
+ modified = true
548
+ }
549
+ } catch(err) {
550
+ console.log('err addSynchroDBWhere', where, err)
551
+ } finally {
552
+ mutex.release()
553
+ }
554
+ return modified
555
+ }
556
+
557
+ async function removeSynchroDBWhere(where, whereDb) {
558
+ await mutex.acquire()
559
+ try {
560
+ const swhere = stringifyWithSortedKeys(where)
561
+ await whereDb.filter(value => (value.sortedjson === swhere)).delete()
562
+ } catch(err) {
563
+ console.log('err removeSynchroDBWhere', err)
564
+ } finally {
565
+ mutex.release()
566
+ }
567
+ }
568
+
569
+ // async function synchronizeModelWhereList(modelName, idbValues, idbMetadata, cutoffDate, whereDb) {
570
+ // const whereList = await getWhereList(whereDb)
571
+ // for (const where of whereList) {
572
+ // await synchronize(modelName, idbValues, idbMetadata, where, cutoffDate)
573
+ // }
574
+ // }
575
+
576
+ // Singleton map to reuse Dexie instances per database name
577
+ const dbInstances = new Map();
578
+
579
+ function getOrCreateDB(dbName: string, fields: string[]) {
580
+ if (!dbInstances.has(dbName)) {
581
+ const db = new Dexie(dbName);
582
+ db.version(1).stores({
583
+ whereList: "sortedjson",
584
+ values: ['uid', '__deleted__', ...fields].join(','),
585
+ metadata: "uid, created_at, updated_at, deleted_at",
586
+ });
587
+ dbInstances.set(dbName, db);
588
+ }
589
+ return dbInstances.get(dbName);
590
+ }
591
+
592
+ // enrich `app` with new methods and attributes
593
+ return Object.assign(app, {
594
+ createOfflineModel,
595
+ })
596
+ }
597
+
598
+
599
+ function stringifyWithSortedKeys(obj, space = null) {
600
+ return JSON.stringify(obj, (key, value) => {
601
+ // If the value is a plain object (not an array, null, or other object type like Date)
602
+ if (value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.toString.call(value) === '[object Object]') {
603
+ const sorted = {};
604
+ // Get all keys, sort them, and then re-add them to a new object
605
+ // This new object will maintain the sorted order when stringified
606
+ Object.keys(value).sort().forEach(k => {
607
+ sorted[k] = value[k];
608
+ });
609
+ return sorted;
610
+ }
611
+ // For all other types (primitives, arrays, null, etc.), return the value as is
612
+ return value;
613
+ }, space); // 'space' is optional for pretty-printing (e.g., 2 or 4)
165
614
  }
615
+ // console.log('stringifyWithSortedKeys({ age: 30, name: "Alice", data: { city: "Paris", color: "red" }})', stringifyWithSortedKeys({ age: 30, name: "Alice", data: { city: "Paris", color: "red" } }))
616
+
617
+ export class Mutex {
618
+ constructor() {
619
+ this.locked = false;
620
+ this.queue = [];
621
+ }
622
+
623
+ async acquire() {
624
+ if (this.locked) {
625
+ return new Promise(resolve => this.queue.push(resolve));
626
+ }
627
+ this.locked = true;
628
+ }
629
+
630
+ release() {
631
+ if (this.queue.length > 0) {
632
+ const next = this.queue.shift();
633
+ next();
634
+ } else {
635
+ this.locked = false;
636
+ }
637
+ }
638
+ }
639
+
640
+ function isSubset(subset, fullObject) {
641
+ // return Object.entries(subset).some(([key, value]) => fullObject[key] === value)
642
+ for (const key in fullObject) {
643
+ if (fullObject[key] !== subset[key]) return false
644
+ }
645
+ return true
646
+ }
647
+ // console.log('isSubset({a: 1, b: 2}, {b: 2})=true', isSubset({a: 1, b: 2}, {b: 2}))
648
+ // console.log('isSubset({}, {})=true', isSubset({}, {}))
649
+ // console.log('isSubset({a: 1}, {})=true', isSubset({a: 1}, {}))
650
+ // console.log('isSubset({a: 1}, {b: 2})=false', isSubset({a: 1}, {b: 2}))
651
+ // console.log('isSubset({a: 1}, {a: 1})=true', isSubset({a: 1}, {a: 1}))
652
+
653
+ function isSubsetAmong(subset, fullObjectList) {
654
+ return fullObjectList.some(fullObject => isSubset(subset, fullObject));
655
+ }
656
+ // console.log('isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}])=true', isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}]))
657
+
@@ -1,18 +0,0 @@
1
- {
2
- // Utilisez IntelliSense pour en savoir plus sur les attributs possibles.
3
- // Pointez pour afficher la description des attributs existants.
4
- // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
5
- "version": "0.2.0",
6
- "configurations": [
7
- {
8
- "type": "node",
9
- "request": "launch",
10
- "name": "test",
11
- "skipFiles": [
12
- "<node_internals>/**"
13
- ],
14
- "cwd": "${workspaceFolder}",
15
- "program": "${workspaceFolder}/test/index.test.js"
16
- }
17
- ]
18
- }