@syncular/client-react 0.0.2-2 → 0.0.3-14

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.
@@ -15,11 +15,22 @@ import type {
15
15
  MutationsCommitFn,
16
16
  MutationsTx,
17
17
  OutboxCommitMeta,
18
+ SubscriptionState,
19
+ SyncAwaitBootstrapOptions,
20
+ SyncAwaitPhaseOptions,
18
21
  SyncClientDb,
19
22
  SyncClientPlugin,
23
+ SyncDiagnostics,
24
+ SyncInspectorOptions,
25
+ SyncInspectorSnapshot,
20
26
  SyncOperation,
27
+ SyncProgress,
28
+ SyncRepairOptions,
29
+ SyncResetOptions,
30
+ SyncResetResult,
21
31
  SyncSubscriptionRequest,
22
32
  SyncTransport,
33
+ TransportHealth,
23
34
  } from '@syncular/client';
24
35
  import {
25
36
  type ConflictInfo,
@@ -126,6 +137,30 @@ export interface UseSyncEngineResult {
126
137
  disconnect: () => void;
127
138
  start: () => Promise<void>;
128
139
  resetLocalState: () => void;
140
+ getTransportHealth: () => Readonly<TransportHealth>;
141
+ getProgress: () => Promise<SyncProgress>;
142
+ getDiagnostics: () => Promise<SyncDiagnostics>;
143
+ getInspectorSnapshot: (
144
+ options?: SyncInspectorOptions
145
+ ) => Promise<SyncInspectorSnapshot>;
146
+ listSubscriptionStates: (args?: {
147
+ stateId?: string;
148
+ table?: string;
149
+ status?: 'active' | 'revoked';
150
+ }) => Promise<SubscriptionState[]>;
151
+ getSubscriptionState: (
152
+ subscriptionId: string,
153
+ options?: { stateId?: string }
154
+ ) => Promise<SubscriptionState | null>;
155
+ reset: (options: SyncResetOptions) => Promise<SyncResetResult>;
156
+ repair: (options: SyncRepairOptions) => Promise<SyncResetResult>;
157
+ awaitPhase: (
158
+ phase: SyncProgress['channelPhase'],
159
+ options?: SyncAwaitPhaseOptions
160
+ ) => Promise<SyncProgress>;
161
+ awaitBootstrapComplete: (
162
+ options?: SyncAwaitBootstrapOptions
163
+ ) => Promise<SyncProgress>;
129
164
  }
130
165
 
131
166
  export interface SyncStatus {
@@ -133,12 +168,22 @@ export interface SyncStatus {
133
168
  isOnline: boolean;
134
169
  isSyncing: boolean;
135
170
  lastSyncAt: number | null;
171
+ lastSyncAgeMs: number | null;
172
+ isStale: boolean;
136
173
  pendingCount: number;
137
174
  error: SyncError | null;
138
175
  isRetrying: boolean;
139
176
  retryCount: number;
140
177
  }
141
178
 
179
+ export interface UseSyncStatusOptions {
180
+ /**
181
+ * Mark status as stale when `Date.now() - lastSyncAt` exceeds this value.
182
+ * If omitted, `isStale` is always false.
183
+ */
184
+ staleAfterMs?: number;
185
+ }
186
+
142
187
  export interface UseSyncConnectionResult {
143
188
  state: SyncConnectionState;
144
189
  mode: SyncTransportMode;
@@ -148,6 +193,64 @@ export interface UseSyncConnectionResult {
148
193
  disconnect: () => void;
149
194
  }
150
195
 
196
+ export interface UseTransportHealthResult {
197
+ health: TransportHealth;
198
+ }
199
+
200
+ export interface UseSyncProgressOptions {
201
+ /**
202
+ * Polling interval while bootstrapping.
203
+ * Set to 0 to disable interval refresh.
204
+ */
205
+ pollIntervalMs?: number;
206
+ }
207
+
208
+ export interface UseSyncProgressResult {
209
+ progress: SyncProgress | null;
210
+ isLoading: boolean;
211
+ error: Error | null;
212
+ refresh: () => Promise<void>;
213
+ }
214
+
215
+ export interface UseSyncInspectorOptions {
216
+ /**
217
+ * Polling interval for refreshing inspector snapshots.
218
+ * Set to 0 to disable interval refresh.
219
+ */
220
+ pollIntervalMs?: number;
221
+ /**
222
+ * Max number of recent events in the snapshot.
223
+ */
224
+ eventLimit?: number;
225
+ }
226
+
227
+ export interface UseSyncInspectorResult {
228
+ snapshot: SyncInspectorSnapshot | null;
229
+ isLoading: boolean;
230
+ error: Error | null;
231
+ refresh: () => Promise<void>;
232
+ }
233
+
234
+ export interface UseSyncSubscriptionsOptions {
235
+ stateId?: string;
236
+ table?: string;
237
+ status?: 'active' | 'revoked';
238
+ }
239
+
240
+ export interface UseSyncSubscriptionsResult {
241
+ subscriptions: SubscriptionState[];
242
+ isLoading: boolean;
243
+ error: Error | null;
244
+ refresh: () => Promise<void>;
245
+ }
246
+
247
+ export interface UseSyncSubscriptionResult {
248
+ subscription: SubscriptionState | null;
249
+ isLoading: boolean;
250
+ error: Error | null;
251
+ refresh: () => Promise<void>;
252
+ }
253
+
151
254
  export interface UseConflictsResult {
152
255
  conflicts: ConflictInfo[];
153
256
  count: number;
@@ -179,6 +282,8 @@ export interface UseSyncQueryResult<T> {
179
282
  data: T | undefined;
180
283
  isLoading: boolean;
181
284
  error: Error | null;
285
+ isStale: boolean;
286
+ lastSyncAt: number | null;
182
287
  refetch: () => Promise<void>;
183
288
  }
184
289
 
@@ -186,6 +291,9 @@ export interface UseSyncQueryOptions {
186
291
  enabled?: boolean;
187
292
  deps?: unknown[];
188
293
  keyField?: string;
294
+ watchTables?: string[];
295
+ pollIntervalMs?: number;
296
+ staleAfterMs?: number;
189
297
  }
190
298
 
191
299
  export interface UseQueryResult<T> {
@@ -506,6 +614,47 @@ export function createSyncularReact<DB extends SyncClientDb>() {
506
614
  () => engine.resetLocalState(),
507
615
  [engine]
508
616
  );
617
+ const getTransportHealth = useCallback(
618
+ () => engine.getTransportHealth(),
619
+ [engine]
620
+ );
621
+ const getProgress = useCallback(() => engine.getProgress(), [engine]);
622
+ const getDiagnostics = useCallback(() => engine.getDiagnostics(), [engine]);
623
+ const getInspectorSnapshot = useCallback(
624
+ (options?: SyncInspectorOptions) => engine.getInspectorSnapshot(options),
625
+ [engine]
626
+ );
627
+ const listSubscriptionStates = useCallback(
628
+ (args?: {
629
+ stateId?: string;
630
+ table?: string;
631
+ status?: 'active' | 'revoked';
632
+ }) => engine.listSubscriptionStates(args),
633
+ [engine]
634
+ );
635
+ const getSubscriptionState = useCallback(
636
+ (subscriptionId: string, options?: { stateId?: string }) =>
637
+ engine.getSubscriptionState(subscriptionId, options),
638
+ [engine]
639
+ );
640
+ const reset = useCallback(
641
+ (options: SyncResetOptions) => engine.reset(options),
642
+ [engine]
643
+ );
644
+ const repair = useCallback(
645
+ (options: SyncRepairOptions) => engine.repair(options),
646
+ [engine]
647
+ );
648
+ const awaitPhase = useCallback(
649
+ (phase: SyncProgress['channelPhase'], options?: SyncAwaitPhaseOptions) =>
650
+ engine.awaitPhase(phase, options),
651
+ [engine]
652
+ );
653
+ const awaitBootstrapComplete = useCallback(
654
+ (options?: SyncAwaitBootstrapOptions) =>
655
+ engine.awaitBootstrapComplete(options),
656
+ [engine]
657
+ );
509
658
 
510
659
  return {
511
660
  state,
@@ -514,10 +663,21 @@ export function createSyncularReact<DB extends SyncClientDb>() {
514
663
  disconnect,
515
664
  start,
516
665
  resetLocalState,
666
+ getTransportHealth,
667
+ getProgress,
668
+ getDiagnostics,
669
+ getInspectorSnapshot,
670
+ listSubscriptionStates,
671
+ getSubscriptionState,
672
+ reset,
673
+ repair,
674
+ awaitPhase,
675
+ awaitBootstrapComplete,
517
676
  };
518
677
  }
519
678
 
520
- function useSyncStatus(): SyncStatus {
679
+ function useSyncStatus(options: UseSyncStatusOptions = {}): SyncStatus {
680
+ const { staleAfterMs } = options;
521
681
  const engine = useEngine();
522
682
 
523
683
  const state = useSyncExternalStore(
@@ -526,19 +686,44 @@ export function createSyncularReact<DB extends SyncClientDb>() {
526
686
  useCallback(() => engine.getState(), [engine])
527
687
  );
528
688
 
529
- return useMemo<SyncStatus>(
530
- () => ({
689
+ const [staleClock, setStaleClock] = useState<number>(Date.now());
690
+
691
+ useEffect(() => {
692
+ if (staleAfterMs === undefined || staleAfterMs <= 0) return;
693
+
694
+ const intervalMs = Math.min(
695
+ 1000,
696
+ Math.max(100, Math.floor(staleAfterMs / 2))
697
+ );
698
+ const timer = setInterval(() => {
699
+ setStaleClock(Date.now());
700
+ }, intervalMs);
701
+
702
+ return () => clearInterval(timer);
703
+ }, [staleAfterMs]);
704
+
705
+ return useMemo<SyncStatus>(() => {
706
+ const now = staleAfterMs !== undefined ? staleClock : Date.now();
707
+ const lastSyncAgeMs =
708
+ state.lastSyncAt === null ? null : Math.max(0, now - state.lastSyncAt);
709
+ const isStale =
710
+ staleAfterMs !== undefined && staleAfterMs > 0
711
+ ? state.lastSyncAt === null || (lastSyncAgeMs ?? 0) > staleAfterMs
712
+ : false;
713
+
714
+ return {
531
715
  enabled: state.enabled,
532
716
  isOnline: state.connectionState === 'connected',
533
717
  isSyncing: state.isSyncing,
534
718
  lastSyncAt: state.lastSyncAt,
719
+ lastSyncAgeMs,
720
+ isStale,
535
721
  pendingCount: state.pendingCount,
536
722
  error: state.error,
537
723
  isRetrying: state.isRetrying,
538
724
  retryCount: state.retryCount,
539
- }),
540
- [state]
541
- );
725
+ };
726
+ }, [state, staleAfterMs, staleClock]);
542
727
  }
543
728
 
544
729
  function useSyncConnection(): UseSyncConnectionResult {
@@ -571,6 +756,290 @@ export function createSyncularReact<DB extends SyncClientDb>() {
571
756
  );
572
757
  }
573
758
 
759
+ function useTransportHealth(): UseTransportHealthResult {
760
+ const engine = useEngine();
761
+
762
+ const health = useSyncExternalStore(
763
+ useCallback(
764
+ (callback) => {
765
+ const unsubscribers = [
766
+ engine.subscribe(callback),
767
+ engine.on('connection:change', callback),
768
+ engine.on('sync:complete', callback),
769
+ engine.on('sync:error', callback),
770
+ ];
771
+ return () => {
772
+ for (const unsubscribe of unsubscribers) unsubscribe();
773
+ };
774
+ },
775
+ [engine]
776
+ ),
777
+ useCallback(() => engine.getTransportHealth(), [engine]),
778
+ useCallback(() => engine.getTransportHealth(), [engine])
779
+ );
780
+
781
+ return useMemo(() => ({ health }), [health]);
782
+ }
783
+
784
+ function useSyncProgress(
785
+ options: UseSyncProgressOptions = {}
786
+ ): UseSyncProgressResult {
787
+ const engine = useEngine();
788
+ const { pollIntervalMs = 500 } = options;
789
+
790
+ const [progress, setProgress] = useState<SyncProgress | null>(null);
791
+ const [isLoading, setIsLoading] = useState(true);
792
+ const [error, setError] = useState<Error | null>(null);
793
+ const loadedRef = useRef(false);
794
+
795
+ const refresh = useCallback(async () => {
796
+ if (!loadedRef.current) {
797
+ setIsLoading(true);
798
+ }
799
+
800
+ try {
801
+ const next = await engine.getProgress();
802
+ setProgress(next);
803
+ setError(null);
804
+ } catch (err) {
805
+ setError(err instanceof Error ? err : new Error(String(err)));
806
+ } finally {
807
+ loadedRef.current = true;
808
+ setIsLoading(false);
809
+ }
810
+ }, [engine]);
811
+
812
+ useEffect(() => {
813
+ void refresh();
814
+ }, [refresh]);
815
+
816
+ useEffect(() => {
817
+ const unsubscribers = [
818
+ engine.on('sync:start', refresh),
819
+ engine.on('sync:complete', refresh),
820
+ engine.on('sync:error', refresh),
821
+ engine.on('bootstrap:start', refresh),
822
+ engine.on('bootstrap:progress', refresh),
823
+ engine.on('bootstrap:complete', refresh),
824
+ ];
825
+ return () => {
826
+ for (const unsubscribe of unsubscribers) unsubscribe();
827
+ };
828
+ }, [engine, refresh]);
829
+
830
+ useEffect(() => {
831
+ if (pollIntervalMs <= 0) return;
832
+ if (progress?.channelPhase !== 'bootstrapping') return;
833
+
834
+ const timer = setInterval(() => {
835
+ void refresh();
836
+ }, pollIntervalMs);
837
+
838
+ return () => clearInterval(timer);
839
+ }, [pollIntervalMs, progress?.channelPhase, refresh]);
840
+
841
+ return useMemo(
842
+ () => ({
843
+ progress,
844
+ isLoading,
845
+ error,
846
+ refresh,
847
+ }),
848
+ [progress, isLoading, error, refresh]
849
+ );
850
+ }
851
+
852
+ function useSyncInspector(
853
+ options: UseSyncInspectorOptions = {}
854
+ ): UseSyncInspectorResult {
855
+ const engine = useEngine();
856
+ const { pollIntervalMs = 2_000, eventLimit } = options;
857
+
858
+ const [snapshot, setSnapshot] = useState<SyncInspectorSnapshot | null>(
859
+ null
860
+ );
861
+ const [isLoading, setIsLoading] = useState(true);
862
+ const [error, setError] = useState<Error | null>(null);
863
+ const loadedRef = useRef(false);
864
+
865
+ const refresh = useCallback(async () => {
866
+ if (!loadedRef.current) {
867
+ setIsLoading(true);
868
+ }
869
+
870
+ try {
871
+ const next = await engine.getInspectorSnapshot({ eventLimit });
872
+ setSnapshot(next);
873
+ setError(null);
874
+ } catch (err) {
875
+ setError(err instanceof Error ? err : new Error(String(err)));
876
+ } finally {
877
+ loadedRef.current = true;
878
+ setIsLoading(false);
879
+ }
880
+ }, [engine, eventLimit]);
881
+
882
+ useEffect(() => {
883
+ void refresh();
884
+ }, [refresh]);
885
+
886
+ useEffect(() => {
887
+ const unsubscribers = [
888
+ engine.on('sync:start', refresh),
889
+ engine.on('sync:complete', refresh),
890
+ engine.on('sync:error', refresh),
891
+ engine.on('bootstrap:start', refresh),
892
+ engine.on('bootstrap:progress', refresh),
893
+ engine.on('bootstrap:complete', refresh),
894
+ engine.on('connection:change', refresh),
895
+ engine.on('outbox:change', refresh),
896
+ engine.on('data:change', refresh),
897
+ ];
898
+ return () => {
899
+ for (const unsubscribe of unsubscribers) unsubscribe();
900
+ };
901
+ }, [engine, refresh]);
902
+
903
+ useEffect(() => {
904
+ if (pollIntervalMs <= 0) return;
905
+ const timer = setInterval(() => {
906
+ void refresh();
907
+ }, pollIntervalMs);
908
+ return () => clearInterval(timer);
909
+ }, [pollIntervalMs, refresh]);
910
+
911
+ return useMemo(
912
+ () => ({
913
+ snapshot,
914
+ isLoading,
915
+ error,
916
+ refresh,
917
+ }),
918
+ [snapshot, isLoading, error, refresh]
919
+ );
920
+ }
921
+
922
+ function useSyncSubscriptions(
923
+ options: UseSyncSubscriptionsOptions = {}
924
+ ): UseSyncSubscriptionsResult {
925
+ const engine = useEngine();
926
+ const { stateId, table, status } = options;
927
+
928
+ const [subscriptions, setSubscriptions] = useState<SubscriptionState[]>([]);
929
+ const [isLoading, setIsLoading] = useState(true);
930
+ const [error, setError] = useState<Error | null>(null);
931
+ const loadedRef = useRef(false);
932
+
933
+ const refresh = useCallback(async () => {
934
+ if (!loadedRef.current) {
935
+ setIsLoading(true);
936
+ }
937
+
938
+ try {
939
+ const next = await engine.listSubscriptionStates({
940
+ stateId,
941
+ table,
942
+ status,
943
+ });
944
+ setSubscriptions(next);
945
+ setError(null);
946
+ } catch (err) {
947
+ setError(err instanceof Error ? err : new Error(String(err)));
948
+ } finally {
949
+ loadedRef.current = true;
950
+ setIsLoading(false);
951
+ }
952
+ }, [engine, stateId, table, status]);
953
+
954
+ useEffect(() => {
955
+ void refresh();
956
+ }, [refresh]);
957
+
958
+ useEffect(() => {
959
+ const unsubscribers = [
960
+ engine.on('sync:complete', refresh),
961
+ engine.on('sync:error', refresh),
962
+ engine.on('bootstrap:start', refresh),
963
+ engine.on('bootstrap:progress', refresh),
964
+ engine.on('bootstrap:complete', refresh),
965
+ ];
966
+ return () => {
967
+ for (const unsubscribe of unsubscribers) unsubscribe();
968
+ };
969
+ }, [engine, refresh]);
970
+
971
+ return useMemo(
972
+ () => ({
973
+ subscriptions,
974
+ isLoading,
975
+ error,
976
+ refresh,
977
+ }),
978
+ [subscriptions, isLoading, error, refresh]
979
+ );
980
+ }
981
+
982
+ function useSyncSubscription(
983
+ subscriptionId: string,
984
+ options: { stateId?: string } = {}
985
+ ): UseSyncSubscriptionResult {
986
+ const engine = useEngine();
987
+ const { stateId } = options;
988
+
989
+ const [subscription, setSubscription] = useState<SubscriptionState | null>(
990
+ null
991
+ );
992
+ const [isLoading, setIsLoading] = useState(true);
993
+ const [error, setError] = useState<Error | null>(null);
994
+ const loadedRef = useRef(false);
995
+
996
+ const refresh = useCallback(async () => {
997
+ if (!loadedRef.current) {
998
+ setIsLoading(true);
999
+ }
1000
+
1001
+ try {
1002
+ const next = await engine.getSubscriptionState(subscriptionId, {
1003
+ stateId,
1004
+ });
1005
+ setSubscription(next);
1006
+ setError(null);
1007
+ } catch (err) {
1008
+ setError(err instanceof Error ? err : new Error(String(err)));
1009
+ } finally {
1010
+ loadedRef.current = true;
1011
+ setIsLoading(false);
1012
+ }
1013
+ }, [engine, stateId, subscriptionId]);
1014
+
1015
+ useEffect(() => {
1016
+ void refresh();
1017
+ }, [refresh]);
1018
+
1019
+ useEffect(() => {
1020
+ const unsubscribers = [
1021
+ engine.on('sync:complete', refresh),
1022
+ engine.on('sync:error', refresh),
1023
+ engine.on('bootstrap:start', refresh),
1024
+ engine.on('bootstrap:progress', refresh),
1025
+ engine.on('bootstrap:complete', refresh),
1026
+ ];
1027
+ return () => {
1028
+ for (const unsubscribe of unsubscribers) unsubscribe();
1029
+ };
1030
+ }, [engine, refresh]);
1031
+
1032
+ return useMemo(
1033
+ () => ({
1034
+ subscription,
1035
+ isLoading,
1036
+ error,
1037
+ refresh,
1038
+ }),
1039
+ [subscription, isLoading, error, refresh]
1040
+ );
1041
+ }
1042
+
574
1043
  function useConflicts(): UseConflictsResult {
575
1044
  const engine = useEngine();
576
1045
 
@@ -694,9 +1163,17 @@ export function createSyncularReact<DB extends SyncClientDb>() {
694
1163
  ) => ExecutableQuery<TResult> | Promise<TResult>,
695
1164
  options: UseSyncQueryOptions = {}
696
1165
  ): UseSyncQueryResult<TResult> {
697
- const { enabled = true, deps = [], keyField = 'id' } = options;
1166
+ const {
1167
+ enabled = true,
1168
+ deps = [],
1169
+ keyField = 'id',
1170
+ watchTables = [],
1171
+ pollIntervalMs,
1172
+ staleAfterMs,
1173
+ } = options;
698
1174
  const { db } = useSyncContext();
699
1175
  const engine = useEngine();
1176
+ const watchTablesSet = useMemo(() => new Set(watchTables), [watchTables]);
700
1177
 
701
1178
  const queryFnRef = useRef<typeof queryFn>(queryFn);
702
1179
  queryFnRef.current = queryFn;
@@ -704,6 +1181,10 @@ export function createSyncularReact<DB extends SyncClientDb>() {
704
1181
  const [data, setData] = useState<TResult | undefined>(undefined);
705
1182
  const [isLoading, setIsLoading] = useState(true);
706
1183
  const [error, setError] = useState<Error | null>(null);
1184
+ const [lastSyncAt, setLastSyncAt] = useState<number | null>(
1185
+ () => engine.getState().lastSyncAt
1186
+ );
1187
+ const [staleClock, setStaleClock] = useState<number>(Date.now());
707
1188
 
708
1189
  const versionRef = useRef(0);
709
1190
  const watchedScopesRef = useRef<Set<string>>(new Set());
@@ -717,6 +1198,7 @@ export function createSyncularReact<DB extends SyncClientDb>() {
717
1198
  previousFingerprintRef.current = 'disabled';
718
1199
  setData(undefined);
719
1200
  }
1201
+ setLastSyncAt(engine.getState().lastSyncAt);
720
1202
  setIsLoading(false);
721
1203
  hasLoadedRef.current = true;
722
1204
  return;
@@ -746,6 +1228,7 @@ export function createSyncularReact<DB extends SyncClientDb>() {
746
1228
 
747
1229
  if (version === versionRef.current) {
748
1230
  watchedScopesRef.current = scopeCollector;
1231
+ setLastSyncAt(engine.getState().lastSyncAt);
749
1232
 
750
1233
  const fingerprint = fingerprintCollectorRef.current.getCombined();
751
1234
  if (
@@ -774,10 +1257,20 @@ export function createSyncularReact<DB extends SyncClientDb>() {
774
1257
  // eslint-disable-next-line react-hooks/exhaustive-deps
775
1258
  }, [executeQuery, ...deps]);
776
1259
 
1260
+ useEffect(() => {
1261
+ const unsubscribe = engine.subscribe(() => {
1262
+ const nextLastSyncAt = engine.getState().lastSyncAt;
1263
+ setLastSyncAt((previous) =>
1264
+ previous === nextLastSyncAt ? previous : nextLastSyncAt
1265
+ );
1266
+ });
1267
+ return unsubscribe;
1268
+ }, [engine]);
1269
+
777
1270
  useEffect(() => {
778
1271
  if (!enabled) return;
779
1272
  const unsubscribe = engine.on('sync:complete', () => {
780
- executeQuery();
1273
+ void executeQuery();
781
1274
  });
782
1275
  return unsubscribe;
783
1276
  }, [engine, enabled, executeQuery]);
@@ -786,35 +1279,75 @@ export function createSyncularReact<DB extends SyncClientDb>() {
786
1279
  if (!enabled) return;
787
1280
 
788
1281
  const unsubscribe = engine.on('data:change', (event) => {
789
- const changedScopes = event.scopes || [];
1282
+ const changedScopes = event.scopes ?? [];
790
1283
  const watchedScopes = watchedScopesRef.current;
1284
+ const hasDynamicFilter = watchedScopes.size > 0;
1285
+ const hasTableFilter = watchTablesSet.size > 0;
791
1286
 
792
- if (watchedScopes.size > 0) {
793
- const hasWatchedScope = changedScopes.some((s) =>
794
- watchedScopes.has(s)
1287
+ if (hasDynamicFilter || hasTableFilter) {
1288
+ const matchesDynamic = changedScopes.some((scope) =>
1289
+ watchedScopes.has(scope)
1290
+ );
1291
+ const matchesConfigured = changedScopes.some((scope) =>
1292
+ watchTablesSet.has(scope)
795
1293
  );
796
- if (!hasWatchedScope) return;
1294
+
1295
+ if (!matchesDynamic && !matchesConfigured) {
1296
+ return;
1297
+ }
797
1298
  }
798
1299
 
799
- executeQuery();
1300
+ void executeQuery();
800
1301
  });
801
1302
 
802
1303
  return unsubscribe;
803
- }, [engine, enabled, executeQuery]);
1304
+ }, [engine, enabled, executeQuery, watchTablesSet]);
1305
+
1306
+ useEffect(() => {
1307
+ if (!enabled) return;
1308
+ if (pollIntervalMs === undefined || pollIntervalMs <= 0) return;
1309
+
1310
+ const timer = setInterval(() => {
1311
+ void executeQuery();
1312
+ }, pollIntervalMs);
1313
+
1314
+ return () => clearInterval(timer);
1315
+ }, [enabled, pollIntervalMs, executeQuery]);
1316
+
1317
+ useEffect(() => {
1318
+ if (staleAfterMs === undefined || staleAfterMs <= 0) return;
1319
+
1320
+ const intervalMs = Math.min(
1321
+ 1000,
1322
+ Math.max(100, Math.floor(staleAfterMs / 2))
1323
+ );
1324
+ const timer = setInterval(() => {
1325
+ setStaleClock(Date.now());
1326
+ }, intervalMs);
1327
+
1328
+ return () => clearInterval(timer);
1329
+ }, [staleAfterMs]);
804
1330
 
805
1331
  const refetch = useCallback(async () => {
806
1332
  await executeQuery();
807
1333
  }, [executeQuery]);
808
1334
 
809
- return useMemo(
810
- () => ({
1335
+ return useMemo(() => {
1336
+ const now = staleAfterMs !== undefined ? staleClock : Date.now();
1337
+ const isStale =
1338
+ staleAfterMs !== undefined && staleAfterMs > 0
1339
+ ? lastSyncAt === null || now - lastSyncAt > staleAfterMs
1340
+ : false;
1341
+
1342
+ return {
811
1343
  data,
812
1344
  isLoading,
813
1345
  error,
1346
+ isStale,
1347
+ lastSyncAt,
814
1348
  refetch,
815
- }),
816
- [data, isLoading, error, refetch]
817
- );
1349
+ };
1350
+ }, [data, isLoading, error, staleAfterMs, staleClock, lastSyncAt, refetch]);
818
1351
  }
819
1352
 
820
1353
  function useQuery<TResult>(
@@ -1382,6 +1915,11 @@ export function createSyncularReact<DB extends SyncClientDb>() {
1382
1915
  useSyncEngine,
1383
1916
  useSyncStatus,
1384
1917
  useSyncConnection,
1918
+ useTransportHealth,
1919
+ useSyncProgress,
1920
+ useSyncInspector,
1921
+ useSyncSubscriptions,
1922
+ useSyncSubscription,
1385
1923
  useSyncQuery,
1386
1924
  useQuery,
1387
1925
  useMutation,