@firtoz/collection-sync 1.0.0

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.
@@ -0,0 +1,1025 @@
1
+ import type { SyncMessage } from "@firtoz/db-helpers";
2
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
3
+ import type {
4
+ RangeFingerprint,
5
+ SyncClientMessage,
6
+ SyncClientMessageBody,
7
+ SyncRange,
8
+ SyncRangeSort,
9
+ SyncServerMessage,
10
+ } from "./sync-protocol";
11
+ import { DEFAULT_SYNC_COLLECTION_ID } from "./sync-protocol";
12
+ import { createClientMutationId } from "./sync-protocol";
13
+ import {
14
+ partialSyncRowKey,
15
+ partialSyncRowVersionWatermarkMs,
16
+ type PartialSyncRowShape,
17
+ } from "./partial-sync-row-key";
18
+ import type { PartialSyncViewTransition } from "./partial-sync-interest";
19
+
20
+ export type PartialSyncViewTransitionEvent<TItem extends PartialSyncRowShape> =
21
+ {
22
+ type: PartialSyncViewTransition;
23
+ change: SyncMessage<TItem>;
24
+ };
25
+
26
+ export type PartialSyncRangePatchAppliedEvent<
27
+ TItem extends PartialSyncRowShape,
28
+ > = {
29
+ change: SyncMessage<TItem>;
30
+ viewTransition?: PartialSyncViewTransition;
31
+ };
32
+
33
+ type CollectionWithReceiveSync<TItem> = {
34
+ utils: {
35
+ receiveSync: (messages: SyncMessage<TItem>[]) => Promise<void>;
36
+ };
37
+ /**
38
+ * When set, server `queryRangeChunk` rows can become `update` messages when the collection
39
+ * already holds that id — including durable hydration (IndexedDB / SQLite reload) where rows
40
+ * exist before {@link PartialSyncClientBridge.seedHydratedLocalRows} runs or if it is skipped.
41
+ * Without this, the bridge may emit `insert` and hit duplicate-key errors from `receiveSync`.
42
+ */
43
+ get?: (key: string | number) => TItem | undefined;
44
+ };
45
+
46
+ function serverRowSupersedesLocal<TItem extends PartialSyncRowShape>(
47
+ local: TItem,
48
+ server: TItem,
49
+ ): boolean {
50
+ const lm = partialSyncRowVersionWatermarkMs(local);
51
+ const sm = partialSyncRowVersionWatermarkMs(server);
52
+ if (sm > lm) return true;
53
+ if (sm < lm) return false;
54
+ try {
55
+ return JSON.stringify(local) !== JSON.stringify(server);
56
+ } catch {
57
+ return true;
58
+ }
59
+ }
60
+
61
+ type SendFn = (msg: SyncClientMessage) => void;
62
+
63
+ export type PartialSyncState =
64
+ | { status: "offline" }
65
+ | { status: "connecting" }
66
+ | { status: "connected" }
67
+ | { status: "fetching"; requestId: string; chunksReceived: number }
68
+ | {
69
+ status: "partial";
70
+ cachedCount: number;
71
+ totalCount: number;
72
+ cacheUtilization: number;
73
+ }
74
+ | {
75
+ status: "realtime";
76
+ cachedCount: number;
77
+ totalCount: number;
78
+ cacheUtilization: number;
79
+ }
80
+ | { status: "evicting"; cachedCount: number; evictingCount: number }
81
+ | { status: "disconnected"; cachedCount: number }
82
+ | { status: "error"; message: string };
83
+
84
+ export type PartialSyncRangeResult<TItem> = {
85
+ rows: TItem[];
86
+ totalCount: number;
87
+ lastCursor: unknown | null;
88
+ hasMore: boolean;
89
+ /** Server applied a small delta; caller may need to refetch the window without fingerprint. */
90
+ invalidateWindow?: boolean;
91
+ /** Server confirmed fingerprint; no new rows on the wire. */
92
+ upToDate?: boolean;
93
+ };
94
+
95
+ export type PartialSyncReconcileResult<TItem extends PartialSyncRowShape> = {
96
+ added: TItem[];
97
+ updated: TItem[];
98
+ staleIds: Array<string | number>;
99
+ movedHints: Array<{ id: string | number; hint: Record<string, unknown> }>;
100
+ totalCount: number;
101
+ };
102
+
103
+ export interface PartialSyncClientBridgeOptions<
104
+ TItem extends PartialSyncRowShape,
105
+ > {
106
+ /** Defaults to a random UUID when omitted (must match {@link SyncClientBridge} when using mutations). */
107
+ clientId?: string;
108
+ /** Must match the server's partial-sync {@link PartialSyncServerBridgeOptions.collectionId}. */
109
+ collectionId?: string;
110
+ collection: CollectionWithReceiveSync<TItem>;
111
+ send: SendFn;
112
+ onStateChange?: (state: PartialSyncState) => void;
113
+ beforeApplyRows?: (rows: TItem[]) => Promise<void>;
114
+ /** Fired when a `rangePatch` carries `viewTransition` (row crossed client interest). */
115
+ onViewTransition?: (event: PartialSyncViewTransitionEvent<TItem>) => void;
116
+ /** Fired after any `rangePatch` is applied (including view transitions). */
117
+ onRangePatchApplied?: (
118
+ event: PartialSyncRangePatchAppliedEvent<TItem>,
119
+ ) => void;
120
+ }
121
+
122
+ type InFlightRequest<TItem> = {
123
+ requestId: string;
124
+ rows: TItem[];
125
+ totalCount: number;
126
+ lastCursor: unknown | null;
127
+ hasMore: boolean;
128
+ chunksReceived: number;
129
+ resolve: (result: PartialSyncRangeResult<TItem>) => void;
130
+ reject: (error: unknown) => void;
131
+ };
132
+
133
+ type InFlightReconcileRequest<TItem extends PartialSyncRowShape> = {
134
+ requestId: string;
135
+ resolve: (result: PartialSyncReconcileResult<TItem>) => void;
136
+ reject: (error: unknown) => void;
137
+ };
138
+
139
+ export class PartialSyncClientBridge<TItem extends PartialSyncRowShape> {
140
+ readonly clientId: string;
141
+ readonly collectionId: string;
142
+ #connected = false;
143
+ #state: PartialSyncState = { status: "offline" };
144
+ #inFlightRequests = new Map<string, InFlightRequest<TItem>>();
145
+ #inFlightReconcileRequests = new Map<
146
+ string,
147
+ InFlightReconcileRequest<TItem>
148
+ >();
149
+ #cachedIds = new Set<string | number>();
150
+ #cacheUtilization = 0;
151
+ #totalCount = 0;
152
+ #sendFn: SendFn;
153
+ /** Row keys last delivered by a completed server range response (see viewport `cacheDisplayMode`). */
154
+ #serverConfirmedKeys = new Set<string | number>();
155
+ #serverConfirmedKeysRevision = 0;
156
+ #confirmedRevisionListeners = new Set<() => void>();
157
+ /**
158
+ * Ensures `queryRangeChunk` / `rangeDelta` handlers never overlap: concurrent
159
+ * {@link handleServerMessage} calls must not run `receiveSync` in parallel for range fetches.
160
+ */
161
+ #rangeFetchApplySerial: Promise<void> = Promise.resolve();
162
+ /**
163
+ * Plain `rangePatch` updates: merge by row key. `connectPartialSync` calls
164
+ * `flushPendingCoalescedInboundUpdates` after each inbound pump pass; call it yourself if you use
165
+ * the bridge without that helper.
166
+ */
167
+ #pendingCoalescedUpdatesByKey = new Map<
168
+ string | number,
169
+ Extract<SyncMessage<TItem>, { type: "update" }>
170
+ >();
171
+
172
+ constructor(private readonly options: PartialSyncClientBridgeOptions<TItem>) {
173
+ this.clientId = options.clientId ?? crypto.randomUUID();
174
+ this.collectionId = options.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
175
+ this.#sendFn = options.send;
176
+ }
177
+
178
+ #out(msg: SyncClientMessageBody): void {
179
+ this.#sendFn({
180
+ ...msg,
181
+ collectionId: this.collectionId,
182
+ } as SyncClientMessage);
183
+ }
184
+
185
+ #scheduleRangeFetchApply(fn: () => Promise<void>): Promise<void> {
186
+ const next = this.#rangeFetchApplySerial.catch(() => {}).then(fn);
187
+ this.#rangeFetchApplySerial = next;
188
+ return next;
189
+ }
190
+
191
+ get state(): PartialSyncState {
192
+ return this.#state;
193
+ }
194
+
195
+ get cachedCount(): number {
196
+ return this.#cachedIds.size;
197
+ }
198
+
199
+ setConnecting(): void {
200
+ this.#pendingCoalescedUpdatesByKey.clear();
201
+ this.#setState({ status: "connecting" });
202
+ }
203
+
204
+ setConnected(connected: boolean): void {
205
+ this.#connected = connected;
206
+ if (!connected) {
207
+ this.#pendingCoalescedUpdatesByKey.clear();
208
+ this.#setState({ status: "disconnected", cachedCount: this.cachedCount });
209
+ return;
210
+ }
211
+ if (this.cachedCount > 0) {
212
+ this.#setState({
213
+ status: "realtime",
214
+ cachedCount: this.cachedCount,
215
+ totalCount: this.#totalCount,
216
+ cacheUtilization: this.#cacheUtilization,
217
+ });
218
+ return;
219
+ }
220
+ this.#setState({ status: "connected" });
221
+ }
222
+
223
+ setOffline(): void {
224
+ this.#connected = false;
225
+ this.#pendingCoalescedUpdatesByKey.clear();
226
+ this.#setState({ status: "offline" });
227
+ }
228
+
229
+ setSend(send: SendFn): void {
230
+ this.#sendFn = send;
231
+ }
232
+
233
+ setError(message: string): void {
234
+ this.#setState({ status: "error", message });
235
+ }
236
+
237
+ setCacheUtilization(utilization: number): void {
238
+ this.#cacheUtilization = Math.max(0, utilization);
239
+ if (this.#state.status === "partial" || this.#state.status === "realtime") {
240
+ this.#setState({
241
+ ...this.#state,
242
+ cacheUtilization: this.#cacheUtilization,
243
+ });
244
+ }
245
+ }
246
+
247
+ setEvicting(evictingCount: number): void {
248
+ this.#setState({
249
+ status: "evicting",
250
+ cachedCount: this.cachedCount,
251
+ evictingCount,
252
+ });
253
+ }
254
+
255
+ clearEvictingState(): void {
256
+ this.#setState({
257
+ status: this.#connected ? "realtime" : "partial",
258
+ cachedCount: this.cachedCount,
259
+ totalCount: this.#totalCount,
260
+ cacheUtilization: this.#cacheUtilization,
261
+ });
262
+ }
263
+
264
+ #exitFetchingAfterApplyFailure(): void {
265
+ if (this.#connected) {
266
+ this.#setState({
267
+ status: "realtime",
268
+ cachedCount: this.cachedCount,
269
+ totalCount: this.#totalCount,
270
+ cacheUtilization: this.#cacheUtilization,
271
+ });
272
+ } else {
273
+ this.#setState({
274
+ status: "partial",
275
+ cachedCount: this.cachedCount,
276
+ totalCount: this.#totalCount,
277
+ cacheUtilization: this.#cacheUtilization,
278
+ });
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Drop in-flight `queryRange` / `queryByOffset` / `rangeQuery` requests (e.g. user seek / sort reset).
284
+ * {@link requestRange}, {@link requestByOffset}, and {@link requestRangeQuery} call this first so
285
+ * overlapping viewport debounces cannot double-apply the same rows.
286
+ */
287
+ abortRangeRequests(): void {
288
+ for (const inflight of this.#inFlightRequests.values()) {
289
+ inflight.reject(
290
+ Object.assign(new Error("Range request aborted"), {
291
+ name: "AbortError",
292
+ }),
293
+ );
294
+ }
295
+ this.#inFlightRequests.clear();
296
+ for (const inflight of this.#inFlightReconcileRequests.values()) {
297
+ inflight.reject(
298
+ Object.assign(new Error("Range request aborted"), {
299
+ name: "AbortError",
300
+ }),
301
+ );
302
+ }
303
+ this.#inFlightReconcileRequests.clear();
304
+ }
305
+
306
+ /**
307
+ * Clear tracked row ids (e.g. after a local `truncate()` on the collection). Local truncate
308
+ * does not flow through `receiveSync`, so the bridge must be reset to match.
309
+ */
310
+ clearServerConfirmedKeys(): void {
311
+ this.#serverConfirmedKeys.clear();
312
+ this.#serverConfirmedKeysRevision += 1;
313
+ this.#notifyConfirmedKeysRevision();
314
+ }
315
+
316
+ /** Keys from the latest completed `rangeQuery` / chunk response. */
317
+ get serverConfirmedKeys(): ReadonlySet<string | number> {
318
+ return this.#serverConfirmedKeys;
319
+ }
320
+
321
+ /** Bumps when {@link serverConfirmedKeys} changes; pass into predicate hooks as a dependency. */
322
+ get serverConfirmedKeysRevision(): number {
323
+ return this.#serverConfirmedKeysRevision;
324
+ }
325
+
326
+ /** Subscribe to {@link serverConfirmedKeysRevision} changes (for `useSyncExternalStore`). */
327
+ subscribeConfirmedKeysRevision(listener: () => void): () => void {
328
+ this.#confirmedRevisionListeners.add(listener);
329
+ return () => {
330
+ this.#confirmedRevisionListeners.delete(listener);
331
+ };
332
+ }
333
+
334
+ #notifyConfirmedKeysRevision(): void {
335
+ for (const listener of this.#confirmedRevisionListeners) {
336
+ listener();
337
+ }
338
+ }
339
+
340
+ clearTrackedRowIds(): void {
341
+ this.#cachedIds.clear();
342
+ this.#serverConfirmedKeys.clear();
343
+ this.#serverConfirmedKeysRevision += 1;
344
+ this.#notifyConfirmedKeysRevision();
345
+ const s = this.#state;
346
+ if (s.status === "partial" || s.status === "realtime") {
347
+ this.#setState({ ...s, cachedCount: 0 });
348
+ } else if (s.status === "disconnected") {
349
+ this.#setState({ status: "disconnected", cachedCount: 0 });
350
+ } else if (s.status === "evicting") {
351
+ this.#setState({ ...s, cachedCount: 0 });
352
+ }
353
+ }
354
+
355
+ requestRange(
356
+ sort: SyncRangeSort,
357
+ limit: number,
358
+ afterCursor: unknown | null,
359
+ ): Promise<PartialSyncRangeResult<TItem>> {
360
+ this.abortRangeRequests();
361
+ const requestId = createClientMutationId("qr");
362
+ this.#setState({
363
+ status: "fetching",
364
+ requestId,
365
+ chunksReceived: 0,
366
+ });
367
+
368
+ return new Promise<PartialSyncRangeResult<TItem>>((resolve, reject) => {
369
+ this.#inFlightRequests.set(requestId, {
370
+ requestId,
371
+ rows: [],
372
+ totalCount: 0,
373
+ lastCursor: afterCursor,
374
+ hasMore: false,
375
+ chunksReceived: 0,
376
+ resolve,
377
+ reject,
378
+ });
379
+ this.#out({
380
+ type: "queryRange",
381
+ clientId: this.clientId,
382
+ requestId,
383
+ sort,
384
+ limit,
385
+ afterCursor,
386
+ });
387
+ });
388
+ }
389
+
390
+ requestByOffset(
391
+ sort: SyncRangeSort,
392
+ limit: number,
393
+ offset: number,
394
+ ): Promise<PartialSyncRangeResult<TItem>> {
395
+ this.abortRangeRequests();
396
+ const requestId = createClientMutationId("qo");
397
+ this.#setState({
398
+ status: "fetching",
399
+ requestId,
400
+ chunksReceived: 0,
401
+ });
402
+
403
+ return new Promise<PartialSyncRangeResult<TItem>>((resolve, reject) => {
404
+ this.#inFlightRequests.set(requestId, {
405
+ requestId,
406
+ rows: [],
407
+ totalCount: 0,
408
+ lastCursor: null,
409
+ hasMore: false,
410
+ chunksReceived: 0,
411
+ resolve,
412
+ reject,
413
+ });
414
+ this.#out({
415
+ type: "queryByOffset",
416
+ clientId: this.clientId,
417
+ requestId,
418
+ sort,
419
+ limit,
420
+ offset,
421
+ });
422
+ });
423
+ }
424
+
425
+ requestRangeQuery(
426
+ range: SyncRange,
427
+ fingerprint?: RangeFingerprint,
428
+ ): Promise<PartialSyncRangeResult<TItem>> {
429
+ this.abortRangeRequests();
430
+ const requestId = createClientMutationId("rq");
431
+ this.#setState({
432
+ status: "fetching",
433
+ requestId,
434
+ chunksReceived: 0,
435
+ });
436
+
437
+ return new Promise<PartialSyncRangeResult<TItem>>((resolve, reject) => {
438
+ this.#inFlightRequests.set(requestId, {
439
+ requestId,
440
+ rows: [],
441
+ totalCount: 0,
442
+ lastCursor: null,
443
+ hasMore: false,
444
+ chunksReceived: 0,
445
+ resolve,
446
+ reject,
447
+ });
448
+ this.#out({
449
+ type: "rangeQuery",
450
+ clientId: this.clientId,
451
+ requestId,
452
+ range,
453
+ ...(fingerprint !== undefined ? { fingerprint } : {}),
454
+ });
455
+ });
456
+ }
457
+
458
+ /**
459
+ * Reconcile the client's cached window against the server using a row manifest (id + version ms).
460
+ * Pass `manifest` to override rows; otherwise uses {@link PartialSyncClientBridge.serverConfirmedKeys} and {@link PartialSyncClientBridgeOptions.collection} `get`.
461
+ */
462
+ requestRangeReconcile(
463
+ range: SyncRange,
464
+ manifest?: Array<{ id: string | number; version: number }>,
465
+ ): Promise<PartialSyncReconcileResult<TItem>> {
466
+ this.abortRangeRequests();
467
+ const requestId = createClientMutationId("rc");
468
+ const resolvedManifest = manifest ?? this.#buildReconcileManifest();
469
+ this.#setState({
470
+ status: "fetching",
471
+ requestId,
472
+ chunksReceived: 0,
473
+ });
474
+
475
+ return new Promise<PartialSyncReconcileResult<TItem>>((resolve, reject) => {
476
+ this.#inFlightReconcileRequests.set(requestId, {
477
+ requestId,
478
+ resolve,
479
+ reject,
480
+ });
481
+ this.#out({
482
+ type: "rangeReconcile",
483
+ clientId: this.clientId,
484
+ requestId,
485
+ range,
486
+ manifest: resolvedManifest,
487
+ });
488
+ });
489
+ }
490
+
491
+ #buildReconcileManifest(): Array<{ id: string | number; version: number }> {
492
+ const get = this.options.collection.get;
493
+ if (get === undefined) return [];
494
+ const out: Array<{ id: string | number; version: number }> = [];
495
+ for (const key of this.#serverConfirmedKeys) {
496
+ let local = get(key);
497
+ if (local === undefined && typeof key === "number") {
498
+ local = get(String(key));
499
+ } else if (local === undefined && typeof key === "string") {
500
+ const asNum = Number(key);
501
+ if (!Number.isNaN(asNum)) {
502
+ local = get(asNum);
503
+ }
504
+ }
505
+ if (local === undefined) continue;
506
+ const rowId = local.id;
507
+ if (typeof rowId !== "string" && typeof rowId !== "number") {
508
+ continue;
509
+ }
510
+ out.push({
511
+ id: rowId,
512
+ version: partialSyncRowVersionWatermarkMs(local),
513
+ });
514
+ }
515
+ return out;
516
+ }
517
+
518
+ async handleServerMessage(message: SyncServerMessage<TItem>): Promise<void> {
519
+ const mid = message.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
520
+ if (mid !== this.collectionId) return;
521
+ switch (message.type) {
522
+ case "queryRangeChunk":
523
+ await this.#scheduleRangeFetchApply(() =>
524
+ this.#handleQueryRangeChunk(message),
525
+ );
526
+ return;
527
+ case "rangeUpToDate":
528
+ this.#handleRangeUpToDate(message);
529
+ return;
530
+ case "rangeDelta":
531
+ await this.#scheduleRangeFetchApply(() =>
532
+ this.#handleRangeDelta(message),
533
+ );
534
+ return;
535
+ case "rangeReconcileResult":
536
+ await this.#scheduleRangeFetchApply(() =>
537
+ this.#handleRangeReconcileResult(message),
538
+ );
539
+ return;
540
+ case "rangePatch":
541
+ await this.#handleRangePatch(message);
542
+ return;
543
+ case "syncBatch":
544
+ await this.#applyAndTrack(message.changes as SyncMessage<TItem>[]);
545
+ return;
546
+ case "ack":
547
+ await this.#applyAndTrack(message.changes as SyncMessage<TItem>[]);
548
+ return;
549
+ case "syncBackfill":
550
+ await this.#applyAndTrack(message.changes as SyncMessage<TItem>[]);
551
+ return;
552
+ case "reject":
553
+ this.setError(message.reason);
554
+ return;
555
+ case "pong":
556
+ return;
557
+ default:
558
+ exhaustiveGuard(message);
559
+ }
560
+ }
561
+
562
+ async #handleRangePatch(
563
+ message: Extract<SyncServerMessage<TItem>, { type: "rangePatch" }>,
564
+ ): Promise<void> {
565
+ const { change, viewTransition } = message;
566
+ if (viewTransition === "exitView") {
567
+ if (change.type === "update") {
568
+ this.options.onViewTransition?.({ type: "exitView", change });
569
+ }
570
+ await this.#applyAndTrack([change]);
571
+ this.#prunePartialInterestTrackingAfterExitView(change);
572
+ this.options.onRangePatchApplied?.({ change, viewTransition });
573
+ return;
574
+ }
575
+ if (viewTransition === "enterView") {
576
+ if (change.type === "update") {
577
+ this.options.onViewTransition?.({ type: "enterView", change });
578
+ const key = partialSyncRowKey(change.value.id);
579
+ const getRow = this.options.collection.get;
580
+ let local = getRow !== undefined ? getRow(key) : undefined;
581
+ if (local === undefined && getRow !== undefined) {
582
+ if (typeof key === "number") {
583
+ local = getRow(String(key));
584
+ } else {
585
+ const asNum = Number(key);
586
+ if (!Number.isNaN(asNum)) {
587
+ local = getRow(asNum);
588
+ }
589
+ }
590
+ }
591
+ const alreadyInCollection =
592
+ this.#cachedIds.has(key) || local !== undefined;
593
+ const toApply: SyncMessage<TItem>[] = alreadyInCollection
594
+ ? [change]
595
+ : [{ type: "insert", value: change.value }];
596
+ await this.#applyAndTrack(toApply);
597
+ this.options.onRangePatchApplied?.({ change, viewTransition });
598
+ return;
599
+ }
600
+ await this.#applyAndTrack([change]);
601
+ this.options.onRangePatchApplied?.({ change, viewTransition });
602
+ return;
603
+ }
604
+ await this.#applyAndTrack([change], change.type === "update");
605
+ this.#mergeServerConfirmedKeysFromMessages([change]);
606
+ this.options.onRangePatchApplied?.({ change, viewTransition });
607
+ }
608
+
609
+ #replaceServerConfirmedKeysFromRows(rows: readonly TItem[]): void {
610
+ this.#serverConfirmedKeys.clear();
611
+ for (const row of rows) {
612
+ this.#serverConfirmedKeys.add(partialSyncRowKey(row.id));
613
+ }
614
+ this.#serverConfirmedKeysRevision += 1;
615
+ this.#notifyConfirmedKeysRevision();
616
+ }
617
+
618
+ /** Row left the client's server-confirmed window; stop counting it as partial-sync cached. */
619
+ #prunePartialInterestTrackingAfterExitView(change: SyncMessage<TItem>): void {
620
+ switch (change.type) {
621
+ case "insert":
622
+ case "update": {
623
+ const key = partialSyncRowKey(change.value.id);
624
+ this.#cachedIds.delete(key);
625
+ this.#serverConfirmedKeys.delete(key);
626
+ break;
627
+ }
628
+ case "delete": {
629
+ this.#cachedIds.delete(change.key);
630
+ this.#serverConfirmedKeys.delete(change.key);
631
+ break;
632
+ }
633
+ case "truncate":
634
+ this.#cachedIds.clear();
635
+ this.#serverConfirmedKeys.clear();
636
+ break;
637
+ default:
638
+ exhaustiveGuard(change);
639
+ }
640
+ this.#serverConfirmedKeysRevision += 1;
641
+ this.#notifyConfirmedKeysRevision();
642
+ this.#refreshCachedCountInState();
643
+ }
644
+
645
+ #mergeServerConfirmedKeysFromMessages(changes: SyncMessage<TItem>[]): void {
646
+ if (changes.length === 0) return;
647
+ for (const change of changes) {
648
+ switch (change.type) {
649
+ case "insert":
650
+ case "update":
651
+ this.#serverConfirmedKeys.add(partialSyncRowKey(change.value.id));
652
+ break;
653
+ case "delete":
654
+ this.#serverConfirmedKeys.delete(change.key);
655
+ break;
656
+ case "truncate":
657
+ this.#serverConfirmedKeys.clear();
658
+ break;
659
+ default:
660
+ exhaustiveGuard(change);
661
+ }
662
+ }
663
+ this.#serverConfirmedKeysRevision += 1;
664
+ this.#notifyConfirmedKeysRevision();
665
+ }
666
+
667
+ /** True only if this handler still owns the in-flight entry (not superseded by {@link abortRangeRequests}). */
668
+ #isActiveRangeRequest(
669
+ requestId: string,
670
+ inFlight: InFlightRequest<TItem>,
671
+ ): boolean {
672
+ return this.#inFlightRequests.get(requestId) === inFlight;
673
+ }
674
+
675
+ async #handleQueryRangeChunk(
676
+ message: Extract<SyncServerMessage<TItem>, { type: "queryRangeChunk" }>,
677
+ ): Promise<void> {
678
+ const inFlight = this.#inFlightRequests.get(message.requestId);
679
+ if (!inFlight) return;
680
+ inFlight.chunksReceived += 1;
681
+ inFlight.totalCount = message.totalCount;
682
+ inFlight.lastCursor = message.lastCursor;
683
+ inFlight.hasMore = message.hasMore;
684
+ this.#totalCount = message.totalCount;
685
+ this.#setState({
686
+ status: "fetching",
687
+ requestId: message.requestId,
688
+ chunksReceived: inFlight.chunksReceived,
689
+ });
690
+
691
+ if (message.rows.length > 0) {
692
+ await this.options.beforeApplyRows?.(message.rows);
693
+ if (!this.#isActiveRangeRequest(message.requestId, inFlight)) return;
694
+ const getRow = this.options.collection.get;
695
+ const changes: SyncMessage<TItem>[] = [];
696
+ let cacheTouched = false;
697
+ for (const row of message.rows) {
698
+ const pk = partialSyncRowKey(row.id);
699
+ const local = getRow !== undefined ? getRow(pk) : undefined;
700
+
701
+ if (local !== undefined) {
702
+ if (!this.#cachedIds.has(pk)) {
703
+ this.#cachedIds.add(pk);
704
+ cacheTouched = true;
705
+ }
706
+ if (serverRowSupersedesLocal(local, row)) {
707
+ changes.push({
708
+ type: "update",
709
+ value: row,
710
+ previousValue: local,
711
+ } as SyncMessage<TItem>);
712
+ }
713
+ continue;
714
+ }
715
+
716
+ if (!this.#cachedIds.has(pk)) {
717
+ changes.push({ type: "insert", value: row } as SyncMessage<TItem>);
718
+ }
719
+ }
720
+ if (changes.length > 0) {
721
+ try {
722
+ await this.#applyAndTrack(changes);
723
+ } catch (err) {
724
+ if (!this.#isActiveRangeRequest(message.requestId, inFlight)) {
725
+ return;
726
+ }
727
+ this.#inFlightRequests.delete(message.requestId);
728
+ inFlight.reject(err as Error);
729
+ this.#exitFetchingAfterApplyFailure();
730
+ return;
731
+ }
732
+ if (!this.#isActiveRangeRequest(message.requestId, inFlight)) {
733
+ return;
734
+ }
735
+ } else if (cacheTouched) {
736
+ this.#refreshCachedCountInState();
737
+ }
738
+ inFlight.rows.push(...message.rows);
739
+ }
740
+
741
+ if (!message.done) return;
742
+ if (!this.#isActiveRangeRequest(message.requestId, inFlight)) return;
743
+ this.#replaceServerConfirmedKeysFromRows(inFlight.rows);
744
+ this.#inFlightRequests.delete(message.requestId);
745
+ const result: PartialSyncRangeResult<TItem> = {
746
+ rows: inFlight.rows,
747
+ totalCount: inFlight.totalCount,
748
+ lastCursor: inFlight.lastCursor,
749
+ hasMore: inFlight.hasMore,
750
+ };
751
+ inFlight.resolve(result);
752
+ this.#setState({
753
+ status: this.#connected ? "realtime" : "partial",
754
+ cachedCount: this.cachedCount,
755
+ totalCount: this.#totalCount,
756
+ cacheUtilization: this.#cacheUtilization,
757
+ });
758
+ }
759
+
760
+ #handleRangeUpToDate(
761
+ message: Extract<SyncServerMessage<TItem>, { type: "rangeUpToDate" }>,
762
+ ): void {
763
+ const inFlight = this.#inFlightRequests.get(message.requestId);
764
+ if (!inFlight) return;
765
+ this.#inFlightRequests.delete(message.requestId);
766
+ this.#totalCount = message.totalCount;
767
+ inFlight.resolve({
768
+ rows: [],
769
+ totalCount: message.totalCount,
770
+ lastCursor: null,
771
+ hasMore: false,
772
+ upToDate: true,
773
+ });
774
+ this.#setState({
775
+ status: this.#connected ? "realtime" : "partial",
776
+ cachedCount: this.cachedCount,
777
+ totalCount: this.#totalCount,
778
+ cacheUtilization: this.#cacheUtilization,
779
+ });
780
+ }
781
+
782
+ async #handleRangeDelta(
783
+ message: Extract<SyncServerMessage<TItem>, { type: "rangeDelta" }>,
784
+ ): Promise<void> {
785
+ const inFlight = this.#inFlightRequests.get(message.requestId);
786
+ if (!inFlight) return;
787
+ const delta = message.changes as SyncMessage<TItem>[];
788
+ await this.#applyAndTrack(delta);
789
+ if (!this.#isActiveRangeRequest(message.requestId, inFlight)) return;
790
+ this.#mergeServerConfirmedKeysFromMessages(delta);
791
+ this.#inFlightRequests.delete(message.requestId);
792
+ this.#totalCount = message.totalCount;
793
+ inFlight.resolve({
794
+ rows: [],
795
+ totalCount: message.totalCount,
796
+ lastCursor: message.lastCursor ?? null,
797
+ hasMore: false,
798
+ invalidateWindow: true,
799
+ });
800
+ this.#setState({
801
+ status: this.#connected ? "realtime" : "partial",
802
+ cachedCount: this.cachedCount,
803
+ totalCount: this.#totalCount,
804
+ cacheUtilization: this.#cacheUtilization,
805
+ });
806
+ }
807
+
808
+ async #handleRangeReconcileResult(
809
+ message: Extract<
810
+ SyncServerMessage<TItem>,
811
+ { type: "rangeReconcileResult" }
812
+ >,
813
+ ): Promise<void> {
814
+ const inFlight = this.#inFlightReconcileRequests.get(message.requestId);
815
+ if (!inFlight) return;
816
+
817
+ const get = this.options.collection.get;
818
+ const toApply: SyncMessage<TItem>[] = [
819
+ ...(message.added as SyncMessage<TItem>[]),
820
+ ...(message.updated as SyncMessage<TItem>[]),
821
+ ];
822
+
823
+ for (const staleKey of message.stale) {
824
+ let local: TItem | undefined;
825
+ if (get !== undefined) {
826
+ local = get(staleKey);
827
+ if (local === undefined && typeof staleKey === "number") {
828
+ local = get(String(staleKey));
829
+ } else if (local === undefined && typeof staleKey === "string") {
830
+ const asNum = Number(staleKey);
831
+ if (!Number.isNaN(asNum)) {
832
+ local = get(asNum);
833
+ }
834
+ }
835
+ }
836
+ if (local !== undefined) {
837
+ const syn: SyncMessage<TItem> = {
838
+ type: "update",
839
+ value: local,
840
+ previousValue: local,
841
+ } as SyncMessage<TItem>;
842
+ this.options.onViewTransition?.({ type: "exitView", change: syn });
843
+ }
844
+ toApply.push({ type: "delete", key: staleKey } as SyncMessage<TItem>);
845
+ }
846
+
847
+ if (toApply.length > 0) {
848
+ await this.#applyAndTrack(toApply);
849
+ if (this.#inFlightReconcileRequests.get(message.requestId) !== inFlight) {
850
+ return;
851
+ }
852
+ this.#mergeServerConfirmedKeysFromMessages(toApply);
853
+ }
854
+
855
+ this.#inFlightReconcileRequests.delete(message.requestId);
856
+ this.#totalCount = message.totalCount;
857
+
858
+ const addedRows: TItem[] = [];
859
+ for (const ch of message.added) {
860
+ if (ch.type === "insert") {
861
+ addedRows.push(ch.value);
862
+ }
863
+ }
864
+ const updatedRows: TItem[] = [];
865
+ for (const ch of message.updated) {
866
+ if (ch.type === "update") {
867
+ updatedRows.push(ch.value);
868
+ }
869
+ }
870
+
871
+ inFlight.resolve({
872
+ added: addedRows,
873
+ updated: updatedRows,
874
+ staleIds: [...message.stale],
875
+ movedHints: [...message.movedHints],
876
+ totalCount: message.totalCount,
877
+ });
878
+
879
+ this.#refreshCachedCountInState();
880
+ this.#setState({
881
+ status: this.#connected ? "realtime" : "partial",
882
+ cachedCount: this.cachedCount,
883
+ totalCount: this.#totalCount,
884
+ cacheUtilization: this.#cacheUtilization,
885
+ });
886
+ }
887
+
888
+ /**
889
+ * Merge rows already present in the local collection (e.g. IndexedDB eager `initialLoad`) into
890
+ * `#cachedIds` so {@link cachedCount} and React-driven `bridgeState` match durable storage after reload.
891
+ * Safe to call multiple times; ids are a set. Does not call `receiveSync`.
892
+ */
893
+ seedHydratedLocalRows(rows: readonly TItem[]): void {
894
+ if (rows.length === 0) return;
895
+ for (const row of rows) {
896
+ this.#cachedIds.add(partialSyncRowKey(row.id));
897
+ }
898
+ this.#refreshCachedCountInState();
899
+ }
900
+
901
+ #refreshCachedCountInState(): void {
902
+ const s = this.#state;
903
+ switch (s.status) {
904
+ case "partial":
905
+ case "realtime":
906
+ this.#setState({ ...s, cachedCount: this.cachedCount });
907
+ break;
908
+ case "disconnected":
909
+ this.#setState({
910
+ status: "disconnected",
911
+ cachedCount: this.cachedCount,
912
+ });
913
+ break;
914
+ case "evicting":
915
+ this.#setState({ ...s, cachedCount: this.cachedCount });
916
+ break;
917
+ case "connected":
918
+ if (this.#connected && this.cachedCount > 0) {
919
+ this.#setState({
920
+ status: "realtime",
921
+ cachedCount: this.cachedCount,
922
+ totalCount: this.#totalCount,
923
+ cacheUtilization: this.#cacheUtilization,
924
+ });
925
+ }
926
+ break;
927
+ default:
928
+ break;
929
+ }
930
+ }
931
+
932
+ /**
933
+ * Updates `#cachedIds` after {@link SyncClientBridge} has already applied the same messages via `receiveSync`
934
+ * (e.g. `syncBatch`) so we do not double-apply.
935
+ */
936
+ syncTrackedIdsFromMessages(changes: SyncMessage<TItem>[]): void {
937
+ for (const change of changes) {
938
+ switch (change.type) {
939
+ case "insert":
940
+ this.#cachedIds.add(partialSyncRowKey(change.value.id));
941
+ break;
942
+ case "update":
943
+ this.#cachedIds.add(partialSyncRowKey(change.value.id));
944
+ break;
945
+ case "delete":
946
+ this.#cachedIds.delete(change.key);
947
+ break;
948
+ case "truncate":
949
+ this.#cachedIds.clear();
950
+ break;
951
+ default:
952
+ exhaustiveGuard(change);
953
+ }
954
+ }
955
+ }
956
+
957
+ async #drainPendingCoalescedUpdates(): Promise<void> {
958
+ if (this.#pendingCoalescedUpdatesByKey.size === 0) return;
959
+ const batch = [...this.#pendingCoalescedUpdatesByKey.values()];
960
+ this.#pendingCoalescedUpdatesByKey.clear();
961
+ const get = this.options.collection.get;
962
+ const toApply =
963
+ get === undefined
964
+ ? batch
965
+ : batch.map((ch) => {
966
+ if (ch.type !== "update") return ch;
967
+ const id = ch.value.id;
968
+ const current =
969
+ (typeof id === "string" || typeof id === "number"
970
+ ? get(id)
971
+ : undefined) ?? get(partialSyncRowKey(id));
972
+ if (current === undefined) return ch;
973
+ return { ...ch, previousValue: current };
974
+ });
975
+ await this.#receiveSyncAndTrack(toApply);
976
+ }
977
+
978
+ /**
979
+ * Apply pending plain `rangePatch` updates coalesced by row id. Idempotent when the map is empty.
980
+ * Invoked automatically by `connectPartialSync` after each inbound pump drain.
981
+ */
982
+ async flushPendingCoalescedInboundUpdates(): Promise<void> {
983
+ await this.#drainPendingCoalescedUpdates();
984
+ }
985
+
986
+ async #receiveSyncAndTrack(changes: SyncMessage<TItem>[]): Promise<void> {
987
+ if (changes.length === 0) return;
988
+ await this.options.collection.utils.receiveSync(changes);
989
+ this.syncTrackedIdsFromMessages(changes);
990
+ }
991
+
992
+ /**
993
+ * @param coalesceSameRowUpdates When true (plain `rangePatch` updates only), merge by row key;
994
+ * the transport layer must call `flushPendingCoalescedInboundUpdates` after processing queued inbound work.
995
+ */
996
+ async #applyAndTrack(
997
+ changes: SyncMessage<TItem>[],
998
+ coalesceSameRowUpdates = false,
999
+ ): Promise<void> {
1000
+ if (changes.length === 0) return;
1001
+
1002
+ if (!coalesceSameRowUpdates) {
1003
+ await this.#drainPendingCoalescedUpdates();
1004
+ await this.#receiveSyncAndTrack(changes);
1005
+ return;
1006
+ }
1007
+
1008
+ for (const ch of changes) {
1009
+ if (ch.type !== "update") {
1010
+ await this.#drainPendingCoalescedUpdates();
1011
+ await this.#receiveSyncAndTrack(changes);
1012
+ return;
1013
+ }
1014
+ this.#pendingCoalescedUpdatesByKey.set(
1015
+ partialSyncRowKey(ch.value.id),
1016
+ ch,
1017
+ );
1018
+ }
1019
+ }
1020
+
1021
+ #setState(state: PartialSyncState): void {
1022
+ this.#state = state;
1023
+ this.options.onStateChange?.(state);
1024
+ }
1025
+ }