@syncular/client-react 0.0.1-60
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 +222 -0
- package/dist/createSyncularReact.d.ts.map +1 -0
- package/dist/createSyncularReact.js +775 -0
- package/dist/createSyncularReact.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
- package/src/__tests__/SyncEngine.test.ts +1332 -0
- package/src/__tests__/SyncProvider.strictmode.test.tsx +117 -0
- package/src/__tests__/fingerprint.test.ts +181 -0
- package/src/__tests__/hooks/useMutation.test.tsx +468 -0
- package/src/__tests__/hooks.test.tsx +384 -0
- package/src/__tests__/integration/conflict-resolution.test.ts +439 -0
- package/src/__tests__/integration/provider-reconfig.test.ts +279 -0
- package/src/__tests__/integration/push-flow.test.ts +321 -0
- package/src/__tests__/integration/realtime-sync.test.ts +222 -0
- package/src/__tests__/integration/self-conflict.test.ts +91 -0
- package/src/__tests__/integration/test-setup.ts +550 -0
- package/src/__tests__/integration/two-client-sync.test.ts +373 -0
- package/src/__tests__/setup.ts +7 -0
- package/src/__tests__/test-utils.ts +199 -0
- package/src/__tests__/useMutations.test.tsx +198 -0
- package/src/createSyncularReact.tsx +1346 -0
- package/src/index.ts +36 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createMutationsApi, createOutboxCommit, createQueryContext, enqueueOutboxCommit, FingerprintCollector, resolveConflict as resolveConflictDb, SyncEngine, } from '@syncular/client';
|
|
3
|
+
import { sql } from 'kysely';
|
|
4
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function isExecutableQuery(value) {
|
|
9
|
+
if (!isRecord(value))
|
|
10
|
+
return false;
|
|
11
|
+
return typeof value.execute === 'function';
|
|
12
|
+
}
|
|
13
|
+
export function createSyncularReact() {
|
|
14
|
+
const SyncContext = createContext(null);
|
|
15
|
+
function SyncProvider({ db, transport, shapes, actorId, clientId, subscriptions = [], limitCommits, limitSnapshotRows, maxSnapshotPages, stateId, pollIntervalMs, maxRetries, migrate, onMigrationError, realtimeEnabled, realtimeFallbackPollMs, onError, onConflict, onDataChange, plugins, autoStart = true, renderWhileStarting = true, children, }) {
|
|
16
|
+
const config = useMemo(() => ({
|
|
17
|
+
db,
|
|
18
|
+
transport,
|
|
19
|
+
shapes,
|
|
20
|
+
actorId,
|
|
21
|
+
clientId,
|
|
22
|
+
subscriptions,
|
|
23
|
+
limitCommits,
|
|
24
|
+
limitSnapshotRows,
|
|
25
|
+
maxSnapshotPages,
|
|
26
|
+
stateId,
|
|
27
|
+
pollIntervalMs,
|
|
28
|
+
maxRetries,
|
|
29
|
+
migrate,
|
|
30
|
+
onMigrationError,
|
|
31
|
+
realtimeEnabled,
|
|
32
|
+
realtimeFallbackPollMs,
|
|
33
|
+
onError,
|
|
34
|
+
onConflict,
|
|
35
|
+
onDataChange,
|
|
36
|
+
plugins,
|
|
37
|
+
}), [
|
|
38
|
+
db,
|
|
39
|
+
transport,
|
|
40
|
+
shapes,
|
|
41
|
+
actorId,
|
|
42
|
+
clientId,
|
|
43
|
+
subscriptions,
|
|
44
|
+
limitCommits,
|
|
45
|
+
limitSnapshotRows,
|
|
46
|
+
maxSnapshotPages,
|
|
47
|
+
stateId,
|
|
48
|
+
pollIntervalMs,
|
|
49
|
+
maxRetries,
|
|
50
|
+
migrate,
|
|
51
|
+
onMigrationError,
|
|
52
|
+
realtimeEnabled,
|
|
53
|
+
realtimeFallbackPollMs,
|
|
54
|
+
onError,
|
|
55
|
+
onConflict,
|
|
56
|
+
onDataChange,
|
|
57
|
+
plugins,
|
|
58
|
+
]);
|
|
59
|
+
const [engine] = useState(() => new SyncEngine(config));
|
|
60
|
+
const [initialProps] = useState(() => ({
|
|
61
|
+
actorId,
|
|
62
|
+
clientId,
|
|
63
|
+
db,
|
|
64
|
+
transport,
|
|
65
|
+
shapes,
|
|
66
|
+
}));
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const changedProps = [];
|
|
69
|
+
if (actorId !== initialProps.actorId)
|
|
70
|
+
changedProps.push('actorId');
|
|
71
|
+
if (clientId !== initialProps.clientId)
|
|
72
|
+
changedProps.push('clientId');
|
|
73
|
+
if (db !== initialProps.db)
|
|
74
|
+
changedProps.push('db');
|
|
75
|
+
if (transport !== initialProps.transport)
|
|
76
|
+
changedProps.push('transport');
|
|
77
|
+
if (shapes !== initialProps.shapes)
|
|
78
|
+
changedProps.push('shapes');
|
|
79
|
+
if (changedProps.length > 0) {
|
|
80
|
+
const message = `[SyncProvider] Critical props changed after mount: ${changedProps.join(', ')}. ` +
|
|
81
|
+
'This is not supported and may cause undefined behavior. ' +
|
|
82
|
+
'Use a React key prop to force remount, e.g., ' +
|
|
83
|
+
`<SyncProvider key={userId} ...> or <SyncProvider key={actorId + ':' + clientId} ...>`;
|
|
84
|
+
console.error(message);
|
|
85
|
+
if (process.env.NODE_ENV === 'development') {
|
|
86
|
+
console.warn('[SyncProvider] In development, consider using React StrictMode ' +
|
|
87
|
+
'to help detect these issues early.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}, [actorId, clientId, db, transport, shapes, initialProps]);
|
|
91
|
+
const [isReady, setIsReady] = useState(false);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
let cancelled = false;
|
|
94
|
+
if (!autoStart) {
|
|
95
|
+
setIsReady(true);
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
engine.stop();
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
engine
|
|
102
|
+
.start()
|
|
103
|
+
.then(() => {
|
|
104
|
+
if (!cancelled)
|
|
105
|
+
setIsReady(true);
|
|
106
|
+
})
|
|
107
|
+
.catch((err) => {
|
|
108
|
+
console.error('[SyncProvider] Engine start failed:', err);
|
|
109
|
+
if (!cancelled)
|
|
110
|
+
setIsReady(true);
|
|
111
|
+
});
|
|
112
|
+
return () => {
|
|
113
|
+
cancelled = true;
|
|
114
|
+
engine.stop();
|
|
115
|
+
};
|
|
116
|
+
}, [engine, autoStart]);
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (isReady && subscriptions.length > 0) {
|
|
119
|
+
engine.updateSubscriptions(subscriptions);
|
|
120
|
+
}
|
|
121
|
+
}, [engine, isReady, subscriptions]);
|
|
122
|
+
const value = useMemo(() => ({
|
|
123
|
+
engine,
|
|
124
|
+
db,
|
|
125
|
+
transport,
|
|
126
|
+
shapes,
|
|
127
|
+
}), [engine, db, transport, shapes]);
|
|
128
|
+
if (!isReady && renderWhileStarting === false) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return (_jsx(SyncContext.Provider, { value: value, children: children }));
|
|
132
|
+
}
|
|
133
|
+
function useSyncContext() {
|
|
134
|
+
const context = useContext(SyncContext);
|
|
135
|
+
if (!context) {
|
|
136
|
+
throw new Error('useSyncContext must be used within a SyncProvider');
|
|
137
|
+
}
|
|
138
|
+
return context;
|
|
139
|
+
}
|
|
140
|
+
function useEngine() {
|
|
141
|
+
return useSyncContext().engine;
|
|
142
|
+
}
|
|
143
|
+
function useSyncEngine() {
|
|
144
|
+
const engine = useEngine();
|
|
145
|
+
const state = useSyncExternalStore(useCallback((callback) => engine.subscribe(callback), [engine]), useCallback(() => engine.getState(), [engine]), useCallback(() => engine.getState(), [engine]));
|
|
146
|
+
const sync = useCallback(() => engine.sync(), [engine]);
|
|
147
|
+
const reconnect = useCallback(() => engine.reconnect(), [engine]);
|
|
148
|
+
const disconnect = useCallback(() => engine.disconnect(), [engine]);
|
|
149
|
+
const start = useCallback(() => engine.start(), [engine]);
|
|
150
|
+
const resetLocalState = useCallback(() => engine.resetLocalState(), [engine]);
|
|
151
|
+
return {
|
|
152
|
+
state,
|
|
153
|
+
sync,
|
|
154
|
+
reconnect,
|
|
155
|
+
disconnect,
|
|
156
|
+
start,
|
|
157
|
+
resetLocalState,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function useSyncStatus() {
|
|
161
|
+
const engine = useEngine();
|
|
162
|
+
const state = useSyncExternalStore(useCallback((callback) => engine.subscribe(callback), [engine]), useCallback(() => engine.getState(), [engine]), useCallback(() => engine.getState(), [engine]));
|
|
163
|
+
return useMemo(() => ({
|
|
164
|
+
enabled: state.enabled,
|
|
165
|
+
isOnline: state.connectionState === 'connected',
|
|
166
|
+
isSyncing: state.isSyncing,
|
|
167
|
+
lastSyncAt: state.lastSyncAt,
|
|
168
|
+
pendingCount: state.pendingCount,
|
|
169
|
+
error: state.error,
|
|
170
|
+
isRetrying: state.isRetrying,
|
|
171
|
+
retryCount: state.retryCount,
|
|
172
|
+
}), [state]);
|
|
173
|
+
}
|
|
174
|
+
function useSyncConnection() {
|
|
175
|
+
const engine = useEngine();
|
|
176
|
+
const engineState = useSyncExternalStore(useCallback((callback) => engine.subscribe(callback), [engine]), useCallback(() => engine.getState(), [engine]), useCallback(() => engine.getState(), [engine]));
|
|
177
|
+
const reconnect = useCallback(() => engine.reconnect(), [engine]);
|
|
178
|
+
const disconnect = useCallback(() => engine.disconnect(), [engine]);
|
|
179
|
+
return useMemo(() => ({
|
|
180
|
+
state: engineState.connectionState,
|
|
181
|
+
mode: engineState.transportMode,
|
|
182
|
+
isConnected: engineState.connectionState === 'connected',
|
|
183
|
+
isReconnecting: engineState.connectionState === 'reconnecting',
|
|
184
|
+
reconnect,
|
|
185
|
+
disconnect,
|
|
186
|
+
}), [
|
|
187
|
+
engineState.connectionState,
|
|
188
|
+
engineState.transportMode,
|
|
189
|
+
reconnect,
|
|
190
|
+
disconnect,
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
function useConflicts() {
|
|
194
|
+
const engine = useEngine();
|
|
195
|
+
const [conflicts, setConflicts] = useState([]);
|
|
196
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
197
|
+
const refresh = useCallback(async () => {
|
|
198
|
+
try {
|
|
199
|
+
setIsLoading(true);
|
|
200
|
+
const result = await engine.getConflicts();
|
|
201
|
+
setConflicts(result);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
console.error('[useConflicts] Failed to refresh:', err);
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
setIsLoading(false);
|
|
208
|
+
}
|
|
209
|
+
}, [engine]);
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
refresh();
|
|
212
|
+
}, [refresh]);
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
const unsubscribe = engine.on('sync:complete', () => {
|
|
215
|
+
refresh();
|
|
216
|
+
});
|
|
217
|
+
return unsubscribe;
|
|
218
|
+
}, [engine, refresh]);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
const unsubscribe = engine.on('sync:error', () => {
|
|
221
|
+
refresh();
|
|
222
|
+
});
|
|
223
|
+
return unsubscribe;
|
|
224
|
+
}, [engine, refresh]);
|
|
225
|
+
return useMemo(() => ({
|
|
226
|
+
conflicts,
|
|
227
|
+
count: conflicts.length,
|
|
228
|
+
hasConflicts: conflicts.length > 0,
|
|
229
|
+
isLoading,
|
|
230
|
+
refresh,
|
|
231
|
+
}), [conflicts, isLoading, refresh]);
|
|
232
|
+
}
|
|
233
|
+
function useResolveConflict(options = {}) {
|
|
234
|
+
const { onSuccess, onError, syncAfterResolve = true } = options;
|
|
235
|
+
const { db } = useSyncContext();
|
|
236
|
+
const engine = useEngine();
|
|
237
|
+
const [isPending, setIsPending] = useState(false);
|
|
238
|
+
const [error, setError] = useState(null);
|
|
239
|
+
const resolve = useCallback(async (conflictId, resolution, mergedData) => {
|
|
240
|
+
setIsPending(true);
|
|
241
|
+
setError(null);
|
|
242
|
+
try {
|
|
243
|
+
let resolutionStr;
|
|
244
|
+
if (resolution === 'merge' && mergedData) {
|
|
245
|
+
resolutionStr = `merge:${JSON.stringify(mergedData)}`;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
resolutionStr = resolution;
|
|
249
|
+
}
|
|
250
|
+
await resolveConflictDb(db, {
|
|
251
|
+
id: conflictId,
|
|
252
|
+
resolution: resolutionStr,
|
|
253
|
+
});
|
|
254
|
+
onSuccess?.(conflictId);
|
|
255
|
+
if (syncAfterResolve) {
|
|
256
|
+
engine.sync().catch((err) => {
|
|
257
|
+
console.warn('[useResolveConflict] Sync after resolve failed:', err);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
263
|
+
setError(e);
|
|
264
|
+
onError?.(e);
|
|
265
|
+
throw e;
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
setIsPending(false);
|
|
269
|
+
}
|
|
270
|
+
}, [db, engine, syncAfterResolve, onSuccess, onError]);
|
|
271
|
+
const reset = useCallback(() => {
|
|
272
|
+
setError(null);
|
|
273
|
+
}, []);
|
|
274
|
+
return useMemo(() => ({
|
|
275
|
+
resolve,
|
|
276
|
+
isPending,
|
|
277
|
+
error,
|
|
278
|
+
reset,
|
|
279
|
+
}), [resolve, isPending, error, reset]);
|
|
280
|
+
}
|
|
281
|
+
function useSyncQuery(queryFn, options = {}) {
|
|
282
|
+
const { enabled = true, deps = [], keyField = 'id' } = options;
|
|
283
|
+
const { db } = useSyncContext();
|
|
284
|
+
const engine = useEngine();
|
|
285
|
+
const queryFnRef = useRef(queryFn);
|
|
286
|
+
queryFnRef.current = queryFn;
|
|
287
|
+
const [data, setData] = useState(undefined);
|
|
288
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
289
|
+
const [error, setError] = useState(null);
|
|
290
|
+
const versionRef = useRef(0);
|
|
291
|
+
const watchedScopesRef = useRef(new Set());
|
|
292
|
+
const fingerprintCollectorRef = useRef(new FingerprintCollector());
|
|
293
|
+
const previousFingerprintRef = useRef('');
|
|
294
|
+
const hasLoadedRef = useRef(false);
|
|
295
|
+
const executeQuery = useCallback(async () => {
|
|
296
|
+
if (!enabled) {
|
|
297
|
+
if (previousFingerprintRef.current !== 'disabled') {
|
|
298
|
+
previousFingerprintRef.current = 'disabled';
|
|
299
|
+
setData(undefined);
|
|
300
|
+
}
|
|
301
|
+
setIsLoading(false);
|
|
302
|
+
hasLoadedRef.current = true;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const version = ++versionRef.current;
|
|
306
|
+
try {
|
|
307
|
+
if (!hasLoadedRef.current) {
|
|
308
|
+
setIsLoading(true);
|
|
309
|
+
}
|
|
310
|
+
fingerprintCollectorRef.current.clear();
|
|
311
|
+
const scopeCollector = new Set();
|
|
312
|
+
const ctx = createQueryContext(db, scopeCollector, fingerprintCollectorRef.current, engine, keyField);
|
|
313
|
+
const fnResult = queryFnRef.current(ctx);
|
|
314
|
+
const result = isExecutableQuery(fnResult)
|
|
315
|
+
? await fnResult.execute()
|
|
316
|
+
: await fnResult;
|
|
317
|
+
if (version === versionRef.current) {
|
|
318
|
+
watchedScopesRef.current = scopeCollector;
|
|
319
|
+
const fingerprint = fingerprintCollectorRef.current.getCombined();
|
|
320
|
+
if (fingerprint !== previousFingerprintRef.current ||
|
|
321
|
+
fingerprint === '') {
|
|
322
|
+
previousFingerprintRef.current = fingerprint;
|
|
323
|
+
setData(result);
|
|
324
|
+
}
|
|
325
|
+
setError(null);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
if (version === versionRef.current) {
|
|
330
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
if (version === versionRef.current) {
|
|
335
|
+
setIsLoading(false);
|
|
336
|
+
hasLoadedRef.current = true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}, [db, enabled, engine, keyField]);
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
executeQuery();
|
|
342
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
343
|
+
}, [executeQuery, ...deps]);
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (!enabled)
|
|
346
|
+
return;
|
|
347
|
+
const unsubscribe = engine.on('sync:complete', () => {
|
|
348
|
+
executeQuery();
|
|
349
|
+
});
|
|
350
|
+
return unsubscribe;
|
|
351
|
+
}, [engine, enabled, executeQuery]);
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (!enabled)
|
|
354
|
+
return;
|
|
355
|
+
const unsubscribe = engine.on('data:change', (event) => {
|
|
356
|
+
const changedScopes = event.scopes || [];
|
|
357
|
+
const watchedScopes = watchedScopesRef.current;
|
|
358
|
+
if (watchedScopes.size > 0) {
|
|
359
|
+
const hasWatchedScope = changedScopes.some((s) => watchedScopes.has(s));
|
|
360
|
+
if (!hasWatchedScope)
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
executeQuery();
|
|
364
|
+
});
|
|
365
|
+
return unsubscribe;
|
|
366
|
+
}, [engine, enabled, executeQuery]);
|
|
367
|
+
const refetch = useCallback(async () => {
|
|
368
|
+
await executeQuery();
|
|
369
|
+
}, [executeQuery]);
|
|
370
|
+
return useMemo(() => ({
|
|
371
|
+
data,
|
|
372
|
+
isLoading,
|
|
373
|
+
error,
|
|
374
|
+
refetch,
|
|
375
|
+
}), [data, isLoading, error, refetch]);
|
|
376
|
+
}
|
|
377
|
+
function useQuery(queryFn, options = {}) {
|
|
378
|
+
const { enabled = true, deps = [], keyField = 'id' } = options;
|
|
379
|
+
const { db } = useSyncContext();
|
|
380
|
+
const engine = useEngine();
|
|
381
|
+
const queryFnRef = useRef(queryFn);
|
|
382
|
+
queryFnRef.current = queryFn;
|
|
383
|
+
const [data, setData] = useState(undefined);
|
|
384
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
385
|
+
const [error, setError] = useState(null);
|
|
386
|
+
const versionRef = useRef(0);
|
|
387
|
+
const fingerprintCollectorRef = useRef(new FingerprintCollector());
|
|
388
|
+
const previousFingerprintRef = useRef('');
|
|
389
|
+
const executeQuery = useCallback(async () => {
|
|
390
|
+
if (!enabled) {
|
|
391
|
+
setData(undefined);
|
|
392
|
+
setIsLoading(false);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const version = ++versionRef.current;
|
|
396
|
+
try {
|
|
397
|
+
setIsLoading(true);
|
|
398
|
+
fingerprintCollectorRef.current.clear();
|
|
399
|
+
const scopeCollector = new Set();
|
|
400
|
+
const ctx = createQueryContext(db, scopeCollector, fingerprintCollectorRef.current, engine, keyField);
|
|
401
|
+
const fnResult = queryFnRef.current(ctx);
|
|
402
|
+
const result = isExecutableQuery(fnResult)
|
|
403
|
+
? await fnResult.execute()
|
|
404
|
+
: await fnResult;
|
|
405
|
+
if (version === versionRef.current) {
|
|
406
|
+
const fingerprint = fingerprintCollectorRef.current.getCombined();
|
|
407
|
+
if (fingerprint !== previousFingerprintRef.current ||
|
|
408
|
+
fingerprint === '') {
|
|
409
|
+
previousFingerprintRef.current = fingerprint;
|
|
410
|
+
setData(result);
|
|
411
|
+
}
|
|
412
|
+
setError(null);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
if (version === versionRef.current) {
|
|
417
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
if (version === versionRef.current) {
|
|
422
|
+
setIsLoading(false);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}, [db, enabled, engine, keyField]);
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
executeQuery();
|
|
428
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
429
|
+
}, [executeQuery, ...deps]);
|
|
430
|
+
const refetch = useCallback(async () => {
|
|
431
|
+
await executeQuery();
|
|
432
|
+
}, [executeQuery]);
|
|
433
|
+
return useMemo(() => ({
|
|
434
|
+
data,
|
|
435
|
+
isLoading,
|
|
436
|
+
error,
|
|
437
|
+
refetch,
|
|
438
|
+
}), [data, isLoading, error, refetch]);
|
|
439
|
+
}
|
|
440
|
+
function useMutation(options) {
|
|
441
|
+
const { table, syncImmediately = true, onSuccess, onError } = options;
|
|
442
|
+
const { db } = useSyncContext();
|
|
443
|
+
const engine = useEngine();
|
|
444
|
+
const [isPending, setIsPending] = useState(false);
|
|
445
|
+
const [error, setError] = useState(null);
|
|
446
|
+
const enqueue = useCallback(async (inputs) => {
|
|
447
|
+
setIsPending(true);
|
|
448
|
+
setError(null);
|
|
449
|
+
try {
|
|
450
|
+
for (const input of inputs) {
|
|
451
|
+
if (input.table !== undefined && input.table !== table) {
|
|
452
|
+
throw new Error(`[useMutation] MutationInput.table must match hook table "${table}" (got "${input.table}")`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const operations = inputs.map((input) => ({
|
|
456
|
+
scope: table,
|
|
457
|
+
table: table,
|
|
458
|
+
row_id: input.rowId,
|
|
459
|
+
op: input.op,
|
|
460
|
+
payload: input.op === 'delete' ? null : (input.payload ?? null),
|
|
461
|
+
base_version: input.baseVersion ?? null,
|
|
462
|
+
}));
|
|
463
|
+
const result = await enqueueOutboxCommit(db, { operations });
|
|
464
|
+
const mutationResult = {
|
|
465
|
+
commitId: result.id,
|
|
466
|
+
clientCommitId: result.clientCommitId,
|
|
467
|
+
};
|
|
468
|
+
await engine.applyLocalMutation(inputs.map((input) => ({
|
|
469
|
+
table,
|
|
470
|
+
rowId: input.rowId,
|
|
471
|
+
op: input.op,
|
|
472
|
+
payload: input.op === 'delete' ? null : (input.payload ?? null),
|
|
473
|
+
})));
|
|
474
|
+
onSuccess?.(mutationResult);
|
|
475
|
+
if (syncImmediately) {
|
|
476
|
+
engine.sync().catch((err) => {
|
|
477
|
+
console.warn('[useMutation] Background sync failed:', err);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return mutationResult;
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
484
|
+
setError(e);
|
|
485
|
+
onError?.(e);
|
|
486
|
+
throw e;
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
setIsPending(false);
|
|
490
|
+
}
|
|
491
|
+
}, [db, engine, table, syncImmediately, onSuccess, onError]);
|
|
492
|
+
const mutateLegacy = useCallback(async (input) => {
|
|
493
|
+
return enqueue([input]);
|
|
494
|
+
}, [enqueue]);
|
|
495
|
+
const upsert = useCallback(async (rowId, payload, opts) => {
|
|
496
|
+
return enqueue([
|
|
497
|
+
{
|
|
498
|
+
table,
|
|
499
|
+
rowId,
|
|
500
|
+
op: 'upsert',
|
|
501
|
+
payload,
|
|
502
|
+
baseVersion: opts?.baseVersion,
|
|
503
|
+
},
|
|
504
|
+
]);
|
|
505
|
+
}, [enqueue, table]);
|
|
506
|
+
const deleteRow = useCallback(async (rowId, opts) => {
|
|
507
|
+
return enqueue([
|
|
508
|
+
{
|
|
509
|
+
table,
|
|
510
|
+
rowId,
|
|
511
|
+
op: 'delete',
|
|
512
|
+
baseVersion: opts?.baseVersion,
|
|
513
|
+
},
|
|
514
|
+
]);
|
|
515
|
+
}, [enqueue, table]);
|
|
516
|
+
const mutate = useMemo(() => {
|
|
517
|
+
const fn = mutateLegacy;
|
|
518
|
+
fn.upsert = upsert;
|
|
519
|
+
fn.delete = deleteRow;
|
|
520
|
+
return fn;
|
|
521
|
+
}, [mutateLegacy, upsert, deleteRow]);
|
|
522
|
+
const reset = useCallback(() => {
|
|
523
|
+
setError(null);
|
|
524
|
+
}, []);
|
|
525
|
+
return useMemo(() => ({
|
|
526
|
+
mutate,
|
|
527
|
+
mutateMany: enqueue,
|
|
528
|
+
isPending,
|
|
529
|
+
error,
|
|
530
|
+
reset,
|
|
531
|
+
}), [mutate, enqueue, isPending, error, reset]);
|
|
532
|
+
}
|
|
533
|
+
function useMutations(options = {}) {
|
|
534
|
+
const { db } = useSyncContext();
|
|
535
|
+
const engine = useEngine();
|
|
536
|
+
const [isPending, setIsPending] = useState(false);
|
|
537
|
+
const [error, setError] = useState(null);
|
|
538
|
+
const reset = useCallback(() => {
|
|
539
|
+
setError(null);
|
|
540
|
+
}, []);
|
|
541
|
+
const versionColumn = options.versionColumn ?? 'server_version';
|
|
542
|
+
const defaultSync = options.sync ?? 'background';
|
|
543
|
+
const onSuccess = options.onSuccess;
|
|
544
|
+
const onError = options.onError;
|
|
545
|
+
const baseCommit = useMemo(() => createOutboxCommit({
|
|
546
|
+
db,
|
|
547
|
+
versionColumn,
|
|
548
|
+
}), [db, versionColumn]);
|
|
549
|
+
const commit = useCallback(async (fn, commitOptions) => {
|
|
550
|
+
setIsPending(true);
|
|
551
|
+
setError(null);
|
|
552
|
+
try {
|
|
553
|
+
const { result, receipt, meta } = await baseCommit(fn);
|
|
554
|
+
engine.recordLocalMutations(meta.localMutations);
|
|
555
|
+
void engine.refreshOutboxStats();
|
|
556
|
+
onSuccess?.({
|
|
557
|
+
commitId: receipt.commitId,
|
|
558
|
+
clientCommitId: receipt.clientCommitId,
|
|
559
|
+
opCount: meta.operations.length,
|
|
560
|
+
});
|
|
561
|
+
const syncMode = commitOptions?.sync ?? defaultSync;
|
|
562
|
+
if (syncMode) {
|
|
563
|
+
const syncPromise = engine.sync();
|
|
564
|
+
if (syncMode === 'await') {
|
|
565
|
+
await syncPromise;
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
syncPromise.catch((err) => {
|
|
569
|
+
console.warn('[useMutations] Background sync failed:', err);
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return { result, receipt, meta };
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
577
|
+
setError(e);
|
|
578
|
+
onError?.(e);
|
|
579
|
+
throw e;
|
|
580
|
+
}
|
|
581
|
+
finally {
|
|
582
|
+
setIsPending(false);
|
|
583
|
+
}
|
|
584
|
+
}, [baseCommit, defaultSync, engine, onError, onSuccess]);
|
|
585
|
+
const api = useMemo(() => createMutationsApi(commit), [commit]);
|
|
586
|
+
return useMemo(() => Object.assign(api, {
|
|
587
|
+
$isPending: isPending,
|
|
588
|
+
$error: error,
|
|
589
|
+
$reset: reset,
|
|
590
|
+
}), [api, error, isPending, reset]);
|
|
591
|
+
}
|
|
592
|
+
function useOutbox() {
|
|
593
|
+
const { db } = useSyncContext();
|
|
594
|
+
const engine = useEngine();
|
|
595
|
+
const [stats, setStats] = useState({
|
|
596
|
+
pending: 0,
|
|
597
|
+
sending: 0,
|
|
598
|
+
failed: 0,
|
|
599
|
+
acked: 0,
|
|
600
|
+
total: 0,
|
|
601
|
+
});
|
|
602
|
+
const [pending, setPending] = useState([]);
|
|
603
|
+
const [failed, setFailed] = useState([]);
|
|
604
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
605
|
+
const refresh = useCallback(async () => {
|
|
606
|
+
try {
|
|
607
|
+
setIsLoading(true);
|
|
608
|
+
const newStats = await engine.refreshOutboxStats({ emit: false });
|
|
609
|
+
setStats(newStats);
|
|
610
|
+
const rowsResult = await sql `
|
|
611
|
+
select
|
|
612
|
+
${sql.ref('id')},
|
|
613
|
+
${sql.ref('client_commit_id')},
|
|
614
|
+
${sql.ref('status')},
|
|
615
|
+
${sql.ref('operations_json')},
|
|
616
|
+
${sql.ref('error')},
|
|
617
|
+
${sql.ref('created_at')},
|
|
618
|
+
${sql.ref('updated_at')},
|
|
619
|
+
${sql.ref('attempt_count')}
|
|
620
|
+
from ${sql.table('sync_outbox_commits')}
|
|
621
|
+
where ${sql.ref('status')} in (${sql.join([
|
|
622
|
+
sql.val('pending'),
|
|
623
|
+
sql.val('failed'),
|
|
624
|
+
])})
|
|
625
|
+
order by ${sql.ref('created_at')} asc
|
|
626
|
+
`.execute(db);
|
|
627
|
+
const rows = rowsResult.rows;
|
|
628
|
+
const commits = rows.map((row) => {
|
|
629
|
+
let operationsCount = 0;
|
|
630
|
+
try {
|
|
631
|
+
const ops = typeof row.operations_json === 'string'
|
|
632
|
+
? JSON.parse(row.operations_json)
|
|
633
|
+
: row.operations_json;
|
|
634
|
+
operationsCount = Array.isArray(ops) ? ops.length : 0;
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
operationsCount = 0;
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
id: row.id,
|
|
641
|
+
clientCommitId: row.client_commit_id,
|
|
642
|
+
status: row.status,
|
|
643
|
+
operationsCount,
|
|
644
|
+
error: row.error,
|
|
645
|
+
createdAt: row.created_at,
|
|
646
|
+
updatedAt: row.updated_at,
|
|
647
|
+
attemptCount: Number(row.attempt_count),
|
|
648
|
+
};
|
|
649
|
+
});
|
|
650
|
+
setPending(commits.filter((c) => c.status === 'pending'));
|
|
651
|
+
setFailed(commits.filter((c) => c.status === 'failed'));
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
console.error('[useOutbox] Failed to refresh:', err);
|
|
655
|
+
}
|
|
656
|
+
finally {
|
|
657
|
+
setIsLoading(false);
|
|
658
|
+
}
|
|
659
|
+
}, [db, engine]);
|
|
660
|
+
useEffect(() => {
|
|
661
|
+
refresh();
|
|
662
|
+
}, [refresh]);
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
const unsubscribe = engine.on('outbox:change', () => {
|
|
665
|
+
refresh();
|
|
666
|
+
});
|
|
667
|
+
return unsubscribe;
|
|
668
|
+
}, [engine, refresh]);
|
|
669
|
+
useEffect(() => {
|
|
670
|
+
const unsubscribe = engine.on('sync:complete', () => {
|
|
671
|
+
refresh();
|
|
672
|
+
});
|
|
673
|
+
return unsubscribe;
|
|
674
|
+
}, [engine, refresh]);
|
|
675
|
+
const hasUnsent = stats.pending > 0 || stats.failed > 0;
|
|
676
|
+
const clearFailed = useCallback(async () => {
|
|
677
|
+
const count = await engine.clearFailedCommits();
|
|
678
|
+
await refresh();
|
|
679
|
+
return count;
|
|
680
|
+
}, [engine, refresh]);
|
|
681
|
+
const clearAll = useCallback(async () => {
|
|
682
|
+
const count = await engine.clearAllCommits();
|
|
683
|
+
await refresh();
|
|
684
|
+
return count;
|
|
685
|
+
}, [engine, refresh]);
|
|
686
|
+
return useMemo(() => ({
|
|
687
|
+
stats,
|
|
688
|
+
pending,
|
|
689
|
+
failed,
|
|
690
|
+
hasUnsent,
|
|
691
|
+
isLoading,
|
|
692
|
+
refresh,
|
|
693
|
+
clearFailed,
|
|
694
|
+
clearAll,
|
|
695
|
+
}), [
|
|
696
|
+
stats,
|
|
697
|
+
pending,
|
|
698
|
+
failed,
|
|
699
|
+
hasUnsent,
|
|
700
|
+
isLoading,
|
|
701
|
+
refresh,
|
|
702
|
+
clearFailed,
|
|
703
|
+
clearAll,
|
|
704
|
+
]);
|
|
705
|
+
}
|
|
706
|
+
function usePresence(scopeKey) {
|
|
707
|
+
const engine = useEngine();
|
|
708
|
+
const [presence, setPresence] = useState([]);
|
|
709
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
710
|
+
useEffect(() => {
|
|
711
|
+
const initial = engine.getPresence(scopeKey);
|
|
712
|
+
setPresence(initial);
|
|
713
|
+
setIsLoading(false);
|
|
714
|
+
const unsubscribe = engine.on('presence:change', (event) => {
|
|
715
|
+
if (event.scopeKey === scopeKey) {
|
|
716
|
+
setPresence(event.presence);
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
return unsubscribe;
|
|
720
|
+
}, [engine, scopeKey]);
|
|
721
|
+
return { presence, isLoading };
|
|
722
|
+
}
|
|
723
|
+
function usePresenceWithJoin(scopeKey, options = {}) {
|
|
724
|
+
const { metadata: initialMetadata, autoJoin = true } = options;
|
|
725
|
+
const engine = useEngine();
|
|
726
|
+
const { presence, isLoading } = usePresence(scopeKey);
|
|
727
|
+
const [isJoined, setIsJoined] = useState(false);
|
|
728
|
+
const join = useCallback((metadata) => {
|
|
729
|
+
engine.joinPresence(scopeKey, metadata);
|
|
730
|
+
setIsJoined(true);
|
|
731
|
+
}, [engine, scopeKey]);
|
|
732
|
+
const leave = useCallback(() => {
|
|
733
|
+
engine.leavePresence(scopeKey);
|
|
734
|
+
setIsJoined(false);
|
|
735
|
+
}, [engine, scopeKey]);
|
|
736
|
+
const updateMetadata = useCallback((metadata) => {
|
|
737
|
+
engine.updatePresenceMetadata(scopeKey, metadata);
|
|
738
|
+
}, [engine, scopeKey]);
|
|
739
|
+
useEffect(() => {
|
|
740
|
+
if (autoJoin) {
|
|
741
|
+
join(initialMetadata);
|
|
742
|
+
}
|
|
743
|
+
return () => {
|
|
744
|
+
leave();
|
|
745
|
+
};
|
|
746
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
747
|
+
}, [autoJoin, initialMetadata, join, leave]);
|
|
748
|
+
return {
|
|
749
|
+
presence,
|
|
750
|
+
isLoading,
|
|
751
|
+
updateMetadata,
|
|
752
|
+
join,
|
|
753
|
+
leave,
|
|
754
|
+
isJoined,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
SyncProvider,
|
|
759
|
+
useSyncContext,
|
|
760
|
+
useEngine,
|
|
761
|
+
useSyncEngine,
|
|
762
|
+
useSyncStatus,
|
|
763
|
+
useSyncConnection,
|
|
764
|
+
useSyncQuery,
|
|
765
|
+
useQuery,
|
|
766
|
+
useMutation,
|
|
767
|
+
useMutations,
|
|
768
|
+
useOutbox,
|
|
769
|
+
useConflicts,
|
|
770
|
+
useResolveConflict,
|
|
771
|
+
usePresence,
|
|
772
|
+
usePresenceWithJoin,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
//# sourceMappingURL=createSyncularReact.js.map
|