cry-synced-db-client 0.1.203 → 0.1.204

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +35 -2
  2. package/dist/index.js +131 -121
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Versions
2
2
 
3
+ ## 0.1.204 (2026-06-17)
4
+
5
+ ### Per-collection error isolation in `uploadDirtyItems`
6
+
7
+ Celoten per-collection cikel (`getDirty`, `getByIds`, `saveMany`,
8
+ `incrementDirtyUploadAttempts`, ...) je zdaj zavit v `try/catch`. Če Dexie
9
+ operacija za eno kolekcijo vrže (npr. corrupted transaction, IndexedDB error),
10
+ odpade samo tista kolekcija — ostale se normalno pošljejo naprej.
11
+
12
+ **Before:** ena kolekcija pade → celoten upload crasne, vse ostale collection
13
+ dirty entry-ji ostanejo neposlani do naslednjega sync cycle-a.
14
+ **After:** napaka se logira (`console.error`), zanka gre na naslednjo kolekcijo.
15
+
16
+ 876 pass, 0 fail.
17
+
3
18
  ## 0.1.203 (2026-06-15)
4
19
 
5
20
  ### `onDirtyItemStuck` callback zdaj prejme tudi `itemsContent: DirtyChange[]`
@@ -8,6 +23,7 @@
8
23
  entry-ji (vključno s `changes` payload-om), ne samo `DirtyMeta[]` z metapodatki.
9
24
 
10
25
  **Affected paths:**
26
+
11
27
  - `callOnDirtyItemStuck` v `SyncEngine.ts`: async, fetcha polne entry-je prek
12
28
  `dexieDb.getDirtyChangesBatch()` in jih posreduje kot `itemsContent`
13
29
 
@@ -30,11 +46,13 @@ callback dobi poleg metapodatkov (`id`, `collection`, `stuckSince`, ...) tudi
30
46
  `changes` payload z dejanskimi field-level spremembami.
31
47
 
32
48
  **Affected paths:**
49
+
33
50
  - `clearDirty()` clear-all path: fetcha `DirtyChange[]` prek `getDirtyChangesBatch`
34
51
  - `discardStuckItems()`: zamenja `getDirty` + `find` (ki je vračal
35
52
  `Partial<LocalDbEntity>[]`) z `getDirtyChangesBatch` za pravilne `DirtyChange[]`
36
53
 
37
54
  **Type change (backward-compatible):**
55
+
38
56
  - `items` v `BeforeDirtyClearAllInfo`: `DirtyMeta[]` → `DirtyChange[]`
39
57
  - `DirtyChange` je superset of `DirtyMeta`; vsa obstoječa polja (`id`,
40
58
  `collection`, `createdAt`, `updatedAt`, `stuckSince`, ...) so še vedno na voljo
