cry-db 2.4.31 → 2.4.33
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/README.md +90 -0
- package/dist/mongo.d.mts +147 -3
- package/dist/mongo.d.mts.map +1 -1
- package/dist/mongo.mjs +543 -48
- package/dist/mongo.mjs.map +1 -1
- package/dist/types.d.mts +11 -0
- package/dist/types.d.mts.map +1 -1
- package/dist/types.mjs.map +1 -1
- package/package.json +37 -36
package/dist/mongo.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// AI modified: 2026-05-09 (terminal bracket-id `arr[<id>]: undefined` → $pull (regular doc, lahko z arrayFilters); kombiniran z insert v unified $filter+$concatArrays pipeline stage per field)
|
|
2
|
+
// AI modified: 2026-05-09 (terminal bracket-id `arr[<id>]` z array vrednostjo → idempotent insert preko aggregation pipeline ($concatArrays + $filter); quoted bracket id `['p0']`/`["p0"]` zdaj enak unquoted `[p0]`)
|
|
3
|
+
// AI modified: 2026-04-25 (GetNewerSpec.specId — disambiguate duplicate-collection specs in findNewerMany / findNewerManyStream)
|
|
1
4
|
// AI modified: 2026-04-21
|
|
2
5
|
import * as bcrypt from 'bcrypt';
|
|
3
6
|
import { Formatter, FracturedJsonOptions } from 'fracturedjsonjs';
|
|
@@ -34,6 +37,7 @@ const parseFieldList = (fields) => (typeof fields === 'string' ? fields.split(',
|
|
|
34
37
|
// String helper functions (faster than RegExp)
|
|
35
38
|
const startsWithDollar = (s) => s.length > 0 && s[0] === '$';
|
|
36
39
|
const startsWithDoubleUnderscore = (s) => s.length > 1 && s[0] === '_' && s[1] === '_';
|
|
40
|
+
const isServerReserved = (s) => s === "_rev" || s === "_ts";
|
|
37
41
|
const startsWithHashedPrefix = (s) => s.length > 10 && s.startsWith(HASHED_PREFIX);
|
|
38
42
|
const isHex24 = (s) => {
|
|
39
43
|
if (s.length !== 24)
|
|
@@ -306,12 +310,22 @@ export class Mongo extends Db {
|
|
|
306
310
|
fjLog.debug('findNewer returns', ret);
|
|
307
311
|
return ret;
|
|
308
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* Batched delta read across multiple collection specs.
|
|
315
|
+
*
|
|
316
|
+
* Response keying: each spec's results land at `spec.specId ?? spec.collection`
|
|
317
|
+
* in the returned `Record`. Specs without `specId` collide if they share a
|
|
318
|
+
* `collection` (existing behavior — last spec wins). Pass `specId` to
|
|
319
|
+
* disambiguate, e.g. when sending a positive-query spec and a scope-exit
|
|
320
|
+
* spec for the same collection in one batch.
|
|
321
|
+
*/
|
|
309
322
|
async findNewerMany(spec = [], defaultOpts = {}) {
|
|
310
323
|
var _a;
|
|
311
324
|
fjLog.debug('findNewerMany called', spec);
|
|
312
325
|
const dbName = this.db; // Capture db at operation start
|
|
313
326
|
let conn = await this.connect();
|
|
314
327
|
const getOneColl = async (coll) => {
|
|
328
|
+
var _a;
|
|
315
329
|
let query = this._createQueryForNewer(coll.timestamp, coll.query || {});
|
|
316
330
|
const opts = { ...defaultOpts, ...coll.opts };
|
|
317
331
|
if (this.softdelete && !opts.returnDeleted) {
|
|
@@ -323,7 +337,7 @@ export class Mongo extends Db {
|
|
|
323
337
|
query._archived = { $exists: false };
|
|
324
338
|
}
|
|
325
339
|
if (process.env.MONGO_DEBUG_FINDNEWERMANY) {
|
|
326
|
-
fjLog.debug("findNewerMany <-", coll.collection, coll.timestamp, coll.query, " -> ", JSON.stringify(query));
|
|
340
|
+
fjLog.debug("findNewerMany <-", coll.collection, coll.specId, coll.timestamp, coll.query, " -> ", JSON.stringify(query));
|
|
327
341
|
}
|
|
328
342
|
let r = this._applyQueryOpts(conn.db(dbName).collection(coll.collection).find(query, {}).sort({ _ts: 1 }), opts);
|
|
329
343
|
let data = await r.toArray();
|
|
@@ -331,14 +345,14 @@ export class Mongo extends Db {
|
|
|
331
345
|
fjLog.debug("findNewerMany ->", coll.collection, JSON.stringify(data, null, 2));
|
|
332
346
|
}
|
|
333
347
|
this._processReturnedObject(data, this._findNewerRemoveFields);
|
|
334
|
-
return {
|
|
348
|
+
return { key: (_a = coll.specId) !== null && _a !== void 0 ? _a : coll.collection, data };
|
|
335
349
|
};
|
|
336
350
|
if (process.env.MONGO_SERIALIZE_FIND_NEWER_MANY) {
|
|
337
351
|
let out = {};
|
|
338
352
|
for await (let coll of spec) {
|
|
339
353
|
let r = await getOneColl(coll);
|
|
340
354
|
if ((_a = r.data) === null || _a === void 0 ? void 0 : _a.length)
|
|
341
|
-
out[r.
|
|
355
|
+
out[r.key] = r.data;
|
|
342
356
|
}
|
|
343
357
|
return out;
|
|
344
358
|
}
|
|
@@ -347,7 +361,7 @@ export class Mongo extends Db {
|
|
|
347
361
|
for (let coll of spec)
|
|
348
362
|
promises.push(getOneColl(coll));
|
|
349
363
|
let out = {};
|
|
350
|
-
(await Promise.all(promises)).filter(r => r.data.length).forEach(r => out[r.
|
|
364
|
+
(await Promise.all(promises)).filter(r => r.data.length).forEach(r => out[r.key] = r.data);
|
|
351
365
|
return out;
|
|
352
366
|
}
|
|
353
367
|
}
|
|
@@ -356,8 +370,13 @@ export class Mongo extends Db {
|
|
|
356
370
|
* Instead of collecting all results into memory, iterates each collection's
|
|
357
371
|
* cursor and calls onChunk for each batch of documents.
|
|
358
372
|
*
|
|
359
|
-
* @param spec - Array of collection specs with timestamps and queries
|
|
360
|
-
*
|
|
373
|
+
* @param spec - Array of collection specs with timestamps and queries.
|
|
374
|
+
* Each spec may set `specId` to disambiguate multiple specs against the
|
|
375
|
+
* same collection (forwarded as the third arg to `onChunk`).
|
|
376
|
+
* @param onChunk - Callback called for each batch of documents. Receives
|
|
377
|
+
* `(collection, items, specId?)`. `specId` is `undefined` when the
|
|
378
|
+
* originating spec did not set one — old callers ignoring the third arg
|
|
379
|
+
* keep working unchanged.
|
|
361
380
|
* @param chunkSize - Number of documents per chunk (default 200)
|
|
362
381
|
* @param defaultOpts - Default query options applied to all collections
|
|
363
382
|
*/
|
|
@@ -382,12 +401,12 @@ export class Mongo extends Db {
|
|
|
382
401
|
this._processReturnedObject(doc, this._findNewerRemoveFields);
|
|
383
402
|
chunk.push(doc);
|
|
384
403
|
if (chunk.length >= chunkSize) {
|
|
385
|
-
await onChunk(coll.collection, chunk);
|
|
404
|
+
await onChunk(coll.collection, chunk, coll.specId);
|
|
386
405
|
chunk = [];
|
|
387
406
|
}
|
|
388
407
|
}
|
|
389
408
|
if (chunk.length > 0) {
|
|
390
|
-
await onChunk(coll.collection, chunk);
|
|
409
|
+
await onChunk(coll.collection, chunk, coll.specId);
|
|
391
410
|
}
|
|
392
411
|
}
|
|
393
412
|
}
|
|
@@ -616,21 +635,29 @@ export class Mongo extends Db {
|
|
|
616
635
|
if (this._hasHashedKeys(update))
|
|
617
636
|
await this._processHashedKeys(update);
|
|
618
637
|
update = this._processUpdateObject(update);
|
|
638
|
+
const processed = this._applyBracketProcessing(update);
|
|
639
|
+
if (processed.arrayFilters)
|
|
640
|
+
opts.arrayFilters = processed.arrayFilters;
|
|
641
|
+
const isPipeline = Array.isArray(processed.update);
|
|
642
|
+
// For pipeline form, sequence-keys auto-injection cannot recurse into array of stages.
|
|
643
|
+
// Skip the `update.$set` normalization and seqKeys path entirely when pipeline.
|
|
644
|
+
let seqKeys = isPipeline ? undefined : this._findSequenceKeys(update.$set);
|
|
619
645
|
fjLog.debug('updateOne called', collection, query, update);
|
|
620
|
-
let seqKeys = this._findSequenceKeys(update.$set);
|
|
621
646
|
let obj = await this.executeTransactionally(collection, async (conn, client) => {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
647
|
+
if (!isPipeline) {
|
|
648
|
+
update.$set = update.$set || {};
|
|
649
|
+
if (seqKeys)
|
|
650
|
+
await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
|
|
651
|
+
if (update.$set === undefined || Object.keys(update.$set).length === 0)
|
|
652
|
+
delete update.$set;
|
|
653
|
+
}
|
|
654
|
+
let res = await conn.findOneAndUpdate(query, processed.update, opts);
|
|
628
655
|
if (!res)
|
|
629
656
|
return null;
|
|
630
|
-
let resObj = this._removeUnchanged(res, update, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
657
|
+
let resObj = isPipeline ? res : this._removeUnchanged(res, update, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
631
658
|
await this._publishAndAudit('update', dbName, collection, resObj);
|
|
632
659
|
return resObj;
|
|
633
|
-
}, !!seqKeys, { operation: "updateOne", collection, query, update, options });
|
|
660
|
+
}, !!seqKeys, { operation: "updateOne", collection, query, update: processed.update, options });
|
|
634
661
|
fjLog.debug('updateOne returns', obj);
|
|
635
662
|
return this._processReturnedObject(await obj);
|
|
636
663
|
}
|
|
@@ -648,21 +675,27 @@ export class Mongo extends Db {
|
|
|
648
675
|
if (this._hasHashedKeys(update))
|
|
649
676
|
await this._processHashedKeys(update);
|
|
650
677
|
update = this._processUpdateObject(update);
|
|
678
|
+
const processed = this._applyBracketProcessing(update);
|
|
679
|
+
if (processed.arrayFilters)
|
|
680
|
+
opts.arrayFilters = processed.arrayFilters;
|
|
681
|
+
const isPipeline = Array.isArray(processed.update);
|
|
651
682
|
fjLog.debug('save called', collection, id, update);
|
|
652
|
-
let seqKeys = this._findSequenceKeys(update.$set);
|
|
683
|
+
let seqKeys = isPipeline ? undefined : this._findSequenceKeys(update.$set);
|
|
653
684
|
let obj = await this.executeTransactionally(collection, async (conn, client) => {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
685
|
+
if (!isPipeline) {
|
|
686
|
+
update.$set = update.$set || {};
|
|
687
|
+
if (seqKeys)
|
|
688
|
+
await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
|
|
689
|
+
if (update.$set === undefined || Object.keys(update.$set).length === 0)
|
|
690
|
+
delete update.$set;
|
|
691
|
+
}
|
|
692
|
+
let res = await conn.findOneAndUpdate({ _id }, processed.update, opts);
|
|
660
693
|
if (!res)
|
|
661
694
|
return null;
|
|
662
|
-
let resObj = this._removeUnchanged(res, update, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
695
|
+
let resObj = isPipeline ? res : this._removeUnchanged(res, update, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
663
696
|
await this._publishAndAudit('update', dbName, collection, resObj);
|
|
664
697
|
return resObj;
|
|
665
|
-
}, !!seqKeys, { operation: "save", collection, _id, update, options });
|
|
698
|
+
}, !!seqKeys, { operation: "save", collection, _id, update: processed.update, options });
|
|
666
699
|
fjLog.debug('save returns', obj);
|
|
667
700
|
return this._processReturnedObject(await obj);
|
|
668
701
|
}
|
|
@@ -684,23 +717,29 @@ export class Mongo extends Db {
|
|
|
684
717
|
if (this._hasHashedKeys(update))
|
|
685
718
|
await this._processHashedKeys(update);
|
|
686
719
|
update = this._processUpdateObject(update);
|
|
720
|
+
const processed = this._applyBracketProcessing(update);
|
|
721
|
+
if (processed.arrayFilters)
|
|
722
|
+
opts.arrayFilters = processed.arrayFilters;
|
|
723
|
+
const isPipeline = Array.isArray(processed.update);
|
|
687
724
|
fjLog.debug('update called', collection, query, update);
|
|
688
|
-
let seqKeys = this._findSequenceKeys(update.$set);
|
|
725
|
+
let seqKeys = isPipeline ? undefined : this._findSequenceKeys(update.$set);
|
|
689
726
|
let obj = await this.executeTransactionally(collection, async (conn, client) => {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
727
|
+
if (!isPipeline) {
|
|
728
|
+
update.$set = update.$set || {};
|
|
729
|
+
if (seqKeys)
|
|
730
|
+
await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
|
|
731
|
+
if (update.$set === undefined || Object.keys(update.$set).length === 0)
|
|
732
|
+
delete update.$set;
|
|
733
|
+
}
|
|
695
734
|
fjLog.debug('update called', collection, query, update);
|
|
696
|
-
let res = await conn.updateMany(query, update, opts);
|
|
735
|
+
let res = await conn.updateMany(query, processed.update, opts);
|
|
697
736
|
let resObj = {
|
|
698
737
|
n: res.modifiedCount,
|
|
699
738
|
ok: !!res.acknowledged
|
|
700
739
|
};
|
|
701
740
|
await this._publishAndAudit('updateMany', dbName, collection, resObj);
|
|
702
741
|
return resObj;
|
|
703
|
-
}, !!seqKeys, { operation: "update", collection, query, update });
|
|
742
|
+
}, !!seqKeys, { operation: "update", collection, query, update: processed.update });
|
|
704
743
|
fjLog.debug('update returns', obj);
|
|
705
744
|
return await obj;
|
|
706
745
|
}
|
|
@@ -723,17 +762,23 @@ export class Mongo extends Db {
|
|
|
723
762
|
if (this._hasHashedKeys(update))
|
|
724
763
|
await this._processHashedKeys(update);
|
|
725
764
|
update = this._processUpdateObject(update);
|
|
726
|
-
|
|
765
|
+
const processed = this._applyBracketProcessing(update);
|
|
766
|
+
if (processed.arrayFilters)
|
|
767
|
+
opts.arrayFilters = processed.arrayFilters;
|
|
768
|
+
const isPipeline = Array.isArray(processed.update);
|
|
769
|
+
let seqKeys = isPipeline ? undefined : this._findSequenceKeys(update.$set);
|
|
727
770
|
fjLog.debug('upsert processed', collection, query, update);
|
|
728
771
|
if (Object.keys(query).length === 0)
|
|
729
772
|
query._id = Mongo.newid();
|
|
730
773
|
let ret = await this.executeTransactionally(collection, async (conn, client) => {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
774
|
+
if (!isPipeline) {
|
|
775
|
+
update.$set = update.$set || {};
|
|
776
|
+
if (seqKeys)
|
|
777
|
+
await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
|
|
778
|
+
if (update.$set === undefined || Object.keys(update.$set).length === 0)
|
|
779
|
+
delete update.$set;
|
|
780
|
+
}
|
|
781
|
+
let ret = await conn.findOneAndUpdate(query, processed.update, opts);
|
|
737
782
|
if (ret) {
|
|
738
783
|
// Detect if this was an insert or update by checking _rev
|
|
739
784
|
const isInsert = this.revisions && ret._rev === 1;
|
|
@@ -821,23 +866,28 @@ export class Mongo extends Db {
|
|
|
821
866
|
update = this.replaceIds(update);
|
|
822
867
|
if (this._hasHashedKeys(update))
|
|
823
868
|
await this._processHashedKeys(update);
|
|
824
|
-
const
|
|
869
|
+
const processedBase = this._processUpdateObject({ ...update });
|
|
870
|
+
const processed = this._applyBracketProcessing(processedBase);
|
|
871
|
+
const isPipeline = Array.isArray(processed.update);
|
|
825
872
|
const opts = {
|
|
826
873
|
upsert: true,
|
|
827
874
|
returnDocument: "after",
|
|
828
875
|
includeResultMetadata: true,
|
|
876
|
+
...(processed.arrayFilters ? { arrayFilters: processed.arrayFilters } : {}),
|
|
829
877
|
...this._sessionOpt()
|
|
830
878
|
};
|
|
831
879
|
const res = await conn
|
|
832
880
|
.db(dbName)
|
|
833
881
|
.collection(collection)
|
|
834
|
-
.findOneAndUpdate({ _id: objectId },
|
|
882
|
+
.findOneAndUpdate({ _id: objectId }, processed.update, opts);
|
|
835
883
|
if (res === null || res === void 0 ? void 0 : res.value) {
|
|
836
884
|
// Determine if this was an insert or update based on lastErrorObject
|
|
837
885
|
const wasInsert = !((_a = res.lastErrorObject) === null || _a === void 0 ? void 0 : _a.updatedExisting);
|
|
838
886
|
const operation = wasInsert ? 'insert' : 'update';
|
|
839
|
-
// For inserts
|
|
840
|
-
|
|
887
|
+
// For inserts (or pipeline updates) return full object; for regular
|
|
888
|
+
// updates, strip fields that didn't change. _removeUnchanged expects
|
|
889
|
+
// doc-form update — skip for pipeline.
|
|
890
|
+
const retObj = (wasInsert || isPipeline) ? res.value : this._removeUnchanged(res.value, processedBase, false);
|
|
841
891
|
this._processReturnedObject(retObj);
|
|
842
892
|
return { success: true, _id, data: retObj, operation, wasInsert };
|
|
843
893
|
}
|
|
@@ -1000,8 +1050,12 @@ export class Mongo extends Db {
|
|
|
1000
1050
|
};
|
|
1001
1051
|
if (this._hasHashedKeys(update))
|
|
1002
1052
|
await this._processHashedKeys(update);
|
|
1003
|
-
const
|
|
1004
|
-
const
|
|
1053
|
+
const processedBase = this._processUpdateObject({ ...update });
|
|
1054
|
+
const processed = this._applyBracketProcessing(processedBase);
|
|
1055
|
+
if (processed.arrayFilters)
|
|
1056
|
+
options.arrayFilters = processed.arrayFilters;
|
|
1057
|
+
const isPipeline = Array.isArray(processed.update);
|
|
1058
|
+
const result = await conn.findOneAndUpdate(query, processed.update, options);
|
|
1005
1059
|
if (!((_a = result === null || result === void 0 ? void 0 : result.value) === null || _a === void 0 ? void 0 : _a._id))
|
|
1006
1060
|
return null;
|
|
1007
1061
|
const ret = result.value;
|
|
@@ -1016,7 +1070,7 @@ export class Mongo extends Db {
|
|
|
1016
1070
|
else {
|
|
1017
1071
|
oper = "insert";
|
|
1018
1072
|
}
|
|
1019
|
-
const retObj = oper === "insert" ? ret : this._removeUnchanged(ret,
|
|
1073
|
+
const retObj = oper === "insert" ? ret : (isPipeline ? ret : this._removeUnchanged(ret, processedBase, !!(opts === null || opts === void 0 ? void 0 : opts.returnFullObject)));
|
|
1020
1074
|
this._processReturnedObject(retObj);
|
|
1021
1075
|
return { operation: oper, data: retObj };
|
|
1022
1076
|
});
|
|
@@ -2352,6 +2406,10 @@ export class Mongo extends Db {
|
|
|
2352
2406
|
delete update[key];
|
|
2353
2407
|
continue;
|
|
2354
2408
|
}
|
|
2409
|
+
if (isServerReserved(keyStr) && !keyStr.startsWith(HASHED_PREFIX)) {
|
|
2410
|
+
delete update[key];
|
|
2411
|
+
continue;
|
|
2412
|
+
}
|
|
2355
2413
|
if (key === '' || key === null || key === undefined) {
|
|
2356
2414
|
delete update[key];
|
|
2357
2415
|
continue;
|
|
@@ -2385,6 +2443,443 @@ export class Mongo extends Db {
|
|
|
2385
2443
|
}
|
|
2386
2444
|
return update;
|
|
2387
2445
|
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Translate `arr[<_id>].field` bracket-by-_id segments inside `$set`
|
|
2448
|
+
* and `$unset` paths to mongo positional `$[<filterId>]` placeholders,
|
|
2449
|
+
* generating matching `arrayFilters` entries.
|
|
2450
|
+
*
|
|
2451
|
+
* Lets clients identify array elements by their `_id` instead of by
|
|
2452
|
+
* position. Mongo evaluates the filter against the live document at
|
|
2453
|
+
* write time, so the operation is atomic — no race window exists where
|
|
2454
|
+
* a concurrent reorder/insert/delete by another writer could shift the
|
|
2455
|
+
* targeted index between client read and server write.
|
|
2456
|
+
*
|
|
2457
|
+
* Path token forms (matches `cry-synced-db-client` `tokenizePath`):
|
|
2458
|
+
* - plain `field` → kept as-is
|
|
2459
|
+
* - numeric `<n>` → kept as-is (legacy positional index path)
|
|
2460
|
+
* - bracket `[<_id>]` → translated to `$[fN]`, filter `{fN._id: <_id>}` emitted
|
|
2461
|
+
*
|
|
2462
|
+
* Filters are deduplicated by (parentPath, _id) so multiple paths into
|
|
2463
|
+
* the same array element share one filter (mongo requires every filter
|
|
2464
|
+
* identifier to be referenced).
|
|
2465
|
+
*
|
|
2466
|
+
* Mutates `update.$set` / `update.$unset` in place. Returns the
|
|
2467
|
+
* arrayFilters array (or `undefined` when no bracket paths present).
|
|
2468
|
+
*/
|
|
2469
|
+
_extractArrayFilters(update) {
|
|
2470
|
+
const filters = [];
|
|
2471
|
+
const memo = new Map();
|
|
2472
|
+
let counter = 0;
|
|
2473
|
+
const translatePath = (path) => {
|
|
2474
|
+
const tokens = Mongo._tokenizePath(path);
|
|
2475
|
+
const out = [];
|
|
2476
|
+
let parentKey = '';
|
|
2477
|
+
for (const t of tokens) {
|
|
2478
|
+
if (t.length >= 2 && t.charCodeAt(0) === 91 /* [ */ && t.charCodeAt(t.length - 1) === 93 /* ] */) {
|
|
2479
|
+
const idStr = Mongo._unquoteBracketId(t, path);
|
|
2480
|
+
const memoKey = `${parentKey}|${idStr}`;
|
|
2481
|
+
let filterName = memo.get(memoKey);
|
|
2482
|
+
if (filterName === undefined) {
|
|
2483
|
+
filterName = `f${counter++}`;
|
|
2484
|
+
memo.set(memoKey, filterName);
|
|
2485
|
+
filters.push({ [`${filterName}._id`]: idStr });
|
|
2486
|
+
}
|
|
2487
|
+
out.push(`$[${filterName}]`);
|
|
2488
|
+
parentKey = parentKey ? `${parentKey}.$[${filterName}]` : `$[${filterName}]`;
|
|
2489
|
+
}
|
|
2490
|
+
else {
|
|
2491
|
+
out.push(t);
|
|
2492
|
+
parentKey = parentKey ? `${parentKey}.${t}` : t;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
return out.join('.');
|
|
2496
|
+
};
|
|
2497
|
+
const translateOp = (opName) => {
|
|
2498
|
+
const op = update[opName];
|
|
2499
|
+
if (!op || typeof op !== 'object')
|
|
2500
|
+
return;
|
|
2501
|
+
// Fast path: skip rebuild if no bracket present.
|
|
2502
|
+
let hasBracket = false;
|
|
2503
|
+
for (const k of Object.keys(op)) {
|
|
2504
|
+
if (k.indexOf('[') >= 0) {
|
|
2505
|
+
hasBracket = true;
|
|
2506
|
+
break;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
if (!hasBracket)
|
|
2510
|
+
return;
|
|
2511
|
+
const translated = {};
|
|
2512
|
+
for (const k of Object.keys(op)) {
|
|
2513
|
+
translated[translatePath(k)] = op[k];
|
|
2514
|
+
}
|
|
2515
|
+
update[opName] = translated;
|
|
2516
|
+
};
|
|
2517
|
+
translateOp('$set');
|
|
2518
|
+
translateOp('$unset');
|
|
2519
|
+
return filters.length > 0 ? filters : undefined;
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Tokenize a dot-notation path that may contain `[<_id>]` bracket
|
|
2523
|
+
* segments. Mirrors the client-side `cry-synced-db-client/utils/computeDiff#tokenizePath`.
|
|
2524
|
+
*
|
|
2525
|
+
* "postavke[A].kolicina" → ["postavke", "[A]", "kolicina"]
|
|
2526
|
+
* "koraki.0.diag" → ["koraki", "0", "diag"]
|
|
2527
|
+
* "stranka.gsm" → ["stranka", "gsm"]
|
|
2528
|
+
* "arr[A].sub[B].field" → ["arr", "[A]", "sub", "[B]", "field"]
|
|
2529
|
+
*/
|
|
2530
|
+
static _tokenizePath(path) {
|
|
2531
|
+
const out = [];
|
|
2532
|
+
let buf = '';
|
|
2533
|
+
for (let i = 0; i < path.length; i++) {
|
|
2534
|
+
const ch = path[i];
|
|
2535
|
+
if (ch === '.') {
|
|
2536
|
+
if (buf) {
|
|
2537
|
+
out.push(buf);
|
|
2538
|
+
buf = '';
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
else if (ch === '[') {
|
|
2542
|
+
if (buf) {
|
|
2543
|
+
out.push(buf);
|
|
2544
|
+
buf = '';
|
|
2545
|
+
}
|
|
2546
|
+
const close = path.indexOf(']', i);
|
|
2547
|
+
if (close < 0) {
|
|
2548
|
+
buf += ch;
|
|
2549
|
+
continue;
|
|
2550
|
+
}
|
|
2551
|
+
out.push(path.substring(i, close + 1));
|
|
2552
|
+
i = close;
|
|
2553
|
+
}
|
|
2554
|
+
else {
|
|
2555
|
+
buf += ch;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
if (buf)
|
|
2559
|
+
out.push(buf);
|
|
2560
|
+
return out;
|
|
2561
|
+
}
|
|
2562
|
+
/**
|
|
2563
|
+
* Strip `[ ]` wrapper and surrounding `'…'` / `"…"` quotes from a bracket-id token.
|
|
2564
|
+
* Convention: `[p2]`, `['p2']`, `["p2"]` all denote element with `_id === "p2"`.
|
|
2565
|
+
* Quoted form is preferred when id contains characters that could conflict with
|
|
2566
|
+
* dot-notation (rare, but defensive).
|
|
2567
|
+
*
|
|
2568
|
+
* Throws on empty id (`[]`, `['']`, `[""]`) — empty bracket has no semantic meaning
|
|
2569
|
+
* (no element to target) and almost certainly indicates a caller bug. Optional
|
|
2570
|
+
* `path` argument is included in the error message for easier debugging.
|
|
2571
|
+
*/
|
|
2572
|
+
static _unquoteBracketId(bracketToken, path) {
|
|
2573
|
+
let id = bracketToken.slice(1, -1);
|
|
2574
|
+
const len = id.length;
|
|
2575
|
+
if (len >= 2) {
|
|
2576
|
+
const first = id.charCodeAt(0);
|
|
2577
|
+
const last = id.charCodeAt(len - 1);
|
|
2578
|
+
if ((first === 39 /* ' */ && last === 39) || (first === 34 /* " */ && last === 34)) {
|
|
2579
|
+
id = id.slice(1, -1);
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
if (id.length === 0) {
|
|
2583
|
+
throw new Error(`cry-db: empty bracket id in ${path ? `path "${path}"` : `token "${bracketToken}"`} — bracket-by-_id syntax requires non-empty id (e.g. "arr[<id>]" or "arr['<id>']").`);
|
|
2584
|
+
}
|
|
2585
|
+
return id;
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* Pre-processing pass that VALIDATES and AUTO-FILLS element `_id` for terminal
|
|
2589
|
+
* bracket-id paths in `$set` whose value is an object (replace) or array of
|
|
2590
|
+
* objects (insert).
|
|
2591
|
+
*
|
|
2592
|
+
* Three rules enforced:
|
|
2593
|
+
* 1) `_id` in brackets must be non-empty (empty → throw via `_unquoteBracketId`)
|
|
2594
|
+
* 2) `_id` in brackets must equal element's `_id` (mismatch → throw, prevents data corruption)
|
|
2595
|
+
* 3) element `_id` may be omitted (auto-fill from bracket id)
|
|
2596
|
+
*
|
|
2597
|
+
* Mutates element objects in place (auto-fill). Throws on rule 1/2 violation.
|
|
2598
|
+
* Skips primitive values (caller knows what they're doing) and `null` (rare,
|
|
2599
|
+
* not a typical insert/replace shape).
|
|
2600
|
+
*
|
|
2601
|
+
* Called BEFORE `_extractArrayInserts`/`_extractArrayRemoves`/`_extractArrayFilters`
|
|
2602
|
+
* so downstream code can assume validated/filled element `_id`.
|
|
2603
|
+
*/
|
|
2604
|
+
_validateAndAutoFillTerminalBracketValues(update) {
|
|
2605
|
+
const $set = update.$set;
|
|
2606
|
+
if (!$set || typeof $set !== 'object')
|
|
2607
|
+
return;
|
|
2608
|
+
let hasBracket = false;
|
|
2609
|
+
for (const k of Object.keys($set)) {
|
|
2610
|
+
if (k.indexOf('[') >= 0) {
|
|
2611
|
+
hasBracket = true;
|
|
2612
|
+
break;
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
if (!hasBracket)
|
|
2616
|
+
return;
|
|
2617
|
+
for (const path of Object.keys($set)) {
|
|
2618
|
+
const tokens = Mongo._tokenizePath(path);
|
|
2619
|
+
const lastToken = tokens[tokens.length - 1];
|
|
2620
|
+
const isTerminalBracket = !!(lastToken
|
|
2621
|
+
&& lastToken.length >= 2
|
|
2622
|
+
&& lastToken.charCodeAt(0) === 91 /* [ */
|
|
2623
|
+
&& lastToken.charCodeAt(lastToken.length - 1) === 93 /* ] */);
|
|
2624
|
+
if (!isTerminalBracket)
|
|
2625
|
+
continue;
|
|
2626
|
+
const bracketId = Mongo._unquoteBracketId(lastToken, path); // throws on empty
|
|
2627
|
+
const value = $set[path];
|
|
2628
|
+
const validateElement = (el, locator) => {
|
|
2629
|
+
if (el == null || typeof el !== 'object') {
|
|
2630
|
+
throw new Error(`cry-db: bracket-id terminal at "${path}"${locator} — value must be an object with _id matching bracket id "${bracketId}" (got ${el === null ? 'null' : typeof el}).`);
|
|
2631
|
+
}
|
|
2632
|
+
if (el._id == null) {
|
|
2633
|
+
el._id = bracketId; // auto-fill rule 3
|
|
2634
|
+
}
|
|
2635
|
+
else if (String(el._id) !== bracketId) {
|
|
2636
|
+
throw new Error(`cry-db: bracket-id terminal at "${path}"${locator} — element _id "${el._id}" does not match bracket id "${bracketId}". Either omit element _id (auto-filled from bracket) or align both.`);
|
|
2637
|
+
}
|
|
2638
|
+
};
|
|
2639
|
+
if (Array.isArray(value)) {
|
|
2640
|
+
for (let i = 0; i < value.length; i++)
|
|
2641
|
+
validateElement(value[i], ` [${i}]`);
|
|
2642
|
+
}
|
|
2643
|
+
else if (value !== null && value !== undefined && typeof value === 'object') {
|
|
2644
|
+
validateElement(value, '');
|
|
2645
|
+
}
|
|
2646
|
+
// Primitives (string/number/boolean) — pass through to existing arrayFilters
|
|
2647
|
+
// path. Mongo would set the array element to the primitive, which is unusual
|
|
2648
|
+
// but not strictly invalid.
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
/**
|
|
2652
|
+
* Detect "terminal-bracket-id with array value" paths in `$set` — these are the
|
|
2653
|
+
* INSERT operations: client wants to add array elements identified by `_id`,
|
|
2654
|
+
* not update an existing element's sub-fields.
|
|
2655
|
+
*
|
|
2656
|
+
* Convention:
|
|
2657
|
+
* - Path ends with `[<id>]` (terminal bracket, no `.<subfield>` after) AND
|
|
2658
|
+
* - Value is an array AND
|
|
2659
|
+
* - Every element in the array has `_id` (required for idempotency check)
|
|
2660
|
+
*
|
|
2661
|
+
* Mutates `update.$set` (removes matched paths). Returns extracted insert specs,
|
|
2662
|
+
* or `undefined` if no inserts present.
|
|
2663
|
+
*
|
|
2664
|
+
* Each spec carries:
|
|
2665
|
+
* - `field`: parent array field path (e.g. `"postavke"`)
|
|
2666
|
+
* - `ids`: `_id`s of all elements to insert (used to dedupe existing)
|
|
2667
|
+
* - `elements`: the new elements to push
|
|
2668
|
+
*
|
|
2669
|
+
* Note: parent paths with NESTED brackets (e.g. `terapije[t1].postavke[<new>]`)
|
|
2670
|
+
* are NOT supported here — left in `$set` for the existing arrayFilters path
|
|
2671
|
+
* (which would silently no-op for non-existent ids; future enhancement).
|
|
2672
|
+
*/
|
|
2673
|
+
_extractArrayInserts(update) {
|
|
2674
|
+
const $set = update.$set;
|
|
2675
|
+
if (!$set || typeof $set !== 'object')
|
|
2676
|
+
return undefined;
|
|
2677
|
+
// Fast path: no bracket present in any key.
|
|
2678
|
+
let hasBracket = false;
|
|
2679
|
+
for (const k of Object.keys($set)) {
|
|
2680
|
+
if (k.indexOf('[') >= 0) {
|
|
2681
|
+
hasBracket = true;
|
|
2682
|
+
break;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
if (!hasBracket)
|
|
2686
|
+
return undefined;
|
|
2687
|
+
const inserts = [];
|
|
2688
|
+
const remaining = {};
|
|
2689
|
+
for (const path of Object.keys($set)) {
|
|
2690
|
+
const value = $set[path];
|
|
2691
|
+
const tokens = Mongo._tokenizePath(path);
|
|
2692
|
+
const lastToken = tokens[tokens.length - 1];
|
|
2693
|
+
const isTerminalBracket = !!(lastToken
|
|
2694
|
+
&& lastToken.length >= 2
|
|
2695
|
+
&& lastToken.charCodeAt(0) === 91 /* [ */
|
|
2696
|
+
&& lastToken.charCodeAt(lastToken.length - 1) === 93 /* ] */);
|
|
2697
|
+
if (isTerminalBracket && Array.isArray(value)) {
|
|
2698
|
+
const parentTokens = tokens.slice(0, -1);
|
|
2699
|
+
// Reject nested-bracket parent paths — combining $push semantics with
|
|
2700
|
+
// arrayFilters-targeted parent is not cleanly expressible in mongo.
|
|
2701
|
+
const parentHasBracket = parentTokens.some((t) => t.length > 0 && t.charCodeAt(0) === 91);
|
|
2702
|
+
// Every element must have `_id` (required so we can dedupe before push).
|
|
2703
|
+
const allElementsHaveId = value.every((e) => e && typeof e === 'object' && e._id != null);
|
|
2704
|
+
if (!parentHasBracket && allElementsHaveId && parentTokens.length > 0) {
|
|
2705
|
+
const fieldPath = parentTokens.join('.');
|
|
2706
|
+
const ids = value.map((e) => String(e._id));
|
|
2707
|
+
inserts.push({ field: fieldPath, ids, elements: value });
|
|
2708
|
+
continue;
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
remaining[path] = value;
|
|
2712
|
+
}
|
|
2713
|
+
if (inserts.length === 0)
|
|
2714
|
+
return undefined;
|
|
2715
|
+
update.$set = remaining;
|
|
2716
|
+
if (Object.keys(remaining).length === 0)
|
|
2717
|
+
delete update.$set;
|
|
2718
|
+
return inserts;
|
|
2719
|
+
}
|
|
2720
|
+
/**
|
|
2721
|
+
* Detect "terminal-bracket-id with no value" paths in `$unset` — these are the
|
|
2722
|
+
* REMOVE operations: client wants to drop array elements identified by `_id`.
|
|
2723
|
+
*
|
|
2724
|
+
* Convention:
|
|
2725
|
+
* - Path ends with `[<id>]` (terminal bracket, no `.<subfield>` after) AND
|
|
2726
|
+
* - Was sent as `undefined` in the original update (became `$unset` after
|
|
2727
|
+
* `_processUpdateObject` normalization).
|
|
2728
|
+
*
|
|
2729
|
+
* Mongo's `$unset` on `arr.$[fN]` would set the element to `null` in place
|
|
2730
|
+
* (not remove from array). For real removal we use `$pull` instead — or, when
|
|
2731
|
+
* combined with terminal-bracket inserts, the unified `$filter + $concatArrays`
|
|
2732
|
+
* aggregation stage in `_applyBracketProcessing`.
|
|
2733
|
+
*
|
|
2734
|
+
* Mutates `update.$unset` (removes matched paths). Returns extracted remove
|
|
2735
|
+
* specs (one per parent field, ids merged), or `undefined` if no removes present.
|
|
2736
|
+
*
|
|
2737
|
+
* Note: parent paths with NESTED brackets are not supported here (left in `$unset`
|
|
2738
|
+
* for the existing arrayFilters fallback).
|
|
2739
|
+
*/
|
|
2740
|
+
_extractArrayRemoves(update) {
|
|
2741
|
+
const $unset = update.$unset;
|
|
2742
|
+
if (!$unset || typeof $unset !== 'object')
|
|
2743
|
+
return undefined;
|
|
2744
|
+
let hasBracket = false;
|
|
2745
|
+
for (const k of Object.keys($unset)) {
|
|
2746
|
+
if (k.indexOf('[') >= 0) {
|
|
2747
|
+
hasBracket = true;
|
|
2748
|
+
break;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
if (!hasBracket)
|
|
2752
|
+
return undefined;
|
|
2753
|
+
const removesByField = new Map();
|
|
2754
|
+
const remaining = {};
|
|
2755
|
+
for (const path of Object.keys($unset)) {
|
|
2756
|
+
const tokens = Mongo._tokenizePath(path);
|
|
2757
|
+
const lastToken = tokens[tokens.length - 1];
|
|
2758
|
+
const isTerminalBracket = !!(lastToken
|
|
2759
|
+
&& lastToken.length >= 2
|
|
2760
|
+
&& lastToken.charCodeAt(0) === 91 /* [ */
|
|
2761
|
+
&& lastToken.charCodeAt(lastToken.length - 1) === 93 /* ] */);
|
|
2762
|
+
if (isTerminalBracket) {
|
|
2763
|
+
const parentTokens = tokens.slice(0, -1);
|
|
2764
|
+
const parentHasBracket = parentTokens.some((t) => t.length > 0 && t.charCodeAt(0) === 91);
|
|
2765
|
+
if (!parentHasBracket && parentTokens.length > 0) {
|
|
2766
|
+
const fieldPath = parentTokens.join('.');
|
|
2767
|
+
const id = Mongo._unquoteBracketId(lastToken, path);
|
|
2768
|
+
if (!removesByField.has(fieldPath))
|
|
2769
|
+
removesByField.set(fieldPath, []);
|
|
2770
|
+
removesByField.get(fieldPath).push(id);
|
|
2771
|
+
continue;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
remaining[path] = $unset[path];
|
|
2775
|
+
}
|
|
2776
|
+
if (removesByField.size === 0)
|
|
2777
|
+
return undefined;
|
|
2778
|
+
update.$unset = remaining;
|
|
2779
|
+
if (Object.keys(remaining).length === 0)
|
|
2780
|
+
delete update.$unset;
|
|
2781
|
+
return Array.from(removesByField.entries()).map(([field, ids]) => ({ field, ids }));
|
|
2782
|
+
}
|
|
2783
|
+
/**
|
|
2784
|
+
* Combined bracket-path processing: extracts inserts (`arr[id]: [els]`),
|
|
2785
|
+
* removes (`arr[id]: undefined` → `$unset`), and arrayFilters (`arr[id].field = X`).
|
|
2786
|
+
* Decides whether mongo update should be sent as a regular doc or as an aggregation
|
|
2787
|
+
* pipeline.
|
|
2788
|
+
*
|
|
2789
|
+
* Returns `{ update, arrayFilters? }`:
|
|
2790
|
+
* - `update` is the original update doc OR an aggregation pipeline.
|
|
2791
|
+
* - `arrayFilters` is set when sub-field bracket paths needed translation; only
|
|
2792
|
+
* valid when `update` is the doc form (mongo doesn't support arrayFilters on pipelines).
|
|
2793
|
+
*
|
|
2794
|
+
* Strategy matrix:
|
|
2795
|
+
* - no inserts, no removes → existing arrayFilters path (or pure update doc)
|
|
2796
|
+
* - removes only → adds `$pull` to update doc; arrayFilters allowed
|
|
2797
|
+
* - inserts (± removes) → pipeline form with unified `$filter + $concatArrays`
|
|
2798
|
+
* per parent field; arrayFilters NOT allowed (throws)
|
|
2799
|
+
*
|
|
2800
|
+
* The unified pipeline stage atomically filters out elements matching any of
|
|
2801
|
+
* the (removeIds ∪ insertIds), then appends new elements. This makes inserts
|
|
2802
|
+
* idempotent (re-inserting same _id no-ops) and combines remove+insert without
|
|
2803
|
+
* race window.
|
|
2804
|
+
*/
|
|
2805
|
+
_applyBracketProcessing(update) {
|
|
2806
|
+
// Pre-pass: validate bracket-id ↔ element-_id consistency, auto-fill missing _id.
|
|
2807
|
+
// Throws on empty bracket id or _id mismatch. Mutates element objects in $set.
|
|
2808
|
+
this._validateAndAutoFillTerminalBracketValues(update);
|
|
2809
|
+
const inserts = this._extractArrayInserts(update);
|
|
2810
|
+
const removes = this._extractArrayRemoves(update);
|
|
2811
|
+
const arrayFilters = this._extractArrayFilters(update);
|
|
2812
|
+
if (!inserts && !removes) {
|
|
2813
|
+
return arrayFilters ? { update, arrayFilters } : { update };
|
|
2814
|
+
}
|
|
2815
|
+
// Removes-only path: `$pull` on update doc. Coexists fine with `$set` + arrayFilters.
|
|
2816
|
+
if (removes && !inserts) {
|
|
2817
|
+
const pullOp = (update.$pull || {});
|
|
2818
|
+
for (const rm of removes) {
|
|
2819
|
+
const existing = pullOp[rm.field];
|
|
2820
|
+
if (existing && existing._id && Array.isArray(existing._id.$in)) {
|
|
2821
|
+
const merged = new Set([...existing._id.$in, ...rm.ids]);
|
|
2822
|
+
pullOp[rm.field] = { _id: { $in: Array.from(merged) } };
|
|
2823
|
+
}
|
|
2824
|
+
else {
|
|
2825
|
+
pullOp[rm.field] = { _id: { $in: [...rm.ids] } };
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
update.$pull = pullOp;
|
|
2829
|
+
return arrayFilters ? { update, arrayFilters } : { update };
|
|
2830
|
+
}
|
|
2831
|
+
// Inserts present (± removes) → pipeline form.
|
|
2832
|
+
if (arrayFilters) {
|
|
2833
|
+
throw new Error('cry-db: cannot combine bracket-by-_id sub-field updates (e.g. `arr[id].field`) with terminal-bracket array inserts (e.g. `arr[id]: [<elements>]`) in the same update. Pipeline form does not support arrayFilters. Split into two separate updateOne/save calls.');
|
|
2834
|
+
}
|
|
2835
|
+
// Group inserts AND removes per parent field — they share one filter+concat stage.
|
|
2836
|
+
// Insert elements implicitly remove existing same-_id entries first (idempotency).
|
|
2837
|
+
const fieldOps = new Map();
|
|
2838
|
+
if (inserts) {
|
|
2839
|
+
for (const ins of inserts) {
|
|
2840
|
+
if (!fieldOps.has(ins.field))
|
|
2841
|
+
fieldOps.set(ins.field, { removeIds: [], insertElements: [] });
|
|
2842
|
+
const ops = fieldOps.get(ins.field);
|
|
2843
|
+
ops.removeIds.push(...ins.ids);
|
|
2844
|
+
ops.insertElements.push(...ins.elements);
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
if (removes) {
|
|
2848
|
+
for (const rm of removes) {
|
|
2849
|
+
if (!fieldOps.has(rm.field))
|
|
2850
|
+
fieldOps.set(rm.field, { removeIds: [], insertElements: [] });
|
|
2851
|
+
const ops = fieldOps.get(rm.field);
|
|
2852
|
+
ops.removeIds.push(...rm.ids);
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
const pipeline = [];
|
|
2856
|
+
if (update.$set && Object.keys(update.$set).length > 0) {
|
|
2857
|
+
pipeline.push({ $set: update.$set });
|
|
2858
|
+
}
|
|
2859
|
+
if (update.$unset && Object.keys(update.$unset).length > 0) {
|
|
2860
|
+
pipeline.push({ $unset: Object.keys(update.$unset) });
|
|
2861
|
+
}
|
|
2862
|
+
for (const [field, ops] of fieldOps.entries()) {
|
|
2863
|
+
const dedupedRemoveIds = Array.from(new Set(ops.removeIds));
|
|
2864
|
+
pipeline.push({
|
|
2865
|
+
$set: {
|
|
2866
|
+
[field]: {
|
|
2867
|
+
$concatArrays: [
|
|
2868
|
+
{
|
|
2869
|
+
$filter: {
|
|
2870
|
+
input: { $ifNull: [`$${field}`, []] },
|
|
2871
|
+
as: 'el',
|
|
2872
|
+
cond: { $not: { $in: ['$$el._id', dedupedRemoveIds] } },
|
|
2873
|
+
},
|
|
2874
|
+
},
|
|
2875
|
+
ops.insertElements,
|
|
2876
|
+
],
|
|
2877
|
+
},
|
|
2878
|
+
},
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
return { update: pipeline };
|
|
2882
|
+
}
|
|
2388
2883
|
async _processHashedKeys(obj) {
|
|
2389
2884
|
const hashedKeys = Object.keys(obj).filter(startsWithHashedPrefix);
|
|
2390
2885
|
await Promise.all(hashedKeys.map(async (key) => {
|