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 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
- const next = navigateSegment(current, part);
530
- if (next === void 0) return false;
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
- return setSegment(current, last, value);
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
- break;
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
- * @returns true if the value was successfully set, false if path traversal failed.
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): boolean;
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.186",
3
+ "version": "0.1.188",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",