@@ -54,11 +72,13 @@ bi postali stuck — zdaj se po 3 skipih (vsak sync da 2 incrementa = main + fol
54
72
  dobijo `stuckSince`.
55
73
 
56
74
  **Internal:**
75
+
57
76
  - V `uploadDirtyItems` zbira `preprocessSkippedIds` v obeh poteh, batch-write v
58
77
  `incrementDirtyUploadAttempts` takoj za `for` zanko (en DB write za vse skipane
59
78
  iteme namesto N write-ov)
60
79
 
61
80
  **Tests:** 5 novih testov v `test/preprocessDirtyItem.test.ts`:
81
+
62
82
  - `undefined/throw: numUploadAttempts increments across sync cycles, eventually stuck`
63
83
  - `undefined/throw: onDirtyItemStuck fires when item becomes stuck`
64
84
  - `mixed: stuck item via getStuckItems while non-stuck dirty still active`
@@ -72,17 +92,21 @@ dobijo `stuckSince`.
72
92
  Nov mehanizem za detekcijo dirty itemov, ki jih server vztrajno zavrača.
73
93
 
74
94
  **Fields na `DirtyChange` / `DirtyMeta`:**
95
+
75
96
  - `firstUploadAttempt?`, `lastUploadAttempt?`, `numUploadAttempts?`, `stuckSince?`
76
97
  - `stuckSince` se nastavi ko `numUploadAttempts > DIRTY_STUCK_AFTER_UPLOAD_ATTEMPTS` (2)
77
98
 
78
99
  **Nove metode na `SyncedDb`:**
100
+
79
101
  - `getStuckItems()` — vrne samo stuck dirty iteme po kolekcijah (`Record<collection, DirtyMeta[]>`)
80
102
  - `discardStuckItems(calledFrom?)` — zbriše vse stuck dirty iteme, sproži `onBeforeDirtyClearAll` z `reason: "discard-stuck"`
81
103
 
82
104
  **Nov callback:**
105
+
83
106
  - `onDirtyItemStuck(info: DirtyItemStuckInfo)` — sproži se ko item prvič postane stuck (po 3. neuspelem uploadu). Vsebuje `collection`, `items: DirtyMeta[]`, `calledFrom`, `timestamp`.
84
107
 
85
108
  **Internal:**
109
+
86
110
  - `I_DexieDb.incrementDirtyUploadAttempts(collection, ids)` — nova metoda, vrača `DirtyMeta[]` na novo stuck itemov
87
111
  - Vgrajena v `SyncEngine.uploadDirtyItems` in `uploadDirtyItemsForCollection` — per-collection result (errored ids) in catch block (network/timeout)
88
112
  - `DexieDb.getDirtyMeta` sedaj vključuje nova polja v izhod
@@ -105,6 +129,7 @@ Razlika od `refreshInBackground`: `refreshImmediately` je blokiren — počaka
105
129
  na odgovor serverja in vrne ažuriran podatek.
106
130
 
107
131
  Internal:
132
+
108
133
  - `_refreshImmediately()` — nova zasebna metoda: fetča s serverja, primerja
109
134
  `_rev`, posodobi Dexie samo če server novejši; za iteme z dirty spremembami
110
135
  delegira `processCollectionServerData`
@@ -129,7 +154,6 @@ in-mem. `referToServer` zdaj resnično awaita sveže podatke s serverja.
129
154
  Internal: `ensureItemsAreLoaded` — odstranjena Dexie pre-check (`getByIds`
130
155
  → `missingIds`).
131
156
 
132
-
133
157
  ## 0.1.194 (2026-06-09)
134
158
 
135
159
  ### Preload status `ready` fix
@@ -139,7 +163,6 @@ je `state === "hydrated"`, ne glede na `itemCount` ali `everDownloaded`.
139
163
  Prazen (še nikoli sinhroniziran) direktorij je še vedno ready — svežina
140
164
  podatkov je globalni koncept (`lastSuccessfulServerSync`).
141
165
 
142
-
143
166
  ## 0.1.193 (2026-05-25)
144
167
 
145
168
  Adds per-collection preload status reporting (non-breaking, additive).
@@ -277,6 +300,7 @@ batched download leaves collections registered; next auto-sync tick
277
300
  retries via the normal sync flow.
278
301
 
279
302
  Compared to looping `addCollectionToSync(spec)` per item:
303
+
280
304
  - N collections × per-collection RTT (sequential or `Promise.all`)
281
305
  → 1 RTT for the whole batch.
282
306
  - Hydration also parallelized.
@@ -668,6 +692,7 @@ paths are preserved. After the prune, `entry.baseTs` / `entry.baseRev`
668
692
  advance to the new floor.
669
693
 
670
694
  Implemented in:
695
+
671
696
  - `src/db/DexieDb.ts` — new module-level `rebaseDirtyOnServerAdvance`
672
697
  called from both `addDirtyChange` and `addDirtyChangesBatch` before
673
698
  `mergeDirtyChanges`.
@@ -737,6 +762,7 @@ drop silently. These fields are server-mirrored — local materialization
737
762
  with partial data could diverge from the canonical state.
738
763
 
739
764
  Tests:
765
+
740
766
  - `test/applyDiffLocallyObiskShape.test.ts` — 5 new cases covering
741
767
  multi-segment prefix shapes plus the `_`-prefix silent-drop contract.
742
768
  - `test/applyDiffLocallyMaterialize.test.ts` — flipped the
@@ -836,6 +862,7 @@ sentry/log dashboards.
836
862
 
837
863
  What's NOT routed through `networkError` (intentional — these are real
838
864
  bugs regardless of network state):
