@syncular/client 0.0.1 → 0.0.2-126

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.
Files changed (83) hide show
  1. package/README.md +23 -0
  2. package/dist/blobs/index.js +3 -3
  3. package/dist/client.d.ts +10 -5
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +70 -21
  6. package/dist/client.js.map +1 -1
  7. package/dist/conflicts.d.ts.map +1 -1
  8. package/dist/conflicts.js +1 -7
  9. package/dist/conflicts.js.map +1 -1
  10. package/dist/create-client.d.ts +5 -1
  11. package/dist/create-client.d.ts.map +1 -1
  12. package/dist/create-client.js +22 -10
  13. package/dist/create-client.js.map +1 -1
  14. package/dist/engine/SyncEngine.d.ts +24 -2
  15. package/dist/engine/SyncEngine.d.ts.map +1 -1
  16. package/dist/engine/SyncEngine.js +290 -43
  17. package/dist/engine/SyncEngine.js.map +1 -1
  18. package/dist/engine/index.js +2 -2
  19. package/dist/engine/types.d.ts +16 -4
  20. package/dist/engine/types.d.ts.map +1 -1
  21. package/dist/handlers/create-handler.d.ts +15 -5
  22. package/dist/handlers/create-handler.d.ts.map +1 -1
  23. package/dist/handlers/create-handler.js +35 -24
  24. package/dist/handlers/create-handler.js.map +1 -1
  25. package/dist/handlers/types.d.ts +5 -5
  26. package/dist/handlers/types.d.ts.map +1 -1
  27. package/dist/index.js +19 -19
  28. package/dist/migrate.d.ts +1 -1
  29. package/dist/migrate.d.ts.map +1 -1
  30. package/dist/migrate.js +148 -28
  31. package/dist/migrate.js.map +1 -1
  32. package/dist/mutations.d.ts +3 -1
  33. package/dist/mutations.d.ts.map +1 -1
  34. package/dist/mutations.js +93 -18
  35. package/dist/mutations.js.map +1 -1
  36. package/dist/outbox.d.ts.map +1 -1
  37. package/dist/outbox.js +1 -11
  38. package/dist/outbox.js.map +1 -1
  39. package/dist/plugins/incrementing-version.d.ts +1 -1
  40. package/dist/plugins/incrementing-version.js +2 -2
  41. package/dist/plugins/index.js +2 -2
  42. package/dist/proxy/dialect.js +1 -1
  43. package/dist/proxy/driver.js +1 -1
  44. package/dist/proxy/index.js +4 -4
  45. package/dist/proxy/mutations.js +1 -1
  46. package/dist/pull-engine.d.ts +29 -3
  47. package/dist/pull-engine.d.ts.map +1 -1
  48. package/dist/pull-engine.js +314 -78
  49. package/dist/pull-engine.js.map +1 -1
  50. package/dist/push-engine.d.ts.map +1 -1
  51. package/dist/push-engine.js +28 -3
  52. package/dist/push-engine.js.map +1 -1
  53. package/dist/query/QueryContext.js +1 -1
  54. package/dist/query/index.js +3 -3
  55. package/dist/query/tracked-select.d.ts +2 -1
  56. package/dist/query/tracked-select.d.ts.map +1 -1
  57. package/dist/query/tracked-select.js +1 -1
  58. package/dist/schema.d.ts +2 -2
  59. package/dist/schema.d.ts.map +1 -1
  60. package/dist/sync-loop.d.ts +5 -1
  61. package/dist/sync-loop.d.ts.map +1 -1
  62. package/dist/sync-loop.js +167 -18
  63. package/dist/sync-loop.js.map +1 -1
  64. package/package.json +30 -6
  65. package/src/client.test.ts +369 -0
  66. package/src/client.ts +101 -22
  67. package/src/conflicts.ts +1 -10
  68. package/src/create-client.ts +33 -5
  69. package/src/engine/SyncEngine.test.ts +157 -0
  70. package/src/engine/SyncEngine.ts +359 -40
  71. package/src/engine/types.ts +22 -4
  72. package/src/handlers/create-handler.ts +86 -37
  73. package/src/handlers/types.ts +10 -4
  74. package/src/migrate.ts +215 -33
  75. package/src/mutations.ts +143 -21
  76. package/src/outbox.ts +1 -15
  77. package/src/plugins/incrementing-version.ts +2 -2
  78. package/src/pull-engine.test.ts +147 -0
  79. package/src/pull-engine.ts +392 -77
  80. package/src/push-engine.ts +33 -1
  81. package/src/query/tracked-select.ts +1 -1
  82. package/src/schema.ts +2 -2
  83. package/src/sync-loop.ts +215 -19
