cry-synced-db-client 0.1.181 → 0.1.183

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,111 @@
1
1
  # Versions
2
2
 
3
+ ## 0.1.183 (2026-05-14)
4
+
5
+ ### Fix: `applyDiffLocally` materializes deep sub-field paths under bracket-by-id
6
+
7
+ `materializeBracketPath` (the fallback when `setByPath` can't reach the
8
+ target) capped at `tokens.length ≤ 3`. Any deeper path was dropped with
9
+ a `dropping bracket-path diff entry (unsupported token count N)` log
10
+ (silent when first segment started with `_`, visible otherwise).
11
+
12
+ Real-world obisk records carry nested objects on every `zaracunaj`
13
+ element — `karence.karence.meso`, `uporabljeneSerijskeNaNapravi.serijske`,
14
+ etc. Saves like
15
+
16
+ ```typescript
17
+ syncedDb.save("obiski", id, {
18
+ "zaracunaj[<newId>].karence.karence.meso": 5,
19
+ });
20
+ ```
21
+
22
+ dropped the data even though the intent is unambiguous and the data
23
+ shape is valid.
24
+
25
+ **New behavior** in `materializeBracketPath`:
26
+
27
+ | Shape | Result |
28
+ |---|---|
29
+ | `polje[<id>] = <obj>` | `seed.polje = [<obj>]` (unchanged) |
30
+ | `polje[<id>].field = v` | `seed.polje = [{_id: <id>, field: v}]` (unchanged) |
31
+ | `polje[<id>].a.b.c = v` (new) | `seed.polje = [{_id: <id>, a: {b: {c: v}}}]` |
32
+ | Matching `_id` exists, intermediate missing (new) | Walk into the element, create missing intermediates on the way down, set the leaf. No duplicate element. |
33
+
34
+ Multi-bracket paths (e.g. `polje[a].sub[b]`, `polje[a].b.sub[c]`) still
35
+ drop — they require shape knowledge the fallback can't reconstruct;
36
+ server applies them and the next sync hydrates local.
37
+
38
+ Tests: `test/applyDiffLocallyObiskShape.test.ts` (18 cases) — uses a
39
+ trimmed real-shape obisk fixture with the two _id-arrays (top-level
40
+ `zaracunaj` and `_dobavnica_saved.postavke` nested under a plain
41
+ container), all the composition-change scenarios, the deep-bracket-path
42
+ edge cases that used to drop, and a "no drop-log during typical
43
+ roundtrip" guard. 3 cases were red before the fix; all pass after.
44
+ Full suite: 762 bun + 18 vitest pass.
45
+
46
+ ## 0.1.182 (2026-05-14)
47
+
48
+ ### `networkError` utility — quiet log noise during sustained offline
49
+
50
+ New `src/utils/networkError.ts`:
51
+
52
+ ```ts
53
+ export function networkError(message: string, ...rest: unknown[]): void {
54
+ const isOnline =
55
+ typeof navigator === "undefined" || navigator.onLine !== false;
56
+ if (isOnline) {
57
+ console.error(message, ...rest);
58
+ } else {
59
+ console.info(message, ...rest);
60
+ }
61
+ }
62
+ ```
63
+
64
+ Applied to 15 sites where the failure mode reduces to "REST/WS
65
+ unreachable because the network is down":
66
+
67
+ | Module | Site |
68
+ |---|---|
69
+ | `ConnectionManager` | Failed to go online after forceOffline release |
70
+ | `ConnectionManager` | Auto-sync failed |
71
+ | `ConnectionManager` | Reconnect tryGoOnline failed |
72
+ | `PendingChangesManager` | REST upload failed |
73
+ | `WakeSyncManager` | Wake sync (`<trigger>`) failed |
74
+ | `SyncEngine` | uploadDirtyItems failed (download succeeded) |
75
+ | `SyncEngine` | Sync failed |
76
+ | `SyncedDb` | tryGoOnline on becameLeader failed |
77
+ | `SyncedDb` | referToServer failed for `<collection>` |
78
+ | `SyncedDb` | refreshInBackground failed for `<collection>` |
79
+ | `SyncedDb` | Failed to hard delete `<id>` |
80
+ | `SyncedDb` | `[evict]` server-assisted pass failed |
81
+ | `SyncedDb` | `[evict]` server-assisted batch failed |
82
+ | `Ebus2ProxyNotifier` | WebSocket error: `<event.type>` |
83
+ | `Ebus2ProxyNotifier` | Reconnection failed |
84
+
85
+ Effect: a 5-minute offline period that previously produced ~25–30
86
+ `console.error` lines (auto-sync ticks every 60s, reconnect probes
87
+ every 60s, WS reconnect backoff, dirty-flush retries) now produces 0.
88
+ The events are still logged at `console.info` so an operator inspecting
89
+ them sees the full timeline — they just don't surface as "errors" in
90
+ sentry/log dashboards.
91
+
92
+ What's NOT routed through `networkError` (intentional — these are real
93
+ bugs regardless of network state):
94
+ - Caller bugs (falsy `_id`, missing `_id`, id mismatch, no id provided)
95
+ - Dexie failures (`bulkPut failed`, `Failed to write to Dexie`, etc.)
96
+ - Consumer-supplied callback throws (`onSyncEnd callback failed`, etc.)
97
+ - WS protocol errors (malformed msgpack, server-side error frames)
98
+ - Server-side per-item rejection (`Sync upload error [coll] _id=...`)
99
+ - `DB-WARNING` per-item warnings
100
+ - BroadcastChannel failures (cross-tab, local, not network)
101
+
102
+ Caveat: `navigator.onLine` only reports the OS network interface, not
103
+ server reachability. WiFi-on-but-server-unreachable still logs at error
104
+ severity, which is the correct behavior — that failure is unexpected
105
+ from the browser's point of view.
106
+
107
+ Bundle: 356.0 KB → 356.2 KB. Tests: 736 bun + 18 vitest still pass.
108
+
3
109
  ## 0.1.181 (2026-05-14)
