cry-db 2.4.33 → 2.4.35
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 +46 -0
- package/dist/mongo.d.mts +97 -20
- package/dist/mongo.d.mts.map +1 -1
- package/dist/mongo.mjs +423 -81
- package/dist/mongo.mjs.map +1 -1
- package/dist/types.d.mts +15 -0
- package/dist/types.d.mts.map +1 -1
- package/dist/types.mjs.map +1 -1
- package/package.json +1 -1
package/dist/mongo.mjs
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// AI modified: 2026-05-10 (`updateMany` publish payload: `rawUpdate` izpuščen — mongo ne vrne seznama prizadetih `_id`-jev iz `updateMany`, zato spec sam po sebi za consumer-ja ni uporaben. `update` (single-doc) in batch update item-i ga še vedno nosijo, ker tam `_id` poznamo.)
|
|
2
|
+
// AI modified: 2026-05-10 (publish payload za update: `data` je sedaj očiščen (brez mongo operatorjev `$set`/`$unset`/`$pull`/`$[…]`); polja prizadeta z bracket potmi se objavijo kot CELOTNI parent array (sliced iz post-update dokumenta, brez diff-a). Nov `rawUpdate` field na publish payload-u nosi update kot ga je klient poslal (z bracket potmi nedotaknjenimi). Prisoten na update in batch update dogodkih.)
|
|
3
|
+
// 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.)
|
|
4
|
+
// 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.)
|
|
1
5
|
// 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
6
|
// AI modified: 2026-05-09 (terminal bracket-id `arr[<id>]` z array vrednostjo → idempotent insert preko aggregation pipeline ($concatArrays + $filter); quoted bracket id `['p0']`/`["p0"]` zdaj enak unquoted `[p0]`)
|
|
3
7
|
// AI modified: 2026-04-25 (GetNewerSpec.specId — disambiguate duplicate-collection specs in findNewerMany / findNewerManyStream)
|
|
@@ -634,6 +638,11 @@ export class Mongo extends Db {
|
|
|
634
638
|
};
|
|
635
639
|
if (this._hasHashedKeys(update))
|
|
636
640
|
await this._processHashedKeys(update);
|
|
641
|
+
// Snapshot the user-form update BEFORE `_processUpdateObject` so the
|
|
642
|
+
// bracket form survives bracket translation AND no auto-injected mongo
|
|
643
|
+
// operators (`$inc: { _rev }`, `$currentDate: { _ts }`) end up in the
|
|
644
|
+
// publish payload's `rawUpdate`.
|
|
645
|
+
const inputUpdate = cloneDeep(update);
|
|
637
646
|
update = this._processUpdateObject(update);
|
|
638
647
|
const processed = this._applyBracketProcessing(update);
|
|
639
648
|
if (processed.arrayFilters)
|
|
@@ -654,8 +663,8 @@ export class Mongo extends Db {
|
|
|
654
663
|
let res = await conn.findOneAndUpdate(query, processed.update, opts);
|
|
655
664
|
if (!res)
|
|
656
665
|
return null;
|
|
657
|
-
let resObj =
|
|
658
|
-
await this._publishAndAudit('update', dbName, collection, resObj);
|
|
666
|
+
let resObj = this._buildPublishDelta(res, inputUpdate, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
667
|
+
await this._publishAndAudit('update', dbName, collection, resObj, false, inputUpdate);
|
|
659
668
|
return resObj;
|
|
660
669
|
}, !!seqKeys, { operation: "updateOne", collection, query, update: processed.update, options });
|
|
661
670
|
fjLog.debug('updateOne returns', obj);
|
|
@@ -674,6 +683,7 @@ export class Mongo extends Db {
|
|
|
674
683
|
let _id = Mongo.toId(id || update._id) || Mongo.newid();
|
|
675
684
|
if (this._hasHashedKeys(update))
|
|
676
685
|
await this._processHashedKeys(update);
|
|
686
|
+
const inputUpdate = cloneDeep(update);
|
|
677
687
|
update = this._processUpdateObject(update);
|
|
678
688
|
const processed = this._applyBracketProcessing(update);
|
|
679
689
|
if (processed.arrayFilters)
|
|
@@ -692,8 +702,8 @@ export class Mongo extends Db {
|
|
|
692
702
|
let res = await conn.findOneAndUpdate({ _id }, processed.update, opts);
|
|
693
703
|
if (!res)
|
|
694
704
|
return null;
|
|
695
|
-
let resObj =
|
|
696
|
-
await this._publishAndAudit('update', dbName, collection, resObj);
|
|
705
|
+
let resObj = this._buildPublishDelta(res, inputUpdate, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
706
|
+
await this._publishAndAudit('update', dbName, collection, resObj, false, inputUpdate);
|
|
697
707
|
return resObj;
|
|
698
708
|
}, !!seqKeys, { operation: "save", collection, _id, update: processed.update, options });
|
|
699
709
|
fjLog.debug('save returns', obj);
|
|
@@ -737,6 +747,9 @@ export class Mongo extends Db {
|
|
|
737
747
|
n: res.modifiedCount,
|
|
738
748
|
ok: !!res.acknowledged
|
|
739
749
|
};
|
|
750
|
+
// No `rawUpdate` for `updateMany` — mongo doesn't return per-doc _id-s,
|
|
751
|
+
// so the spec is not actionable on the consumer side. Subscribers see
|
|
752
|
+
// `enquireLastTs: true` on the rev event and refetch via `findNewer*`.
|
|
740
753
|
await this._publishAndAudit('updateMany', dbName, collection, resObj);
|
|
741
754
|
return resObj;
|
|
742
755
|
}, !!seqKeys, { operation: "update", collection, query, update: processed.update });
|
|
@@ -761,6 +774,7 @@ export class Mongo extends Db {
|
|
|
761
774
|
fjLog.debug('upsert called', collection, query, update);
|
|
762
775
|
if (this._hasHashedKeys(update))
|
|
763
776
|
await this._processHashedKeys(update);
|
|
777
|
+
const inputUpdate = cloneDeep(update);
|
|
764
778
|
update = this._processUpdateObject(update);
|
|
765
779
|
const processed = this._applyBracketProcessing(update);
|
|
766
780
|
if (processed.arrayFilters)
|
|
@@ -783,10 +797,12 @@ export class Mongo extends Db {
|
|
|
783
797
|
// Detect if this was an insert or update by checking _rev
|
|
784
798
|
const isInsert = this.revisions && ret._rev === 1;
|
|
785
799
|
let oper = isInsert ? "insert" : "update";
|
|
786
|
-
// For inserts,
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
800
|
+
// For inserts, surface the full record (query fields like {a:4} need to land);
|
|
801
|
+
// for updates, build the de-bracketed delta. rawUpdate is sent on update only —
|
|
802
|
+
// an insert isn't a delta against a prior state, so the spec adds no signal there.
|
|
803
|
+
let retObj = isInsert ? ret : this._buildPublishDelta(ret, inputUpdate, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
804
|
+
await this._publishAndAudit(oper, dbName, collection, retObj, false, isInsert ? undefined : inputUpdate);
|
|
805
|
+
return this._buildPublishDelta(ret, inputUpdate, !!(options === null || options === void 0 ? void 0 : options.returnFullObject));
|
|
790
806
|
}
|
|
791
807
|
;
|
|
792
808
|
return ret;
|
|
@@ -866,9 +882,9 @@ export class Mongo extends Db {
|
|
|
866
882
|
update = this.replaceIds(update);
|
|
867
883
|
if (this._hasHashedKeys(update))
|
|
868
884
|
await this._processHashedKeys(update);
|
|
885
|
+
const inputUpdate = cloneDeep(update);
|
|
869
886
|
const processedBase = this._processUpdateObject({ ...update });
|
|
870
887
|
const processed = this._applyBracketProcessing(processedBase);
|
|
871
|
-
const isPipeline = Array.isArray(processed.update);
|
|
872
888
|
const opts = {
|
|
873
889
|
upsert: true,
|
|
874
890
|
returnDocument: "after",
|
|
@@ -884,12 +900,15 @@ export class Mongo extends Db {
|
|
|
884
900
|
// Determine if this was an insert or update based on lastErrorObject
|
|
885
901
|
const wasInsert = !((_a = res.lastErrorObject) === null || _a === void 0 ? void 0 : _a.updatedExisting);
|
|
886
902
|
const operation = wasInsert ? 'insert' : 'update';
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
|
|
903
|
+
// Inserts surface the full doc (query fields must reach the client).
|
|
904
|
+
// Updates always go through _buildPublishDelta so bracket keys collapse
|
|
905
|
+
// to top-level parent array slices read straight from the post-update
|
|
906
|
+
// doc — non-bracket keys keep the legacy literal-key behavior.
|
|
907
|
+
const retObj = wasInsert
|
|
908
|
+
? res.value
|
|
909
|
+
: this._buildPublishDelta(res.value, inputUpdate, false);
|
|
891
910
|
this._processReturnedObject(retObj);
|
|
892
|
-
return { success: true, _id, data: retObj, operation, wasInsert };
|
|
911
|
+
return { success: true, _id, data: retObj, operation, wasInsert, rawUpdate: wasInsert ? undefined : inputUpdate };
|
|
893
912
|
}
|
|
894
913
|
return { success: false, _id, error: 'Operation failed' };
|
|
895
914
|
}
|
|
@@ -911,7 +930,11 @@ export class Mongo extends Db {
|
|
|
911
930
|
else {
|
|
912
931
|
result.results.updated.push(dbEntity);
|
|
913
932
|
}
|
|
914
|
-
|
|
933
|
+
const item = { operation: res.operation, data: res.data };
|
|
934
|
+
// rawUpdate is meaningful only for the update branch — inserts publish the full doc.
|
|
935
|
+
if (!res.wasInsert)
|
|
936
|
+
item.rawUpdate = res.rawUpdate;
|
|
937
|
+
batchData.push(item);
|
|
915
938
|
}
|
|
916
939
|
else if (!res.success && res.error) {
|
|
917
940
|
errors.push({ _id: String(res._id), error: res.error });
|
|
@@ -1050,11 +1073,11 @@ export class Mongo extends Db {
|
|
|
1050
1073
|
};
|
|
1051
1074
|
if (this._hasHashedKeys(update))
|
|
1052
1075
|
await this._processHashedKeys(update);
|
|
1076
|
+
const inputUpdate = cloneDeep(update);
|
|
1053
1077
|
const processedBase = this._processUpdateObject({ ...update });
|
|
1054
1078
|
const processed = this._applyBracketProcessing(processedBase);
|
|
1055
1079
|
if (processed.arrayFilters)
|
|
1056
1080
|
options.arrayFilters = processed.arrayFilters;
|
|
1057
|
-
const isPipeline = Array.isArray(processed.update);
|
|
1058
1081
|
const result = await conn.findOneAndUpdate(query, processed.update, options);
|
|
1059
1082
|
if (!((_a = result === null || result === void 0 ? void 0 : result.value) === null || _a === void 0 ? void 0 : _a._id))
|
|
1060
1083
|
return null;
|
|
@@ -1070,9 +1093,11 @@ export class Mongo extends Db {
|
|
|
1070
1093
|
else {
|
|
1071
1094
|
oper = "insert";
|
|
1072
1095
|
}
|
|
1073
|
-
const retObj = oper === "insert"
|
|
1096
|
+
const retObj = oper === "insert"
|
|
1097
|
+
? ret
|
|
1098
|
+
: this._buildPublishDelta(ret, inputUpdate, !!(opts === null || opts === void 0 ? void 0 : opts.returnFullObject));
|
|
1074
1099
|
this._processReturnedObject(retObj);
|
|
1075
|
-
return { operation: oper, data: retObj };
|
|
1100
|
+
return { operation: oper, data: retObj, rawUpdate: oper === 'update' ? inputUpdate : undefined };
|
|
1076
1101
|
});
|
|
1077
1102
|
const results = await Promise.all(upsertPromises);
|
|
1078
1103
|
// Filter out nulls and separate batchData and changes
|
|
@@ -1080,7 +1105,10 @@ export class Mongo extends Db {
|
|
|
1080
1105
|
const changes = [];
|
|
1081
1106
|
for (const result of results) {
|
|
1082
1107
|
if (result) {
|
|
1083
|
-
|
|
1108
|
+
const item = { operation: result.operation, data: result.data };
|
|
1109
|
+
if (result.rawUpdate)
|
|
1110
|
+
item.rawUpdate = result.rawUpdate;
|
|
1111
|
+
batchData.push(item);
|
|
1084
1112
|
changes.push(result.data);
|
|
1085
1113
|
}
|
|
1086
1114
|
}
|
|
@@ -2178,19 +2206,50 @@ export class Mongo extends Db {
|
|
|
2178
2206
|
}
|
|
2179
2207
|
return into;
|
|
2180
2208
|
}
|
|
2181
|
-
|
|
2209
|
+
/**
|
|
2210
|
+
* Build the publish-data slice for an update result. Bracket paths in
|
|
2211
|
+
* `inputUpdate` are mapped to their top-level parent array field, whose
|
|
2212
|
+
* post-update value is taken straight from `wholerecord` (the document
|
|
2213
|
+
* `findOneAndUpdate` returned with `returnDocument: "after"`). Nothing
|
|
2214
|
+
* is synthesized — we only slice.
|
|
2215
|
+
*
|
|
2216
|
+
* Non-bracket keys keep the legacy literal-key lookup, so a mixed update
|
|
2217
|
+
* like `{ name: 'X', 'arr[id].f': 1 }` still publishes top-level scalars
|
|
2218
|
+
* the way it always has, while bracket-keyed paths now collapse to a
|
|
2219
|
+
* top-level parent slice that downstream consumers can apply directly.
|
|
2220
|
+
*
|
|
2221
|
+
* `inputUpdate` is the user-form snapshot taken AFTER `_processHashedKeys`
|
|
2222
|
+
* and BEFORE `_processUpdateObject` — keys may be in top-level shorthand
|
|
2223
|
+
* (`{ "arr[id]": v }`) or already wrapped in `$set`/`$unset`/etc.
|
|
2224
|
+
*/
|
|
2225
|
+
_buildPublishDelta(wholerecord, inputUpdate, returnFullObject) {
|
|
2182
2226
|
if (returnFullObject)
|
|
2183
2227
|
return wholerecord;
|
|
2184
|
-
let a = {};
|
|
2185
2228
|
wholerecord = wholerecord || {};
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2229
|
+
const out = {};
|
|
2230
|
+
const keys = this._getFieldsRecursively(inputUpdate);
|
|
2231
|
+
for (const k of keys) {
|
|
2232
|
+
const bracketIdx = k.indexOf('[');
|
|
2233
|
+
if (bracketIdx < 0) {
|
|
2234
|
+
out[k] = wholerecord[k];
|
|
2235
|
+
continue;
|
|
2236
|
+
}
|
|
2237
|
+
// Bracket path: publish the top-level parent field as a whole,
|
|
2238
|
+
// sliced from the post-update document. The "top-level parent" is
|
|
2239
|
+
// everything before the FIRST `.` or `[` separator.
|
|
2240
|
+
const dotIdx = k.indexOf('.');
|
|
2241
|
+
const cutAt = dotIdx >= 0 && dotIdx < bracketIdx ? dotIdx : bracketIdx;
|
|
2242
|
+
const topField = k.substring(0, cutAt);
|
|
2243
|
+
if (topField && !(topField in out))
|
|
2244
|
+
out[topField] = wholerecord[topField];
|
|
2245
|
+
}
|
|
2246
|
+
if (wholerecord._rev != null)
|
|
2247
|
+
out._rev = wholerecord._rev;
|
|
2248
|
+
if (wholerecord._ts != null)
|
|
2249
|
+
out._ts = wholerecord._ts;
|
|
2250
|
+
if (wholerecord._id != null)
|
|
2251
|
+
out._id = wholerecord._id;
|
|
2252
|
+
return out;
|
|
2194
2253
|
}
|
|
2195
2254
|
_shouldAuditCollection(db, col) {
|
|
2196
2255
|
if (!this.auditing)
|
|
@@ -2198,7 +2257,7 @@ export class Mongo extends Db {
|
|
|
2198
2257
|
const fullName = ((db ? db + "." : "") + (col || "")).toLowerCase();
|
|
2199
2258
|
return this.auditedCollectionsLower.some(m => fullName.includes(m));
|
|
2200
2259
|
}
|
|
2201
|
-
async _publishAndAudit(operation, db, collection, dataToPublish, noEmit) {
|
|
2260
|
+
async _publishAndAudit(operation, db, collection, dataToPublish, noEmit, rawUpdate) {
|
|
2202
2261
|
if (!dataToPublish._id && !["deleteMany", "updateMany"].includes(operation))
|
|
2203
2262
|
throw new Error(`_publishAndAudit requires _id for ${operation}`);
|
|
2204
2263
|
let data = cloneDeep(dataToPublish);
|
|
@@ -2212,7 +2271,7 @@ export class Mongo extends Db {
|
|
|
2212
2271
|
let toPublishAll = [];
|
|
2213
2272
|
this._processReturnedObject(data, this._findNewerRemoveFields);
|
|
2214
2273
|
if (this.emittingPublishEvents && data) {
|
|
2215
|
-
const toPublish = this._makeDataPublication(db, collection, data, operation);
|
|
2274
|
+
const toPublish = this._makeDataPublication(db, collection, data, operation, rawUpdate);
|
|
2216
2275
|
if (toPublish) {
|
|
2217
2276
|
if (!noEmit)
|
|
2218
2277
|
this.emit("publish", toPublish);
|
|
@@ -2233,15 +2292,18 @@ export class Mongo extends Db {
|
|
|
2233
2292
|
}
|
|
2234
2293
|
return toPublishAll;
|
|
2235
2294
|
}
|
|
2236
|
-
_makeDataPublication(db, collection, data, operation) {
|
|
2295
|
+
_makeDataPublication(db, collection, data, operation, rawUpdate) {
|
|
2296
|
+
const payload = {
|
|
2297
|
+
db,
|
|
2298
|
+
collection,
|
|
2299
|
+
operation,
|
|
2300
|
+
data,
|
|
2301
|
+
};
|
|
2302
|
+
if (rawUpdate)
|
|
2303
|
+
payload.rawUpdate = rawUpdate;
|
|
2237
2304
|
let toPublish = {
|
|
2238
2305
|
channel: `db/${db}/${collection}${data._id ? "/" + data._id.toString() : ""}`,
|
|
2239
|
-
payload:
|
|
2240
|
-
db,
|
|
2241
|
-
collection,
|
|
2242
|
-
operation,
|
|
2243
|
-
data,
|
|
2244
|
-
}
|
|
2306
|
+
payload: payload,
|
|
2245
2307
|
};
|
|
2246
2308
|
if (data && data._auth)
|
|
2247
2309
|
toPublish.user = data._auth.username;
|
|
@@ -2585,21 +2647,25 @@ export class Mongo extends Db {
|
|
|
2585
2647
|
return id;
|
|
2586
2648
|
}
|
|
2587
2649
|
/**
|
|
2588
|
-
* Pre-processing pass that VALIDATES
|
|
2589
|
-
*
|
|
2590
|
-
*
|
|
2650
|
+
* Pre-processing pass that VALIDATES element `_id` for terminal bracket-id
|
|
2651
|
+
* paths in `$set` whose value is an object (replace) or array of objects
|
|
2652
|
+
* (insert).
|
|
2591
2653
|
*
|
|
2592
|
-
*
|
|
2654
|
+
* Two rules enforced:
|
|
2593
2655
|
* 1) `_id` in brackets must be non-empty (empty → throw via `_unquoteBracketId`)
|
|
2594
|
-
* 2)
|
|
2595
|
-
*
|
|
2656
|
+
* 2) every element MUST carry an `_id` that equals the bracket id
|
|
2657
|
+
* (missing or mismatched → throw, prevents data corruption / silent renaming)
|
|
2596
2658
|
*
|
|
2597
|
-
*
|
|
2598
|
-
*
|
|
2599
|
-
* not a typical insert/replace shape).
|
|
2659
|
+
* Throws on violation. Skips primitive values (caller knows what they're
|
|
2660
|
+
* doing) and `null` (rare, not a typical insert/replace shape).
|
|
2600
2661
|
*
|
|
2601
2662
|
* Called BEFORE `_extractArrayInserts`/`_extractArrayRemoves`/`_extractArrayFilters`
|
|
2602
|
-
* so downstream code can assume
|
|
2663
|
+
* so downstream code can assume every element has an `_id`.
|
|
2664
|
+
*
|
|
2665
|
+
* Note: auto-fill of missing `_id` from the bracket id was previously supported
|
|
2666
|
+
* but was removed because it hid client bugs (e.g. constructing an insert with
|
|
2667
|
+
* the wrong field name and silently writing an element identified by the bracket).
|
|
2668
|
+
* Always set `_id` explicitly on the client.
|
|
2603
2669
|
*/
|
|
2604
2670
|
_validateAndAutoFillTerminalBracketValues(update) {
|
|
2605
2671
|
const $set = update.$set;
|
|
@@ -2630,10 +2696,10 @@ export class Mongo extends Db {
|
|
|
2630
2696
|
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
2697
|
}
|
|
2632
2698
|
if (el._id == null) {
|
|
2633
|
-
|
|
2699
|
+
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).`);
|
|
2634
2700
|
}
|
|
2635
|
-
|
|
2636
|
-
throw new Error(`cry-db: bracket-id terminal at "${path}"${locator} — element _id "${el._id}" does not match bracket id "${bracketId}"
|
|
2701
|
+
if (String(el._id) !== bracketId) {
|
|
2702
|
+
throw new Error(`cry-db: bracket-id terminal at "${path}"${locator} — element _id "${el._id}" does not match bracket id "${bracketId}".`);
|
|
2637
2703
|
}
|
|
2638
2704
|
};
|
|
2639
2705
|
if (Array.isArray(value)) {
|
|
@@ -2780,11 +2846,234 @@ export class Mongo extends Db {
|
|
|
2780
2846
|
delete update.$unset;
|
|
2781
2847
|
return Array.from(removesByField.entries()).map(([field, ids]) => ({ field, ids }));
|
|
2782
2848
|
}
|
|
2849
|
+
/**
|
|
2850
|
+
* Extracts SINGLE-bracket bracket-by-_id paths from `$set`/`$unset`, grouped
|
|
2851
|
+
* by `(parentArrayField, elementId)`. Used when pipeline form is needed
|
|
2852
|
+
* (because terminal-bracket inserts/removes are present) — these paths must
|
|
2853
|
+
* be expressed as `$map` + `$mergeObjects` stages, since mongo does not
|
|
2854
|
+
* allow `arrayFilters` to coexist with aggregation pipelines.
|
|
2855
|
+
*
|
|
2856
|
+
* Path forms detected (mutated out of `$set`/`$unset`):
|
|
2857
|
+
* - `$set` `arr[id].field` → sets["field"] = value (sub-field set)
|
|
2858
|
+
* - `$set` `arr[id].sub.field` → sets["sub.field"] = value (deep sub-field set)
|
|
2859
|
+
* - `$set` `arr[id]` (object value) → replace = value (whole-element replace)
|
|
2860
|
+
* - `$unset` `arr[id].field` → unsets.push("field") (sub-field unset)
|
|
2861
|
+
*
|
|
2862
|
+
* NOT detected (left in `$set`/`$unset` for caller to handle):
|
|
2863
|
+
* - paths with NESTED brackets (e.g. `arr[A].sub[B].field`) — caller must
|
|
2864
|
+
* reject these when in pipeline mode (still unsupported).
|
|
2865
|
+
* - terminal `arr[id]` already removed by `_extractArrayInserts`/`_extractArrayRemoves`.
|
|
2866
|
+
*
|
|
2867
|
+
* Mutates `update.$set` and `update.$unset`. Returns the grouped ops, or
|
|
2868
|
+
* `undefined` if no extractable paths were present.
|
|
2869
|
+
*/
|
|
2870
|
+
_extractArraySubFieldUpdates(update) {
|
|
2871
|
+
const result = new Map();
|
|
2872
|
+
let extractedAny = false;
|
|
2873
|
+
const ensureEntry = (parentField, id) => {
|
|
2874
|
+
if (!result.has(parentField))
|
|
2875
|
+
result.set(parentField, new Map());
|
|
2876
|
+
const idMap = result.get(parentField);
|
|
2877
|
+
if (!idMap.has(id))
|
|
2878
|
+
idMap.set(id, { sets: {}, unsets: [] });
|
|
2879
|
+
return idMap.get(id);
|
|
2880
|
+
};
|
|
2881
|
+
const processOp = (opName) => {
|
|
2882
|
+
const op = update[opName];
|
|
2883
|
+
if (!op || typeof op !== 'object')
|
|
2884
|
+
return;
|
|
2885
|
+
let hasBracket = false;
|
|
2886
|
+
for (const k of Object.keys(op)) {
|
|
2887
|
+
if (k.indexOf('[') >= 0) {
|
|
2888
|
+
hasBracket = true;
|
|
2889
|
+
break;
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
if (!hasBracket)
|
|
2893
|
+
return;
|
|
2894
|
+
const remaining = {};
|
|
2895
|
+
let mutated = false;
|
|
2896
|
+
for (const path of Object.keys(op)) {
|
|
2897
|
+
const tokens = Mongo._tokenizePath(path);
|
|
2898
|
+
let bracketCount = 0;
|
|
2899
|
+
let bracketIdx = -1;
|
|
2900
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
2901
|
+
const t = tokens[i];
|
|
2902
|
+
if (t.length >= 2 && t.charCodeAt(0) === 91 /* [ */ && t.charCodeAt(t.length - 1) === 93 /* ] */) {
|
|
2903
|
+
bracketCount++;
|
|
2904
|
+
bracketIdx = i;
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
if (bracketCount === 1 && bracketIdx > 0) {
|
|
2908
|
+
const parentField = tokens.slice(0, bracketIdx).join('.');
|
|
2909
|
+
const id = Mongo._unquoteBracketId(tokens[bracketIdx], path);
|
|
2910
|
+
const restPath = tokens.slice(bracketIdx + 1).join('.');
|
|
2911
|
+
const elOps = ensureEntry(parentField, id);
|
|
2912
|
+
if (opName === '$set') {
|
|
2913
|
+
if (restPath === '') {
|
|
2914
|
+
// `arr[id]: <obj>` — whole-element replace.
|
|
2915
|
+
elOps.replace = op[path];
|
|
2916
|
+
}
|
|
2917
|
+
else {
|
|
2918
|
+
elOps.sets[restPath] = op[path];
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
else {
|
|
2922
|
+
// `$unset arr[id].field` — sub-field unset. Terminal `$unset arr[id]`
|
|
2923
|
+
// is already extracted by `_extractArrayRemoves`.
|
|
2924
|
+
if (restPath === '') {
|
|
2925
|
+
remaining[path] = op[path];
|
|
2926
|
+
continue;
|
|
2927
|
+
}
|
|
2928
|
+
elOps.unsets.push(restPath);
|
|
2929
|
+
}
|
|
2930
|
+
mutated = true;
|
|
2931
|
+
extractedAny = true;
|
|
2932
|
+
continue;
|
|
2933
|
+
}
|
|
2934
|
+
remaining[path] = op[path];
|
|
2935
|
+
}
|
|
2936
|
+
if (mutated) {
|
|
2937
|
+
update[opName] = remaining;
|
|
2938
|
+
if (Object.keys(remaining).length === 0)
|
|
2939
|
+
delete update[opName];
|
|
2940
|
+
}
|
|
2941
|
+
};
|
|
2942
|
+
processOp('$set');
|
|
2943
|
+
processOp('$unset');
|
|
2944
|
+
return extractedAny ? result : undefined;
|
|
2945
|
+
}
|
|
2946
|
+
/**
|
|
2947
|
+
* Build an aggregation expression that produces a transformed copy of an
|
|
2948
|
+
* element, applying the given sets/unsets and (optionally) a whole-element
|
|
2949
|
+
* replacement.
|
|
2950
|
+
*
|
|
2951
|
+
* - `replace`: when set, becomes the new base (full element replacement).
|
|
2952
|
+
* - `sets`: dot-paths within element → values; deep-merged via recursive
|
|
2953
|
+
* `$mergeObjects`/`$ifNull` so nested updates preserve sibling fields.
|
|
2954
|
+
* - `unsets`: dot-paths within element to remove. Implemented via
|
|
2955
|
+
* `$arrayToObject($filter($objectToArray(merged), kv.k notIn unsets))` —
|
|
2956
|
+
* `$$REMOVE` inside `$mergeObjects` does NOT drop the field (mongo treats
|
|
2957
|
+
* it as a missing value and falls through to the previous operand), so
|
|
2958
|
+
* the explicit kv-filter is required.
|
|
2959
|
+
*
|
|
2960
|
+
* The returned expression is suitable as the `in:` argument of `$map`.
|
|
2961
|
+
*/
|
|
2962
|
+
static _buildElementMergeExpr(baseExpr, ops) {
|
|
2963
|
+
const hasSets = Object.keys(ops.sets).length > 0;
|
|
2964
|
+
const hasUnsets = ops.unsets.length > 0;
|
|
2965
|
+
const hasReplace = ops.replace !== undefined;
|
|
2966
|
+
if (!hasSets && !hasUnsets && !hasReplace)
|
|
2967
|
+
return baseExpr;
|
|
2968
|
+
if (!hasSets && !hasUnsets) {
|
|
2969
|
+
// Pure replace — embed as literal so client-supplied field names like
|
|
2970
|
+
// "_id" don't get interpreted as field paths.
|
|
2971
|
+
return { $literal: ops.replace };
|
|
2972
|
+
}
|
|
2973
|
+
const root = { directs: {}, nested: {}, unsets: [] };
|
|
2974
|
+
const walkInsert = (path, isUnset, value) => {
|
|
2975
|
+
const parts = path.split('.');
|
|
2976
|
+
let node = root;
|
|
2977
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
2978
|
+
const key = parts[i];
|
|
2979
|
+
if (!node.nested[key])
|
|
2980
|
+
node.nested[key] = { directs: {}, nested: {}, unsets: [] };
|
|
2981
|
+
node = node.nested[key];
|
|
2982
|
+
}
|
|
2983
|
+
const leaf = parts[parts.length - 1];
|
|
2984
|
+
if (isUnset)
|
|
2985
|
+
node.unsets.push(leaf);
|
|
2986
|
+
else
|
|
2987
|
+
node.directs[leaf] = value;
|
|
2988
|
+
};
|
|
2989
|
+
for (const [p, v] of Object.entries(ops.sets))
|
|
2990
|
+
walkInsert(p, false, v);
|
|
2991
|
+
for (const p of ops.unsets)
|
|
2992
|
+
walkInsert(p, true);
|
|
2993
|
+
// Recursively build merge expression. `basePath` is a string field path
|
|
2994
|
+
// (e.g. "$$el", "$$el.sub") used to read the existing nested value.
|
|
2995
|
+
const buildOverlay = (node, basePath) => {
|
|
2996
|
+
const overlay = {};
|
|
2997
|
+
for (const [k, v] of Object.entries(node.directs))
|
|
2998
|
+
overlay[k] = v;
|
|
2999
|
+
for (const [k, sub] of Object.entries(node.nested)) {
|
|
3000
|
+
const subPath = `${basePath}.${k}`;
|
|
3001
|
+
overlay[k] = buildOverlay(sub, subPath);
|
|
3002
|
+
}
|
|
3003
|
+
// Merge sets/nested onto the existing object (or {} if absent).
|
|
3004
|
+
let merged = { $mergeObjects: [{ $ifNull: [basePath, {}] }, overlay] };
|
|
3005
|
+
// Drop unset top-level keys at this level via kv-filter.
|
|
3006
|
+
if (node.unsets.length > 0) {
|
|
3007
|
+
merged = {
|
|
3008
|
+
$arrayToObject: {
|
|
3009
|
+
$filter: {
|
|
3010
|
+
input: { $objectToArray: merged },
|
|
3011
|
+
as: 'kv',
|
|
3012
|
+
cond: { $not: { $in: ['$$kv.k', node.unsets] } },
|
|
3013
|
+
},
|
|
3014
|
+
},
|
|
3015
|
+
};
|
|
3016
|
+
}
|
|
3017
|
+
return merged;
|
|
3018
|
+
};
|
|
3019
|
+
// If a replace is also requested, treat replacement as the new base.
|
|
3020
|
+
// Wrap with `$let` since a literal object isn't addressable by string field path.
|
|
3021
|
+
if (hasReplace) {
|
|
3022
|
+
const overlayExpr = buildOverlay(root, '$$replaced');
|
|
3023
|
+
return {
|
|
3024
|
+
$let: {
|
|
3025
|
+
vars: { replaced: { $literal: ops.replace } },
|
|
3026
|
+
in: overlayExpr,
|
|
3027
|
+
},
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
return buildOverlay(root, baseExpr);
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Translate an update doc's `$inc` and `$currentDate` operators into
|
|
3034
|
+
* pipeline-stage equivalents, so revisions (`_rev` increment, `_ts` timestamp)
|
|
3035
|
+
* still fire when the rest of the update goes through aggregation pipeline.
|
|
3036
|
+
*
|
|
3037
|
+
* - `$inc: { f: n }` → `{ $set: { f: { $add: [{ $ifNull: ['$f', 0] }, n] } } }`
|
|
3038
|
+
* - `$currentDate: { f: true }` → `{ $set: { f: '$$NOW' } }` (Date)
|
|
3039
|
+
* - `$currentDate: { f: { $type: 'date' } }` → same as above
|
|
3040
|
+
* - `$currentDate: { f: { $type: 'timestamp' } }` → `{ $set: { f: '$$CLUSTER_TIME' } }` (Timestamp)
|
|
3041
|
+
*
|
|
3042
|
+
* Mutates `update` (deletes `$inc` and `$currentDate`). Returns 0..2 pipeline stages.
|
|
3043
|
+
*/
|
|
3044
|
+
static _drainIncAndCurrentDateToPipelineStages(update) {
|
|
3045
|
+
const stages = [];
|
|
3046
|
+
const inc = update.$inc;
|
|
3047
|
+
if (inc && typeof inc === 'object') {
|
|
3048
|
+
const setObj = {};
|
|
3049
|
+
for (const [k, v] of Object.entries(inc)) {
|
|
3050
|
+
setObj[k] = { $add: [{ $ifNull: [`$${k}`, 0] }, v] };
|
|
3051
|
+
}
|
|
3052
|
+
if (Object.keys(setObj).length > 0)
|
|
3053
|
+
stages.push({ $set: setObj });
|
|
3054
|
+
delete update.$inc;
|
|
3055
|
+
}
|
|
3056
|
+
const cd = update.$currentDate;
|
|
3057
|
+
if (cd && typeof cd === 'object') {
|
|
3058
|
+
const setObj = {};
|
|
3059
|
+
for (const [k, spec] of Object.entries(cd)) {
|
|
3060
|
+
let isTimestamp = false;
|
|
3061
|
+
if (spec && typeof spec === 'object' && spec.$type === 'timestamp')
|
|
3062
|
+
isTimestamp = true;
|
|
3063
|
+
setObj[k] = isTimestamp ? '$$CLUSTER_TIME' : '$$NOW';
|
|
3064
|
+
}
|
|
3065
|
+
if (Object.keys(setObj).length > 0)
|
|
3066
|
+
stages.push({ $set: setObj });
|
|
3067
|
+
delete update.$currentDate;
|
|
3068
|
+
}
|
|
3069
|
+
return stages;
|
|
3070
|
+
}
|
|
2783
3071
|
/**
|
|
2784
3072
|
* Combined bracket-path processing: extracts inserts (`arr[id]: [els]`),
|
|
2785
|
-
* removes (`arr[id]: undefined` → `$unset`),
|
|
2786
|
-
*
|
|
2787
|
-
*
|
|
3073
|
+
* removes (`arr[id]: undefined` → `$unset`), sub-field updates
|
|
3074
|
+
* (`arr[id].field`, `arr[id].sub.field`, `arr[id]: <obj>`) and arrayFilters.
|
|
3075
|
+
* Decides whether mongo update should be sent as a regular doc or as an
|
|
3076
|
+
* aggregation pipeline.
|
|
2788
3077
|
*
|
|
2789
3078
|
* Returns `{ update, arrayFilters? }`:
|
|
2790
3079
|
* - `update` is the original update doc OR an aggregation pipeline.
|
|
@@ -2794,13 +3083,17 @@ export class Mongo extends Db {
|
|
|
2794
3083
|
* Strategy matrix:
|
|
2795
3084
|
* - no inserts, no removes → existing arrayFilters path (or pure update doc)
|
|
2796
3085
|
* - removes only → adds `$pull` to update doc; arrayFilters allowed
|
|
2797
|
-
* - inserts (± removes) → pipeline form
|
|
2798
|
-
*
|
|
3086
|
+
* - inserts (± removes) → pipeline form; SINGLE-bracket sub-field paths
|
|
3087
|
+
* are also translated to pipeline `$map` + `$mergeObjects`
|
|
3088
|
+
* so they coexist with the inserts. NESTED-bracket paths
|
|
3089
|
+
* (e.g. `arr[A].sub[B].field`) combined with inserts still
|
|
3090
|
+
* throw — caller must split into two operations.
|
|
2799
3091
|
*
|
|
2800
|
-
* The unified pipeline stage atomically filters out elements
|
|
2801
|
-
*
|
|
2802
|
-
*
|
|
2803
|
-
*
|
|
3092
|
+
* The unified per-parent-field pipeline stage atomically filters out elements
|
|
3093
|
+
* matching any of (removeIds ∪ insertIds), maps remaining elements through the
|
|
3094
|
+
* sub-field merge expression, then appends new elements. Inserts remain
|
|
3095
|
+
* idempotent (re-inserting same `_id` no-ops). `$inc` and `$currentDate` are
|
|
3096
|
+
* also translated to pipeline stages so revisions (`_rev`/`_ts`) still fire.
|
|
2804
3097
|
*/
|
|
2805
3098
|
_applyBracketProcessing(update) {
|
|
2806
3099
|
// Pre-pass: validate bracket-id ↔ element-_id consistency, auto-fill missing _id.
|
|
@@ -2808,8 +3101,10 @@ export class Mongo extends Db {
|
|
|
2808
3101
|
this._validateAndAutoFillTerminalBracketValues(update);
|
|
2809
3102
|
const inserts = this._extractArrayInserts(update);
|
|
2810
3103
|
const removes = this._extractArrayRemoves(update);
|
|
2811
|
-
|
|
3104
|
+
// Pure sub-field updates only (no inserts, no removes) — keep on legacy
|
|
3105
|
+
// arrayFilters path (faster, smaller payload, doesn't fight $inc/$currentDate).
|
|
2812
3106
|
if (!inserts && !removes) {
|
|
3107
|
+
const arrayFilters = this._extractArrayFilters(update);
|
|
2813
3108
|
return arrayFilters ? { update, arrayFilters } : { update };
|
|
2814
3109
|
}
|
|
2815
3110
|
// Removes-only path: `$pull` on update doc. Coexists fine with `$set` + arrayFilters.
|
|
@@ -2826,32 +3121,60 @@ export class Mongo extends Db {
|
|
|
2826
3121
|
}
|
|
2827
3122
|
}
|
|
2828
3123
|
update.$pull = pullOp;
|
|
3124
|
+
const arrayFilters = this._extractArrayFilters(update);
|
|
2829
3125
|
return arrayFilters ? { update, arrayFilters } : { update };
|
|
2830
3126
|
}
|
|
2831
3127
|
// Inserts present (± removes) → pipeline form.
|
|
2832
|
-
|
|
2833
|
-
|
|
3128
|
+
// Extract single-bracket sub-field paths so we can express them as pipeline
|
|
3129
|
+
// stages instead of arrayFilters (mongo forbids arrayFilters on pipelines).
|
|
3130
|
+
const subFieldOps = this._extractArraySubFieldUpdates(update);
|
|
3131
|
+
// Any bracket paths still in $set/$unset must be NESTED-bracket
|
|
3132
|
+
// (e.g. `arr[A].sub[B].field`) — those aren't expressible without arrayFilters,
|
|
3133
|
+
// so combining them with inserts is still unsupported.
|
|
3134
|
+
const hasNestedBracket = (op) => {
|
|
3135
|
+
if (!op)
|
|
3136
|
+
return false;
|
|
3137
|
+
for (const k of Object.keys(op))
|
|
3138
|
+
if (k.indexOf('[') >= 0)
|
|
3139
|
+
return true;
|
|
3140
|
+
return false;
|
|
3141
|
+
};
|
|
3142
|
+
if (hasNestedBracket(update.$set) || hasNestedBracket(update.$unset)) {
|
|
3143
|
+
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.');
|
|
2834
3144
|
}
|
|
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
3145
|
const fieldOps = new Map();
|
|
3146
|
+
const ensureField = (field) => {
|
|
3147
|
+
if (!fieldOps.has(field))
|
|
3148
|
+
fieldOps.set(field, { removeIds: [], insertElements: [], elementOps: new Map() });
|
|
3149
|
+
return fieldOps.get(field);
|
|
3150
|
+
};
|
|
2838
3151
|
if (inserts) {
|
|
2839
3152
|
for (const ins of inserts) {
|
|
2840
|
-
|
|
2841
|
-
fieldOps.set(ins.field, { removeIds: [], insertElements: [] });
|
|
2842
|
-
const ops = fieldOps.get(ins.field);
|
|
3153
|
+
const ops = ensureField(ins.field);
|
|
2843
3154
|
ops.removeIds.push(...ins.ids);
|
|
2844
3155
|
ops.insertElements.push(...ins.elements);
|
|
2845
3156
|
}
|
|
2846
3157
|
}
|
|
2847
3158
|
if (removes) {
|
|
2848
3159
|
for (const rm of removes) {
|
|
2849
|
-
|
|
2850
|
-
fieldOps.set(rm.field, { removeIds: [], insertElements: [] });
|
|
2851
|
-
const ops = fieldOps.get(rm.field);
|
|
3160
|
+
const ops = ensureField(rm.field);
|
|
2852
3161
|
ops.removeIds.push(...rm.ids);
|
|
2853
3162
|
}
|
|
2854
3163
|
}
|
|
3164
|
+
if (subFieldOps) {
|
|
3165
|
+
for (const [field, idMap] of subFieldOps.entries()) {
|
|
3166
|
+
const ops = ensureField(field);
|
|
3167
|
+
for (const [id, elOps] of idMap.entries()) {
|
|
3168
|
+
// If an insert with the same id is also queued, the insert wins
|
|
3169
|
+
// (insertElements are already extracted; element merge would only
|
|
3170
|
+
// affect "existing" rows that survive the filter — but the insert
|
|
3171
|
+
// id is in removeIds, so it's filtered out anyway). Keep the
|
|
3172
|
+
// sub-field update so callers that mix these get predictable
|
|
3173
|
+
// behavior on existing data; it's a no-op if id ends up filtered.
|
|
3174
|
+
ops.elementOps.set(id, elOps);
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
2855
3178
|
const pipeline = [];
|
|
2856
3179
|
if (update.$set && Object.keys(update.$set).length > 0) {
|
|
2857
3180
|
pipeline.push({ $set: update.$set });
|
|
@@ -2859,21 +3182,40 @@ export class Mongo extends Db {
|
|
|
2859
3182
|
if (update.$unset && Object.keys(update.$unset).length > 0) {
|
|
2860
3183
|
pipeline.push({ $unset: Object.keys(update.$unset) });
|
|
2861
3184
|
}
|
|
3185
|
+
// Translate $inc / $currentDate so revisions (_rev/_ts) still update in pipeline form.
|
|
3186
|
+
for (const stage of Mongo._drainIncAndCurrentDateToPipelineStages(update))
|
|
3187
|
+
pipeline.push(stage);
|
|
2862
3188
|
for (const [field, ops] of fieldOps.entries()) {
|
|
2863
3189
|
const dedupedRemoveIds = Array.from(new Set(ops.removeIds));
|
|
3190
|
+
const filteredInput = {
|
|
3191
|
+
$filter: {
|
|
3192
|
+
input: { $ifNull: [`$${field}`, []] },
|
|
3193
|
+
as: 'el',
|
|
3194
|
+
cond: { $not: { $in: ['$$el._id', dedupedRemoveIds] } },
|
|
3195
|
+
},
|
|
3196
|
+
};
|
|
3197
|
+
let mappedExisting = filteredInput;
|
|
3198
|
+
if (ops.elementOps.size > 0) {
|
|
3199
|
+
// Build $switch with one branch per (id → merged element).
|
|
3200
|
+
const branches = [];
|
|
3201
|
+
for (const [id, elOps] of ops.elementOps.entries()) {
|
|
3202
|
+
branches.push({
|
|
3203
|
+
case: { $eq: ['$$el._id', id] },
|
|
3204
|
+
then: Mongo._buildElementMergeExpr('$$el', elOps),
|
|
3205
|
+
});
|
|
3206
|
+
}
|
|
3207
|
+
mappedExisting = {
|
|
3208
|
+
$map: {
|
|
3209
|
+
input: filteredInput,
|
|
3210
|
+
as: 'el',
|
|
3211
|
+
in: { $switch: { branches, default: '$$el' } },
|
|
3212
|
+
},
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
2864
3215
|
pipeline.push({
|
|
2865
3216
|
$set: {
|
|
2866
3217
|
[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
|
-
],
|
|
3218
|
+
$concatArrays: [mappedExisting, ops.insertElements],
|
|
2877
3219
|
},
|
|
2878
3220
|
},
|
|
2879
3221
|
});
|