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/README.md +131 -42
- package/dist/index.d.mts +2 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/dist/mongo.d.mts +24 -0
- package/dist/mongo.d.mts.map +1 -1
- package/dist/mongo.mjs +383 -90
- package/dist/mongo.mjs.map +1 -1
- package/dist/types.d.mts +23 -0
- package/dist/types.d.mts.map +1 -1
- package/dist/types.mjs.map +1 -1
- package/dist/utils.d.mts +74 -0
- package/dist/utils.d.mts.map +1 -0
- package/dist/utils.mjs +807 -0
- package/dist/utils.mjs.map +1 -0
- package/package.json +1 -1
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3213
|
-
//
|
|
3214
|
-
|
|
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
|
-
//
|
|
3281
|
-
//
|
|
3282
|
-
//
|
|
3283
|
-
//
|
|
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 (!
|
|
3307
|
-
|
|
3308
|
-
return
|
|
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
|
|
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]:
|
|
3685
|
+
[field]: Mongo._buildArrayTransformExpr(`$${field}`, ops),
|
|
3393
3686
|
},
|
|
3394
3687
|
});
|
|
3395
3688
|
}
|