4
110
 
5
111
  ### `measureEndToEndRtt` now rides `callWorker` over the WebSocket
package/dist/index.js CHANGED
@@ -732,6 +732,16 @@ function childPathForArrayElement(prefix, element, index) {
732
732
  return prefix ? `${prefix}.${index}` : String(index);
733
733
  }
734
734
 
735
+ // src/utils/networkError.ts
736
+ function networkError(message, ...rest) {
737
+ const isOnline = typeof navigator === "undefined" || navigator.onLine !== false;
738
+ if (isOnline) {
739
+ console.error(message, ...rest);
740
+ } else {
741
+ console.info(message, ...rest);
742
+ }
743
+ }
744
+
735
745
  // src/db/managers/InMemManager.ts
736
746
  var InMemManager = class {
737
747
  constructor(config) {
@@ -1422,7 +1432,7 @@ var ConnectionManager = class {
1422
1432
  } else {
1423
1433
  this.deps.tryBecomeLeader();
1424
1434
  this.tryGoOnline().catch((err) => {
1425
- console.error(`[Connection] Failed to go online after forceOffline release: ${err}`, err);
1435
+ networkError(`[Connection] Failed to go online after forceOffline release: ${err}`, err);
1426
1436
  });
1427
1437
  }
1428
1438
  }
@@ -1480,7 +1490,7 @@ var ConnectionManager = class {
1480
1490
  this.autoSyncTimer = setInterval(() => {
1481
1491
  if (this.forcedOffline || !this.online) return;
1482
1492
  this.deps.sync(`interval ${intervalMs}ms`).catch((err) => {
1483
- console.error(`[Connection] Auto-sync failed: ${err}`, err);
1493
+ networkError(`[Connection] Auto-sync failed: ${err}`, err);
1484
1494
  });
1485
1495
  }, intervalMs);
1486
1496
  }
@@ -1489,7 +1499,7 @@ var ConnectionManager = class {
1489
1499
  this.reconnectTimer = setInterval(() => {
1490
1500
  if (this.forcedOffline || this.online || this.tryGoOnlineInFlight) return;
1491
1501
  this.tryGoOnline().catch((err) => {
1492
- console.error(`[Connection] Reconnect tryGoOnline failed: ${err}`, err);
1502
+ networkError(`[Connection] Reconnect tryGoOnline failed: ${err}`, err);
1493
1503
  });
1494
1504
  }, retryMs);
1495
1505
  }
@@ -2827,7 +2837,7 @@ var PendingChangesManager = class {
2827
2837
  try {
2828
2838
  await this.deps.uploadDirtyItems();
2829
2839
  } catch (err) {
2830
- console.error(`[PendingChanges] REST upload failed: ${err}`, err);
2840
+ networkError(`[PendingChanges] REST upload failed: ${err}`, err);
2831
2841
  } finally {
2832
2842
  this.isUploadingToRest = false;
2833
2843
  resolveUpload();
@@ -3209,7 +3219,7 @@ var _SyncEngine = class _SyncEngine {
3209
3219
  }
3210
3220
  }
3211
3221
  } catch (err) {
3212
- console.error(
3222
+ networkError(
3213
3223
  "[SyncEngine] uploadDirtyItems failed (download succeeded, staying online):",
3214
3224
  err
3215
3225
  );
@@ -3236,7 +3246,7 @@ var _SyncEngine = class _SyncEngine {
3236
3246
  });
3237
3247
  } catch (err) {
3238
3248
  const reason = err instanceof Error ? err.message : String(err);
3239
- console.error(`[SyncEngine] Sync failed: ${err}`, err);
3249
+ networkError(`[SyncEngine] Sync failed: ${err}`, err);
3240
3250
  this.deps.onSyncFailed(`Sync failed: ${reason}`);
3241
3251
  this.callOnSyncEnd({
3242
3252
  durationMs: Date.now() - startTime,
@@ -4291,7 +4301,7 @@ var WakeSyncManager = class {
4291
4301
  }
4292
4302
  }
4293
4303
  this.deps.sync(`wake-sync:${trigger}`).catch((err) => {
4294
- console.error(`[WakeSync] Wake sync (${trigger}) failed:`, err);
4304
+ networkError(`[WakeSync] Wake sync (${trigger}) failed:`, err);
4295
4305
  });
4296
4306
  }, this.debounceMs);
4297
4307
  }
