cry-synced-db-client 0.1.175 → 0.1.177

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,6 +1,166 @@
1
1
  # Versions
2
2
 
3
- ## Unreleased
3
+ ## 0.1.177 (2026-05-13)
4
+
5
+ Hot-path micro-optimizations from a ts-coding skill audit. Five small wins,
6
+ each verified independently against the actual control flow before applying.
7
+
8
+ ### `$regex` operand cached per pattern
9
+
10
+ `matchesOperator` for `$regex` used to call `new RegExp(operand)` on every
11
+ record × operator invocation. For in-mem `find` over a large collection
12
+ this recompiled the same pattern thousands of times. Now a module-level
13
+ `Map<string, RegExp>` caches compiled regexes by operand string with a
14
+ bounded FIFO eviction (128 entries) so dynamic patterns don't leak.
15
+ Implementation in `src/utils/localQuery.ts:compileRegex`.
16
+
17
+ ### `SyncedDb.close()` is idempotent
18
+
19
+ Added a `this.closed` guard that early-returns on the second call. Most
20
+ disposal calls inside `close()` were already idempotent (timer clears,
21
+ listener removes), but `crossTabSync.dispose()`, `wakeSync.dispose()`,
22
+ `networkStatus.dispose()`, and `serverUpdateNotifier.dispose?.()` are not
23
+ internally guarded — calling them twice could throw. Now safe.
24
+
25
+ ### Orphan dirty reconstruction batches into `saveMany`
26
+
27
+ `SyncEngine.uploadDirtyItems` reconstructs missing main rows when a dirty
28
+ change exists but the main entry was lost (e.g. debounced write didn't
29
+ flush before reload). Previously called `await dexieDb.save(...)` per
30
+ orphan inside the per-item loop. Now collects reconstructed entities and
31
+ issues one `saveMany` at the end. Rare path — minor improvement, no
32
+ behavior change.
33
+
34
+ ### `sentIds` warning path drops intermediate spreads
35
+
36
+ `SyncEngine.uploadDirtyItems` builds a `sentIds` Set for the
37
+ "unacknowledged items" warning. Previously used
38
+ `new Set([...batches.flat().filter(...).flatMap(...)])` (multiple array
39
+ allocations) and `[...sentIds].filter(...)` (one more spread). Now a
40
+ single for-of pass into a Set, plus a direct iteration to build `unacked`.
41
+
42
+ ### `ServerUpdateHandler` batch case now concurrent
43
+
44
+ WebSocket batch server-update notifications previously processed each
45
+ insert / update / delete with sequential `await` calls. Each per-item
46
+ handler operates on an independent `_id`; Dexie reads/writes on different
47
+ keys don't conflict, so replaced the loops with `Promise.all` over the
48
+ per-item handlers. On a batch of N items this turns N sequential IDB
49
+ round-trips into N concurrent ones — the dominant latency win for any
50
+ tab subscribed to a busy collection.
51
+
52
+ The deeper "single `getByIds` + `saveMany` per batch" refactor was
53
+ deferred: the per-item update handler has 5+ semantically distinct
54
+ branches (self-echo A/B/C, pending change merge, dirty merge, clean
55
+ meta-only) and duplicating that logic for the batch path carries
56
+ re-implementation risk that isn't justified by the additional latency
57
+ savings over `Promise.all`.
58
+
59
+ ## 0.1.176 (2026-05-13)
60
+
61
+ ### Passive transport metrics on `findNewerManyStream` round-trip (rdb2)
62
+
63
+ `FindNewerManyResultInfo` (delivered to `onFindNewerManyResult`) now
64
+ carries three optional metrics captured passively during the sync
65
+ round-trip — no extra round-trip needed, no overhead when callbacks
66
+ are absent:
67
+
68
+ - `requestBytes` — msgpack-encoded request body size (upload payload).
69
+ - `responseBytes` — sum of wire bytes received across all streaming
70
+ chunks (download payload). Renamed from the short-lived
71
+ `bytesStreamed`.
72
+ - `ttfbMs` — time-to-first-byte: elapsed from request send until
73
+ response headers arrive. Conflates `upload travel + server work +
74
+ first-byte travel` (would need a server-timing header to split
75
+ further).
76
+
77
+ Combined with the existing `durationMs`, callers can derive download
78
+ throughput and a server-vs-network breakdown without instrumenting the
79
+ transport themselves:
80
+
81
+ ```typescript
82
+ new SyncedDb({
83
+ onFindNewerManyResult: (info) => {
84
+ if (!info.success || info.responseBytes == null) return;
85
+ const downloadMs = info.durationMs - (info.ttfbMs ?? 0);
86
+ const kBps = downloadMs > 0 ? (info.responseBytes / downloadMs) : 0;
87
+ metrics.record({
88
+ req: info.requestBytes,
89
+ resp: info.responseBytes,
90
+ ttfb: info.ttfbMs,
91
+ total: info.durationMs,
92
+ kBps,
93
+ });
94
+ },
95
+ });
96
+ ```
97
+
98
+ Wired through the transport layer:
99
+
100
+ - `I_RestInterface.findNewerManyStream` options gain three optional
101
+ byte/timing callbacks: `onRequestBytes(bytes)`,
102
+ `onTtfbMs(ms)`, `onResponseChunkBytes(bytes)`. All fire even on
103
+ failure paths where applicable. Old two-arg `onChunk` callbacks keep
104
+ working unchanged.
105
+ - `RestProxy` fires `onRequestBytes` just before `fetch`, `onTtfbMs`
106
+ immediately after `await fetch(...)` resolves (separate `fetchStart`
107
+ marker, independent of `timeRequests`), and `onResponseChunkBytes`
108
+ per `reader.read()` chunk in `parseStreamingResponse`.
109
+ - `SyncEngine.syncCore` accumulates bytes / captures TTFB and forwards
110
+ them into `callOnFindNewerManyResult` on both success and error
111
+ paths. Mocks and alternative transports that don't fire the
112
+ callbacks leave the fields `undefined` — graceful degradation.
113
+
114
+ ### RTT measurement: `measureWsRtt` + `measureEndToEndRtt` (ebus-proxy)
115
+
116
+ Two diagnostic methods on `SyncedDb` (and `I_ServerUpdateNotifier`) to
117
+ measure connection latency. Together they isolate where latency comes
118
+ from:
119
+
120
+ - **`measureWsRtt(timeoutMs?)`** — client → notifier server → client.
121
+ Sends a tagged WS ping (`{type: "ping", id: <unique>}`) and resolves
122
+ with `performance.now()` delta when the matching pong arrives. Pure
123
+ proxy responsiveness + network. Sub-ms on localhost.
124
+ - **`measureEndToEndRtt(timeoutMs?)`** — client → proxy → broker →
125
+ `echo` worker → broker → proxy → client. HTTP GET to ebus-proxy's
126
+ `/?service=echo` endpoint with `Date.now()` msgpack payload; worker
127
+ returns it unchanged. ~3-15 ms on localhost.
128
+
129
+ Both return RTT in milliseconds, both Promise-based (don't block thread).
130
+ Multiple concurrent `measureWsRtt()` calls work — each uses a unique
131
+ correlation id.
132
+
133
+ Diagnostic interpretation:
134
+
135
+ | WS RTT | E2E RTT | Likely cause |
136
+ |---|---|---|
137
+ | low | low | All good |
138
+ | low | high | Broker / echo worker overloaded |
139
+ | high | high (similar Δ) | Network or proxy slow |
140
+ | spikes | stable | WS frame issue (tab throttle, frozen socket) |
141
+
142
+ ```typescript
143
+ const wsRtt = await syncedDb.measureWsRtt();
144
+ const e2eRtt = await syncedDb.measureEndToEndRtt();
145
+ console.log(`network/proxy=${wsRtt.toFixed(1)}ms, full chain=${e2eRtt.toFixed(1)}ms`);
146
+ ```
147
+
148
+ The notifier-interface methods are **optional** (`measureWsRtt?` /
149
+ `measureEndToEndRtt?`) so custom `I_ServerUpdateNotifier`
150
+ implementations don't need to implement them. `SyncedDb` pass-throughs
151
+ throw a descriptive error if missing.
152
+
153
+ Implementation in `Ebus2ProxyServerUpdateNotifier`: tagged ping reuses
154
+ the existing WS handler (extends pong dispatch in `handleMessage`
155
+ with a `_pendingRttPings` Map). HTTP echo derives the base URL from
156
+ `wsUrl` (`ws://` → `http://`, `wss://` → `https://`), msgpack-encodes
157
+ the payload, hits `/?service=echo`, validates byte-equal echo.
158
+
159
+ Live test (localhost, warm, 20 samples): WS p50 0.30 ms, E2E p50
160
+ 4.11 ms. Mock-only unit tests in `test/measureRtt.test.ts` (7 cases —
161
+ delegation, propagated rejection, no-notifier error, shape sanity).
162
+
163
+ ## 0.1.175 (2026-05-13)
4
164
 
5
165
  ### `save(coll, id, {field: {}})` clears existing nested children
6
166
 
@@ -74,6 +234,37 @@ await syncedDb.replaceSyncCollection({
74
234
  });
75
235
  ```
76
236
 
237
+ ## 0.1.174 (2026-05-12)
238
+
239
+ ### Fix: overlay `_dirty_changes` onto in-mem on init
240
+
241
+ After Ctrl+R while a debounced Dexie main write was pending, the in-mem
242
+ cache showed stale Dexie main state instead of the merged dirty diff —
243
+ UI readers saw old field values until the next sync round-trip.
244
+ Production incident, klikvet 2026-05-12 with the server offline.
245
+
246
+ Root cause: `loadCollectionToInMem` (called from `init()`) read only the
247
+ Dexie main table; it never overlaid `_dirty_changes`.
248
+ `recoverPendingWrites` recovers from `localStorage`, but `localStorage`
249
+ may have been cleared by an earlier partial-debounce success — the
250
+ Dexie-only `_dirty_changes` table is the durable source for those
251
+ writes.
252
+
253
+ Fix: after loading Dexie main, walk `_dirty_changes` and apply each diff
254
+ to the matching main row via `applyDiffLocally`. Orphan dirty (no
255
+ matching main row) is included in in-mem with a `console.warn`. Dirty
256
+ entries marking `_deleted` / `_archived` remove the record from in-mem.
257
+
258
+ Scope: in-mem cache only. `findById` uses the in-mem fast path, so its
259
+ results now reflect dirty. `find()` reads Dexie main directly — that's
260
+ a separate Dexie-overlay gap, not addressed here.
261
+
262
+ Regression test: `test/dirtyOverlayOnInit.test.ts` (5 cases — production
263
+ scenario, orphan, soft-delete via dirty, no-dirty fast path, multiple
264
+ records). 708 pass / 0 fail.
265
+
266
+ ## 0.1.173 (2026-05-12)
267
+
77
268
  ### `preprocessDirtyItem` callback — per-item filter / transform before upload
78
269
 
79
270
  New optional config callback fired for **every** dirty item just before it
@@ -110,6 +301,8 @@ new SyncedDb({
110
301
  Useful for: per-tenant data sanitization, conditional upload gating,
111
302
  audit-trail injection.
112
303
 
304
+ ## 0.1.172 (2026-05-12)
305
+
113
306
  ### Nested-bracket terminal layering in `mergeDirtyPath` Case 2
114
307
 
115
308
  When a new terminal-bracket whole-element write arrives AFTER pending
@@ -148,6 +341,8 @@ alongside actual upload errors in observability pipelines.
148
341
  `SUPRESS_DB_WARNINGS` constant in `SyncEngine.ts` silences them when
149
342
  needed (e.g. during noisy migrations).
150
343
 
344
+ ## 0.1.171 (2026-05-11)
345
+
151
346
  ### Runtime collection registration (`addCollectionToSync`, `replaceSyncCollection`)
152
347
 
153
348
  Two methods to install / replace collection configs at runtime; both load the
@@ -244,6 +439,8 @@ const sinceMs = isLeader
244
439
  : Date.now() - syncedDb.followerSince()!.getTime();
245
440
  ```
246
441
 
442
+ ## 0.1.163 (2026-05-10)
443
+
247
444
  ### `onServerSyncWrite` callback
248
445
 
249
446
  Single-shot callback that fires once per `restInterface.updateCollections`
@@ -281,7 +478,7 @@ parity across **mongo + Dexie + in-mem** simultaneously after a partial
281
478
  `save({ postavke: [{_id: "P1", kolicina: 2}] })` over an existing
282
479
  `postavke[0] = {_id: "P1", opis: "postavka 1", kolicina: 1}`.
283
480
 
284
- ## 0.1.162
481
+ ## 0.1.162 (2026-05-10)
285
482
 
286
483
  ### Bracket-by-_id paths flow through server unchanged
287
484
 
@@ -336,7 +533,7 @@ Replaced with `applyDiffLocally(base, diff, id)`:
336
533
 
337
534
  `deleteByPath` is now a sibling export of `setByPath` in `computeDiff.ts`.
338
535
 
339
- ## 0.1.161
536
+ ## 0.1.161 (2026-05-10)
340
537
 
341
538
  ### Don't auto-stamp `_id` on bracket-array elements
342
539
 
@@ -347,7 +544,7 @@ preserved. This allows callers to mix:
347
544
  - Bracket-by-_id sub-field path: `update["postavke[<id>].field"] = value`
348
545
  in the same payload without the client mutating element identity.
349
546
 
350
- ## 0.1.160
547
+ ## 0.1.160 (2026-05-09)
351
548
 
352
549
  ### Composition changes emit precise paths (not full-array replace)
353
550
 
@@ -369,7 +566,7 @@ Pre-fix, composition change emitted full-array replace at `basePath`,
369
566
  which `mergeDirtyPath` Case 2 then dropped pending sub-field paths on
370
567
  the same parent — race-y data-loss strip pattern visible in production.
371
568
 
372
- ## 0.1.159
569
+ ## 0.1.159 (2026-05-09)
373
570
 
374
571
  ### Self-echo WS suppression for `_rev <= local._rev`
375
572
 
@@ -388,11 +585,11 @@ older snapshot because a self-echo WS arrived after writeback and
388
585
  overwrote in-mem with the server's `$set`-iterated copy of postavke
389
586
  (missing freshly-set `pop` and `navodilo` fields). Now in-mem is preserved.
390
587
 
391
- ## 0.1.158
588
+ ## 0.1.158 (2026-05-09)
392
589
 
393
590
  Internal version bump consolidating 0.1.157 fixes for production publish.
394
591
 
395
- ## 0.1.157
592
+ ## 0.1.157 (2026-05-08)
396
593
 
397
594
  ### Recursive server-managed metadata strip at upload boundary
398
595
 
@@ -429,7 +626,7 @@ stuck-dirty payload (mixing top-level full arrays with bracket paths)
429
626
  into Dexie's `_dirty_changes` and asserts upload succeeds without
430
627
  mongo path-conflict errors via a `MongoFaithfulRestInterface` mock.
431
628
 
432
- ## 0.1.156
629
+ ## 0.1.156 (2026-05-08)
433
630
 
434
631
  Three related fixes targeting **dirty-payload metadata leak** and
435
632
  **concurrent array merge corruption** observed in production
@@ -505,7 +702,7 @@ Temporary upload-time scrubber dropping legacy position-based array
505
702
  paths (`field.<digit>(.…)?`) when `serverRev > baseRev`. Marked for
506
703
  removal after ~2026-05-15 once all clients have re-synced.
507
704
 
508
- ## 0.1.155
705
+ ## 0.1.155 (2026-05-08)
509
706
 
510
707
  Two new `SyncedDbConfig` fields targeting **cross-device scope-exit**
511
708
  detection: situations where one device modifies a record so it no longer
@@ -562,7 +759,7 @@ wake.
562
759
  - `_collectScopeExitPlan` and single-collection `evictOutOfScopeRecords`
563
760
  both route through the new helper.
564
761
 
565
- ## Unreleased
762
+ ## 0.1.149 (2026-04-27)
566
763
 
567
764
  ### `SyncSource` flag in `I_InMemDb.saveMany` / `deleteManyByIds`
568
765
 
@@ -600,6 +797,8 @@ Tests: `test/syncSource.test.ts` (9 cases) covers initial / incremental
600
797
  / refresh propagation across all public write paths. `MockInMemDb`
601
798
  exposes `recordedCalls: RecordedInMemCall[]` for assertion.
602
799
 
800
+ ## 0.1.148 (2026-04-26)
801
+
603
802
  ### `uploadDirtyItems` follow-up pass — drain in-sync writes immediately
604
803
 
605
804
  Writes that land **during** a sync iteration had their
@@ -630,6 +829,8 @@ first pass — a follow-up failure does not roll back the first pass's
630
829
  already-cleared dirty entries; affected items are caught at the next
631
830
  sync tick (same retry semantics as before).
632
831
 
832
+ ## 0.1.147 (2026-04-25)
833
+
633
834
  ### Auto-eviction co-located with sync — one round-trip total
634
835
 
635
836
  When `evictStaleRecordsEveryHrs > 0` and the interval has elapsed, the
@@ -701,6 +902,8 @@ request carry multiple specs against the same collection without
701
902
  library always populates them. Downstream code that constructs mock
702
903
  literals (e.g. tests) needs the new fields.
703
904
 
905
+ ## 0.1.144 (2026-04-24)
906
+
704
907
  ### Fix: filtered-sync tombstone (scope-exit from other writers)
705
908
 
706
909
  When a collection has `syncConfig.query` (e.g. `{ status: { $ne: "obsolete" } }`)
@@ -735,6 +938,8 @@ predicate (no implicit server policy).
735
938
  `$nor` support, required for the negated query to evaluate against the test
736
939
  mock and for any client-side filtering that uses logical operators.
737
940
 
941
+ ## 0.1.142 (2026-04-21)
942
+
738
943
  ### `getDirtyMeta()` for lightweight dirty-state inspection
739
944
 
740
945
  - New `SyncedDb.getDirtyMeta()` returns dirty-entry meta (everything except the
@@ -765,6 +970,8 @@ Two contributing causes, both fixed:
765
970
  cache learns of them via the existing shared-Dexie reload path. Reload
766
971
  broadcasts (post-full-sync) remain leader-only.
767
972
 
973
+ ## 0.1.141 (2026-04-21)
974
+
768
975
  ### BREAKING: Self-healing sync/reconnect lifecycle
769
976
 
770
977
  Fixes a class of bugs where the 60s auto-sync scheduler silently died after a
@@ -795,17 +1002,21 @@ tenants, 62–296 min of dead scheduler with dirty items accumulating.
795
1002
  `onForcedOffline: (reason) => log(reason)` → `onSyncFailed: (reason) => log(reason)`.
796
1003
  Signature is identical. No other callback changes.
797
1004
 
798
- - Add `refreshInBackground` `QueryOpts` option for `findById` / `findByIds`
799
- - Stale-while-revalidate: cache-hit returns local result immediately and
800
- triggers a background fetch that updates Dexie + in-mem through conflict
801
- resolution (`processCollectionServerData`).
802
- - Orthogonal to `referToServer` does not change miss behaviour. With
803
- defaults (`referToServer: true`) misses are still awaited; with
804
- `referToServer: false` misses return `null` and bg fetch loads them async.
805
- - Dedupes against `referToServer`: IDs fetched blockingly are NOT re-fetched
806
- in the background (no double-round-trip).
807
- - Noop when offline or on writeOnly collections.
808
- - Ignored on `find` / `findOne` (use `referToServer` there).
1005
+ ## 0.1.139 (2026-04-21)
1006
+
1007
+ ### `refreshInBackground` `QueryOpts` option for `findById` / `findByIds`
1008
+
1009
+ Stale-while-revalidate: cache-hit returns the local result immediately and
1010
+ triggers a background fetch that updates Dexie + in-mem through conflict
1011
+ resolution (`processCollectionServerData`).
1012
+
1013
+ - Orthogonal to `referToServer` — does not change miss behaviour. With
1014
+ defaults (`referToServer: true`) misses are still awaited; with
1015
+ `referToServer: false` misses return `null` and bg fetch loads them async.
1016
+ - Dedupes against `referToServer`: IDs fetched blockingly are NOT re-fetched
1017
+ in the background (no double-round-trip).
1018
+ - Noop when offline or on writeOnly collections.
1019
+ - Ignored on `find` / `findOne` (use `referToServer` there).
809
1020
 
810
1021
  ## 0.1.146 (2026-04-25)
811
1022
 
package/dist/index.js CHANGED
@@ -37,6 +37,21 @@ import Dexie2 from "dexie";
37
37
  import { ObjectId as ObjectId2 } from "bson";
38
38
 
39
39
  // src/utils/localQuery.ts
40
+ var regexCache = /* @__PURE__ */ new Map();
41
+ var REGEX_CACHE_MAX = 128;
42
+ function compileRegex(operand) {
43
+ if (operand instanceof RegExp) return operand;
44
+ const key = String(operand);
45
+ let r = regexCache.get(key);
46
+ if (r) return r;
47
+ r = new RegExp(key);
48
+ if (regexCache.size >= REGEX_CACHE_MAX) {
49
+ const oldest = regexCache.keys().next().value;
50
+ if (oldest !== void 0) regexCache.delete(oldest);
51
+ }
52
+ regexCache.set(key, r);
53
+ return r;
54
+ }
40
55
  function matchesQuery(item, query) {
41
56
  for (const [key, condition] of Object.entries(query)) {
42
57
  if (key === "$and") {
@@ -107,7 +122,7 @@ function matchesOperator(value, operator, operand) {
107
122
  case "$exists":
108
123
  return operand ? value !== void 0 : value === void 0;
109
124
  case "$regex": {
110
- const regex = operand instanceof RegExp ? operand : new RegExp(operand);
125
+ const regex = compileRegex(operand);
111
126
  if (typeof value === "string") return regex.test(value);
112
127
  if (Array.isArray(value)) {
113
128
  return value.some((v) => typeof v === "string" && regex.test(v));
@@ -3083,6 +3098,9 @@ var _SyncEngine = class _SyncEngine {
3083
3098
  source: isInitial ? "initial" : "incremental"
3084
3099
  });
3085
3100
  }
3101
+ let requestBytes;
3102
+ let responseBytes;
3103
+ let ttfbMs;
3086
3104
  try {
3087
3105
  const completedCollections = /* @__PURE__ */ new Set();
3088
3106
  const allSpecs = extras && extras.specs.length > 0 ? [...syncSpecs, ...extras.specs] : syncSpecs;
@@ -3118,6 +3136,17 @@ var _SyncEngine = class _SyncEngine {
3118
3136
  items: items.length
3119
3137
  });
3120
3138
  }
3139
+ },
3140
+ {
3141
+ onRequestBytes: (bytes) => {
3142
+ requestBytes = bytes;
3143
+ },
3144
+ onTtfbMs: (ms) => {
3145
+ ttfbMs = ms;
3146
+ },
3147
+ onResponseChunkBytes: (bytes) => {
3148
+ responseBytes = (responseBytes != null ? responseBytes : 0) + bytes;
3149
+ }
3121
3150
  }
3122
3151
  ),
3123
3152
  "findNewerManyStream"
@@ -3130,7 +3159,15 @@ var _SyncEngine = class _SyncEngine {
3130
3159
  sentCount: 0
3131
3160
  };
3132
3161
  }
3133
- this.callOnFindNewerManyResult(syncSpecs, {}, findNewerManyStartTime, true, calledFrom);
3162
+ this.callOnFindNewerManyResult(
3163
+ syncSpecs,
3164
+ {},
3165
+ findNewerManyStartTime,
3166
+ true,
3167
+ calledFrom,
3168
+ void 0,
3169
+ { requestBytes, responseBytes, ttfbMs }
3170
+ );
3134
3171
  this.callbackSafe(this.callbacks.onServerSyncEnd, {
3135
3172
  calledFrom,
3136
3173
  collectionCount: syncSpecs.length,
@@ -3139,7 +3176,15 @@ var _SyncEngine = class _SyncEngine {
3139
3176
  success: true
3140
3177
  });
3141
3178
  } catch (err) {
3142
- this.callOnFindNewerManyResult(syncSpecs, {}, findNewerManyStartTime, false, calledFrom, err);
3179
+ this.callOnFindNewerManyResult(
3180
+ syncSpecs,
3181
+ {},
3182
+ findNewerManyStartTime,
3183
+ false,
3184
+ calledFrom,
3185
+ err,
3186
+ { requestBytes, responseBytes, ttfbMs }
3187
+ );
3143
3188
  this.callbackSafe(this.callbacks.onServerSyncEnd, {
3144
3189
  calledFrom,
3145
3190
  collectionCount: syncSpecs.length,
@@ -3223,6 +3268,7 @@ var _SyncEngine = class _SyncEngine {
3223
3268
  const skipped = [];
3224
3269
  const ids = dirtyChanges.map((dc) => dc._id);
3225
3270
  const fullItems = await this.dexieDb.getByIds(collectionName, ids);
3271
+ const orphanReconstructed = [];
3226
3272
  for (let i = 0; i < fullItems.length; i++) {
3227
3273
  const fullItem = fullItems[i];
3228
3274
  const id = ids[i];
@@ -3238,7 +3284,7 @@ var _SyncEngine = class _SyncEngine {
3238
3284
  const delta = dirtyChangesMap.get(String(id));
3239
3285
  if (delta) {
3240
3286
  const reconstructed = __spreadProps(__spreadValues({}, delta), { _id: id });
3241
- await this.dexieDb.save(collectionName, id, reconstructed);
3287
+ orphanReconstructed.push(reconstructed);
3242
3288
  updates.push({ _id: id, delta });
3243
3289
  } else {
3244
3290
  skipped.push({ _id: String(id), reason: "no-delta-for-orphan" });
@@ -3247,6 +3293,9 @@ var _SyncEngine = class _SyncEngine {
3247
3293
  skipped.push({ _id: "<null>", reason: "no-fullitem-no-id" });
3248
3294
  }
3249
3295
  }
3296
+ if (orphanReconstructed.length > 0) {
3297
+ await this.dexieDb.saveMany(collectionName, orphanReconstructed);
3298
+ }
3250
3299
  if (updates.length === 0) {
3251
3300
  console.warn(
3252
3301
  `[SyncEngine] uploadDirtyItems: ${collectionName} has ${dirtyChanges.length} dirty entries but 0 resolvable items`,
@@ -3506,14 +3555,20 @@ var _SyncEngine = class _SyncEngine {
3506
3555
  );
3507
3556
  }
3508
3557
  }
3509
- const sentIds = /* @__PURE__ */ new Set([
3510
- ...collectionBatches.flat().filter((b) => b.collection === collection).flatMap((b) => [
3511
- ...b.batch.updates.map((u) => String(u._id)),
3512
- ...b.batch.deletes.map((d) => String(d._id))
3513
- ])
3514
- ]);
3515
- const ackIds = new Set(allSuccessIds.map(String));
3516
- const unacked = [...sentIds].filter((id) => !ackIds.has(id));
3558
+ const sentIds = /* @__PURE__ */ new Set();
3559
+ for (const batch of collectionBatches) {
3560
+ for (const b of batch) {
3561
+ if (b.collection !== collection) continue;
3562
+ for (const u of b.batch.updates) sentIds.add(String(u._id));
3563
+ for (const d of b.batch.deletes) sentIds.add(String(d._id));
3564
+ }
3565
+ }
3566
+ const ackIds = /* @__PURE__ */ new Set();
3567
+ for (const id of allSuccessIds) ackIds.add(String(id));
3568
+ const unacked = [];
3569
+ for (const id of sentIds) {
3570
+ if (!ackIds.has(id)) unacked.push(id);
3571
+ }
3517
3572
  if (unacked.length > 0) {
3518
3573
  console.warn(
3519
3574
  `[SyncEngine] uploadDirtyItems: ${collection}: ${unacked.length} items sent but not acknowledged:`,
@@ -3799,7 +3854,7 @@ var _SyncEngine = class _SyncEngine {
3799
3854
  }
3800
3855
  }
3801
3856
  }
3802
- callOnFindNewerManyResult(specs, results, startTime, success, calledFrom, error) {
3857
+ callOnFindNewerManyResult(specs, results, startTime, success, calledFrom, error, metrics) {
3803
3858
  if (this.callbacks.onFindNewerManyResult) {
3804
3859
  try {
3805
3860
  this.callbacks.onFindNewerManyResult({
@@ -3808,7 +3863,10 @@ var _SyncEngine = class _SyncEngine {
3808
3863
  durationMs: Date.now() - startTime,
3809
3864
  success,
3810
3865
  error: error instanceof Error ? error : error ? new Error(String(error)) : void 0,
3811
- calledFrom
3866
+ calledFrom,
3867
+ requestBytes: metrics == null ? void 0 : metrics.requestBytes,
3868
+ responseBytes: metrics == null ? void 0 : metrics.responseBytes,
3869
+ ttfbMs: metrics == null ? void 0 : metrics.ttfbMs
3812
3870
  });
3813
3871
  } catch (err) {
3814
3872
  console.error("[SyncEngine] onFindNewerManyResult callback failed:", err);
@@ -3944,39 +4002,57 @@ var ServerUpdateHandler = class {
3944
4002
  deletes.push(item.data);
3945
4003
  }
3946
4004
  }
3947
- for (const serverItem of inserts) {
3948
- await this.handleServerItemInsert(collectionName, serverItem);
3949
- updatedIds.push(String(serverItem._id));
4005
+ if (inserts.length > 0) {
4006
+ await Promise.all(
4007
+ inserts.map(
4008
+ (serverItem) => this.handleServerItemInsert(collectionName, serverItem)
4009
+ )
4010
+ );
4011
+ for (const serverItem of inserts) {
4012
+ updatedIds.push(String(serverItem._id));
4013
+ }
3950
4014
  }
3951
4015
  if (updates.length > 0) {
3952
4016
  const updateIds = updates.map((u) => u._id);
3953
4017
  const localItems = await this.dexieDb.getByIds(collectionName, updateIds);
3954
4018
  const missingIds = [];
4019
+ const updatePromises = [];
3955
4020
  for (let i = 0; i < updates.length; i++) {
3956
4021
  const deltaData = updates[i];
3957
4022
  const localItem = localItems[i];
3958
4023
  if (localItem) {
3959
- await this.handleServerItemUpdate(collectionName, localItem, deltaData);
4024
+ updatePromises.push(
4025
+ this.handleServerItemUpdate(collectionName, localItem, deltaData)
4026
+ );
3960
4027
  updatedIds.push(String(deltaData._id));
3961
4028
  } else {
3962
4029
  missingIds.push(deltaData._id);
3963
4030
  }
3964
4031
  }
4032
+ if (updatePromises.length > 0) await Promise.all(updatePromises);
3965
4033
  if (missingIds.length > 0) {
3966
4034
  const fullItems = await this.restInterface.findByIds(
3967
4035
  collectionName,
3968
4036
  missingIds
3969
4037
  );
4038
+ const insertPromises = [];
3970
4039
  for (const fullItem of fullItems) {
3971
4040
  if (!fullItem) continue;
3972
- await this.handleServerItemInsert(collectionName, fullItem);
4041
+ insertPromises.push(
4042
+ this.handleServerItemInsert(collectionName, fullItem)
4043
+ );
3973
4044
  updatedIds.push(String(fullItem._id));
3974
4045
  }
4046
+ if (insertPromises.length > 0) await Promise.all(insertPromises);
3975
4047
  }
3976
4048
  }
3977
- for (const deleteData of deletes) {
3978
- await this.handleServerItemDelete(collectionName, deleteData._id);
3979
- updatedIds.push(String(deleteData._id));
4049
+ if (deletes.length > 0) {
4050
+ await Promise.all(
4051
+ deletes.map((d) => this.handleServerItemDelete(collectionName, d._id))
4052
+ );
4053
+ for (const deleteData of deletes) {
4054
+ updatedIds.push(String(deleteData._id));
4055
+ }
3980
4056
  }
3981
4057
  break;
3982
4058
  }
@@ -4313,6 +4389,7 @@ var _SyncedDb = class _SyncedDb {
4313
4389
  this.collections = /* @__PURE__ */ new Map();
4314
4390
  // State
4315
4391
  this.initialized = false;
4392
+ this.closed = false;
4316
4393
  this.syncing = false;
4317
4394
  this.syncLock = false;
4318
4395
  this.wsUpdateQueue = [];
@@ -4571,6 +4648,36 @@ var _SyncedDb = class _SyncedDb {
4571
4648
  followerSince() {
4572
4649
  return this.leaderElection.followerSince();
4573
4650
  }
4651
+ /**
4652
+ * WS round-trip time (client → notifier server → client). Delegates
4653
+ * to `serverUpdateNotifier.measureWsRtt`. Throws if no notifier is
4654
+ * configured or the notifier implementation doesn't support RTT.
4655
+ */
4656
+ async measureWsRtt(timeoutMs) {
4657
+ var _a;
4658
+ const fn = (_a = this.serverUpdateNotifier) == null ? void 0 : _a.measureWsRtt;
4659
+ if (!fn) {
4660
+ throw new Error(
4661
+ "[SyncedDb] measureWsRtt: no serverUpdateNotifier or notifier does not support RTT"
4662
+ );
4663
+ }
4664
+ return fn.call(this.serverUpdateNotifier, timeoutMs);
4665
+ }
4666
+ /**
4667
+ * End-to-end RTT including downstream broker/worker hop. Delegates
4668
+ * to `serverUpdateNotifier.measureEndToEndRtt`. Throws if no
4669
+ * notifier is configured or the notifier doesn't support it.
4670
+ */
4671
+ async measureEndToEndRtt(timeoutMs) {
4672
+ var _a;
4673
+ const fn = (_a = this.serverUpdateNotifier) == null ? void 0 : _a.measureEndToEndRtt;
4674
+ if (!fn) {
4675
+ throw new Error(
4676
+ "[SyncedDb] measureEndToEndRtt: no serverUpdateNotifier or notifier does not support end-to-end RTT"
4677
+ );
4678
+ }
4679
+ return fn.call(this.serverUpdateNotifier, timeoutMs);
4680
+ }
4574
4681
  /**
4575
4682
  * Register a collection for sync at runtime. See `I_SyncedDb.addCollectionToSync`.
4576
4683
  */
@@ -4821,6 +4928,8 @@ var _SyncedDb = class _SyncedDb {
4821
4928
  }
4822
4929
  async close() {
4823
4930
  var _a, _b;
4931
+ if (this.closed) return;
4932
+ this.closed = true;
4824
4933
  this.leaderElection.setClosing(true);
4825
4934
  this.pendingChanges.cancelRestUploadTimer();
4826
4935
  this.connectionManager.stopTimers();
@@ -9347,11 +9456,12 @@ var RestProxy = class {
9347
9456
  * type=0x01 for data, type=0x00 for end-of-stream.
9348
9457
  */
9349
9458
  async findNewerManyStream(spec, onChunk, options) {
9350
- var _a, _b, _c;
9459
+ var _a, _b, _c, _d, _e;
9351
9460
  const connectTimeout = (_a = options == null ? void 0 : options.timeoutMs) != null ? _a : this.defaultTimeoutMs;
9352
9461
  const activityTimeout = (_b = options == null ? void 0 : options.activityTimeoutMs) != null ? _b : 3e4;
9353
9462
  const externalSignal = (_c = options == null ? void 0 : options.signal) != null ? _c : this.globalSignal;
9354
9463
  const startTime = this.timeRequests ? performance.now() : 0;
9464
+ const fetchStart = performance.now();
9355
9465
  const data = {
9356
9466
  payload: {
9357
9467
  db: this.tenant,
@@ -9365,6 +9475,7 @@ var RestProxy = class {
9365
9475
  }
9366
9476
  };
9367
9477
  const body = pack2(data);
9478
+ (_d = options == null ? void 0 : options.onRequestBytes) == null ? void 0 : _d.call(options, body.byteLength);
9368
9479
  const requestUrl = this.apiKey ? `${this.endpoint}?apikey=${this.apiKey}&stream=1` : `${this.endpoint}?stream=1`;
9369
9480
  const controller = new AbortController();
9370
9481
  let timeoutId = setTimeout(
@@ -9379,6 +9490,7 @@ var RestProxy = class {
9379
9490
  body,
9380
9491
  signal: combinedSignal
9381
9492
  });
9493
+ (_e = options == null ? void 0 : options.onTtfbMs) == null ? void 0 : _e.call(options, performance.now() - fetchStart);
9382
9494
  clearTimeout(timeoutId);
9383
9495
  timeoutId = void 0;
9384
9496
  if (!response.ok) {
@@ -9390,7 +9502,12 @@ var RestProxy = class {
9390
9502
  timeoutId = setTimeout(() => controller.abort(), activityTimeout);
9391
9503
  };
9392
9504
  resetActivity();
9393
- await this.parseStreamingResponse(response, onChunk, resetActivity);
9505
+ await this.parseStreamingResponse(
9506
+ response,
9507
+ onChunk,
9508
+ resetActivity,
9509
+ options == null ? void 0 : options.onResponseChunkBytes
9510
+ );
9394
9511
  if (timeoutId !== void 0) clearTimeout(timeoutId);
9395
9512
  timeoutId = void 0;
9396
9513
  if (this.timeRequests) {
@@ -9428,7 +9545,7 @@ var RestProxy = class {
9428
9545
  *
9429
9546
  * `onChunk` receives `specId` as the third arg for type-0x02 frames; `undefined` otherwise.
9430
9547
  */
9431
- async parseStreamingResponse(response, onChunk, onActivity) {
9548
+ async parseStreamingResponse(response, onChunk, onActivity, onChunkBytes) {
9432
9549
  const reader = response.body.getReader();
9433
9550
  const buffer = new StreamBuffer();
9434
9551
  const decoder2 = new TextDecoder();
@@ -9436,6 +9553,7 @@ var RestProxy = class {
9436
9553
  const { done, value } = await reader.read();
9437
9554
  if (done) return false;
9438
9555
  onActivity();
9556
+ if (onChunkBytes) onChunkBytes(value.byteLength);
9439
9557
  buffer.append(value);
9440
9558
  return true;
9441
9559
  };
@@ -9570,6 +9688,13 @@ var Ebus2ProxyServerUpdateNotifier = class {
9570
9688
  this.reconnectAttempt = 0;
9571
9689
  this.forcedOffline = false;
9572
9690
  this.subscribedChannels = /* @__PURE__ */ new Set();
9691
+ /**
9692
+ * Pending RTT measurement promises keyed by ping id. Each entry is a
9693
+ * resolver that the pong handler invokes once the matching pong
9694
+ * arrives. Disconnect clears the map (caller's timeout fires soon
9695
+ * after if any are still pending).
9696
+ */
9697
+ this._pendingRttPings = /* @__PURE__ */ new Map();
9573
9698
  var _a, _b, _c, _d, _e;
9574
9699
  this.endpoint = config.wsUrl;
9575
9700
  this.wsUrl = config.wsUrl;
@@ -9625,10 +9750,88 @@ var Ebus2ProxyServerUpdateNotifier = class {
9625
9750
  this.onWsConnectCallbacks.length = 0;
9626
9751
  this.onWsDisconnectCallbacks.length = 0;
9627
9752
  this.onWsReconnectCallbacks.length = 0;
9753
+ this._pendingRttPings.clear();
9628
9754
  }
9629
9755
  isConnected() {
9630
9756
  return this.connected && !this.forcedOffline;
9631
9757
  }
9758
+ /**
9759
+ * WS round-trip time (client → proxy → client). Sends a tagged ping
9760
+ * over the existing WebSocket and resolves with `performance.now()`
9761
+ * delta when the matching pong arrives. Does NOT touch the cry-ebus2
9762
+ * broker or any worker — measures pure proxy responsiveness +
9763
+ * network latency.
9764
+ *
9765
+ * Throws if the WebSocket is not OPEN. The keepalive ping/pong
9766
+ * watchdog is unaffected; multiple `measureWsRtt()` calls can be
9767
+ * in flight simultaneously (each uses a unique correlation id).
9768
+ *
9769
+ * @param timeoutMs Max wait for matching pong (default: 5000)
9770
+ * @returns RTT in milliseconds
9771
+ */
9772
+ async measureWsRtt(timeoutMs = 5e3) {
9773
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
9774
+ throw new Error("[Ebus2ProxyNotifier] measureWsRtt: WebSocket not OPEN");
9775
+ }
9776
+ const id = `rtt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
9777
+ const t0 = performance.now();
9778
+ return new Promise((resolve, reject) => {
9779
+ const timer = setTimeout(() => {
9780
+ this._pendingRttPings.delete(id);
9781
+ reject(new Error(`[Ebus2ProxyNotifier] measureWsRtt: timeout after ${timeoutMs}ms`));
9782
+ }, timeoutMs);
9783
+ this._pendingRttPings.set(id, () => {
9784
+ clearTimeout(timer);
9785
+ resolve(performance.now() - t0);
9786
+ });
9787
+ const pingMsg = { type: "ping", id };
9788
+ this.ws.send(packr2.pack(preprocessForPack2(pingMsg)));
9789
+ });
9790
+ }
9791
+ /**
9792
+ * End-to-end RTT (client → proxy → broker → echo worker → broker →
9793
+ * proxy → client). Sends an HTTP request to ebus-proxy's `echo`
9794
+ * service with `Date.now()` as the msgpack payload; the worker
9795
+ * returns the payload unchanged, and we verify byte-equality before
9796
+ * reporting RTT.
9797
+ *
9798
+ * The HTTP base URL is derived from `wsUrl` (`ws://` → `http://`,
9799
+ * `wss://` → `https://`). Throws on HTTP error, payload mismatch,
9800
+ * or timeout.
9801
+ *
9802
+ * @param timeoutMs Max wait for HTTP response (default: 5000)
9803
+ * @returns RTT in milliseconds (full round-trip)
9804
+ */
9805
+ async measureEndToEndRtt(timeoutMs = 5e3) {
9806
+ const httpBase = this.wsUrl.replace(/^ws:\/\//, "http://").replace(/^wss:\/\//, "https://").replace(/\/+$/, "");
9807
+ const sentAt = Date.now();
9808
+ const packed = packr2.pack(sentAt);
9809
+ let payloadB64;
9810
+ const bufCtor = globalThis.Buffer;
9811
+ if (bufCtor) {
9812
+ payloadB64 = bufCtor.from(packed).toString("base64");
9813
+ } else {
9814
+ let bin = "";
9815
+ for (let i = 0; i < packed.length; i++) bin += String.fromCharCode(packed[i]);
9816
+ payloadB64 = btoa(bin);
9817
+ }
9818
+ const keyParam = this.ebusProxyApiKey ? `&apikey=${encodeURIComponent(this.ebusProxyApiKey)}` : "";
9819
+ const url = `${httpBase}/?service=echo&payload=${encodeURIComponent(payloadB64)}${keyParam}&timeout=${timeoutMs}`;
9820
+ const t0 = performance.now();
9821
+ const signal = typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : void 0;
9822
+ const res = await fetch(url, signal ? { signal } : void 0);
9823
+ if (!res.ok) {
9824
+ throw new Error(`[Ebus2ProxyNotifier] measureEndToEndRtt: HTTP ${res.status}`);
9825
+ }
9826
+ const buf = new Uint8Array(await res.arrayBuffer());
9827
+ const echoed = unpackr2.unpack(buf);
9828
+ if (echoed !== sentAt) {
9829
+ throw new Error(
9830
+ `[Ebus2ProxyNotifier] measureEndToEndRtt: payload mismatch (sent ${sentAt}, got ${JSON.stringify(echoed)})`
9831
+ );
9832
+ }
9833
+ return performance.now() - t0;
9834
+ }
9632
9835
  /**
9633
9836
  * Set connection lifecycle callbacks.
9634
9837
  * These are merged with any callbacks provided in the constructor config.
@@ -9775,6 +9978,13 @@ var Ebus2ProxyServerUpdateNotifier = class {
9775
9978
  break;
9776
9979
  case "pong":
9777
9980
  this.handlePong();
9981
+ if (message.id !== void 0) {
9982
+ const resolver = this._pendingRttPings.get(message.id);
9983
+ if (resolver) {
9984
+ this._pendingRttPings.delete(message.id);
9985
+ resolver(message);
9986
+ }
9987
+ }
9778
9988
  break;
9779
9989
  case "error":
9780
9990
  console.error("[Ebus2ProxyNotifier] WebSocket server error:", message.error);
@@ -62,6 +62,13 @@ export declare class Ebus2ProxyServerUpdateNotifier implements I_ServerUpdateNot
62
62
  private pongTimer?;
63
63
  private forcedOffline;
64
64
  private subscribedChannels;
65
+ /**
66
+ * Pending RTT measurement promises keyed by ping id. Each entry is a
67
+ * resolver that the pong handler invokes once the matching pong
68
+ * arrives. Disconnect clears the map (caller's timeout fires soon
69
+ * after if any are still pending).
70
+ */
71
+ private _pendingRttPings;
65
72
  constructor(config: Ebus2ProxyServerUpdateNotifierConfig);
66
73
  subscribe(callback: ServerUpdateCallback): () => void;
67
74
  connect(): Promise<void>;
@@ -73,6 +80,36 @@ export declare class Ebus2ProxyServerUpdateNotifier implements I_ServerUpdateNot
73
80
  */
74
81
  dispose(): void;
75
82
  isConnected(): boolean;
83
+ /**
84
+ * WS round-trip time (client → proxy → client). Sends a tagged ping
85
+ * over the existing WebSocket and resolves with `performance.now()`
86
+ * delta when the matching pong arrives. Does NOT touch the cry-ebus2
87
+ * broker or any worker — measures pure proxy responsiveness +
88
+ * network latency.
89
+ *
90
+ * Throws if the WebSocket is not OPEN. The keepalive ping/pong
91
+ * watchdog is unaffected; multiple `measureWsRtt()` calls can be
92
+ * in flight simultaneously (each uses a unique correlation id).
93
+ *
94
+ * @param timeoutMs Max wait for matching pong (default: 5000)
95
+ * @returns RTT in milliseconds
96
+ */
97
+ measureWsRtt(timeoutMs?: number): Promise<number>;
98
+ /**
99
+ * End-to-end RTT (client → proxy → broker → echo worker → broker →
100
+ * proxy → client). Sends an HTTP request to ebus-proxy's `echo`
101
+ * service with `Date.now()` as the msgpack payload; the worker
102
+ * returns the payload unchanged, and we verify byte-equality before
103
+ * reporting RTT.
104
+ *
105
+ * The HTTP base URL is derived from `wsUrl` (`ws://` → `http://`,
106
+ * `wss://` → `https://`). Throws on HTTP error, payload mismatch,
107
+ * or timeout.
108
+ *
109
+ * @param timeoutMs Max wait for HTTP response (default: 5000)
110
+ * @returns RTT in milliseconds (full round-trip)
111
+ */
112
+ measureEndToEndRtt(timeoutMs?: number): Promise<number>;
76
113
  /**
77
114
  * Set connection lifecycle callbacks.
78
115
  * These are merged with any callbacks provided in the constructor config.
@@ -119,6 +119,9 @@ export declare class RestProxy implements I_RestInterface {
119
119
  timeoutMs?: number;
120
120
  signal?: AbortSignal;
121
121
  activityTimeoutMs?: number;
122
+ onRequestBytes?: (bytes: number) => void;
123
+ onTtfbMs?: (ms: number) => void;
124
+ onResponseChunkBytes?: (bytes: number) => void;
122
125
  }): Promise<void>;
123
126
  /**
124
127
  * Parse streaming response. Auto-detects format:
@@ -24,6 +24,7 @@ export declare class SyncedDb implements I_SyncedDb {
24
24
  private readonly wakeSync?;
25
25
  private readonly networkStatus?;
26
26
  private initialized;
27
+ private closed;
27
28
  private syncing;
28
29
  private syncLock;
29
30
  private wsUpdateQueue;
@@ -65,6 +66,18 @@ export declare class SyncedDb implements I_SyncedDb {
65
66
  isLeaderTab(): boolean;
66
67
  leaderSince(): Date | undefined;
67
68
  followerSince(): Date | undefined;
69
+ /**
70
+ * WS round-trip time (client → notifier server → client). Delegates
71
+ * to `serverUpdateNotifier.measureWsRtt`. Throws if no notifier is
72
+ * configured or the notifier implementation doesn't support RTT.
73
+ */
74
+ measureWsRtt(timeoutMs?: number): Promise<number>;
75
+ /**
76
+ * End-to-end RTT including downstream broker/worker hop. Delegates
77
+ * to `serverUpdateNotifier.measureEndToEndRtt`. Throws if no
78
+ * notifier is configured or the notifier doesn't support it.
79
+ */
80
+ measureEndToEndRtt(timeoutMs?: number): Promise<number>;
68
81
  /**
69
82
  * Register a collection for sync at runtime. See `I_SyncedDb.addCollectionToSync`.
70
83
  */
@@ -106,10 +106,26 @@ export interface I_RestInterface {
106
106
  * Streaming variant of findNewerMany. Calls onChunk for each batch of items as they arrive.
107
107
  * `specId` is forwarded as the third arg when the originating spec set one — `undefined` otherwise.
108
108
  * Old two-arg `onChunk` callbacks keep working unchanged (the third arg is ignored).
109
+ *
110
+ * Optional metric callbacks (all fire even on failure paths where applicable):
111
+ * - `onRequestBytes(bytes)` — fires once just before fetch with the
112
+ * msgpack-encoded request body's byte length.
113
+ * - `onTtfbMs(ms)` — fires once when response headers are received,
114
+ * with elapsed time since the fetch started. TTFB conflates
115
+ * upload + server processing + first-byte travel — server-side
116
+ * timing header (if available) is required to split further.
117
+ * - `onResponseChunkBytes(bytes)` — fires per response chunk read
118
+ * with that chunk's byte length (summed = total response bytes).
119
+ *
120
+ * Combined, callers can compute upload/download throughput AND
121
+ * server-vs-network breakdown without extra round-trips.
109
122
  */
110
123
  findNewerManyStream<T>(spec: GetNewerSpec<T>[], onChunk: (collection: string, items: T[], specId?: string) => Promise<void>, options?: {
111
124
  timeoutMs?: number;
112
125
  signal?: AbortSignal;
126
+ onRequestBytes?: (bytes: number) => void;
127
+ onTtfbMs?: (ms: number) => void;
128
+ onResponseChunkBytes?: (bytes: number) => void;
113
129
  }): Promise<void>;
114
130
  deleteOne<T>(collection: string, query: QuerySpec<T>): Promise<T>;
115
131
  /** Izvede agregacijo na serverju */
@@ -54,4 +54,33 @@ export interface I_ServerUpdateNotifier {
54
54
  * Optional method.
55
55
  */
56
56
  dispose?(): void;
57
+ /**
58
+ * Measure round-trip time over the existing transport (e.g. WebSocket
59
+ * ping/pong). Returns RTT in milliseconds. Does NOT involve any
60
+ * downstream broker or worker — measures the client ↔ notifier
61
+ * server hop only.
62
+ *
63
+ * Useful for diagnosing connection quality. Compare with
64
+ * `measureEndToEndRtt` to isolate broker/worker overhead.
65
+ *
66
+ * Optional. Throws if transport is not connected or doesn't support
67
+ * RTT measurement.
68
+ *
69
+ * @param timeoutMs Max wait for response (default: 5000)
70
+ */
71
+ measureWsRtt?(timeoutMs?: number): Promise<number>;
72
+ /**
73
+ * Measure full round-trip time including any downstream broker /
74
+ * worker hop (e.g. ebus-proxy → cry-ebus2 broker → echo worker →
75
+ * back). Returns RTT in milliseconds.
76
+ *
77
+ * Implementation typically invokes a server-side `echo` service that
78
+ * returns the payload unchanged, so the measurement is end-to-end.
79
+ *
80
+ * Optional. Throws if not supported or if the round-trip fails
81
+ * (e.g. timeout, payload mismatch, transport error).
82
+ *
83
+ * @param timeoutMs Max wait for response (default: 5000)
84
+ */
85
+ measureEndToEndRtt?(timeoutMs?: number): Promise<number>;
57
86
  }
@@ -189,6 +189,27 @@ export interface FindNewerManyResultInfo {
189
189
  error?: Error;
190
190
  /** Where sync was called from (for debugging) */
191
191
  calledFrom?: string;
192
+ /**
193
+ * Msgpack-encoded request body size in bytes (upload payload).
194
+ * Undefined when the transport implementation didn't report it.
195
+ */
196
+ requestBytes?: number;
197
+ /**
198
+ * Sum of wire bytes received across all streaming chunks (download
199
+ * payload). For passive download-speed measurement compute
200
+ * `responseBytes / (durationMs - ttfbMs) * 1000` for bytes/sec
201
+ * (excludes server processing time).
202
+ * Undefined when the transport didn't report per-chunk byte counts.
203
+ */
204
+ responseBytes?: number;
205
+ /**
206
+ * Time-to-first-byte in ms — elapsed from request send until response
207
+ * headers are received. Conflates upload travel + server processing +
208
+ * first-byte travel. Together with `responseBytes` and `durationMs`
209
+ * enables passive throughput + server-vs-network breakdown estimates.
210
+ * Undefined when the transport didn't report it.
211
+ */
212
+ ttfbMs?: number;
192
213
  }
193
214
  /**
194
215
  * Callback payload for Dexie write requests (before writing)
@@ -1070,6 +1091,33 @@ export interface I_SyncedDb {
1070
1091
  * @returns Date of follower transition, or undefined if currently the leader
1071
1092
  */
1072
1093
  followerSince(): Date | undefined;
1094
+ /**
1095
+ * Measure WS round-trip time: client → notifier server → client.
1096
+ * Pure proxy / network latency, without any downstream broker or
1097
+ * worker hop. Pairs with `measureEndToEndRtt` for diagnostics:
1098
+ * a low WS RTT + high end-to-end RTT points at the broker/worker
1099
+ * as the bottleneck; a high WS RTT points at the network or proxy.
1100
+ *
1101
+ * Throws when no `serverUpdateNotifier` is configured or the
1102
+ * notifier implementation does not support RTT measurement.
1103
+ *
1104
+ * @param timeoutMs Max wait for response (default: 5000)
1105
+ * @returns RTT in milliseconds
1106
+ */
1107
+ measureWsRtt(timeoutMs?: number): Promise<number>;
1108
+ /**
1109
+ * Measure end-to-end RTT including the downstream broker/worker
1110
+ * hop (e.g. ebus-proxy → cry-ebus2 broker → echo worker → back).
1111
+ * The implementation typically calls a server-side `echo` service
1112
+ * that returns the payload unchanged.
1113
+ *
1114
+ * Throws when no `serverUpdateNotifier` is configured or the
1115
+ * notifier implementation does not support end-to-end RTT.
1116
+ *
1117
+ * @param timeoutMs Max wait for response (default: 5000)
1118
+ * @returns RTT in milliseconds (full round-trip)
1119
+ */
1120
+ measureEndToEndRtt(timeoutMs?: number): Promise<number>;
1073
1121
  /**
1074
1122
  * Get metadata for a single object.
1075
1123
  * @param collection Collection name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.175",
3
+ "version": "0.1.177",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",