865
+
839
866
  - Caller bugs (falsy `_id`, missing `_id`, id mismatch, no id provided)
840
867
  - Dexie failures (`bulkPut failed`, `Failed to write to Dexie`, etc.)
841
868
  - Consumer-supplied callback throws (`onSyncEnd callback failed`, etc.)
@@ -1015,6 +1042,7 @@ in the same scannable line as the tag while the full error object remains
1015
1042
  attached for devtools inspection.
1016
1043
 
1017
1044
  Also fixed:
1045
+
1018
1046
  - `SyncedDb.findById`: bare `console.error(err)` → tag line first, Error
1019
1047
  object as second arg.
1020
1048
  - `Ebus2ProxyNotifier`: WebSocket error event and server-error payload now
@@ -1236,6 +1264,7 @@ Behavior on subsequent calls (inspected against the existing config's
1236
1264
  spec; the new config (whatever shape) drives future syncs alone.
1237
1265
 
1238
1266
  Constraints:
1267
+
1239
1268
  - Dexie schema must already declare the table (Dexie does not support
1240
1269
  adding tables to an open database). The auto-register handles only the
1241
1270
  runtime SyncedDb-level config.
@@ -1549,6 +1578,7 @@ spread that would replace top-level array/object fields wholesale and
1549
1578
  drop nested fields the caller's `update` didn't mention.
1550
1579
 
1551
1580
  Replaced with `applyDiffLocally(base, diff, id)`:
1581
+
1552
1582
  1. Deep-clone `base` (currentMem ?? existing) via `safeDeepClone`
1553
1583
  (handles Date and `ObjectId`-like values; avoids `structuredClone`
1554
1584
  throwing on bson class instances)
@@ -1565,6 +1595,7 @@ Replaced with `applyDiffLocally(base, diff, id)`:
1565
1595
  Reverted automatic `_id` stamping for objects appearing as array elements.
1566
1596
  If an array of objects lacks `_id`, the caller's element shape is now
1567
1597
  preserved. This allows callers to mix:
1598
+
1568
1599
  - Whole-element bracket replace: `update.postavke = [{...}]`
1569
1600
  - Bracket-by-_id sub-field path: `update["postavke[<id>].field"] = value`
1570
1601
  in the same payload without the client mutating element identity.
@@ -1583,6 +1614,7 @@ in the same payload without the client mutating element identity.
1583
1614
  | Different `_id` set | mixed: `$pull` + `$push` + sub-field |
1584
1615
 
1585
1616
  For composition changes, `computeDiff` now emits:
1617
+
1586
1618
  - **Removed `_id`**: `arr[<id>] = undefined` (server: `$pull`)
1587
1619
  - **Added `_id`**: `arr[<id>] = [element]` (server: `$concatArrays + $filter`)
1588
1620
  - **Retained `_id`**: element-wise sub-field via `arr[<id>].field`
@@ -2090,6 +2122,7 @@ streams separately. **Non-breaking**: consumers that destructure
2090
2122
  `{ collection, loaded, total }` and ignore `phase` keep working unchanged.
2091
2123
 
2092
2124
  Type change in `I_SyncedDb.SyncedDbConfig` and internal `SyncEngineCallbacks`:
2125
+
2093
2126
  ```ts
2094
2127
  onSyncProgress?: (info: {
2095
2128
  phase: 'dexie' | 'server';
package/dist/index.js CHANGED
@@ -3528,59 +3528,80 @@ var _SyncEngine = class _SyncEngine {
3528
3528
  async uploadDirtyItems(calledFrom) {
3529
3529
  var _a, _b;
3530
3530
  const collectionBatches = [];
3531
- for (const [collectionName] of this.collections) {
3532
- const dirtyChanges = await this.dexieDb.getDirty(collectionName);
3533
- if (dirtyChanges.length === 0) continue;
3534
- const dirtyChangesMap = /* @__PURE__ */ new Map();
3535
- for (const dirtyItem of dirtyChanges) {
3536
- dirtyChangesMap.set(String(dirtyItem._id), dirtyItem);
3537
- }
3538
- const updates = [];
3539
- const skipped = [];
3540
- const ids = dirtyChanges.map((dc) => dc._id);
3541
- const fullItems = await this.dexieDb.getByIds(
3542
- collectionName,
3543
- ids
3544
- );
3545
- const orphanReconstructed = [];
3546
- for (let i = 0; i < fullItems.length; i++) {
3547
- const fullItem = fullItems[i];
3548
- const id = ids[i];
3549
- if (fullItem) {
3550
- const delta = dirtyChangesMap.get(String(fullItem._id));
3551
- if (delta) {
3552
- const currentServerRev = typeof fullItem._rev === "number" ? fullItem._rev : void 0;
3553
- updates.push({ _id: fullItem._id, delta, currentServerRev });
3531
+ for (const [collectionName] of this.collections)
3532
+ try {
3533
+ const dirtyChanges = await this.dexieDb.getDirty(collectionName);
3534
+ if (dirtyChanges.length === 0) continue;
3535
+ const dirtyChangesMap = /* @__PURE__ */ new Map();
3536
+ for (const dirtyItem of dirtyChanges) {
3537
+ dirtyChangesMap.set(String(dirtyItem._id), dirtyItem);
3538
+ }
3539
+ const updates = [];
3540
+ const skipped = [];
3541
+ const ids = dirtyChanges.map((dc) => dc._id);
3542
+ const fullItems = await this.dexieDb.getByIds(
3543
+ collectionName,
3544
+ ids
3545
+ );
3546
+ const orphanReconstructed = [];
3547
+ for (let i = 0; i < fullItems.length; i++) {
3548
+ const fullItem = fullItems[i];
3549
+ const id = ids[i];
3550
+ if (fullItem) {
3551
+ const delta = dirtyChangesMap.get(String(fullItem._id));
3552
+ if (delta) {
3553
+ const currentServerRev = typeof fullItem._rev === "number" ? fullItem._rev : void 0;
3554
+ updates.push({ _id: fullItem._id, delta, currentServerRev });
3555
+ } else {
3556
+ skipped.push({
3557
+ _id: String(fullItem._id),
3558
+ reason: "no-delta-for-fullitem"
3559
+ });
3560
+ }
3561
+ } else if (id != null) {
3562
+ const delta = dirtyChangesMap.get(String(id));
3563
+ if (delta) {
3564
+ const reconstructed = __spreadProps(__spreadValues({}, delta), { _id: id });
3565
+ orphanReconstructed.push(reconstructed);
3566
+ updates.push({ _id: id, delta });
3567
+ } else {
3568
+ skipped.push({ _id: String(id), reason: "no-delta-for-orphan" });
3569
+ }
3554
3570
  } else {
3555
- skipped.push({
3556
- _id: String(fullItem._id),
3557
- reason: "no-delta-for-fullitem"
3558
- });
3571
+ skipped.push({ _id: "<null>", reason: "no-fullitem-no-id" });
3559
3572
  }
3560
- } else if (id != null) {
3561
- const delta = dirtyChangesMap.get(String(id));
3562
- if (delta) {
3563
- const reconstructed = __spreadProps(__spreadValues({}, delta), { _id: id });
3564
- orphanReconstructed.push(reconstructed);
3565
- updates.push({ _id: id, delta });
3566
- } else {
3567
- skipped.push({ _id: String(id), reason: "no-delta-for-orphan" });
3573
+ }
3574
+ if (orphanReconstructed.length > 0) {
3575
+ await this.dexieDb.saveMany(collectionName, orphanReconstructed);
3576
+ }
3577
+ if (updates.length === 0) {
3578
+ console.warn(
3579
+ `[SyncEngine] uploadDirtyItems: ${collectionName} has`,
3580
+ dirtyChanges.length,
3581
+ "dirty entries but 0 resolvable items",
3582
+ skipped
3583
+ );
3584
+ if (this.callbacks.onUploadSkip) {
3585
+ try {
3586
+ this.callbacks.onUploadSkip({
3587
+ collection: collectionName,
3588
+ reason: "no-resolvable-items",
3589
+ dirtyCount: dirtyChanges.length,
3590
+ skippedIds: skipped.slice(0, 20).map((s) => s._id),
3591
+ skipReasons: skipped.slice(0, 20),
3592
+ calledFrom,
3593
+ timestamp: /* @__PURE__ */ new Date()
3594
+ });
3595
+ } catch (err) {
3596
+ console.error(
3597
+ `[SyncEngine] onUploadSkip callback failed: ${err}`,
3598
+ err
3599
+ );
3600
+ }
3568
3601
  }
3569
- } else {
3570
- skipped.push({ _id: "<null>", reason: "no-fullitem-no-id" });
3602
+ continue;
3571
3603
  }
3572
- }
3573
- if (orphanReconstructed.length > 0) {
3574
- await this.dexieDb.saveMany(collectionName, orphanReconstructed);
3575
- }
3576
- if (updates.length === 0) {
3577
- console.warn(
3578
- `[SyncEngine] uploadDirtyItems: ${collectionName} has`,
3579
- dirtyChanges.length,
3580
- "dirty entries but 0 resolvable items",
3581
- skipped
3582
- );
3583
- if (this.callbacks.onUploadSkip) {
3604
+ if (skipped.length > 0 && this.callbacks.onUploadSkip) {
3584
3605
  try {
3585
3606
  this.callbacks.onUploadSkip({
3586
3607
  collection: collectionName,
@@ -3598,86 +3619,75 @@ var _SyncEngine = class _SyncEngine {
3598
3619
  );
3599
3620
  }
3600
3621
  }
3601
- continue;
3602
- }
3603
- if (skipped.length > 0 && this.callbacks.onUploadSkip) {
3604
- try {
3605
- this.callbacks.onUploadSkip({
3606
- collection: collectionName,
3607
- reason: "no-resolvable-items",
3608
- dirtyCount: dirtyChanges.length,
3609
- skippedIds: skipped.slice(0, 20).map((s) => s._id),
3610
- skipReasons: skipped.slice(0, 20),
3611
- calledFrom,
3612
- timestamp: /* @__PURE__ */ new Date()
3613
- });
3614
- } catch (err) {
3615
- console.error(
3616
- `[SyncEngine] onUploadSkip callback failed: ${err}`,
3617
- err
3622
+ const mappedUpdates = [];
3623
+ const preprocessSkippedIds = [];
3624
+ for (const item of updates) {
3625
+ const dirtyBaseRev = typeof item.delta._rev === "number" ? item.delta._rev : void 0;
3626
+ const stripped = stripServerManagedFromChanges(
3627
+ item.delta
3618
3628
  );
3619
- }
3620
- }
3621
- const mappedUpdates = [];
3622
- const preprocessSkippedIds = [];
3623
- for (const item of updates) {
3624
- const dirtyBaseRev = typeof item.delta._rev === "number" ? item.delta._rev : void 0;
3625
- const stripped = stripServerManagedFromChanges(
3626
- item.delta
3627
- );
3628
- const fixed = fixDotnetArrays(
3629
- stripped,
3630
- item.currentServerRev,
3631
- dirtyBaseRev
3632
- );
3633
- let candidate = {
3634
- _id: item._id,
3635
- update: fixed
3636
- };
3637
- if (this.preprocessDirtyItem) {
3638
- try {
3639
- const processed = this.preprocessDirtyItem(
3640
- candidate,
3641
- collectionName
3642
- );
3643
- if (processed === void 0) {
3629
+ const fixed = fixDotnetArrays(
3630
+ stripped,
3631
+ item.currentServerRev,
3632
+ dirtyBaseRev
3633
+ );
3634
+ let candidate = {
3635
+ _id: item._id,
3636
+ update: fixed
3637
+ };
3638
+ if (this.preprocessDirtyItem) {
3639
+ try {
3640
+ const processed = this.preprocessDirtyItem(
3641
+ candidate,
3642
+ collectionName
3643
+ );
3644
+ if (processed === void 0) {
3645
+ preprocessSkippedIds.push(item._id);
3646
+ continue;
3647
+ }
3648
+ candidate = processed;
3649
+ } catch (err) {
3650
+ console.error(
3651
+ `[SyncEngine] preprocessDirtyItem(${collectionName}) failed for _id=${String(item._id)}; keeping dirty for retry:`,
3652
+ err
3653
+ );
3644
3654
  preprocessSkippedIds.push(item._id);
3645
3655
  continue;
3646
3656
  }
3647
- candidate = processed;
3648
- } catch (err) {
3649
- console.error(
3650
- `[SyncEngine] preprocessDirtyItem(${collectionName}) failed for _id=${String(item._id)}; keeping dirty for retry:`,
3651
- err
3652
- );
3653
- preprocessSkippedIds.push(item._id);
3654
- continue;
3655
3657
  }
3658
+ mappedUpdates.push({
3659
+ _id: candidate._id,
3660
+ _rev: dirtyBaseRev != null ? dirtyBaseRev : 0,
3661
+ update: candidate.update
3662
+ });
3656
3663
  }
3657
- mappedUpdates.push({
3658
- _id: candidate._id,
3659
- _rev: dirtyBaseRev != null ? dirtyBaseRev : 0,
3660
- update: candidate.update
3661
- });
3662
- }
3663
- if (preprocessSkippedIds.length > 0) {
3664
- const newlyStuck = await this.dexieDb.incrementDirtyUploadAttempts(
3665
- collectionName,
3666
- preprocessSkippedIds
3664
+ if (preprocessSkippedIds.length > 0) {
3665
+ const newlyStuck = await this.dexieDb.incrementDirtyUploadAttempts(
3666
+ collectionName,
3667
+ preprocessSkippedIds
3668
+ );
3669
+ await this.callOnDirtyItemStuck(
3670
+ collectionName,
3671
+ newlyStuck,
3672
+ calledFrom
3673
+ );
3674
+ }
3675
+ if (mappedUpdates.length === 0) continue;
3676
+ collectionBatches.push([
3677
+ {
3678
+ collection: collectionName,
3679
+ batch: {
3680
+ updates: mappedUpdates,
3681
+ deletes: []
3682
+ }
3683
+ }
3684
+ ]);
3685
+ } catch (err) {
3686
+ console.error(
3687
+ `[SyncEngine] uploadDirtyItems: failed for collection "${collectionName}":`,
3688
+ err
3667
3689
  );
3668
- await this.callOnDirtyItemStuck(collectionName, newlyStuck, calledFrom);
3669
3690
  }
3670
- if (mappedUpdates.length === 0) continue;
3671
- collectionBatches.push([
3672
- {
3673
- collection: collectionName,
3674
- batch: {
3675
- updates: mappedUpdates,
3676
- deletes: []
3677
- }
3678
- }
3679
- ]);
3680
- }
3681
3691
  if (collectionBatches.length === 0) {
3682
3692
  return { sentCount: 0 };
3683
3693
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.203",
3
+ "version": "0.1.204",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",