cry-db 2.4.48 → 2.4.49

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
@@ -923,10 +923,10 @@ export class Mongo extends Db {
923
923
  const processed = this._applyBracketProcessing(processedBase);
924
924
  if (processed.warnings) {
925
925
  for (const w of processed.warnings) {
926
- warnings.push({
927
- _id: String(_id),
928
- error: `dropped nested-bracket ${w.op} path "${w.path}" = ${JSON.stringify(w.value)}`,
929
- });
926
+ const msg = w.kind === 'overlap'
927
+ ? `same-id overlap at "${w.path}" — terminal-bracket value ${JSON.stringify(w.value)} sets the element base; sub-field overlay applies on top`
928
+ : `dropped nested-bracket ${w.op} path "${w.path}" = ${JSON.stringify(w.value)}`;
929
+ warnings.push({ _id: String(_id), error: msg });
930
930
  }
931
931
  }
932
932
  const opts = {
@@ -3042,6 +3042,46 @@ export class Mongo extends Db {
3042
3042
  processOp('$unset');
3043
3043
  return extractedAny ? result : undefined;
3044
3044
  }
3045
+ /**
3046
+ * Build the filter + concat + map+switch expression that transforms an
3047
+ * array according to a FieldOps spec. Used both at the top level (in
3048
+ * `_applyBracketProcessing`'s main pipeline) and recursively from
3049
+ * `_buildElementMergeExpr` to compose nested-array transformations
3050
+ * inside an element overlay.
3051
+ *
3052
+ * `baseInputPath` is a mongo field path string (e.g. `"$koraki"` for
3053
+ * top-level, `"$$el.postavke"` for nested). It MUST be a string so the
3054
+ * generated expression can read the existing array via `$ifNull`.
3055
+ */
3056
+ static _buildArrayTransformExpr(baseInputPath, ops) {
3057
+ const dedupedRemoveIds = Mongo._normalizeBracketIds(Array.from(new Set(ops.removeIds)));
3058
+ const filteredInput = {
3059
+ $filter: {
3060
+ input: { $ifNull: [baseInputPath, []] },
3061
+ as: 'el',
3062
+ cond: { $not: { $in: ['$$el._id', dedupedRemoveIds] } },
3063
+ },
3064
+ };
3065
+ const concatExpr = {
3066
+ $concatArrays: [filteredInput, { $literal: ops.insertElements }],
3067
+ };
3068
+ if (ops.elementOps.size === 0)
3069
+ return concatExpr;
3070
+ const branches = [];
3071
+ for (const [id, elOps] of ops.elementOps.entries()) {
3072
+ branches.push({
3073
+ case: { $eq: ['$$el._id', Mongo._normalizeBracketId(id)] },
3074
+ then: Mongo._buildElementMergeExpr('$$el', elOps),
3075
+ });
3076
+ }
3077
+ return {
3078
+ $map: {
3079
+ input: concatExpr,
3080
+ as: 'el',
3081
+ in: { $switch: { branches, default: '$$el' } },
3082
+ },
3083
+ };
3084
+ }
3045
3085
  /**
3046
3086
  * Build an aggregation expression that produces a transformed copy of an
3047
3087
  * element, applying the given sets/unsets and (optionally) a whole-element
@@ -3055,6 +3095,9 @@ export class Mongo extends Db {
3055
3095
  * `$$REMOVE` inside `$mergeObjects` does NOT drop the field (mongo treats
3056
3096
  * it as a missing value and falls through to the previous operand), so
3057
3097
  * the explicit kv-filter is required.
3098
+ * - `nestedArrayOps`: per-array-field FieldOps for nested-bracket support.
3099
+ * Each entry produces a `$mergeObjects` overlay that replaces the named
3100
+ * field with the recursive output of `_buildArrayTransformExpr`.
3058
3101
  *
3059
3102
  * The returned expression is suitable as the `in:` argument of `$map`.
3060
3103
  */
@@ -3062,12 +3105,65 @@ export class Mongo extends Db {
3062
3105
  const hasSets = Object.keys(ops.sets).length > 0;
3063
3106
  const hasUnsets = ops.unsets.length > 0;
3064
3107
  const hasReplace = ops.replace !== undefined;
3065
- if (!hasSets && !hasUnsets && !hasReplace)
3108
+ const hasNestedArrayOps = !!(ops.nestedArrayOps && ops.nestedArrayOps.size > 0);
3109
+ // Helper: layer the nested-array overlays on top of an existing
3110
+ // merge expression. Used at every return point so nested-bracket
3111
+ // sub-field updates and nested terminal-bracket inserts apply
3112
+ // regardless of whether the outer element also has sets/unsets/replace.
3113
+ //
3114
+ // Removes-only entries (no insertElements, no elementOps) are
3115
+ // conditionally applied — only if the source array exists. This
3116
+ // matches mongo's native "no-op on missing field" semantics for
3117
+ // $pull / $unset and avoids creating spurious empty arrays on
3118
+ // elements that didn't originally carry the field.
3119
+ const wrapWithNestedArrays = (expr, basePath) => {
3120
+ if (!hasNestedArrayOps)
3121
+ return expr;
3122
+ const alwaysOverlay = {};
3123
+ const conditionalEntries = [];
3124
+ for (const [field, nestedFieldOps] of ops.nestedArrayOps.entries()) {
3125
+ const transform = Mongo._buildArrayTransformExpr(`${basePath}.${field}`, nestedFieldOps);
3126
+ const hasInserts = nestedFieldOps.insertElements.length > 0;
3127
+ const hasOverlays = nestedFieldOps.elementOps.size > 0;
3128
+ if (hasInserts || hasOverlays) {
3129
+ alwaysOverlay[field] = transform;
3130
+ }
3131
+ else {
3132
+ conditionalEntries.push({ field, transform });
3133
+ }
3134
+ }
3135
+ let result = Object.keys(alwaysOverlay).length > 0
3136
+ ? { $mergeObjects: [expr, alwaysOverlay] }
3137
+ : expr;
3138
+ for (const { field, transform } of conditionalEntries) {
3139
+ result = {
3140
+ $cond: [
3141
+ { $isArray: `${basePath}.${field}` },
3142
+ { $mergeObjects: [result, { [field]: transform }] },
3143
+ result,
3144
+ ],
3145
+ };
3146
+ }
3147
+ return result;
3148
+ };
3149
+ if (!hasSets && !hasUnsets && !hasReplace && !hasNestedArrayOps) {
3066
3150
  return baseExpr;
3151
+ }
3152
+ if (!hasSets && !hasUnsets && !hasReplace && hasNestedArrayOps) {
3153
+ return wrapWithNestedArrays(baseExpr, baseExpr);
3154
+ }
3067
3155
  if (!hasSets && !hasUnsets) {
3068
3156
  // Pure replace — embed as literal so client-supplied field names like
3069
3157
  // "_id" don't get interpreted as field paths.
3070
- return { $literal: ops.replace };
3158
+ const replaceExpr = { $literal: ops.replace };
3159
+ if (!hasNestedArrayOps)
3160
+ return replaceExpr;
3161
+ return {
3162
+ $let: {
3163
+ vars: { replaced: replaceExpr },
3164
+ in: wrapWithNestedArrays('$$replaced', '$$replaced'),
3165
+ },
3166
+ };
3071
3167
  }
3072
3168
  const root = { directs: {}, nested: {}, unsets: [] };
3073
3169
  const walkInsert = (path, isUnset, value) => {
@@ -3131,11 +3227,11 @@ export class Mongo extends Db {
3131
3227
  return {
3132
3228
  $let: {
3133
3229
  vars: { replaced: { $literal: ops.replace } },
3134
- in: overlayExpr,
3230
+ in: wrapWithNestedArrays(overlayExpr, '$$replaced'),
3135
3231
  },
3136
3232
  };
3137
3233
  }
3138
- return buildOverlay(root, baseExpr);
3234
+ return wrapWithNestedArrays(buildOverlay(root, baseExpr), baseExpr);
3139
3235
  }
3140
3236
  /**
3141
3237
  * Translate an update doc's `$inc` and `$currentDate` operators into
@@ -3209,9 +3305,42 @@ export class Mongo extends Db {
3209
3305
  this._validateAndAutoFillTerminalBracketValues(update);
3210
3306
  const inserts = this._extractArrayInserts(update);
3211
3307
  const removes = this._extractArrayRemoves(update);
3212
- // Pure sub-field updates only (no inserts, no removes) keep on legacy
3213
- // arrayFilters path (faster, smaller payload, doesn't fight $inc/$currentDate).
3214
- if (!inserts && !removes) {
3308
+ // Detect NESTED terminal-bracket paths (2+ brackets, last token is bracket
3309
+ // with array value or in $unset). Mongo cannot express these via
3310
+ // `arrayFilters` both `$set arr.$[f0].sub.$[f1]` AND `$unset arr.$[f0].sub.$[f1]`
3311
+ // collide with sibling sub-field paths on the same `arr.$[f0].sub.$[f1]`
3312
+ // positional. Force pipeline form so the nested processing below can
3313
+ // recursively express filter+concat+map+switch inside an element overlay.
3314
+ const hasNestedTerminalBracket = (op) => {
3315
+ if (!op)
3316
+ return false;
3317
+ for (const k of Object.keys(op)) {
3318
+ const tokens = Mongo._tokenizePath(k);
3319
+ if (tokens.length < 2)
3320
+ continue;
3321
+ let bracketCount = 0;
3322
+ for (const t of tokens) {
3323
+ if (t.length >= 2 && t.charCodeAt(0) === 91 && t.charCodeAt(t.length - 1) === 93) {
3324
+ bracketCount++;
3325
+ }
3326
+ }
3327
+ if (bracketCount < 2)
3328
+ continue;
3329
+ const last = tokens[tokens.length - 1];
3330
+ if (last.length >= 2 && last.charCodeAt(0) === 91 && last.charCodeAt(last.length - 1) === 93) {
3331
+ return true;
3332
+ }
3333
+ }
3334
+ return false;
3335
+ };
3336
+ const $setEarlyForNested = update.$set;
3337
+ const $unsetEarlyForNested = update.$unset;
3338
+ const hasNestedTerminal = hasNestedTerminalBracket($setEarlyForNested) || hasNestedTerminalBracket($unsetEarlyForNested);
3339
+ // Pure sub-field updates only (no inserts, no removes, no nested-terminal) —
3340
+ // keep on legacy arrayFilters path (faster, smaller payload, doesn't fight
3341
+ // $inc/$currentDate). Nested sub-field paths like `arr[A].sub[B].field`
3342
+ // ARE supported via nested arrayFilters here.
3343
+ if (!inserts && !removes && !hasNestedTerminal) {
3215
3344
  const arrayFilters = this._extractArrayFilters(update);
3216
3345
  return arrayFilters ? { update, arrayFilters } : { update };
3217
3346
  }
@@ -3277,52 +3406,42 @@ export class Mongo extends Db {
3277
3406
  // Extract single-bracket sub-field paths so we can express them as pipeline
3278
3407
  // stages instead of arrayFilters (mongo forbids arrayFilters on pipelines).
3279
3408
  const subFieldOps = this._extractArraySubFieldUpdates(update);
3280
- // NESTED-bracket paths (e.g. `arr[A].sub[B].field`) can't be expressed
3281
- // in the pipeline form alongside terminal-bracket inserts (mongo forbids
3282
- // arrayFilters on pipelines). Drop them, collect as warnings so callers
3283
- // can surface them, and let the rest of the update proceed.
3409
+ // Build the recursive fieldOps structure. Top-level (single-bracket)
3410
+ // inserts/removes/sub-field updates have already been extracted by
3411
+ // the dedicated helpers; ingest them now. Remaining bracket-containing
3412
+ // keys in $set/$unset are NESTED (e.g. `arr[A].sub[B].field`) and get
3413
+ // processed below into the recursive `nestedArrayOps` structure.
3284
3414
  const warnings = [];
3285
- const stripNestedBracket = (op, opName) => {
3286
- if (!op)
3287
- return;
3288
- for (const k of Object.keys(op)) {
3289
- if (k.indexOf('[') >= 0) {
3290
- warnings.push({ path: k, value: op[k], op: opName });
3291
- delete op[k];
3292
- }
3293
- }
3294
- };
3295
- stripNestedBracket(update.$set, '$set');
3296
- stripNestedBracket(update.$unset, '$unset');
3297
- if (warnings.length > 0) {
3298
- fjLog.warn('cry-db: dropping NESTED-bracket sub-field paths combined with terminal-bracket array operations (not expressible in pipeline form):', warnings);
3299
- }
3300
- if (update.$set && Object.keys(update.$set).length === 0)
3301
- delete update.$set;
3302
- if (update.$unset && Object.keys(update.$unset).length === 0)
3303
- delete update.$unset;
3304
3415
  const fieldOps = new Map();
3305
- const ensureField = (field) => {
3306
- if (!fieldOps.has(field))
3307
- fieldOps.set(field, { removeIds: [], insertElements: [], elementOps: new Map() });
3308
- return fieldOps.get(field);
3416
+ const ensureField = (container, field) => {
3417
+ if (!container.has(field))
3418
+ container.set(field, { removeIds: [], insertElements: [], elementOps: new Map() });
3419
+ return container.get(field);
3420
+ };
3421
+ const ensureElement = (fops, id) => {
3422
+ if (!fops.elementOps.has(id))
3423
+ fops.elementOps.set(id, { sets: {}, unsets: [], nestedArrayOps: new Map() });
3424
+ const elOps = fops.elementOps.get(id);
3425
+ if (!elOps.nestedArrayOps)
3426
+ elOps.nestedArrayOps = new Map();
3427
+ return elOps;
3309
3428
  };
3310
3429
  if (inserts) {
3311
3430
  for (const ins of inserts) {
3312
- const ops = ensureField(ins.field);
3431
+ const ops = ensureField(fieldOps, ins.field);
3313
3432
  ops.removeIds.push(...ins.ids);
3314
3433
  ops.insertElements.push(...ins.elements);
3315
3434
  }
3316
3435
  }
3317
3436
  if (removes) {
3318
3437
  for (const rm of removes) {
3319
- const ops = ensureField(rm.field);
3438
+ const ops = ensureField(fieldOps, rm.field);
3320
3439
  ops.removeIds.push(...rm.ids);
3321
3440
  }
3322
3441
  }
3323
3442
  if (subFieldOps) {
3324
3443
  for (const [field, idMap] of subFieldOps.entries()) {
3325
- const ops = ensureField(field);
3444
+ const ops = ensureField(fieldOps, field);
3326
3445
  for (const [id, elOps] of idMap.entries()) {
3327
3446
  // Sub-field updates are applied AFTER the post-concat array (i.e.
3328
3447
  // they overlay BOTH existing-kept elements and freshly-inserted
@@ -3330,10 +3449,173 @@ export class Mongo extends Db {
3330
3449
  // element with the insert as its base and the sub-field update
3331
3450
  // overriding matching fields. Order: non-bracket → terminal-bracket
3332
3451
  // insert/remove → sub-field overlay.
3333
- ops.elementOps.set(id, elOps);
3452
+ const target = ensureElement(ops, id);
3453
+ Object.assign(target.sets, elOps.sets);
3454
+ target.unsets.push(...elOps.unsets);
3455
+ if (elOps.replace !== undefined)
3456
+ target.replace = elOps.replace;
3334
3457
  }
3335
3458
  }
3336
3459
  }
3460
+ // Process REMAINING bracket-containing keys (multi-bracket / nested).
3461
+ // Each path adds entries to the recursive nestedArrayOps structure.
3462
+ const processNestedPaths = (op, opName) => {
3463
+ if (!op)
3464
+ return;
3465
+ const remaining = {};
3466
+ for (const path of Object.keys(op)) {
3467
+ if (path.indexOf('[') < 0) {
3468
+ remaining[path] = op[path];
3469
+ continue;
3470
+ }
3471
+ const tokens = Mongo._tokenizePath(path);
3472
+ const brackets = [];
3473
+ for (let i = 0; i < tokens.length; i++) {
3474
+ const t = tokens[i];
3475
+ if (t.length >= 2 && t.charCodeAt(0) === 91 && t.charCodeAt(t.length - 1) === 93) {
3476
+ brackets.push({ idx: i, id: Mongo._unquoteBracketId(t, path) });
3477
+ }
3478
+ }
3479
+ if (brackets.length < 2) {
3480
+ // Single-bracket: extractors above should have handled it; if it
3481
+ // remained, it's malformed (e.g. empty parent). Drop with warning.
3482
+ warnings.push({ path, value: op[path], op: opName, kind: 'dropped' });
3483
+ continue;
3484
+ }
3485
+ // Walk all but the innermost bracket — descend through nestedArrayOps.
3486
+ let currentMap = fieldOps;
3487
+ let ok = true;
3488
+ for (let bi = 0; bi < brackets.length - 1; bi++) {
3489
+ const bracket = brackets[bi];
3490
+ const fieldStart = bi === 0 ? 0 : brackets[bi - 1].idx + 1;
3491
+ const fieldName = tokens.slice(fieldStart, bracket.idx).join('.');
3492
+ if (!fieldName) {
3493
+ ok = false;
3494
+ break;
3495
+ }
3496
+ const fops = ensureField(currentMap, fieldName);
3497
+ const elOps = ensureElement(fops, bracket.id);
3498
+ currentMap = elOps.nestedArrayOps;
3499
+ }
3500
+ if (!ok) {
3501
+ warnings.push({ path, value: op[path], op: opName, kind: 'dropped' });
3502
+ continue;
3503
+ }
3504
+ const lastB = brackets[brackets.length - 1];
3505
+ const fieldStart = brackets[brackets.length - 2].idx + 1;
3506
+ const innerFieldName = tokens.slice(fieldStart, lastB.idx).join('.');
3507
+ const restPath = tokens.slice(lastB.idx + 1).join('.');
3508
+ if (!innerFieldName) {
3509
+ warnings.push({ path, value: op[path], op: opName, kind: 'dropped' });
3510
+ continue;
3511
+ }
3512
+ const innerFOps = ensureField(currentMap, innerFieldName);
3513
+ const value = op[path];
3514
+ if (opName === '$set') {
3515
+ if (restPath === '') {
3516
+ if (Array.isArray(value)) {
3517
+ innerFOps.removeIds.push(lastB.id);
3518
+ innerFOps.insertElements.push(...value);
3519
+ }
3520
+ else {
3521
+ // Whole-element replace at nested level.
3522
+ const elOps = ensureElement(innerFOps, lastB.id);
3523
+ elOps.replace = value;
3524
+ }
3525
+ }
3526
+ else {
3527
+ const elOps = ensureElement(innerFOps, lastB.id);
3528
+ elOps.sets[restPath] = value;
3529
+ }
3530
+ }
3531
+ else {
3532
+ // $unset
3533
+ if (restPath === '') {
3534
+ innerFOps.removeIds.push(lastB.id);
3535
+ }
3536
+ else {
3537
+ const elOps = ensureElement(innerFOps, lastB.id);
3538
+ elOps.unsets.push(restPath);
3539
+ }
3540
+ }
3541
+ }
3542
+ update[opName] = remaining;
3543
+ if (Object.keys(remaining).length === 0)
3544
+ delete update[opName];
3545
+ };
3546
+ processNestedPaths(update.$set, '$set');
3547
+ processNestedPaths(update.$unset, '$unset');
3548
+ // Detect same-id overlap at NESTED levels: a "new base" (terminal-array
3549
+ // insert OR whole-element replace) targeting the same `_id` as a
3550
+ // sub-field set/unset on the same element. Both apply — new base goes
3551
+ // first, sub-field overlay applies on top — but the unusual overlap is
3552
+ // surfaced as a `kind: 'overlap'` warning so callers know.
3553
+ //
3554
+ // Two flavors at the source of the "new base":
3555
+ // - terminal-array (`arr[id]: [...]`): id ends up in `removeIds`
3556
+ // AND `insertElements`. The matching element in elementOps will
3557
+ // carry sets/unsets if a same-id sub-field path was also present.
3558
+ // - whole-element replace (`arr[id]: {...}`): id ends up in
3559
+ // `elementOps[id].replace`. Sets/unsets co-locate on the same
3560
+ // entry when a same-id sub-field path was also present.
3561
+ //
3562
+ // Top-level same-id case is the same semantic but stays silent — it's
3563
+ // a documented, supported merge for backwards compat.
3564
+ const detectOverlaps = (container, parentPathRepr) => {
3565
+ for (const [field, fops] of container.entries()) {
3566
+ const fullField = parentPathRepr ? `${parentPathRepr}.${field}` : field;
3567
+ const isNested = parentPathRepr !== '';
3568
+ // Flavor 1: terminal-array insert + sub-field on same id.
3569
+ const removeIdSet = new Set(fops.removeIds);
3570
+ if (isNested) {
3571
+ for (const id of fops.removeIds) {
3572
+ const elOps = fops.elementOps.get(id);
3573
+ if (!elOps)
3574
+ continue;
3575
+ const hasOps = Object.keys(elOps.sets).length > 0
3576
+ || elOps.unsets.length > 0
3577
+ || elOps.replace !== undefined;
3578
+ if (hasOps) {
3579
+ const insertedEl = fops.insertElements.find((e) => String(e._id) === id);
3580
+ warnings.push({
3581
+ path: `${fullField}[${id}]`,
3582
+ value: insertedEl,
3583
+ op: '$set',
3584
+ kind: 'overlap',
3585
+ });
3586
+ }
3587
+ }
3588
+ }
3589
+ // Flavor 2: whole-element replace + sub-field on same id.
3590
+ // Skip ids already covered by Flavor 1 above (avoid double-warn).
3591
+ for (const [id, elOps] of fops.elementOps.entries()) {
3592
+ if (isNested && !removeIdSet.has(id)) {
3593
+ const hasReplace = elOps.replace !== undefined;
3594
+ const hasSetsOrUnsets = Object.keys(elOps.sets).length > 0
3595
+ || elOps.unsets.length > 0;
3596
+ if (hasReplace && hasSetsOrUnsets) {
3597
+ warnings.push({
3598
+ path: `${fullField}[${id}]`,
3599
+ value: elOps.replace,
3600
+ op: '$set',
3601
+ kind: 'overlap',
3602
+ });
3603
+ }
3604
+ }
3605
+ if (elOps.nestedArrayOps && elOps.nestedArrayOps.size > 0) {
3606
+ detectOverlaps(elOps.nestedArrayOps, `${fullField}[${id}]`);
3607
+ }
3608
+ }
3609
+ }
3610
+ };
3611
+ detectOverlaps(fieldOps, '');
3612
+ if (warnings.length > 0) {
3613
+ fjLog.warn('cry-db: bracket-by-_id warnings:', warnings);
3614
+ }
3615
+ if (update.$set && Object.keys(update.$set).length === 0)
3616
+ delete update.$set;
3617
+ if (update.$unset && Object.keys(update.$unset).length === 0)
3618
+ delete update.$unset;
3337
3619
  const pipeline = [];
3338
3620
  if (update.$set && Object.keys(update.$set).length > 0) {
3339
3621
  pipeline.push({ $set: update.$set });
@@ -3344,52 +3626,13 @@ export class Mongo extends Db {
3344
3626
  // Translate $inc / $currentDate so revisions (_rev/_ts) still update in pipeline form.
3345
3627
  for (const stage of Mongo._drainIncAndCurrentDateToPipelineStages(update))
3346
3628
  pipeline.push(stage);
3629
+ // Build one $set stage per parent array field. The filter+concat+map+switch
3630
+ // expression (with potentially recursive nested-array transforms inside
3631
+ // per-element overlays) is built by `_buildArrayTransformExpr`.
3347
3632
  for (const [field, ops] of fieldOps.entries()) {
3348
- const dedupedRemoveIds = Mongo._normalizeBracketIds(Array.from(new Set(ops.removeIds)));
3349
- const filteredInput = {
3350
- $filter: {
3351
- input: { $ifNull: [`$${field}`, []] },
3352
- as: 'el',
3353
- cond: { $not: { $in: ['$$el._id', dedupedRemoveIds] } },
3354
- },
3355
- };
3356
- // `$literal` wraps the insert array as opaque data, so mongo's
3357
- // expression parser does not interpret user-supplied field names
3358
- // with `.` or `$` as expression paths (legitimate-as-stored mongo
3359
- // doc keys would otherwise crash with
3360
- // `FieldPath field names may not contain '.'`).
3361
- const concatExpr = {
3362
- $concatArrays: [filteredInput, { $literal: ops.insertElements }],
3363
- };
3364
- // Apply sub-field updates to the FULL post-concat array (existing-kept
3365
- // AND newly inserted), so when an update mixes a terminal-bracket
3366
- // replace (`arr[id]: [el]`) and sub-field updates (`arr[id].field: x`)
3367
- // for the SAME id, the sub-field update overrides matching fields on
3368
- // the inserted element. This matches the user-facing semantics
3369
- // "bracketed fields apply after non-bracketed" applied transitively:
3370
- // sub-field updates are the most-specific bracket form, so they are
3371
- // applied LAST.
3372
- let resultExpr = concatExpr;
3373
- if (ops.elementOps.size > 0) {
3374
- // Build $switch with one branch per (id → merged element).
3375
- const branches = [];
3376
- for (const [id, elOps] of ops.elementOps.entries()) {
3377
- branches.push({
3378
- case: { $eq: ['$$el._id', Mongo._normalizeBracketId(id)] },
3379
- then: Mongo._buildElementMergeExpr('$$el', elOps),
3380
- });
3381
- }
3382
- resultExpr = {
3383
- $map: {
3384
- input: concatExpr,
3385
- as: 'el',
3386
- in: { $switch: { branches, default: '$$el' } },
3387
- },
3388
- };
3389
- }
3390
3633
  pipeline.push({
3391
3634
  $set: {
3392
- [field]: resultExpr,
3635
+ [field]: Mongo._buildArrayTransformExpr(`$${field}`, ops),
3393
3636
  },
3394
3637
  });
3395
3638
  }