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 +71 -0
- package/dist/index.js +57 -2
- package/dist/src/db/SyncedDb.d.ts +13 -0
- package/dist/src/utils/computeDiff.d.ts +8 -0
- package/package.json +1 -1
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 (
|
|
6649
|
-
|
|
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
|
*
|