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 +232 -21
- package/dist/index.js +235 -25
- package/dist/src/db/Ebus2ProxyServerUpdateNotifier.d.ts +37 -0
- package/dist/src/db/RestProxy.d.ts +3 -0
- package/dist/src/db/SyncedDb.d.ts +13 -0
- package/dist/src/types/I_RestInterface.d.ts +16 -0
- package/dist/src/types/I_ServerUpdateNotifier.d.ts +29 -0
- package/dist/src/types/I_SyncedDb.d.ts +48 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,166 @@
|
|
|
1
1
|
# Versions
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
-
##
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
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
|
-
|
|
3948
|
-
await
|
|
3949
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3978
|
-
await
|
|
3979
|
-
|
|
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(
|
|
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
|