@syncular/client-react 0.0.2-2 → 0.0.3-6
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.
- package/dist/createSyncularReact.d.ts +70 -2
- package/dist/createSyncularReact.d.ts.map +1 -1
- package/dist/createSyncularReact.js +284 -25
- package/dist/createSyncularReact.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/useSyncGroup.d.ts +42 -0
- package/dist/useSyncGroup.d.ts.map +1 -0
- package/dist/useSyncGroup.js +74 -0
- package/dist/useSyncGroup.js.map +1 -0
- package/package.json +4 -4
- package/src/__tests__/SyncEngine.test.ts +64 -0
- package/src/__tests__/hooks.test.tsx +120 -0
- package/src/createSyncularReact.tsx +458 -20
- package/src/index.ts +14 -0
- package/src/useSyncGroup.ts +169 -0
|
@@ -15,11 +15,20 @@ 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,
|
|
20
24
|
SyncOperation,
|
|
25
|
+
SyncProgress,
|
|
26
|
+
SyncRepairOptions,
|
|
27
|
+
SyncResetOptions,
|
|
28
|
+
SyncResetResult,
|
|
21
29
|
SyncSubscriptionRequest,
|
|
22
30
|
SyncTransport,
|
|
31
|
+
TransportHealth,
|
|
23
32
|
} from '@syncular/client';
|
|
24
33
|
import {
|
|
25
34
|
type ConflictInfo,
|
|
@@ -126,6 +135,27 @@ export interface UseSyncEngineResult {
|
|
|
126
135
|
disconnect: () => void;
|
|
127
136
|
start: () => Promise<void>;
|
|
128
137
|
resetLocalState: () => void;
|
|
138
|
+
getTransportHealth: () => Readonly<TransportHealth>;
|
|
139
|
+
getProgress: () => Promise<SyncProgress>;
|
|
140
|
+
getDiagnostics: () => Promise<SyncDiagnostics>;
|
|
141
|
+
listSubscriptionStates: (args?: {
|
|
142
|
+
stateId?: string;
|
|
143
|
+
table?: string;
|
|
144
|
+
status?: 'active' | 'revoked';
|
|
145
|
+
}) => Promise<SubscriptionState[]>;
|
|
146
|
+
getSubscriptionState: (
|
|
147
|
+
subscriptionId: string,
|
|
148
|
+
options?: { stateId?: string }
|
|
149
|
+
) => Promise<SubscriptionState | null>;
|
|
150
|
+
reset: (options: SyncResetOptions) => Promise<SyncResetResult>;
|
|
151
|
+
repair: (options: SyncRepairOptions) => Promise<SyncResetResult>;
|
|
152
|
+
awaitPhase: (
|
|
153
|
+
phase: SyncProgress['channelPhase'],
|
|
154
|
+
options?: SyncAwaitPhaseOptions
|
|
155
|
+
) => Promise<SyncProgress>;
|
|
156
|
+
awaitBootstrapComplete: (
|
|
157
|
+
options?: SyncAwaitBootstrapOptions
|
|
158
|
+
) => Promise<SyncProgress>;
|
|
129
159
|
}
|
|
130
160
|
|
|
131
161
|
export interface SyncStatus {
|
|
@@ -133,12 +163,22 @@ export interface SyncStatus {
|
|
|
133
163
|
isOnline: boolean;
|
|
134
164
|
isSyncing: boolean;
|
|
135
165
|
lastSyncAt: number | null;
|
|
166
|
+
lastSyncAgeMs: number | null;
|
|
167
|
+
isStale: boolean;
|
|
136
168
|
pendingCount: number;
|
|
137
169
|
error: SyncError | null;
|
|
138
170
|
isRetrying: boolean;
|
|
139
171
|
retryCount: number;
|
|
140
172
|
}
|
|
141
173
|
|
|
174
|
+
export interface UseSyncStatusOptions {
|
|
175
|
+
/**
|
|
176
|
+
* Mark status as stale when `Date.now() - lastSyncAt` exceeds this value.
|
|
177
|
+
* If omitted, `isStale` is always false.
|
|
178
|
+
*/
|
|
179
|
+
staleAfterMs?: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
142
182
|
export interface UseSyncConnectionResult {
|
|
143
183
|
state: SyncConnectionState;
|
|
144
184
|
mode: SyncTransportMode;
|
|
@@ -148,6 +188,45 @@ export interface UseSyncConnectionResult {
|
|
|
148
188
|
disconnect: () => void;
|
|
149
189
|
}
|
|
150
190
|
|
|
191
|
+
export interface UseTransportHealthResult {
|
|
192
|
+
health: TransportHealth;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface UseSyncProgressOptions {
|
|
196
|
+
/**
|
|
197
|
+
* Polling interval while bootstrapping.
|
|
198
|
+
* Set to 0 to disable interval refresh.
|
|
199
|
+
*/
|
|
200
|
+
pollIntervalMs?: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface UseSyncProgressResult {
|
|
204
|
+
progress: SyncProgress | null;
|
|
205
|
+
isLoading: boolean;
|
|
206
|
+
error: Error | null;
|
|
207
|
+
refresh: () => Promise<void>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface UseSyncSubscriptionsOptions {
|
|
211
|
+
stateId?: string;
|
|
212
|
+
table?: string;
|
|
213
|
+
status?: 'active' | 'revoked';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface UseSyncSubscriptionsResult {
|
|
217
|
+
subscriptions: SubscriptionState[];
|
|
218
|
+
isLoading: boolean;
|
|
219
|
+
error: Error | null;
|
|
220
|
+
refresh: () => Promise<void>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface UseSyncSubscriptionResult {
|
|
224
|
+
subscription: SubscriptionState | null;
|
|
225
|
+
isLoading: boolean;
|
|
226
|
+
error: Error | null;
|
|
227
|
+
refresh: () => Promise<void>;
|
|
228
|
+
}
|
|
229
|
+
|
|
151
230
|
export interface UseConflictsResult {
|
|
152
231
|
conflicts: ConflictInfo[];
|
|
153
232
|
count: number;
|
|
@@ -179,6 +258,8 @@ export interface UseSyncQueryResult<T> {
|
|
|
179
258
|
data: T | undefined;
|
|
180
259
|
isLoading: boolean;
|
|
181
260
|
error: Error | null;
|
|
261
|
+
isStale: boolean;
|
|
262
|
+
lastSyncAt: number | null;
|
|
182
263
|
refetch: () => Promise<void>;
|
|
183
264
|
}
|
|
184
265
|
|
|
@@ -186,6 +267,9 @@ export interface UseSyncQueryOptions {
|
|
|
186
267
|
enabled?: boolean;
|
|
187
268
|
deps?: unknown[];
|
|
188
269
|
keyField?: string;
|
|
270
|
+
watchTables?: string[];
|
|
271
|
+
pollIntervalMs?: number;
|
|
272
|
+
staleAfterMs?: number;
|
|
189
273
|
}
|
|
190
274
|
|
|
191
275
|
export interface UseQueryResult<T> {
|
|
@@ -506,6 +590,43 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
506
590
|
() => engine.resetLocalState(),
|
|
507
591
|
[engine]
|
|
508
592
|
);
|
|
593
|
+
const getTransportHealth = useCallback(
|
|
594
|
+
() => engine.getTransportHealth(),
|
|
595
|
+
[engine]
|
|
596
|
+
);
|
|
597
|
+
const getProgress = useCallback(() => engine.getProgress(), [engine]);
|
|
598
|
+
const getDiagnostics = useCallback(() => engine.getDiagnostics(), [engine]);
|
|
599
|
+
const listSubscriptionStates = useCallback(
|
|
600
|
+
(args?: {
|
|
601
|
+
stateId?: string;
|
|
602
|
+
table?: string;
|
|
603
|
+
status?: 'active' | 'revoked';
|
|
604
|
+
}) => engine.listSubscriptionStates(args),
|
|
605
|
+
[engine]
|
|
606
|
+
);
|
|
607
|
+
const getSubscriptionState = useCallback(
|
|
608
|
+
(subscriptionId: string, options?: { stateId?: string }) =>
|
|
609
|
+
engine.getSubscriptionState(subscriptionId, options),
|
|
610
|
+
[engine]
|
|
611
|
+
);
|
|
612
|
+
const reset = useCallback(
|
|
613
|
+
(options: SyncResetOptions) => engine.reset(options),
|
|
614
|
+
[engine]
|
|
615
|
+
);
|
|
616
|
+
const repair = useCallback(
|
|
617
|
+
(options: SyncRepairOptions) => engine.repair(options),
|
|
618
|
+
[engine]
|
|
619
|
+
);
|
|
620
|
+
const awaitPhase = useCallback(
|
|
621
|
+
(phase: SyncProgress['channelPhase'], options?: SyncAwaitPhaseOptions) =>
|
|
622
|
+
engine.awaitPhase(phase, options),
|
|
623
|
+
[engine]
|
|
624
|
+
);
|
|
625
|
+
const awaitBootstrapComplete = useCallback(
|
|
626
|
+
(options?: SyncAwaitBootstrapOptions) =>
|
|
627
|
+
engine.awaitBootstrapComplete(options),
|
|
628
|
+
[engine]
|
|
629
|
+
);
|
|
509
630
|
|
|
510
631
|
return {
|
|
511
632
|
state,
|
|
@@ -514,10 +635,20 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
514
635
|
disconnect,
|
|
515
636
|
start,
|
|
516
637
|
resetLocalState,
|
|
638
|
+
getTransportHealth,
|
|
639
|
+
getProgress,
|
|
640
|
+
getDiagnostics,
|
|
641
|
+
listSubscriptionStates,
|
|
642
|
+
getSubscriptionState,
|
|
643
|
+
reset,
|
|
644
|
+
repair,
|
|
645
|
+
awaitPhase,
|
|
646
|
+
awaitBootstrapComplete,
|
|
517
647
|
};
|
|
518
648
|
}
|
|
519
649
|
|
|
520
|
-
function useSyncStatus(): SyncStatus {
|
|
650
|
+
function useSyncStatus(options: UseSyncStatusOptions = {}): SyncStatus {
|
|
651
|
+
const { staleAfterMs } = options;
|
|
521
652
|
const engine = useEngine();
|
|
522
653
|
|
|
523
654
|
const state = useSyncExternalStore(
|
|
@@ -526,19 +657,44 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
526
657
|
useCallback(() => engine.getState(), [engine])
|
|
527
658
|
);
|
|
528
659
|
|
|
529
|
-
|
|
530
|
-
|
|
660
|
+
const [staleClock, setStaleClock] = useState<number>(Date.now());
|
|
661
|
+
|
|
662
|
+
useEffect(() => {
|
|
663
|
+
if (staleAfterMs === undefined || staleAfterMs <= 0) return;
|
|
664
|
+
|
|
665
|
+
const intervalMs = Math.min(
|
|
666
|
+
1000,
|
|
667
|
+
Math.max(100, Math.floor(staleAfterMs / 2))
|
|
668
|
+
);
|
|
669
|
+
const timer = setInterval(() => {
|
|
670
|
+
setStaleClock(Date.now());
|
|
671
|
+
}, intervalMs);
|
|
672
|
+
|
|
673
|
+
return () => clearInterval(timer);
|
|
674
|
+
}, [staleAfterMs]);
|
|
675
|
+
|
|
676
|
+
return useMemo<SyncStatus>(() => {
|
|
677
|
+
const now = staleAfterMs !== undefined ? staleClock : Date.now();
|
|
678
|
+
const lastSyncAgeMs =
|
|
679
|
+
state.lastSyncAt === null ? null : Math.max(0, now - state.lastSyncAt);
|
|
680
|
+
const isStale =
|
|
681
|
+
staleAfterMs !== undefined && staleAfterMs > 0
|
|
682
|
+
? state.lastSyncAt === null || (lastSyncAgeMs ?? 0) > staleAfterMs
|
|
683
|
+
: false;
|
|
684
|
+
|
|
685
|
+
return {
|
|
531
686
|
enabled: state.enabled,
|
|
532
687
|
isOnline: state.connectionState === 'connected',
|
|
533
688
|
isSyncing: state.isSyncing,
|
|
534
689
|
lastSyncAt: state.lastSyncAt,
|
|
690
|
+
lastSyncAgeMs,
|
|
691
|
+
isStale,
|
|
535
692
|
pendingCount: state.pendingCount,
|
|
536
693
|
error: state.error,
|
|
537
694
|
isRetrying: state.isRetrying,
|
|
538
695
|
retryCount: state.retryCount,
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
);
|
|
696
|
+
};
|
|
697
|
+
}, [state, staleAfterMs, staleClock]);
|
|
542
698
|
}
|
|
543
699
|
|
|
544
700
|
function useSyncConnection(): UseSyncConnectionResult {
|
|
@@ -571,6 +727,220 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
571
727
|
);
|
|
572
728
|
}
|
|
573
729
|
|
|
730
|
+
function useTransportHealth(): UseTransportHealthResult {
|
|
731
|
+
const engine = useEngine();
|
|
732
|
+
|
|
733
|
+
const health = useSyncExternalStore(
|
|
734
|
+
useCallback(
|
|
735
|
+
(callback) => {
|
|
736
|
+
const unsubscribers = [
|
|
737
|
+
engine.subscribe(callback),
|
|
738
|
+
engine.on('connection:change', callback),
|
|
739
|
+
engine.on('sync:complete', callback),
|
|
740
|
+
engine.on('sync:error', callback),
|
|
741
|
+
];
|
|
742
|
+
return () => {
|
|
743
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
744
|
+
};
|
|
745
|
+
},
|
|
746
|
+
[engine]
|
|
747
|
+
),
|
|
748
|
+
useCallback(() => engine.getTransportHealth(), [engine]),
|
|
749
|
+
useCallback(() => engine.getTransportHealth(), [engine])
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
return useMemo(() => ({ health }), [health]);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function useSyncProgress(
|
|
756
|
+
options: UseSyncProgressOptions = {}
|
|
757
|
+
): UseSyncProgressResult {
|
|
758
|
+
const engine = useEngine();
|
|
759
|
+
const { pollIntervalMs = 500 } = options;
|
|
760
|
+
|
|
761
|
+
const [progress, setProgress] = useState<SyncProgress | null>(null);
|
|
762
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
763
|
+
const [error, setError] = useState<Error | null>(null);
|
|
764
|
+
const loadedRef = useRef(false);
|
|
765
|
+
|
|
766
|
+
const refresh = useCallback(async () => {
|
|
767
|
+
if (!loadedRef.current) {
|
|
768
|
+
setIsLoading(true);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
const next = await engine.getProgress();
|
|
773
|
+
setProgress(next);
|
|
774
|
+
setError(null);
|
|
775
|
+
} catch (err) {
|
|
776
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
777
|
+
} finally {
|
|
778
|
+
loadedRef.current = true;
|
|
779
|
+
setIsLoading(false);
|
|
780
|
+
}
|
|
781
|
+
}, [engine]);
|
|
782
|
+
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
void refresh();
|
|
785
|
+
}, [refresh]);
|
|
786
|
+
|
|
787
|
+
useEffect(() => {
|
|
788
|
+
const unsubscribers = [
|
|
789
|
+
engine.on('sync:start', refresh),
|
|
790
|
+
engine.on('sync:complete', refresh),
|
|
791
|
+
engine.on('sync:error', refresh),
|
|
792
|
+
engine.on('bootstrap:start', refresh),
|
|
793
|
+
engine.on('bootstrap:progress', refresh),
|
|
794
|
+
engine.on('bootstrap:complete', refresh),
|
|
795
|
+
];
|
|
796
|
+
return () => {
|
|
797
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
798
|
+
};
|
|
799
|
+
}, [engine, refresh]);
|
|
800
|
+
|
|
801
|
+
useEffect(() => {
|
|
802
|
+
if (pollIntervalMs <= 0) return;
|
|
803
|
+
if (progress?.channelPhase !== 'bootstrapping') return;
|
|
804
|
+
|
|
805
|
+
const timer = setInterval(() => {
|
|
806
|
+
void refresh();
|
|
807
|
+
}, pollIntervalMs);
|
|
808
|
+
|
|
809
|
+
return () => clearInterval(timer);
|
|
810
|
+
}, [pollIntervalMs, progress?.channelPhase, refresh]);
|
|
811
|
+
|
|
812
|
+
return useMemo(
|
|
813
|
+
() => ({
|
|
814
|
+
progress,
|
|
815
|
+
isLoading,
|
|
816
|
+
error,
|
|
817
|
+
refresh,
|
|
818
|
+
}),
|
|
819
|
+
[progress, isLoading, error, refresh]
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function useSyncSubscriptions(
|
|
824
|
+
options: UseSyncSubscriptionsOptions = {}
|
|
825
|
+
): UseSyncSubscriptionsResult {
|
|
826
|
+
const engine = useEngine();
|
|
827
|
+
const { stateId, table, status } = options;
|
|
828
|
+
|
|
829
|
+
const [subscriptions, setSubscriptions] = useState<SubscriptionState[]>([]);
|
|
830
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
831
|
+
const [error, setError] = useState<Error | null>(null);
|
|
832
|
+
const loadedRef = useRef(false);
|
|
833
|
+
|
|
834
|
+
const refresh = useCallback(async () => {
|
|
835
|
+
if (!loadedRef.current) {
|
|
836
|
+
setIsLoading(true);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
const next = await engine.listSubscriptionStates({
|
|
841
|
+
stateId,
|
|
842
|
+
table,
|
|
843
|
+
status,
|
|
844
|
+
});
|
|
845
|
+
setSubscriptions(next);
|
|
846
|
+
setError(null);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
849
|
+
} finally {
|
|
850
|
+
loadedRef.current = true;
|
|
851
|
+
setIsLoading(false);
|
|
852
|
+
}
|
|
853
|
+
}, [engine, stateId, table, status]);
|
|
854
|
+
|
|
855
|
+
useEffect(() => {
|
|
856
|
+
void refresh();
|
|
857
|
+
}, [refresh]);
|
|
858
|
+
|
|
859
|
+
useEffect(() => {
|
|
860
|
+
const unsubscribers = [
|
|
861
|
+
engine.on('sync:complete', refresh),
|
|
862
|
+
engine.on('sync:error', refresh),
|
|
863
|
+
engine.on('bootstrap:start', refresh),
|
|
864
|
+
engine.on('bootstrap:progress', refresh),
|
|
865
|
+
engine.on('bootstrap:complete', refresh),
|
|
866
|
+
];
|
|
867
|
+
return () => {
|
|
868
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
869
|
+
};
|
|
870
|
+
}, [engine, refresh]);
|
|
871
|
+
|
|
872
|
+
return useMemo(
|
|
873
|
+
() => ({
|
|
874
|
+
subscriptions,
|
|
875
|
+
isLoading,
|
|
876
|
+
error,
|
|
877
|
+
refresh,
|
|
878
|
+
}),
|
|
879
|
+
[subscriptions, isLoading, error, refresh]
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function useSyncSubscription(
|
|
884
|
+
subscriptionId: string,
|
|
885
|
+
options: { stateId?: string } = {}
|
|
886
|
+
): UseSyncSubscriptionResult {
|
|
887
|
+
const engine = useEngine();
|
|
888
|
+
const { stateId } = options;
|
|
889
|
+
|
|
890
|
+
const [subscription, setSubscription] = useState<SubscriptionState | null>(
|
|
891
|
+
null
|
|
892
|
+
);
|
|
893
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
894
|
+
const [error, setError] = useState<Error | null>(null);
|
|
895
|
+
const loadedRef = useRef(false);
|
|
896
|
+
|
|
897
|
+
const refresh = useCallback(async () => {
|
|
898
|
+
if (!loadedRef.current) {
|
|
899
|
+
setIsLoading(true);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
try {
|
|
903
|
+
const next = await engine.getSubscriptionState(subscriptionId, {
|
|
904
|
+
stateId,
|
|
905
|
+
});
|
|
906
|
+
setSubscription(next);
|
|
907
|
+
setError(null);
|
|
908
|
+
} catch (err) {
|
|
909
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
910
|
+
} finally {
|
|
911
|
+
loadedRef.current = true;
|
|
912
|
+
setIsLoading(false);
|
|
913
|
+
}
|
|
914
|
+
}, [engine, stateId, subscriptionId]);
|
|
915
|
+
|
|
916
|
+
useEffect(() => {
|
|
917
|
+
void refresh();
|
|
918
|
+
}, [refresh]);
|
|
919
|
+
|
|
920
|
+
useEffect(() => {
|
|
921
|
+
const unsubscribers = [
|
|
922
|
+
engine.on('sync:complete', refresh),
|
|
923
|
+
engine.on('sync:error', refresh),
|
|
924
|
+
engine.on('bootstrap:start', refresh),
|
|
925
|
+
engine.on('bootstrap:progress', refresh),
|
|
926
|
+
engine.on('bootstrap:complete', refresh),
|
|
927
|
+
];
|
|
928
|
+
return () => {
|
|
929
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
930
|
+
};
|
|
931
|
+
}, [engine, refresh]);
|
|
932
|
+
|
|
933
|
+
return useMemo(
|
|
934
|
+
() => ({
|
|
935
|
+
subscription,
|
|
936
|
+
isLoading,
|
|
937
|
+
error,
|
|
938
|
+
refresh,
|
|
939
|
+
}),
|
|
940
|
+
[subscription, isLoading, error, refresh]
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
574
944
|
function useConflicts(): UseConflictsResult {
|
|
575
945
|
const engine = useEngine();
|
|
576
946
|
|
|
@@ -694,9 +1064,17 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
694
1064
|
) => ExecutableQuery<TResult> | Promise<TResult>,
|
|
695
1065
|
options: UseSyncQueryOptions = {}
|
|
696
1066
|
): UseSyncQueryResult<TResult> {
|
|
697
|
-
const {
|
|
1067
|
+
const {
|
|
1068
|
+
enabled = true,
|
|
1069
|
+
deps = [],
|
|
1070
|
+
keyField = 'id',
|
|
1071
|
+
watchTables = [],
|
|
1072
|
+
pollIntervalMs,
|
|
1073
|
+
staleAfterMs,
|
|
1074
|
+
} = options;
|
|
698
1075
|
const { db } = useSyncContext();
|
|
699
1076
|
const engine = useEngine();
|
|
1077
|
+
const watchTablesSet = useMemo(() => new Set(watchTables), [watchTables]);
|
|
700
1078
|
|
|
701
1079
|
const queryFnRef = useRef<typeof queryFn>(queryFn);
|
|
702
1080
|
queryFnRef.current = queryFn;
|
|
@@ -704,6 +1082,10 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
704
1082
|
const [data, setData] = useState<TResult | undefined>(undefined);
|
|
705
1083
|
const [isLoading, setIsLoading] = useState(true);
|
|
706
1084
|
const [error, setError] = useState<Error | null>(null);
|
|
1085
|
+
const [lastSyncAt, setLastSyncAt] = useState<number | null>(
|
|
1086
|
+
() => engine.getState().lastSyncAt
|
|
1087
|
+
);
|
|
1088
|
+
const [staleClock, setStaleClock] = useState<number>(Date.now());
|
|
707
1089
|
|
|
708
1090
|
const versionRef = useRef(0);
|
|
709
1091
|
const watchedScopesRef = useRef<Set<string>>(new Set());
|
|
@@ -717,6 +1099,7 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
717
1099
|
previousFingerprintRef.current = 'disabled';
|
|
718
1100
|
setData(undefined);
|
|
719
1101
|
}
|
|
1102
|
+
setLastSyncAt(engine.getState().lastSyncAt);
|
|
720
1103
|
setIsLoading(false);
|
|
721
1104
|
hasLoadedRef.current = true;
|
|
722
1105
|
return;
|
|
@@ -746,6 +1129,7 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
746
1129
|
|
|
747
1130
|
if (version === versionRef.current) {
|
|
748
1131
|
watchedScopesRef.current = scopeCollector;
|
|
1132
|
+
setLastSyncAt(engine.getState().lastSyncAt);
|
|
749
1133
|
|
|
750
1134
|
const fingerprint = fingerprintCollectorRef.current.getCombined();
|
|
751
1135
|
if (
|
|
@@ -774,10 +1158,20 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
774
1158
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
775
1159
|
}, [executeQuery, ...deps]);
|
|
776
1160
|
|
|
1161
|
+
useEffect(() => {
|
|
1162
|
+
const unsubscribe = engine.subscribe(() => {
|
|
1163
|
+
const nextLastSyncAt = engine.getState().lastSyncAt;
|
|
1164
|
+
setLastSyncAt((previous) =>
|
|
1165
|
+
previous === nextLastSyncAt ? previous : nextLastSyncAt
|
|
1166
|
+
);
|
|
1167
|
+
});
|
|
1168
|
+
return unsubscribe;
|
|
1169
|
+
}, [engine]);
|
|
1170
|
+
|
|
777
1171
|
useEffect(() => {
|
|
778
1172
|
if (!enabled) return;
|
|
779
1173
|
const unsubscribe = engine.on('sync:complete', () => {
|
|
780
|
-
executeQuery();
|
|
1174
|
+
void executeQuery();
|
|
781
1175
|
});
|
|
782
1176
|
return unsubscribe;
|
|
783
1177
|
}, [engine, enabled, executeQuery]);
|
|
@@ -786,35 +1180,75 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
786
1180
|
if (!enabled) return;
|
|
787
1181
|
|
|
788
1182
|
const unsubscribe = engine.on('data:change', (event) => {
|
|
789
|
-
const changedScopes = event.scopes
|
|
1183
|
+
const changedScopes = event.scopes ?? [];
|
|
790
1184
|
const watchedScopes = watchedScopesRef.current;
|
|
1185
|
+
const hasDynamicFilter = watchedScopes.size > 0;
|
|
1186
|
+
const hasTableFilter = watchTablesSet.size > 0;
|
|
791
1187
|
|
|
792
|
-
if (
|
|
793
|
-
const
|
|
794
|
-
watchedScopes.has(
|
|
1188
|
+
if (hasDynamicFilter || hasTableFilter) {
|
|
1189
|
+
const matchesDynamic = changedScopes.some((scope) =>
|
|
1190
|
+
watchedScopes.has(scope)
|
|
795
1191
|
);
|
|
796
|
-
|
|
1192
|
+
const matchesConfigured = changedScopes.some((scope) =>
|
|
1193
|
+
watchTablesSet.has(scope)
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
if (!matchesDynamic && !matchesConfigured) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
797
1199
|
}
|
|
798
1200
|
|
|
799
|
-
executeQuery();
|
|
1201
|
+
void executeQuery();
|
|
800
1202
|
});
|
|
801
1203
|
|
|
802
1204
|
return unsubscribe;
|
|
803
|
-
}, [engine, enabled, executeQuery]);
|
|
1205
|
+
}, [engine, enabled, executeQuery, watchTablesSet]);
|
|
1206
|
+
|
|
1207
|
+
useEffect(() => {
|
|
1208
|
+
if (!enabled) return;
|
|
1209
|
+
if (pollIntervalMs === undefined || pollIntervalMs <= 0) return;
|
|
1210
|
+
|
|
1211
|
+
const timer = setInterval(() => {
|
|
1212
|
+
void executeQuery();
|
|
1213
|
+
}, pollIntervalMs);
|
|
1214
|
+
|
|
1215
|
+
return () => clearInterval(timer);
|
|
1216
|
+
}, [enabled, pollIntervalMs, executeQuery]);
|
|
1217
|
+
|
|
1218
|
+
useEffect(() => {
|
|
1219
|
+
if (staleAfterMs === undefined || staleAfterMs <= 0) return;
|
|
1220
|
+
|
|
1221
|
+
const intervalMs = Math.min(
|
|
1222
|
+
1000,
|
|
1223
|
+
Math.max(100, Math.floor(staleAfterMs / 2))
|
|
1224
|
+
);
|
|
1225
|
+
const timer = setInterval(() => {
|
|
1226
|
+
setStaleClock(Date.now());
|
|
1227
|
+
}, intervalMs);
|
|
1228
|
+
|
|
1229
|
+
return () => clearInterval(timer);
|
|
1230
|
+
}, [staleAfterMs]);
|
|
804
1231
|
|
|
805
1232
|
const refetch = useCallback(async () => {
|
|
806
1233
|
await executeQuery();
|
|
807
1234
|
}, [executeQuery]);
|
|
808
1235
|
|
|
809
|
-
return useMemo(
|
|
810
|
-
|
|
1236
|
+
return useMemo(() => {
|
|
1237
|
+
const now = staleAfterMs !== undefined ? staleClock : Date.now();
|
|
1238
|
+
const isStale =
|
|
1239
|
+
staleAfterMs !== undefined && staleAfterMs > 0
|
|
1240
|
+
? lastSyncAt === null || now - lastSyncAt > staleAfterMs
|
|
1241
|
+
: false;
|
|
1242
|
+
|
|
1243
|
+
return {
|
|
811
1244
|
data,
|
|
812
1245
|
isLoading,
|
|
813
1246
|
error,
|
|
1247
|
+
isStale,
|
|
1248
|
+
lastSyncAt,
|
|
814
1249
|
refetch,
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
);
|
|
1250
|
+
};
|
|
1251
|
+
}, [data, isLoading, error, staleAfterMs, staleClock, lastSyncAt, refetch]);
|
|
818
1252
|
}
|
|
819
1253
|
|
|
820
1254
|
function useQuery<TResult>(
|
|
@@ -1382,6 +1816,10 @@ export function createSyncularReact<DB extends SyncClientDb>() {
|
|
|
1382
1816
|
useSyncEngine,
|
|
1383
1817
|
useSyncStatus,
|
|
1384
1818
|
useSyncConnection,
|
|
1819
|
+
useTransportHealth,
|
|
1820
|
+
useSyncProgress,
|
|
1821
|
+
useSyncSubscriptions,
|
|
1822
|
+
useSyncSubscription,
|
|
1385
1823
|
useSyncQuery,
|
|
1386
1824
|
useQuery,
|
|
1387
1825
|
useMutation,
|
package/src/index.ts
CHANGED
|
@@ -29,8 +29,22 @@ export type {
|
|
|
29
29
|
UseResolveConflictResult,
|
|
30
30
|
UseSyncConnectionResult,
|
|
31
31
|
UseSyncEngineResult,
|
|
32
|
+
UseSyncProgressOptions,
|
|
33
|
+
UseSyncProgressResult,
|
|
32
34
|
UseSyncQueryOptions,
|
|
33
35
|
UseSyncQueryResult,
|
|
36
|
+
UseSyncStatusOptions,
|
|
37
|
+
UseSyncSubscriptionResult,
|
|
38
|
+
UseSyncSubscriptionsOptions,
|
|
39
|
+
UseSyncSubscriptionsResult,
|
|
40
|
+
UseTransportHealthResult,
|
|
34
41
|
} from './createSyncularReact';
|
|
35
42
|
// Re-export core client types for convenience.
|
|
36
43
|
export { createSyncularReact } from './createSyncularReact';
|
|
44
|
+
export type {
|
|
45
|
+
SyncGroupChannel,
|
|
46
|
+
SyncGroupChannelSnapshot,
|
|
47
|
+
SyncGroupStatus,
|
|
48
|
+
UseSyncGroupResult,
|
|
49
|
+
} from './useSyncGroup';
|
|
50
|
+
export { useSyncGroup } from './useSyncGroup';
|