cry-db 2.4.48 → 2.5.0

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
@@ -897,11 +897,17 @@ export class Mongo extends Db {
897
897
  inserted: [],
898
898
  updated: [],
899
899
  deleted: [],
900
+ mustRefresh: [],
900
901
  }
901
902
  };
902
903
  const batchData = [];
903
904
  const errors = [];
904
905
  const warnings = [];
906
+ // Per-update flag: did the client send a field cry-db resolves
907
+ // server-side (SEQ_*/__hashed__)? Captured BEFORE the bulk sequence
908
+ // resolution and per-update hashing mutate the updates in place, so
909
+ // the placeholders are still observable here.
910
+ const updateResolvedFlags = (updates !== null && updates !== void 0 ? updates : []).map(u => this._hasSequenceFields(u.update) || this._hasHashedKeys(u.update));
905
911
  // Handle sequences in updates before processing
906
912
  if (updates === null || updates === void 0 ? void 0 : updates.length) {
907
913
  const updatesWithSeq = updates.filter(u => this._hasSequenceFields(u.update));
@@ -911,7 +917,7 @@ export class Mongo extends Db {
911
917
  }
912
918
  // Process updates (with upsert) in parallel
913
919
  if (updates === null || updates === void 0 ? void 0 : updates.length) {
914
- const updatePromises = updates.map(async ({ _id, update }) => {
920
+ const updatePromises = updates.map(async ({ _id, _rev: clientRev, update }, idx) => {
915
921
  var _a, _b;
916
922
  try {
917
923
  const objectId = Mongo._toId(_id);
@@ -923,10 +929,10 @@ export class Mongo extends Db {
923
929
  const processed = this._applyBracketProcessing(processedBase);
924
930
  if (processed.warnings) {
925
931
  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
- });
932
+ const msg = w.kind === 'overlap'
933
+ ? `same-id overlap at "${w.path}" — terminal-bracket value ${JSON.stringify(w.value)} sets the element base; sub-field overlay applies on top`
934
+ : `dropped nested-bracket ${w.op} path "${w.path}" = ${JSON.stringify(w.value)}`;
935
+ warnings.push({ _id: String(_id), error: msg });
930
936
  }
931
937
  }
932
938
  const opts = {
@@ -945,6 +951,19 @@ export class Mongo extends Db {
945
951
  // Determine if this was an insert or update based on lastErrorObject
946
952
  const wasInsert = !((_b = res.lastErrorObject) === null || _b === void 0 ? void 0 : _b.updatedExisting);
947
953
  const operation = wasInsert ? 'insert' : 'update';
954
+ // mustRefresh: the client must replace its local copy when cry-db
955
+ // resolved a field (SEQ_*/__hashed__) OR a concurrent external write
956
+ // advanced the stored `_rev` beyond this write (gap > the +1 this write
957
+ // applied). An omitted `_rev` (clientRev → 0) forces a refresh — the
958
+ // old-client fallback. Built from a sanitized clone (hash never leaks).
959
+ const writtenRev = res.value._rev;
960
+ const inc = this.revisions ? 1 : 0;
961
+ const externallyModified = !wasInsert
962
+ && typeof writtenRev === 'number'
963
+ && (writtenRev - inc) > (clientRev !== null && clientRev !== void 0 ? clientRev : 0);
964
+ const mustRefresh = (updateResolvedFlags[idx] || externallyModified)
965
+ ? this._buildMustRefreshRecord(res.value)
966
+ : undefined;
948
967
  // Inserts surface the full doc (query fields must reach the client).
949
968
  // Updates always go through _buildPublishDelta so bracket keys collapse
950
969
  // to top-level parent array slices read straight from the post-update
@@ -953,7 +972,7 @@ export class Mongo extends Db {
953
972
  ? res.value
954
973
  : this._buildPublishDelta(res.value, inputUpdate, false);
955
974
  this._processReturnedObject(retObj);
956
- return { success: true, _id, data: retObj, operation, wasInsert, rawUpdate: wasInsert ? undefined : inputUpdate };
975
+ return { success: true, _id, data: retObj, operation, wasInsert, rawUpdate: wasInsert ? undefined : inputUpdate, mustRefresh };
957
976
  }
958
977
  return { success: false, _id, error: 'Operation failed' };
959
978
  }
@@ -975,6 +994,8 @@ export class Mongo extends Db {
975
994
  else {
976
995
  result.results.updated.push(dbEntity);
977
996
  }
997
+ if (res.mustRefresh)
998
+ result.results.mustRefresh.push(res.mustRefresh);
978
999
  const item = { operation: res.operation, data: res.data };
979
1000
  // rawUpdate is meaningful only for the update branch — inserts publish the full doc.
980
1001
  if (!res.wasInsert)
@@ -988,7 +1009,7 @@ export class Mongo extends Db {
988
1009
  }
989
1010
  // Process deletes in parallel
990
1011
  if (deletes === null || deletes === void 0 ? void 0 : deletes.length) {
991
- const deletePromises = deletes.map(async ({ _id }) => {
1012
+ const deletePromises = deletes.map(async ({ _id, _rev: clientRev }) => {
992
1013
  try {
993
1014
  const objectId = Mongo._toId(_id);
994
1015
  if (!this.softdelete) {
@@ -997,8 +1018,16 @@ export class Mongo extends Db {
997
1018
  .collection(collection)
998
1019
  .findOneAndDelete({ _id: objectId }, this._sessionOpt());
999
1020
  if (res) {
1021
+ // Hard delete applies no `_rev` increment: the removed doc's `_rev`
1022
+ // exceeding the client's base means an external write landed first.
1023
+ // Omitted `_rev` (clientRev → 0) forces a refresh — old-client fallback.
1024
+ const externallyModified = typeof res._rev === 'number'
1025
+ && res._rev > (clientRev !== null && clientRev !== void 0 ? clientRev : 0);
1026
+ const mustRefresh = externallyModified
1027
+ ? this._buildMustRefreshRecord(res)
1028
+ : undefined;
1000
1029
  this._processReturnedObject(res);
1001
- return { success: true, _id, data: res, operation: 'delete' };
1030
+ return { success: true, _id, data: res, operation: 'delete', mustRefresh };
1002
1031
  }
1003
1032
  return { success: false, _id, error: 'Document not found' };
1004
1033
  }
@@ -1018,8 +1047,16 @@ export class Mongo extends Db {
1018
1047
  .collection(collection)
1019
1048
  .findOneAndUpdate({ _id: objectId }, del, opts);
1020
1049
  if (res) {
1050
+ // Soft delete applied a +1 `_rev` increment: a gap beyond that +1
1051
+ // means an external write modified the record before this delete.
1052
+ // Omitted `_rev` (clientRev → 0) forces a refresh — old-client fallback.
1053
+ const externallyModified = typeof res._rev === 'number'
1054
+ && (res._rev - 1) > (clientRev !== null && clientRev !== void 0 ? clientRev : 0);
1055
+ const mustRefresh = externallyModified
1056
+ ? this._buildMustRefreshRecord(res)
1057
+ : undefined;
1021
1058
  this._processReturnedObject(res);
1022
- return { success: true, _id, data: res, operation: 'delete' };
1059
+ return { success: true, _id, data: res, operation: 'delete', mustRefresh };
1023
1060
  }
1024
1061
  return { success: false, _id, error: 'Document not found' };
1025
1062
  }
@@ -1038,6 +1075,8 @@ export class Mongo extends Db {
1038
1075
  _deleted: res.data._deleted,
1039
1076
  };
1040
1077
  result.results.deleted.push(dbEntity);
1078
+ if (res.mustRefresh)
1079
+ result.results.mustRefresh.push(res.mustRefresh);
1041
1080
  batchData.push({ operation: res.operation, data: res.data });
1042
1081
  }
1043
1082
  else if (!res.success && res.error) {
@@ -2297,6 +2336,17 @@ export class Mongo extends Db {
2297
2336
  out._id = wholerecord._id;
2298
2337
  return out;
2299
2338
  }
2339
+ /**
2340
+ * Build a `mustRefresh` entry: a full, sanitized clone of the post-write
2341
+ * document the client must adopt locally. Sanitization strips `__hashed__`
2342
+ * (the hash never leaves the server), other `__`-internal keys, and the
2343
+ * configured `findNewerRemoveFields`/`removeFieldsAlways` — matching what a
2344
+ * read/publish exposes. `_id`/`_rev`/`_ts` are preserved. Clones first so
2345
+ * the caller's `res.value`/`res` is left intact for the publish payload.
2346
+ */
2347
+ _buildMustRefreshRecord(fullDoc) {
2348
+ return this._processReturnedObject(cloneDeep(fullDoc), this._findNewerRemoveFields);
2349
+ }
2300
2350
  _shouldAuditCollection(db, col) {
2301
2351
  if (!this.auditing)
2302
2352
  return false;
@@ -3042,6 +3092,46 @@ export class Mongo extends Db {
3042
3092
  processOp('$unset');
3043
3093
  return extractedAny ? result : undefined;
3044
3094
  }
3095
+ /**
3096
+ * Build the filter + concat + map+switch expression that transforms an
3097
+ * array according to a FieldOps spec. Used both at the top level (in
3098
+ * `_applyBracketProcessing`'s main pipeline) and recursively from
3099
+ * `_buildElementMergeExpr` to compose nested-array transformations
3100
+ * inside an element overlay.
3101
+ *
3102
+ * `baseInputPath` is a mongo field path string (e.g. `"$koraki"` for
3103
+ * top-level, `"$$el.postavke"` for nested). It MUST be a string so the
3104
+ * generated expression can read the existing array via `$ifNull`.
3105
+ */
3106
+ static _buildArrayTransformExpr(baseInputPath, ops) {
3107
+ const dedupedRemoveIds = Mongo._normalizeBracketIds(Array.from(new Set(ops.removeIds)));
3108
+ const filteredInput = {
3109
+ $filter: {
3110
+ input: { $ifNull: [baseInputPath, []] },
3111
+ as: 'el',
3112
+ cond: { $not: { $in: ['$$el._id', dedupedRemoveIds] } },
3113
+ },
3114
+ };
3115
+ const concatExpr = {
3116
+ $concatArrays: [filteredInput, { $literal: ops.insertElements }],
3117
+ };
3118
+ if (ops.elementOps.size === 0)
3119
+ return concatExpr;
3120
+ const branches = [];
3121
+ for (const [id, elOps] of ops.elementOps.entries()) {
3122
+ branches.push({
3123
+ case: { $eq: ['$$el._id', Mongo._normalizeBracketId(id)] },
3124
+ then: Mongo._buildElementMergeExpr('$$el', elOps),
3125
+ });
3126
+ }
3127
+ return {
3128
+ $map: {
3129
+ input: concatExpr,
3130
+ as: 'el',
3131
+ in: { $switch: { branches, default: '$$el' } },
3132
+ },
3133
+ };
3134
+ }
3045
3135
  /**
3046
3136
  * Build an aggregation expression that produces a transformed copy of an
3047
3137
  * element, applying the given sets/unsets and (optionally) a whole-element
@@ -3055,6 +3145,9 @@ export class Mongo extends Db {
3055
3145
  * `$$REMOVE` inside `$mergeObjects` does NOT drop the field (mongo treats
3056
3146
  * it as a missing value and falls through to the previous operand), so
3057
3147
  * the explicit kv-filter is required.
3148
+ * - `nestedArrayOps`: per-array-field FieldOps for nested-bracket support.
3149
+ * Each entry produces a `$mergeObjects` overlay that replaces the named
3150
+ * field with the recursive output of `_buildArrayTransformExpr`.
3058
3151
  *
3059
3152
  * The returned expression is suitable as the `in:` argument of `$map`.
3060
3153
  */
@@ -3062,12 +3155,65 @@ export class Mongo extends Db {
3062
3155
  const hasSets = Object.keys(ops.sets).length > 0;
3063
3156
  const hasUnsets = ops.unsets.length > 0;
3064
3157
  const hasReplace = ops.replace !== undefined;
3065
- if (!hasSets && !hasUnsets && !hasReplace)
3158
+ const hasNestedArrayOps = !!(ops.nestedArrayOps && ops.nestedArrayOps.size > 0);
3159
+ // Helper: layer the nested-array overlays on top of an existing
3160
+ // merge expression. Used at every return point so nested-bracket
3161
+ // sub-field updates and nested terminal-bracket inserts apply
3162
+ // regardless of whether the outer element also has sets/unsets/replace.
3163
+ //
3164
+ // Removes-only entries (no insertElements, no elementOps) are
3165
+ // conditionally applied — only if the source array exists. This
3166
+ // matches mongo's native "no-op on missing field" semantics for
3167
+ // $pull / $unset and avoids creating spurious empty arrays on
3168
+ // elements that didn't originally carry the field.
3169
+ const wrapWithNestedArrays = (expr, basePath) => {
3170
+ if (!hasNestedArrayOps)
3171
+ return expr;
3172
+ const alwaysOverlay = {};
3173
+ const conditionalEntries = [];
3174
+ for (const [field, nestedFieldOps] of ops.nestedArrayOps.entries()) {
3175
+ const transform = Mongo._buildArrayTransformExpr(`${basePath}.${field}`, nestedFieldOps);
3176
+ const hasInserts = nestedFieldOps.insertElements.length > 0;
3177
+ const hasOverlays = nestedFieldOps.elementOps.size > 0;
3178
+ if (hasInserts || hasOverlays) {
3179
+ alwaysOverlay[field] = transform;
3180
+ }
3181
+ else {
3182
+ conditionalEntries.push({ field, transform });
3183
+ }
3184
+ }
3185
+ let result = Object.keys(alwaysOverlay).length > 0
3186
+ ? { $mergeObjects: [expr, alwaysOverlay] }
3187
+ : expr;
3188
+ for (const { field, transform } of conditionalEntries) {
3189
+ result = {
3190
+ $cond: [
3191
+ { $isArray: `${basePath}.${field}` },
3192
+ { $mergeObjects: [result, { [field]: transform }] },
3193
+ result,
3194
+ ],
3195
+ };
3196
+ }
3197
+ return result;
3198
+ };
3199
+ if (!hasSets && !hasUnsets && !hasReplace && !hasNestedArrayOps) {
3066
3200
  return baseExpr;
3201
+ }
3202
+ if (!hasSets && !hasUnsets && !hasReplace && hasNestedArrayOps) {
3203
+ return wrapWithNestedArrays(baseExpr, baseExpr);
3204
+ }
3067
3205
  if (!hasSets && !hasUnsets) {
3068
3206
  // Pure replace — embed as literal so client-supplied field names like
3069
3207
  // "_id" don't get interpreted as field paths.
3070
- return { $literal: ops.replace };
3208
+ const replaceExpr = { $literal: ops.replace };
3209
+ if (!hasNestedArrayOps)
3210
+ return replaceExpr;
3211
+ return {
3212
+ $let: {
3213
+ vars: { replaced: replaceExpr },
3214
+ in: wrapWithNestedArrays('$$replaced', '$$replaced'),
3215
+ },
3216
+ };
3071
3217
  }
3072
3218
  const root = { directs: {}, nested: {}, unsets: [] };
3073
3219
  const walkInsert = (path, isUnset, value) => {
@@ -3131,11 +3277,11 @@ export class Mongo extends Db {
3131
3277
  return {
3132
3278
  $let: {
3133
3279
  vars: { replaced: { $literal: ops.replace } },
3134
- in: overlayExpr,
3280
+ in: wrapWithNestedArrays(overlayExpr, '$$replaced'),
3135
3281
  },
3136
3282
  };
3137
3283
  }
3138
- return buildOverlay(root, baseExpr);
3284
+ return wrapWithNestedArrays(buildOverlay(root, baseExpr), baseExpr);
3139
3285
  }
3140
3286
  /**
3141
3287
  * Translate an update doc's `$inc` and `$currentDate` operators into
@@ -3209,9 +3355,42 @@ export class Mongo extends Db {
3209
3355
  this._validateAndAutoFillTerminalBracketValues(update);
3210
3356
  const inserts = this._extractArrayInserts(update);
3211
3357
  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) {
3358
+ // Detect NESTED terminal-bracket paths (2+ brackets, last token is bracket
3359
+ // with array value or in $unset). Mongo cannot express these via
3360
+ // `arrayFilters` both `$set arr.$[f0].sub.$[f1]` AND `$unset arr.$[f0].sub.$[f1]`
3361
+ // collide with sibling sub-field paths on the same `arr.$[f0].sub.$[f1]`
3362
+ // positional. Force pipeline form so the nested processing below can
3363
+ // recursively express filter+concat+map+switch inside an element overlay.
3364
+ const hasNestedTerminalBracket = (op) => {
3365
+ if (!op)
3366
+ return false;
3367
+ for (const k of Object.keys(op)) {
3368
+ const tokens = Mongo._tokenizePath(k);
3369
+ if (tokens.length < 2)
3370
+ continue;
3371
+ let bracketCount = 0;
3372
+ for (const t of tokens) {
3373
+ if (t.length >= 2 && t.charCodeAt(0) === 91 && t.charCodeAt(t.length - 1) === 93) {
3374
+ bracketCount++;
3375
+ }
3376
+ }
3377
+ if (bracketCount < 2)
3378
+ continue;
3379
+ const last = tokens[tokens.length - 1];
3380
+ if (last.length >= 2 && last.charCodeAt(0) === 91 && last.charCodeAt(last.length - 1) === 93) {
3381
+ return true;
3382
+ }
3383
+ }
3384
+ return false;
3385
+ };
3386
+ const $setEarlyForNested = update.$set;
3387
+ const $unsetEarlyForNested = update.$unset;
3388
+ const hasNestedTerminal = hasNestedTerminalBracket($setEarlyForNested) || hasNestedTerminalBracket($unsetEarlyForNested);
3389
+ // Pure sub-field updates only (no inserts, no removes, no nested-terminal) —
3390
+ // keep on legacy arrayFilters path (faster, smaller payload, doesn't fight
3391
+ // $inc/$currentDate). Nested sub-field paths like `arr[A].sub[B].field`
3392
+ // ARE supported via nested arrayFilters here.
3393
+ if (!inserts && !removes && !hasNestedTerminal) {
3215
3394
  const arrayFilters = this._extractArrayFilters(update);
3216
3395
  return arrayFilters ? { update, arrayFilters } : { update };
3217
3396
  }
@@ -3277,52 +3456,42 @@ export class Mongo extends Db {
3277
3456
  // Extract single-bracket sub-field paths so we can express them as pipeline
3278
3457
  // stages instead of arrayFilters (mongo forbids arrayFilters on pipelines).
3279
3458
  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.
3459
+ // Build the recursive fieldOps structure. Top-level (single-bracket)
3460
+ // inserts/removes/sub-field updates have already been extracted by
3461
+ // the dedicated helpers; ingest them now. Remaining bracket-containing
3462
+ // keys in $set/$unset are NESTED (e.g. `arr[A].sub[B].field`) and get
3463
+ // processed below into the recursive `nestedArrayOps` structure.
3284
3464
  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
3465
  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);
3466
+ const ensureField = (container, field) => {
3467
+ if (!container.has(field))
3468
+ container.set(field, { removeIds: [], insertElements: [], elementOps: new Map() });
3469
+ return container.get(field);
3470
+ };
3471
+ const ensureElement = (fops, id) => {
3472
+ if (!fops.elementOps.has(id))
3473
+ fops.elementOps.set(id, { sets: {}, unsets: [], nestedArrayOps: new Map() });
3474
+ const elOps = fops.elementOps.get(id);
3475
+ if (!elOps.nestedArrayOps)
3476
+ elOps.nestedArrayOps = new Map();
3477
+ return elOps;
3309
3478
  };
3310
3479
  if (inserts) {
3311
3480
  for (const ins of inserts) {
3312
- const ops = ensureField(ins.field);
3481
+ const ops = ensureField(fieldOps, ins.field);
3313
3482
  ops.removeIds.push(...ins.ids);
3314
3483
  ops.insertElements.push(...ins.elements);
3315
3484
  }
3316
3485
  }
3317
3486
  if (removes) {
3318
3487
  for (const rm of removes) {
3319
- const ops = ensureField(rm.field);
3488
+ const ops = ensureField(fieldOps, rm.field);
3320
3489
  ops.removeIds.push(...rm.ids);
3321
3490
  }
3322
3491
  }
3323
3492
  if (subFieldOps) {
3324
3493
  for (const [field, idMap] of subFieldOps.entries()) {
3325
- const ops = ensureField(field);
3494
+ const ops = ensureField(fieldOps, field);
3326
3495
  for (const [id, elOps] of idMap.entries()) {
3327
3496
  // Sub-field updates are applied AFTER the post-concat array (i.e.
3328
3497
  // they overlay BOTH existing-kept elements and freshly-inserted
@@ -3330,10 +3499,173 @@ export class Mongo extends Db {
3330
3499
  // element with the insert as its base and the sub-field update
3331
3500
  // overriding matching fields. Order: non-bracket → terminal-bracket
3332
3501
  // insert/remove → sub-field overlay.
3333
- ops.elementOps.set(id, elOps);
3502
+ const target = ensureElement(ops, id);
3503
+ Object.assign(target.sets, elOps.sets);
3504
+ target.unsets.push(...elOps.unsets);
3505
+ if (elOps.replace !== undefined)
3506
+ target.replace = elOps.replace;
3507
+ }
3508
+ }
3509
+ }
3510
+ // Process REMAINING bracket-containing keys (multi-bracket / nested).
3511
+ // Each path adds entries to the recursive nestedArrayOps structure.
3512
+ const processNestedPaths = (op, opName) => {
3513
+ if (!op)
3514
+ return;
3515
+ const remaining = {};
3516
+ for (const path of Object.keys(op)) {
3517
+ if (path.indexOf('[') < 0) {
3518
+ remaining[path] = op[path];
3519
+ continue;
3520
+ }
3521
+ const tokens = Mongo._tokenizePath(path);
3522
+ const brackets = [];
3523
+ for (let i = 0; i < tokens.length; i++) {
3524
+ const t = tokens[i];
3525
+ if (t.length >= 2 && t.charCodeAt(0) === 91 && t.charCodeAt(t.length - 1) === 93) {
3526
+ brackets.push({ idx: i, id: Mongo._unquoteBracketId(t, path) });
3527
+ }
3528
+ }
3529
+ if (brackets.length < 2) {
3530
+ // Single-bracket: extractors above should have handled it; if it
3531
+ // remained, it's malformed (e.g. empty parent). Drop with warning.
3532
+ warnings.push({ path, value: op[path], op: opName, kind: 'dropped' });
3533
+ continue;
3534
+ }
3535
+ // Walk all but the innermost bracket — descend through nestedArrayOps.
3536
+ let currentMap = fieldOps;
3537
+ let ok = true;
3538
+ for (let bi = 0; bi < brackets.length - 1; bi++) {
3539
+ const bracket = brackets[bi];
3540
+ const fieldStart = bi === 0 ? 0 : brackets[bi - 1].idx + 1;
3541
+ const fieldName = tokens.slice(fieldStart, bracket.idx).join('.');
3542
+ if (!fieldName) {
3543
+ ok = false;
3544
+ break;
3545
+ }
3546
+ const fops = ensureField(currentMap, fieldName);
3547
+ const elOps = ensureElement(fops, bracket.id);
3548
+ currentMap = elOps.nestedArrayOps;
3549
+ }
3550
+ if (!ok) {
3551
+ warnings.push({ path, value: op[path], op: opName, kind: 'dropped' });
3552
+ continue;
3553
+ }
3554
+ const lastB = brackets[brackets.length - 1];
3555
+ const fieldStart = brackets[brackets.length - 2].idx + 1;
3556
+ const innerFieldName = tokens.slice(fieldStart, lastB.idx).join('.');
3557
+ const restPath = tokens.slice(lastB.idx + 1).join('.');
3558
+ if (!innerFieldName) {
3559
+ warnings.push({ path, value: op[path], op: opName, kind: 'dropped' });
3560
+ continue;
3561
+ }
3562
+ const innerFOps = ensureField(currentMap, innerFieldName);
3563
+ const value = op[path];
3564
+ if (opName === '$set') {
3565
+ if (restPath === '') {
3566
+ if (Array.isArray(value)) {
3567
+ innerFOps.removeIds.push(lastB.id);
3568
+ innerFOps.insertElements.push(...value);
3569
+ }
3570
+ else {
3571
+ // Whole-element replace at nested level.
3572
+ const elOps = ensureElement(innerFOps, lastB.id);
3573
+ elOps.replace = value;
3574
+ }
3575
+ }
3576
+ else {
3577
+ const elOps = ensureElement(innerFOps, lastB.id);
3578
+ elOps.sets[restPath] = value;
3579
+ }
3580
+ }
3581
+ else {
3582
+ // $unset
3583
+ if (restPath === '') {
3584
+ innerFOps.removeIds.push(lastB.id);
3585
+ }
3586
+ else {
3587
+ const elOps = ensureElement(innerFOps, lastB.id);
3588
+ elOps.unsets.push(restPath);
3589
+ }
3590
+ }
3591
+ }
3592
+ update[opName] = remaining;
3593
+ if (Object.keys(remaining).length === 0)
3594
+ delete update[opName];
3595
+ };
3596
+ processNestedPaths(update.$set, '$set');
3597
+ processNestedPaths(update.$unset, '$unset');
3598
+ // Detect same-id overlap at NESTED levels: a "new base" (terminal-array
3599
+ // insert OR whole-element replace) targeting the same `_id` as a
3600
+ // sub-field set/unset on the same element. Both apply — new base goes
3601
+ // first, sub-field overlay applies on top — but the unusual overlap is
3602
+ // surfaced as a `kind: 'overlap'` warning so callers know.
3603
+ //
3604
+ // Two flavors at the source of the "new base":
3605
+ // - terminal-array (`arr[id]: [...]`): id ends up in `removeIds`
3606
+ // AND `insertElements`. The matching element in elementOps will
3607
+ // carry sets/unsets if a same-id sub-field path was also present.
3608
+ // - whole-element replace (`arr[id]: {...}`): id ends up in
3609
+ // `elementOps[id].replace`. Sets/unsets co-locate on the same
3610
+ // entry when a same-id sub-field path was also present.
3611
+ //
3612
+ // Top-level same-id case is the same semantic but stays silent — it's
3613
+ // a documented, supported merge for backwards compat.
3614
+ const detectOverlaps = (container, parentPathRepr) => {
3615
+ for (const [field, fops] of container.entries()) {
3616
+ const fullField = parentPathRepr ? `${parentPathRepr}.${field}` : field;
3617
+ const isNested = parentPathRepr !== '';
3618
+ // Flavor 1: terminal-array insert + sub-field on same id.
3619
+ const removeIdSet = new Set(fops.removeIds);
3620
+ if (isNested) {
3621
+ for (const id of fops.removeIds) {
3622
+ const elOps = fops.elementOps.get(id);
3623
+ if (!elOps)
3624
+ continue;
3625
+ const hasOps = Object.keys(elOps.sets).length > 0
3626
+ || elOps.unsets.length > 0
3627
+ || elOps.replace !== undefined;
3628
+ if (hasOps) {
3629
+ const insertedEl = fops.insertElements.find((e) => String(e._id) === id);
3630
+ warnings.push({
3631
+ path: `${fullField}[${id}]`,
3632
+ value: insertedEl,
3633
+ op: '$set',
3634
+ kind: 'overlap',
3635
+ });
3636
+ }
3637
+ }
3638
+ }
3639
+ // Flavor 2: whole-element replace + sub-field on same id.
3640
+ // Skip ids already covered by Flavor 1 above (avoid double-warn).
3641
+ for (const [id, elOps] of fops.elementOps.entries()) {
3642
+ if (isNested && !removeIdSet.has(id)) {
3643
+ const hasReplace = elOps.replace !== undefined;
3644
+ const hasSetsOrUnsets = Object.keys(elOps.sets).length > 0
3645
+ || elOps.unsets.length > 0;
3646
+ if (hasReplace && hasSetsOrUnsets) {
3647
+ warnings.push({
3648
+ path: `${fullField}[${id}]`,
3649
+ value: elOps.replace,
3650
+ op: '$set',
3651
+ kind: 'overlap',
3652
+ });
3653
+ }
3654
+ }
3655
+ if (elOps.nestedArrayOps && elOps.nestedArrayOps.size > 0) {
3656
+ detectOverlaps(elOps.nestedArrayOps, `${fullField}[${id}]`);
3657
+ }
3334
3658
  }
3335
3659
  }
3660
+ };
3661
+ detectOverlaps(fieldOps, '');
3662
+ if (warnings.length > 0) {
3663
+ fjLog.warn('cry-db: bracket-by-_id warnings:', warnings);
3336
3664
  }
3665
+ if (update.$set && Object.keys(update.$set).length === 0)
3666
+ delete update.$set;
3667
+ if (update.$unset && Object.keys(update.$unset).length === 0)
3668
+ delete update.$unset;
3337
3669
  const pipeline = [];
3338
3670
  if (update.$set && Object.keys(update.$set).length > 0) {
3339
3671
  pipeline.push({ $set: update.$set });
@@ -3344,52 +3676,13 @@ export class Mongo extends Db {
3344
3676
  // Translate $inc / $currentDate so revisions (_rev/_ts) still update in pipeline form.
3345
3677
  for (const stage of Mongo._drainIncAndCurrentDateToPipelineStages(update))
3346
3678
  pipeline.push(stage);
3679
+ // Build one $set stage per parent array field. The filter+concat+map+switch
3680
+ // expression (with potentially recursive nested-array transforms inside
3681
+ // per-element overlays) is built by `_buildArrayTransformExpr`.
3347
3682
  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
3683
  pipeline.push({
3391
3684
  $set: {
3392
- [field]: resultExpr,
3685
+ [field]: Mongo._buildArrayTransformExpr(`$${field}`, ops),
3393
3686
  },
3394
3687
  });
3395
3688
  }