cry-synced-db-client 0.1.186 → 0.1.187
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/CHANGELOG.md +168 -0
- package/dist/index.js +44 -40
- package/dist/src/db/SyncedDb.d.ts +0 -13
- package/dist/src/utils/computeDiff.d.ts +31 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,173 @@
|
|
|
1
1
|
# Versions
|
|
2
2
|
|
|
3
|
+
## 0.1.187 (2026-05-16)
|
|
4
|
+
|
|
5
|
+
### Fix: `setByPath` auto-creates plain-object intermediates (root cause of `sestevki` parent+child conflict)
|
|
6
|
+
|
|
7
|
+
Production bug (klikvet vetnm, 2026-05-15, ~290 hits/day): obisk records
|
|
8
|
+
stuck dirty with mongo upload errors —
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
[SyncEngine] Sync upload error [obiski] _id=…:
|
|
12
|
+
Updating the path 'sestevki.skupaj.prc' would create a conflict at 'sestevki'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The `0.1.185` `rebaseDirtyOnServerAdvance` fix targeted the case where
|
|
16
|
+
server `_rev` advances past the local base (the `kritje + skupaj`
|
|
17
|
+
two-write scenario). But a more general root cause was still latent in
|
|
18
|
+
`setByPath`: when the function was asked to drill `skupaj.prc` into an
|
|
19
|
+
existing `sestevki: {}` accumulated parent, it returned `false`
|
|
20
|
+
because navigation hit `undefined` at the missing `skupaj`
|
|
21
|
+
intermediate — and `mergeDirtyPath` Case 1's failure handler fell
|
|
22
|
+
through to Case 3, writing the dot-path as a SIBLING key.
|
|
23
|
+
|
|
24
|
+
`applyDiffLocally` had already worked around this asymmetrically by
|
|
25
|
+
adding `materializePlainDotPath` in `0.1.185`, but that helper only
|
|
26
|
+
ran inside `applyDiffLocally` — `mergeDirtyPath` continued to use the
|
|
27
|
+
strict `setByPath` and produced the sibling-coexistence pattern in the
|
|
28
|
+
dirty payload sent to the server.
|
|
29
|
+
|
|
30
|
+
**This release lifts the fix into `setByPath` itself**, the single
|
|
31
|
+
primitive used by both code paths. Default semantics change from
|
|
32
|
+
"fail at any missing intermediate" to "auto-create empty plain-object
|
|
33
|
+
intermediate and continue":
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Before (≤ 0.1.186): setByPath({}, "a.b", 1) → false
|
|
37
|
+
// After (≥ 0.1.187): setByPath({}, "a.b", 1) → true, target = {a: {b: 1}}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Safety guards** added to keep the change strictly bug-fix
|
|
41
|
+
direction:
|
|
42
|
+
|
|
43
|
+
- **Path look-ahead**: refuse auto-create entirely when ANY segment in
|
|
44
|
+
the path is bracket-by-id (`[<id>]`) or numeric (`0`). Those shapes
|
|
45
|
+
imply an array intermediate which `setByPath` cannot synthesize
|
|
46
|
+
without external info (which `_id`? what other elements live there?).
|
|
47
|
+
The existing `materializeBracketPath` fallback in
|
|
48
|
+
`applyDiffLocally` keeps owning that case and was untouched.
|
|
49
|
+
- **Host-type refusal**: when an intermediate exists but is a non-plain
|
|
50
|
+
host object (`Date`, `ObjectId`, `RegExp`, `Map`, …), refuse with
|
|
51
|
+
`false`. Drilling further would silently mutate the host instance
|
|
52
|
+
(e.g. `dateInstance.year = 2026`) — a data-corruption vector.
|
|
53
|
+
Arrays are EXEMPT from this refusal because bracket/numeric next
|
|
54
|
+
segments handle them correctly.
|
|
55
|
+
- **Rollback on final-segment failure**: if `setByPath` auto-creates
|
|
56
|
+
intermediates and `setSegment` ultimately fails, remove every
|
|
57
|
+
intermediate it created. The caller's target sees no orphan `{}`
|
|
58
|
+
branches.
|
|
59
|
+
- **Null treated as undefined**: a stored `null` at an intermediate
|
|
60
|
+
position is auto-created over (real production state included
|
|
61
|
+
`sestevki: null`).
|
|
62
|
+
- **`opts.autoCreate = false` opt-out**: callers that explicitly need
|
|
63
|
+
the pre-0.1.187 strict fail-at-miss behavior pass `{ autoCreate: false }`.
|
|
64
|
+
No in-tree caller does, but the opt-out keeps the API
|
|
65
|
+
backwards-compatible for external consumers.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
export function setByPath(
|
|
69
|
+
target: any,
|
|
70
|
+
path: string,
|
|
71
|
+
value: any,
|
|
72
|
+
opts?: { autoCreate?: boolean }, // default { autoCreate: true }
|
|
73
|
+
): boolean { … }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `mergeDirtyPath` Case 1 — promote-on-failure replaces fall-through
|
|
77
|
+
|
|
78
|
+
The Case 1 fallback ("setByPath failed → fall through to Case 3
|
|
79
|
+
orthogonal") used to be the SOURCE of the parent+child sibling
|
|
80
|
+
payload. With auto-create on, `setByPath` returns `false` only for
|
|
81
|
+
genuine type clashes (primitive parent + plain-key descendant, or path
|
|
82
|
+
shape mismatch). In that case the right resolution is to drop the
|
|
83
|
+
stale parent and promote the deep path:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// 1. setByPath(mutationTarget, relativePath, newValue) returns false.
|
|
87
|
+
// 2. The two forms cannot coexist in a mongo $set.
|
|
88
|
+
// 3. Per server-wins-rebase convention: newer write wins.
|
|
89
|
+
delete accumulated[existingKey];
|
|
90
|
+
accumulated[newPath] = newValue;
|
|
91
|
+
return;
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Also: when the existing parent value is `null` or `undefined`, Case 1
|
|
95
|
+
now pre-materializes `accumulated[existingKey] = {}` BEFORE calling
|
|
96
|
+
`setByPath` — mirrors the auto-create semantics for top-level keys
|
|
97
|
+
that `setByPath` does for intermediates.
|
|
98
|
+
|
|
99
|
+
### Simplification: `applyDiffLocally` drops `materializePlainDotPath`
|
|
100
|
+
|
|
101
|
+
The `0.1.185` `materializePlainDotPath` private helper in
|
|
102
|
+
`SyncedDb.applyDiffLocally` is now redundant — its job is subsumed by
|
|
103
|
+
the new `setByPath` default. Removed (~60 LOC). Bracket-path
|
|
104
|
+
materialization (`materializeBracketPath`) is unchanged and still
|
|
105
|
+
owns the array-shape synthesis case.
|
|
106
|
+
|
|
107
|
+
### Real-data regression test
|
|
108
|
+
|
|
109
|
+
New fixture `test/fixtures/stuckSestevkiObiskiVetnm.json` (9 entries
|
|
110
|
+
pulled from `https://vetnm.klik.vet/api/clients` 2026-05-16,
|
|
111
|
+
heartbeat.dirtyContent.obiski). 5 entries exhibit the parent+child
|
|
112
|
+
overlap pattern; 4 entries are stuck for unrelated reasons (no
|
|
113
|
+
overlap). README documents the per-entry pattern breakdown and
|
|
114
|
+
verifies (`mongosh`) that none of the 9 `_id`s exist on the server.
|
|
115
|
+
|
|
116
|
+
New test `test/sestevkiRealProductionData.test.ts` (26 tests):
|
|
117
|
+
|
|
118
|
+
- **Replay path** — simulates the write sequence that produces each
|
|
119
|
+
stuck pattern via `mergeDirtyPath`. Asserts post-merge has no
|
|
120
|
+
parent+child overlap, descendant values reachable via canonical path.
|
|
121
|
+
- **Orthogonal merge non-destruction** — orthogonal field write on
|
|
122
|
+
legacy stuck dirty entry doesn't worsen the existing conflict
|
|
123
|
+
(recovery for those is via `repairExistingDirty` migration OR
|
|
124
|
+
app-side `preprocessDirtyItem` hook in klikvet `ocistiDirtyItem`).
|
|
125
|
+
- **Batched-vs-sequential equivalence** — `mergeDirtyChanges(batch)`
|
|
126
|
+
produces same state as N individual `mergeDirtyPath` calls.
|
|
127
|
+
- **Explicit variant coverage** — parent=`{}` + all-zero / non-zero
|
|
128
|
+
children, plus synthetic parent=null case (current fixture has no
|
|
129
|
+
null-parent overlap but the fix must handle it).
|
|
130
|
+
- **computeDiff round-trip** — for each overlap fixture, synthesize a
|
|
131
|
+
base→target pair and assert `computeDiff(base, target)` produces no
|
|
132
|
+
parent+child overlap in its diff key set.
|
|
133
|
+
|
|
134
|
+
Existing `test/computeDiff.test.ts` extended with 18 new unit tests
|
|
135
|
+
in two describe blocks (`setByPath — auto-create plain-object
|
|
136
|
+
intermediates (0.1.187)` and `mergeDirtyPath — sestevki regression`).
|
|
137
|
+
|
|
138
|
+
### Backwards compatibility
|
|
139
|
+
|
|
140
|
+
| Caller scenario | Pre-0.1.187 | Post-0.1.187 |
|
|
141
|
+
| -------------------------------------------------------- | ----------- | ------------ |
|
|
142
|
+
| `setByPath({}, "a.b", v)` | `false` | `true` (`{a:{b:v}}`) |
|
|
143
|
+
| `setByPath({a:{c:1}}, "a.b", v)` | `true` (set b alongside c) | unchanged |
|
|
144
|
+
| `setByPath({a:"str"}, "a.b", v)` | `false` | unchanged (`false`) |
|
|
145
|
+
| `setByPath({a:Date}, "a.year", 2026)` | true (mutates Date instance) | `false` (NEW guard) |
|
|
146
|
+
| `setByPath({arr:[]}, "arr.0.b", v)` | `false` | unchanged (`false`) |
|
|
147
|
+
| `setByPath({arr:[]}, "arr[id]", [{_id:id,…}])` | `true` | unchanged |
|
|
148
|
+
| `setByPath(t, "...", v, { autoCreate: false })` | (new param) | strict pre-0.1.187 mode |
|
|
149
|
+
| `mergeDirtyPath` Case 1 with `{}` parent + dot-child | siblings (BUG) | merges into parent |
|
|
150
|
+
|
|
151
|
+
The Date instance mutation case is the only INTENTIONAL behavioral
|
|
152
|
+
change beyond bug fix — that path was producing silent data corruption
|
|
153
|
+
and is now correctly refused.
|
|
154
|
+
|
|
155
|
+
### Migration path for pre-fix stuck entries
|
|
156
|
+
|
|
157
|
+
The fix prevents NEW two-form payloads. Old stuck dirty entries
|
|
158
|
+
already in IndexedDB `_dirty_changes` tables stay until either:
|
|
159
|
+
|
|
160
|
+
1. The device makes a new write that overlaps the stuck path — Case 1
|
|
161
|
+
`setByPath` will then auto-create and absorb the dotted children
|
|
162
|
+
(or Case 1's fallback will drop+promote).
|
|
163
|
+
2. The app provides a `preprocessDirtyItem` hook (`klikvet` ships one
|
|
164
|
+
in `code/klikvet/ocistiDirtyItem.ts` 0.20.123 — defensive
|
|
165
|
+
parent+child path conflict resolver that runs at upload time).
|
|
166
|
+
|
|
167
|
+
A future `SyncedDb.repairExistingDirty()` migration could replay all
|
|
168
|
+
`_dirty_changes` rows through the new `mergeDirtyChanges` for a
|
|
169
|
+
one-shot batch cleanup; not included in this release.
|
|
170
|
+
|
|
3
171
|
## 0.1.186 (2026-05-15)
|
|
4
172
|
|
|
5
173
|
### `WakeSyncManager` silences expected wake-time `timeout` errors
|
package/dist/index.js
CHANGED
|
@@ -520,18 +520,51 @@ function tokenizePath(path) {
|
|
|
520
520
|
if (buf) out.push(buf);
|
|
521
521
|
return out;
|
|
522
522
|
}
|
|
523
|
-
function setByPath(target2, path, value) {
|
|
523
|
+
function setByPath(target2, path, value, opts) {
|
|
524
524
|
if (target2 === null || target2 === void 0) return false;
|
|
525
|
+
const autoCreate = (opts == null ? void 0 : opts.autoCreate) !== false;
|
|
525
526
|
const parts = tokenizePath(path);
|
|
527
|
+
const pathHasArrayShape = parts.some(
|
|
528
|
+
(p) => p.startsWith("[") && p.endsWith("]") || /^\d+$/.test(p)
|
|
529
|
+
);
|
|
530
|
+
const canAutoCreate = autoCreate && !pathHasArrayShape;
|
|
526
531
|
let current = target2;
|
|
532
|
+
const created = [];
|
|
527
533
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
528
534
|
const part = parts[i];
|
|
529
|
-
|
|
530
|
-
|
|
535
|
+
let next = navigateSegment(current, part);
|
|
536
|
+
const isMissing = next === void 0 || next === null;
|
|
537
|
+
if (isMissing && canAutoCreate) {
|
|
538
|
+
if (isPlainObjectContainer(current)) {
|
|
539
|
+
current[part] = {};
|
|
540
|
+
created.push({ container: current, key: part });
|
|
541
|
+
next = current[part];
|
|
542
|
+
} else {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
} else if (isMissing) {
|
|
546
|
+
return false;
|
|
547
|
+
} else if (autoCreate && !pathHasArrayShape && !isPlainObjectContainer(next) && !Array.isArray(next)) {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
531
550
|
current = next;
|
|
532
551
|
}
|
|
533
552
|
const last = parts[parts.length - 1];
|
|
534
|
-
|
|
553
|
+
const ok = setSegment(current, last, value);
|
|
554
|
+
if (!ok && created.length > 0) {
|
|
555
|
+
for (let i = created.length - 1; i >= 0; i--) {
|
|
556
|
+
const { container, key } = created[i];
|
|
557
|
+
delete container[key];
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return ok;
|
|
561
|
+
}
|
|
562
|
+
function isPlainObjectContainer(value) {
|
|
563
|
+
if (value === null || value === void 0) return false;
|
|
564
|
+
if (typeof value !== "object") return false;
|
|
565
|
+
if (Array.isArray(value)) return false;
|
|
566
|
+
const proto = Object.getPrototypeOf(value);
|
|
567
|
+
return proto === Object.prototype || proto === null;
|
|
535
568
|
}
|
|
536
569
|
function navigateSegment(current, part) {
|
|
537
570
|
if (current === null || current === void 0) return void 0;
|
|
@@ -646,6 +679,10 @@ function mergeDirtyPath(accumulated, newPath, newValue) {
|
|
|
646
679
|
if (existingIsTerminal && Array.isArray(existingValue) && existingValue.length === 1) {
|
|
647
680
|
mutationTarget = existingValue[0];
|
|
648
681
|
}
|
|
682
|
+
if (!existingIsTerminal && (existingValue === null || existingValue === void 0)) {
|
|
683
|
+
accumulated[existingKey] = {};
|
|
684
|
+
mutationTarget = accumulated[existingKey];
|
|
685
|
+
}
|
|
649
686
|
if (!existingIsTerminal && newPath[existingKey.length] === "[" && canExpandArrayToBrackets(existingValue)) {
|
|
650
687
|
delete accumulated[existingKey];
|
|
651
688
|
for (const el of existingValue) {
|
|
@@ -658,7 +695,9 @@ function mergeDirtyPath(accumulated, newPath, newValue) {
|
|
|
658
695
|
const relativePath = sepChar === "[" ? newPath.substring(existingKey.length) : newPath.substring(existingKey.length + 1);
|
|
659
696
|
const ok = setByPath(mutationTarget, relativePath, newValue);
|
|
660
697
|
if (ok) return;
|
|
661
|
-
|
|
698
|
+
delete accumulated[existingKey];
|
|
699
|
+
accumulated[newPath] = newValue;
|
|
700
|
+
return;
|
|
662
701
|
}
|
|
663
702
|
}
|
|
664
703
|
const descendants = [];
|
|
@@ -6652,45 +6691,10 @@ var _SyncedDb = class _SyncedDb {
|
|
|
6652
6691
|
}
|
|
6653
6692
|
const ok = setByPath(seed, path, value);
|
|
6654
6693
|
if (ok) continue;
|
|
6655
|
-
if (!path.includes("[") && _SyncedDb.materializePlainDotPath(seed, path, value)) {
|
|
6656
|
-
continue;
|
|
6657
|
-
}
|
|
6658
6694
|
_SyncedDb.materializeBracketPath(seed, path, value, collection, fallbackId);
|
|
6659
6695
|
}
|
|
6660
6696
|
return seed;
|
|
6661
6697
|
}
|
|
6662
|
-
/**
|
|
6663
|
-
* Bracket-free dot-path fallback for `applyDiffLocally`. Walks the path,
|
|
6664
|
-
* creating missing plain-object intermediates as needed, and sets the
|
|
6665
|
-
* leaf. Refuses (returns `false`) if any segment is numeric (array
|
|
6666
|
-
* index) or `[<id>]` (bracket selector) — those need shape knowledge
|
|
6667
|
-
* outside the scope of plain-object materialization. Also refuses if
|
|
6668
|
-
* an existing intermediate is non-plain (array, Date, primitive) —
|
|
6669
|
-
* overwriting would risk silent data loss.
|
|
6670
|
-
*
|
|
6671
|
-
* Used after `setByPath` fails on a dot-only path (no `[` in `path`).
|
|
6672
|
-
* Counterpart to `materializeBracketPath` for the bracket case.
|
|
6673
|
-
*/
|
|
6674
|
-
static materializePlainDotPath(seed, path, value) {
|
|
6675
|
-
const parts = tokenizePath(path);
|
|
6676
|
-
if (parts.length < 2) return false;
|
|
6677
|
-
for (const seg of parts) {
|
|
6678
|
-
if (seg.startsWith("[") || /^\d+$/.test(seg)) return false;
|
|
6679
|
-
}
|
|
6680
|
-
let cur = seed;
|
|
6681
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
6682
|
-
const seg = parts[i];
|
|
6683
|
-
const next = cur[seg];
|
|
6684
|
-
if (next == null) {
|
|
6685
|
-
cur[seg] = {};
|
|
6686
|
-
} else if (typeof next !== "object" || Array.isArray(next) || next instanceof Date) {
|
|
6687
|
-
return false;
|
|
6688
|
-
}
|
|
6689
|
-
cur = cur[seg];
|
|
6690
|
-
}
|
|
6691
|
-
cur[parts[parts.length - 1]] = value;
|
|
6692
|
-
return true;
|
|
6693
|
-
}
|
|
6694
6698
|
/**
|
|
6695
6699
|
* Fallback for `setByPath` failures inside `applyDiffLocally`. Materializes
|
|
6696
6700
|
* missing array containers AND missing intermediate plain objects so the
|
|
@@ -519,19 +519,6 @@ export declare class SyncedDb implements I_SyncedDb {
|
|
|
519
519
|
* the input `base` reference.
|
|
520
520
|
*/
|
|
521
521
|
private static applyDiffLocally;
|
|
522
|
-
/**
|
|
523
|
-
* Bracket-free dot-path fallback for `applyDiffLocally`. Walks the path,
|
|
524
|
-
* creating missing plain-object intermediates as needed, and sets the
|
|
525
|
-
* leaf. Refuses (returns `false`) if any segment is numeric (array
|
|
526
|
-
* index) or `[<id>]` (bracket selector) — those need shape knowledge
|
|
527
|
-
* outside the scope of plain-object materialization. Also refuses if
|
|
528
|
-
* an existing intermediate is non-plain (array, Date, primitive) —
|
|
529
|
-
* overwriting would risk silent data loss.
|
|
530
|
-
*
|
|
531
|
-
* Used after `setByPath` fails on a dot-only path (no `[` in `path`).
|
|
532
|
-
* Counterpart to `materializeBracketPath` for the bracket case.
|
|
533
|
-
*/
|
|
534
|
-
private static materializePlainDotPath;
|
|
535
522
|
/**
|
|
536
523
|
* Fallback for `setByPath` failures inside `applyDiffLocally`. Materializes
|
|
537
524
|
* missing array containers AND missing intermediate plain objects so the
|
|
@@ -55,9 +55,38 @@ export declare function tokenizePath(path: string): string[];
|
|
|
55
55
|
* - Bracket ("[<_id>]") → array element matched by `_id` field
|
|
56
56
|
* - Plain ("field") → object key
|
|
57
57
|
*
|
|
58
|
-
*
|
|
58
|
+
* Auto-creation policy (when `opts.autoCreate !== false`, the **default
|
|
59
|
+
* since 0.1.187**):
|
|
60
|
+
* - PLAIN segment whose intermediate is `undefined` / `null` AND whose
|
|
61
|
+
* parent is a plain object → materialize `{}` in place and continue.
|
|
62
|
+
* - PLAIN segment whose intermediate is a primitive (string, number,
|
|
63
|
+
* boolean, Date, ObjectId, …) → REFUSE (return false). Overwriting
|
|
64
|
+
* would destroy data the caller didn't ask to delete.
|
|
65
|
+
* - NUMERIC ("0", "1") segment → unchanged (cannot safely materialize an
|
|
66
|
+
* array slot at a specific index without committing to the array
|
|
67
|
+
* shape).
|
|
68
|
+
* - BRACKET ("[<_id>]") segment → unchanged (cannot synthesize an array
|
|
69
|
+
* element with the right `_id` without the full element value).
|
|
70
|
+
*
|
|
71
|
+
* Set `opts.autoCreate = false` to restore the pre-0.1.187 strict
|
|
72
|
+
* "fail at any missing intermediate" behavior.
|
|
73
|
+
*
|
|
74
|
+
* Why auto-create is default-on: callers like `mergeDirtyPath` Case 1 and
|
|
75
|
+
* `applyDiffLocally` always WANT the value to land inside the parent
|
|
76
|
+
* object. Pre-0.1.187 `setByPath` returned `false` on missing
|
|
77
|
+
* intermediates, the merge fell through to "add as sibling key", and the
|
|
78
|
+
* dirty payload ended up with both `sestevki: {}` AND
|
|
79
|
+
* `sestevki.skupaj.prc = 0` — which mongo `$set` rejects (production
|
|
80
|
+
* vetnm 2026-05-15, 290 hits/day).
|
|
81
|
+
*
|
|
82
|
+
* @returns true if the value was successfully set, false if path traversal
|
|
83
|
+
* failed for a reason that cannot be auto-corrected (primitive
|
|
84
|
+
* intermediate, missing array element, non-plain container at a
|
|
85
|
+
* plain-segment site).
|
|
59
86
|
*/
|
|
60
|
-
export declare function setByPath(target: any, path: string, value: any
|
|
87
|
+
export declare function setByPath(target: any, path: string, value: any, opts?: {
|
|
88
|
+
autoCreate?: boolean;
|
|
89
|
+
}): boolean;
|
|
61
90
|
/**
|
|
62
91
|
* Delete the value at `path` within `target`. Sibling of `setByPath` —
|
|
63
92
|
* navigates the same tokenized path forms (numeric, bracket-by-_id, plain),
|