cry-synced-db-client 0.1.174 → 0.1.176

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
@@ -2,6 +2,180 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Passive transport metrics on `findNewerManyStream` round-trip (rdb2)
6
+
7
+ `FindNewerManyResultInfo` (delivered to `onFindNewerManyResult`) now
8
+ carries three optional metrics captured passively during the sync
9
+ round-trip — no extra round-trip needed, no overhead when callbacks
10
+ are absent:
11
+
12
+ - `requestBytes` — msgpack-encoded request body size (upload payload).
13
+ - `responseBytes` — sum of wire bytes received across all streaming
14
+ chunks (download payload). Renamed from the short-lived
15
+ `bytesStreamed`.
16
+ - `ttfbMs` — time-to-first-byte: elapsed from request send until
17
+ response headers arrive. Conflates `upload travel + server work +
18
+ first-byte travel` (would need a server-timing header to split
19
+ further).
20
+
21
+ Combined with the existing `durationMs`, callers can derive download
22
+ throughput and a server-vs-network breakdown without instrumenting the
23
+ transport themselves:
24
+
25
+ ```typescript
26
+ new SyncedDb({
27
+ onFindNewerManyResult: (info) => {
28
+ if (!info.success || info.responseBytes == null) return;
29
+ const downloadMs = info.durationMs - (info.ttfbMs ?? 0);
30
+ const kBps = downloadMs > 0 ? (info.responseBytes / downloadMs) : 0;
31
+ metrics.record({
32
+ req: info.requestBytes,
33
+ resp: info.responseBytes,
34
+ ttfb: info.ttfbMs,
35
+ total: info.durationMs,
36
+ kBps,
37
+ });
38
+ },
39
+ });
40
+ ```
41
+
42
+ Wired through the transport layer:
43
+
44
+ - `I_RestInterface.findNewerManyStream` options gain three optional
45
+ byte/timing callbacks: `onRequestBytes(bytes)`,
46
+ `onTtfbMs(ms)`, `onResponseChunkBytes(bytes)`. All fire even on
47
+ failure paths where applicable. Old two-arg `onChunk` callbacks keep
48
+ working unchanged.
49
+ - `RestProxy` fires `onRequestBytes` just before `fetch`, `onTtfbMs`
50
+ immediately after `await fetch(...)` resolves (separate `fetchStart`
51
+ marker, independent of `timeRequests`), and `onResponseChunkBytes`
52
+ per `reader.read()` chunk in `parseStreamingResponse`.
53
+ - `SyncEngine.syncCore` accumulates bytes / captures TTFB and forwards
54
+ them into `callOnFindNewerManyResult` on both success and error
55
+ paths. Mocks and alternative transports that don't fire the
56
+ callbacks leave the fields `undefined` — graceful degradation.
57
+
58
+ ### RTT measurement: `measureWsRtt` + `measureEndToEndRtt` (ebus-proxy)
59
+
60
+ Two diagnostic methods on `SyncedDb` (and `I_ServerUpdateNotifier`) to
61
+ measure connection latency. Together they isolate where latency comes
62
+ from:
63
+
64
+ - **`measureWsRtt(timeoutMs?)`** — client → notifier server → client.
65
+ Sends a tagged WS ping (`{type: "ping", id: <unique>}`) and resolves
66
+ with `performance.now()` delta when the matching pong arrives. Pure
67
+ proxy responsiveness + network. Sub-ms on localhost.
68
+ - **`measureEndToEndRtt(timeoutMs?)`** — client → proxy → broker →
69
+ `echo` worker → broker → proxy → client. HTTP GET to ebus-proxy's
70
+ `/?service=echo` endpoint with `Date.now()` msgpack payload; worker
71
+ returns it unchanged. ~3-15 ms on localhost.
72
+
73
+ Both return RTT in milliseconds, both Promise-based (don't block thread).
74
+ Multiple concurrent `measureWsRtt()` calls work — each uses a unique
75
+ correlation id.
76
+
77
+ Diagnostic interpretation:
78
+
79
+ | WS RTT | E2E RTT | Likely cause |
80
+ |---|---|---|
81
+ | low | low | All good |
82
+ | low | high | Broker / echo worker overloaded |
83
+ | high | high (similar Δ) | Network or proxy slow |
84
+ | spikes | stable | WS frame issue (tab throttle, frozen socket) |
85
+
86
+ ```typescript
87
+ const wsRtt = await syncedDb.measureWsRtt();
88
+ const e2eRtt = await syncedDb.measureEndToEndRtt();
89
+ console.log(`network/proxy=${wsRtt.toFixed(1)}ms, full chain=${e2eRtt.toFixed(1)}ms`);
90
+ ```
91
+
92
+ The notifier-interface methods are **optional** (`measureWsRtt?` /
93
+ `measureEndToEndRtt?`) so custom `I_ServerUpdateNotifier`
94
+ implementations don't need to implement them. `SyncedDb` pass-throughs
95
+ throw a descriptive error if missing.
96
+
97
+ Implementation in `Ebus2ProxyServerUpdateNotifier`: tagged ping reuses
98
+ the existing WS handler (extends pong dispatch in `handleMessage`
99
+ with a `_pendingRttPings` Map). HTTP echo derives the base URL from
100
+ `wsUrl` (`ws://` → `http://`, `wss://` → `https://`), msgpack-encodes
101
+ the payload, hits `/?service=echo`, validates byte-equal echo.
102
+
103
+ Live test (localhost, warm, 20 samples): WS p50 0.30 ms, E2E p50
104
+ 4.11 ms. Mock-only unit tests in `test/measureRtt.test.ts` (7 cases —
105
+ delegation, propagated rejection, no-notifier error, shape sanity).
106
+
107
+ ### `save(coll, id, {field: {}})` clears existing nested children
108
+
109
+ `computeDiffInto` for plain objects iterated only `Object.keys(update)`,
110
+ so an update like `{cepljenja: {}}` against an existing
111
+ `{cepljenja: {<id>: {…}}}` emitted **zero diff entries** — children were
112
+ preserved silently. Empty-object value is now treated as a full replace
113
+ of the field (symmetric with `{field: []}` array replace and
114
+ `{field: undefined}` delete).
115
+
116
+ ```typescript
117
+ // existing.cepljenja = { "<id>": { …data } }
118
+ await syncedDb.save("pacienti", id, { cepljenja: {} });
119
+ // after: pacient.cepljenja === {} — children physically removed in
120
+ // in-mem, Dexie, and the dirty payload
121
+ ```
122
+
123
+ Implementation in `src/utils/computeDiff.ts:computeDiffInto`: when both
124
+ sides are plain objects and `update` has zero keys while `existing` has
125
+ some, emit `diff[basePath] = {}` instead of falling through to the
126
+ "iterate update keys" loop. The earlier `deepEquals(existing, update)`
127
+ guard still short-circuits the `{} → {}` no-op case.
128
+
129
+ Regression test: `test/saveEmptyObjectClearsChildren.test.ts` (4 cases:
130
+ in-mem clear, Dexie clear, dirty payload emits wipe, sibling fields
131
+ preserved). 721 pass / 0 fail.
132
+
133
+ ### `findById` / `findByIds` auto-register unconfigured collections as temporary
134
+
135
+ Calling `findById(collection, id)` or `findByIds(collection, ids)` for a
136
+ collection NOT in the runtime sync config (e.g. boot-time `collections: [...]`)
137
+ no longer throws. Instead the collection is auto-registered as
138
+ **temporary** with `syncConfig.query: () => ({_id: {$in: [<ids>]}})`. The
139
+ call then proceeds through the normal flow — `referToServer` (default
140
+ `true`) loads the row from the server on cache miss and returns it.
141
+
142
+ Behavior on subsequent calls (inspected against the existing config's
143
+ `syncConfig.query` shape, no extra bookkeeping state):
144
+
145
+ | Existing config | Action |
146
+ |---|---|
147
+ | none | install `{_id: {$in: [<ids>]}}` (static object) |
148
+ | temporary, query matches `{_id: {$in: [...]}}` | append novel ids to the existing `$in` array |
149
+ | temporary, query is a function / different shape / absent | skip — leave alone |
150
+ | permanent | skip — never touch a permanent config |
151
+
152
+ `replaceSyncCollection` naturally resets accumulation by overwriting the
153
+ spec; the new config (whatever shape) drives future syncs alone.
154
+
155
+ Constraints:
156
+ - Dexie schema must already declare the table (Dexie does not support
157
+ adding tables to an open database). The auto-register handles only the
158
+ runtime SyncedDb-level config.
159
+ - An active `syncOnlyTheseCollections` filter (non-null — set via
160
+ `setSyncOnlyTheseCollections([…])` with at least one entry) is extended
161
+ to include the new temp collection so it participates in future sync
162
+ ticks. When no filter is set (sync-all mode), this step is a no-op.
163
+
164
+ ```typescript
165
+ // No runtime config for "zivali" — boot only registers "racuni".
166
+ new SyncedDb({ collections: [{ name: "racuni" }], dexieDb /* has zivali */, ... });
167
+
168
+ // Previously: throws "Collection 'zivali' not configured".
169
+ // Now: auto-registers as temporary, fetches via referToServer, returns.
170
+ const zival = await syncedDb.findById("zivali", id);
171
+
172
+ // Upgrade to permanent when the app wires up real sync:
173
+ await syncedDb.replaceSyncCollection({
174
+ name: "zivali",
175
+ syncConfig: { query: () => ({ vrsta: "pes" }) },
176
+ });
177
+ ```
178
+
5
179
  ### `preprocessDirtyItem` callback — per-item filter / transform before upload
6
180
 
7
181
  New optional config callback fired for **every** dirty item just before it
package/dist/index.js CHANGED
@@ -440,6 +440,10 @@ function computeDiffInto(existing, update, basePath, diff) {
440
440
  diff[basePath] = update;
441
441
  return;
442
442
  }
443
+ if (Object.keys(update).length === 0 && Object.keys(existing).length > 0) {
444
+ diff[basePath] = update;
445
+ return;
446
+ }
443
447
  for (const key of Object.keys(update)) {
444
448
  const childPath = basePath ? `${basePath}.${key}` : key;
445
449
  computeDiffInto(existing[key], update[key], childPath, diff);
@@ -3079,6 +3083,9 @@ var _SyncEngine = class _SyncEngine {
3079
3083
  source: isInitial ? "initial" : "incremental"
3080
3084
  });
3081
3085
  }
