@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,859 @@
1
+ import type { SyncMessage } from "@firtoz/db-helpers";
2
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
3
+ import {
4
+ classifyPartialSyncRangePatch,
5
+ filterSyncMessagesForPredicateRange,
6
+ type DeliveredRange,
7
+ type PartialSyncPatchResult,
8
+ } from "./partial-sync-interest";
9
+ import { defaultPredicateColumnValue } from "./partial-sync-predicate-match";
10
+ import type { PartialSyncRowId } from "./partial-sync-row-key";
11
+ import {
12
+ partialSyncRowKey,
13
+ partialSyncRowVersionWatermarkMs,
14
+ } from "./partial-sync-row-key";
15
+
16
+ function deliveredRowIdKey(id: PartialSyncRowId): string {
17
+ return String(partialSyncRowKey(id));
18
+ }
19
+ import type {
20
+ RangeCondition,
21
+ SyncClientMessage,
22
+ SyncRange,
23
+ SyncRangeSort,
24
+ SyncServerMessage,
25
+ SyncServerMessageBody,
26
+ } from "./sync-protocol";
27
+ import { DEFAULT_SYNC_COLLECTION_ID } from "./sync-protocol";
28
+ import type { PartialSyncRowShape } from "./partial-sync-row-key";
29
+
30
+ export type { DeliveredRange } from "./partial-sync-interest";
31
+
32
+ export type ClientQueryState<
33
+ TItem extends PartialSyncRowShape = { id: string; updatedAt: null },
34
+ > = {
35
+ clientId: string;
36
+ deliveredRanges: DeliveredRange[];
37
+ /** Each entry is one predicate query's `conditions` (AND); OR across entries. */
38
+ predicateGroups: RangeCondition[][];
39
+ /** Row ids delivered to this client in the current interest session (for scoped deletes). */
40
+ deliveredRowIds: Set<string>;
41
+ pendingPatches: PartialSyncPatchResult<TItem>[];
42
+ streaming: boolean;
43
+ };
44
+
45
+ export interface PartialSyncServerBridgeStore<
46
+ TItem extends PartialSyncRowShape,
47
+ > {
48
+ queryRange: (options: {
49
+ sort: SyncRangeSort;
50
+ limit: number;
51
+ afterCursor: unknown | null;
52
+ chunkSize: number;
53
+ }) => AsyncIterable<TItem[]>;
54
+ queryByOffset: (options: {
55
+ sort: SyncRangeSort;
56
+ limit: number;
57
+ offset: number;
58
+ chunkSize: number;
59
+ }) => AsyncIterable<TItem[]>;
60
+ getTotalCount: () => Promise<number>;
61
+ getSortValue: (row: TItem, column: string) => unknown;
62
+
63
+ /** Predicate-based range (optional). */
64
+ queryByPredicate?: (options: {
65
+ conditions: RangeCondition[];
66
+ sort?: SyncRangeSort;
67
+ limit?: number;
68
+ chunkSize: number;
69
+ }) => AsyncIterable<TItem[]>;
70
+
71
+ getPredicateCount?: (conditions: RangeCondition[]) => Promise<number>;
72
+
73
+ /**
74
+ * Changes since `sinceVersion` within `range`. `null` if changelog cannot answer
75
+ * (caller should full-fetch).
76
+ */
77
+ changesSince?: (options: {
78
+ range: SyncRange;
79
+ sinceVersion: number;
80
+ chunkSize: number;
81
+ }) => Promise<{ changes: SyncMessage<TItem>[]; totalCount: number } | null>;
82
+
83
+ /** Authoritative row lookup (e.g. for {@link PartialSyncServerBridgeOptions.resolveMovedHint}). */
84
+ getRow?: (key: string | number) => Promise<TItem | undefined>;
85
+ }
86
+
87
+ export type PartialSyncPushServerChangesOptions = {
88
+ /**
89
+ * Do not emit `rangePatch` to this client (e.g. the mutation author already applied the change
90
+ * locally and receives `ack` with the same payload).
91
+ */
92
+ excludeClientId?: string;
93
+ };
94
+
95
+ export interface PartialSyncServerBridgeOptions<
96
+ TItem extends PartialSyncRowShape,
97
+ > {
98
+ store: PartialSyncServerBridgeStore<TItem>;
99
+ sendToClient: (clientId: string, message: SyncServerMessage<TItem>) => void;
100
+ queryChunkSize?: number;
101
+ /** Multiplex key for sync messages. Default {@link DEFAULT_SYNC_COLLECTION_ID}. */
102
+ collectionId?: string;
103
+ /**
104
+ * Narrow client-requested predicate conditions (e.g. fog of war). Applied before querying and
105
+ * before interest tracking for predicate `rangeQuery`.
106
+ */
107
+ resolveClientVisibility?: (
108
+ clientId: string,
109
+ requestedConditions: RangeCondition[],
110
+ ) => RangeCondition[] | Promise<RangeCondition[]>;
111
+ /**
112
+ * Optional hint for rows that left the client's range during `rangeReconcile`.
113
+ * Return `null` to enforce fog of war (default when omitted).
114
+ */
115
+ resolveMovedHint?: (
116
+ row: TItem,
117
+ range: SyncRange,
118
+ ) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
119
+ }
120
+
121
+ export class PartialSyncServerBridge<TItem extends PartialSyncRowShape> {
122
+ #clientStates = new Map<string, ClientQueryState<TItem>>();
123
+ readonly #cid: string;
124
+
125
+ constructor(private readonly options: PartialSyncServerBridgeOptions<TItem>) {
126
+ this.#cid = options.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
127
+ }
128
+
129
+ get collectionId(): string {
130
+ return this.#cid;
131
+ }
132
+
133
+ #emit(clientId: string, body: SyncServerMessageBody<TItem>): void {
134
+ this.options.sendToClient(clientId, {
135
+ ...body,
136
+ collectionId: this.#cid,
137
+ } as SyncServerMessage<TItem>);
138
+ }
139
+
140
+ async handleClientMessage(message: SyncClientMessage): Promise<void> {
141
+ const mid = message.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
142
+ if (mid !== this.#cid) return;
143
+ switch (message.type) {
144
+ case "ping":
145
+ this.#emit(message.clientId, {
146
+ type: "pong",
147
+ timestamp: message.timestamp,
148
+ });
149
+ return;
150
+ case "queryRange":
151
+ await this.#handleQueryRange(message);
152
+ return;
153
+ case "queryByOffset":
154
+ await this.#handleQueryByOffset(message);
155
+ return;
156
+ case "rangeQuery":
157
+ await this.#handleRangeQuery(message);
158
+ return;
159
+ case "rangeReconcile":
160
+ await this.#handleRangeReconcile(message);
161
+ return;
162
+ case "syncHello":
163
+ case "mutateBatch":
164
+ // Partial sync query bridge is read-focused. Mutations use PartialSyncMutationHandler.
165
+ return;
166
+ default:
167
+ exhaustiveGuard(message);
168
+ }
169
+ }
170
+
171
+ async pushServerChanges(
172
+ changes: SyncMessage<TItem>[],
173
+ options?: PartialSyncPushServerChangesOptions,
174
+ ): Promise<void> {
175
+ const exclude = options?.excludeClientId;
176
+ for (const state of this.#clientStates.values()) {
177
+ if (exclude !== undefined && state.clientId === exclude) continue;
178
+ for (const change of changes) {
179
+ const patch = classifyPartialSyncRangePatch(
180
+ state.deliveredRanges,
181
+ state.predicateGroups,
182
+ change,
183
+ (row, column) => this.options.store.getSortValue(row, column),
184
+ (row, column) => defaultPredicateColumnValue(row, column),
185
+ { deliveredRowIds: state.deliveredRowIds },
186
+ );
187
+ if (patch === null) continue;
188
+ if (state.streaming) {
189
+ state.pendingPatches.push(patch);
190
+ continue;
191
+ }
192
+ this.#emit(state.clientId, {
193
+ type: "rangePatch",
194
+ change: patch.change,
195
+ ...(patch.viewTransition !== undefined
196
+ ? { viewTransition: patch.viewTransition }
197
+ : {}),
198
+ });
199
+ this.#applyPatchToDeliveredRowIds(state, patch);
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Drop partial-sync interest for a disconnected client (prevents unbounded `#clientStates`
206
+ * growth and stale subscriptions).
207
+ */
208
+ removeClient(clientId: string): void {
209
+ this.#clientStates.delete(clientId);
210
+ }
211
+
212
+ /**
213
+ * Replace predicate interest for a client (server-authoritative visibility). Clears sort-range
214
+ * tracking; the next `rangeQuery` should re-establish delivered ranges from fresh chunks.
215
+ */
216
+ setClientVisibility(clientId: string, conditions: RangeCondition[]): void {
217
+ const state = this.#getOrCreateClientState(clientId);
218
+ state.predicateGroups = [[...conditions]];
219
+ state.deliveredRanges.length = 0;
220
+ state.deliveredRowIds.clear();
221
+ }
222
+
223
+ getClientState(clientId: string): ClientQueryState<TItem> | undefined {
224
+ return this.#clientStates.get(clientId);
225
+ }
226
+
227
+ #resetClientInterest(state: ClientQueryState<TItem>): void {
228
+ state.deliveredRanges.length = 0;
229
+ state.predicateGroups.length = 0;
230
+ state.pendingPatches.length = 0;
231
+ state.deliveredRowIds.clear();
232
+ }
233
+
234
+ #trackDeliveredRowIdsFromRows(
235
+ state: ClientQueryState<TItem>,
236
+ rows: TItem[],
237
+ ): void {
238
+ for (const row of rows) {
239
+ state.deliveredRowIds.add(deliveredRowIdKey(row.id));
240
+ }
241
+ }
242
+
243
+ #trackDeliveredRowIdsFromMessages(
244
+ state: ClientQueryState<TItem>,
245
+ messages: SyncMessage<TItem>[],
246
+ ): void {
247
+ for (const c of messages) {
248
+ if (c.type === "insert" || c.type === "update") {
249
+ state.deliveredRowIds.add(deliveredRowIdKey(c.value.id));
250
+ }
251
+ if (c.type === "delete") {
252
+ state.deliveredRowIds.delete(deliveredRowIdKey(c.key));
253
+ }
254
+ if (c.type === "truncate") {
255
+ state.deliveredRowIds.clear();
256
+ }
257
+ }
258
+ }
259
+
260
+ #applyPatchToDeliveredRowIds(
261
+ state: ClientQueryState<TItem>,
262
+ patch: PartialSyncPatchResult<TItem>,
263
+ ): void {
264
+ const ch = patch.change;
265
+ if (ch.type === "truncate") {
266
+ state.deliveredRowIds.clear();
267
+ return;
268
+ }
269
+ if (ch.type === "delete") {
270
+ state.deliveredRowIds.delete(deliveredRowIdKey(ch.key));
271
+ return;
272
+ }
273
+ if (ch.type === "insert") {
274
+ state.deliveredRowIds.add(deliveredRowIdKey(ch.value.id));
275
+ return;
276
+ }
277
+ if (ch.type === "update") {
278
+ const id = deliveredRowIdKey(ch.value.id);
279
+ if (patch.viewTransition === "exitView") {
280
+ state.deliveredRowIds.delete(id);
281
+ } else {
282
+ state.deliveredRowIds.add(id);
283
+ }
284
+ }
285
+ }
286
+
287
+ async #handleRangeQuery(
288
+ message: Extract<SyncClientMessage, { type: "rangeQuery" }>,
289
+ ): Promise<void> {
290
+ const { clientId, requestId, fingerprint } = message;
291
+ let range: SyncRange = message.range;
292
+ const state = this.#getOrCreateClientState(clientId);
293
+ /** Predicate viewport queries replace interest; index fingerprint refresh must keep sort ranges. */
294
+ if (range.kind === "predicate") {
295
+ this.#resetClientInterest(state);
296
+ } else {
297
+ state.predicateGroups.length = 0;
298
+ state.pendingPatches.length = 0;
299
+ }
300
+
301
+ if (range.kind === "predicate") {
302
+ const resolved =
303
+ this.options.resolveClientVisibility !== undefined
304
+ ? await this.options.resolveClientVisibility(
305
+ clientId,
306
+ range.conditions,
307
+ )
308
+ : range.conditions;
309
+ range = { ...range, conditions: resolved };
310
+ state.predicateGroups.push([...resolved]);
311
+ }
312
+
313
+ const rangeLimit =
314
+ range.kind === "index" ? range.limit : (range.limit ?? 200);
315
+
316
+ if (fingerprint !== undefined && this.options.store.changesSince) {
317
+ const delta = await this.options.store.changesSince({
318
+ range,
319
+ sinceVersion: fingerprint.version,
320
+ chunkSize: Math.max(1, this.options.queryChunkSize ?? 200),
321
+ });
322
+ if (delta !== null) {
323
+ if (delta.changes.length === 0) {
324
+ this.#emit(clientId, {
325
+ type: "rangeUpToDate",
326
+ requestId,
327
+ totalCount: delta.totalCount,
328
+ });
329
+ this.#mergeDeliveredRangesFromChanges(state, range, delta.changes);
330
+ return;
331
+ }
332
+ const maxDelta = Math.max(1, Math.ceil(rangeLimit * 0.5));
333
+ if (delta.changes.length <= maxDelta) {
334
+ const filteredDelta =
335
+ range.kind === "predicate"
336
+ ? filterSyncMessagesForPredicateRange(
337
+ range.conditions,
338
+ delta.changes,
339
+ (row, column) => this.options.store.getSortValue(row, column),
340
+ defaultPredicateColumnValue,
341
+ )
342
+ : delta.changes;
343
+ this.#emit(clientId, {
344
+ type: "rangeDelta",
345
+ requestId,
346
+ totalCount: delta.totalCount,
347
+ changes: filteredDelta,
348
+ });
349
+ this.#mergeDeliveredRangesFromChanges(state, range, filteredDelta);
350
+ this.#trackDeliveredRowIdsFromMessages(state, filteredDelta);
351
+ return;
352
+ }
353
+ }
354
+ }
355
+
356
+ if (range.kind === "index" && range.mode === "cursor") {
357
+ await this.#handleQueryRange({
358
+ type: "queryRange",
359
+ collectionId: this.#cid,
360
+ clientId,
361
+ requestId,
362
+ sort: range.sort,
363
+ limit: range.limit,
364
+ afterCursor: range.afterCursor,
365
+ });
366
+ return;
367
+ }
368
+ if (range.kind === "index" && range.mode === "offset") {
369
+ await this.#handleQueryByOffset({
370
+ type: "queryByOffset",
371
+ collectionId: this.#cid,
372
+ clientId,
373
+ requestId,
374
+ sort: range.sort,
375
+ limit: range.limit,
376
+ offset: range.offset,
377
+ });
378
+ return;
379
+ }
380
+ if (range.kind === "predicate") {
381
+ await this.#handleQueryPredicate(message, range);
382
+ return;
383
+ }
384
+ exhaustiveGuard(range);
385
+ }
386
+
387
+ async #handleRangeReconcile(
388
+ message: Extract<SyncClientMessage, { type: "rangeReconcile" }>,
389
+ ): Promise<void> {
390
+ const { clientId, requestId, manifest } = message;
391
+ let range: SyncRange = message.range;
392
+ const state = this.#getOrCreateClientState(clientId);
393
+ if (range.kind === "predicate") {
394
+ this.#resetClientInterest(state);
395
+ } else {
396
+ state.predicateGroups.length = 0;
397
+ state.pendingPatches.length = 0;
398
+ }
399
+ if (range.kind === "predicate") {
400
+ const resolved =
401
+ this.options.resolveClientVisibility !== undefined
402
+ ? await this.options.resolveClientVisibility(
403
+ clientId,
404
+ range.conditions,
405
+ )
406
+ : range.conditions;
407
+ range = { ...range, conditions: resolved };
408
+ state.predicateGroups.push([...resolved]);
409
+ }
410
+
411
+ const { rows, totalCount } = await this.#collectRowsForRange(range);
412
+ const byKey = new Map<string, TItem>();
413
+ for (const row of rows) {
414
+ byKey.set(deliveredRowIdKey(row.id), row);
415
+ }
416
+
417
+ const manifestKeys = new Set(
418
+ manifest.map((m) => String(partialSyncRowKey(m.id))),
419
+ );
420
+
421
+ const added: SyncMessage<TItem>[] = [];
422
+ const updated: SyncMessage<TItem>[] = [];
423
+ const stale: Array<string | number> = [];
424
+ const movedHints: Array<{
425
+ id: string | number;
426
+ hint: Record<string, unknown>;
427
+ }> = [];
428
+
429
+ for (const row of rows) {
430
+ const k = deliveredRowIdKey(row.id);
431
+ if (!manifestKeys.has(k)) {
432
+ added.push({ type: "insert", value: row });
433
+ }
434
+ }
435
+
436
+ for (const entry of manifest) {
437
+ const k = String(partialSyncRowKey(entry.id));
438
+ const serverRow = byKey.get(k);
439
+ if (serverRow === undefined) {
440
+ stale.push(entry.id);
441
+ const getRow = this.options.store.getRow;
442
+ const resolveMovedHint = this.options.resolveMovedHint;
443
+ if (getRow !== undefined && resolveMovedHint !== undefined) {
444
+ const current = await getRow(entry.id);
445
+ if (current !== undefined) {
446
+ const hint = await resolveMovedHint(current, range);
447
+ if (hint !== null) {
448
+ movedHints.push({ id: entry.id, hint });
449
+ }
450
+ }
451
+ }
452
+ continue;
453
+ }
454
+ const serverV = partialSyncRowVersionWatermarkMs(serverRow);
455
+ if (serverV !== entry.version) {
456
+ updated.push({
457
+ type: "update",
458
+ value: serverRow,
459
+ previousValue: {
460
+ ...(serverRow as object),
461
+ updatedAt: entry.version,
462
+ } as TItem,
463
+ });
464
+ }
465
+ }
466
+
467
+ this.#syncInterestAfterReconcile(state, range, rows);
468
+
469
+ this.#emit(clientId, {
470
+ type: "rangeReconcileResult",
471
+ requestId,
472
+ added,
473
+ updated,
474
+ stale,
475
+ movedHints,
476
+ totalCount,
477
+ });
478
+ }
479
+
480
+ async #collectRowsForRange(
481
+ range: SyncRange,
482
+ ): Promise<{ rows: TItem[]; totalCount: number }> {
483
+ const chunkSize = Math.max(1, this.options.queryChunkSize ?? 200);
484
+ if (range.kind === "predicate") {
485
+ const queryByPredicate = this.options.store.queryByPredicate;
486
+ if (!queryByPredicate) {
487
+ return {
488
+ rows: [],
489
+ totalCount: await this.options.store.getTotalCount(),
490
+ };
491
+ }
492
+ const limit = range.limit ?? chunkSize;
493
+ const totalCount = this.options.store.getPredicateCount
494
+ ? await this.options.store.getPredicateCount(range.conditions)
495
+ : await this.options.store.getTotalCount();
496
+ const rows: TItem[] = [];
497
+ for await (const chunk of queryByPredicate({
498
+ conditions: range.conditions,
499
+ sort: range.sort,
500
+ limit,
501
+ chunkSize,
502
+ })) {
503
+ rows.push(...chunk);
504
+ if (rows.length >= limit) break;
505
+ }
506
+ return { rows: rows.slice(0, limit), totalCount };
507
+ }
508
+ if (range.kind === "index" && range.mode === "offset") {
509
+ const totalCount = await this.options.store.getTotalCount();
510
+ const rows: TItem[] = [];
511
+ for await (const chunk of this.options.store.queryByOffset({
512
+ sort: range.sort,
513
+ limit: range.limit,
514
+ offset: range.offset,
515
+ chunkSize,
516
+ })) {
517
+ rows.push(...chunk);
518
+ if (rows.length >= range.limit) break;
519
+ }
520
+ return { rows: rows.slice(0, range.limit), totalCount };
521
+ }
522
+ if (range.kind === "index" && range.mode === "cursor") {
523
+ const totalCount = await this.options.store.getTotalCount();
524
+ const rows: TItem[] = [];
525
+ for await (const chunk of this.options.store.queryRange({
526
+ sort: range.sort,
527
+ limit: range.limit,
528
+ afterCursor: range.afterCursor,
529
+ chunkSize,
530
+ })) {
531
+ rows.push(...chunk);
532
+ if (rows.length >= range.limit) break;
533
+ }
534
+ return { rows: rows.slice(0, range.limit), totalCount };
535
+ }
536
+ exhaustiveGuard(range);
537
+ }
538
+
539
+ #syncInterestAfterReconcile(
540
+ state: ClientQueryState<TItem>,
541
+ range: SyncRange,
542
+ rows: TItem[],
543
+ ): void {
544
+ state.deliveredRowIds.clear();
545
+ for (const row of rows) {
546
+ state.deliveredRowIds.add(deliveredRowIdKey(row.id));
547
+ }
548
+ state.deliveredRanges.length = 0;
549
+ const sort =
550
+ range.kind === "index"
551
+ ? range.sort
552
+ : range.kind === "predicate"
553
+ ? range.sort
554
+ : undefined;
555
+ if (sort !== undefined && rows.length > 0) {
556
+ this.#trackDeliveredRange(state, sort, null, rows);
557
+ }
558
+ }
559
+
560
+ async #handleQueryPredicate(
561
+ message: Extract<SyncClientMessage, { type: "rangeQuery" }>,
562
+ range: Extract<SyncRange, { kind: "predicate" }>,
563
+ ): Promise<void> {
564
+ const queryByPredicate = this.options.store.queryByPredicate;
565
+ if (!queryByPredicate) {
566
+ const totalCount = await this.options.store.getTotalCount();
567
+ this.#emit(message.clientId, {
568
+ type: "queryRangeChunk",
569
+ requestId: message.requestId,
570
+ rows: [],
571
+ totalCount,
572
+ lastCursor: null,
573
+ hasMore: false,
574
+ chunkIndex: 0,
575
+ done: true,
576
+ });
577
+ return;
578
+ }
579
+
580
+ const state = this.#getOrCreateClientState(message.clientId);
581
+ state.streaming = true;
582
+ const chunkSize = Math.max(1, this.options.queryChunkSize ?? 200);
583
+ const limit = range.limit ?? chunkSize;
584
+ const totalCount = this.options.store.getPredicateCount
585
+ ? await this.options.store.getPredicateCount(range.conditions)
586
+ : await this.options.store.getTotalCount();
587
+
588
+ const iterable = queryByPredicate({
589
+ conditions: range.conditions,
590
+ sort: range.sort,
591
+ limit,
592
+ chunkSize,
593
+ });
594
+
595
+ let chunkIndex = 0;
596
+ let totalDelivered = 0;
597
+ let emittedAny = false;
598
+ const sortForTrack = range.sort;
599
+ for await (const rows of iterable) {
600
+ emittedAny = true;
601
+ totalDelivered += rows.length;
602
+ const reachedLimit = totalDelivered >= limit;
603
+ const likelyFinalChunk = rows.length < chunkSize || reachedLimit;
604
+ const isFinalChunk = likelyFinalChunk;
605
+ const lastRow = rows[rows.length - 1];
606
+ const lastCursor =
607
+ lastRow === undefined || sortForTrack === undefined
608
+ ? null
609
+ : this.options.store.getSortValue(lastRow, sortForTrack.column);
610
+ const hasMoreForClient = isFinalChunk
611
+ ? totalDelivered === limit && totalDelivered < totalCount
612
+ : true;
613
+ this.#emit(message.clientId, {
614
+ type: "queryRangeChunk",
615
+ requestId: message.requestId,
616
+ rows,
617
+ totalCount,
618
+ lastCursor,
619
+ hasMore: hasMoreForClient,
620
+ chunkIndex,
621
+ done: isFinalChunk,
622
+ });
623
+ if (sortForTrack !== undefined) {
624
+ this.#trackDeliveredRange(state, sortForTrack, null, rows);
625
+ }
626
+ this.#trackDeliveredRowIdsFromRows(state, rows as TItem[]);
627
+ chunkIndex += 1;
628
+ if (isFinalChunk) break;
629
+ }
630
+
631
+ if (!emittedAny) {
632
+ this.#emit(message.clientId, {
633
+ type: "queryRangeChunk",
634
+ requestId: message.requestId,
635
+ rows: [],
636
+ totalCount,
637
+ lastCursor: null,
638
+ hasMore: false,
639
+ chunkIndex,
640
+ done: true,
641
+ });
642
+ }
643
+
644
+ state.streaming = false;
645
+ this.#flushPendingPatches(state);
646
+ }
647
+
648
+ #mergeDeliveredRangesFromChanges(
649
+ state: ClientQueryState<TItem>,
650
+ range: SyncRange,
651
+ changes: SyncMessage<TItem>[],
652
+ ): void {
653
+ const sort =
654
+ range.kind === "index"
655
+ ? range.sort
656
+ : range.kind === "predicate"
657
+ ? range.sort
658
+ : undefined;
659
+ if (sort === undefined) return;
660
+ const rows: TItem[] = [];
661
+ for (const change of changes) {
662
+ if (change.type === "insert" || change.type === "update") {
663
+ rows.push(change.value);
664
+ }
665
+ }
666
+ if (rows.length === 0) return;
667
+ this.#trackDeliveredRange(state, sort, null, rows);
668
+ this.#trackDeliveredRowIdsFromRows(state, rows);
669
+ }
670
+
671
+ async #handleQueryRange(
672
+ message: Extract<SyncClientMessage, { type: "queryRange" }>,
673
+ ): Promise<void> {
674
+ const state = this.#getOrCreateClientState(message.clientId);
675
+ state.streaming = true;
676
+ const totalCount = await this.options.store.getTotalCount();
677
+ const chunkSize = Math.max(1, this.options.queryChunkSize ?? 200);
678
+ const iterable = this.options.store.queryRange({
679
+ sort: message.sort,
680
+ limit: message.limit,
681
+ afterCursor: message.afterCursor,
682
+ chunkSize,
683
+ });
684
+ let chunkIndex = 0;
685
+ let totalDelivered = 0;
686
+ let emittedAny = false;
687
+ for await (const rows of iterable) {
688
+ emittedAny = true;
689
+ totalDelivered += rows.length;
690
+ const reachedLimit = totalDelivered >= message.limit;
691
+ const likelyFinalChunk = rows.length < chunkSize || reachedLimit;
692
+ const isFinalChunk = likelyFinalChunk;
693
+ const lastRow = rows[rows.length - 1];
694
+ const lastCursor =
695
+ lastRow === undefined
696
+ ? message.afterCursor
697
+ : this.options.store.getSortValue(lastRow, message.sort.column);
698
+ // Pagination: more pages may exist if this request returned a full page and
699
+ // the table still has rows beyond what we returned (not `!isFinalChunk`, which
700
+ // only meant "more chunks in this stream" and wrongly set hasMore=false on the
701
+ // last chunk of a single-page response).
702
+ const hasMoreForClient = isFinalChunk
703
+ ? totalDelivered === message.limit && totalDelivered < totalCount
704
+ : true;
705
+ this.#emit(message.clientId, {
706
+ type: "queryRangeChunk",
707
+ requestId: message.requestId,
708
+ rows,
709
+ totalCount,
710
+ lastCursor,
711
+ hasMore: hasMoreForClient,
712
+ chunkIndex,
713
+ done: isFinalChunk,
714
+ });
715
+ this.#trackDeliveredRange(state, message.sort, message.afterCursor, rows);
716
+ this.#trackDeliveredRowIdsFromRows(state, rows as TItem[]);
717
+ chunkIndex += 1;
718
+ if (isFinalChunk) {
719
+ break;
720
+ }
721
+ }
722
+
723
+ if (!emittedAny) {
724
+ this.#emit(message.clientId, {
725
+ type: "queryRangeChunk",
726
+ requestId: message.requestId,
727
+ rows: [],
728
+ totalCount,
729
+ lastCursor: message.afterCursor,
730
+ hasMore: false,
731
+ chunkIndex,
732
+ done: true,
733
+ });
734
+ }
735
+
736
+ state.streaming = false;
737
+ this.#flushPendingPatches(state);
738
+ }
739
+
740
+ async #handleQueryByOffset(
741
+ message: Extract<SyncClientMessage, { type: "queryByOffset" }>,
742
+ ): Promise<void> {
743
+ const state = this.#getOrCreateClientState(message.clientId);
744
+ state.streaming = true;
745
+ const totalCount = await this.options.store.getTotalCount();
746
+ const chunkSize = Math.max(1, this.options.queryChunkSize ?? 200);
747
+ const iterable = this.options.store.queryByOffset({
748
+ sort: message.sort,
749
+ limit: message.limit,
750
+ offset: message.offset,
751
+ chunkSize,
752
+ });
753
+ let chunkIndex = 0;
754
+ let totalDelivered = 0;
755
+ let emittedAny = false;
756
+ for await (const rows of iterable) {
757
+ emittedAny = true;
758
+ totalDelivered += rows.length;
759
+ const reachedLimit = totalDelivered >= message.limit;
760
+ const likelyFinalChunk = rows.length < chunkSize || reachedLimit;
761
+ const isFinalChunk = likelyFinalChunk;
762
+ const lastRow = rows[rows.length - 1];
763
+ const lastCursor =
764
+ lastRow === undefined
765
+ ? null
766
+ : this.options.store.getSortValue(lastRow, message.sort.column);
767
+ const hasMoreForClient = isFinalChunk
768
+ ? totalDelivered === message.limit &&
769
+ message.offset + totalDelivered < totalCount
770
+ : true;
771
+ this.#emit(message.clientId, {
772
+ type: "queryRangeChunk",
773
+ requestId: message.requestId,
774
+ rows,
775
+ totalCount,
776
+ lastCursor,
777
+ hasMore: hasMoreForClient,
778
+ chunkIndex,
779
+ done: isFinalChunk,
780
+ });
781
+ this.#trackDeliveredRange(state, message.sort, null, rows);
782
+ this.#trackDeliveredRowIdsFromRows(state, rows as TItem[]);
783
+ chunkIndex += 1;
784
+ if (isFinalChunk) {
785
+ break;
786
+ }
787
+ }
788
+
789
+ if (!emittedAny) {
790
+ this.#emit(message.clientId, {
791
+ type: "queryRangeChunk",
792
+ requestId: message.requestId,
793
+ rows: [],
794
+ totalCount,
795
+ lastCursor: null,
796
+ hasMore: false,
797
+ chunkIndex,
798
+ done: true,
799
+ });
800
+ }
801
+
802
+ state.streaming = false;
803
+ this.#flushPendingPatches(state);
804
+ }
805
+
806
+ #getOrCreateClientState(clientId: string): ClientQueryState<TItem> {
807
+ let state = this.#clientStates.get(clientId);
808
+ if (!state) {
809
+ state = {
810
+ clientId,
811
+ deliveredRanges: [],
812
+ predicateGroups: [],
813
+ deliveredRowIds: new Set(),
814
+ pendingPatches: [],
815
+ streaming: false,
816
+ };
817
+ this.#clientStates.set(clientId, state);
818
+ }
819
+ return state;
820
+ }
821
+
822
+ #trackDeliveredRange(
823
+ state: ClientQueryState<TItem>,
824
+ sort: SyncRangeSort,
825
+ afterCursor: unknown | null,
826
+ rows: TItem[],
827
+ ): void {
828
+ if (rows.length === 0) return;
829
+ const firstValue =
830
+ afterCursor ??
831
+ this.options.store.getSortValue(rows[0] as TItem, sort.column);
832
+ const lastValue = this.options.store.getSortValue(
833
+ rows[rows.length - 1] as TItem,
834
+ sort.column,
835
+ );
836
+ const range: DeliveredRange = {
837
+ sortColumn: sort.column,
838
+ sortDirection: sort.direction,
839
+ fromValue: firstValue,
840
+ toValue: lastValue,
841
+ };
842
+ state.deliveredRanges.push(range);
843
+ }
844
+
845
+ #flushPendingPatches(state: ClientQueryState<TItem>): void {
846
+ if (state.pendingPatches.length === 0) return;
847
+ for (const patch of state.pendingPatches) {
848
+ this.#emit(state.clientId, {
849
+ type: "rangePatch",
850
+ change: patch.change,
851
+ ...(patch.viewTransition !== undefined
852
+ ? { viewTransition: patch.viewTransition }
853
+ : {}),
854
+ });
855
+ this.#applyPatchToDeliveredRowIds(state, patch);
856
+ }
857
+ state.pendingPatches.length = 0;
858
+ }
859
+ }