cry-db 2.4.32 → 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/dist/mongo.mjs CHANGED
@@ -1,3 +1,5 @@
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]`)
1
3
  // AI modified: 2026-04-25 (GetNewerSpec.specId — disambiguate duplicate-collection specs in findNewerMany / findNewerManyStream)
2
4
  // AI modified: 2026-04-21
3
5
  import * as bcrypt from 'bcrypt';
@@ -35,6 +37,7 @@ const parseFieldList = (fields) => (typeof fields === 'string' ? fields.split(',
35
37
  // String helper functions (faster than RegExp)
36
38
  const startsWithDollar = (s) => s.length > 0 && s[0] === '$';
37
39
  const startsWithDoubleUnderscore = (s) => s.length > 1 && s[0] === '_' && s[1] === '_';
40
+ const isServerReserved = (s) => s === "_rev" || s === "_ts";
38
41
  const startsWithHashedPrefix = (s) => s.length > 10 && s.startsWith(HASHED_PREFIX);
39
42
  const isHex24 = (s) => {
40
43
  if (s.length !== 24)
@@ -632,21 +635,29 @@ export class Mongo extends Db {
632
635
  if (this._hasHashedKeys(update))
633
636
  await this._processHashedKeys(update);
634
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);
635
645
  fjLog.debug('updateOne called', collection, query, update);
636
- let seqKeys = this._findSequenceKeys(update.$set);
637
646
  let obj = await this.executeTransactionally(collection, async (conn, client) => {
638
- update.$set = update.$set || {};
639
- if (seqKeys)
640
- await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
641
- if (update.$set === undefined || Object.keys(update.$set).length === 0)
642
- delete update.$set;
643
- let res = await conn.findOneAndUpdate(query, update, opts);
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);
644
655
  if (!res)
645
656
  return null;
646
- 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));
647
658
  await this._publishAndAudit('update', dbName, collection, resObj);
648
659
  return resObj;
649
- }, !!seqKeys, { operation: "updateOne", collection, query, update, options });
660
+ }, !!seqKeys, { operation: "updateOne", collection, query, update: processed.update, options });
650
661
  fjLog.debug('updateOne returns', obj);
651
662
  return this._processReturnedObject(await obj);
652
663
  }
@@ -664,21 +675,27 @@ export class Mongo extends Db {
664
675
  if (this._hasHashedKeys(update))
665
676
  await this._processHashedKeys(update);
666
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);
667
682
  fjLog.debug('save called', collection, id, update);
668
- let seqKeys = this._findSequenceKeys(update.$set);
683
+ let seqKeys = isPipeline ? undefined : this._findSequenceKeys(update.$set);
669
684
  let obj = await this.executeTransactionally(collection, async (conn, client) => {
670
- update.$set = update.$set || {};
671
- if (seqKeys)
672
- await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
673
- if (update.$set === undefined || Object.keys(update.$set).length === 0)
674
- delete update.$set;
675
- let res = await conn.findOneAndUpdate({ _id }, update, opts);
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);
676
693
  if (!res)
677
694
  return null;
678
- 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));
679
696
  await this._publishAndAudit('update', dbName, collection, resObj);
680
697
  return resObj;
681
- }, !!seqKeys, { operation: "save", collection, _id, update, options });
698
+ }, !!seqKeys, { operation: "save", collection, _id, update: processed.update, options });
682
699
  fjLog.debug('save returns', obj);
683
700
  return this._processReturnedObject(await obj);
684
701
  }
@@ -700,23 +717,29 @@ export class Mongo extends Db {
700
717
  if (this._hasHashedKeys(update))
701
718
  await this._processHashedKeys(update);
702
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);
703
724
  fjLog.debug('update called', collection, query, update);
704
- let seqKeys = this._findSequenceKeys(update.$set);
725
+ let seqKeys = isPipeline ? undefined : this._findSequenceKeys(update.$set);
705
726
  let obj = await this.executeTransactionally(collection, async (conn, client) => {
706
- update.$set = update.$set || {};
707
- if (seqKeys)
708
- await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
709
- if (update.$set === undefined || Object.keys(update.$set).length === 0)
710
- delete update.$set;
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
+ }
711
734
  fjLog.debug('update called', collection, query, update);
712
- let res = await conn.updateMany(query, update, opts);
735
+ let res = await conn.updateMany(query, processed.update, opts);
713
736
  let resObj = {
714
737
  n: res.modifiedCount,
715
738
  ok: !!res.acknowledged
716
739
  };
717
740
  await this._publishAndAudit('updateMany', dbName, collection, resObj);
718
741
  return resObj;
719
- }, !!seqKeys, { operation: "update", collection, query, update });
742
+ }, !!seqKeys, { operation: "update", collection, query, update: processed.update });
720
743
  fjLog.debug('update returns', obj);
721
744
  return await obj;
722
745
  }
@@ -739,17 +762,23 @@ export class Mongo extends Db {
739
762
  if (this._hasHashedKeys(update))
740
763
  await this._processHashedKeys(update);
741
764
  update = this._processUpdateObject(update);
742
- let seqKeys = this._findSequenceKeys(update.$set);
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);
743
770
  fjLog.debug('upsert processed', collection, query, update);
744
771
  if (Object.keys(query).length === 0)
745
772
  query._id = Mongo.newid();
746
773
  let ret = await this.executeTransactionally(collection, async (conn, client) => {
747
- update.$set = update.$set || {};
748
- if (seqKeys)
749
- await this._processSequenceField(client, dbName, collection, update.$set, seqKeys);
750
- if (update.$set === undefined || Object.keys(update.$set).length === 0)
751
- delete update.$set;
752
- let ret = await conn.findOneAndUpdate(query, update, opts);
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);
753
782
  if (ret) {
754
783
  // Detect if this was an insert or update by checking _rev
755
784
  const isInsert = this.revisions && ret._rev === 1;
@@ -837,23 +866,28 @@ export class Mongo extends Db {
837
866
  update = this.replaceIds(update);
838
867
  if (this._hasHashedKeys(update))
839
868
  await this._processHashedKeys(update);
840
- const processedUpdate = this._processUpdateObject({ ...update });
869
+ const processedBase = this._processUpdateObject({ ...update });
870
+ const processed = this._applyBracketProcessing(processedBase);
871
+ const isPipeline = Array.isArray(processed.update);
841
872
  const opts = {
842
873
  upsert: true,
843
874
  returnDocument: "after",
844
875
  includeResultMetadata: true,
876
+ ...(processed.arrayFilters ? { arrayFilters: processed.arrayFilters } : {}),
845
877
  ...this._sessionOpt()
846
878
  };
847
879
  const res = await conn
848
880
  .db(dbName)
849
881
  .collection(collection)
850
- .findOneAndUpdate({ _id: objectId }, processedUpdate, opts);
882
+ .findOneAndUpdate({ _id: objectId }, processed.update, opts);
851
883
  if (res === null || res === void 0 ? void 0 : res.value) {
852
884
  // Determine if this was an insert or update based on lastErrorObject
853
885
  const wasInsert = !((_a = res.lastErrorObject) === null || _a === void 0 ? void 0 : _a.updatedExisting);
854
886
  const operation = wasInsert ? 'insert' : 'update';
855
- // For inserts, return full object; for updates, remove unchanged fields
856
- const retObj = wasInsert ? res.value : this._removeUnchanged(res.value, processedUpdate, false);
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);
857
891
  this._processReturnedObject(retObj);
858
892
  return { success: true, _id, data: retObj, operation, wasInsert };
859
893
  }
@@ -1016,8 +1050,12 @@ export class Mongo extends Db {
1016
1050
  };
1017
1051
  if (this._hasHashedKeys(update))
1018
1052
  await this._processHashedKeys(update);
1019
- const processedUpdate = this._processUpdateObject({ ...update });
1020
- const result = await conn.findOneAndUpdate(query, processedUpdate, options);
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);
1021
1059
  if (!((_a = result === null || result === void 0 ? void 0 : result.value) === null || _a === void 0 ? void 0 : _a._id))
1022
1060
  return null;
1023
1061
  const ret = result.value;
@@ -1032,7 +1070,7 @@ export class Mongo extends Db {
1032
1070
  else {
1033
1071
  oper = "insert";
1034
1072
  }
1035
- const retObj = oper === "insert" ? ret : this._removeUnchanged(ret, processedUpdate, !!(opts === null || opts === void 0 ? void 0 : opts.returnFullObject));
1073
+ const retObj = oper === "insert" ? ret : (isPipeline ? ret : this._removeUnchanged(ret, processedBase, !!(opts === null || opts === void 0 ? void 0 : opts.returnFullObject)));
1036
1074
  this._processReturnedObject(retObj);
1037
1075
  return { operation: oper, data: retObj };
1038
1076
  });
@@ -2368,6 +2406,10 @@ export class Mongo extends Db {
2368
2406
  delete update[key];
2369
2407
  continue;
2370
2408
  }
2409
+ if (isServerReserved(keyStr) && !keyStr.startsWith(HASHED_PREFIX)) {
2410
+ delete update[key];
2411
+ continue;
2412
+ }
2371
2413
  if (key === '' || key === null || key === undefined) {
2372
2414
  delete update[key];
2373
2415
  continue;
@@ -2401,6 +2443,443 @@ export class Mongo extends Db {
2401
2443
  }
2402
2444
  return update;
2403
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
+ }
2404
2883
  async _processHashedKeys(obj) {
2405
2884
  const hashedKeys = Object.keys(obj).filter(startsWithHashedPrefix);
2406
2885
  await Promise.all(hashedKeys.map(async (key) => {