cry-db 2.4.32 → 2.4.34
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 +191 -0
- package/dist/mongo.d.mts.map +1 -1
- package/dist/mongo.mjs +801 -40
- package/dist/mongo.mjs.map +1 -1
- package/package.json +37 -36
package/dist/mongo.mjs
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// AI modified: 2026-05-10 (drop auto-fill `_id` iz bracket id — če element nima `_id`, se zdaj vrže napaka. Avto-zapolnitev je skrivala odjemalske napake; klient mora `_id` vedno nastaviti eksplicitno.)
|
|
2
|
+
// AI modified: 2026-05-09 (kombiniran update: `arr[id].field` sub-field updates + `arr[id]: [els]` terminal-bracket inserts v isti operaciji — sub-field paths se zdaj prevedejo v pipeline `$map` + `$mergeObjects`, namesto da bi se metala napaka. Tudi `$inc`/`$currentDate` (npr. `_rev`/`_ts` pri useRevisions) se zdaj ohranjata v pipeline obliki.)
|
|
3
|
+
// 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)
|
|
4
|
+
// 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]`)
|
|
1
5
|
// AI modified: 2026-04-25 (GetNewerSpec.specId — disambiguate duplicate-collection specs in findNewerMany / findNewerManyStream)
|
|
2
6
|
// AI modified: 2026-04-21
|
|
3
7
|
import * as bcrypt from 'bcrypt';
|
|
@@ -35,6 +39,7 @@ const parseFieldList = (fields) => (typeof fields === 'string' ? fields.split(',
|
|
|
35
39
|
// String helper functions (faster than RegExp)
|
|
36
40
|
const startsWithDollar = (s) => s.length > 0 && s[0] === '$';
|
|
37
41
|
const startsWithDoubleUnderscore = (s) => s.length > 1 && s[0] === '_' && s[1] === '_';
|
|
42
|
+
const isServerReserved = (s) => s === "_rev" || s === "_ts";
|
|
38
43
|
const startsWithHashedPrefix = (s) => s.length > 10 && s.startsWith(HASHED_PREFIX);
|
|
39
44
|
const isHex24 = (s) => {
|
|
40
45
|
if (s.length !== 24)
|
|
@@ -632,21 +637,29 @@ export class Mongo extends Db {
|
|
|
632
637
|
if (this._hasHashedKeys(update))
|
|
633
638
|
await this._processHashedKeys(update);
|
|
634
639
|
update = this._processUpdateObject(update);
|
|
640
|
+
const processed = this._applyBracketProcessing(update);
|
|
641
|
+
if (processed.arrayFilters)
|
|
642
|
+
opts.arrayFilters = processed.arrayFilters;
|
|
643
|
+
const isPipeline = Array.isArray(processed.update);
|
|
644
|
+
// For pipeline form, sequence-keys auto-injection cannot recurse into array of stages.
|
|
645
|
+
// Skip the `update.$set` normalization and seqKeys path entirely when pipeline.
|
|
646
|
+
let seqKeys = isPipeline ? undefined : this._findSequenceKeys(update.$set);
|
|
635
647
|
fjLog.debug('updateOne called', collection, query, update);
|
|
636
|
-
let seqKeys = this._findSequenceKeys(update.$set);
|
|
637
648
|
let obj = await this.executeTransactionally(collection, async (conn, client) => {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
649
|
+
if (!isPipeline) {
|
|
650
|
+
update.$set = update.$set || {};
|
|
651
|
+
if (seqKeys)
|
|
652
|
+
await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
|
|
653
|
+
if (update.$set === undefined || Object.keys(update.$set).length === 0)
|
|
654
|
+
delete update.$set;
|
|
655
|
+
}
|
|
656
|
+
let res = await conn.findOneAndUpdate(query, processed.update, opts);
|
|
644
657
|
if (!res)
|
|
645
658
|
return null;
|
|
646
|
-
let resObj = this._removeUnchanged(res, update, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
659
|
+
let resObj = isPipeline ? res : this._removeUnchanged(res, update, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
647
660
|
await this._publishAndAudit('update', dbName, collection, resObj);
|
|
648
661
|
return resObj;
|
|
649
|
-
}, !!seqKeys, { operation: "updateOne", collection, query, update, options });
|
|
662
|
+
}, !!seqKeys, { operation: "updateOne", collection, query, update: processed.update, options });
|
|
650
663
|
fjLog.debug('updateOne returns', obj);
|
|
651
664
|
return this._processReturnedObject(await obj);
|
|
652
665
|
}
|
|
@@ -664,21 +677,27 @@ export class Mongo extends Db {
|
|
|
664
677
|
if (this._hasHashedKeys(update))
|
|
665
678
|
await this._processHashedKeys(update);
|
|
666
679
|
update = this._processUpdateObject(update);
|
|
680
|
+
const processed = this._applyBracketProcessing(update);
|
|
681
|
+
if (processed.arrayFilters)
|
|
682
|
+
opts.arrayFilters = processed.arrayFilters;
|
|
683
|
+
const isPipeline = Array.isArray(processed.update);
|
|
667
684
|
fjLog.debug('save called', collection, id, update);
|
|
668
|
-
let seqKeys = this._findSequenceKeys(update.$set);
|
|
685
|
+
let seqKeys = isPipeline ? undefined : this._findSequenceKeys(update.$set);
|
|
669
686
|
let obj = await this.executeTransactionally(collection, async (conn, client) => {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
687
|
+
if (!isPipeline) {
|
|
688
|
+
update.$set = update.$set || {};
|
|
689
|
+
if (seqKeys)
|
|
690
|
+
await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
|
|
691
|
+
if (update.$set === undefined || Object.keys(update.$set).length === 0)
|
|
692
|
+
delete update.$set;
|
|
693
|
+
}
|
|
694
|
+
let res = await conn.findOneAndUpdate({ _id }, processed.update, opts);
|
|
676
695
|
if (!res)
|
|
677
696
|
return null;
|
|
678
|
-
let resObj = this._removeUnchanged(res, update, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
697
|
+
let resObj = isPipeline ? res : this._removeUnchanged(res, update, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
679
698
|
await this._publishAndAudit('update', dbName, collection, resObj);
|
|
680
699
|
return resObj;
|
|
681
|
-
}, !!seqKeys, { operation: "save", collection, _id, update, options });
|
|
700
|
+
}, !!seqKeys, { operation: "save", collection, _id, update: processed.update, options });
|
|
682
701
|
fjLog.debug('save returns', obj);
|
|
683
702
|
return this._processReturnedObject(await obj);
|
|
684
703
|
}
|
|
@@ -700,23 +719,29 @@ export class Mongo extends Db {
|
|
|
700
719
|
if (this._hasHashedKeys(update))
|
|
701
720
|
await this._processHashedKeys(update);
|
|
702
721
|
update = this._processUpdateObject(update);
|
|
722
|
+
const processed = this._applyBracketProcessing(update);
|
|
723
|
+
if (processed.arrayFilters)
|
|
724
|
+
opts.arrayFilters = processed.arrayFilters;
|
|
725
|
+
const isPipeline = Array.isArray(processed.update);
|
|
703
726
|
fjLog.debug('update called', collection, query, update);
|
|
704
|
-
let seqKeys = this._findSequenceKeys(update.$set);
|
|
727
|
+
let seqKeys = isPipeline ? undefined : this._findSequenceKeys(update.$set);
|
|
705
728
|
let obj = await this.executeTransactionally(collection, async (conn, client) => {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
729
|
+
if (!isPipeline) {
|
|
730
|
+
update.$set = update.$set || {};
|
|
731
|
+
if (seqKeys)
|
|
732
|
+
await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
|
|
733
|
+
if (update.$set === undefined || Object.keys(update.$set).length === 0)
|
|
734
|
+
delete update.$set;
|
|
735
|
+
}
|
|
711
736
|
fjLog.debug('update called', collection, query, update);
|
|
712
|
-
let res = await conn.updateMany(query, update, opts);
|
|
737
|
+
let res = await conn.updateMany(query, processed.update, opts);
|
|
713
738
|
let resObj = {
|
|
714
739
|
n: res.modifiedCount,
|
|
715
740
|
ok: !!res.acknowledged
|
|
716
741
|
};
|
|
717
742
|
await this._publishAndAudit('updateMany', dbName, collection, resObj);
|
|
718
743
|
return resObj;
|
|
719
|
-
}, !!seqKeys, { operation: "update", collection, query, update });
|
|
744
|
+
}, !!seqKeys, { operation: "update", collection, query, update: processed.update });
|
|
720
745
|
fjLog.debug('update returns', obj);
|
|
721
746
|
return await obj;
|
|
722
747
|
}
|
|
@@ -739,17 +764,23 @@ export class Mongo extends Db {
|
|
|
739
764
|
if (this._hasHashedKeys(update))
|
|
740
765
|
await this._processHashedKeys(update);
|
|
741
766
|
update = this._processUpdateObject(update);
|
|
742
|
-
|
|
767
|
+
const processed = this._applyBracketProcessing(update);
|
|
768
|
+
if (processed.arrayFilters)
|
|
769
|
+
opts.arrayFilters = processed.arrayFilters;
|
|
770
|
+
const isPipeline = Array.isArray(processed.update);
|
|
771
|
+
let seqKeys = isPipeline ? undefined : this._findSequenceKeys(update.$set);
|
|
743
772
|
fjLog.debug('upsert processed', collection, query, update);
|
|
744
773
|
if (Object.keys(query).length === 0)
|
|
745
774
|
query._id = Mongo.newid();
|
|
746
775
|
let ret = await this.executeTransactionally(collection, async (conn, client) => {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
776
|
+
if (!isPipeline) {
|
|
777
|
+
update.$set = update.$set || {};
|
|
778
|
+
if (seqKeys)
|
|
779
|
+
await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
|
|
780
|
+
if (update.$set === undefined || Object.keys(update.$set).length === 0)
|
|
781
|
+
delete update.$set;
|
|
782
|
+
}
|
|
783
|
+
let ret = await conn.findOneAndUpdate(query, processed.update, opts);
|
|
753
784
|
if (ret) {
|
|
754
785
|
// Detect if this was an insert or update by checking _rev
|
|
755
786
|
const isInsert = this.revisions && ret._rev === 1;
|
|
@@ -837,23 +868,28 @@ export class Mongo extends Db {
|
|
|
837
868
|
update = this.replaceIds(update);
|
|
838
869
|
if (this._hasHashedKeys(update))
|
|
839
870
|
await this._processHashedKeys(update);
|
|
840
|
-
const
|
|
871
|
+
const processedBase = this._processUpdateObject({ ...update });
|
|
872
|
+
const processed = this._applyBracketProcessing(processedBase);
|
|
873
|
+
const isPipeline = Array.isArray(processed.update);
|
|
841
874
|
const opts = {
|
|
842
875
|
upsert: true,
|
|
843
876
|
returnDocument: "after",
|
|
844
877
|
includeResultMetadata: true,
|
|
878
|
+
...(processed.arrayFilters ? { arrayFilters: processed.arrayFilters } : {}),
|
|
845
879
|
...this._sessionOpt()
|
|
846
880
|
};
|
|
847
881
|
const res = await conn
|
|
848
882
|
.db(dbName)
|
|
849
883
|
.collection(collection)
|
|
850
|
-
.findOneAndUpdate({ _id: objectId },
|
|
884
|
+
.findOneAndUpdate({ _id: objectId }, processed.update, opts);
|
|
851
885
|
if (res === null || res === void 0 ? void 0 : res.value) {
|
|
852
886
|
// Determine if this was an insert or update based on lastErrorObject
|
|
853
887
|
const wasInsert = !((_a = res.lastErrorObject) === null || _a === void 0 ? void 0 : _a.updatedExisting);
|
|
854
888
|
const operation = wasInsert ? 'insert' : 'update';
|
|
855
|
-
// For inserts
|
|
856
|
-
|
|
889
|
+
// For inserts (or pipeline updates) return full object; for regular
|
|
890
|
+
// updates, strip fields that didn't change. _removeUnchanged expects
|
|
891
|
+
// doc-form update — skip for pipeline.
|
|
892
|
+
const retObj = (wasInsert || isPipeline) ? res.value : this._removeUnchanged(res.value, processedBase, false);
|
|
857
893
|
this._processReturnedObject(retObj);
|
|
858
894
|
return { success: true, _id, data: retObj, operation, wasInsert };
|
|
859
895
|
}
|
|
@@ -1016,8 +1052,12 @@ export class Mongo extends Db {
|
|
|
1016
1052
|
};
|
|
1017
1053
|
if (this._hasHashedKeys(update))
|
|
1018
1054
|
await this._processHashedKeys(update);
|
|
1019
|
-
const
|
|
1020
|
-
const
|
|
1055
|
+
const processedBase = this._processUpdateObject({ ...update });
|
|
1056
|
+
const processed = this._applyBracketProcessing(processedBase);
|
|
1057
|
+
if (processed.arrayFilters)
|
|
1058
|
+
options.arrayFilters = processed.arrayFilters;
|
|
1059
|
+
const isPipeline = Array.isArray(processed.update);
|
|
1060
|
+
const result = await conn.findOneAndUpdate(query, processed.update, options);
|
|
1021
1061
|
if (!((_a = result === null || result === void 0 ? void 0 : result.value) === null || _a === void 0 ? void 0 : _a._id))
|
|
1022
1062
|
return null;
|
|
1023
1063
|
const ret = result.value;
|
|
@@ -1032,7 +1072,7 @@ export class Mongo extends Db {
|
|
|
1032
1072
|
else {
|
|
1033
1073
|
oper = "insert";
|
|
1034
1074
|
}
|
|
1035
|
-
const retObj = oper === "insert" ? ret : this._removeUnchanged(ret,
|
|
1075
|
+
const retObj = oper === "insert" ? ret : (isPipeline ? ret : this._removeUnchanged(ret, processedBase, !!(opts === null || opts === void 0 ? void 0 : opts.returnFullObject)));
|
|
1036
1076
|
this._processReturnedObject(retObj);
|
|
1037
1077
|
return { operation: oper, data: retObj };
|
|
1038
1078
|
});
|
|
@@ -2368,6 +2408,10 @@ export class Mongo extends Db {
|
|
|
2368
2408
|
delete update[key];
|
|
2369
2409
|
continue;
|
|
2370
2410
|
}
|
|
2411
|
+
if (isServerReserved(keyStr) && !keyStr.startsWith(HASHED_PREFIX)) {
|
|
2412
|
+
delete update[key];
|
|
2413
|
+
continue;
|
|
2414
|
+
}
|
|
2371
2415
|
if (key === '' || key === null || key === undefined) {
|
|
2372
2416
|
delete update[key];
|
|
2373
2417
|
continue;
|
|
@@ -2401,6 +2445,723 @@ export class Mongo extends Db {
|
|
|
2401
2445
|
}
|
|
2402
2446
|
return update;
|
|
2403
2447
|
}
|
|
2448
|
+
/**
|
|
2449
|
+
* Translate `arr[<_id>].field` bracket-by-_id segments inside `$set`
|
|
2450
|
+
* and `$unset` paths to mongo positional `$[<filterId>]` placeholders,
|
|
2451
|
+
* generating matching `arrayFilters` entries.
|
|
2452
|
+
*
|
|
2453
|
+
* Lets clients identify array elements by their `_id` instead of by
|
|
2454
|
+
* position. Mongo evaluates the filter against the live document at
|
|
2455
|
+
* write time, so the operation is atomic — no race window exists where
|
|
2456
|
+
* a concurrent reorder/insert/delete by another writer could shift the
|
|
2457
|
+
* targeted index between client read and server write.
|
|
2458
|
+
*
|
|
2459
|
+
* Path token forms (matches `cry-synced-db-client` `tokenizePath`):
|
|
2460
|
+
* - plain `field` → kept as-is
|
|
2461
|
+
* - numeric `<n>` → kept as-is (legacy positional index path)
|
|
2462
|
+
* - bracket `[<_id>]` → translated to `$[fN]`, filter `{fN._id: <_id>}` emitted
|
|
2463
|
+
*
|
|
2464
|
+
* Filters are deduplicated by (parentPath, _id) so multiple paths into
|
|
2465
|
+
* the same array element share one filter (mongo requires every filter
|
|
2466
|
+
* identifier to be referenced).
|
|
2467
|
+
*
|
|
2468
|
+
* Mutates `update.$set` / `update.$unset` in place. Returns the
|
|
2469
|
+
* arrayFilters array (or `undefined` when no bracket paths present).
|
|
2470
|
+
*/
|
|
2471
|
+
_extractArrayFilters(update) {
|
|
2472
|
+
const filters = [];
|
|
2473
|
+
const memo = new Map();
|
|
2474
|
+
let counter = 0;
|
|
2475
|
+
const translatePath = (path) => {
|
|
2476
|
+
const tokens = Mongo._tokenizePath(path);
|
|
2477
|
+
const out = [];
|
|
2478
|
+
let parentKey = '';
|
|
2479
|
+
for (const t of tokens) {
|
|
2480
|
+
if (t.length >= 2 && t.charCodeAt(0) === 91 /* [ */ && t.charCodeAt(t.length - 1) === 93 /* ] */) {
|
|
2481
|
+
const idStr = Mongo._unquoteBracketId(t, path);
|
|
2482
|
+
const memoKey = `${parentKey}|${idStr}`;
|
|
2483
|
+
let filterName = memo.get(memoKey);
|
|
2484
|
+
if (filterName === undefined) {
|
|
2485
|
+
filterName = `f${counter++}`;
|
|
2486
|
+
memo.set(memoKey, filterName);
|
|
2487
|
+
filters.push({ [`${filterName}._id`]: idStr });
|
|
2488
|
+
}
|
|
2489
|
+
out.push(`$[${filterName}]`);
|
|
2490
|
+
parentKey = parentKey ? `${parentKey}.$[${filterName}]` : `$[${filterName}]`;
|
|
2491
|
+
}
|
|
2492
|
+
else {
|
|
2493
|
+
out.push(t);
|
|
2494
|
+
parentKey = parentKey ? `${parentKey}.${t}` : t;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
return out.join('.');
|
|
2498
|
+
};
|
|
2499
|
+
const translateOp = (opName) => {
|
|
2500
|
+
const op = update[opName];
|
|
2501
|
+
if (!op || typeof op !== 'object')
|
|
2502
|
+
return;
|
|
2503
|
+
// Fast path: skip rebuild if no bracket present.
|
|
2504
|
+
let hasBracket = false;
|
|
2505
|
+
for (const k of Object.keys(op)) {
|
|
2506
|
+
if (k.indexOf('[') >= 0) {
|
|
2507
|
+
hasBracket = true;
|
|
2508
|
+
break;
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
if (!hasBracket)
|
|
2512
|
+
return;
|
|
2513
|
+
const translated = {};
|
|
2514
|
+
for (const k of Object.keys(op)) {
|
|
2515
|
+
translated[translatePath(k)] = op[k];
|
|
2516
|
+
}
|
|
2517
|
+
update[opName] = translated;
|
|
2518
|
+
};
|
|
2519
|
+
translateOp('$set');
|
|
2520
|
+
translateOp('$unset');
|
|
2521
|
+
return filters.length > 0 ? filters : undefined;
|
|
2522
|
+
}
|
|
2523
|
+
/**
|
|
2524
|
+
* Tokenize a dot-notation path that may contain `[<_id>]` bracket
|
|
2525
|
+
* segments. Mirrors the client-side `cry-synced-db-client/utils/computeDiff#tokenizePath`.
|
|
2526
|
+
*
|
|
2527
|
+
* "postavke[A].kolicina" → ["postavke", "[A]", "kolicina"]
|
|
2528
|
+
* "koraki.0.diag" → ["koraki", "0", "diag"]
|
|
2529
|
+
* "stranka.gsm" → ["stranka", "gsm"]
|
|
2530
|
+
* "arr[A].sub[B].field" → ["arr", "[A]", "sub", "[B]", "field"]
|
|
2531
|
+
*/
|
|
2532
|
+
static _tokenizePath(path) {
|
|
2533
|
+
const out = [];
|
|
2534
|
+
let buf = '';
|
|
2535
|
+
for (let i = 0; i < path.length; i++) {
|
|
2536
|
+
const ch = path[i];
|
|
2537
|
+
if (ch === '.') {
|
|
2538
|
+
if (buf) {
|
|
2539
|
+
out.push(buf);
|
|
2540
|
+
buf = '';
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
else if (ch === '[') {
|
|
2544
|
+
if (buf) {
|
|
2545
|
+
out.push(buf);
|
|
2546
|
+
buf = '';
|
|
2547
|
+
}
|
|
2548
|
+
const close = path.indexOf(']', i);
|
|
2549
|
+
if (close < 0) {
|
|
2550
|
+
buf += ch;
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
out.push(path.substring(i, close + 1));
|
|
2554
|
+
i = close;
|
|
2555
|
+
}
|
|
2556
|
+
else {
|
|
2557
|
+
buf += ch;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
if (buf)
|
|
2561
|
+
out.push(buf);
|
|
2562
|
+
return out;
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* Strip `[ ]` wrapper and surrounding `'…'` / `"…"` quotes from a bracket-id token.
|
|
2566
|
+
* Convention: `[p2]`, `['p2']`, `["p2"]` all denote element with `_id === "p2"`.
|
|
2567
|
+
* Quoted form is preferred when id contains characters that could conflict with
|
|
2568
|
+
* dot-notation (rare, but defensive).
|
|
2569
|
+
*
|
|
2570
|
+
* Throws on empty id (`[]`, `['']`, `[""]`) — empty bracket has no semantic meaning
|
|
2571
|
+
* (no element to target) and almost certainly indicates a caller bug. Optional
|
|
2572
|
+
* `path` argument is included in the error message for easier debugging.
|
|
2573
|
+
*/
|
|
2574
|
+
static _unquoteBracketId(bracketToken, path) {
|
|
2575
|
+
let id = bracketToken.slice(1, -1);
|
|
2576
|
+
const len = id.length;
|
|
2577
|
+
if (len >= 2) {
|
|
2578
|
+
const first = id.charCodeAt(0);
|
|
2579
|
+
const last = id.charCodeAt(len - 1);
|
|
2580
|
+
if ((first === 39 /* ' */ && last === 39) || (first === 34 /* " */ && last === 34)) {
|
|
2581
|
+
id = id.slice(1, -1);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
if (id.length === 0) {
|
|
2585
|
+
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>']").`);
|
|
2586
|
+
}
|
|
2587
|
+
return id;
|
|
2588
|
+
}
|
|
2589
|
+
/**
|
|
2590
|
+
* Pre-processing pass that VALIDATES element `_id` for terminal bracket-id
|
|
2591
|
+
* paths in `$set` whose value is an object (replace) or array of objects
|
|
2592
|
+
* (insert).
|
|
2593
|
+
*
|
|
2594
|
+
* Two rules enforced:
|
|
2595
|
+
* 1) `_id` in brackets must be non-empty (empty → throw via `_unquoteBracketId`)
|
|
2596
|
+
* 2) every element MUST carry an `_id` that equals the bracket id
|
|
2597
|
+
* (missing or mismatched → throw, prevents data corruption / silent renaming)
|
|
2598
|
+
*
|
|
2599
|
+
* Throws on violation. Skips primitive values (caller knows what they're
|
|
2600
|
+
* doing) and `null` (rare, not a typical insert/replace shape).
|
|
2601
|
+
*
|
|
2602
|
+
* Called BEFORE `_extractArrayInserts`/`_extractArrayRemoves`/`_extractArrayFilters`
|
|
2603
|
+
* so downstream code can assume every element has an `_id`.
|
|
2604
|
+
*
|
|
2605
|
+
* Note: auto-fill of missing `_id` from the bracket id was previously supported
|
|
2606
|
+
* but was removed because it hid client bugs (e.g. constructing an insert with
|
|
2607
|
+
* the wrong field name and silently writing an element identified by the bracket).
|
|
2608
|
+
* Always set `_id` explicitly on the client.
|
|
2609
|
+
*/
|
|
2610
|
+
_validateAndAutoFillTerminalBracketValues(update) {
|
|
2611
|
+
const $set = update.$set;
|
|
2612
|
+
if (!$set || typeof $set !== 'object')
|
|
2613
|
+
return;
|
|
2614
|
+
let hasBracket = false;
|
|
2615
|
+
for (const k of Object.keys($set)) {
|
|
2616
|
+
if (k.indexOf('[') >= 0) {
|
|
2617
|
+
hasBracket = true;
|
|
2618
|
+
break;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
if (!hasBracket)
|
|
2622
|
+
return;
|
|
2623
|
+
for (const path of Object.keys($set)) {
|
|
2624
|
+
const tokens = Mongo._tokenizePath(path);
|
|
2625
|
+
const lastToken = tokens[tokens.length - 1];
|
|
2626
|
+
const isTerminalBracket = !!(lastToken
|
|
2627
|
+
&& lastToken.length >= 2
|
|
2628
|
+
&& lastToken.charCodeAt(0) === 91 /* [ */
|
|
2629
|
+
&& lastToken.charCodeAt(lastToken.length - 1) === 93 /* ] */);
|
|
2630
|
+
if (!isTerminalBracket)
|
|
2631
|
+
continue;
|
|
2632
|
+
const bracketId = Mongo._unquoteBracketId(lastToken, path); // throws on empty
|
|
2633
|
+
const value = $set[path];
|
|
2634
|
+
const validateElement = (el, locator) => {
|
|
2635
|
+
if (el == null || typeof el !== 'object') {
|
|
2636
|
+
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}).`);
|
|
2637
|
+
}
|
|
2638
|
+
if (el._id == null) {
|
|
2639
|
+
throw new Error(`cry-db: bracket-id terminal at "${path}"${locator} — element is missing _id. Set it explicitly to "${bracketId}" (auto-fill is no longer supported).`);
|
|
2640
|
+
}
|
|
2641
|
+
if (String(el._id) !== bracketId) {
|
|
2642
|
+
throw new Error(`cry-db: bracket-id terminal at "${path}"${locator} — element _id "${el._id}" does not match bracket id "${bracketId}".`);
|
|
2643
|
+
}
|
|
2644
|
+
};
|
|
2645
|
+
if (Array.isArray(value)) {
|
|
2646
|
+
for (let i = 0; i < value.length; i++)
|
|
2647
|
+
validateElement(value[i], ` [${i}]`);
|
|
2648
|
+
}
|
|
2649
|
+
else if (value !== null && value !== undefined && typeof value === 'object') {
|
|
2650
|
+
validateElement(value, '');
|
|
2651
|
+
}
|
|
2652
|
+
// Primitives (string/number/boolean) — pass through to existing arrayFilters
|
|
2653
|
+
// path. Mongo would set the array element to the primitive, which is unusual
|
|
2654
|
+
// but not strictly invalid.
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* Detect "terminal-bracket-id with array value" paths in `$set` — these are the
|
|
2659
|
+
* INSERT operations: client wants to add array elements identified by `_id`,
|
|
2660
|
+
* not update an existing element's sub-fields.
|
|
2661
|
+
*
|
|
2662
|
+
* Convention:
|
|
2663
|
+
* - Path ends with `[<id>]` (terminal bracket, no `.<subfield>` after) AND
|
|
2664
|
+
* - Value is an array AND
|
|
2665
|
+
* - Every element in the array has `_id` (required for idempotency check)
|
|
2666
|
+
*
|
|
2667
|
+
* Mutates `update.$set` (removes matched paths). Returns extracted insert specs,
|
|
2668
|
+
* or `undefined` if no inserts present.
|
|
2669
|
+
*
|
|
2670
|
+
* Each spec carries:
|
|
2671
|
+
* - `field`: parent array field path (e.g. `"postavke"`)
|
|
2672
|
+
* - `ids`: `_id`s of all elements to insert (used to dedupe existing)
|
|
2673
|
+
* - `elements`: the new elements to push
|
|
2674
|
+
*
|
|
2675
|
+
* Note: parent paths with NESTED brackets (e.g. `terapije[t1].postavke[<new>]`)
|
|
2676
|
+
* are NOT supported here — left in `$set` for the existing arrayFilters path
|
|
2677
|
+
* (which would silently no-op for non-existent ids; future enhancement).
|
|
2678
|
+
*/
|
|
2679
|
+
_extractArrayInserts(update) {
|
|
2680
|
+
const $set = update.$set;
|
|
2681
|
+
if (!$set || typeof $set !== 'object')
|
|
2682
|
+
return undefined;
|
|
2683
|
+
// Fast path: no bracket present in any key.
|
|
2684
|
+
let hasBracket = false;
|
|
2685
|
+
for (const k of Object.keys($set)) {
|
|
2686
|
+
if (k.indexOf('[') >= 0) {
|
|
2687
|
+
hasBracket = true;
|
|
2688
|
+
break;
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
if (!hasBracket)
|
|
2692
|
+
return undefined;
|
|
2693
|
+
const inserts = [];
|
|
2694
|
+
const remaining = {};
|
|
2695
|
+
for (const path of Object.keys($set)) {
|
|
2696
|
+
const value = $set[path];
|
|
2697
|
+
const tokens = Mongo._tokenizePath(path);
|
|
2698
|
+
const lastToken = tokens[tokens.length - 1];
|
|
2699
|
+
const isTerminalBracket = !!(lastToken
|
|
2700
|
+
&& lastToken.length >= 2
|
|
2701
|
+
&& lastToken.charCodeAt(0) === 91 /* [ */
|
|
2702
|
+
&& lastToken.charCodeAt(lastToken.length - 1) === 93 /* ] */);
|
|
2703
|
+
if (isTerminalBracket && Array.isArray(value)) {
|
|
2704
|
+
const parentTokens = tokens.slice(0, -1);
|
|
2705
|
+
// Reject nested-bracket parent paths — combining $push semantics with
|
|
2706
|
+
// arrayFilters-targeted parent is not cleanly expressible in mongo.
|
|
2707
|
+
const parentHasBracket = parentTokens.some((t) => t.length > 0 && t.charCodeAt(0) === 91);
|
|
2708
|
+
// Every element must have `_id` (required so we can dedupe before push).
|
|
2709
|
+
const allElementsHaveId = value.every((e) => e && typeof e === 'object' && e._id != null);
|
|
2710
|
+
if (!parentHasBracket && allElementsHaveId && parentTokens.length > 0) {
|
|
2711
|
+
const fieldPath = parentTokens.join('.');
|
|
2712
|
+
const ids = value.map((e) => String(e._id));
|
|
2713
|
+
inserts.push({ field: fieldPath, ids, elements: value });
|
|
2714
|
+
continue;
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
remaining[path] = value;
|
|
2718
|
+
}
|
|
2719
|
+
if (inserts.length === 0)
|
|
2720
|
+
return undefined;
|
|
2721
|
+
update.$set = remaining;
|
|
2722
|
+
if (Object.keys(remaining).length === 0)
|
|
2723
|
+
delete update.$set;
|
|
2724
|
+
return inserts;
|
|
2725
|
+
}
|
|
2726
|
+
/**
|
|
2727
|
+
* Detect "terminal-bracket-id with no value" paths in `$unset` — these are the
|
|
2728
|
+
* REMOVE operations: client wants to drop array elements identified by `_id`.
|
|
2729
|
+
*
|
|
2730
|
+
* Convention:
|
|
2731
|
+
* - Path ends with `[<id>]` (terminal bracket, no `.<subfield>` after) AND
|
|
2732
|
+
* - Was sent as `undefined` in the original update (became `$unset` after
|
|
2733
|
+
* `_processUpdateObject` normalization).
|
|
2734
|
+
*
|
|
2735
|
+
* Mongo's `$unset` on `arr.$[fN]` would set the element to `null` in place
|
|
2736
|
+
* (not remove from array). For real removal we use `$pull` instead — or, when
|
|
2737
|
+
* combined with terminal-bracket inserts, the unified `$filter + $concatArrays`
|
|
2738
|
+
* aggregation stage in `_applyBracketProcessing`.
|
|
2739
|
+
*
|
|
2740
|
+
* Mutates `update.$unset` (removes matched paths). Returns extracted remove
|
|
2741
|
+
* specs (one per parent field, ids merged), or `undefined` if no removes present.
|
|
2742
|
+
*
|
|
2743
|
+
* Note: parent paths with NESTED brackets are not supported here (left in `$unset`
|
|
2744
|
+
* for the existing arrayFilters fallback).
|
|
2745
|
+
*/
|
|
2746
|
+
_extractArrayRemoves(update) {
|
|
2747
|
+
const $unset = update.$unset;
|
|
2748
|
+
if (!$unset || typeof $unset !== 'object')
|
|
2749
|
+
return undefined;
|
|
2750
|
+
let hasBracket = false;
|
|
2751
|
+
for (const k of Object.keys($unset)) {
|
|
2752
|
+
if (k.indexOf('[') >= 0) {
|
|
2753
|
+
hasBracket = true;
|
|
2754
|
+
break;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
if (!hasBracket)
|
|
2758
|
+
return undefined;
|
|
2759
|
+
const removesByField = new Map();
|
|
2760
|
+
const remaining = {};
|
|
2761
|
+
for (const path of Object.keys($unset)) {
|
|
2762
|
+
const tokens = Mongo._tokenizePath(path);
|
|
2763
|
+
const lastToken = tokens[tokens.length - 1];
|
|
2764
|
+
const isTerminalBracket = !!(lastToken
|
|
2765
|
+
&& lastToken.length >= 2
|
|
2766
|
+
&& lastToken.charCodeAt(0) === 91 /* [ */
|
|
2767
|
+
&& lastToken.charCodeAt(lastToken.length - 1) === 93 /* ] */);
|
|
2768
|
+
if (isTerminalBracket) {
|
|
2769
|
+
const parentTokens = tokens.slice(0, -1);
|
|
2770
|
+
const parentHasBracket = parentTokens.some((t) => t.length > 0 && t.charCodeAt(0) === 91);
|
|
2771
|
+
if (!parentHasBracket && parentTokens.length > 0) {
|
|
2772
|
+
const fieldPath = parentTokens.join('.');
|
|
2773
|
+
const id = Mongo._unquoteBracketId(lastToken, path);
|
|
2774
|
+
if (!removesByField.has(fieldPath))
|
|
2775
|
+
removesByField.set(fieldPath, []);
|
|
2776
|
+
removesByField.get(fieldPath).push(id);
|
|
2777
|
+
continue;
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
remaining[path] = $unset[path];
|
|
2781
|
+
}
|
|
2782
|
+
if (removesByField.size === 0)
|
|
2783
|
+
return undefined;
|
|
2784
|
+
update.$unset = remaining;
|
|
2785
|
+
if (Object.keys(remaining).length === 0)
|
|
2786
|
+
delete update.$unset;
|
|
2787
|
+
return Array.from(removesByField.entries()).map(([field, ids]) => ({ field, ids }));
|
|
2788
|
+
}
|
|
2789
|
+
/**
|
|
2790
|
+
* Extracts SINGLE-bracket bracket-by-_id paths from `$set`/`$unset`, grouped
|
|
2791
|
+
* by `(parentArrayField, elementId)`. Used when pipeline form is needed
|
|
2792
|
+
* (because terminal-bracket inserts/removes are present) — these paths must
|
|
2793
|
+
* be expressed as `$map` + `$mergeObjects` stages, since mongo does not
|
|
2794
|
+
* allow `arrayFilters` to coexist with aggregation pipelines.
|
|
2795
|
+
*
|
|
2796
|
+
* Path forms detected (mutated out of `$set`/`$unset`):
|
|
2797
|
+
* - `$set` `arr[id].field` → sets["field"] = value (sub-field set)
|
|
2798
|
+
* - `$set` `arr[id].sub.field` → sets["sub.field"] = value (deep sub-field set)
|
|
2799
|
+
* - `$set` `arr[id]` (object value) → replace = value (whole-element replace)
|
|
2800
|
+
* - `$unset` `arr[id].field` → unsets.push("field") (sub-field unset)
|
|
2801
|
+
*
|
|
2802
|
+
* NOT detected (left in `$set`/`$unset` for caller to handle):
|
|
2803
|
+
* - paths with NESTED brackets (e.g. `arr[A].sub[B].field`) — caller must
|
|
2804
|
+
* reject these when in pipeline mode (still unsupported).
|
|
2805
|
+
* - terminal `arr[id]` already removed by `_extractArrayInserts`/`_extractArrayRemoves`.
|
|
2806
|
+
*
|
|
2807
|
+
* Mutates `update.$set` and `update.$unset`. Returns the grouped ops, or
|
|
2808
|
+
* `undefined` if no extractable paths were present.
|
|
2809
|
+
*/
|
|
2810
|
+
_extractArraySubFieldUpdates(update) {
|
|
2811
|
+
const result = new Map();
|
|
2812
|
+
let extractedAny = false;
|
|
2813
|
+
const ensureEntry = (parentField, id) => {
|
|
2814
|
+
if (!result.has(parentField))
|
|
2815
|
+
result.set(parentField, new Map());
|
|
2816
|
+
const idMap = result.get(parentField);
|
|
2817
|
+
if (!idMap.has(id))
|
|
2818
|
+
idMap.set(id, { sets: {}, unsets: [] });
|
|
2819
|
+
return idMap.get(id);
|
|
2820
|
+
};
|
|
2821
|
+
const processOp = (opName) => {
|
|
2822
|
+
const op = update[opName];
|
|
2823
|
+
if (!op || typeof op !== 'object')
|
|
2824
|
+
return;
|
|
2825
|
+
let hasBracket = false;
|
|
2826
|
+
for (const k of Object.keys(op)) {
|
|
2827
|
+
if (k.indexOf('[') >= 0) {
|
|
2828
|
+
hasBracket = true;
|
|
2829
|
+
break;
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
if (!hasBracket)
|
|
2833
|
+
return;
|
|
2834
|
+
const remaining = {};
|
|
2835
|
+
let mutated = false;
|
|
2836
|
+
for (const path of Object.keys(op)) {
|
|
2837
|
+
const tokens = Mongo._tokenizePath(path);
|
|
2838
|
+
let bracketCount = 0;
|
|
2839
|
+
let bracketIdx = -1;
|
|
2840
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
2841
|
+
const t = tokens[i];
|
|
2842
|
+
if (t.length >= 2 && t.charCodeAt(0) === 91 /* [ */ && t.charCodeAt(t.length - 1) === 93 /* ] */) {
|
|
2843
|
+
bracketCount++;
|
|
2844
|
+
bracketIdx = i;
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
if (bracketCount === 1 && bracketIdx > 0) {
|
|
2848
|
+
const parentField = tokens.slice(0, bracketIdx).join('.');
|
|
2849
|
+
const id = Mongo._unquoteBracketId(tokens[bracketIdx], path);
|
|
2850
|
+
const restPath = tokens.slice(bracketIdx + 1).join('.');
|
|
2851
|
+
const elOps = ensureEntry(parentField, id);
|
|
2852
|
+
if (opName === '$set') {
|
|
2853
|
+
if (restPath === '') {
|
|
2854
|
+
// `arr[id]: <obj>` — whole-element replace.
|
|
2855
|
+
elOps.replace = op[path];
|
|
2856
|
+
}
|
|
2857
|
+
else {
|
|
2858
|
+
elOps.sets[restPath] = op[path];
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
else {
|
|
2862
|
+
// `$unset arr[id].field` — sub-field unset. Terminal `$unset arr[id]`
|
|
2863
|
+
// is already extracted by `_extractArrayRemoves`.
|
|
2864
|
+
if (restPath === '') {
|
|
2865
|
+
remaining[path] = op[path];
|
|
2866
|
+
continue;
|
|
2867
|
+
}
|
|
2868
|
+
elOps.unsets.push(restPath);
|
|
2869
|
+
}
|
|
2870
|
+
mutated = true;
|
|
2871
|
+
extractedAny = true;
|
|
2872
|
+
continue;
|
|
2873
|
+
}
|
|
2874
|
+
remaining[path] = op[path];
|
|
2875
|
+
}
|
|
2876
|
+
if (mutated) {
|
|
2877
|
+
update[opName] = remaining;
|
|
2878
|
+
if (Object.keys(remaining).length === 0)
|
|
2879
|
+
delete update[opName];
|
|
2880
|
+
}
|
|
2881
|
+
};
|
|
2882
|
+
processOp('$set');
|
|
2883
|
+
processOp('$unset');
|
|
2884
|
+
return extractedAny ? result : undefined;
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* Build an aggregation expression that produces a transformed copy of an
|
|
2888
|
+
* element, applying the given sets/unsets and (optionally) a whole-element
|
|
2889
|
+
* replacement.
|
|
2890
|
+
*
|
|
2891
|
+
* - `replace`: when set, becomes the new base (full element replacement).
|
|
2892
|
+
* - `sets`: dot-paths within element → values; deep-merged via recursive
|
|
2893
|
+
* `$mergeObjects`/`$ifNull` so nested updates preserve sibling fields.
|
|
2894
|
+
* - `unsets`: dot-paths within element to remove. Implemented via
|
|
2895
|
+
* `$arrayToObject($filter($objectToArray(merged), kv.k notIn unsets))` —
|
|
2896
|
+
* `$$REMOVE` inside `$mergeObjects` does NOT drop the field (mongo treats
|
|
2897
|
+
* it as a missing value and falls through to the previous operand), so
|
|
2898
|
+
* the explicit kv-filter is required.
|
|
2899
|
+
*
|
|
2900
|
+
* The returned expression is suitable as the `in:` argument of `$map`.
|
|
2901
|
+
*/
|
|
2902
|
+
static _buildElementMergeExpr(baseExpr, ops) {
|
|
2903
|
+
const hasSets = Object.keys(ops.sets).length > 0;
|
|
2904
|
+
const hasUnsets = ops.unsets.length > 0;
|
|
2905
|
+
const hasReplace = ops.replace !== undefined;
|
|
2906
|
+
if (!hasSets && !hasUnsets && !hasReplace)
|
|
2907
|
+
return baseExpr;
|
|
2908
|
+
if (!hasSets && !hasUnsets) {
|
|
2909
|
+
// Pure replace — embed as literal so client-supplied field names like
|
|
2910
|
+
// "_id" don't get interpreted as field paths.
|
|
2911
|
+
return { $literal: ops.replace };
|
|
2912
|
+
}
|
|
2913
|
+
const root = { directs: {}, nested: {}, unsets: [] };
|
|
2914
|
+
const walkInsert = (path, isUnset, value) => {
|
|
2915
|
+
const parts = path.split('.');
|
|
2916
|
+
let node = root;
|
|
2917
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
2918
|
+
const key = parts[i];
|
|
2919
|
+
if (!node.nested[key])
|
|
2920
|
+
node.nested[key] = { directs: {}, nested: {}, unsets: [] };
|
|
2921
|
+
node = node.nested[key];
|
|
2922
|
+
}
|
|
2923
|
+
const leaf = parts[parts.length - 1];
|
|
2924
|
+
if (isUnset)
|
|
2925
|
+
node.unsets.push(leaf);
|
|
2926
|
+
else
|
|
2927
|
+
node.directs[leaf] = value;
|
|
2928
|
+
};
|
|
2929
|
+
for (const [p, v] of Object.entries(ops.sets))
|
|
2930
|
+
walkInsert(p, false, v);
|
|
2931
|
+
for (const p of ops.unsets)
|
|
2932
|
+
walkInsert(p, true);
|
|
2933
|
+
// Recursively build merge expression. `basePath` is a string field path
|
|
2934
|
+
// (e.g. "$$el", "$$el.sub") used to read the existing nested value.
|
|
2935
|
+
const buildOverlay = (node, basePath) => {
|
|
2936
|
+
const overlay = {};
|
|
2937
|
+
for (const [k, v] of Object.entries(node.directs))
|
|
2938
|
+
overlay[k] = v;
|
|
2939
|
+
for (const [k, sub] of Object.entries(node.nested)) {
|
|
2940
|
+
const subPath = `${basePath}.${k}`;
|
|
2941
|
+
overlay[k] = buildOverlay(sub, subPath);
|
|
2942
|
+
}
|
|
2943
|
+
// Merge sets/nested onto the existing object (or {} if absent).
|
|
2944
|
+
let merged = { $mergeObjects: [{ $ifNull: [basePath, {}] }, overlay] };
|
|
2945
|
+
// Drop unset top-level keys at this level via kv-filter.
|
|
2946
|
+
if (node.unsets.length > 0) {
|
|
2947
|
+
merged = {
|
|
2948
|
+
$arrayToObject: {
|
|
2949
|
+
$filter: {
|
|
2950
|
+
input: { $objectToArray: merged },
|
|
2951
|
+
as: 'kv',
|
|
2952
|
+
cond: { $not: { $in: ['$$kv.k', node.unsets] } },
|
|
2953
|
+
},
|
|
2954
|
+
},
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
return merged;
|
|
2958
|
+
};
|
|
2959
|
+
// If a replace is also requested, treat replacement as the new base.
|
|
2960
|
+
// Wrap with `$let` since a literal object isn't addressable by string field path.
|
|
2961
|
+
if (hasReplace) {
|
|
2962
|
+
const overlayExpr = buildOverlay(root, '$$replaced');
|
|
2963
|
+
return {
|
|
2964
|
+
$let: {
|
|
2965
|
+
vars: { replaced: { $literal: ops.replace } },
|
|
2966
|
+
in: overlayExpr,
|
|
2967
|
+
},
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
return buildOverlay(root, baseExpr);
|
|
2971
|
+
}
|
|
2972
|
+
/**
|
|
2973
|
+
* Translate an update doc's `$inc` and `$currentDate` operators into
|
|
2974
|
+
* pipeline-stage equivalents, so revisions (`_rev` increment, `_ts` timestamp)
|
|
2975
|
+
* still fire when the rest of the update goes through aggregation pipeline.
|
|
2976
|
+
*
|
|
2977
|
+
* - `$inc: { f: n }` → `{ $set: { f: { $add: [{ $ifNull: ['$f', 0] }, n] } } }`
|
|
2978
|
+
* - `$currentDate: { f: true }` → `{ $set: { f: '$$NOW' } }` (Date)
|
|
2979
|
+
* - `$currentDate: { f: { $type: 'date' } }` → same as above
|
|
2980
|
+
* - `$currentDate: { f: { $type: 'timestamp' } }` → `{ $set: { f: '$$CLUSTER_TIME' } }` (Timestamp)
|
|
2981
|
+
*
|
|
2982
|
+
* Mutates `update` (deletes `$inc` and `$currentDate`). Returns 0..2 pipeline stages.
|
|
2983
|
+
*/
|
|
2984
|
+
static _drainIncAndCurrentDateToPipelineStages(update) {
|
|
2985
|
+
const stages = [];
|
|
2986
|
+
const inc = update.$inc;
|
|
2987
|
+
if (inc && typeof inc === 'object') {
|
|
2988
|
+
const setObj = {};
|
|
2989
|
+
for (const [k, v] of Object.entries(inc)) {
|
|
2990
|
+
setObj[k] = { $add: [{ $ifNull: [`$${k}`, 0] }, v] };
|
|
2991
|
+
}
|
|
2992
|
+
if (Object.keys(setObj).length > 0)
|
|
2993
|
+
stages.push({ $set: setObj });
|
|
2994
|
+
delete update.$inc;
|
|
2995
|
+
}
|
|
2996
|
+
const cd = update.$currentDate;
|
|
2997
|
+
if (cd && typeof cd === 'object') {
|
|
2998
|
+
const setObj = {};
|
|
2999
|
+
for (const [k, spec] of Object.entries(cd)) {
|
|
3000
|
+
let isTimestamp = false;
|
|
3001
|
+
if (spec && typeof spec === 'object' && spec.$type === 'timestamp')
|
|
3002
|
+
isTimestamp = true;
|
|
3003
|
+
setObj[k] = isTimestamp ? '$$CLUSTER_TIME' : '$$NOW';
|
|
3004
|
+
}
|
|
3005
|
+
if (Object.keys(setObj).length > 0)
|
|
3006
|
+
stages.push({ $set: setObj });
|
|
3007
|
+
delete update.$currentDate;
|
|
3008
|
+
}
|
|
3009
|
+
return stages;
|
|
3010
|
+
}
|
|
3011
|
+
/**
|
|
3012
|
+
* Combined bracket-path processing: extracts inserts (`arr[id]: [els]`),
|
|
3013
|
+
* removes (`arr[id]: undefined` → `$unset`), sub-field updates
|
|
3014
|
+
* (`arr[id].field`, `arr[id].sub.field`, `arr[id]: <obj>`) and arrayFilters.
|
|
3015
|
+
* Decides whether mongo update should be sent as a regular doc or as an
|
|
3016
|
+
* aggregation pipeline.
|
|
3017
|
+
*
|
|
3018
|
+
* Returns `{ update, arrayFilters? }`:
|
|
3019
|
+
* - `update` is the original update doc OR an aggregation pipeline.
|
|
3020
|
+
* - `arrayFilters` is set when sub-field bracket paths needed translation; only
|
|
3021
|
+
* valid when `update` is the doc form (mongo doesn't support arrayFilters on pipelines).
|
|
3022
|
+
*
|
|
3023
|
+
* Strategy matrix:
|
|
3024
|
+
* - no inserts, no removes → existing arrayFilters path (or pure update doc)
|
|
3025
|
+
* - removes only → adds `$pull` to update doc; arrayFilters allowed
|
|
3026
|
+
* - inserts (± removes) → pipeline form; SINGLE-bracket sub-field paths
|
|
3027
|
+
* are also translated to pipeline `$map` + `$mergeObjects`
|
|
3028
|
+
* so they coexist with the inserts. NESTED-bracket paths
|
|
3029
|
+
* (e.g. `arr[A].sub[B].field`) combined with inserts still
|
|
3030
|
+
* throw — caller must split into two operations.
|
|
3031
|
+
*
|
|
3032
|
+
* The unified per-parent-field pipeline stage atomically filters out elements
|
|
3033
|
+
* matching any of (removeIds ∪ insertIds), maps remaining elements through the
|
|
3034
|
+
* sub-field merge expression, then appends new elements. Inserts remain
|
|
3035
|
+
* idempotent (re-inserting same `_id` no-ops). `$inc` and `$currentDate` are
|
|
3036
|
+
* also translated to pipeline stages so revisions (`_rev`/`_ts`) still fire.
|
|
3037
|
+
*/
|
|
3038
|
+
_applyBracketProcessing(update) {
|
|
3039
|
+
// Pre-pass: validate bracket-id ↔ element-_id consistency, auto-fill missing _id.
|
|
3040
|
+
// Throws on empty bracket id or _id mismatch. Mutates element objects in $set.
|
|
3041
|
+
this._validateAndAutoFillTerminalBracketValues(update);
|
|
3042
|
+
const inserts = this._extractArrayInserts(update);
|
|
3043
|
+
const removes = this._extractArrayRemoves(update);
|
|
3044
|
+
// Pure sub-field updates only (no inserts, no removes) — keep on legacy
|
|
3045
|
+
// arrayFilters path (faster, smaller payload, doesn't fight $inc/$currentDate).
|
|
3046
|
+
if (!inserts && !removes) {
|
|
3047
|
+
const arrayFilters = this._extractArrayFilters(update);
|
|
3048
|
+
return arrayFilters ? { update, arrayFilters } : { update };
|
|
3049
|
+
}
|
|
3050
|
+
// Removes-only path: `$pull` on update doc. Coexists fine with `$set` + arrayFilters.
|
|
3051
|
+
if (removes && !inserts) {
|
|
3052
|
+
const pullOp = (update.$pull || {});
|
|
3053
|
+
for (const rm of removes) {
|
|
3054
|
+
const existing = pullOp[rm.field];
|
|
3055
|
+
if (existing && existing._id && Array.isArray(existing._id.$in)) {
|
|
3056
|
+
const merged = new Set([...existing._id.$in, ...rm.ids]);
|
|
3057
|
+
pullOp[rm.field] = { _id: { $in: Array.from(merged) } };
|
|
3058
|
+
}
|
|
3059
|
+
else {
|
|
3060
|
+
pullOp[rm.field] = { _id: { $in: [...rm.ids] } };
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
update.$pull = pullOp;
|
|
3064
|
+
const arrayFilters = this._extractArrayFilters(update);
|
|
3065
|
+
return arrayFilters ? { update, arrayFilters } : { update };
|
|
3066
|
+
}
|
|
3067
|
+
// Inserts present (± removes) → pipeline form.
|
|
3068
|
+
// Extract single-bracket sub-field paths so we can express them as pipeline
|
|
3069
|
+
// stages instead of arrayFilters (mongo forbids arrayFilters on pipelines).
|
|
3070
|
+
const subFieldOps = this._extractArraySubFieldUpdates(update);
|
|
3071
|
+
// Any bracket paths still in $set/$unset must be NESTED-bracket
|
|
3072
|
+
// (e.g. `arr[A].sub[B].field`) — those aren't expressible without arrayFilters,
|
|
3073
|
+
// so combining them with inserts is still unsupported.
|
|
3074
|
+
const hasNestedBracket = (op) => {
|
|
3075
|
+
if (!op)
|
|
3076
|
+
return false;
|
|
3077
|
+
for (const k of Object.keys(op))
|
|
3078
|
+
if (k.indexOf('[') >= 0)
|
|
3079
|
+
return true;
|
|
3080
|
+
return false;
|
|
3081
|
+
};
|
|
3082
|
+
if (hasNestedBracket(update.$set) || hasNestedBracket(update.$unset)) {
|
|
3083
|
+
throw new Error('cry-db: cannot combine NESTED-bracket sub-field paths (e.g. `arr[A].sub[B].field`) with terminal-bracket array inserts (e.g. `arr[id]: [<elements>]`) in the same update. Split into two separate updateOne/save calls.');
|
|
3084
|
+
}
|
|
3085
|
+
const fieldOps = new Map();
|
|
3086
|
+
const ensureField = (field) => {
|
|
3087
|
+
if (!fieldOps.has(field))
|
|
3088
|
+
fieldOps.set(field, { removeIds: [], insertElements: [], elementOps: new Map() });
|
|
3089
|
+
return fieldOps.get(field);
|
|
3090
|
+
};
|
|
3091
|
+
if (inserts) {
|
|
3092
|
+
for (const ins of inserts) {
|
|
3093
|
+
const ops = ensureField(ins.field);
|
|
3094
|
+
ops.removeIds.push(...ins.ids);
|
|
3095
|
+
ops.insertElements.push(...ins.elements);
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
if (removes) {
|
|
3099
|
+
for (const rm of removes) {
|
|
3100
|
+
const ops = ensureField(rm.field);
|
|
3101
|
+
ops.removeIds.push(...rm.ids);
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
if (subFieldOps) {
|
|
3105
|
+
for (const [field, idMap] of subFieldOps.entries()) {
|
|
3106
|
+
const ops = ensureField(field);
|
|
3107
|
+
for (const [id, elOps] of idMap.entries()) {
|
|
3108
|
+
// If an insert with the same id is also queued, the insert wins
|
|
3109
|
+
// (insertElements are already extracted; element merge would only
|
|
3110
|
+
// affect "existing" rows that survive the filter — but the insert
|
|
3111
|
+
// id is in removeIds, so it's filtered out anyway). Keep the
|
|
3112
|
+
// sub-field update so callers that mix these get predictable
|
|
3113
|
+
// behavior on existing data; it's a no-op if id ends up filtered.
|
|
3114
|
+
ops.elementOps.set(id, elOps);
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
const pipeline = [];
|
|
3119
|
+
if (update.$set && Object.keys(update.$set).length > 0) {
|
|
3120
|
+
pipeline.push({ $set: update.$set });
|
|
3121
|
+
}
|
|
3122
|
+
if (update.$unset && Object.keys(update.$unset).length > 0) {
|
|
3123
|
+
pipeline.push({ $unset: Object.keys(update.$unset) });
|
|
3124
|
+
}
|
|
3125
|
+
// Translate $inc / $currentDate so revisions (_rev/_ts) still update in pipeline form.
|
|
3126
|
+
for (const stage of Mongo._drainIncAndCurrentDateToPipelineStages(update))
|
|
3127
|
+
pipeline.push(stage);
|
|
3128
|
+
for (const [field, ops] of fieldOps.entries()) {
|
|
3129
|
+
const dedupedRemoveIds = Array.from(new Set(ops.removeIds));
|
|
3130
|
+
const filteredInput = {
|
|
3131
|
+
$filter: {
|
|
3132
|
+
input: { $ifNull: [`$${field}`, []] },
|
|
3133
|
+
as: 'el',
|
|
3134
|
+
cond: { $not: { $in: ['$$el._id', dedupedRemoveIds] } },
|
|
3135
|
+
},
|
|
3136
|
+
};
|
|
3137
|
+
let mappedExisting = filteredInput;
|
|
3138
|
+
if (ops.elementOps.size > 0) {
|
|
3139
|
+
// Build $switch with one branch per (id → merged element).
|
|
3140
|
+
const branches = [];
|
|
3141
|
+
for (const [id, elOps] of ops.elementOps.entries()) {
|
|
3142
|
+
branches.push({
|
|
3143
|
+
case: { $eq: ['$$el._id', id] },
|
|
3144
|
+
then: Mongo._buildElementMergeExpr('$$el', elOps),
|
|
3145
|
+
});
|
|
3146
|
+
}
|
|
3147
|
+
mappedExisting = {
|
|
3148
|
+
$map: {
|
|
3149
|
+
input: filteredInput,
|
|
3150
|
+
as: 'el',
|
|
3151
|
+
in: { $switch: { branches, default: '$$el' } },
|
|
3152
|
+
},
|
|
3153
|
+
};
|
|
3154
|
+
}
|
|
3155
|
+
pipeline.push({
|
|
3156
|
+
$set: {
|
|
3157
|
+
[field]: {
|
|
3158
|
+
$concatArrays: [mappedExisting, ops.insertElements],
|
|
3159
|
+
},
|
|
3160
|
+
},
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
3163
|
+
return { update: pipeline };
|
|
3164
|
+
}
|
|
2404
3165
|
async _processHashedKeys(obj) {
|
|
2405
3166
|
const hashedKeys = Object.keys(obj).filter(startsWithHashedPrefix);
|
|
2406
3167
|
await Promise.all(hashedKeys.map(async (key) => {
|