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 +103 -0
- package/dist/index.js +60 -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,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 (
|
|
6649
|
-
|
|
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
|
*
|