@@ -5,10 +5,15 @@
5
5
  * and provides a clean API for framework bindings to consume.
6
6
  */
7
7
 
8
- import type {
9
- SyncChange,
10
- SyncPullResponse,
11
- SyncSubscriptionRequest,
8
+ import {
9
+ captureSyncException,
10
+ countSyncMetric,
11
+ distributionSyncMetric,
12
+ isRecord,
13
+ type SyncChange,
14
+ type SyncPullResponse,
15
+ type SyncSubscriptionRequest,
16
+ startSyncSpan,
12
17
  } from '@syncular/core';
13
18
  import { type Kysely, sql } from 'kysely';
14
19
  import { syncPushOnce } from '../push-engine';
@@ -39,6 +44,7 @@ const DEFAULT_MAX_RETRIES = 5;
39
44
  const INITIAL_RETRY_DELAY_MS = 1000;
40
45
  const MAX_RETRY_DELAY_MS = 60000;
41
46
  const EXPONENTIAL_FACTOR = 2;
47
+ const REALTIME_RECONNECT_CATCHUP_DELAY_MS = 500;
42
48
 
43
49
  function calculateRetryDelay(attemptIndex: number): number {
44
50
  return Math.min(
@@ -70,8 +76,10 @@ function createSyncError(
70
76
  };
71
77
  }
72
78
 
73
- function isRecord(value: unknown): value is Record<string, unknown> {
74
- return typeof value === 'object' && value !== null && !Array.isArray(value);
79
+ function resolveSyncTriggerLabel(
80
+ trigger?: 'ws' | 'local' | 'poll'
81
+ ): 'ws' | 'local' | 'poll' | 'auto' {
82
+ return trigger ?? 'auto';
75
83
  }
76
84
 
77
85
  /**
@@ -99,6 +107,8 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
99
107
  private syncPromise: Promise<SyncResult> | null = null;
100
108
  private syncRequestedWhileRunning = false;
101
109
  private retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
110
+ private realtimeCatchupTimeoutId: ReturnType<typeof setTimeout> | null = null;
111
+ private hasRealtimeConnectedOnce = false;
102
112
 
103
113
  /**
104
114
  * In-memory map tracking local mutation timestamps by rowId.
@@ -282,7 +292,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
282
292
 
283
293
  private detectTransportMode(): SyncTransportMode {
284
294
  if (
285
- this.config.realtimeEnabled &&
295
+ this.config.realtimeEnabled !== false &&
286
296
  isRealtimeTransport(this.config.transport)
287
297
  ) {
288
298
  return 'realtime';
@@ -473,12 +483,18 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
473
483
  clearTimeout(this.retryTimeoutId);
474
484
  this.retryTimeoutId = null;
475
485
  }
486
+ if (this.realtimeCatchupTimeoutId) {
487
+ clearTimeout(this.realtimeCatchupTimeoutId);
488
+ this.realtimeCatchupTimeoutId = null;
489
+ }
476
490
  }
477
491
 
478
492
  /**
479
493
  * Trigger a manual sync
480
494
  */
481
- async sync(): Promise<SyncResult> {
495
+ async sync(opts?: {
496
+ trigger?: 'ws' | 'local' | 'poll';
497
+ }): Promise<SyncResult> {
482
498
  // Dedupe concurrent sync calls
483
499
  if (this.syncPromise) {
484
500
  // A sync is already in-flight; queue one more run so we don't miss
@@ -501,7 +517,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
501
517
  };
502
518
  }
503
519
 
504
- this.syncPromise = this.performSyncLoop();
520
+ this.syncPromise = this.performSyncLoop(opts?.trigger);
505
521
  try {
506
522
  return await this.syncPromise;
507
523
  } finally {
@@ -509,7 +525,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
509
525
  }
510
526
  }
511
527
 
512
- private async performSyncLoop(): Promise<SyncResult> {
528
+ private async performSyncLoop(
529
+ trigger?: 'ws' | 'local' | 'poll'
530
+ ): Promise<SyncResult> {
513
531
  let lastResult: SyncResult = {
514
532
  success: false,
515
533
  pushedCommits: 0,
@@ -520,7 +538,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
520
538
 
521
539
  do {
522
540
  this.syncRequestedWhileRunning = false;
523
- lastResult = await this.performSyncOnce();
541
+ lastResult = await this.performSyncOnce(trigger);
542
+ // After the first iteration, clear trigger context
543
+ trigger = undefined;
524
544
  // If the sync failed, let retry logic handle backoff instead of tight looping.
525
545
  if (!lastResult.success) break;
526
546
  } while (
@@ -532,27 +552,45 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
532
552
  return lastResult;
533
553
  }
534
554
 
535
- private async performSyncOnce(): Promise<SyncResult> {
555
+ private async performSyncOnce(
556
+ trigger?: 'ws' | 'local' | 'poll'
557
+ ): Promise<SyncResult> {
536
558
  const timestamp = Date.now();
559
+ const startedAtMs = timestamp;
560
+ const triggerLabel = resolveSyncTriggerLabel(trigger);
537
561
  this.updateState({ isSyncing: true });
538
562
  this.emit('sync:start', { timestamp });
563
+ countSyncMetric('sync.client.sync.attempts', 1, {
564
+ attributes: { trigger: triggerLabel },
565
+ });
539
566
 
540
567
  try {
541
568
  const pullApplyTimestamp = Date.now();
542
- const result = await syncOnce(
543
- this.config.db,
544
- this.config.transport,
545
- this.config.shapes,
569
+ const result = await startSyncSpan(
546
570
  {
547
- clientId: this.config.clientId!,
548
- actorId: this.config.actorId ?? undefined,
549
- plugins: this.config.plugins,
550
- subscriptions: this.config.subscriptions as SyncSubscriptionRequest[],
551
- limitCommits: this.config.limitCommits,
552
- limitSnapshotRows: this.config.limitSnapshotRows,
553
- maxSnapshotPages: this.config.maxSnapshotPages,
554
- stateId: this.config.stateId,
555
- }
571
+ name: 'sync.client.sync',
572
+ op: 'sync.client.sync',
573
+ attributes: { trigger: triggerLabel },
574
+ },
575
+ () =>
576
+ syncOnce(
577
+ this.config.db,
578
+ this.config.transport,
579
+ this.config.handlers,
580
+ {
581
+ clientId: this.config.clientId!,
582
+ actorId: this.config.actorId ?? undefined,
583
+ plugins: this.config.plugins,
584
+ subscriptions: this.config
585
+ .subscriptions as SyncSubscriptionRequest[],
586
+ limitCommits: this.config.limitCommits,
587
+ limitSnapshotRows: this.config.limitSnapshotRows,
588
+ maxSnapshotPages: this.config.maxSnapshotPages,
589
+ stateId: this.config.stateId,
590
+ sha256: this.config.sha256,
591
+ trigger,
592
+ }
593
+ )
556
594
  );
557
595
 
558
596
  const syncResult: SyncResult = {
@@ -594,8 +632,42 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
594
632
  this.config.onDataChange?.(changedTables);
595
633
  }
596
634
 
597
- // Refresh outbox stats
598
- await this.refreshOutboxStats();
635
+ // Refresh outbox stats (fire-and-forget — don't block sync:complete)
636
+ this.refreshOutboxStats().catch((error) => {
637
+ console.warn(
638
+ '[SyncEngine] Failed to refresh outbox stats after sync:',
639
+ error
640
+ );
641
+ });
642
+
643
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
644
+ countSyncMetric('sync.client.sync.results', 1, {
645
+ attributes: {
646
+ trigger: triggerLabel,
647
+ status: 'success',
648
+ },
649
+ });
650
+ distributionSyncMetric('sync.client.sync.duration_ms', durationMs, {
651
+ unit: 'millisecond',
652
+ attributes: {
653
+ trigger: triggerLabel,
654
+ status: 'success',
655
+ },
656
+ });
657
+ distributionSyncMetric(
658
+ 'sync.client.sync.pushed_commits',
659
+ result.pushedCommits,
660
+ {
661
+ attributes: { trigger: triggerLabel },
662
+ }
663
+ );
664
+ distributionSyncMetric(
665
+ 'sync.client.sync.pull_rounds',
666
+ result.pullRounds,
667
+ {
668
+ attributes: { trigger: triggerLabel },
669
+ }
670
+ );
599
671
 
600
672
  return syncResult;
601
673
  } catch (err) {
@@ -614,6 +686,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
614
686
 
615
687
  this.handleError(error);
616
688
 
689
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
690
+ countSyncMetric('sync.client.sync.results', 1, {
691
+ attributes: {
692
+ trigger: triggerLabel,
693
+ status: 'error',
694
+ },
695
+ });
696
+ distributionSyncMetric('sync.client.sync.duration_ms', durationMs, {
697
+ unit: 'millisecond',
698
+ attributes: {
699
+ trigger: triggerLabel,
700
+ status: 'error',
701
+ },
702
+ });
703
+ captureSyncException(err, {
704
+ event: 'sync.client.sync',
705
+ trigger: triggerLabel,
706
+ });
707
+
617
708
  // Schedule retry if under max retries
618
709
  const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
619
710
  if (this.state.retryCount < maxRetries) {
@@ -652,6 +743,165 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
652
743
  return Array.from(tables);
653
744
  }
654
745
 
746
+ /**
747
+ * Apply changes delivered inline over WebSocket for instant UI updates.
748
+ * Returns true if changes were applied and cursor updated successfully,
749
+ * false if anything failed (caller should fall back to HTTP sync).
750
+ */
751
+ private async applyWsDeliveredChanges(
752
+ changes: SyncChange[],
753
+ cursor: number
754
+ ): Promise<boolean> {
755
+ try {
756
+ await this.config.db.transaction().execute(async (trx) => {
757
+ for (const change of changes) {
758
+ const handler = this.config.handlers.get(change.table);
759
+ if (!handler) {
760
+ throw new Error(
761
+ `Missing client table handler for WS change table "${change.table}"`
762
+ );
763
+ }
764
+ await handler.applyChange({ trx }, change);
765
+ }
766
+
767
+ // Update subscription cursors
768
+ const stateId = this.config.stateId ?? 'default';
769
+ await sql`
770
+ update ${sql.table('sync_subscription_state')}
771
+ set ${sql.ref('cursor')} = ${sql.val(cursor)}
772
+ where ${sql.ref('state_id')} = ${sql.val(stateId)}
773
+ and ${sql.ref('cursor')} < ${sql.val(cursor)}
774
+ `.execute(trx);
775
+ });
776
+
777
+ // Update mutation timestamps BEFORE emitting data:change so that
778
+ // React hooks re-querying the DB see fresh fingerprints immediately.
779
+ const now = Date.now();
780
+ for (const change of changes) {
781
+ if (!change.table || !change.row_id) continue;
782
+ if (change.op === 'delete') {
783
+ this.mutationTimestamps.delete(`${change.table}:${change.row_id}`);
784
+ } else {
785
+ this.bumpMutationTimestamp(change.table, change.row_id, now);
786
+ }
787
+ }
788
+
789
+ // Emit data change for immediate UI update
790
+ const changedTables = [...new Set(changes.map((c) => c.table))];
791
+ if (changedTables.length > 0) {
792
+ this.emit('data:change', {
793
+ scopes: changedTables,
794
+ timestamp: Date.now(),
795
+ });
796
+ this.config.onDataChange?.(changedTables);
797
+ }
798
+
799
+ return true;
800
+ } catch {
801
+ return false;
802
+ }
803
+ }
804
+
805
+ /**
806
+ * Handle WS-delivered changes: apply them and decide whether to skip HTTP pull.
807
+ * Falls back to full HTTP sync when conditions require it.
808
+ */
809
+ private async handleWsDelivery(
810
+ changes: SyncChange[],
811
+ cursor: number
812
+ ): Promise<void> {
813
+ // If a sync is already in-flight, let it handle everything
814
+ if (this.syncPromise) {
815
+ countSyncMetric('sync.client.ws.delivery.events', 1, {
816
+ attributes: { path: 'inflight_sync' },
817
+ });
818
+ this.triggerSyncInBackground(
819
+ { trigger: 'ws' },
820
+ 'ws delivery with in-flight sync'
821
+ );
822
+ return;
823
+ }
824
+
825
+ // If there are pending outbox commits, need to push via HTTP
826
+ if (this.state.pendingCount > 0) {
827
+ countSyncMetric('sync.client.ws.delivery.events', 1, {
828
+ attributes: { path: 'pending_outbox' },
829
+ });
830
+ this.triggerSyncInBackground(
831
+ { trigger: 'ws' },
832
+ 'ws delivery with pending outbox'
833
+ );
834
+ return;
835
+ }
836
+
837
+ // If afterPull plugins exist, inline WS changes may require transforms
838
+ // (e.g. decryption). Fall back to HTTP sync and do not apply inline payload.
839
+ const hasAfterPullPlugins = this.config.plugins?.some(
840
+ (p) => typeof p.afterPull === 'function'
841
+ );
842
+ if (hasAfterPullPlugins) {
843
+ countSyncMetric('sync.client.ws.delivery.events', 1, {
844
+ attributes: { path: 'after_pull_plugins' },
845
+ });
846
+ this.triggerSyncInBackground(
847
+ { trigger: 'ws' },
848
+ 'ws delivery with afterPull plugins'
849
+ );
850
+ return;
851
+ }
852
+
853
+ // Apply changes + update cursor
854
+ const inlineApplyStartedAtMs = Date.now();
855
+ const applied = await this.applyWsDeliveredChanges(changes, cursor);
856
+ const inlineApplyDurationMs = Math.max(
857
+ 0,
858
+ Date.now() - inlineApplyStartedAtMs
859
+ );
860
+ distributionSyncMetric(
861
+ 'sync.client.ws.inline_apply.duration_ms',
862
+ inlineApplyDurationMs,
863
+ {
864
+ unit: 'millisecond',
865
+ }
866
+ );
867
+
868
+ if (!applied) {
869
+ countSyncMetric('sync.client.ws.delivery.events', 1, {
870
+ attributes: { path: 'inline_fallback' },
871
+ });
872
+ this.triggerSyncInBackground(
873
+ { trigger: 'ws' },
874
+ 'ws inline apply fallback'
875
+ );
876
+ return;
877
+ }
878
+
879
+ // All clear — skip HTTP pull entirely
880
+ countSyncMetric('sync.client.ws.delivery.events', 1, {
881
+ attributes: { path: 'inline_applied' },
882
+ });
883
+ this.updateState({
884
+ lastSyncAt: Date.now(),
885
+ error: null,
886
+ retryCount: 0,
887
+ isRetrying: false,
888
+ });
889
+
890
+ this.emit('sync:complete', {
891
+ timestamp: Date.now(),
892
+ pushedCommits: 0,
893
+ pullRounds: 0,
894
+ pullResponse: { ok: true, subscriptions: [] },
895
+ });
896
+
897
+ this.refreshOutboxStats().catch((error) => {
898
+ console.warn(
899
+ '[SyncEngine] Failed to refresh outbox stats after WS apply:',
900
+ error
901
+ );
902
+ });
903
+ }
904
+
655
905
  private timestampCounter = 0;
656
906
 
657
907
  private nextPreciseTimestamp(now: number): number {
@@ -758,7 +1008,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
758
1008
  this.retryTimeoutId = setTimeout(() => {
759
1009
  this.retryTimeoutId = null;
760
1010
  if (!this.isDestroyed) {
761
- this.sync();
1011
+ this.triggerSyncInBackground(undefined, 'retry timer');
762
1012
  }
763
1013
  }, delay);
764
1014
  }
@@ -768,13 +1018,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
768
1018
  this.config.onError?.(error);
769
1019
  }
770
1020
 
1021
+ private triggerSyncInBackground(
1022
+ opts?: { trigger?: 'ws' | 'local' | 'poll' },
1023
+ reason = 'background'
1024
+ ): void {
1025
+ void this.sync(opts).catch((error) => {
1026
+ console.error(
1027
+ `[SyncEngine] Unexpected sync failure during ${reason}:`,
1028
+ error
1029
+ );
1030
+ });
1031
+ }
1032
+
771
1033
  private setupPolling(): void {
772
1034
  this.stopPolling();
773
1035
 
774
1036
  const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
775
1037
  this.pollerId = setInterval(() => {
776
1038
  if (!this.state.isSyncing && !this.isDestroyed) {
777
- this.sync();
1039
+ this.triggerSyncInBackground(undefined, 'polling interval');
778
1040
  }
779
1041
  }, interval);
780
1042
 
@@ -827,16 +1089,38 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
827
1089
  { clientId: this.config.clientId! },
828
1090
  (event) => {
829
1091
  if (event.event === 'sync') {
830
- this.sync();
1092
+ countSyncMetric('sync.client.ws.events', 1, {
1093
+ attributes: { type: 'sync' },
1094
+ });
1095
+ const hasInlineChanges =
1096
+ Array.isArray(event.data.changes) && event.data.changes.length > 0;
1097
+ const cursor = event.data.cursor;
1098
+
1099
+ if (hasInlineChanges && typeof cursor === 'number') {
1100
+ // WS delivered changes + cursor — may skip HTTP pull
1101
+ this.handleWsDelivery(event.data.changes as SyncChange[], cursor);
1102
+ } else {
1103
+ // Cursor-only wake-up or no cursor — must HTTP sync
1104
+ countSyncMetric('sync.client.ws.delivery.events', 1, {
1105
+ attributes: { path: 'cursor_wakeup' },
1106
+ });
1107
+ this.triggerSyncInBackground({ trigger: 'ws' }, 'ws cursor wakeup');
1108
+ }
831
1109
  }
832
1110
  },
833
1111
  (state) => {
834
1112
  switch (state) {
835
- case 'connected':
1113
+ case 'connected': {
1114
+ const wasConnectedBefore = this.hasRealtimeConnectedOnce;
1115
+ this.hasRealtimeConnectedOnce = true;
836
1116
  this.setConnectionState('connected');
837
1117
  this.stopFallbackPolling();
838
- this.sync();
1118
+ this.triggerSyncInBackground(undefined, 'realtime connected state');
1119
+ if (wasConnectedBefore) {
1120
+ this.scheduleRealtimeReconnectCatchupSync();
1121
+ }
839
1122
  break;
1123
+ }
840
1124
  case 'connecting':
841
1125
  this.setConnectionState('connecting');
842
1126
  break;
@@ -850,6 +1134,10 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
850
1134
  }
851
1135
 
852
1136
  private stopRealtime(): void {
1137
+ if (this.realtimeCatchupTimeoutId) {
1138
+ clearTimeout(this.realtimeCatchupTimeoutId);
1139
+ this.realtimeCatchupTimeoutId = null;
1140
+ }
853
1141
  if (this.realtimePresenceUnsub) {
854
1142
  this.realtimePresenceUnsub();
855
1143
  this.realtimePresenceUnsub = null;
@@ -861,13 +1149,28 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
861
1149
  this.stopFallbackPolling();
862
1150
  }
863
1151
 
1152
+ private scheduleRealtimeReconnectCatchupSync(): void {
1153
+ if (this.realtimeCatchupTimeoutId) {
1154
+ clearTimeout(this.realtimeCatchupTimeoutId);
1155
+ }
1156
+
1157
+ this.realtimeCatchupTimeoutId = setTimeout(() => {
1158
+ this.realtimeCatchupTimeoutId = null;
1159
+
1160
+ if (this.isDestroyed || !this.isEnabled()) return;
1161
+ if (this.state.connectionState !== 'connected') return;
1162
+
1163
+ this.triggerSyncInBackground(undefined, 'realtime reconnect catchup');
1164
+ }, REALTIME_RECONNECT_CATCHUP_DELAY_MS);
1165
+ }
1166
+
864
1167
  private startFallbackPolling(): void {
865
1168
  if (this.fallbackPollerId) return;
866
1169
 
867
1170
  const interval = this.config.realtimeFallbackPollMs ?? 30_000;
868
1171
  this.fallbackPollerId = setInterval(() => {
869
1172
  if (!this.state.isSyncing && !this.isDestroyed) {
870
- this.sync();
1173
+ this.triggerSyncInBackground(undefined, 'realtime fallback poll');
871
1174
  }
872
1175
  }, interval);
873
1176
  }
@@ -879,6 +1182,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
879
1182
  }
880
1183
  }
881
1184
 
1185
+ /**
1186
+ * Clear all in-memory mutation state and emit data:change so UI re-renders.
1187
+ * Call this after deleting local data (e.g. reset flow) so that React hooks
1188
+ * recompute fingerprints from scratch instead of seeing stale timestamps.
1189
+ */
1190
+ resetLocalState(): void {
1191
+ const tables = [...this.tableMutationTimestamps.keys()];
1192
+ this.mutationTimestamps.clear();
1193
+ this.tableMutationTimestamps.clear();
1194
+
1195
+ if (tables.length > 0) {
1196
+ this.emit('data:change', {
1197
+ scopes: tables,
1198
+ timestamp: Date.now(),
1199
+ });
1200
+ this.config.onDataChange?.(tables);
1201
+ }
1202
+ }
1203
+
882
1204
  /**
883
1205
  * Reconnect
884
1206
  */
@@ -901,10 +1223,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
901
1223
  // Polling mode: restart the poller and trigger a sync immediately.
902
1224
  if (this.state.transportMode === 'polling') {
903
1225
  this.setupPolling();
904
- // Trigger sync in background - errors are handled internally by sync()
905
- this.sync().catch((err) => {
906
- console.error('Unexpected error during reconnect sync:', err);
907
- });
1226
+ this.triggerSyncInBackground(undefined, 'reconnect');
908
1227
  }
909
1228
  }
910
1229
 
@@ -1061,7 +1380,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1061
1380
  ): void {
1062
1381
  this.config.subscriptions = subscriptions;
1063
1382
  // Trigger a sync to apply new subscriptions
1064
- this.sync();
1383
+ this.triggerSyncInBackground(undefined, 'subscription update');
1065
1384
  }
1066
1385
 
1067
1386
  /**
@@ -1077,13 +1396,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1077
1396
  }>
1078
1397
  ): Promise<void> {
1079
1398
  const db = this.config.db;
1080
- const shapes = this.config.shapes;
1399
+ const handlers = this.config.handlers;
1081
1400
  const affectedTables = new Set<string>();
1082
1401
  const now = Date.now();
1083
1402
 
1084
1403
  await db.transaction().execute(async (trx) => {
1085
1404
  for (const input of inputs) {
1086
- const handler = shapes.get(input.table);
1405
+ const handler = handlers.get(input.table);
1087
1406
  if (!handler) continue;
1088
1407
 
1089
1408
  affectedTables.add(input.table);
@@ -6,6 +6,8 @@
6
6
 
7
7
  import type {
8
8
  SyncPullResponse,
9
+ SyncPushRequest,
10
+ SyncPushResponse,
9
11
  SyncSubscriptionRequest,
10
12
  SyncTransport,
11
13
  } from '@syncular/core';
@@ -137,8 +139,8 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
137
139
  db: Kysely<DB>;
138
140
  /** Sync transport */
139
141
  transport: SyncTransport;
140
- /** Client shape registry */
141
- shapes: ClientTableRegistry<DB>;
142
+ /** Client table handler registry */
143
+ handlers: ClientTableRegistry<DB>;
142
144
  /** Actor id for sync scoping (null/undefined disables sync) */
143
145
  actorId: string | null | undefined;
144
146
  /** Stable device/app installation id */
@@ -161,7 +163,11 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
161
163
  migrate?: (db: Kysely<DB>) => Promise<void>;
162
164
  /** Called when migration fails. Receives the error. */
163
165
  onMigrationError?: (error: Error) => void;
164
- /** Enable realtime mode (WebSocket wake-ups) if transport supports it */
166
+ /**
167
+ * Enable realtime mode (WebSocket wake-ups).
168
+ * Default behavior is auto-enable when transport supports realtime.
169
+ * Set to false to force polling.
170
+ */
165
171
  realtimeEnabled?: boolean;
166
172
  /** Fallback poll interval when realtime reconnecting */
167
173
  realtimeFallbackPollMs?: number;
@@ -173,6 +179,8 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
173
179
  onDataChange?: (scopes: string[]) => void;
174
180
  /** Optional client plugins (e.g. encryption) */
175
181
  plugins?: SyncClientPlugin[];
182
+ /** Custom SHA-256 hash function (for platforms without crypto.subtle, e.g. React Native) */
183
+ sha256?: (bytes: Uint8Array) => Promise<string>;
176
184
  }
177
185
 
178
186
  /**
@@ -205,7 +213,12 @@ export interface RealtimeTransportLike extends SyncTransport {
205
213
  args: { clientId: string },
206
214
  onEvent: (event: {
207
215
  event: string;
208
- data: { cursor?: number; error?: string; timestamp: number };
216
+ data: {
217
+ cursor?: number;
218
+ changes?: unknown[];
219
+ error?: string;
220
+ timestamp: number;
221
+ };
209
222
  }) => void,
210
223
  onStateChange?: (state: 'disconnected' | 'connecting' | 'connected') => void
211
224
  ): () => void;
@@ -227,6 +240,11 @@ export interface RealtimeTransportLike extends SyncTransport {
227
240
  entries?: PresenceEntry[];
228
241
  }) => void
229
242
  ): () => void;
243
+ /**
244
+ * Push a commit via WebSocket (bypasses HTTP).
245
+ * Returns `null` if WS is not connected or times out (caller should fall back to HTTP).
246
+ */
247
+ pushViaWs?(request: SyncPushRequest): Promise<SyncPushResponse | null>;
230
248
  }
231
249
 
232
250
  /**