cry-synced-db-client 0.1.186 → 0.1.188
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 +252 -0
- package/dist/index.js +107 -40
- package/dist/src/db/SyncedDb.d.ts +27 -13
- package/dist/src/types/I_SyncedDb.d.ts +33 -0
- package/dist/src/utils/computeDiff.d.ts +31 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,257 @@
|
|
|
1
1
|
# Versions
|
|
2
2
|
|
|
3
|
+
## 0.1.188 (2026-05-15)
|
|
4
|
+
|
|
5
|
+
### `clearDirty()` — manual dirty drain + `onBeforeDirtyClearAll` callback
|
|
6
|
+
|
|
7
|
+
Public counterpart to `getDirty` / `getDirtyMeta` for the "stuck dirty
|
|
8
|
+
drain" recovery flow. Use case: a known-bad dirty entry can't upload
|
|
9
|
+
(e.g. pre-fix `sestevki` parent+descendant producing repeated 500-loops
|
|
10
|
+
on klikvet tabs running pre-0.1.185 bundles) and the operator wants to
|
|
11
|
+
forfeit the pending local intent in favor of server state — without
|
|
12
|
+
calling the heavier `dropCollection` / `dropDatabase` which also wipes
|
|
13
|
+
the main row data.
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
clearDirty(
|
|
17
|
+
collection?: string,
|
|
18
|
+
ids?: Id[],
|
|
19
|
+
calledFrom?: string,
|
|
20
|
+
): Promise<DirtyMeta[]>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Returns the `DirtyMeta[]` of every entry that was actually removed, so
|
|
24
|
+
the caller can archive what was lost.
|
|
25
|
+
|
|
26
|
+
| Call shape | Effect | Fires `onBeforeDirtyClearAll` |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `clearDirty()` / `clearDirty(undefined)` | Clear ALL dirty in EVERY collection | ✅ once per collection that has dirty |
|
|
29
|
+
| `clearDirty(coll)` | Clear all dirty in one collection | ❌ |
|
|
30
|
+
| `clearDirty(coll, ids)` | Clear specific ids in that collection | ❌ |
|
|
31
|
+
| `clearDirty(undefined, ids)` | Throws — ambiguous | — |
|
|
32
|
+
|
|
33
|
+
The new config callback:
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
onBeforeDirtyClearAll?: (info: BeforeDirtyClearAllInfo) => void;
|
|
37
|
+
|
|
38
|
+
interface BeforeDirtyClearAllInfo {
|
|
39
|
+
reason: string; // `calledFrom` if provided, else "manual"
|
|
40
|
+
collection: string;
|
|
41
|
+
items: DirtyMeta[]; // every entry in that collection (no `changes` payload)
|
|
42
|
+
calledFrom?: string; // passthrough of the third arg
|
|
43
|
+
timestamp: Date;
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Fires **only** on the clear-all path, **before** the Dexie delete runs,
|
|
48
|
+
**once per collection** with dirty. Use to archive the dirty state to
|
|
49
|
+
syslog / audit trail before it disappears. Per-collection /
|
|
50
|
+
per-id `clearDirty` calls are intentionally silent — the caller already
|
|
51
|
+
knows what they're touching.
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
new SyncedDb({
|
|
55
|
+
// ...
|
|
56
|
+
onBeforeDirtyClearAll: (info) => {
|
|
57
|
+
syslog.warn("dirty cleared", {
|
|
58
|
+
collection: info.collection,
|
|
59
|
+
count: info.items.length,
|
|
60
|
+
reason: info.reason,
|
|
61
|
+
ids: info.items.map((m) => m.id),
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Nuke everything across collections, tagged for the audit log.
|
|
67
|
+
const cleared = await syncedDb.clearDirty(undefined, undefined, "stuck-drain");
|
|
68
|
+
|
|
69
|
+
// Targeted: drop two known-bad rows in `obiski` (silent — no callback).
|
|
70
|
+
await syncedDb.clearDirty("obiski", ["6a032a59...", "6a05a1ba..."]);
|
|
71
|
+
|
|
72
|
+
// Inspect first, then decide.
|
|
73
|
+
const meta = await syncedDb.getDirtyMeta();
|
|
74
|
+
for (const [coll, items] of Object.entries(meta)) {
|
|
75
|
+
if (items.length > 100) {
|
|
76
|
+
await syncedDb.clearDirty(coll, undefined, `over-threshold:${items.length}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`clearDirty` does NOT await in-flight uploads — call `flushToServer()`
|
|
82
|
+
first if a last-chance upload is desired. It also does not touch the
|
|
83
|
+
main row data; in-mem and Dexie rows are preserved at whatever state
|
|
84
|
+
they're at (typically: the server's view if WS-push has landed
|
|
85
|
+
recently, otherwise the pre-rollback local intent).
|
|
86
|
+
|
|
87
|
+
## 0.1.187 (2026-05-16)
|
|
88
|
+
|
|
89
|
+
### Fix: `setByPath` auto-creates plain-object intermediates (root cause of `sestevki` parent+child conflict)
|
|
90
|
+
|
|
91
|
+
Production bug (klikvet vetnm, 2026-05-15, ~290 hits/day): obisk records
|
|
92
|
+
stuck dirty with mongo upload errors —
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
[SyncEngine] Sync upload error [obiski] _id=…:
|
|
96
|
+
Updating the path 'sestevki.skupaj.prc' would create a conflict at 'sestevki'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The `0.1.185` `rebaseDirtyOnServerAdvance` fix targeted the case where
|
|
100
|
+
server `_rev` advances past the local base (the `kritje + skupaj`
|
|
101
|
+
two-write scenario). But a more general root cause was still latent in
|
|
102
|
+
`setByPath`: when the function was asked to drill `skupaj.prc` into an
|
|
103
|
+
existing `sestevki: {}` accumulated parent, it returned `false`
|
|
104
|
+
because navigation hit `undefined` at the missing `skupaj`
|
|
105
|
+
intermediate — and `mergeDirtyPath` Case 1's failure handler fell
|
|
106
|
+
through to Case 3, writing the dot-path as a SIBLING key.
|
|
107
|
+
|
|
108
|
+
`applyDiffLocally` had already worked around this asymmetrically by
|
|
109
|
+
adding `materializePlainDotPath` in `0.1.185`, but that helper only
|
|
110
|
+
ran inside `applyDiffLocally` — `mergeDirtyPath` continued to use the
|
|
111
|
+
strict `setByPath` and produced the sibling-coexistence pattern in the
|
|
112
|
+
dirty payload sent to the server.
|
|
113
|
+
|
|
114
|
+
**This release lifts the fix into `setByPath` itself**, the single
|
|
115
|
+
primitive used by both code paths. Default semantics change from
|
|
116
|
+
"fail at any missing intermediate" to "auto-create empty plain-object
|
|
117
|
+
intermediate and continue":
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// Before (≤ 0.1.186): setByPath({}, "a.b", 1) → false
|
|
121
|
+
// After (≥ 0.1.187): setByPath({}, "a.b", 1) → true, target = {a: {b: 1}}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Safety guards** added to keep the change strictly bug-fix
|
|
125
|
+
direction:
|
|
126
|
+
|
|
127
|
+
- **Path look-ahead**: refuse auto-create entirely when ANY segment in
|
|
128
|
+
the path is bracket-by-id (`[<id>]`) or numeric (`0`). Those shapes
|
|
129
|
+
imply an array intermediate which `setByPath` cannot synthesize
|
|
130
|
+
without external info (which `_id`? what other elements live there?).
|
|
131
|
+
The existing `materializeBracketPath` fallback in
|
|
132
|
+
`applyDiffLocally` keeps owning that case and was untouched.
|
|
133
|
+
- **Host-type refusal**: when an intermediate exists but is a non-plain
|
|
134
|
+
host object (`Date`, `ObjectId`, `RegExp`, `Map`, …), refuse with
|
|
135
|
+
`false`. Drilling further would silently mutate the host instance
|
|
136
|
+
(e.g. `dateInstance.year = 2026`) — a data-corruption vector.
|
|
137
|
+
Arrays are EXEMPT from this refusal because bracket/numeric next
|
|
138
|
+
segments handle them correctly.
|
|
139
|
+
- **Rollback on final-segment failure**: if `setByPath` auto-creates
|
|
140
|
+
intermediates and `setSegment` ultimately fails, remove every
|
|
141
|
+
intermediate it created. The caller's target sees no orphan `{}`
|
|
142
|
+
branches.
|
|
143
|
+
- **Null treated as undefined**: a stored `null` at an intermediate
|
|
144
|
+
position is auto-created over (real production state included
|
|
145
|
+
`sestevki: null`).
|
|
146
|
+
- **`opts.autoCreate = false` opt-out**: callers that explicitly need
|
|
147
|
+
the pre-0.1.187 strict fail-at-miss behavior pass `{ autoCreate: false }`.
|
|
148
|
+
No in-tree caller does, but the opt-out keeps the API
|
|
149
|
+
backwards-compatible for external consumers.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
export function setByPath(
|
|
153
|
+
target: any,
|
|
154
|
+
path: string,
|
|
155
|
+
value: any,
|
|
156
|
+
opts?: { autoCreate?: boolean }, // default { autoCreate: true }
|
|
157
|
+
): boolean { … }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### `mergeDirtyPath` Case 1 — promote-on-failure replaces fall-through
|
|
161
|
+
|
|
162
|
+
The Case 1 fallback ("setByPath failed → fall through to Case 3
|
|
163
|
+
orthogonal") used to be the SOURCE of the parent+child sibling
|
|
164
|
+
payload. With auto-create on, `setByPath` returns `false` only for
|
|
165
|
+
genuine type clashes (primitive parent + plain-key descendant, or path
|
|
166
|
+
shape mismatch). In that case the right resolution is to drop the
|
|
167
|
+
stale parent and promote the deep path:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// 1. setByPath(mutationTarget, relativePath, newValue) returns false.
|
|
171
|
+
// 2. The two forms cannot coexist in a mongo $set.
|
|
172
|
+
// 3. Per server-wins-rebase convention: newer write wins.
|
|
173
|
+
delete accumulated[existingKey];
|
|
174
|
+
accumulated[newPath] = newValue;
|
|
175
|
+
return;
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Also: when the existing parent value is `null` or `undefined`, Case 1
|
|
179
|
+
now pre-materializes `accumulated[existingKey] = {}` BEFORE calling
|
|
180
|
+
`setByPath` — mirrors the auto-create semantics for top-level keys
|
|
181
|
+
that `setByPath` does for intermediates.
|
|
182
|
+
|
|
183
|
+
### Simplification: `applyDiffLocally` drops `materializePlainDotPath`
|
|
184
|
+
|
|
185
|
+
The `0.1.185` `materializePlainDotPath` private helper in
|
|
186
|
+
`SyncedDb.applyDiffLocally` is now redundant — its job is subsumed by
|
|
187
|
+
the new `setByPath` default. Removed (~60 LOC). Bracket-path
|
|
188
|
+
materialization (`materializeBracketPath`) is unchanged and still
|
|
189
|
+
owns the array-shape synthesis case.
|
|
190
|
+
|
|
191
|
+
### Real-data regression test
|
|
192
|
+
|
|
193
|
+
New fixture `test/fixtures/stuckSestevkiObiskiVetnm.json` (9 entries
|
|
194
|
+
pulled from `https://vetnm.klik.vet/api/clients` 2026-05-16,
|
|
195
|
+
heartbeat.dirtyContent.obiski). 5 entries exhibit the parent+child
|
|
196
|
+
overlap pattern; 4 entries are stuck for unrelated reasons (no
|
|
197
|
+
overlap). README documents the per-entry pattern breakdown and
|
|
198
|
+
verifies (`mongosh`) that none of the 9 `_id`s exist on the server.
|
|
199
|
+
|
|
200
|
+
New test `test/sestevkiRealProductionData.test.ts` (26 tests):
|
|
201
|
+
|
|
202
|
+
- **Replay path** — simulates the write sequence that produces each
|
|
203
|
+
stuck pattern via `mergeDirtyPath`. Asserts post-merge has no
|
|
204
|
+
parent+child overlap, descendant values reachable via canonical path.
|
|
205
|
+
- **Orthogonal merge non-destruction** — orthogonal field write on
|
|
206
|
+
legacy stuck dirty entry doesn't worsen the existing conflict
|
|
207
|
+
(recovery for those is via `repairExistingDirty` migration OR
|
|
208
|
+
app-side `preprocessDirtyItem` hook in klikvet `ocistiDirtyItem`).
|
|
209
|
+
- **Batched-vs-sequential equivalence** — `mergeDirtyChanges(batch)`
|
|
210
|
+
produces same state as N individual `mergeDirtyPath` calls.
|
|
211
|
+
- **Explicit variant coverage** — parent=`{}` + all-zero / non-zero
|
|
212
|
+
children, plus synthetic parent=null case (current fixture has no
|
|
213
|
+
null-parent overlap but the fix must handle it).
|
|
214
|
+
- **computeDiff round-trip** — for each overlap fixture, synthesize a
|
|
215
|
+
base→target pair and assert `computeDiff(base, target)` produces no
|
|
216
|
+
parent+child overlap in its diff key set.
|
|
217
|
+
|
|
218
|
+
Existing `test/computeDiff.test.ts` extended with 18 new unit tests
|
|
219
|
+
in two describe blocks (`setByPath — auto-create plain-object
|
|
220
|
+
intermediates (0.1.187)` and `mergeDirtyPath — sestevki regression`).
|
|
221
|
+
|
|
222
|
+
### Backwards compatibility
|
|
223
|
+
|
|
224
|
+
| Caller scenario | Pre-0.1.187 | Post-0.1.187 |
|
|
225
|
+
| -------------------------------------------------------- | ----------- | ------------ |
|
|
226
|
+
| `setByPath({}, "a.b", v)` | `false` | `true` (`{a:{b:v}}`) |
|
|
227
|
+
| `setByPath({a:{c:1}}, "a.b", v)` | `true` (set b alongside c) | unchanged |
|
|
228
|
+
| `setByPath({a:"str"}, "a.b", v)` | `false` | unchanged (`false`) |
|
|
229
|
+
| `setByPath({a:Date}, "a.year", 2026)` | true (mutates Date instance) | `false` (NEW guard) |
|
|
230
|
+
| `setByPath({arr:[]}, "arr.0.b", v)` | `false` | unchanged (`false`) |
|
|
231
|
+
| `setByPath({arr:[]}, "arr[id]", [{_id:id,…}])` | `true` | unchanged |
|
|
232
|
+
| `setByPath(t, "...", v, { autoCreate: false })` | (new param) | strict pre-0.1.187 mode |
|
|
233
|
+
| `mergeDirtyPath` Case 1 with `{}` parent + dot-child | siblings (BUG) | merges into parent |
|
|
234
|
+
|
|
235
|
+
The Date instance mutation case is the only INTENTIONAL behavioral
|
|
236
|
+
change beyond bug fix — that path was producing silent data corruption
|
|
237
|
+
and is now correctly refused.
|
|
238
|
+
|
|
239
|
+
### Migration path for pre-fix stuck entries
|
|
240
|
+
|
|
241
|
+
The fix prevents NEW two-form payloads. Old stuck dirty entries
|
|
242
|
+
already in IndexedDB `_dirty_changes` tables stay until either:
|
|
243
|
+
|
|
244
|
+
1. The device makes a new write that overlaps the stuck path — Case 1
|
|
245
|
+
`setByPath` will then auto-create and absorb the dotted children
|
|
246
|
+
(or Case 1's fallback will drop+promote).
|
|
247
|
+
2. The app provides a `preprocessDirtyItem` hook (`klikvet` ships one
|
|
248
|
+
in `code/klikvet/ocistiDirtyItem.ts` 0.20.123 — defensive
|
|
249
|
+
parent+child path conflict resolver that runs at upload time).
|
|
250
|
+
|
|
251
|
+
A future `SyncedDb.repairExistingDirty()` migration could replay all
|
|
252
|
+
`_dirty_changes` rows through the new `mergeDirtyChanges` for a
|
|
253
|
+
one-shot batch cleanup; not included in this release.
|
|
254
|
+
|
|
3
255
|
## 0.1.186 (2026-05-15)
|
|
4
256
|
|
|
5
257
|
### `WakeSyncManager` silences expected wake-time `timeout` errors
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
698
|
+
delete accumulated[existingKey];
|
|
699
|
+
accumulated[newPath] = newValue;
|
|
700
|
+
return;
|
|
662
701
|
}
|
|
663
702
|
}
|
|
664
703
|
const descendants = [];
|
|
@@ -4445,6 +4484,7 @@ var _SyncedDb = class _SyncedDb {
|
|
|
4445
4484
|
this.onEviction = config.onEviction;
|
|
4446
4485
|
this.onSaveIdMismatch = config.onSaveIdMismatch;
|
|
4447
4486
|
this.onUploadSkip = config.onUploadSkip;
|
|
4487
|
+
this.onBeforeDirtyClearAll = config.onBeforeDirtyClearAll;
|
|
4448
4488
|
this.evictStaleRecordsEveryHrs = (_e = config.evictStaleRecordsEveryHrs) != null ? _e : 0;
|
|
4449
4489
|
this.scopeExitLookbehindMs = (_f = config.scopeExitLookbehindMs) != null ? _f : 0;
|
|
4450
4490
|
this.evictOnWake = (_g = config.evictOnWake) != null ? _g : false;
|
|
@@ -5720,6 +5760,68 @@ var _SyncedDb = class _SyncedDb {
|
|
|
5720
5760
|
}
|
|
5721
5761
|
return result;
|
|
5722
5762
|
}
|
|
5763
|
+
/**
|
|
5764
|
+
* Manually clear pending dirty changes WITHOUT touching the main
|
|
5765
|
+
* row data. Counterpart to `getDirty` / `getDirtyMeta` for the
|
|
5766
|
+
* "stuck dirty drain" recovery flow — when a known-bad dirty entry
|
|
5767
|
+
* can't be uploaded (e.g. pre-fix sestevki parent+descendant
|
|
5768
|
+
* conflict producing repeated 500s) and the operator wants to
|
|
5769
|
+
* forfeit the pending local intent in favor of server state.
|
|
5770
|
+
*
|
|
5771
|
+
* Three call shapes:
|
|
5772
|
+
*
|
|
5773
|
+
* | Call | Effect |
|
|
5774
|
+
* |---|---|
|
|
5775
|
+
* | `clearDirty()` / `clearDirty(undefined)` | Clear ALL dirty in EVERY collection. Fires `onBeforeDirtyClearAll` once per collection that has dirty (BEFORE the Dexie delete). |
|
|
5776
|
+
* | `clearDirty(coll)` | Clear all dirty in one collection. No callback. |
|
|
5777
|
+
* | `clearDirty(coll, ids)` | Clear specific ids in that collection. No callback. |
|
|
5778
|
+
* | `clearDirty(undefined, ids)` | Throws — ambiguous (which collection do the ids belong to?). |
|
|
5779
|
+
*
|
|
5780
|
+
* Returns the `DirtyMeta[]` of every entry that was actually
|
|
5781
|
+
* removed so the caller can log / archive what was lost.
|
|
5782
|
+
*
|
|
5783
|
+
* Pairs naturally with `getDirtyMeta()` for the inspect-then-clear
|
|
5784
|
+
* pattern. Doesn't await in-flight uploads — caller's
|
|
5785
|
+
* responsibility to call `flushToServer()` first if a last-chance
|
|
5786
|
+
* upload is desired.
|
|
5787
|
+
*/
|
|
5788
|
+
async clearDirty(collection, ids, calledFrom) {
|
|
5789
|
+
if (!collection && ids && ids.length > 0) {
|
|
5790
|
+
throw new Error(
|
|
5791
|
+
`[SyncedDb] clearDirty: 'ids' requires a 'collection' \u2014 ids alone are ambiguous across collections.`
|
|
5792
|
+
);
|
|
5793
|
+
}
|
|
5794
|
+
const cleared = [];
|
|
5795
|
+
if (!collection) {
|
|
5796
|
+
for (const [name] of this.collections) {
|
|
5797
|
+
const metas2 = await this.dexieDb.getDirtyMeta(name);
|
|
5798
|
+
if (metas2.length === 0) continue;
|
|
5799
|
+
this.safeCallback(this.onBeforeDirtyClearAll, {
|
|
5800
|
+
reason: calledFrom != null ? calledFrom : "manual",
|
|
5801
|
+
collection: name,
|
|
5802
|
+
items: metas2,
|
|
5803
|
+
calledFrom,
|
|
5804
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
5805
|
+
});
|
|
5806
|
+
await this.dexieDb.clearDirtyChanges(name);
|
|
5807
|
+
for (const m of metas2) cleared.push(m);
|
|
5808
|
+
}
|
|
5809
|
+
return cleared;
|
|
5810
|
+
}
|
|
5811
|
+
this.assertCollection(collection);
|
|
5812
|
+
if (ids && ids.length > 0) {
|
|
5813
|
+
const idStrings = new Set(ids.map((id) => String(id)));
|
|
5814
|
+
const allMetas = await this.dexieDb.getDirtyMeta(collection);
|
|
5815
|
+
for (const m of allMetas) {
|
|
5816
|
+
if (idStrings.has(String(m.id))) cleared.push(m);
|
|
5817
|
+
}
|
|
5818
|
+
await this.dexieDb.clearDirtyChangesBatch(collection, ids);
|
|
5819
|
+
return cleared;
|
|
5820
|
+
}
|
|
5821
|
+
const metas = await this.dexieDb.getDirtyMeta(collection);
|
|
5822
|
+
await this.dexieDb.clearDirtyChanges(collection);
|
|
5823
|
+
return metas;
|
|
5824
|
+
}
|
|
5723
5825
|
// ==================== Data Deletion ====================
|
|
5724
5826
|
async dropCollection(collection, force = false) {
|
|
5725
5827
|
this.assertCollection(collection);
|
|
@@ -6652,45 +6754,10 @@ var _SyncedDb = class _SyncedDb {
|
|
|
6652
6754
|
}
|
|
6653
6755
|
const ok = setByPath(seed, path, value);
|
|
6654
6756
|
if (ok) continue;
|
|
6655
|
-
if (!path.includes("[") && _SyncedDb.materializePlainDotPath(seed, path, value)) {
|
|
6656
|
-
continue;
|
|
6657
|
-
}
|
|
6658
6757
|
_SyncedDb.materializeBracketPath(seed, path, value, collection, fallbackId);
|
|
6659
6758
|
}
|
|
6660
6759
|
return seed;
|
|
6661
6760
|
}
|
|
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
|
-
}
|
|
6694
6761
|
/**
|
|
6695
6762
|
* Fallback for `setByPath` failures inside `applyDiffLocally`. Materializes
|
|
6696
6763
|
* missing array containers AND missing intermediate plain objects so the
|
|
@@ -56,6 +56,7 @@ export declare class SyncedDb implements I_SyncedDb {
|
|
|
56
56
|
private readonly onEviction?;
|
|
57
57
|
private readonly onSaveIdMismatch?;
|
|
58
58
|
private readonly onUploadSkip?;
|
|
59
|
+
private readonly onBeforeDirtyClearAll?;
|
|
59
60
|
private readonly evictStaleRecordsEveryHrs;
|
|
60
61
|
private readonly scopeExitLookbehindMs;
|
|
61
62
|
private readonly evictOnWake;
|
|
@@ -238,6 +239,32 @@ export declare class SyncedDb implements I_SyncedDb {
|
|
|
238
239
|
getOnWakeSync(): ((info: import("./types/managers").WakeSyncInfo) => void) | undefined;
|
|
239
240
|
getDirty<T extends DbEntity>(): Promise<Readonly<Record<string, readonly T[]>>>;
|
|
240
241
|
getDirtyMeta(): Promise<Readonly<Record<string, readonly DirtyMeta[]>>>;
|
|
242
|
+
/**
|
|
243
|
+
* Manually clear pending dirty changes WITHOUT touching the main
|
|
244
|
+
* row data. Counterpart to `getDirty` / `getDirtyMeta` for the
|
|
245
|
+
* "stuck dirty drain" recovery flow — when a known-bad dirty entry
|
|
246
|
+
* can't be uploaded (e.g. pre-fix sestevki parent+descendant
|
|
247
|
+
* conflict producing repeated 500s) and the operator wants to
|
|
248
|
+
* forfeit the pending local intent in favor of server state.
|
|
249
|
+
*
|
|
250
|
+
* Three call shapes:
|
|
251
|
+
*
|
|
252
|
+
* | Call | Effect |
|
|
253
|
+
* |---|---|
|
|
254
|
+
* | `clearDirty()` / `clearDirty(undefined)` | Clear ALL dirty in EVERY collection. Fires `onBeforeDirtyClearAll` once per collection that has dirty (BEFORE the Dexie delete). |
|
|
255
|
+
* | `clearDirty(coll)` | Clear all dirty in one collection. No callback. |
|
|
256
|
+
* | `clearDirty(coll, ids)` | Clear specific ids in that collection. No callback. |
|
|
257
|
+
* | `clearDirty(undefined, ids)` | Throws — ambiguous (which collection do the ids belong to?). |
|
|
258
|
+
*
|
|
259
|
+
* Returns the `DirtyMeta[]` of every entry that was actually
|
|
260
|
+
* removed so the caller can log / archive what was lost.
|
|
261
|
+
*
|
|
262
|
+
* Pairs naturally with `getDirtyMeta()` for the inspect-then-clear
|
|
263
|
+
* pattern. Doesn't await in-flight uploads — caller's
|
|
264
|
+
* responsibility to call `flushToServer()` first if a last-chance
|
|
265
|
+
* upload is desired.
|
|
266
|
+
*/
|
|
267
|
+
clearDirty(collection?: string, ids?: Id[], calledFrom?: string): Promise<DirtyMeta[]>;
|
|
241
268
|
dropCollection(collection: string, force?: boolean): Promise<void>;
|
|
242
269
|
dropDatabase(force?: boolean): Promise<void>;
|
|
243
270
|
/**
|
|
@@ -519,19 +546,6 @@ export declare class SyncedDb implements I_SyncedDb {
|
|
|
519
546
|
* the input `base` reference.
|
|
520
547
|
*/
|
|
521
548
|
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
549
|
/**
|
|
536
550
|
* Fallback for `setByPath` failures inside `applyDiffLocally`. Materializes
|
|
537
551
|
* missing array containers AND missing intermediate plain objects so the
|
|
@@ -91,6 +91,28 @@ export interface SaveIdMismatchInfo {
|
|
|
91
91
|
/** Timestamp when mismatch detected */
|
|
92
92
|
timestamp: Date;
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Callback payload fired by `clearDirty()` when called WITHOUT a
|
|
96
|
+
* `collection` argument (clear-all path). One invocation per
|
|
97
|
+
* collection that has ≥1 dirty entry, fired BEFORE the underlying
|
|
98
|
+
* Dexie delete runs — caller can archive/inspect the records.
|
|
99
|
+
*
|
|
100
|
+
* Per-collection `clearDirty(coll, ...)` calls do NOT fire this
|
|
101
|
+
* callback (targeted operation; caller already knows what they're
|
|
102
|
+
* touching).
|
|
103
|
+
*/
|
|
104
|
+
export interface BeforeDirtyClearAllInfo {
|
|
105
|
+
/** What triggered the clear-all (e.g. "manual", custom string from caller). */
|
|
106
|
+
reason: string;
|
|
107
|
+
/** Collection whose dirty entries are about to be deleted. */
|
|
108
|
+
collection: string;
|
|
109
|
+
/** Meta of every dirty entry in this collection (no `changes` payload). */
|
|
110
|
+
items: import("./I_DexieDb").DirtyMeta[];
|
|
111
|
+
/** Optional caller tag passed through `clearDirty(undefined, undefined, calledFrom)`. */
|
|
112
|
+
calledFrom?: string;
|
|
113
|
+
/** Timestamp when the callback fires. */
|
|
114
|
+
timestamp: Date;
|
|
115
|
+
}
|
|
94
116
|
/**
|
|
95
117
|
* Callback payload for server write requests (before sending)
|
|
96
118
|
*/
|
|
@@ -649,6 +671,17 @@ export interface SyncedDbConfig {
|
|
|
649
671
|
* where `this._id` and `this.protokol._id` had drifted apart.
|
|
650
672
|
*/
|
|
651
673
|
onSaveIdMismatch?: (info: SaveIdMismatchInfo) => void;
|
|
674
|
+
/**
|
|
675
|
+
* Fired by `clearDirty()` (no collection argument — clear-all path)
|
|
676
|
+
* BEFORE the Dexie delete runs, once per collection that has
|
|
677
|
+
* dirty entries. Use to archive the dirty state to syslog / audit
|
|
678
|
+
* trail before it's gone, or to abort by throwing (the throw
|
|
679
|
+
* aborts only the in-flight clear, doesn't propagate).
|
|
680
|
+
*
|
|
681
|
+
* Per-collection `clearDirty(coll, ...)` calls are SILENT — they
|
|
682
|
+
* don't fire this callback.
|
|
683
|
+
*/
|
|
684
|
+
onBeforeDirtyClearAll?: (info: BeforeDirtyClearAllInfo) => void;
|
|
652
685
|
/**
|
|
653
686
|
* Enable in-memory object metadata feature.
|
|
654
687
|
* When true, collections with hasMetadata=true will have their metadata callbacks invoked
|
|
@@ -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
|
-
*
|
|
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
|
|
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),
|