@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.
- package/package.json +1 -1
- package/src/client.mts +123 -110
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
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
448
|
+
for (const [uid] of deleteClient) {
|
|
449
449
|
await idbValues.delete(uid)
|
|
450
|
-
await idbMetadata.
|
|
450
|
+
await idbMetadata.delete(uid)
|
|
451
451
|
}
|
|
452
|
-
// 3- update elements of cache
|
|
453
|
-
for (const elt of
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|