@@ -4447,7 +4457,7 @@ var _SyncedDb = class _SyncedDb {
4447
4457
  onBecameLeader: () => {
4448
4458
  if (this.initialized && !this.connectionManager.isOnline() && !this.connectionManager.isForcedOffline()) {
4449
4459
  this.connectionManager.tryGoOnline().catch((err) => {
4450
- console.error(`[SyncedDb] tryGoOnline on becameLeader failed: ${err}`, err);
4460
+ networkError(`[SyncedDb] tryGoOnline on becameLeader failed: ${err}`, err);
4451
4461
  });
4452
4462
  }
4453
4463
  if (config.onBecameLeader) {
@@ -5246,7 +5256,7 @@ var _SyncedDb = class _SyncedDb {
5246
5256
  await this.syncEngine.processCollectionServerData(collection, serverData, { source: "refresh" });
5247
5257
  }
5248
5258
  }).catch((err) => {
5249
- console.error(`[SyncedDb] referToServer failed for ${collection}:`, err);
5259
+ networkError(`[SyncedDb] referToServer failed for ${collection}:`, err);
5250
5260
  });
5251
5261
  }
5252
5262
  /**
@@ -5267,7 +5277,7 @@ var _SyncedDb = class _SyncedDb {
5267
5277
  if (!serverItems || serverItems.length === 0) return;
5268
5278
  await this.syncEngine.processCollectionServerData(collection, serverItems, { source: "incremental" });
5269
5279
  }).catch((err) => {
5270
- console.error(`[SyncedDb] refreshInBackground failed for ${collection}:`, err);
5280
+ networkError(`[SyncedDb] refreshInBackground failed for ${collection}:`, err);
5271
5281
  });
5272
5282
  }
5273
5283
  async ensureItemsAreLoaded(collection, ids, withDeleted) {
@@ -5532,7 +5542,7 @@ var _SyncedDb = class _SyncedDb {
5532
5542
  this.inMemManager.writeBatch(collection, [{ _id: item.id }], "delete", { source: "incremental" });
5533
5543
  results.push(true);
5534
5544
  } catch (err) {
5535
- console.error(`[SyncedDb] Failed to hard delete ${String(item.id)}:`, err);
5545
+ networkError(`[SyncedDb] Failed to hard delete ${String(item.id)}:`, err);
5536
5546
  results.push(false);
5537
5547
  }
5538
5548
  }
@@ -5913,7 +5923,7 @@ var _SyncedDb = class _SyncedDb {
5913
5923
  for (const id of serverExits) evictIds.push(id);
5914
5924
  serverEvictedCount = serverExits.length;
5915
5925
  } catch (err) {
5916
- console.error(
5926
+ networkError(
5917
5927
  `[SyncedDb] [evict] server-assisted pass failed for ${collection} (proceeding with local-only):`,
5918
5928
  err
5919
5929
  );
@@ -6030,7 +6040,7 @@ var _SyncedDb = class _SyncedDb {
6030
6040
  }
6031
6041
  } catch (err) {
6032
6042
  serverFailed = true;
6033
- console.error(
6043
+ networkError(
6034
6044
  "[SyncedDb] [evict] server-assisted batch failed (proceeding with local-only):",
6035
6045
  err
6036
6046
  );
@@ -6643,19 +6653,25 @@ var _SyncedDb = class _SyncedDb {
6643
6653
  }
6644
6654
  /**
6645
6655
  * Fallback for `setByPath` failures inside `applyDiffLocally`. Materializes
6646
- * the parent array when it's missing from `seed`. Two patterns covered, per
6647
- * production-spec (mozirje 2026-05-10 literal bracket-keyed sibling
6648
- * properties polluting Dexie/in-mem):
6656
+ * the parent array when it's missing from `seed`, OR fills in missing
6657
+ * intermediate objects under an existing array element when the diff
6658
+ * path reaches deeper than the element currently holds.
6649
6659
  *
6650
- * 1. `polje[<id>] = <obj>` → seed.polje = [<obj>]
6651
- * 2. `polje[<id>].<field> = <v>` → seed.polje = [{_id: <id>, <field>: <v>}]
6660
+ * Supported shapes (path tokenizes to `[firstField, [<id>], …rest]`):
6652
6661
  *
6653
- * Everything else (nested-via-dots before bracket, multi-bracket paths
6654
- * like `_redundanca.terapije[<id>].postavke[<id2>]`, deeper sub-fields)
6655
- * is dropped silently materializing those locally would risk corrupting
6656
- * unrelated invariants on the nested objects. Dirty-change still carries
6657
- * the path, so server applies it; next sync brings the canonical state
6658
- * back to local.
6662
+ * 1. `polje[<id>] = <obj>` → seed.polje = [<obj>]
6663
+ * 2. `polje[<id>].field = <v>` → seed.polje = [{_id: <id>, field: <v>}]
6664
+ * 3. `polje[<id>].a.b = <v>` → seed.polje = [{_id: <id>, a: {b: <v>}}]
6665
+ * 4. `polje[<id>].a.b.c.d = <v>` → seed.polje = [{_id: <id>, a: {b: {c: {d: <v>}}}}]
6666
+ * 5. Existing element with `_id` matches `<id>` but missing intermediate
6667
+ * walk down the element, create missing intermediate plain objects,
6668
+ * set the leaf. No duplicate element pushed.
6669
+ *
6670
+ * Dropped (multi-bracket / nested-via-dots-before-bracket / non-plain
6671
+ * intermediates) — materializing those locally would risk corrupting
6672
+ * unrelated invariants on the nested objects. Dirty-change still
6673
+ * carries the path, so the server applies it; next sync brings the
6674
+ * canonical state back to local.
6659
6675
  *
6660
6676
  * Replaces the pre-fix blind `seed[path] = value` fallback that stamped
6661
6677
  * literal bracket-keyed top-level properties (e.g. `"postavke[<id>]": [<el>]`)
@@ -6673,7 +6689,7 @@ var _SyncedDb = class _SyncedDb {
6673
6689
  { collection, _id: String(id), path, value }
6674
6690
  );
6675
6691
  };
6676
- if (tokens.length < 2 || tokens.length > 3) {
6692
+ if (tokens.length < 2) {
6677
6693
  drop(`unsupported token count ${tokens.length}`);
6678
6694
  return;
6679
6695
  }
@@ -6686,9 +6702,11 @@ var _SyncedDb = class _SyncedDb {
6686
6702
  drop("second segment is not a bracket-by-id");
6687
6703
  return;
6688
6704
  }
6689
- if (tokens.length === 3 && tokens[2].startsWith("[")) {
6690
- drop("nested bracket path");
6691
- return;
6705
+ for (let i = 2; i < tokens.length; i++) {
6706
+ if (tokens[i].startsWith("[")) {
6707
+ drop("nested bracket path");
6708
+ return;
6709
+ }
6692
6710
  }
6693
6711
  const bracketId = secondToken.slice(1, -1);
6694
6712
  if (bracketId.length === 0) {
@@ -6719,12 +6737,36 @@ var _SyncedDb = class _SyncedDb {
6719
6737
  }
6720
6738
  return;
6721
6739
  }
6722
- const fieldName = tokens[2];
6723
- const newElement = { _id: bracketId, [fieldName]: value };
6740
+ const subFieldPath = tokens.slice(2);
6741
+ const buildNestedSubTree = (segs, leaf) => {
6742
+ let acc = leaf;
6743
+ for (let i = segs.length - 1; i >= 0; i--) {
6744
+ acc = { [segs[i]]: acc };
6745
+ }
6746
+ return acc;
6747
+ };
6724
6748
  if (existing == null) {
6725
- seed[firstToken] = [newElement];
6749
+ const subTree = buildNestedSubTree(subFieldPath, value);
6750
+ seed[firstToken] = [__spreadValues({ _id: bracketId }, subTree)];
6751
+ return;
6752
+ }
6753
+ const existingIdx = existing.findIndex(
6754
+ (it) => it != null && typeof it === "object" && String(it._id) === bracketId
6755
+ );
6756
+ if (existingIdx >= 0) {
6757
+ let cur = existing[existingIdx];
6758
+ for (let i = 0; i < subFieldPath.length - 1; i++) {
6759
+ const seg = subFieldPath[i];
6760
+ const next = cur[seg];
6761
+ if (next == null || typeof next !== "object" || Array.isArray(next)) {
6762
+ cur[seg] = {};
6763
+ }
6764
+ cur = cur[seg];
6765
+ }
6766
+ cur[subFieldPath[subFieldPath.length - 1]] = value;
6726
6767
  } else {
6727
- existing.push(newElement);
6768
+ const subTree = buildNestedSubTree(subFieldPath, value);
6769
+ existing.push(__spreadValues({ _id: bracketId }, subTree));
6728
6770
  }
6729
6771
  }
6730
6772
  /**
@@ -10113,7 +10155,7 @@ var Ebus2ProxyServerUpdateNotifier = class {
10113
10155
  }
10114
10156
  }
10115
10157
  handleError(event) {
10116
- console.error(`[Ebus2ProxyNotifier] WebSocket error: ${event.type}`, event);
10158
+ networkError(`[Ebus2ProxyNotifier] WebSocket error: ${event.type}`, event);
10117
10159
  }
10118
10160
  handleMessage(event) {
10119
10161
  try {
@@ -10234,7 +10276,7 @@ var Ebus2ProxyServerUpdateNotifier = class {
10234
10276
  this.reconnectTimer = void 0;
10235
10277
  if (this.shouldReconnect && !this.forcedOffline) {
10236
10278
  this.createWebSocket().catch((err) => {
10237
- console.error(`[Ebus2ProxyNotifier] Reconnection failed: ${err}`, err);
10279
+ networkError(`[Ebus2ProxyNotifier] Reconnection failed: ${err}`, err);
10238
10280
  this.currentReconnectDelay = Math.min(
10239
10281
  this.currentReconnectDelay * 2,
10240
10282
  this.maxReconnectDelayMs
@@ -521,19 +521,25 @@ export declare class SyncedDb implements I_SyncedDb {
521
521
  private static applyDiffLocally;
522
522
  /**
523
523
  * Fallback for `setByPath` failures inside `applyDiffLocally`. Materializes
524
- * the parent array when it's missing from `seed`. Two patterns covered, per
525
- * production-spec (mozirje 2026-05-10 literal bracket-keyed sibling
526
- * properties polluting Dexie/in-mem):
527
- *
528
- * 1. `polje[<id>] = <obj>` → seed.polje = [<obj>]
529
- * 2. `polje[<id>].<field> = <v>` → seed.polje = [{_id: <id>, <field>: <v>}]
530
- *
531
- * Everything else (nested-via-dots before bracket, multi-bracket paths
532
- * like `_redundanca.terapije[<id>].postavke[<id2>]`, deeper sub-fields)
533
- * is dropped silently materializing those locally would risk corrupting
534
- * unrelated invariants on the nested objects. Dirty-change still carries
535
- * the path, so server applies it; next sync brings the canonical state
536
- * back to local.
524
+ * the parent array when it's missing from `seed`, OR fills in missing
525
+ * intermediate objects under an existing array element when the diff
526
+ * path reaches deeper than the element currently holds.
527
+ *
528
+ * Supported shapes (path tokenizes to `[firstField, [<id>], …rest]`):
529
+ *
530
+ * 1. `polje[<id>] = <obj>` → seed.polje = [<obj>]
531
+ * 2. `polje[<id>].field = <v>` → seed.polje = [{_id: <id>, field: <v>}]
532
+ * 3. `polje[<id>].a.b = <v>` → seed.polje = [{_id: <id>, a: {b: <v>}}]
533
+ * 4. `polje[<id>].a.b.c.d = <v>` → seed.polje = [{_id: <id>, a: {b: {c: {d: <v>}}}}]
534
+ * 5. Existing element with `_id` matches `<id>` but missing intermediate
535
+ * walk down the element, create missing intermediate plain objects,
536
+ * set the leaf. No duplicate element pushed.
537
+ *
538
+ * Dropped (multi-bracket / nested-via-dots-before-bracket / non-plain
539
+ * intermediates) — materializing those locally would risk corrupting
540
+ * unrelated invariants on the nested objects. Dirty-change still
541
+ * carries the path, so the server applies it; next sync brings the
542
+ * canonical state back to local.
537
543
  *
538
544
  * Replaces the pre-fix blind `seed[path] = value` fallback that stamped
539
545
  * literal bracket-keyed top-level properties (e.g. `"postavke[<id>]": [<el>]`)
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Log a failure at appropriate severity depending on perceived network
3
+ * connectivity.
4
+ *
5
+ * - **Online** (`navigator.onLine === true`, or `navigator` unavailable
6
+ * like in Node/SSR): routes to `console.error`. Failures while the
7
+ * browser thinks it's online are unexpected — operators should see
8
+ * them.
9
+ * - **Offline** (`navigator.onLine === false`): routes to
10
+ * `console.info`. Sync/upload/reconnect failures are the expected
11
+ * steady-state and shouldn't pollute the error stream.
12
+ *
13
+ * Use this ONLY for sites whose failure reduces to "the network is
14
+ * down" — caller bugs, Dexie failures, parse errors, and consumer
15
+ * callback throws must keep using `console.error` directly so they're
16
+ * visible regardless of connectivity.
17
+ *
18
+ * Signature mirrors `console.error` — first arg is the tag-line string
19
+ * (per the console-reporting skill), remaining args are objects /
20
+ * values for devtools inspection.
21
+ *
22
+ * Caveat: `navigator.onLine` only tells you whether the browser has a
23
+ * network interface; it can't detect "WiFi connected but server
24
+ * unreachable". In that case this still routes to `console.error`,
25
+ * which is correct — the failure is unexpected from the browser's
26
+ * point of view.
27
+ */
28
+ export declare function networkError(message: string, ...rest: unknown[]): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.181",
3
+ "version": "0.1.183",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",