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/utils.mjs
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
// AI created: 2026-05-21
|
|
2
|
+
// AI modified: 2026-05-21 (rename computeDiff → computeObjDiff, applyDiffLocally → applyObjDiff)
|
|
3
|
+
//
|
|
4
|
+
// Diff utilities extracted from cry-synced-db-client
|
|
5
|
+
// (`src/utils/computeDiff.ts` + `SyncedDb.applyDiffLocally`). These are the
|
|
6
|
+
// server-symmetric counterparts of the client diff machinery: `computeObjDiff`
|
|
7
|
+
// produces minimal MongoDB-style dot/bracket paths between two documents, and
|
|
8
|
+
// `applyObjDiff` replays such a path-set onto a base document the same way
|
|
9
|
+
// a server-side `$set` + `$unset` would. Pure functions, no mongo dependency.
|
|
10
|
+
// ── ObjectId / plain-object detection ──────────────────────────────────────
|
|
11
|
+
/** Duck-type ObjectId detection (avoids a bson dependency). */
|
|
12
|
+
function isObjectIdLike(v) {
|
|
13
|
+
return !!(v &&
|
|
14
|
+
typeof v === "object" &&
|
|
15
|
+
(v._bsontype === "ObjectId" || v._bsontype === "ObjectID") &&
|
|
16
|
+
typeof v.toString === "function");
|
|
17
|
+
}
|
|
18
|
+
function isPlainObject(v) {
|
|
19
|
+
if (v === null || typeof v !== "object")
|
|
20
|
+
return false;
|
|
21
|
+
if (Array.isArray(v))
|
|
22
|
+
return false;
|
|
23
|
+
if (v instanceof Date)
|
|
24
|
+
return false;
|
|
25
|
+
if (isObjectIdLike(v))
|
|
26
|
+
return false;
|
|
27
|
+
// Filter out other class instances (Map, Set, RegExp, etc.)
|
|
28
|
+
const proto = Object.getPrototypeOf(v);
|
|
29
|
+
return proto === Object.prototype || proto === null;
|
|
30
|
+
}
|
|
31
|
+
/** Deep equality for values that may include Date, ObjectId, plain objects, arrays. */
|
|
32
|
+
export function deepEquals(a, b) {
|
|
33
|
+
if (a === b)
|
|
34
|
+
return true;
|
|
35
|
+
if (a === null || b === null)
|
|
36
|
+
return false;
|
|
37
|
+
if (a === undefined || b === undefined)
|
|
38
|
+
return false;
|
|
39
|
+
if (a instanceof Date && b instanceof Date) {
|
|
40
|
+
return a.getTime() === b.getTime();
|
|
41
|
+
}
|
|
42
|
+
if (isObjectIdLike(a) && isObjectIdLike(b)) {
|
|
43
|
+
return String(a) === String(b);
|
|
44
|
+
}
|
|
45
|
+
if (typeof a !== typeof b)
|
|
46
|
+
return false;
|
|
47
|
+
if (typeof a !== "object")
|
|
48
|
+
return false;
|
|
49
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
50
|
+
return false;
|
|
51
|
+
if (Array.isArray(a)) {
|
|
52
|
+
if (a.length !== b.length)
|
|
53
|
+
return false;
|
|
54
|
+
for (let i = 0; i < a.length; i++) {
|
|
55
|
+
if (!deepEquals(a[i], b[i]))
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
// Plain object comparison
|
|
61
|
+
const ak = Object.keys(a);
|
|
62
|
+
const bk = Object.keys(b);
|
|
63
|
+
if (ak.length !== bk.length)
|
|
64
|
+
return false;
|
|
65
|
+
for (const k of ak) {
|
|
66
|
+
if (!Object.prototype.hasOwnProperty.call(b, k))
|
|
67
|
+
return false;
|
|
68
|
+
if (!deepEquals(a[k], b[k]))
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
// ── computeObjDiff ─────────────────────────────────────────────────────────
|
|
74
|
+
/**
|
|
75
|
+
* Check if every element in array has a non-null _id field.
|
|
76
|
+
* Required for element-wise diff strategy.
|
|
77
|
+
*/
|
|
78
|
+
function allElementsHaveId(arr) {
|
|
79
|
+
if (arr.length === 0)
|
|
80
|
+
return false;
|
|
81
|
+
for (const e of arr) {
|
|
82
|
+
if (!e || typeof e !== "object")
|
|
83
|
+
return false;
|
|
84
|
+
if (e._id == null)
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Check if two arrays have the same _id composition in the same order.
|
|
91
|
+
* Returns false if reordering, additions, or removals are detected.
|
|
92
|
+
*/
|
|
93
|
+
function sameIdSequence(a, b) {
|
|
94
|
+
if (a.length !== b.length)
|
|
95
|
+
return false;
|
|
96
|
+
for (let i = 0; i < a.length; i++) {
|
|
97
|
+
if (String(a[i]._id) !== String(b[i]._id))
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* True if `value` contains (anywhere in its tree) a non-empty array whose
|
|
104
|
+
* every element has an `_id` — i.e. recursing into `value` could emit
|
|
105
|
+
* another bracket-by-id path segment.
|
|
106
|
+
*
|
|
107
|
+
* Used to guard against multi-bracket diff paths: when an element-wise diff
|
|
108
|
+
* would otherwise recurse into a child element that itself contains an
|
|
109
|
+
* `_id`-array, we LIFT the diff — emit the parent element as a single
|
|
110
|
+
* `arr[<id>] = <full element>` replacement at the first bracket level.
|
|
111
|
+
*/
|
|
112
|
+
function containsIdArrayDescendant(value) {
|
|
113
|
+
if (value === null || typeof value !== "object")
|
|
114
|
+
return false;
|
|
115
|
+
if (value instanceof Date || isObjectIdLike(value))
|
|
116
|
+
return false;
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
if (value.length > 0 && allElementsHaveId(value))
|
|
119
|
+
return true;
|
|
120
|
+
for (const v of value) {
|
|
121
|
+
if (containsIdArrayDescendant(v))
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (!isPlainObject(value))
|
|
127
|
+
return false;
|
|
128
|
+
for (const key of Object.keys(value)) {
|
|
129
|
+
if (containsIdArrayDescendant(value[key]))
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Compute diff between two array values at a given path.
|
|
136
|
+
*
|
|
137
|
+
* Strategy (when both arrays have `_id` on every element):
|
|
138
|
+
* 1. Same `_id` set, same order → element-wise sub-field diff via `arr[<id>].field`
|
|
139
|
+
* 2. Same `_id` set, different order → full replace (no good per-element repr for reorder)
|
|
140
|
+
* 3. Different `_id` set (added/removed) → emit PRECISE paths:
|
|
141
|
+
* - removed: `arr[<id>] = undefined` (server: `$pull`)
|
|
142
|
+
* - added: `arr[<id>] = [element]` (server: `$concatArrays + $filter`)
|
|
143
|
+
* - retained: element-wise sub-field via `arr[<id>].field`
|
|
144
|
+
*
|
|
145
|
+
* Falls back to full-replace when elements lack `_id` (no id to use for bracket).
|
|
146
|
+
*/
|
|
147
|
+
function computeArrayDiff(existingArr, updateArr, basePath, diff) {
|
|
148
|
+
// Both empty → no diff.
|
|
149
|
+
if (existingArr.length === 0 && updateArr.length === 0)
|
|
150
|
+
return;
|
|
151
|
+
// Element-wise diff requires `_id` on every element in BOTH arrays. If either
|
|
152
|
+
// is missing, fall back to full replace (no bracket-id to anchor paths).
|
|
153
|
+
// Empty existing is OK — we just emit insert paths for every update element.
|
|
154
|
+
if (!allElementsHaveId(updateArr) ||
|
|
155
|
+
(existingArr.length > 0 && !allElementsHaveId(existingArr))) {
|
|
156
|
+
if (!deepEquals(existingArr, updateArr)) {
|
|
157
|
+
diff[basePath] = updateArr;
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Build _id sets for composition diff.
|
|
162
|
+
const existingIds = new Set();
|
|
163
|
+
for (const e of existingArr)
|
|
164
|
+
existingIds.add(String(e._id));
|
|
165
|
+
const updateIds = new Set();
|
|
166
|
+
for (const u of updateArr)
|
|
167
|
+
updateIds.add(String(u._id));
|
|
168
|
+
// Same _id set?
|
|
169
|
+
let sameSet = existingIds.size === updateIds.size;
|
|
170
|
+
if (sameSet) {
|
|
171
|
+
for (const id of existingIds) {
|
|
172
|
+
if (!updateIds.has(id)) {
|
|
173
|
+
sameSet = false;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (sameSet) {
|
|
179
|
+
if (sameIdSequence(existingArr, updateArr)) {
|
|
180
|
+
// Same composition, same order → per-element sub-field diff.
|
|
181
|
+
// No-multi-bracket invariant: if recursing into the element would
|
|
182
|
+
// produce ANOTHER bracket segment (the element contains a nested
|
|
183
|
+
// `_id`-array), LIFT instead — emit the full element at `arr[<id>]`.
|
|
184
|
+
for (let i = 0; i < updateArr.length; i++) {
|
|
185
|
+
const elementId = String(updateArr[i]._id);
|
|
186
|
+
const elementPath = `${basePath}[${elementId}]`;
|
|
187
|
+
if (containsIdArrayDescendant(updateArr[i])) {
|
|
188
|
+
if (!deepEquals(existingArr[i], updateArr[i])) {
|
|
189
|
+
diff[elementPath] = updateArr[i];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
computeDiffInto(existingArr[i], updateArr[i], elementPath, diff);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
// Same composition, different order → full replace. No clean per-element
|
|
199
|
+
// representation for reorder (mongo arrays are positional; bracket paths
|
|
200
|
+
// target by id, not position).
|
|
201
|
+
diff[basePath] = updateArr;
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// Composition changed. Emit precise insert/remove/sub-field paths.
|
|
206
|
+
// 1. REMOVED _ids → terminal bracket with `undefined` value.
|
|
207
|
+
for (const id of existingIds) {
|
|
208
|
+
if (!updateIds.has(id)) {
|
|
209
|
+
diff[`${basePath}[${id}]`] = undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// 2. ADDED _ids → terminal bracket with `[element]` array value.
|
|
213
|
+
for (const updateEl of updateArr) {
|
|
214
|
+
const id = String(updateEl._id);
|
|
215
|
+
if (!existingIds.has(id)) {
|
|
216
|
+
diff[`${basePath}[${id}]`] = [updateEl];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// 3. RETAINED _ids → element-wise sub-field diff OR full-element lift.
|
|
220
|
+
if (existingArr.length > 0) {
|
|
221
|
+
const existingById = new Map();
|
|
222
|
+
for (const e of existingArr)
|
|
223
|
+
existingById.set(String(e._id), e);
|
|
224
|
+
for (const updateEl of updateArr) {
|
|
225
|
+
const id = String(updateEl._id);
|
|
226
|
+
if (existingIds.has(id)) {
|
|
227
|
+
const elementPath = `${basePath}[${id}]`;
|
|
228
|
+
if (containsIdArrayDescendant(updateEl)) {
|
|
229
|
+
if (!deepEquals(existingById.get(id), updateEl)) {
|
|
230
|
+
diff[elementPath] = updateEl;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
computeDiffInto(existingById.get(id), updateEl, elementPath, diff);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Recursive diff accumulator. Mutates `diff` in place.
|
|
242
|
+
* Public API uses computeObjDiff() which returns a fresh object.
|
|
243
|
+
*/
|
|
244
|
+
function computeDiffInto(existing, update, basePath, diff) {
|
|
245
|
+
// No-op cases
|
|
246
|
+
if (deepEquals(existing, update))
|
|
247
|
+
return;
|
|
248
|
+
// Type mismatch or update is non-object → full replace at basePath
|
|
249
|
+
if (update === null ||
|
|
250
|
+
update === undefined ||
|
|
251
|
+
typeof update !== "object" ||
|
|
252
|
+
update instanceof Date ||
|
|
253
|
+
isObjectIdLike(update)) {
|
|
254
|
+
diff[basePath] = update;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// existing is missing or non-object → emit update as-is at basePath
|
|
258
|
+
if (existing === null ||
|
|
259
|
+
existing === undefined ||
|
|
260
|
+
typeof existing !== "object" ||
|
|
261
|
+
existing instanceof Date ||
|
|
262
|
+
isObjectIdLike(existing)) {
|
|
263
|
+
diff[basePath] = update;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// Array vs object mismatch → full replace
|
|
267
|
+
if (Array.isArray(update) !== Array.isArray(existing)) {
|
|
268
|
+
diff[basePath] = update;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (Array.isArray(update)) {
|
|
272
|
+
computeArrayDiff(existing, update, basePath, diff);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Plain object → recurse on each key in update
|
|
276
|
+
if (!isPlainObject(update) || !isPlainObject(existing)) {
|
|
277
|
+
// Class instance edge case → full replace
|
|
278
|
+
diff[basePath] = update;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// Empty-object full-replace: when `update` is `{}` but `existing` has
|
|
282
|
+
// children, the caller's intent is "clear all children". Symmetric with
|
|
283
|
+
// `field: []` (array replace) and `field: undefined` (delete the field).
|
|
284
|
+
if (Object.keys(update).length === 0 && Object.keys(existing).length > 0) {
|
|
285
|
+
diff[basePath] = update;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
for (const key of Object.keys(update)) {
|
|
289
|
+
const childPath = basePath ? `${basePath}.${key}` : key;
|
|
290
|
+
computeDiffInto(existing[key], update[key], childPath, diff);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Server-managed metadata fields that must NEVER appear in a diff. These are
|
|
295
|
+
* populated by mongo via `$currentDate` (`_ts`) and `$inc` (`_rev`); recursing
|
|
296
|
+
* into them would otherwise emit dot-notation entries (e.g. `_ts.t`, `_ts.i`,
|
|
297
|
+
* `_rev`) that conflict with mongo's atomic operators on the same path.
|
|
298
|
+
*/
|
|
299
|
+
const SERVER_MANAGED_METADATA_KEYS = new Set(["_ts", "_rev", "_csq"]);
|
|
300
|
+
/**
|
|
301
|
+
* Compute the minimal set of MongoDB dot-notation $set paths that transform
|
|
302
|
+
* `existing` into `update` (only for keys present in `update`).
|
|
303
|
+
*
|
|
304
|
+
* @returns Record where keys are dot-notation paths and values are new values.
|
|
305
|
+
* Empty object means no changes.
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* existing = { koraki: [{ _id: "k1", diag: "old", ter: "x" }] }
|
|
309
|
+
* update = { koraki: [{ _id: "k1", diag: "new", ter: "x" }] }
|
|
310
|
+
* → { "koraki[k1].diag": "new" }
|
|
311
|
+
*/
|
|
312
|
+
export function computeObjDiff(existing, update) {
|
|
313
|
+
const diff = {};
|
|
314
|
+
if (!update || typeof update !== "object")
|
|
315
|
+
return diff;
|
|
316
|
+
// No existing → full update is the diff (still strip server metadata).
|
|
317
|
+
if (existing === null || existing === undefined) {
|
|
318
|
+
const cleaned = {};
|
|
319
|
+
for (const k of Object.keys(update)) {
|
|
320
|
+
if (SERVER_MANAGED_METADATA_KEYS.has(k))
|
|
321
|
+
continue;
|
|
322
|
+
cleaned[k] = update[k];
|
|
323
|
+
}
|
|
324
|
+
return cleaned;
|
|
325
|
+
}
|
|
326
|
+
for (const key of Object.keys(update)) {
|
|
327
|
+
// Skip server-managed metadata at the top level so we never recurse into
|
|
328
|
+
// BSON Timestamp `{t,i}` sub-fields (which would emit `_ts.t`/`_ts.i`).
|
|
329
|
+
if (SERVER_MANAGED_METADATA_KEYS.has(key))
|
|
330
|
+
continue;
|
|
331
|
+
computeDiffInto(existing[key], update[key], key, diff);
|
|
332
|
+
}
|
|
333
|
+
return diff;
|
|
334
|
+
}
|
|
335
|
+
// ── path tokenization + set/delete by path ─────────────────────────────────
|
|
336
|
+
/**
|
|
337
|
+
* Tokenize a dot-notation path that may contain bracket-id segments.
|
|
338
|
+
*
|
|
339
|
+
* "postavke[A].kolicina" → ["postavke", "[A]", "kolicina"]
|
|
340
|
+
* "koraki.0.diag" → ["koraki", "0", "diag"]
|
|
341
|
+
* "arr[A].sub[B].field" → ["arr", "[A]", "sub", "[B]", "field"]
|
|
342
|
+
*/
|
|
343
|
+
export function tokenizePath(path) {
|
|
344
|
+
const out = [];
|
|
345
|
+
let buf = "";
|
|
346
|
+
for (let i = 0; i < path.length; i++) {
|
|
347
|
+
const ch = path[i];
|
|
348
|
+
if (ch === ".") {
|
|
349
|
+
if (buf) {
|
|
350
|
+
out.push(buf);
|
|
351
|
+
buf = "";
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
else if (ch === "[") {
|
|
355
|
+
if (buf) {
|
|
356
|
+
out.push(buf);
|
|
357
|
+
buf = "";
|
|
358
|
+
}
|
|
359
|
+
const close = path.indexOf("]", i);
|
|
360
|
+
if (close < 0) {
|
|
361
|
+
buf += ch;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
out.push(path.substring(i, close + 1)); // includes [ and ]
|
|
365
|
+
i = close;
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
buf += ch;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (buf)
|
|
372
|
+
out.push(buf);
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Plain-object container check: a `{}` literal or `Object.create(null)`.
|
|
377
|
+
* Host types (Date, ObjectId, RegExp, Map, Set) and arrays return false.
|
|
378
|
+
*/
|
|
379
|
+
function isPlainObjectContainer(value) {
|
|
380
|
+
if (value === null || value === undefined)
|
|
381
|
+
return false;
|
|
382
|
+
if (typeof value !== "object")
|
|
383
|
+
return false;
|
|
384
|
+
if (Array.isArray(value))
|
|
385
|
+
return false;
|
|
386
|
+
const proto = Object.getPrototypeOf(value);
|
|
387
|
+
return proto === Object.prototype || proto === null;
|
|
388
|
+
}
|
|
389
|
+
/** Navigate one step into `current` by segment; returns child or undefined. */
|
|
390
|
+
function navigateSegment(current, part) {
|
|
391
|
+
if (current === null || current === undefined)
|
|
392
|
+
return undefined;
|
|
393
|
+
if (part.startsWith("[") && part.endsWith("]")) {
|
|
394
|
+
const idStr = part.slice(1, -1);
|
|
395
|
+
if (!Array.isArray(current))
|
|
396
|
+
return undefined;
|
|
397
|
+
return current.find((item) => item && String(item._id) === idStr);
|
|
398
|
+
}
|
|
399
|
+
if (/^\d+$/.test(part) && Array.isArray(current)) {
|
|
400
|
+
return current[Number(part)];
|
|
401
|
+
}
|
|
402
|
+
if (typeof current === "object") {
|
|
403
|
+
return current[part];
|
|
404
|
+
}
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Set the last segment on `current` (mutates in place). Returns success.
|
|
409
|
+
*
|
|
410
|
+
* Terminal bracket-by-_id (`[<id>]`) on an array supports the insert/replace
|
|
411
|
+
* wire form (`arr[<id>] = [<el>]`):
|
|
412
|
+
* - Match found in array → replace at that index
|
|
413
|
+
* - No match found → PUSH the unwrapped element (composition-change INSERT)
|
|
414
|
+
*/
|
|
415
|
+
function setSegment(current, part, value) {
|
|
416
|
+
if (current === null || current === undefined)
|
|
417
|
+
return false;
|
|
418
|
+
if (part.startsWith("[") && part.endsWith("]")) {
|
|
419
|
+
const idStr = part.slice(1, -1);
|
|
420
|
+
if (!Array.isArray(current))
|
|
421
|
+
return false;
|
|
422
|
+
const idx = current.findIndex((item) => item && String(item._id) === idStr);
|
|
423
|
+
// INSERT/REPLACE wire form: `[<el>]` wrapper with `el._id === idStr`.
|
|
424
|
+
// Unwrap and push (no match) or replace (match) — never produces dupes.
|
|
425
|
+
if (Array.isArray(value) &&
|
|
426
|
+
value.length === 1 &&
|
|
427
|
+
value[0] &&
|
|
428
|
+
typeof value[0] === "object" &&
|
|
429
|
+
String(value[0]._id) === idStr) {
|
|
430
|
+
if (idx >= 0)
|
|
431
|
+
current[idx] = value[0];
|
|
432
|
+
else
|
|
433
|
+
current.push(value[0]);
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
if (idx < 0)
|
|
437
|
+
return false;
|
|
438
|
+
current[idx] = value;
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
if (/^\d+$/.test(part) && Array.isArray(current)) {
|
|
442
|
+
current[Number(part)] = value;
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
if (typeof current === "object") {
|
|
446
|
+
current[part] = value;
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Set a value at a path within a nested target. Supports three segment forms:
|
|
453
|
+
* - Numeric ("0", "1") → array index
|
|
454
|
+
* - Bracket ("[<_id>]") → array element matched by `_id` field
|
|
455
|
+
* - Plain ("field") → object key
|
|
456
|
+
*
|
|
457
|
+
* Auto-creation policy (when `opts.autoCreate !== false`, the default):
|
|
458
|
+
* - PLAIN segment whose intermediate is `undefined`/`null` AND whose parent
|
|
459
|
+
* is a plain object → materialize `{}` in place and continue.
|
|
460
|
+
* - PLAIN segment whose intermediate is a primitive → REFUSE (return false).
|
|
461
|
+
* - NUMERIC / BRACKET segment → unchanged (cannot safely synthesize an array
|
|
462
|
+
* slot or element without committing to the array shape). The dedicated
|
|
463
|
+
* `materializeBracketPath` helper handles those.
|
|
464
|
+
*
|
|
465
|
+
* @returns true if the value was set, false if traversal failed for a reason
|
|
466
|
+
* that cannot be auto-corrected.
|
|
467
|
+
*/
|
|
468
|
+
export function setByPath(target, path, value, opts) {
|
|
469
|
+
if (target === null || target === undefined)
|
|
470
|
+
return false;
|
|
471
|
+
const autoCreate = (opts === null || opts === void 0 ? void 0 : opts.autoCreate) !== false;
|
|
472
|
+
const parts = tokenizePath(path);
|
|
473
|
+
// Auto-create gate: refuse if ANY segment requires array shape
|
|
474
|
+
// (bracket-by-id or numeric index). Plain-object intermediates `{}` are the
|
|
475
|
+
// only thing we can synthesize without external shape info.
|
|
476
|
+
const pathHasArrayShape = parts.some((p) => (p.startsWith("[") && p.endsWith("]")) || /^\d+$/.test(p));
|
|
477
|
+
const canAutoCreate = autoCreate && !pathHasArrayShape;
|
|
478
|
+
let current = target;
|
|
479
|
+
// Track keys we created on each container, so we can roll back if the final
|
|
480
|
+
// `setSegment` fails — leaves no orphan `{}` intermediates in the target.
|
|
481
|
+
const created = [];
|
|
482
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
483
|
+
const part = parts[i];
|
|
484
|
+
let next = navigateSegment(current, part);
|
|
485
|
+
// Treat null exactly like undefined for auto-create eligibility.
|
|
486
|
+
const isMissing = next === undefined || next === null;
|
|
487
|
+
if (isMissing && canAutoCreate) {
|
|
488
|
+
// pathHasArrayShape is false here, so all segments are plain.
|
|
489
|
+
if (isPlainObjectContainer(current)) {
|
|
490
|
+
current[part] = {};
|
|
491
|
+
created.push({ container: current, key: part });
|
|
492
|
+
next = current[part];
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
else if (isMissing) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
else if (autoCreate &&
|
|
502
|
+
!pathHasArrayShape &&
|
|
503
|
+
!isPlainObjectContainer(next) &&
|
|
504
|
+
!Array.isArray(next)) {
|
|
505
|
+
// All-plain path + existing intermediate is a HOST object (Date,
|
|
506
|
+
// ObjectId, RegExp, …). Drilling further would silently mutate the host
|
|
507
|
+
// instance. Refuse; caller can take its own recovery branch.
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
current = next;
|
|
511
|
+
}
|
|
512
|
+
const last = parts[parts.length - 1];
|
|
513
|
+
const ok = setSegment(current, last, value);
|
|
514
|
+
if (!ok && created.length > 0) {
|
|
515
|
+
// Roll back auto-created intermediates so the target is unchanged on failure.
|
|
516
|
+
for (let i = created.length - 1; i >= 0; i--) {
|
|
517
|
+
const { container, key } = created[i];
|
|
518
|
+
delete container[key];
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return ok;
|
|
522
|
+
}
|
|
523
|
+
/** Delete the last segment on `current` (mutates in place). Returns success. */
|
|
524
|
+
function deleteSegment(current, part) {
|
|
525
|
+
if (current === null || current === undefined)
|
|
526
|
+
return false;
|
|
527
|
+
if (part.startsWith("[") && part.endsWith("]")) {
|
|
528
|
+
const idStr = part.slice(1, -1);
|
|
529
|
+
if (!Array.isArray(current))
|
|
530
|
+
return false;
|
|
531
|
+
const idx = current.findIndex((item) => item && String(item._id) === idStr);
|
|
532
|
+
if (idx < 0)
|
|
533
|
+
return false;
|
|
534
|
+
current.splice(idx, 1);
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
if (/^\d+$/.test(part) && Array.isArray(current)) {
|
|
538
|
+
const idx = Number(part);
|
|
539
|
+
if (idx < 0 || idx >= current.length)
|
|
540
|
+
return false;
|
|
541
|
+
current.splice(idx, 1);
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
if (typeof current === "object") {
|
|
545
|
+
if (!Object.prototype.hasOwnProperty.call(current, part))
|
|
546
|
+
return false;
|
|
547
|
+
delete current[part];
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Delete the value at `path` within `target`. Sibling of `setByPath` —
|
|
554
|
+
* navigates the same tokenized path forms (numeric, bracket-by-_id, plain),
|
|
555
|
+
* but the final segment removes the property/element instead of setting it.
|
|
556
|
+
*
|
|
557
|
+
* Honors the convention that an `undefined` value in a diff means "delete this
|
|
558
|
+
* field". Returns `true` if a delete actually occurred.
|
|
559
|
+
*/
|
|
560
|
+
export function deleteByPath(target, path) {
|
|
561
|
+
if (target === null || target === undefined)
|
|
562
|
+
return false;
|
|
563
|
+
const parts = tokenizePath(path);
|
|
564
|
+
if (parts.length === 0)
|
|
565
|
+
return false;
|
|
566
|
+
let current = target;
|
|
567
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
568
|
+
const next = navigateSegment(current, parts[i]);
|
|
569
|
+
if (next === undefined || next === null)
|
|
570
|
+
return false;
|
|
571
|
+
current = next;
|
|
572
|
+
}
|
|
573
|
+
const last = parts[parts.length - 1];
|
|
574
|
+
return deleteSegment(current, last);
|
|
575
|
+
}
|
|
576
|
+
// ── applyObjDiff ───────────────────────────────────────────────────────────
|
|
577
|
+
/**
|
|
578
|
+
* Deep clone for `applyObjDiff`. Recurses into plain objects and arrays;
|
|
579
|
+
* preserves `Date` (cloned to avoid shared reference) and `ObjectId`-like
|
|
580
|
+
* values by reference. Other class instances pass through by reference. Avoids
|
|
581
|
+
* `structuredClone` because it throws on class instances like bson `ObjectId`.
|
|
582
|
+
*/
|
|
583
|
+
function safeDeepClone(value) {
|
|
584
|
+
if (value === null || value === undefined)
|
|
585
|
+
return value;
|
|
586
|
+
if (typeof value !== "object")
|
|
587
|
+
return value;
|
|
588
|
+
if (value instanceof Date)
|
|
589
|
+
return new Date(value.getTime());
|
|
590
|
+
if (isObjectIdLike(value))
|
|
591
|
+
return value;
|
|
592
|
+
if (Array.isArray(value)) {
|
|
593
|
+
const out = new Array(value.length);
|
|
594
|
+
for (let i = 0; i < value.length; i++)
|
|
595
|
+
out[i] = safeDeepClone(value[i]);
|
|
596
|
+
return out;
|
|
597
|
+
}
|
|
598
|
+
// Skip class instances other than plain objects (return by reference).
|
|
599
|
+
const proto = Object.getPrototypeOf(value);
|
|
600
|
+
if (proto !== Object.prototype && proto !== null)
|
|
601
|
+
return value;
|
|
602
|
+
const out = {};
|
|
603
|
+
for (const key of Object.keys(value)) {
|
|
604
|
+
out[key] = safeDeepClone(value[key]);
|
|
605
|
+
}
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Fallback for `setByPath` failures inside `applyObjDiff`. Materializes
|
|
610
|
+
* missing array containers AND missing intermediate plain objects so the diff
|
|
611
|
+
* entry can land instead of being dropped.
|
|
612
|
+
*
|
|
613
|
+
* Supported path shape (tokenizes to `[…plain prefix, [<id>], …plain suffix]`):
|
|
614
|
+
* - `polje[<id>] = <obj>` → seed.polje = [<obj>]
|
|
615
|
+
* - `polje[<id>].field = <v>` → seed.polje = [{_id, field: <v>}]
|
|
616
|
+
* - `outer.inner.polje[<id>].a.b = <v>`→ seed.outer = {inner: {polje: [{_id, a:{b:<v>}}]}}
|
|
617
|
+
* - existing matching `_id` → walk into element, set leaf (no dupe)
|
|
618
|
+
*
|
|
619
|
+
* Dropped: multi-bracket paths (need shape knowledge we can't reconstruct) and
|
|
620
|
+
* existing non-plain intermediates. Drops on `_`-prefixed roots are SILENT
|
|
621
|
+
* (server-mirrored fields — next sync hydrates the canonical state); other
|
|
622
|
+
* drops log a `console.error` so real data loss is surfaced.
|
|
623
|
+
*/
|
|
624
|
+
function materializeBracketPath(seed, path, value, collection, id) {
|
|
625
|
+
const tokens = tokenizePath(path);
|
|
626
|
+
const firstToken = tokens[0];
|
|
627
|
+
// Internal/redundancy fields (name starts with `_`) are server-mirrored —
|
|
628
|
+
// dropping such paths locally without logging is acceptable: next sync brings
|
|
629
|
+
// the materialized structure back. Other field names are user-authored, so a
|
|
630
|
+
// drop is real data loss we want to surface.
|
|
631
|
+
const dropSilently = typeof firstToken === "string" && firstToken.startsWith("_");
|
|
632
|
+
const drop = (reason) => {
|
|
633
|
+
if (dropSilently)
|
|
634
|
+
return;
|
|
635
|
+
console.error(`[cry-db] applyObjDiff: dropping bracket-path diff entry (${reason})`, { collection, _id: String(id), path, value });
|
|
636
|
+
};
|
|
637
|
+
if (tokens.length < 2) {
|
|
638
|
+
drop(`unsupported token count ${tokens.length}`);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (firstToken === undefined || firstToken.startsWith("[")) {
|
|
642
|
+
drop("first segment is not a plain field");
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
// Locate the first bracket token. Everything before it is the plain prefix;
|
|
646
|
+
// the bracket itself is the element selector; everything after is the plain
|
|
647
|
+
// suffix walked into the matched element.
|
|
648
|
+
let bracketIdx = -1;
|
|
649
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
650
|
+
if (tokens[i].startsWith("[")) {
|
|
651
|
+
bracketIdx = i;
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (bracketIdx < 0) {
|
|
656
|
+
drop("no bracket segment found");
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
// No additional brackets allowed — multi-bracket paths need shape knowledge
|
|
660
|
+
// we don't carry.
|
|
661
|
+
for (let i = bracketIdx + 1; i < tokens.length; i++) {
|
|
662
|
+
if (tokens[i].startsWith("[")) {
|
|
663
|
+
drop("nested bracket path");
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// `_`-prefixed roots only materialize for the single-segment-prefix shape.
|
|
668
|
+
// Multi-segment prefix under `_` stays a silent drop.
|
|
669
|
+
if (dropSilently && bracketIdx > 1) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const prefixTokens = tokens.slice(0, bracketIdx);
|
|
673
|
+
const bracketToken = tokens[bracketIdx];
|
|
674
|
+
const suffixTokens = tokens.slice(bracketIdx + 1);
|
|
675
|
+
const bracketId = bracketToken.slice(1, -1);
|
|
676
|
+
if (bracketId.length === 0) {
|
|
677
|
+
drop("empty bracket id");
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
// Navigate (and create as needed) the plain prefix down to the parent of the
|
|
681
|
+
// target array. Refuse to overwrite an existing non-plain-object intermediate.
|
|
682
|
+
let parent = seed;
|
|
683
|
+
for (let i = 0; i < prefixTokens.length - 1; i++) {
|
|
684
|
+
const seg = prefixTokens[i];
|
|
685
|
+
const cur = parent[seg];
|
|
686
|
+
if (cur === undefined || cur === null) {
|
|
687
|
+
parent[seg] = {};
|
|
688
|
+
}
|
|
689
|
+
else if (typeof cur !== "object" ||
|
|
690
|
+
Array.isArray(cur) ||
|
|
691
|
+
cur instanceof Date) {
|
|
692
|
+
drop(`existing intermediate "${prefixTokens
|
|
693
|
+
.slice(0, i + 1)
|
|
694
|
+
.join(".")}" is not a plain object`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
parent = parent[seg];
|
|
698
|
+
}
|
|
699
|
+
const lastPrefixSeg = prefixTokens[prefixTokens.length - 1];
|
|
700
|
+
let arr = parent[lastPrefixSeg];
|
|
701
|
+
if (arr != null && !Array.isArray(arr)) {
|
|
702
|
+
drop(`existing "${prefixTokens.join(".")}" is not an array`);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (arr == null) {
|
|
706
|
+
arr = [];
|
|
707
|
+
parent[lastPrefixSeg] = arr;
|
|
708
|
+
}
|
|
709
|
+
// ---------- Suffix length 0: terminal-bracket whole-element op ----------
|
|
710
|
+
if (suffixTokens.length === 0) {
|
|
711
|
+
// `polje[<id>] = <obj>` — unwrap INSERT wire form `[<el>]` if it matches,
|
|
712
|
+
// otherwise treat `value` as the bare element. Stamp `_id` when missing.
|
|
713
|
+
let element = value;
|
|
714
|
+
if (Array.isArray(value) &&
|
|
715
|
+
value.length === 1 &&
|
|
716
|
+
value[0] != null &&
|
|
717
|
+
typeof value[0] === "object") {
|
|
718
|
+
element = value[0];
|
|
719
|
+
}
|
|
720
|
+
if (element == null ||
|
|
721
|
+
typeof element !== "object" ||
|
|
722
|
+
Array.isArray(element)) {
|
|
723
|
+
drop("value is not a single element or wire-form wrapper");
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (element._id == null) {
|
|
727
|
+
element._id = bracketId;
|
|
728
|
+
}
|
|
729
|
+
// Replace an existing element with the same `_id` to avoid duplicates.
|
|
730
|
+
const replaceIdx = arr.findIndex((it) => it != null && typeof it === "object" && String(it._id) === bracketId);
|
|
731
|
+
if (replaceIdx >= 0) {
|
|
732
|
+
arr[replaceIdx] = element;
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
arr.push(element);
|
|
736
|
+
}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
// ---------- Suffix length ≥ 1: sub-field path into the element ----------
|
|
740
|
+
const buildNestedSubTree = (segs, leaf) => {
|
|
741
|
+
let acc = leaf;
|
|
742
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
743
|
+
acc = { [segs[i]]: acc };
|
|
744
|
+
}
|
|
745
|
+
return acc;
|
|
746
|
+
};
|
|
747
|
+
const existingIdx = arr.findIndex((it) => it != null && typeof it === "object" && String(it._id) === bracketId);
|
|
748
|
+
if (existingIdx >= 0) {
|
|
749
|
+
// Walk into the matching element, create missing intermediates, set the leaf.
|
|
750
|
+
let cur = arr[existingIdx];
|
|
751
|
+
for (let i = 0; i < suffixTokens.length - 1; i++) {
|
|
752
|
+
const seg = suffixTokens[i];
|
|
753
|
+
const next = cur[seg];
|
|
754
|
+
if (next == null || typeof next !== "object" || Array.isArray(next)) {
|
|
755
|
+
cur[seg] = {};
|
|
756
|
+
}
|
|
757
|
+
cur = cur[seg];
|
|
758
|
+
}
|
|
759
|
+
cur[suffixTokens[suffixTokens.length - 1]] = value;
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
const subTree = buildNestedSubTree(suffixTokens, value);
|
|
763
|
+
arr.push({ _id: bracketId, ...subTree });
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Mongo-symmetric local apply: starting from a safe deep clone of `base`, walk
|
|
768
|
+
* each `(path, value)` entry of `diff`:
|
|
769
|
+
*
|
|
770
|
+
* - `value === undefined` → `deleteByPath` (mongo `$unset` symmetric)
|
|
771
|
+
* - otherwise → `setByPath` (mongo `$set` symmetric)
|
|
772
|
+
*
|
|
773
|
+
* Bracket-path entries whose parent array doesn't exist yet fall back to
|
|
774
|
+
* `materializeBracketPath`. The result is what an equivalent server-side
|
|
775
|
+
* `$set` + `$unset` would have produced.
|
|
776
|
+
*
|
|
777
|
+
* @param base - source document (cloned, never mutated)
|
|
778
|
+
* @param diff - dot/bracket path → value map (as produced by {@link computeObjDiff})
|
|
779
|
+
* @param fallbackId - `_id` to seed when `base` is null/has no `_id`
|
|
780
|
+
* @param collection - collection name, used only for drop-log context
|
|
781
|
+
* @returns a new object (cloned-and-mutated)
|
|
782
|
+
*/
|
|
783
|
+
export function applyObjDiff(base, diff, fallbackId, collection) {
|
|
784
|
+
const seed = base ? safeDeepClone(base) : { _id: fallbackId };
|
|
785
|
+
if (seed._id == null)
|
|
786
|
+
seed._id = fallbackId;
|
|
787
|
+
for (const path of Object.keys(diff)) {
|
|
788
|
+
const value = diff[path];
|
|
789
|
+
if (value === undefined) {
|
|
790
|
+
// `$unset` symmetric: physically remove the field at this path. Failure
|
|
791
|
+
// (path can't reach target) is fine — the field already wasn't there.
|
|
792
|
+
deleteByPath(seed, path);
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
// `$set` symmetric. Top-level keys without `.` or `[` go directly.
|
|
796
|
+
if (!path.includes(".") && !path.includes("[")) {
|
|
797
|
+
seed[path] = value;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
const ok = setByPath(seed, path, value);
|
|
801
|
+
if (ok)
|
|
802
|
+
continue;
|
|
803
|
+
materializeBracketPath(seed, path, value, collection, fallbackId);
|
|
804
|
+
}
|
|
805
|
+
return seed;
|
|
806
|
+
}
|
|
807
|
+
//# sourceMappingURL=utils.mjs.map
|