cry-synced-db-client 0.1.184 → 0.1.186

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,108 @@
1
1
  # Versions
2
2
 
3
+ ## 0.1.186 (2026-05-15)
4
+
5
+ ### `WakeSyncManager` silences expected wake-time `timeout` errors
6
+
7
+ After sleep → wake the network is typically still reconnecting, so the
8
+ sync triggered by `WakeSyncManager` frequently times out on the first
9
+ attempt. The self-healing scheduler (auto-sync timer + reconnect timer
10
+ from `0.1.141`) retries the next tick and recovers without operator
11
+ attention — so logging the wake-time timeout was pure noise.
12
+
13
+ ```ts
14
+ // Before: every timeout fired through networkError (info/error per online state).
15
+ this.deps.sync(`wake-sync:${trigger}`).catch((err) => {
16
+ networkError(`[WakeSync] Wake sync (${trigger}) failed:`, err);
17
+ });
18
+
19
+ // After: timeouts dropped silently; all other failures still routed
20
+ // through networkError for severity demotion.
21
+ this.deps.sync(`wake-sync:${trigger}`).catch((err: unknown) => {
22
+ const msg = (err as { message?: string } | null)?.message ?? "";
23
+ if (/timeout/i.test(msg)) return;
24
+ networkError(`[WakeSync] Wake sync (${trigger}) failed:`, err);
25
+ });
26
+ ```
27
+
28
+ Match is case-insensitive on the error `message` substring `timeout` —
29
+ covers `RestProxy` timeout (`"REST call <op> timed out after Nms"`),
30
+ worker-call timeout, and the streaming-sync abort path. Non-timeout
31
+ failures (server 5xx, auth, msgpack parse) keep their existing
32
+ severity. Wake events that succeed continue to fire `onWakeSync` and
33
+ hydrate the local cache as before.
34
+
35
+ ## 0.1.185 (2026-05-14)
36
+
37
+ ### Fix: server-wins rebase resolves dirty-merge path conflicts (`sestevki`)
38
+
39
+ Production bug (klikvet, 2026-05-14): obisk records stuck dirty with
40
+ repeated upload errors —
41
+
42
+ ```
43
+ [SyncEngine] Sync upload error [obiski] _id=…:
44
+ Updating the path 'sestevki.skupaj.prc' would create a conflict at 'sestevki'
45
+ ```
46
+
47
+ Reproduction sequence (see `test/sestevkiPathConflict.test.ts`):
48
+
49
+ 1. Local record exists; `sestevki` is missing.
50
+ 2. App writes a partial `sestevki = {kritje: {…}}`. `computeDiff` emits
51
+ a single full-replace key; dirty entry stores
52
+ `changes = {sestevki: {kritje}}` at `baseRev = 1`.
53
+ 3. WS-push of a server-side update lands `sestevki.skupaj = {…}` into
54
+ the Dexie row and bumps `_rev` to `2`. The dirty entry's
55
+ accumulated `sestevki` value is NOT touched.
56
+ 4. App writes `sestevki.skupaj.prc = 50`. `computeDiff` against the
57
+ advanced Dexie row emits a deep dot-path. `mergeDirtyPath`'s
58
+ Case 1 (`setByPath` into the accumulated parent) fails on the
59
+ missing intermediate `skupaj` and falls through to Case 3, which
60
+ writes the deep path as a SIBLING key alongside the parent. The
61
+ dirty payload now carries BOTH `sestevki = {…}` AND
62
+ `sestevki.skupaj.prc = 50` — mongo `$set` refuses every retry.
63
+
64
+ **Rule** (per user directive): for any given path, when the server's
65
+ `_rev` is greater than the local `_rev` the path was emitted against,
66
+ **the server wins** — drop the locally-accumulated path. `baseMeta`
67
+ passed into `addDirtyChange*` is sampled from the live Dexie row by
68
+ `save`, so `baseMeta._rev > entry.baseRev` is the trigger.
69
+
70
+ Surgical scope: only existing paths that OVERLAP with an incoming path
71
+ (equal / ancestor / descendant) are dropped. Unrelated accumulated
72
+ paths are preserved. After the prune, `entry.baseTs` / `entry.baseRev`
73
+ advance to the new floor.
74
+
75
+ Implemented in:
76
+ - `src/db/DexieDb.ts` — new module-level `rebaseDirtyOnServerAdvance`
77
+ called from both `addDirtyChange` and `addDirtyChangesBatch` before
78
+ `mergeDirtyChanges`.
79
+ - `test/mocks/MockDexieDb.ts` — same logic so the bun-test suite
80
+ exercises the same merge semantics (the mock previously used shallow
81
+ spread `{...existing.changes, ...changes}` and masked this bug).
82
+ - `src/utils/computeDiff.ts` — new exported `pathsOverlap(a, b)` helper.
83
+
84
+ ### Fix: `applyDiffLocally` materializes plain dot-path intermediates
85
+
86
+ Companion fix: even with the dirty merge cleaned up, the second save's
87
+ deep dot-path `sestevki.skupaj.prc` still needs to land in the local
88
+ in-mem snapshot. The seed (cloned from `currentMem ?? existing`) may
89
+ be missing the `skupaj` intermediate (in-mem wasn't touched by the
90
+ external WS-push). Previously `setByPath` failed → fell to
91
+ `materializeBracketPath` → dropped with `no bracket segment found`.
92
+
93
+ New `materializePlainDotPath` private helper walks a dot-only path,
94
+ auto-creates plain-object intermediates, and lands the value:
95
+
96
+ | Shape | Result |
97
+ |---|---|
98
+ | `a.b.c = v` with seed missing `b` and `c` | `seed.a = {b: {c: v}}` |
99
+ | Existing intermediate is not a plain object (Date, primitive, array) | Refuses (false), falls through |
100
+ | Path contains numeric or bracket segment | Refuses (false), falls through to `materializeBracketPath` |
101
+
102
+ Called between `setByPath` and `materializeBracketPath` in
103
+ `applyDiffLocally`. Bracket-containing paths still take the existing
104
+ bracket-fallback path.
105
+
3
106
  ## 0.1.184 (2026-05-14)
