@syncular/client 0.0.6-219 → 0.0.6-223

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.
@@ -11,15 +11,17 @@ import {
11
11
  type SyncBootstrapApplyMode,
12
12
  type SyncBootstrapState,
13
13
  type SyncChange,
14
+ type SyncCombinedResponse,
14
15
  type SyncPullRequest,
15
16
  type SyncPullResponse,
16
17
  type SyncPullSubscriptionResponse,
17
18
  type SyncSnapshot,
18
- type SyncSubscriptionRequest,
19
19
  type SyncTransport,
20
20
  type SyncTransportCapabilities,
21
21
  } from '@syncular/core';
22
22
  import { type Kysely, sql, type Transaction } from 'kysely';
23
+ import type { SyncClientSubscription, SyncTraceEvent } from './engine/types';
24
+ import { SyncClientStageError, wrapSyncClientStageError } from './errors';
23
25
  import {
24
26
  type ClientHandlerCollection,
25
27
  getClientHandlerOrThrow,
@@ -93,7 +95,13 @@ function toOwnedUint8Array(chunk: Uint8Array): Uint8Array<ArrayBuffer> {
93
95
  }
94
96
 
95
97
  async function maybeGunzipStream(
96
- stream: ReadableStream<Uint8Array>
98
+ stream: ReadableStream<Uint8Array>,
99
+ context?: {
100
+ stateId?: string;
101
+ subscriptionId?: string;
102
+ table?: string;
103
+ chunkId?: string;
104
+ }
97
105
  ): Promise<ReadableStream<Uint8Array>> {
98
106
  const reader = stream.getReader();
99
107
  const prefetched: Uint8Array[] = [];
@@ -139,7 +147,20 @@ async function maybeGunzipStream(
139
147
  }
140
148
 
141
149
  const compressedBytes = await readAllBytesFromCoreStream(replayStream);
142
- return bytesToReadableStream(await gunzipBytes(compressedBytes));
150
+ try {
151
+ return bytesToReadableStream(await gunzipBytes(compressedBytes));
152
+ } catch (error) {
153
+ throw wrapSyncClientStageError(
154
+ error,
155
+ {
156
+ stage: 'snapshot-gzip-decode',
157
+ ...context,
158
+ },
159
+ `Failed to gunzip snapshot chunk${
160
+ context?.chunkId ? ` "${context.chunkId}"` : ''
161
+ }`
162
+ );
163
+ }
143
164
  }
144
165
 
145
166
  async function* decodeSnapshotRowStreamBatches(
@@ -280,13 +301,30 @@ async function fetchSnapshotChunkStream(
280
301
  request: {
281
302
  chunkId: string;
282
303
  scopeValues?: ScopeValues;
304
+ },
305
+ context?: {
306
+ stateId?: string;
307
+ subscriptionId?: string;
308
+ table?: string;
283
309
  }
284
310
  ): Promise<ReadableStream<Uint8Array>> {
285
- if (transport.fetchSnapshotChunkStream) {
286
- return transport.fetchSnapshotChunkStream(request);
311
+ try {
312
+ if (transport.fetchSnapshotChunkStream) {
313
+ return await transport.fetchSnapshotChunkStream(request);
314
+ }
315
+ const bytes = await transport.fetchSnapshotChunk(request);
316
+ return bytesToReadableStream(bytes);
317
+ } catch (error) {
318
+ throw wrapSyncClientStageError(
319
+ error,
320
+ {
321
+ stage: 'snapshot-chunk-fetch',
322
+ chunkId: request.chunkId,
323
+ ...context,
324
+ },
325
+ `Failed to fetch snapshot chunk "${request.chunkId}"`
326
+ );
287
327
  }
288
- const bytes = await transport.fetchSnapshotChunk(request);
289
- return bytesToReadableStream(bytes);
290
328
  }
291
329
 
292
330
  async function readAllBytesFromStream(
@@ -332,80 +370,212 @@ async function materializeSnapshotChunkRows(
332
370
  scopeValues?: ScopeValues;
333
371
  },
334
372
  expectedHash: string | undefined,
335
- sha256Override?: (bytes: Uint8Array) => Promise<string>
373
+ sha256Override?: (bytes: Uint8Array) => Promise<string>,
374
+ trace?: {
375
+ stateId: string;
376
+ subscriptionId: string;
377
+ table: string;
378
+ chunkIndex: number;
379
+ onTrace?: SyncPullOnceOptions['onTrace'];
380
+ }
336
381
  ): Promise<unknown[]> {
382
+ emitTrace(trace?.onTrace, {
383
+ stage: 'apply:chunk-materialize:start',
384
+ stateId: trace?.stateId,
385
+ subscriptionId: trace?.subscriptionId,
386
+ table: trace?.table,
387
+ chunkId: request.chunkId,
388
+ chunkIndex: trace?.chunkIndex,
389
+ });
390
+ const startedAt = Date.now();
391
+
337
392
  if (
338
393
  transport.capabilities?.snapshotChunkReadMode === 'bytes' &&
339
394
  transport.fetchSnapshotChunk
340
395
  ) {
341
- let bytes = await transport.fetchSnapshotChunk(request);
342
- if (isGzipBytes(bytes)) {
343
- bytes = await gunzipBytes(bytes);
344
- }
345
- if (expectedHash) {
346
- const actualHash = await computeSha256Hex(bytes, sha256Override);
347
- if (actualHash !== expectedHash) {
348
- throw new Error(
349
- `Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`
396
+ try {
397
+ let bytes = await transport.fetchSnapshotChunk(request);
398
+ if (isGzipBytes(bytes)) {
399
+ try {
400
+ bytes = await gunzipBytes(bytes);
401
+ } catch (error) {
402
+ throw wrapSyncClientStageError(
403
+ error,
404
+ {
405
+ stage: 'snapshot-gzip-decode',
406
+ stateId: trace?.stateId,
407
+ subscriptionId: trace?.subscriptionId,
408
+ table: trace?.table,
409
+ chunkId: request.chunkId,
410
+ },
411
+ `Failed to gunzip snapshot chunk "${request.chunkId}"`
412
+ );
413
+ }
414
+ }
415
+ if (expectedHash) {
416
+ const actualHash = await computeSha256Hex(bytes, sha256Override);
417
+ if (actualHash !== expectedHash) {
418
+ throw new SyncClientStageError(
419
+ `Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`,
420
+ {
421
+ stage: 'snapshot-integrity',
422
+ stateId: trace?.stateId,
423
+ subscriptionId: trace?.subscriptionId,
424
+ table: trace?.table,
425
+ chunkId: request.chunkId,
426
+ }
427
+ );
428
+ }
429
+ }
430
+ let rows: unknown[];
431
+ try {
432
+ rows = decodeSnapshotRows(bytes);
433
+ } catch (error) {
434
+ throw wrapSyncClientStageError(
435
+ error,
436
+ {
437
+ stage: 'snapshot-chunk-decode',
438
+ stateId: trace?.stateId,
439
+ subscriptionId: trace?.subscriptionId,
440
+ table: trace?.table,
441
+ chunkId: request.chunkId,
442
+ },
443
+ `Failed to decode snapshot chunk "${request.chunkId}"`
350
444
  );
351
445
  }
446
+ emitTrace(trace?.onTrace, {
447
+ stage: 'apply:chunk-materialize:complete',
448
+ stateId: trace?.stateId,
449
+ subscriptionId: trace?.subscriptionId,
450
+ table: trace?.table,
451
+ chunkId: request.chunkId,
452
+ chunkIndex: trace?.chunkIndex,
453
+ rowCount: rows.length,
454
+ durationMs: Math.max(0, Date.now() - startedAt),
455
+ });
456
+ return rows;
457
+ } catch (error) {
458
+ emitTrace(trace?.onTrace, {
459
+ stage: 'apply:chunk-materialize:error',
460
+ stateId: trace?.stateId,
461
+ subscriptionId: trace?.subscriptionId,
462
+ table: trace?.table,
463
+ chunkId: request.chunkId,
464
+ chunkIndex: trace?.chunkIndex,
465
+ durationMs: Math.max(0, Date.now() - startedAt),
466
+ errorMessage: error instanceof Error ? error.message : String(error),
467
+ });
468
+ throw error;
352
469
  }
353
- return decodeSnapshotRows(bytes);
354
470
  }
355
471
 
356
- const rawStream = await fetchSnapshotChunkStream(transport, request);
357
- const decodedStream = await maybeGunzipStream(rawStream);
358
- let streamForDecode = decodedStream;
359
- let chunkHashPromise: Promise<string> | null = null;
360
-
361
- if (expectedHash) {
362
- const [hashStream, decodeStream] = decodedStream.tee();
363
- streamForDecode = decodeStream;
364
- chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) =>
365
- computeSha256Hex(bytes, sha256Override)
366
- );
367
- }
368
-
369
- const rows: unknown[] = [];
370
- let materializeError: unknown = null;
371
-
372
472
  try {
373
- for await (const batch of decodeSnapshotRowStreamBatches(
374
- streamForDecode,
375
- SNAPSHOT_APPLY_BATCH_ROWS
376
- )) {
377
- rows.push(...batch);
473
+ const rawStream = await fetchSnapshotChunkStream(transport, request, {
474
+ stateId: trace?.stateId,
475
+ subscriptionId: trace?.subscriptionId,
476
+ table: trace?.table,
477
+ });
478
+ const decodedStream = await maybeGunzipStream(rawStream, {
479
+ stateId: trace?.stateId,
480
+ subscriptionId: trace?.subscriptionId,
481
+ table: trace?.table,
482
+ chunkId: request.chunkId,
483
+ });
484
+ let streamForDecode = decodedStream;
485
+ let chunkHashPromise: Promise<string> | null = null;
486
+
487
+ if (expectedHash) {
488
+ const [hashStream, decodeStream] = decodedStream.tee();
489
+ streamForDecode = decodeStream;
490
+ chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) =>
491
+ computeSha256Hex(bytes, sha256Override)
492
+ );
378
493
  }
379
- } catch (error) {
380
- materializeError = error;
381
- }
382
494
 
383
- if (chunkHashPromise) {
495
+ const rows: unknown[] = [];
496
+ let materializeError: unknown = null;
497
+
384
498
  try {
385
- const actualHash = await chunkHashPromise;
386
- if (!materializeError && actualHash !== expectedHash) {
387
- materializeError = new Error(
388
- `Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`
389
- );
499
+ for await (const batch of decodeSnapshotRowStreamBatches(
500
+ streamForDecode,
501
+ SNAPSHOT_APPLY_BATCH_ROWS
502
+ )) {
503
+ rows.push(...batch);
390
504
  }
391
- } catch (hashError) {
392
- if (!materializeError) {
393
- materializeError = hashError;
505
+ } catch (error) {
506
+ materializeError = wrapSyncClientStageError(
507
+ error,
508
+ {
509
+ stage: 'snapshot-chunk-decode',
510
+ stateId: trace?.stateId,
511
+ subscriptionId: trace?.subscriptionId,
512
+ table: trace?.table,
513
+ chunkId: request.chunkId,
514
+ },
515
+ `Failed to decode snapshot chunk "${request.chunkId}"`
516
+ );
517
+ }
518
+
519
+ if (chunkHashPromise) {
520
+ try {
521
+ const actualHash = await chunkHashPromise;
522
+ if (!materializeError && actualHash !== expectedHash) {
523
+ materializeError = new SyncClientStageError(
524
+ `Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`,
525
+ {
526
+ stage: 'snapshot-integrity',
527
+ stateId: trace?.stateId,
528
+ subscriptionId: trace?.subscriptionId,
529
+ table: trace?.table,
530
+ chunkId: request.chunkId,
531
+ }
532
+ );
533
+ }
534
+ } catch (hashError) {
535
+ if (!materializeError) {
536
+ materializeError = hashError;
537
+ }
394
538
  }
395
539
  }
396
- }
397
540
 
398
- if (materializeError) {
399
- throw materializeError;
400
- }
541
+ if (materializeError) {
542
+ throw materializeError;
543
+ }
401
544
 
402
- return rows;
545
+ emitTrace(trace?.onTrace, {
546
+ stage: 'apply:chunk-materialize:complete',
547
+ stateId: trace?.stateId,
548
+ subscriptionId: trace?.subscriptionId,
549
+ table: trace?.table,
550
+ chunkId: request.chunkId,
551
+ chunkIndex: trace?.chunkIndex,
552
+ rowCount: rows.length,
553
+ durationMs: Math.max(0, Date.now() - startedAt),
554
+ });
555
+ return rows;
556
+ } catch (error) {
557
+ emitTrace(trace?.onTrace, {
558
+ stage: 'apply:chunk-materialize:error',
559
+ stateId: trace?.stateId,
560
+ subscriptionId: trace?.subscriptionId,
561
+ table: trace?.table,
562
+ chunkId: request.chunkId,
563
+ chunkIndex: trace?.chunkIndex,
564
+ durationMs: Math.max(0, Date.now() - startedAt),
565
+ errorMessage: error instanceof Error ? error.message : String(error),
566
+ });
567
+ throw error;
568
+ }
403
569
  }
404
570
 
405
571
  async function materializeChunkedSnapshots(
406
572
  transport: SyncTransport,
407
573
  response: SyncPullResponse,
408
- sha256Override?: (bytes: Uint8Array) => Promise<string>
574
+ sha256Override?: (bytes: Uint8Array) => Promise<string>,
575
+ trace?: {
576
+ stateId: string;
577
+ onTrace?: SyncPullOnceOptions['onTrace'];
578
+ }
409
579
  ): Promise<SyncPullResponse> {
410
580
  const subscriptions: SyncPullResponse['subscriptions'] = [];
411
581
 
@@ -424,7 +594,9 @@ async function materializeChunkedSnapshots(
424
594
  }
425
595
 
426
596
  const rows: unknown[] = [];
427
- for (const chunk of chunks) {
597
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
598
+ const chunk = chunks[chunkIndex];
599
+ if (!chunk) continue;
428
600
  const chunkRows = await materializeSnapshotChunkRows(
429
601
  transport,
430
602
  {
@@ -432,7 +604,14 @@ async function materializeChunkedSnapshots(
432
604
  scopeValues: sub.scopes,
433
605
  },
434
606
  chunk.sha256,
435
- sha256Override
607
+ sha256Override,
608
+ {
609
+ stateId: trace?.stateId ?? 'default',
610
+ subscriptionId: sub.id,
611
+ table: snapshot.table,
612
+ chunkIndex,
613
+ onTrace: trace?.onTrace,
614
+ }
436
615
  );
437
616
  rows.push(...chunkRows);
438
617
  }
@@ -459,7 +638,12 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
459
638
  trx: Transaction<DB>,
460
639
  snapshot: SyncSnapshot,
461
640
  scopeValues: ScopeValues,
462
- sha256Override?: (bytes: Uint8Array) => Promise<string>
641
+ sha256Override?: (bytes: Uint8Array) => Promise<string>,
642
+ trace?: {
643
+ stateId: string;
644
+ subscriptionId: string;
645
+ onTrace?: SyncPullOnceOptions['onTrace'];
646
+ }
463
647
  ): Promise<void> {
464
648
  const chunks = snapshot.chunks ?? [];
465
649
  if (chunks.length === 0) {
@@ -472,88 +656,185 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
472
656
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
473
657
  const chunk = chunks[chunkIndex];
474
658
  if (!chunk) continue;
475
-
476
- const rawStream = await fetchSnapshotChunkStream(transport, {
659
+ emitTrace(trace?.onTrace, {
660
+ stage: 'apply:chunk-materialize:start',
661
+ stateId: trace?.stateId,
662
+ subscriptionId: trace?.subscriptionId,
663
+ table: snapshot.table,
477
664
  chunkId: chunk.id,
478
- scopeValues,
665
+ chunkIndex,
479
666
  });
480
- const decodedStream = await maybeGunzipStream(rawStream);
481
- let streamForDecode = decodedStream;
482
- let chunkHashPromise: Promise<string> | null = null;
667
+ const chunkStartedAt = Date.now();
483
668
 
484
- if (chunk.sha256) {
485
- const [hashStream, decodeStream] = decodedStream.tee();
486
- streamForDecode = decodeStream;
487
- chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) =>
488
- computeSha256Hex(bytes, sha256Override)
669
+ try {
670
+ const rawStream = await fetchSnapshotChunkStream(
671
+ transport,
672
+ {
673
+ chunkId: chunk.id,
674
+ scopeValues,
675
+ },
676
+ {
677
+ stateId: trace?.stateId,
678
+ subscriptionId: trace?.subscriptionId,
679
+ table: snapshot.table,
680
+ }
489
681
  );
490
- }
682
+ const decodedStream = await maybeGunzipStream(rawStream, {
683
+ stateId: trace?.stateId,
684
+ subscriptionId: trace?.subscriptionId,
685
+ table: snapshot.table,
686
+ chunkId: chunk.id,
687
+ });
688
+ let streamForDecode = decodedStream;
689
+ let chunkHashPromise: Promise<string> | null = null;
690
+
691
+ if (chunk.sha256) {
692
+ const [hashStream, decodeStream] = decodedStream.tee();
693
+ streamForDecode = decodeStream;
694
+ chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) =>
695
+ computeSha256Hex(bytes, sha256Override)
696
+ );
697
+ }
491
698
 
492
- const rowBatchIterator = decodeSnapshotRowStreamBatches(
493
- streamForDecode,
494
- SNAPSHOT_APPLY_BATCH_ROWS
495
- );
699
+ const rowBatchIterator = decodeSnapshotRowStreamBatches(
700
+ streamForDecode,
701
+ SNAPSHOT_APPLY_BATCH_ROWS
702
+ );
496
703
 
497
- let pendingBatch: unknown[] | null = null;
498
- let applyError: unknown = null;
704
+ let pendingBatch: unknown[] | null = null;
705
+ let applyError: unknown = null;
706
+ let chunkRowCount = 0;
707
+
708
+ try {
709
+ // eslint-disable-next-line no-await-in-loop
710
+ for await (const batch of rowBatchIterator) {
711
+ chunkRowCount += batch.length;
712
+ if (pendingBatch) {
713
+ // eslint-disable-next-line no-await-in-loop
714
+ try {
715
+ await handler.applySnapshot(
716
+ { trx },
717
+ {
718
+ ...snapshot,
719
+ rows: pendingBatch,
720
+ chunks: undefined,
721
+ isFirstPage: nextIsFirstPage,
722
+ isLastPage: false,
723
+ }
724
+ );
725
+ } catch (error) {
726
+ throw wrapSyncClientStageError(
727
+ error,
728
+ {
729
+ stage: 'snapshot-apply',
730
+ stateId: trace?.stateId,
731
+ subscriptionId: trace?.subscriptionId,
732
+ table: snapshot.table,
733
+ chunkId: chunk.id,
734
+ },
735
+ `Failed to apply snapshot chunk "${chunk.id}"`
736
+ );
737
+ }
738
+ nextIsFirstPage = false;
739
+ }
740
+ pendingBatch = batch;
741
+ }
499
742
 
500
- try {
501
- // eslint-disable-next-line no-await-in-loop
502
- for await (const batch of rowBatchIterator) {
503
743
  if (pendingBatch) {
744
+ const isLastChunk = chunkIndex === chunks.length - 1;
504
745
  // eslint-disable-next-line no-await-in-loop
505
- await handler.applySnapshot(
506
- { trx },
507
- {
508
- ...snapshot,
509
- rows: pendingBatch,
510
- chunks: undefined,
511
- isFirstPage: nextIsFirstPage,
512
- isLastPage: false,
513
- }
514
- );
746
+ try {
747
+ await handler.applySnapshot(
748
+ { trx },
749
+ {
750
+ ...snapshot,
751
+ rows: pendingBatch,
752
+ chunks: undefined,
753
+ isFirstPage: nextIsFirstPage,
754
+ isLastPage: isLastChunk ? snapshot.isLastPage : false,
755
+ }
756
+ );
757
+ } catch (error) {
758
+ throw wrapSyncClientStageError(
759
+ error,
760
+ {
761
+ stage: 'snapshot-apply',
762
+ stateId: trace?.stateId,
763
+ subscriptionId: trace?.subscriptionId,
764
+ table: snapshot.table,
765
+ chunkId: chunk.id,
766
+ },
767
+ `Failed to apply snapshot chunk "${chunk.id}"`
768
+ );
769
+ }
515
770
  nextIsFirstPage = false;
516
771
  }
517
- pendingBatch = batch;
772
+ } catch (error) {
773
+ applyError =
774
+ error instanceof SyncClientStageError
775
+ ? error
776
+ : wrapSyncClientStageError(
777
+ error,
778
+ {
779
+ stage: 'snapshot-chunk-decode',
780
+ stateId: trace?.stateId,
781
+ subscriptionId: trace?.subscriptionId,
782
+ table: snapshot.table,
783
+ chunkId: chunk.id,
784
+ },
785
+ `Failed to decode snapshot chunk "${chunk.id}"`
786
+ );
518
787
  }
519
788
 
520
- if (pendingBatch) {
521
- const isLastChunk = chunkIndex === chunks.length - 1;
522
- // eslint-disable-next-line no-await-in-loop
523
- await handler.applySnapshot(
524
- { trx },
525
- {
526
- ...snapshot,
527
- rows: pendingBatch,
528
- chunks: undefined,
529
- isFirstPage: nextIsFirstPage,
530
- isLastPage: isLastChunk ? snapshot.isLastPage : false,
789
+ if (chunkHashPromise) {
790
+ try {
791
+ // eslint-disable-next-line no-await-in-loop
792
+ const actualHash = await chunkHashPromise;
793
+ if (!applyError && actualHash !== chunk.sha256) {
794
+ applyError = new SyncClientStageError(
795
+ `Snapshot chunk integrity check failed: expected sha256 ${chunk.sha256}, got ${actualHash}`,
796
+ {
797
+ stage: 'snapshot-integrity',
798
+ stateId: trace?.stateId,
799
+ subscriptionId: trace?.subscriptionId,
800
+ table: snapshot.table,
801
+ chunkId: chunk.id,
802
+ }
803
+ );
531
804
  }
532
- );
533
- nextIsFirstPage = false;
805
+ } catch (hashError) {
806
+ if (!applyError) {
807
+ applyError = hashError;
808
+ }
809
+ }
534
810
  }
535
- } catch (error) {
536
- applyError = error;
537
- }
538
811
 
539
- if (chunkHashPromise) {
540
- try {
541
- // eslint-disable-next-line no-await-in-loop
542
- const actualHash = await chunkHashPromise;
543
- if (!applyError && actualHash !== chunk.sha256) {
544
- applyError = new Error(
545
- `Snapshot chunk integrity check failed: expected sha256 ${chunk.sha256}, got ${actualHash}`
546
- );
547
- }
548
- } catch (hashError) {
549
- if (!applyError) {
550
- applyError = hashError;
551
- }
812
+ if (applyError) {
813
+ throw applyError;
552
814
  }
553
- }
554
815
 
555
- if (applyError) {
556
- throw applyError;
816
+ emitTrace(trace?.onTrace, {
817
+ stage: 'apply:chunk-materialize:complete',
818
+ stateId: trace?.stateId,
819
+ subscriptionId: trace?.subscriptionId,
820
+ table: snapshot.table,
821
+ chunkId: chunk.id,
822
+ chunkIndex,
823
+ rowCount: chunkRowCount,
824
+ durationMs: Math.max(0, Date.now() - chunkStartedAt),
825
+ });
826
+ } catch (error) {
827
+ emitTrace(trace?.onTrace, {
828
+ stage: 'apply:chunk-materialize:error',
829
+ stateId: trace?.stateId,
830
+ subscriptionId: trace?.subscriptionId,
831
+ table: snapshot.table,
832
+ chunkId: chunk.id,
833
+ chunkIndex,
834
+ durationMs: Math.max(0, Date.now() - chunkStartedAt),
835
+ errorMessage: error instanceof Error ? error.message : String(error),
836
+ });
837
+ throw error;
557
838
  }
558
839
  }
559
840
  }
@@ -580,6 +861,69 @@ function parseBootstrapState(
580
861
  }
581
862
  }
582
863
 
864
+ function normalizeBootstrapPhase(value: number | undefined): number {
865
+ if (value === undefined) return 0;
866
+ return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
867
+ }
868
+
869
+ function isSubscriptionReady(
870
+ row: SyncSubscriptionStateTable | undefined
871
+ ): boolean {
872
+ return (
873
+ row?.status === 'active' &&
874
+ parseBootstrapState(row.bootstrap_state_json) === null &&
875
+ row.cursor >= 0
876
+ );
877
+ }
878
+
879
+ function isSubscriptionBootstrapping(
880
+ row: SyncSubscriptionStateTable | undefined
881
+ ): boolean {
882
+ return (
883
+ row?.status === 'active' &&
884
+ parseBootstrapState(row.bootstrap_state_json) !== null
885
+ );
886
+ }
887
+
888
+ function resolveActiveBootstrapPhase(
889
+ subscriptions: readonly SyncClientSubscription[],
890
+ existingById: ReadonlyMap<string, SyncSubscriptionStateTable>
891
+ ): number | null {
892
+ let lowestPendingPhase: number | null = null;
893
+
894
+ for (const subscription of subscriptions) {
895
+ const phase = normalizeBootstrapPhase(subscription.bootstrapPhase);
896
+ if (isSubscriptionReady(existingById.get(subscription.id))) {
897
+ continue;
898
+ }
899
+ if (lowestPendingPhase === null || phase < lowestPendingPhase) {
900
+ lowestPendingPhase = phase;
901
+ }
902
+ }
903
+
904
+ return lowestPendingPhase;
905
+ }
906
+
907
+ function selectPullSubscriptions(
908
+ subscriptions: readonly SyncClientSubscription[],
909
+ existingById: ReadonlyMap<string, SyncSubscriptionStateTable>
910
+ ): SyncClientSubscription[] {
911
+ const activePhase = resolveActiveBootstrapPhase(subscriptions, existingById);
912
+ if (activePhase === null) {
913
+ return [...subscriptions];
914
+ }
915
+
916
+ return subscriptions.filter((subscription) => {
917
+ const phase = normalizeBootstrapPhase(subscription.bootstrapPhase);
918
+ const existing = existingById.get(subscription.id);
919
+
920
+ if (phase <= activePhase) return true;
921
+ if (isSubscriptionReady(existing)) return true;
922
+ if (isSubscriptionBootstrapping(existing)) return true;
923
+ return false;
924
+ });
925
+ }
926
+
583
927
  function parseScopeValuesJson(
584
928
  value: string | object | null | undefined
585
929
  ): ScopeValues {
@@ -698,7 +1042,7 @@ export interface SyncPullOnceOptions {
698
1042
  * Desired subscriptions (client-chosen ids).
699
1043
  * Cursors are persisted in `sync_subscription_state`.
700
1044
  */
701
- subscriptions: Array<Omit<SyncSubscriptionRequest, 'cursor'>>;
1045
+ subscriptions: SyncClientSubscription[];
702
1046
  limitCommits?: number;
703
1047
  limitSnapshotRows?: number;
704
1048
  maxSnapshotPages?: number;
@@ -721,6 +1065,8 @@ export interface SyncPullOnceOptions {
721
1065
  * Must return the hex-encoded hash string.
722
1066
  */
723
1067
  sha256?: (bytes: Uint8Array) => Promise<string>;
1068
+ /** Optional structured tracing hook for pull/apply lifecycle diagnostics. */
1069
+ onTrace?: (event: SyncTraceEvent) => void;
724
1070
  }
725
1071
 
726
1072
  export interface SyncPullRequestState {
@@ -728,6 +1074,40 @@ export interface SyncPullRequestState {
728
1074
  existing: SyncSubscriptionStateTable[];
729
1075
  existingById: Map<string, SyncSubscriptionStateTable>;
730
1076
  stateId: string;
1077
+ configuredSubscriptions: SyncClientSubscription[];
1078
+ }
1079
+
1080
+ function emitTrace(
1081
+ onTrace: SyncPullOnceOptions['onTrace'],
1082
+ event: Omit<SyncTraceEvent, 'timestamp'>
1083
+ ): void {
1084
+ onTrace?.({
1085
+ timestamp: Date.now(),
1086
+ ...event,
1087
+ });
1088
+ }
1089
+
1090
+ function countSubscriptionRows(
1091
+ subscription: SyncPullSubscriptionResponse
1092
+ ): number | undefined {
1093
+ if (!subscription.bootstrap) return undefined;
1094
+ const snapshots = subscription.snapshots ?? [];
1095
+ if (snapshots.length === 0) return 0;
1096
+ return snapshots.reduce(
1097
+ (sum, snapshot) => sum + (snapshot.rows?.length ?? 0),
1098
+ 0
1099
+ );
1100
+ }
1101
+
1102
+ function countSubscriptionChunks(
1103
+ subscription: SyncPullSubscriptionResponse
1104
+ ): number | undefined {
1105
+ if (!subscription.bootstrap) return undefined;
1106
+ const snapshots = subscription.snapshots ?? [];
1107
+ return snapshots.reduce(
1108
+ (sum, snapshot) => sum + (snapshot.chunks?.length ?? 0),
1109
+ 0
1110
+ );
731
1111
  }
732
1112
 
733
1113
  /**
@@ -760,13 +1140,19 @@ export async function buildPullRequest<DB extends SyncClientDb>(
760
1140
  const existingById = new Map<string, SyncSubscriptionStateTable>();
761
1141
  for (const row of existing) existingById.set(row.subscription_id, row);
762
1142
 
1143
+ const configuredSubscriptions = options.subscriptions ?? [];
1144
+ const selectedSubscriptions = selectPullSubscriptions(
1145
+ configuredSubscriptions,
1146
+ existingById
1147
+ );
1148
+
763
1149
  const request: SyncPullRequest = {
764
1150
  clientId: options.clientId,
765
1151
  limitCommits: options.limitCommits ?? 50,
766
1152
  limitSnapshotRows: options.limitSnapshotRows ?? 1000,
767
1153
  maxSnapshotPages: options.maxSnapshotPages ?? 4,
768
1154
  dedupeRows: options.dedupeRows,
769
- subscriptions: (options.subscriptions ?? []).map((sub) => ({
1155
+ subscriptions: selectedSubscriptions.map((sub) => ({
770
1156
  ...sub,
771
1157
  cursor: Math.max(-1, existingById.get(sub.id)?.cursor ?? -1),
772
1158
  bootstrapState: parseBootstrapState(
@@ -775,7 +1161,13 @@ export async function buildPullRequest<DB extends SyncClientDb>(
775
1161
  })),
776
1162
  };
777
1163
 
778
- return { request, existing, existingById, stateId };
1164
+ return {
1165
+ request,
1166
+ existing,
1167
+ existingById,
1168
+ stateId,
1169
+ configuredSubscriptions,
1170
+ };
779
1171
  }
780
1172
 
781
1173
  export function createFollowupPullState(
@@ -822,9 +1214,13 @@ export function createFollowupPullState(
822
1214
  nextExistingById.set(nextRow.subscription_id, nextRow);
823
1215
  }
824
1216
 
1217
+ const nextSelectedSubscriptions = selectPullSubscriptions(
1218
+ pullState.configuredSubscriptions,
1219
+ nextExistingById
1220
+ );
825
1221
  const nextRequest: SyncPullRequest = {
826
1222
  ...pullState.request,
827
- subscriptions: (pullState.request.subscriptions ?? []).map((sub) => {
1223
+ subscriptions: nextSelectedSubscriptions.map((sub) => {
828
1224
  const row = nextExistingById.get(sub.id);
829
1225
  return {
830
1226
  ...sub,
@@ -839,6 +1235,7 @@ export function createFollowupPullState(
839
1235
  existing: nextExisting,
840
1236
  existingById: nextExistingById,
841
1237
  stateId: pullState.stateId,
1238
+ configuredSubscriptions: pullState.configuredSubscriptions,
842
1239
  };
843
1240
  }
844
1241
 
@@ -916,7 +1313,15 @@ export async function applyPullResponse<DB extends SyncClientDb>(
916
1313
  );
917
1314
 
918
1315
  let responseToApply = requiresMaterializedSnapshots
919
- ? await materializeChunkedSnapshots(transport, rawResponse, options.sha256)
1316
+ ? await materializeChunkedSnapshots(
1317
+ transport,
1318
+ rawResponse,
1319
+ options.sha256,
1320
+ {
1321
+ stateId,
1322
+ onTrace: options.onTrace,
1323
+ }
1324
+ )
920
1325
  : rawResponse;
921
1326
  for (const plugin of plugins) {
922
1327
  if (!plugin.afterPull) continue;
@@ -941,34 +1346,92 @@ export async function applyPullResponse<DB extends SyncClientDb>(
941
1346
 
942
1347
  if (bootstrapApplyMode === 'per-subscription') {
943
1348
  for (const sub of responseToApply.subscriptions) {
944
- await db.transaction().execute(async (trx) => {
945
- await applySubscriptionResponse({
946
- trx,
947
- handlers,
948
- transport,
949
- options,
1349
+ emitTrace(options.onTrace, {
1350
+ stage: 'apply:transaction:start',
1351
+ stateId,
1352
+ transactionMode: bootstrapApplyMode,
1353
+ subscriptionIds: [sub.id],
1354
+ subscriptionCount: 1,
1355
+ });
1356
+ const transactionStartedAt = Date.now();
1357
+ try {
1358
+ await db.transaction().execute(async (trx) => {
1359
+ await applySubscriptionResponse({
1360
+ trx,
1361
+ handlers,
1362
+ transport,
1363
+ options,
1364
+ stateId,
1365
+ existingById,
1366
+ subsById,
1367
+ sub,
1368
+ });
1369
+ });
1370
+ emitTrace(options.onTrace, {
1371
+ stage: 'apply:transaction:complete',
950
1372
  stateId,
951
- existingById,
952
- subsById,
953
- sub,
1373
+ transactionMode: bootstrapApplyMode,
1374
+ subscriptionIds: [sub.id],
1375
+ subscriptionCount: 1,
1376
+ durationMs: Math.max(0, Date.now() - transactionStartedAt),
954
1377
  });
955
- });
956
- }
957
- } else {
958
- await db.transaction().execute(async (trx) => {
959
- for (const sub of responseToApply.subscriptions) {
960
- await applySubscriptionResponse({
961
- trx,
962
- handlers,
963
- transport,
964
- options,
1378
+ } catch (error) {
1379
+ emitTrace(options.onTrace, {
1380
+ stage: 'apply:transaction:error',
965
1381
  stateId,
966
- existingById,
967
- subsById,
968
- sub,
1382
+ transactionMode: bootstrapApplyMode,
1383
+ subscriptionIds: [sub.id],
1384
+ subscriptionCount: 1,
1385
+ durationMs: Math.max(0, Date.now() - transactionStartedAt),
1386
+ errorMessage: error instanceof Error ? error.message : String(error),
969
1387
  });
1388
+ throw error;
970
1389
  }
1390
+ }
1391
+ } else {
1392
+ emitTrace(options.onTrace, {
1393
+ stage: 'apply:transaction:start',
1394
+ stateId,
1395
+ transactionMode: bootstrapApplyMode,
1396
+ subscriptionIds: responseToApply.subscriptions.map((sub) => sub.id),
1397
+ subscriptionCount: responseToApply.subscriptions.length,
971
1398
  });
1399
+ const transactionStartedAt = Date.now();
1400
+ try {
1401
+ await db.transaction().execute(async (trx) => {
1402
+ for (const sub of responseToApply.subscriptions) {
1403
+ await applySubscriptionResponse({
1404
+ trx,
1405
+ handlers,
1406
+ transport,
1407
+ options,
1408
+ stateId,
1409
+ existingById,
1410
+ subsById,
1411
+ sub,
1412
+ });
1413
+ }
1414
+ });
1415
+ emitTrace(options.onTrace, {
1416
+ stage: 'apply:transaction:complete',
1417
+ stateId,
1418
+ transactionMode: bootstrapApplyMode,
1419
+ subscriptionIds: responseToApply.subscriptions.map((sub) => sub.id),
1420
+ subscriptionCount: responseToApply.subscriptions.length,
1421
+ durationMs: Math.max(0, Date.now() - transactionStartedAt),
1422
+ });
1423
+ } catch (error) {
1424
+ emitTrace(options.onTrace, {
1425
+ stage: 'apply:transaction:error',
1426
+ stateId,
1427
+ transactionMode: bootstrapApplyMode,
1428
+ subscriptionIds: responseToApply.subscriptions.map((sub) => sub.id),
1429
+ subscriptionCount: responseToApply.subscriptions.length,
1430
+ durationMs: Math.max(0, Date.now() - transactionStartedAt),
1431
+ errorMessage: error instanceof Error ? error.message : String(error),
1432
+ });
1433
+ throw error;
1434
+ }
972
1435
  }
973
1436
 
974
1437
  return responseToApply;
@@ -1002,7 +1465,7 @@ async function removeUndesiredSubscriptions<DB extends SyncClientDb>(
1002
1465
  trx: Transaction<DB>,
1003
1466
  handlers: ClientHandlerCollection<DB>,
1004
1467
  existing: SyncSubscriptionStateTable[],
1005
- desiredSubscriptions: Array<Omit<SyncSubscriptionRequest, 'cursor'>>,
1468
+ desiredSubscriptions: SyncClientSubscription[],
1006
1469
  stateId: string
1007
1470
  ): Promise<void> {
1008
1471
  const desiredIds = new Set(
@@ -1063,7 +1526,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1063
1526
  options: SyncPullOnceOptions;
1064
1527
  stateId: string;
1065
1528
  existingById: Map<string, SyncSubscriptionStateTable>;
1066
- subsById: Map<string, Omit<SyncSubscriptionRequest, 'cursor'> | undefined>;
1529
+ subsById: Map<string, SyncClientSubscription | undefined>;
1067
1530
  sub: SyncPullSubscriptionResponse;
1068
1531
  }): Promise<void> {
1069
1532
  const {
@@ -1102,146 +1565,275 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1102
1565
  effectiveCursor !== null &&
1103
1566
  sub.nextCursor < effectiveCursor;
1104
1567
 
1568
+ const applyStartedAt = Date.now();
1569
+ emitTrace(options.onTrace, {
1570
+ stage: 'apply:subscription:start',
1571
+ stateId,
1572
+ subscriptionId: sub.id,
1573
+ table: def?.table ?? prev?.table,
1574
+ bootstrap: sub.bootstrap,
1575
+ snapshotCount: sub.snapshots?.length ?? 0,
1576
+ commitCount: sub.commits?.length ?? 0,
1577
+ chunkCount: countSubscriptionChunks(sub),
1578
+ rowCount: countSubscriptionRows(sub),
1579
+ nextCursor: sub.nextCursor,
1580
+ });
1581
+
1105
1582
  if (staleIncrementalResponse) {
1583
+ emitTrace(options.onTrace, {
1584
+ stage: 'apply:subscription:complete',
1585
+ stateId,
1586
+ subscriptionId: sub.id,
1587
+ table: def?.table ?? prev?.table,
1588
+ bootstrap: sub.bootstrap,
1589
+ snapshotCount: sub.snapshots?.length ?? 0,
1590
+ commitCount: sub.commits?.length ?? 0,
1591
+ chunkCount: countSubscriptionChunks(sub),
1592
+ rowCount: countSubscriptionRows(sub),
1593
+ nextCursor: sub.nextCursor,
1594
+ durationMs: Math.max(0, Date.now() - applyStartedAt),
1595
+ });
1106
1596
  return;
1107
1597
  }
1108
1598
 
1109
- if (sub.status === 'revoked') {
1110
- if (prev?.table) {
1599
+ try {
1600
+ if (sub.status === 'revoked') {
1601
+ if (prev?.table) {
1602
+ try {
1603
+ const scopes = parseScopeValuesJson(prev.scopes_json);
1604
+ await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1605
+ trx,
1606
+ scopes,
1607
+ });
1608
+ } catch {
1609
+ // ignore missing handler
1610
+ }
1611
+ }
1612
+
1613
+ await sql`
1614
+ delete from ${sql.table('sync_subscription_state')}
1615
+ where ${sql.ref('state_id')} = ${sql.val(stateId)}
1616
+ and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
1617
+ `.execute(trx);
1618
+ emitTrace(options.onTrace, {
1619
+ stage: 'apply:subscription:complete',
1620
+ stateId,
1621
+ subscriptionId: sub.id,
1622
+ table: def?.table ?? prev?.table,
1623
+ bootstrap: sub.bootstrap,
1624
+ snapshotCount: sub.snapshots?.length ?? 0,
1625
+ commitCount: sub.commits?.length ?? 0,
1626
+ chunkCount: countSubscriptionChunks(sub),
1627
+ rowCount: countSubscriptionRows(sub),
1628
+ nextCursor: null,
1629
+ durationMs: Math.max(0, Date.now() - applyStartedAt),
1630
+ });
1631
+ return;
1632
+ }
1633
+
1634
+ const nextScopes = sub.scopes ?? def?.scopes ?? {};
1635
+ const previousScopes = parseScopeValuesJson(prev?.scopes_json);
1636
+ const scopesChanged = !scopeValuesEqual(previousScopes, nextScopes);
1637
+
1638
+ if (sub.bootstrap && prev?.table && scopesChanged) {
1111
1639
  try {
1112
- const scopes = parseScopeValuesJson(prev.scopes_json);
1113
- await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1114
- trx,
1115
- scopes,
1116
- });
1640
+ const clearScopes = resolveBootstrapClearScopes(
1641
+ previousScopes,
1642
+ nextScopes
1643
+ );
1644
+ if (clearScopes !== 'none') {
1645
+ await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1646
+ trx,
1647
+ scopes: clearScopes ?? previousScopes,
1648
+ });
1649
+ }
1117
1650
  } catch {
1118
1651
  // ignore missing handler
1119
1652
  }
1120
1653
  }
1121
1654
 
1122
- await sql`
1123
- delete from ${sql.table('sync_subscription_state')}
1124
- where ${sql.ref('state_id')} = ${sql.val(stateId)}
1125
- and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
1126
- `.execute(trx);
1127
- return;
1128
- }
1655
+ if (sub.bootstrap) {
1656
+ for (const snapshot of sub.snapshots ?? []) {
1657
+ const handler = getClientHandlerOrThrow(handlers, snapshot.table);
1658
+ const hasChunkRefs =
1659
+ Array.isArray(snapshot.chunks) && snapshot.chunks.length > 0;
1660
+
1661
+ if (snapshot.isFirstPage && handler.onSnapshotStart) {
1662
+ try {
1663
+ await handler.onSnapshotStart({
1664
+ trx,
1665
+ table: snapshot.table,
1666
+ scopes: sub.scopes,
1667
+ });
1668
+ } catch (error) {
1669
+ throw wrapSyncClientStageError(
1670
+ error,
1671
+ {
1672
+ stage: 'snapshot-apply',
1673
+ stateId,
1674
+ subscriptionId: sub.id,
1675
+ table: snapshot.table,
1676
+ },
1677
+ `Failed to start snapshot apply for subscription "${sub.id}"`
1678
+ );
1679
+ }
1680
+ }
1129
1681
 
1130
- const nextScopes = sub.scopes ?? def?.scopes ?? {};
1131
- const previousScopes = parseScopeValuesJson(prev?.scopes_json);
1132
- const scopesChanged = !scopeValuesEqual(previousScopes, nextScopes);
1682
+ if (hasChunkRefs) {
1683
+ await applyChunkedSnapshot(
1684
+ transport,
1685
+ handler,
1686
+ trx,
1687
+ snapshot,
1688
+ sub.scopes,
1689
+ options.sha256,
1690
+ {
1691
+ stateId,
1692
+ subscriptionId: sub.id,
1693
+ onTrace: options.onTrace,
1694
+ }
1695
+ );
1696
+ } else {
1697
+ try {
1698
+ await handler.applySnapshot({ trx }, snapshot);
1699
+ } catch (error) {
1700
+ throw wrapSyncClientStageError(
1701
+ error,
1702
+ {
1703
+ stage: 'snapshot-apply',
1704
+ stateId,
1705
+ subscriptionId: sub.id,
1706
+ table: snapshot.table,
1707
+ },
1708
+ `Failed to apply snapshot for subscription "${sub.id}"`
1709
+ );
1710
+ }
1711
+ }
1133
1712
 
1134
- if (sub.bootstrap && prev?.table && scopesChanged) {
1135
- try {
1136
- const clearScopes = resolveBootstrapClearScopes(
1137
- previousScopes,
1138
- nextScopes
1139
- );
1140
- if (clearScopes !== 'none') {
1141
- await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1142
- trx,
1143
- scopes: clearScopes ?? previousScopes,
1144
- });
1713
+ if (snapshot.isLastPage && handler.onSnapshotEnd) {
1714
+ try {
1715
+ await handler.onSnapshotEnd({
1716
+ trx,
1717
+ table: snapshot.table,
1718
+ scopes: sub.scopes,
1719
+ });
1720
+ } catch (error) {
1721
+ throw wrapSyncClientStageError(
1722
+ error,
1723
+ {
1724
+ stage: 'snapshot-apply',
1725
+ stateId,
1726
+ subscriptionId: sub.id,
1727
+ table: snapshot.table,
1728
+ },
1729
+ `Failed to finalize snapshot apply for subscription "${sub.id}"`
1730
+ );
1731
+ }
1732
+ }
1733
+ }
1734
+ } else {
1735
+ for (const commit of sub.commits) {
1736
+ try {
1737
+ await applyIncrementalCommitChanges(handlers, trx, {
1738
+ changes: commit.changes,
1739
+ commitSeq: commit.commitSeq ?? null,
1740
+ actorId: commit.actorId ?? null,
1741
+ createdAt: commit.createdAt ?? null,
1742
+ });
1743
+ } catch (error) {
1744
+ throw wrapSyncClientStageError(
1745
+ error,
1746
+ {
1747
+ stage: 'snapshot-apply',
1748
+ stateId,
1749
+ subscriptionId: sub.id,
1750
+ table: def?.table ?? prev?.table,
1751
+ },
1752
+ `Failed to apply incremental changes for subscription "${sub.id}"`
1753
+ );
1754
+ }
1145
1755
  }
1146
- } catch {
1147
- // ignore missing handler
1148
1756
  }
1149
- }
1150
1757
 
1151
- if (sub.bootstrap) {
1152
- for (const snapshot of sub.snapshots ?? []) {
1153
- const handler = getClientHandlerOrThrow(handlers, snapshot.table);
1154
- const hasChunkRefs =
1155
- Array.isArray(snapshot.chunks) && snapshot.chunks.length > 0;
1156
-
1157
- if (snapshot.isFirstPage && handler.onSnapshotStart) {
1158
- await handler.onSnapshotStart({
1159
- trx,
1160
- table: snapshot.table,
1161
- scopes: sub.scopes,
1162
- });
1163
- }
1758
+ const now = Date.now();
1759
+ const paramsJson = serializeJsonCached(def?.params ?? {});
1760
+ const scopesJson = serializeJsonCached(nextScopes);
1761
+ const bootstrapStateJson = sub.bootstrap
1762
+ ? sub.bootstrapState
1763
+ ? serializeJsonCached(sub.bootstrapState)
1764
+ : null
1765
+ : null;
1766
+ const table = def?.table ?? 'unknown';
1164
1767
 
1165
- if (hasChunkRefs) {
1166
- await applyChunkedSnapshot(
1167
- transport,
1168
- handler,
1169
- trx,
1170
- snapshot,
1171
- sub.scopes,
1172
- options.sha256
1173
- );
1174
- } else {
1175
- await handler.applySnapshot({ trx }, snapshot);
1176
- }
1768
+ await sql`
1769
+ insert into ${sql.table('sync_subscription_state')} (
1770
+ ${sql.join([
1771
+ sql.ref('state_id'),
1772
+ sql.ref('subscription_id'),
1773
+ sql.ref('table'),
1774
+ sql.ref('scopes_json'),
1775
+ sql.ref('params_json'),
1776
+ sql.ref('cursor'),
1777
+ sql.ref('bootstrap_state_json'),
1778
+ sql.ref('status'),
1779
+ sql.ref('created_at'),
1780
+ sql.ref('updated_at'),
1781
+ ])}
1782
+ ) values (
1783
+ ${sql.join([
1784
+ sql.val(stateId),
1785
+ sql.val(sub.id),
1786
+ sql.val(table),
1787
+ sql.val(scopesJson),
1788
+ sql.val(paramsJson),
1789
+ sql.val(sub.nextCursor),
1790
+ sql.val(bootstrapStateJson),
1791
+ sql.val('active'),
1792
+ sql.val(now),
1793
+ sql.val(now),
1794
+ ])}
1795
+ )
1796
+ on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
1797
+ do update set
1798
+ ${sql.ref('table')} = ${sql.val(table)},
1799
+ ${sql.ref('scopes_json')} = ${sql.val(scopesJson)},
1800
+ ${sql.ref('params_json')} = ${sql.val(paramsJson)},
1801
+ ${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
1802
+ ${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
1803
+ ${sql.ref('status')} = ${sql.val('active')},
1804
+ ${sql.ref('updated_at')} = ${sql.val(now)}
1805
+ `.execute(trx);
1177
1806
 
1178
- if (snapshot.isLastPage && handler.onSnapshotEnd) {
1179
- await handler.onSnapshotEnd({
1180
- trx,
1181
- table: snapshot.table,
1182
- scopes: sub.scopes,
1183
- });
1184
- }
1185
- }
1186
- } else {
1187
- for (const commit of sub.commits) {
1188
- await applyIncrementalCommitChanges(handlers, trx, {
1189
- changes: commit.changes,
1190
- commitSeq: commit.commitSeq ?? null,
1191
- actorId: commit.actorId ?? null,
1192
- createdAt: commit.createdAt ?? null,
1193
- });
1194
- }
1807
+ emitTrace(options.onTrace, {
1808
+ stage: 'apply:subscription:complete',
1809
+ stateId,
1810
+ subscriptionId: sub.id,
1811
+ table,
1812
+ bootstrap: sub.bootstrap,
1813
+ snapshotCount: sub.snapshots?.length ?? 0,
1814
+ commitCount: sub.commits?.length ?? 0,
1815
+ chunkCount: countSubscriptionChunks(sub),
1816
+ rowCount: countSubscriptionRows(sub),
1817
+ nextCursor: sub.nextCursor,
1818
+ durationMs: Math.max(0, Date.now() - applyStartedAt),
1819
+ });
1820
+ } catch (error) {
1821
+ emitTrace(options.onTrace, {
1822
+ stage: 'apply:subscription:error',
1823
+ stateId,
1824
+ subscriptionId: sub.id,
1825
+ table: def?.table ?? prev?.table,
1826
+ bootstrap: sub.bootstrap,
1827
+ snapshotCount: sub.snapshots?.length ?? 0,
1828
+ commitCount: sub.commits?.length ?? 0,
1829
+ chunkCount: countSubscriptionChunks(sub),
1830
+ rowCount: countSubscriptionRows(sub),
1831
+ nextCursor: sub.nextCursor,
1832
+ durationMs: Math.max(0, Date.now() - applyStartedAt),
1833
+ errorMessage: error instanceof Error ? error.message : String(error),
1834
+ });
1835
+ throw error;
1195
1836
  }
1196
-
1197
- const now = Date.now();
1198
- const paramsJson = serializeJsonCached(def?.params ?? {});
1199
- const scopesJson = serializeJsonCached(nextScopes);
1200
- const bootstrapStateJson = sub.bootstrap
1201
- ? sub.bootstrapState
1202
- ? serializeJsonCached(sub.bootstrapState)
1203
- : null
1204
- : null;
1205
- const table = def?.table ?? 'unknown';
1206
-
1207
- await sql`
1208
- insert into ${sql.table('sync_subscription_state')} (
1209
- ${sql.join([
1210
- sql.ref('state_id'),
1211
- sql.ref('subscription_id'),
1212
- sql.ref('table'),
1213
- sql.ref('scopes_json'),
1214
- sql.ref('params_json'),
1215
- sql.ref('cursor'),
1216
- sql.ref('bootstrap_state_json'),
1217
- sql.ref('status'),
1218
- sql.ref('created_at'),
1219
- sql.ref('updated_at'),
1220
- ])}
1221
- ) values (
1222
- ${sql.join([
1223
- sql.val(stateId),
1224
- sql.val(sub.id),
1225
- sql.val(table),
1226
- sql.val(scopesJson),
1227
- sql.val(paramsJson),
1228
- sql.val(sub.nextCursor),
1229
- sql.val(bootstrapStateJson),
1230
- sql.val('active'),
1231
- sql.val(now),
1232
- sql.val(now),
1233
- ])}
1234
- )
1235
- on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
1236
- do update set
1237
- ${sql.ref('table')} = ${sql.val(table)},
1238
- ${sql.ref('scopes_json')} = ${sql.val(scopesJson)},
1239
- ${sql.ref('params_json')} = ${sql.val(paramsJson)},
1240
- ${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
1241
- ${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
1242
- ${sql.ref('status')} = ${sql.val('active')},
1243
- ${sql.ref('updated_at')} = ${sql.val(now)}
1244
- `.execute(trx);
1245
1837
  }
1246
1838
 
1247
1839
  export async function syncPullOnce<DB extends SyncClientDb>(
@@ -1253,10 +1845,48 @@ export async function syncPullOnce<DB extends SyncClientDb>(
1253
1845
  ): Promise<SyncPullResponse> {
1254
1846
  const pullState = pullStateOverride ?? (await buildPullRequest(db, options));
1255
1847
  const { clientId, ...pullBody } = pullState.request;
1256
- const combined = await transport.sync({ clientId, pull: pullBody });
1848
+ emitTrace(options.onTrace, {
1849
+ stage: 'pull:start',
1850
+ stateId: pullState.stateId,
1851
+ subscriptionIds: pullState.request.subscriptions.map(
1852
+ (subscription) => subscription.id
1853
+ ),
1854
+ subscriptionCount: pullState.request.subscriptions.length,
1855
+ });
1856
+ let combined: SyncCombinedResponse;
1857
+ try {
1858
+ combined = await transport.sync({ clientId, pull: pullBody });
1859
+ } catch (error) {
1860
+ emitTrace(options.onTrace, {
1861
+ stage: 'pull:error',
1862
+ stateId: pullState.stateId,
1863
+ subscriptionIds: pullState.request.subscriptions.map(
1864
+ (subscription) => subscription.id
1865
+ ),
1866
+ subscriptionCount: pullState.request.subscriptions.length,
1867
+ errorMessage: error instanceof Error ? error.message : String(error),
1868
+ });
1869
+ throw error;
1870
+ }
1257
1871
  if (!combined.pull) {
1258
1872
  return { ok: true, subscriptions: [] };
1259
1873
  }
1874
+ emitTrace(options.onTrace, {
1875
+ stage: 'pull:response',
1876
+ stateId: pullState.stateId,
1877
+ subscriptionIds: combined.pull.subscriptions.map(
1878
+ (subscription) => subscription.id
1879
+ ),
1880
+ subscriptionCount: combined.pull.subscriptions.length,
1881
+ commitCount: combined.pull.subscriptions.reduce(
1882
+ (sum, subscription) => sum + (subscription.commits?.length ?? 0),
1883
+ 0
1884
+ ),
1885
+ snapshotCount: combined.pull.subscriptions.reduce(
1886
+ (sum, subscription) => sum + (subscription.snapshots?.length ?? 0),
1887
+ 0
1888
+ ),
1889
+ });
1260
1890
  return applyPullResponse(
1261
1891
  db,
1262
1892
  transport,