@syncular/client-react 0.0.2-143 → 0.0.3-12
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 +89 -2
- package/dist/createSyncularReact.d.ts.map +1 -1
- package/dist/createSyncularReact.js +346 -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 +152 -0
- package/src/createSyncularReact.tsx +558 -20
- package/src/index.ts +16 -0
- package/src/useSyncGroup.ts +169 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
793
|
-
const
|
|
794
|
-
watchedScopes.has(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|