@syncular/client 0.0.6-204 → 0.0.6-206

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.
@@ -795,19 +795,19 @@ describe('SyncEngine WS inline apply', () => {
795
795
  expect(state.error?.httpStatus).toBe(429);
796
796
  expect(state.retryCount).toBe(1);
797
797
  expect(state.isRetrying).toBe(true);
798
- expect(delays).toEqual([2000]);
798
+ expect(delays).toEqual([1000]);
799
799
 
800
800
  await engine.sync();
801
801
  state = engine.getState();
802
802
  expect(state.retryCount).toBe(2);
803
803
  expect(state.isRetrying).toBe(true);
804
- expect(delays).toEqual([2000, 4000]);
804
+ expect(delays).toEqual([1000, 2000]);
805
805
 
806
806
  await engine.sync();
807
807
  state = engine.getState();
808
808
  expect(state.retryCount).toBe(3);
809
809
  expect(state.isRetrying).toBe(true);
810
- expect(delays).toEqual([2000, 4000, 8000]);
810
+ expect(delays).toEqual([1000, 2000, 4000]);
811
811
  expect(syncAttempts).toBe(3);
812
812
 
813
813
  engine.destroy();
@@ -819,6 +819,183 @@ describe('SyncEngine WS inline apply', () => {
819
819
  }
820
820
  });
821
821
 
822
+ it('uses a shorter capped recovery retry delay after a recent successful read-only sync', async () => {
823
+ let syncAttempts = 0;
824
+ const delayedFailureTransport: SyncTransport = {
825
+ async sync() {
826
+ syncAttempts += 1;
827
+ if (syncAttempts === 1) {
828
+ return { ok: true, subscriptions: [] };
829
+ }
830
+ throw new SyncTransportError('service unavailable', 503);
831
+ },
832
+ async fetchSnapshotChunk() {
833
+ return new Uint8Array();
834
+ },
835
+ };
836
+ const handlers: ClientHandlerCollection<TestDb> = [
837
+ {
838
+ table: 'tasks',
839
+ async applySnapshot() {},
840
+ async clearAll() {},
841
+ async applyChange() {},
842
+ },
843
+ ];
844
+
845
+ const delays: number[] = [];
846
+ const timeoutHandles: Array<ReturnType<typeof setTimeout>> = [];
847
+ const originalSetTimeout = globalThis.setTimeout;
848
+ const patchedSetTimeout: typeof globalThis.setTimeout = (
849
+ _handler,
850
+ timeout,
851
+ ..._args
852
+ ) => {
853
+ const delayMs = typeof timeout === 'number' ? timeout : 0;
854
+ delays.push(delayMs);
855
+ const handle = originalSetTimeout(() => {}, 60_000);
856
+ timeoutHandles.push(handle);
857
+ return handle;
858
+ };
859
+ globalThis.setTimeout = patchedSetTimeout;
860
+
861
+ try {
862
+ const engine = new SyncEngine<TestDb>({
863
+ db,
864
+ transport: delayedFailureTransport,
865
+ handlers,
866
+ actorId: 'u1',
867
+ clientId: 'client-recovery-retry',
868
+ subscriptions: [
869
+ {
870
+ id: 'sub-1',
871
+ table: 'tasks',
872
+ scopes: {},
873
+ },
874
+ ],
875
+ stateId: 'default',
876
+ pollIntervalMs: 60_000,
877
+ maxRetries: 5,
878
+ });
879
+
880
+ await engine.start();
881
+ expect(engine.getState().lastSyncAt).not.toBeNull();
882
+
883
+ await engine.sync();
884
+ let state = engine.getState();
885
+ expect(state.error?.code).toBe('NETWORK_ERROR');
886
+ expect(state.retryCount).toBe(1);
887
+ expect(state.isRetrying).toBe(true);
888
+
889
+ await engine.sync();
890
+ state = engine.getState();
891
+ expect(state.retryCount).toBe(2);
892
+
893
+ await engine.sync();
894
+ state = engine.getState();
895
+ expect(state.retryCount).toBe(3);
896
+
897
+ await engine.sync();
898
+ state = engine.getState();
899
+ expect(state.retryCount).toBe(4);
900
+ expect(delays).toEqual([250, 500, 1000, 1000]);
901
+
902
+ engine.destroy();
903
+ } finally {
904
+ globalThis.setTimeout = originalSetTimeout;
905
+ for (const handle of timeoutHandles) {
906
+ clearTimeout(handle);
907
+ }
908
+ }
909
+ });
910
+
911
+ it('uses deterministic jitter for realtime reconnect sync and catchup scheduling', () => {
912
+ const handlers: ClientHandlerCollection<TestDb> = [
913
+ {
914
+ table: 'tasks',
915
+ async applySnapshot() {},
916
+ async clearAll() {},
917
+ async applyChange() {},
918
+ },
919
+ ];
920
+
921
+ const delays: number[] = [];
922
+ const timeoutHandles: Array<ReturnType<typeof setTimeout>> = [];
923
+ const originalSetTimeout = globalThis.setTimeout;
924
+ const patchedSetTimeout: typeof globalThis.setTimeout = (
925
+ _handler,
926
+ timeout,
927
+ ..._args
928
+ ) => {
929
+ const delayMs = typeof timeout === 'number' ? timeout : 0;
930
+ delays.push(delayMs);
931
+ const handle = originalSetTimeout(() => {}, 60_000);
932
+ timeoutHandles.push(handle);
933
+ return handle;
934
+ };
935
+ globalThis.setTimeout = patchedSetTimeout;
936
+
937
+ try {
938
+ const engineA = new SyncEngine<TestDb>({
939
+ db,
940
+ transport: noopTransport,
941
+ handlers,
942
+ actorId: 'u1',
943
+ clientId: 'client-reconnect-jitter',
944
+ subscriptions: [],
945
+ stateId: 'default',
946
+ });
947
+ const engineB = new SyncEngine<TestDb>({
948
+ db,
949
+ transport: noopTransport,
950
+ handlers,
951
+ actorId: 'u1',
952
+ clientId: 'client-reconnect-jitter',
953
+ subscriptions: [],
954
+ stateId: 'default',
955
+ });
956
+
957
+ const scheduleReconnectSyncA = Reflect.get(
958
+ engineA,
959
+ 'scheduleRealtimeReconnectSync'
960
+ );
961
+ const scheduleReconnectCatchupA = Reflect.get(
962
+ engineA,
963
+ 'scheduleRealtimeReconnectCatchupSync'
964
+ );
965
+ const scheduleReconnectSyncB = Reflect.get(
966
+ engineB,
967
+ 'scheduleRealtimeReconnectSync'
968
+ );
969
+
970
+ if (
971
+ typeof scheduleReconnectSyncA !== 'function' ||
972
+ typeof scheduleReconnectCatchupA !== 'function' ||
973
+ typeof scheduleReconnectSyncB !== 'function'
974
+ ) {
975
+ throw new Error('Expected reconnect scheduling helpers to be callable');
976
+ }
977
+
978
+ scheduleReconnectSyncA.call(engineA);
979
+ scheduleReconnectCatchupA.call(engineA);
980
+ scheduleReconnectSyncB.call(engineB);
981
+
982
+ expect(delays.length).toBe(3);
983
+ expect(delays[0]).toBeGreaterThanOrEqual(0);
984
+ expect(delays[0]).toBeLessThanOrEqual(250);
985
+ expect(delays[1]).toBeGreaterThanOrEqual(500);
986
+ expect(delays[1]).toBeLessThanOrEqual(750);
987
+ expect(delays[2]).toBe(delays[0]);
988
+
989
+ engineA.destroy();
990
+ engineB.destroy();
991
+ } finally {
992
+ globalThis.setTimeout = originalSetTimeout;
993
+ for (const handle of timeoutHandles) {
994
+ clearTimeout(handle);
995
+ }
996
+ }
997
+ });
998
+
822
999
  it('keeps push failures retryable on 503 and preserves pending outbox state', async () => {
823
1000
  let sawPushRequest = false;
824
1001
  const unavailablePushTransport: SyncTransport = {
@@ -953,7 +1130,7 @@ describe('SyncEngine WS inline apply', () => {
953
1130
  id: 'chunk-retry-1',
954
1131
  byteLength: payload.length,
955
1132
  sha256: '',
956
- encoding: 'json-row-frame-v1',
1133
+ encoding: 'json-row-batch-frame-v2',
957
1134
  compression: 'gzip',
958
1135
  },
959
1136
  ],
@@ -1045,7 +1222,7 @@ describe('SyncEngine WS inline apply', () => {
1045
1222
  const state = engine.getState();
1046
1223
  expect(state.retryCount).toBe(0);
1047
1224
  expect(state.isRetrying).toBe(false);
1048
- expect(delays[0]).toBe(2000);
1225
+ expect(delays[0]).toBe(1000);
1049
1226
  expect(chunkFetchCalls).toBeGreaterThanOrEqual(2);
1050
1227
  expect(syncCalls).toBeGreaterThanOrEqual(2);
1051
1228
 
@@ -75,7 +75,11 @@ const DEFAULT_MAX_RETRIES = 5;
75
75
  const INITIAL_RETRY_DELAY_MS = 1000;
76
76
  const MAX_RETRY_DELAY_MS = 60000;
77
77
  const EXPONENTIAL_FACTOR = 2;
78
+ const RECOVERY_RETRY_DELAY_MS = 250;
79
+ const MAX_RECOVERY_RETRY_DELAY_MS = 1000;
80
+ const RECOVERY_RETRY_WINDOW_MS = 120000;
78
81
  const REALTIME_RECONNECT_CATCHUP_DELAY_MS = 500;
82
+ const REALTIME_RECONNECT_SYNC_JITTER_MS = 250;
79
83
  const DEFAULT_AWAIT_TIMEOUT_MS = 60_000;
80
84
  const DEFAULT_INSPECTOR_EVENT_LIMIT = 100;
81
85
  const MAX_INSPECTOR_EVENT_LIMIT = 500;
@@ -88,6 +92,27 @@ function calculateRetryDelay(attemptIndex: number): number {
88
92
  );
89
93
  }
90
94
 
95
+ function calculateRecoveryRetryDelay(attemptIndex: number): number {
96
+ return Math.min(
97
+ RECOVERY_RETRY_DELAY_MS * EXPONENTIAL_FACTOR ** attemptIndex,
98
+ MAX_RECOVERY_RETRY_DELAY_MS
99
+ );
100
+ }
101
+
102
+ function calculateDeterministicClientJitter(
103
+ clientId: string,
104
+ maxJitterMs: number
105
+ ): number {
106
+ if (maxJitterMs <= 0 || clientId.length === 0) return 0;
107
+
108
+ let hash = 0;
109
+ for (let index = 0; index < clientId.length; index += 1) {
110
+ hash = (hash * 31 + clientId.charCodeAt(index)) >>> 0;
111
+ }
112
+
113
+ return hash % (maxJitterMs + 1);
114
+ }
115
+
91
116
  function isRealtimeTransport(
92
117
  transport: unknown
93
118
  ): transport is RealtimeTransportLike {
@@ -310,6 +335,8 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
310
335
  private syncRequestedWhileRunning = false;
311
336
  private retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
312
337
  private realtimeCatchupTimeoutId: ReturnType<typeof setTimeout> | null = null;
338
+ private realtimeReconnectSyncTimeoutId: ReturnType<typeof setTimeout> | null =
339
+ null;
313
340
  private dataChangeDebounceTimeoutId: ReturnType<typeof setTimeout> | null =
314
341
  null;
315
342
  private pendingDataChangeScopes = new Set<string>();
@@ -1704,6 +1731,10 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1704
1731
  clearTimeout(this.realtimeCatchupTimeoutId);
1705
1732
  this.realtimeCatchupTimeoutId = null;
1706
1733
  }
1734
+ if (this.realtimeReconnectSyncTimeoutId) {
1735
+ clearTimeout(this.realtimeReconnectSyncTimeoutId);
1736
+ this.realtimeReconnectSyncTimeoutId = null;
1737
+ }
1707
1738
  }
1708
1739
 
1709
1740
  /**
@@ -1980,7 +2011,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1980
2011
  // Schedule retry if under max retries
1981
2012
  const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
1982
2013
  if (error.retryable && this.state.retryCount < maxRetries) {
1983
- this.scheduleRetry();
2014
+ this.scheduleRetry(error);
1984
2015
  }
1985
2016
  this.flushReconnectBatchedDataChangesIfReady();
1986
2017
 
@@ -2338,12 +2369,22 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
2338
2369
  }
2339
2370
  }
2340
2371
 
2341
- private scheduleRetry(): void {
2372
+ private shouldUseRecoveryRetryDelay(error?: SyncError): boolean {
2373
+ if (!error || error.code !== 'NETWORK_ERROR') return false;
2374
+ if (this.state.pendingCount > 0) return false;
2375
+ if (this.state.lastSyncAt === null) return false;
2376
+ return Date.now() - this.state.lastSyncAt <= RECOVERY_RETRY_WINDOW_MS;
2377
+ }
2378
+
2379
+ private scheduleRetry(error?: SyncError): void {
2342
2380
  if (this.retryTimeoutId) {
2343
2381
  clearTimeout(this.retryTimeoutId);
2344
2382
  }
2345
2383
 
2346
- const delay = calculateRetryDelay(this.state.retryCount);
2384
+ const attemptIndex = Math.max(0, this.state.retryCount - 1);
2385
+ const delay = this.shouldUseRecoveryRetryDelay(error)
2386
+ ? calculateRecoveryRetryDelay(attemptIndex)
2387
+ : calculateRetryDelay(attemptIndex);
2347
2388
  if (this.state.pendingCount > 0) {
2348
2389
  countSyncMetric('sync.outbox.retry_count', 1, {
2349
2390
  attributes: {
@@ -2524,9 +2565,14 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
2524
2565
  fallbackReason: null,
2525
2566
  });
2526
2567
  this.stopFallbackPolling();
2527
- this.triggerSyncInBackground(undefined, 'realtime connected state');
2528
2568
  if (wasConnectedBefore) {
2569
+ this.scheduleRealtimeReconnectSync();
2529
2570
  this.scheduleRealtimeReconnectCatchupSync();
2571
+ } else {
2572
+ this.triggerSyncInBackground(
2573
+ undefined,
2574
+ 'realtime connected state'
2575
+ );
2530
2576
  }
2531
2577
  break;
2532
2578
  }
@@ -2555,6 +2601,10 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
2555
2601
  clearTimeout(this.realtimeCatchupTimeoutId);
2556
2602
  this.realtimeCatchupTimeoutId = null;
2557
2603
  }
2604
+ if (this.realtimeReconnectSyncTimeoutId) {
2605
+ clearTimeout(this.realtimeReconnectSyncTimeoutId);
2606
+ this.realtimeReconnectSyncTimeoutId = null;
2607
+ }
2558
2608
  if (this.realtimePresenceUnsub) {
2559
2609
  this.realtimePresenceUnsub();
2560
2610
  this.realtimePresenceUnsub = null;
@@ -2575,6 +2625,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
2575
2625
  clearTimeout(this.realtimeCatchupTimeoutId);
2576
2626
  }
2577
2627
 
2628
+ const jitterMs = calculateDeterministicClientJitter(
2629
+ this.config.clientId ?? '',
2630
+ REALTIME_RECONNECT_SYNC_JITTER_MS
2631
+ );
2632
+
2578
2633
  this.realtimeCatchupTimeoutId = setTimeout(() => {
2579
2634
  this.realtimeCatchupTimeoutId = null;
2580
2635
 
@@ -2582,7 +2637,27 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
2582
2637
  if (this.state.connectionState !== 'connected') return;
2583
2638
 
2584
2639
  this.triggerSyncInBackground(undefined, 'realtime reconnect catchup');
2585
- }, REALTIME_RECONNECT_CATCHUP_DELAY_MS);
2640
+ }, REALTIME_RECONNECT_CATCHUP_DELAY_MS + jitterMs);
2641
+ }
2642
+
2643
+ private scheduleRealtimeReconnectSync(): void {
2644
+ if (this.realtimeReconnectSyncTimeoutId) {
2645
+ clearTimeout(this.realtimeReconnectSyncTimeoutId);
2646
+ }
2647
+
2648
+ const jitterMs = calculateDeterministicClientJitter(
2649
+ this.config.clientId ?? '',
2650
+ REALTIME_RECONNECT_SYNC_JITTER_MS
2651
+ );
2652
+
2653
+ this.realtimeReconnectSyncTimeoutId = setTimeout(() => {
2654
+ this.realtimeReconnectSyncTimeoutId = null;
2655
+
2656
+ if (this.isDestroyed || !this.isEnabled()) return;
2657
+ if (this.state.connectionState !== 'connected') return;
2658
+
2659
+ this.triggerSyncInBackground(undefined, 'realtime connected state');
2660
+ }, jitterMs);
2586
2661
  }
2587
2662
 
2588
2663
  private startFallbackPolling(): void {
@@ -133,7 +133,7 @@ describe('applyPullResponse chunk streaming', () => {
133
133
  id: 'chunk-1',
134
134
  byteLength: compressed.length,
135
135
  sha256: '',
136
- encoding: 'json-row-frame-v1',
136
+ encoding: 'json-row-batch-frame-v2',
137
137
  compression: 'gzip',
138
138
  },
139
139
  ],
@@ -258,14 +258,14 @@ describe('applyPullResponse chunk streaming', () => {
258
258
  id: 'chunk-1',
259
259
  byteLength: firstChunk.length,
260
260
  sha256: '',
261
- encoding: 'json-row-frame-v1',
261
+ encoding: 'json-row-batch-frame-v2',
262
262
  compression: 'gzip',
263
263
  },
264
264
  {
265
265
  id: 'chunk-2',
266
266
  byteLength: secondChunk.length,
267
267
  sha256: '',
268
- encoding: 'json-row-frame-v1',
268
+ encoding: 'json-row-batch-frame-v2',
269
269
  compression: 'gzip',
270
270
  },
271
271
  ],
@@ -374,14 +374,14 @@ describe('applyPullResponse chunk streaming', () => {
374
374
  id: 'chunk-1',
375
375
  byteLength: firstChunk.length,
376
376
  sha256: '',
377
- encoding: 'json-row-frame-v1',
377
+ encoding: 'json-row-batch-frame-v2',
378
378
  compression: 'gzip',
379
379
  },
380
380
  {
381
381
  id: 'chunk-2',
382
382
  byteLength: secondChunk.length,
383
383
  sha256: '',
384
- encoding: 'json-row-frame-v1',
384
+ encoding: 'json-row-batch-frame-v2',
385
385
  compression: 'gzip',
386
386
  },
387
387
  ],
@@ -497,7 +497,7 @@ describe('applyPullResponse chunk streaming', () => {
497
497
  id: 'chunk-1',
498
498
  byteLength: chunk.length,
499
499
  sha256: 'deadbeef',
500
- encoding: 'json-row-frame-v1',
500
+ encoding: 'json-row-batch-frame-v2',
501
501
  compression: 'gzip',
502
502
  },
503
503
  ],
@@ -592,7 +592,7 @@ describe('applyPullResponse chunk streaming', () => {
592
592
  id: 'chunk-1',
593
593
  byteLength: chunk.length,
594
594
  sha256: 'expected-hash',
595
- encoding: 'json-row-frame-v1',
595
+ encoding: 'json-row-batch-frame-v2',
596
596
  compression: 'gzip',
597
597
  },
598
598
  ],
@@ -700,14 +700,14 @@ describe('applyPullResponse chunk streaming', () => {
700
700
  id: 'chunk-1',
701
701
  byteLength: firstChunk.length,
702
702
  sha256: 'hash-1',
703
- encoding: 'json-row-frame-v1',
703
+ encoding: 'json-row-batch-frame-v2',
704
704
  compression: 'gzip',
705
705
  },
706
706
  {
707
707
  id: 'chunk-2',
708
708
  byteLength: secondChunk.length,
709
709
  sha256: 'hash-2',
710
- encoding: 'json-row-frame-v1',
710
+ encoding: 'json-row-batch-frame-v2',
711
711
  compression: 'gzip',
712
712
  },
713
713
  ],
@@ -804,7 +804,7 @@ describe('applyPullResponse chunk streaming', () => {
804
804
  id: 'chunk-1',
805
805
  byteLength: chunk.length,
806
806
  sha256: '',
807
- encoding: 'json-row-frame-v1',
807
+ encoding: 'json-row-batch-frame-v2',
808
808
  compression: 'gzip',
809
809
  },
810
810
  ],