@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.
- package/package.json +1 -1
- package/src/client.mts +121 -99
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
448
|
+
for (const [uid] of deleteClient) {
|
|
440
449
|
await idbValues.delete(uid)
|
|
441
|
-
await idbMetadata.
|
|
450
|
+
await idbMetadata.delete(uid)
|
|
442
451
|
}
|
|
443
|
-
// 3- update elements of cache
|
|
444
|
-
for (const elt of
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|