cry-synced-db-client 0.1.184 → 0.1.185

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,76 @@
1
1
  # Versions
2
2
 
3
+ ## 0.1.185 (2026-05-14)
4
+
5
+ ### Fix: server-wins rebase resolves dirty-merge path conflicts (`sestevki`)
6
+
7
+ Production bug (klikvet, 2026-05-14): obisk records stuck dirty with
8
+ repeated 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
+ Reproduction sequence (see `test/sestevkiPathConflict.test.ts`):
16
+
17
+ 1. Local record exists; `sestevki` is missing.
18
+ 2. App writes a partial `sestevki = {kritje: {…}}`. `computeDiff` emits
19
+ a single full-replace key; dirty entry stores
20
+ `changes = {sestevki: {kritje}}` at `baseRev = 1`.
21
+ 3. WS-push of a server-side update lands `sestevki.skupaj = {…}` into
22
+ the Dexie row and bumps `_rev` to `2`. The dirty entry's
23
+ accumulated `sestevki` value is NOT touched.
24
+ 4. App writes `sestevki.skupaj.prc = 50`. `computeDiff` against the
25
+ advanced Dexie row emits a deep dot-path. `mergeDirtyPath`'s
26
+ Case 1 (`setByPath` into the accumulated parent) fails on the
27
+ missing intermediate `skupaj` and falls through to Case 3, which
28
+ writes the deep path as a SIBLING key alongside the parent. The
29
+ dirty payload now carries BOTH `sestevki = {…}` AND
30
+ `sestevki.skupaj.prc = 50` — mongo `$set` refuses every retry.
31
+
32
+ **Rule** (per user directive): for any given path, when the server's
33
+ `_rev` is greater than the local `_rev` the path was emitted against,
34
+ **the server wins** — drop the locally-accumulated path. `baseMeta`
35
+ passed into `addDirtyChange*` is sampled from the live Dexie row by
36
+ `save`, so `baseMeta._rev > entry.baseRev` is the trigger.
37
+
38
+ Surgical scope: only existing paths that OVERLAP with an incoming path
39
+ (equal / ancestor / descendant) are dropped. Unrelated accumulated
40
+ paths are preserved. After the prune, `entry.baseTs` / `entry.baseRev`
41
+ advance to the new floor.
42
+
43
+ Implemented in:
44
+ - `src/db/DexieDb.ts` — new module-level `rebaseDirtyOnServerAdvance`
45
+ called from both `addDirtyChange` and `addDirtyChangesBatch` before
46
+ `mergeDirtyChanges`.
47
+ - `test/mocks/MockDexieDb.ts` — same logic so the bun-test suite
48
+ exercises the same merge semantics (the mock previously used shallow
49
+ spread `{...existing.changes, ...changes}` and masked this bug).
50
+ - `src/utils/computeDiff.ts` — new exported `pathsOverlap(a, b)` helper.
51
+
52
+ ### Fix: `applyDiffLocally` materializes plain dot-path intermediates
53
+
54
+ Companion fix: even with the dirty merge cleaned up, the second save's
55
+ deep dot-path `sestevki.skupaj.prc` still needs to land in the local
56
+ in-mem snapshot. The seed (cloned from `currentMem ?? existing`) may
57
+ be missing the `skupaj` intermediate (in-mem wasn't touched by the
58
+ external WS-push). Previously `setByPath` failed → fell to
59
+ `materializeBracketPath` → dropped with `no bracket segment found`.
60
+
61
+ New `materializePlainDotPath` private helper walks a dot-only path,
62
+ auto-creates plain-object intermediates, and lands the value:
63
+
64
+ | Shape | Result |
65
+ |---|---|
66
+ | `a.b.c = v` with seed missing `b` and `c` | `seed.a = {b: {c: v}}` |
67
+ | Existing intermediate is not a plain object (Date, primitive, array) | Refuses (false), falls through |
68
+ | Path contains numeric or bracket segment | Refuses (false), falls through to `materializeBracketPath` |
69
+
70
+ Called between `setByPath` and `materializeBracketPath` in
71
+ `applyDiffLocally`. Bracket-containing paths still take the existing
72
+ bracket-fallback path.
73
+
3
74
  ## 0.1.184 (2026-05-14)
4
75
 
5
76
  ### 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 = "";
@@ -6645,12 +6648,46 @@ var _SyncedDb = class _SyncedDb {
6645
6648
  continue;
6646
6649
  }
6647
6650
  const ok = setByPath(seed, path, value);
6648
- if (!ok) {
6649
- _SyncedDb.materializeBracketPath(seed, path, value, collection, fallbackId);
6651
+ if (ok) continue;
6652
+ if (!path.includes("[") && _SyncedDb.materializePlainDotPath(seed, path, value)) {
6653
+ continue;
6650
6654
  }
6655
+ _SyncedDb.materializeBracketPath(seed, path, value, collection, fallbackId);
6651
6656
  }
6652
6657
  return seed;
6653
6658
  }
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
+ }
6654
6691
  /**
6655
6692
  * Fallback for `setByPath` failures inside `applyDiffLocally`. Materializes
6656
6693
  * missing array containers AND missing intermediate plain objects so the
@@ -6905,6 +6942,22 @@ function isMetaOnlyChanges(changes) {
6905
6942
  }
6906
6943
  return true;
6907
6944
  }
6945
+ function rebaseDirtyOnServerAdvance(entry, newChanges, baseMeta) {
6946
+ const newRev = baseMeta == null ? void 0 : baseMeta._rev;
6947
+ if (typeof newRev !== "number" || typeof entry.baseRev !== "number" || newRev <= entry.baseRev) {
6948
+ return;
6949
+ }
6950
+ const existingPaths = Object.keys(entry.changes);
6951
+ for (const newPath of Object.keys(newChanges)) {
6952
+ for (const existingPath of existingPaths) {
6953
+ if (pathsOverlap(newPath, existingPath)) {
6954
+ delete entry.changes[existingPath];
6955
+ }
6956
+ }
6957
+ }
6958
+ entry.baseTs = baseMeta._ts;
6959
+ entry.baseRev = newRev;
6960
+ }
6908
6961
  var DexieDb = class extends Dexie {
6909
6962
  constructor(tenant, collectionConfigs) {
6910
6963
  super(`synced-db-${tenant}`);
@@ -7074,6 +7127,7 @@ var DexieDb = class extends Dexie {
7074
7127
  const existing = await this.dirtyChanges.get([collection, stringId]);
7075
7128
  const now = Date.now();
7076
7129
  if (existing) {
7130
+ rebaseDirtyOnServerAdvance(existing, changes, baseMeta);
7077
7131
  mergeDirtyChanges(existing.changes, changes);
7078
7132
  existing.updatedAt = now;
7079
7133
  await this.dirtyChanges.put(existing);
@@ -7104,6 +7158,7 @@ var DexieDb = class extends Dexie {
7104
7158
  const stringId = this.idToString(changeItem.id);
7105
7159
  const existing = existingEntries[i];
7106
7160
  if (existing) {
7161
+ rebaseDirtyOnServerAdvance(existing, changeItem.changes, changeItem.baseMeta);
7107
7162
  mergeDirtyChanges(existing.changes, changeItem.changes);
7108
7163
  existing.updatedAt = now;
7109
7164
  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.185",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",