3086
+ let requestBytes;
3087
+ let responseBytes;
3088
+ let ttfbMs;
3082
3089
  try {
3083
3090
  const completedCollections = /* @__PURE__ */ new Set();
3084
3091
  const allSpecs = extras && extras.specs.length > 0 ? [...syncSpecs, ...extras.specs] : syncSpecs;
@@ -3114,6 +3121,17 @@ var _SyncEngine = class _SyncEngine {
3114
3121
  items: items.length
3115
3122
  });
3116
3123
  }
3124
+ },
3125
+ {
3126
+ onRequestBytes: (bytes) => {
3127
+ requestBytes = bytes;
3128
+ },
3129
+ onTtfbMs: (ms) => {
3130
+ ttfbMs = ms;
3131
+ },
3132
+ onResponseChunkBytes: (bytes) => {
3133
+ responseBytes = (responseBytes != null ? responseBytes : 0) + bytes;
3134
+ }
3117
3135
  }
3118
3136
  ),
3119
3137
  "findNewerManyStream"
@@ -3126,7 +3144,15 @@ var _SyncEngine = class _SyncEngine {
3126
3144
  sentCount: 0
3127
3145
  };
3128
3146
  }
3129
- this.callOnFindNewerManyResult(syncSpecs, {}, findNewerManyStartTime, true, calledFrom);
3147
+ this.callOnFindNewerManyResult(
3148
+ syncSpecs,
3149
+ {},
3150
+ findNewerManyStartTime,
3151
+ true,
3152
+ calledFrom,
3153
+ void 0,
3154
+ { requestBytes, responseBytes, ttfbMs }
3155
+ );
3130
3156
  this.callbackSafe(this.callbacks.onServerSyncEnd, {
3131
3157
  calledFrom,
3132
3158
  collectionCount: syncSpecs.length,
@@ -3135,7 +3161,15 @@ var _SyncEngine = class _SyncEngine {
3135
3161
  success: true
3136
3162
  });
3137
3163
  } catch (err) {
3138
- this.callOnFindNewerManyResult(syncSpecs, {}, findNewerManyStartTime, false, calledFrom, err);
3164
+ this.callOnFindNewerManyResult(
3165
+ syncSpecs,
3166
+ {},
3167
+ findNewerManyStartTime,
3168
+ false,
3169
+ calledFrom,
3170
+ err,
3171
+ { requestBytes, responseBytes, ttfbMs }
3172
+ );
3139
3173
  this.callbackSafe(this.callbacks.onServerSyncEnd, {
3140
3174
  calledFrom,
3141
3175
  collectionCount: syncSpecs.length,
@@ -3795,7 +3829,7 @@ var _SyncEngine = class _SyncEngine {
3795
3829
  }
3796
3830
  }
3797
3831
  }
3798
- callOnFindNewerManyResult(specs, results, startTime, success, calledFrom, error) {
3832
+ callOnFindNewerManyResult(specs, results, startTime, success, calledFrom, error, metrics) {
3799
3833
  if (this.callbacks.onFindNewerManyResult) {
3800
3834
  try {
3801
3835
  this.callbacks.onFindNewerManyResult({
@@ -3804,7 +3838,10 @@ var _SyncEngine = class _SyncEngine {
3804
3838
  durationMs: Date.now() - startTime,
3805
3839
  success,
3806
3840
  error: error instanceof Error ? error : error ? new Error(String(error)) : void 0,
3807
- calledFrom
3841
+ calledFrom,
3842
+ requestBytes: metrics == null ? void 0 : metrics.requestBytes,
3843
+ responseBytes: metrics == null ? void 0 : metrics.responseBytes,
3844
+ ttfbMs: metrics == null ? void 0 : metrics.ttfbMs
3808
3845
  });
3809
3846
  } catch (err) {
3810
3847
  console.error("[SyncEngine] onFindNewerManyResult callback failed:", err);
@@ -4567,6 +4604,36 @@ var _SyncedDb = class _SyncedDb {
4567
4604
  followerSince() {
4568
4605
  return this.leaderElection.followerSince();
4569
4606
  }
4607
+ /**
4608
+ * WS round-trip time (client → notifier server → client). Delegates
4609
+ * to `serverUpdateNotifier.measureWsRtt`. Throws if no notifier is
4610
+ * configured or the notifier implementation doesn't support RTT.
4611
+ */
4612
+ async measureWsRtt(timeoutMs) {
4613
+ var _a;
4614
+ const fn = (_a = this.serverUpdateNotifier) == null ? void 0 : _a.measureWsRtt;
4615
+ if (!fn) {
4616
+ throw new Error(
4617
+ "[SyncedDb] measureWsRtt: no serverUpdateNotifier or notifier does not support RTT"
4618
+ );
4619
+ }
4620
+ return fn.call(this.serverUpdateNotifier, timeoutMs);
4621
+ }
4622
+ /**
4623
+ * End-to-end RTT including downstream broker/worker hop. Delegates
4624
+ * to `serverUpdateNotifier.measureEndToEndRtt`. Throws if no
4625
+ * notifier is configured or the notifier doesn't support it.
4626
+ */
4627
+ async measureEndToEndRtt(timeoutMs) {
4628
+ var _a;
4629
+ const fn = (_a = this.serverUpdateNotifier) == null ? void 0 : _a.measureEndToEndRtt;
4630
+ if (!fn) {
4631
+ throw new Error(
4632
+ "[SyncedDb] measureEndToEndRtt: no serverUpdateNotifier or notifier does not support end-to-end RTT"
4633
+ );
4634
+ }
4635
+ return fn.call(this.serverUpdateNotifier, timeoutMs);
4636
+ }
4570
4637
  /**
4571
4638
  * Register a collection for sync at runtime. See `I_SyncedDb.addCollectionToSync`.
4572
4639
  */
@@ -4871,6 +4938,7 @@ var _SyncedDb = class _SyncedDb {
4871
4938
  // ==================== Read Operations ====================
4872
4939
  async findById(collection, id, opts) {
4873
4940
  var _a;
4941
+ this._autoRegisterTemporaryForFind(collection, [id]);
4874
4942
  this.assertCollection(collection);
4875
4943
  if (!id) {
4876
4944
  const err = new Error(`[SyncedDb] findById ${collection} no id ${id}`);
@@ -4928,6 +4996,7 @@ var _SyncedDb = class _SyncedDb {
4928
4996
  }
4929
4997
  async findByIds(collection, ids, opts) {
4930
4998
  var _a;
4999
+ this._autoRegisterTemporaryForFind(collection, ids);
4931
5000
  this.assertCollection(collection);
4932
5001
  ids = ids.map((id) => this.normalizeId(id, "findByIds", collection));
4933
5002
  opts = this.resolveOpts(opts);
@@ -6331,6 +6400,68 @@ var _SyncedDb = class _SyncedDb {
6331
6400
  throw new Error(`SyncedDb: Collection "${(name == null ? void 0 : name.toString()) || "?"}" not configured`);
6332
6401
  }
6333
6402
  }
6403
+ /**
6404
+ * Auto-register an unconfigured collection as TEMPORARY when `findById` /
6405
+ * `findByIds` is called for it. The collection becomes part of the sync
6406
+ * scope with a `{_id: {$in: <ids>}}` static query so future sync ticks
6407
+ * keep those rows fresh.
6408
+ *
6409
+ * Behavior matrix:
6410
+ *
6411
+ * | Existing config | Action |
6412
+ * |---|---|
6413
+ * | none | install temp with `query: {_id: {$in: [<ids>]}}` |
6414
+ * | temporary, query is `{_id: {$in: [...]}}` | append novel `<ids>` to the existing `$in` array |
6415
+ * | temporary, query is anything else (function, different shape, missing) | skip — leave config untouched |
6416
+ * | permanent | skip — never alter a permanent config |
6417
+ *
6418
+ * No extra state map — accumulation lives in the config's own `$in`
6419
+ * array. `replaceSyncCollection` (or any other path that installs a new
6420
+ * config) naturally resets accumulation by overwriting the config.
6421
+ *
6422
+ * Cheap fast paths: synchronous, no Dexie/server I/O. The regular
6423
+ * `findById` flow (in-mem cache → `referToServer` → `ensureItemsAreLoaded`)
6424
+ * handles the actual data load for THIS call.
6425
+ *
6426
+ * If an `syncOnlyTheseCollections` filter is active (non-null, i.e.
6427
+ * `setSyncOnlyTheseCollections([…])` was called with at least one
6428
+ * entry), a newly-installed temp collection is added to the filter so
6429
+ * it participates in future sync ticks. When no filter is set
6430
+ * (sync-all mode), this step is a no-op.
6431
+ */
6432
+ _autoRegisterTemporaryForFind(name, ids) {
6433
+ var _a;
6434
+ const idStrings = ids.map((id) => String(id));
6435
+ const existing = this.collections.get(name);
6436
+ if (!existing) {
6437
+ this.collections.set(name, {
6438
+ name,
6439
+ temporaryConfig: true,
6440
+ syncConfig: {
6441
+ query: { _id: { $in: idStrings } }
6442
+ }
6443
+ });
6444
+ if (this.syncOnlyCollections) {
6445
+ this.syncOnlyCollections.add(name);
6446
+ }
6447
+ return;
6448
+ }
6449
+ if (!existing.temporaryConfig) return;
6450
+ const query = (_a = existing.syncConfig) == null ? void 0 : _a.query;
6451
+ if (typeof query !== "object" || query === null) return;
6452
+ const idField = query._id;
6453
+ if (typeof idField !== "object" || idField === null) return;
6454
+ const inArr = idField.$in;
6455
+ if (!Array.isArray(inArr)) return;
6456
+ const seen = /* @__PURE__ */ new Set();
6457
+ for (const existingId of inArr) seen.add(String(existingId));
6458
+ for (const id of idStrings) {
6459
+ if (!seen.has(id)) {
6460
+ inArr.push(id);
6461
+ seen.add(id);
6462
+ }
6463
+ }
6464
+ }
6334
6465
  /** Stringify an Id parameter (ObjectId → hex string). */
6335
6466
  normalizeId(id, method, collection) {
6336
6467
  if (!id && id !== void 0) {
@@ -9279,11 +9410,12 @@ var RestProxy = class {
9279
9410
  * type=0x01 for data, type=0x00 for end-of-stream.
9280
9411
  */
9281
9412
  async findNewerManyStream(spec, onChunk, options) {
9282
- var _a, _b, _c;
9413
+ var _a, _b, _c, _d, _e;
9283
9414
  const connectTimeout = (_a = options == null ? void 0 : options.timeoutMs) != null ? _a : this.defaultTimeoutMs;
9284
9415
  const activityTimeout = (_b = options == null ? void 0 : options.activityTimeoutMs) != null ? _b : 3e4;
9285
9416
  const externalSignal = (_c = options == null ? void 0 : options.signal) != null ? _c : this.globalSignal;
9286
9417
  const startTime = this.timeRequests ? performance.now() : 0;
9418
+ const fetchStart = performance.now();
9287
9419
  const data = {
9288
9420
  payload: {
9289
9421
  db: this.tenant,
@@ -9297,6 +9429,7 @@ var RestProxy = class {
9297
9429
  }
9298
9430
  };
9299
9431
  const body = pack2(data);
9432
+ (_d = options == null ? void 0 : options.onRequestBytes) == null ? void 0 : _d.call(options, body.byteLength);
9300
9433
  const requestUrl = this.apiKey ? `${this.endpoint}?apikey=${this.apiKey}&stream=1` : `${this.endpoint}?stream=1`;
9301
9434
  const controller = new AbortController();
9302
9435
  let timeoutId = setTimeout(
@@ -9311,6 +9444,7 @@ var RestProxy = class {
9311
9444
  body,
9312
9445
  signal: combinedSignal
9313
9446
  });
9447
+ (_e = options == null ? void 0 : options.onTtfbMs) == null ? void 0 : _e.call(options, performance.now() - fetchStart);
9314
9448
  clearTimeout(timeoutId);
9315
9449
  timeoutId = void 0;
9316
9450
  if (!response.ok) {
@@ -9322,7 +9456,12 @@ var RestProxy = class {
9322
9456
  timeoutId = setTimeout(() => controller.abort(), activityTimeout);
9323
9457
  };
9324
9458
  resetActivity();
9325
- await this.parseStreamingResponse(response, onChunk, resetActivity);
9459
+ await this.parseStreamingResponse(
9460
+ response,
9461
+ onChunk,
9462
+ resetActivity,
9463
+ options == null ? void 0 : options.onResponseChunkBytes
9464
+ );
9326
9465
  if (timeoutId !== void 0) clearTimeout(timeoutId);
9327
9466
  timeoutId = void 0;
9328
9467
  if (this.timeRequests) {
@@ -9360,7 +9499,7 @@ var RestProxy = class {
9360
9499
  *
9361
9500
  * `onChunk` receives `specId` as the third arg for type-0x02 frames; `undefined` otherwise.
9362
9501
  */
9363
- async parseStreamingResponse(response, onChunk, onActivity) {
9502
+ async parseStreamingResponse(response, onChunk, onActivity, onChunkBytes) {
9364
9503
  const reader = response.body.getReader();
9365
9504
  const buffer = new StreamBuffer();
9366
9505
  const decoder2 = new TextDecoder();
@@ -9368,6 +9507,7 @@ var RestProxy = class {
9368
9507
  const { done, value } = await reader.read();
9369
9508
  if (done) return false;
9370
9509
  onActivity();
9510
+ if (onChunkBytes) onChunkBytes(value.byteLength);
9371
9511
  buffer.append(value);
9372
9512
  return true;
9373
9513
  };
@@ -9502,6 +9642,13 @@ var Ebus2ProxyServerUpdateNotifier = class {
9502
9642
  this.reconnectAttempt = 0;
9503
9643
  this.forcedOffline = false;
9504
9644
  this.subscribedChannels = /* @__PURE__ */ new Set();
9645
+ /**
9646
+ * Pending RTT measurement promises keyed by ping id. Each entry is a
9647
+ * resolver that the pong handler invokes once the matching pong
9648
+ * arrives. Disconnect clears the map (caller's timeout fires soon
9649
+ * after if any are still pending).
9650
+ */
9651
+ this._pendingRttPings = /* @__PURE__ */ new Map();
9505
9652
  var _a, _b, _c, _d, _e;
9506
9653
  this.endpoint = config.wsUrl;
9507
9654
  this.wsUrl = config.wsUrl;
@@ -9557,10 +9704,88 @@ var Ebus2ProxyServerUpdateNotifier = class {
9557
9704
  this.onWsConnectCallbacks.length = 0;
9558
9705
  this.onWsDisconnectCallbacks.length = 0;
9559
9706
  this.onWsReconnectCallbacks.length = 0;
9707
+ this._pendingRttPings.clear();
9560
9708
  }
9561
9709
  isConnected() {
9562
9710
  return this.connected && !this.forcedOffline;
9563
9711
  }
9712
+ /**
9713
+ * WS round-trip time (client → proxy → client). Sends a tagged ping
9714
+ * over the existing WebSocket and resolves with `performance.now()`
9715
+ * delta when the matching pong arrives. Does NOT touch the cry-ebus2
9716
+ * broker or any worker — measures pure proxy responsiveness +
9717
+ * network latency.
9718
+ *
9719
+ * Throws if the WebSocket is not OPEN. The keepalive ping/pong
9720
+ * watchdog is unaffected; multiple `measureWsRtt()` calls can be
9721
+ * in flight simultaneously (each uses a unique correlation id).
9722
+ *
9723
+ * @param timeoutMs Max wait for matching pong (default: 5000)
9724
+ * @returns RTT in milliseconds
9725
+ */
9726
+ async measureWsRtt(timeoutMs = 5e3) {
9727
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
9728
+ throw new Error("[Ebus2ProxyNotifier] measureWsRtt: WebSocket not OPEN");
9729
+ }
9730
+ const id = `rtt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
9731
+ const t0 = performance.now();
9732
+ return new Promise((resolve, reject) => {
9733
+ const timer = setTimeout(() => {
9734
+ this._pendingRttPings.delete(id);
9735
+ reject(new Error(`[Ebus2ProxyNotifier] measureWsRtt: timeout after ${timeoutMs}ms`));
9736
+ }, timeoutMs);
9737
+ this._pendingRttPings.set(id, () => {
9738
+ clearTimeout(timer);
9739
+ resolve(performance.now() - t0);
9740
+ });
9741
+ const pingMsg = { type: "ping", id };
9742
+ this.ws.send(packr2.pack(preprocessForPack2(pingMsg)));
9743
+ });
9744
+ }
9745
+ /**
9746
+ * End-to-end RTT (client → proxy → broker → echo worker → broker →
9747
+ * proxy → client). Sends an HTTP request to ebus-proxy's `echo`
9748
+ * service with `Date.now()` as the msgpack payload; the worker
9749
+ * returns the payload unchanged, and we verify byte-equality before
9750
+ * reporting RTT.
9751
+ *
9752
+ * The HTTP base URL is derived from `wsUrl` (`ws://` → `http://`,
9753
+ * `wss://` → `https://`). Throws on HTTP error, payload mismatch,
9754
+ * or timeout.
9755
+ *
9756
+ * @param timeoutMs Max wait for HTTP response (default: 5000)
9757
+ * @returns RTT in milliseconds (full round-trip)
9758
+ */
9759
+ async measureEndToEndRtt(timeoutMs = 5e3) {
9760
+ const httpBase = this.wsUrl.replace(/^ws:\/\//, "http://").replace(/^wss:\/\//, "https://").replace(/\/+$/, "");
9761
+ const sentAt = Date.now();
9762
+ const packed = packr2.pack(sentAt);
9763
+ let payloadB64;
9764
+ const bufCtor = globalThis.Buffer;
9765
+ if (bufCtor) {
9766
+ payloadB64 = bufCtor.from(packed).toString("base64");
9767
+ } else {
9768
+ let bin = "";
9769
+ for (let i = 0; i < packed.length; i++) bin += String.fromCharCode(packed[i]);
9770
+ payloadB64 = btoa(bin);
9771
+ }
9772
+ const keyParam = this.ebusProxyApiKey ? `&apikey=${encodeURIComponent(this.ebusProxyApiKey)}` : "";
9773
+ const url = `${httpBase}/?service=echo&payload=${encodeURIComponent(payloadB64)}${keyParam}&timeout=${timeoutMs}`;
9774
+ const t0 = performance.now();
9775
+ const signal = typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : void 0;
9776
+ const res = await fetch(url, signal ? { signal } : void 0);
9777
+ if (!res.ok) {
9778
+ throw new Error(`[Ebus2ProxyNotifier] measureEndToEndRtt: HTTP ${res.status}`);
9779
+ }
9780
+ const buf = new Uint8Array(await res.arrayBuffer());
9781
+ const echoed = unpackr2.unpack(buf);
9782
+ if (echoed !== sentAt) {
9783
+ throw new Error(
9784
+ `[Ebus2ProxyNotifier] measureEndToEndRtt: payload mismatch (sent ${sentAt}, got ${JSON.stringify(echoed)})`
9785
+ );
9786
+ }
9787
+ return performance.now() - t0;
9788
+ }
9564
9789
  /**
9565
9790
  * Set connection lifecycle callbacks.
9566
9791
  * These are merged with any callbacks provided in the constructor config.
@@ -9707,6 +9932,13 @@ var Ebus2ProxyServerUpdateNotifier = class {
9707
9932
  break;
9708
9933
  case "pong":
9709
9934
  this.handlePong();
9935
+ if (message.id !== void 0) {
9936
+ const resolver = this._pendingRttPings.get(message.id);
9937
+ if (resolver) {
9938
+ this._pendingRttPings.delete(message.id);
9939
+ resolver(message);
9940
+ }
9941
+ }
9710
9942
  break;
9711
9943
  case "error":
9712
9944
  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:
@@ -65,6 +65,18 @@ export declare class SyncedDb implements I_SyncedDb {
65
65
  isLeaderTab(): boolean;
66
66
  leaderSince(): Date | undefined;
67
67
  followerSince(): Date | undefined;
68
+ /**
69
+ * WS round-trip time (client → notifier server → client). Delegates
70
+ * to `serverUpdateNotifier.measureWsRtt`. Throws if no notifier is
71
+ * configured or the notifier implementation doesn't support RTT.
72
+ */
73
+ measureWsRtt(timeoutMs?: number): Promise<number>;
74
+ /**
75
+ * End-to-end RTT including downstream broker/worker hop. Delegates
76
+ * to `serverUpdateNotifier.measureEndToEndRtt`. Throws if no
77
+ * notifier is configured or the notifier doesn't support it.
78
+ */
79
+ measureEndToEndRtt(timeoutMs?: number): Promise<number>;
68
80
  /**
69
81
  * Register a collection for sync at runtime. See `I_SyncedDb.addCollectionToSync`.
70
82
  */
@@ -426,6 +438,36 @@ export declare class SyncedDb implements I_SyncedDb {
426
438
  */
427
439
  private preloadAllSyncMetas;
428
440
  private assertCollection;
441
+ /**
442
+ * Auto-register an unconfigured collection as TEMPORARY when `findById` /
443
+ * `findByIds` is called for it. The collection becomes part of the sync
444
+ * scope with a `{_id: {$in: <ids>}}` static query so future sync ticks
445
+ * keep those rows fresh.
446
+ *
447
+ * Behavior matrix:
448
+ *
449
+ * | Existing config | Action |
450
+ * |---|---|
451
+ * | none | install temp with `query: {_id: {$in: [<ids>]}}` |
452
+ * | temporary, query is `{_id: {$in: [...]}}` | append novel `<ids>` to the existing `$in` array |
453
+ * | temporary, query is anything else (function, different shape, missing) | skip — leave config untouched |
454
+ * | permanent | skip — never alter a permanent config |
455
+ *
456
+ * No extra state map — accumulation lives in the config's own `$in`
457
+ * array. `replaceSyncCollection` (or any other path that installs a new
458
+ * config) naturally resets accumulation by overwriting the config.
459
+ *
460
+ * Cheap fast paths: synchronous, no Dexie/server I/O. The regular
461
+ * `findById` flow (in-mem cache → `referToServer` → `ensureItemsAreLoaded`)
462
+ * handles the actual data load for THIS call.
463
+ *
464
+ * If an `syncOnlyTheseCollections` filter is active (non-null, i.e.
465
+ * `setSyncOnlyTheseCollections([…])` was called with at least one
466
+ * entry), a newly-installed temp collection is added to the filter so
467
+ * it participates in future sync ticks. When no filter is set
468
+ * (sync-all mode), this step is a no-op.
469
+ */
470
+ private _autoRegisterTemporaryForFind;
429
471
  private static readonly STRINGIFIED_FALSY;
430
472
  /** Stringify an Id parameter (ObjectId → hex string). */
431
473
  private normalizeId;
@@ -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)
@@ -864,6 +885,13 @@ export interface I_SyncedDb {
864
885
  * jih v ozadju revalidira s serverja (Dexie + in-mem skozi konflikt
865
886
  * resolucijo). Ortogonalno do referToServer; ne povzroči duplikata
866
887
  * server klicev za missing ID-je.
888
+ *
889
+ * Auto-registracija: če `collection` ni registrirana v runtime sync
890
+ * configu, se avtomatsko doda kot **temporary** s queryjem
891
+ * `{_id: {$in: [<id>]}}`. Dexie schema mora že imeti tabelo
892
+ * (Dexie ne podpira dodajanja tabel ob runtime-u). Klic nato teče
893
+ * skozi normalen findById flow — `referToServer` (privzeto `true`)
894
+ * naloži zapis s serverja ob cache miss-u.
867
895
  */
868
896
  findById<T extends DbEntity>(collection: string, id: Id, opts?: QueryOpts): Promise<T | null>;
869
897
  /**
@@ -873,6 +901,11 @@ export interface I_SyncedDb {
873
901
  * jih v ozadju revalidira s serverja (Dexie + in-mem skozi konflikt
874
902
  * resolucijo). Ortogonalno do referToServer; ne povzroči duplikata
875
903
  * server klicev za missing ID-je.
904
+ *
905
+ * Auto-registracija: enako kot `findById` — če `collection` ni v
906
+ * runtime sync configu, se doda kot temporary s queryjem
907
+ * `{_id: {$in: [<ids>]}}` (vsi ID-ji iz klica). Dexie schema mora že
908
+ * imeti tabelo.
876
909
  */
877
910
  findByIds<T extends DbEntity>(collection: string, ids: Id[], opts?: QueryOpts): Promise<T[]>;
878
911
  /**
@@ -1058,6 +1091,33 @@ export interface I_SyncedDb {
1058
1091
  * @returns Date of follower transition, or undefined if currently the leader
1059
1092
  */
1060
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>;
1061
1121
  /**
1062
1122
  * Get metadata for a single object.
1063
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.174",
3
+ "version": "0.1.176",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",