cry-synced-db-client 0.1.185 → 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 CHANGED
@@ -1,5 +1,205 @@
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
+
171
+ ## 0.1.186 (2026-05-15)
172
+
173
+ ### `WakeSyncManager` silences expected wake-time `timeout` errors
174
+
175
+ After sleep → wake the network is typically still reconnecting, so the
176
+ sync triggered by `WakeSyncManager` frequently times out on the first
177
+ attempt. The self-healing scheduler (auto-sync timer + reconnect timer
178
+ from `0.1.141`) retries the next tick and recovers without operator
179
+ attention — so logging the wake-time timeout was pure noise.
180
+
181
+ ```ts
182
+ // Before: every timeout fired through networkError (info/error per online state).
183
+ this.deps.sync(`wake-sync:${trigger}`).catch((err) => {
184
+ networkError(`[WakeSync] Wake sync (${trigger}) failed:`, err);
185
+ });
186
+
187
+ // After: timeouts dropped silently; all other failures still routed
188
+ // through networkError for severity demotion.
189
+ this.deps.sync(`wake-sync:${trigger}`).catch((err: unknown) => {
190
+ const msg = (err as { message?: string } | null)?.message ?? "";
191
+ if (/timeout/i.test(msg)) return;
192
+ networkError(`[WakeSync] Wake sync (${trigger}) failed:`, err);
193
+ });
194
+ ```
195
+
196
+ Match is case-insensitive on the error `message` substring `timeout` —
197
+ covers `RestProxy` timeout (`"REST call <op> timed out after Nms"`),
198
+ worker-call timeout, and the streaming-sync abort path. Non-timeout
199
+ failures (server 5xx, auth, msgpack parse) keep their existing
200
+ severity. Wake events that succeed continue to fire `onWakeSync` and
201
+ hydrate the local cache as before.
202
+
3
203
  ## 0.1.185 (2026-05-14)
4
204
 
5
205
  ### Fix: server-wins rebase resolves dirty-merge path conflicts (`sestevki`)
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
- const next = navigateSegment(current, part);
530
- if (next === void 0) return false;
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
- return setSegment(current, last, value);
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
- break;
698
+ delete accumulated[existingKey];
699
+ accumulated[newPath] = newValue;
700
+ return;
662
701
  }
663
702
  }
664
703
  const descendants = [];
@@ -4304,6 +4343,9 @@ var WakeSyncManager = class {
4304
4343
  }
4305
4344
  }
4306
4345
  this.deps.sync(`wake-sync:${trigger}`).catch((err) => {
4346
+ var _a;
4347
+ const msg = (_a = err == null ? void 0 : err.message) != null ? _a : "";
4348
+ if (/timeout/i.test(msg)) return;
4307
4349
  networkError(`[WakeSync] Wake sync (${trigger}) failed:`, err);
4308
4350
  });
4309
4351
  }, this.debounceMs);
@@ -6649,45 +6691,10 @@ var _SyncedDb = class _SyncedDb {
6649
6691
  }
6650
6692
  const ok = setByPath(seed, path, value);
6651
6693
  if (ok) continue;
6652
- if (!path.includes("[") && _SyncedDb.materializePlainDotPath(seed, path, value)) {
6653
- continue;
6654
- }
6655
6694
  _SyncedDb.materializeBracketPath(seed, path, value, collection, fallbackId);
6656
6695
  }
6657
6696
  return seed;
6658
6697
  }
6659
- /**
6660
- * Bracket-free dot-path fallback for `applyDiffLocally`. Walks the path,
6661
- * creating missing plain-object intermediates as needed, and sets the
6662
- * leaf. Refuses (returns `false`) if any segment is numeric (array
6663
- * index) or `[<id>]` (bracket selector) — those need shape knowledge
6664
- * outside the scope of plain-object materialization. Also refuses if
6665
- * an existing intermediate is non-plain (array, Date, primitive) —
6666
- * overwriting would risk silent data loss.
6667
- *
6668
- * Used after `setByPath` fails on a dot-only path (no `[` in `path`).
6669
- * Counterpart to `materializeBracketPath` for the bracket case.
6670
- */
6671
- static materializePlainDotPath(seed, path, value) {
6672
- const parts = tokenizePath(path);
6673
- if (parts.length < 2) return false;
6674
- for (const seg of parts) {
6675
- if (seg.startsWith("[") || /^\d+$/.test(seg)) return false;
6676
- }
6677
- let cur = seed;
6678
- for (let i = 0; i < parts.length - 1; i++) {
6679
- const seg = parts[i];
6680
- const next = cur[seg];
6681
- if (next == null) {
6682
- cur[seg] = {};
6683
- } else if (typeof next !== "object" || Array.isArray(next) || next instanceof Date) {
6684
- return false;
6685
- }
6686
- cur = cur[seg];
6687
- }
6688
- cur[parts[parts.length - 1]] = value;
6689
- return true;
6690
- }
6691
6698
  /**
6692
6699
  * Fallback for `setByPath` failures inside `applyDiffLocally`. Materializes
6693
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
- * @returns true if the value was successfully set, false if path traversal failed.
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): boolean;
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.185",
3
+ "version": "0.1.187",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",