4
107
 
5
108
  ### Fix: `applyDiffLocally` supports multi-segment plain prefix before bracket-by-id
package/dist/index.js CHANGED
@@ -488,6 +488,9 @@ function isDescendantOrEqual(path, candidate) {
488
488
  const next = path[candidate.length];
489
489
  return next === "." || next === "[";
490
490
  }
491
+ function pathsOverlap(a, b) {
492
+ return isDescendantOrEqual(a, b) || isDescendantOrEqual(b, a);
493
+ }
491
494
  function tokenizePath(path) {
492
495
  const out = [];
493
496
  let buf = "";
@@ -4301,6 +4304,9 @@ var WakeSyncManager = class {
4301
4304
  }
4302
4305
  }
4303
4306
  this.deps.sync(`wake-sync:${trigger}`).catch((err) => {
4307
+ var _a;
4308
+ const msg = (_a = err == null ? void 0 : err.message) != null ? _a : "";
4309
+ if (/timeout/i.test(msg)) return;
4304
4310
  networkError(`[WakeSync] Wake sync (${trigger}) failed:`, err);
4305
4311
  });
4306
4312
  }, this.debounceMs);
@@ -6645,12 +6651,46 @@ var _SyncedDb = class _SyncedDb {
6645
6651
  continue;
6646
6652
  }
6647
6653
  const ok = setByPath(seed, path, value);
6648
- if (!ok) {
6649
- _SyncedDb.materializeBracketPath(seed, path, value, collection, fallbackId);
6654
+ if (ok) continue;
6655
+ if (!path.includes("[") && _SyncedDb.materializePlainDotPath(seed, path, value)) {
6656
+ continue;
6650
6657
  }
6658
+ _SyncedDb.materializeBracketPath(seed, path, value, collection, fallbackId);
6651
6659
  }
6652
6660
  return seed;
6653
6661
  }
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
+ }
6654
6694
  /**
6655
6695
  * Fallback for `setByPath` failures inside `applyDiffLocally`. Materializes
6656
6696
  * missing array containers AND missing intermediate plain objects so the
@@ -6905,6 +6945,22 @@ function isMetaOnlyChanges(changes) {
6905
6945
  }
6906
6946
  return true;
6907
6947
  }
6948
+ function rebaseDirtyOnServerAdvance(entry, newChanges, baseMeta) {
6949
+ const newRev = baseMeta == null ? void 0 : baseMeta._rev;
6950
+ if (typeof newRev !== "number" || typeof entry.baseRev !== "number" || newRev <= entry.baseRev) {
6951
+ return;
6952
+ }
6953
+ const existingPaths = Object.keys(entry.changes);
6954
+ for (const newPath of Object.keys(newChanges)) {
6955
+ for (const existingPath of existingPaths) {
6956
+ if (pathsOverlap(newPath, existingPath)) {
6957
+ delete entry.changes[existingPath];
6958
+ }
6959
+ }
6960
+ }
6961
+ entry.baseTs = baseMeta._ts;
6962
+ entry.baseRev = newRev;
6963
+ }
6908
6964
  var DexieDb = class extends Dexie {
6909
6965
  constructor(tenant, collectionConfigs) {
6910
6966
  super(`synced-db-${tenant}`);
@@ -7074,6 +7130,7 @@ var DexieDb = class extends Dexie {
7074
7130
  const existing = await this.dirtyChanges.get([collection, stringId]);
7075
7131
  const now = Date.now();
7076
7132
  if (existing) {
7133
+ rebaseDirtyOnServerAdvance(existing, changes, baseMeta);
7077
7134
  mergeDirtyChanges(existing.changes, changes);
7078
7135
  existing.updatedAt = now;
7079
7136
  await this.dirtyChanges.put(existing);
@@ -7104,6 +7161,7 @@ var DexieDb = class extends Dexie {
7104
7161
  const stringId = this.idToString(changeItem.id);
7105
7162
  const existing = existingEntries[i];
7106
7163
  if (existing) {
7164
+ rebaseDirtyOnServerAdvance(existing, changeItem.changes, changeItem.baseMeta);
7107
7165
  mergeDirtyChanges(existing.changes, changeItem.changes);
7108
7166
  existing.updatedAt = now;
7109
7167
  toWrite.push(existing);
@@ -519,6 +519,19 @@ 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;
522
535
  /**
523
536
  * Fallback for `setByPath` failures inside `applyDiffLocally`. Materializes
524
537
  * missing array containers AND missing intermediate plain objects so the
@@ -32,6 +32,14 @@ export declare function computeDiff(existing: Record<string, any> | null | undef
32
32
  * to legacy Object.assign accumulation.
33
33
  */
34
34
  export declare function hasDotNotationPaths(changes: Record<string, any>): boolean;
35
+ /**
36
+ * Symmetric overlap check used by dirty-merge rebase: paths overlap when
37
+ * they are equal OR one is an ancestor of the other. Returning `true`
38
+ * means a single mongo `$set` containing both would be rejected with a
39
+ * "Updating the path X would create a conflict at Y" — or at minimum the
40
+ * two writes target the same subtree.
41
+ */
42
+ export declare function pathsOverlap(a: string, b: string): boolean;
35
43
  /**
36
44
  * Tokenize a dot-notation path that may contain bracket-id segments.
37
45
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.184",
3
+ "version": "0.1.186",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",