@pylonsync/react 0.2.4
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/package.json +24 -0
- package/src/db.ts +218 -0
- package/src/hooks.ts +922 -0
- package/src/index.ts +610 -0
- package/src/typed.ts +149 -0
- package/src/useRoom.ts +231 -0
- package/src/useSession.ts +71 -0
- package/src/useShard.ts +299 -0
- package/tsconfig.json +7 -0
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import { SyncEngine, type Row } from "@pylonsync/sync";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { callFn, getBaseUrl, getReactStorage, storageKey } from "./index";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Query shapes
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** Operator-based filter matching the server's query_filtered API. */
|
|
10
|
+
export type QueryFilter = Record<string, unknown> & {
|
|
11
|
+
$order?: Record<string, "asc" | "desc">;
|
|
12
|
+
$limit?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Include syntax for nested relations: `{ author: {}, tags: {} }`. */
|
|
16
|
+
export type IncludeSpec = Record<string, Record<string, unknown>>;
|
|
17
|
+
|
|
18
|
+
export interface QueryOptions {
|
|
19
|
+
/** Filter by fields and operators (server-side). */
|
|
20
|
+
where?: QueryFilter;
|
|
21
|
+
/** Expand relations inline (server-side graph query). */
|
|
22
|
+
include?: IncludeSpec;
|
|
23
|
+
/** Limit number of rows. */
|
|
24
|
+
limit?: number;
|
|
25
|
+
/** Order by field(s). */
|
|
26
|
+
orderBy?: Record<string, "asc" | "desc">;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseQueryReturn<T> {
|
|
30
|
+
data: T[];
|
|
31
|
+
loading: boolean;
|
|
32
|
+
error: Error | null;
|
|
33
|
+
/** Re-fetch from the server. Rarely needed — data is live. */
|
|
34
|
+
refetch: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseQueryOneReturn<T> {
|
|
38
|
+
data: T | null;
|
|
39
|
+
loading: boolean;
|
|
40
|
+
error: Error | null;
|
|
41
|
+
refetch: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// useQuery — high-level hook returning {data, loading, error}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Live query hook. Returns rows for an entity with loading/error state.
|
|
50
|
+
*
|
|
51
|
+
* Automatically re-renders when underlying data changes via the sync engine.
|
|
52
|
+
*
|
|
53
|
+
* ```tsx
|
|
54
|
+
* const { data: todos, loading, error } = useQuery<Todo>(sync, "Todo");
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* With filters and ordering:
|
|
58
|
+
*
|
|
59
|
+
* ```tsx
|
|
60
|
+
* const { data } = useQuery<Todo>(sync, "Todo", {
|
|
61
|
+
* where: { done: false, priority: { $gte: 3 } },
|
|
62
|
+
* orderBy: { createdAt: "desc" },
|
|
63
|
+
* limit: 20,
|
|
64
|
+
* });
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* Filter/order/limit are applied client-side against the sync store;
|
|
68
|
+
* the sync engine pulls the full entity in the background.
|
|
69
|
+
*/
|
|
70
|
+
export function useQuery<T = Row>(
|
|
71
|
+
sync: SyncEngine,
|
|
72
|
+
entity: string,
|
|
73
|
+
options?: QueryOptions
|
|
74
|
+
): UseQueryReturn<T> {
|
|
75
|
+
const loading = useRef<boolean>(sync.store.list(entity).length === 0);
|
|
76
|
+
const error = useRef<Error | null>(null);
|
|
77
|
+
const optionsKey = JSON.stringify(options || {});
|
|
78
|
+
|
|
79
|
+
// Subscribe function stable across the lifetime of this entity/options combo.
|
|
80
|
+
const subscribe = useMemo(
|
|
81
|
+
() => (onChange: () => void) => {
|
|
82
|
+
return sync.store.subscribe((changedEntity?: string) => {
|
|
83
|
+
if (!changedEntity || changedEntity === entity) {
|
|
84
|
+
onChange();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
[sync, entity]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Cache the filtered snapshot so getSnapshot returns a stable reference
|
|
92
|
+
// while the underlying data is unchanged.
|
|
93
|
+
const snapshotCache = useRef<{ rows: T[]; sig: string }>({
|
|
94
|
+
rows: [],
|
|
95
|
+
sig: "__init__",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const getSnapshot = useCallback((): T[] => {
|
|
99
|
+
const rows = sync.store.list(entity) as Row[];
|
|
100
|
+
const filtered = applyClientFilter(rows, options);
|
|
101
|
+
const sig = optionsKey + ":" + JSON.stringify(filtered);
|
|
102
|
+
if (sig !== snapshotCache.current.sig) {
|
|
103
|
+
snapshotCache.current = { rows: filtered as T[], sig };
|
|
104
|
+
}
|
|
105
|
+
if (rows.length > 0 && loading.current) loading.current = false;
|
|
106
|
+
return snapshotCache.current.rows;
|
|
107
|
+
}, [sync, entity, optionsKey, options]);
|
|
108
|
+
|
|
109
|
+
const getServerSnapshot = useCallback((): T[] => [] as T[], []);
|
|
110
|
+
|
|
111
|
+
const data = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
112
|
+
|
|
113
|
+
const refetch = useCallback(() => {
|
|
114
|
+
loading.current = true;
|
|
115
|
+
error.current = null;
|
|
116
|
+
sync.pull().catch((e: unknown) => {
|
|
117
|
+
error.current = e instanceof Error ? e : new Error(String(e));
|
|
118
|
+
});
|
|
119
|
+
}, [sync]);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
data,
|
|
123
|
+
loading: loading.current,
|
|
124
|
+
error: error.current,
|
|
125
|
+
refetch,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Live single-row query by ID. Returns the row or null, with loading/error state.
|
|
131
|
+
*
|
|
132
|
+
* ```tsx
|
|
133
|
+
* const { data: todo, loading } = useQueryOne<Todo>(sync, "Todo", todoId);
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export function useQueryOne<T = Row>(
|
|
137
|
+
sync: SyncEngine,
|
|
138
|
+
entity: string,
|
|
139
|
+
id: string
|
|
140
|
+
): UseQueryOneReturn<T> {
|
|
141
|
+
const loading = useRef<boolean>(sync.store.get(entity, id) === null);
|
|
142
|
+
const error = useRef<Error | null>(null);
|
|
143
|
+
|
|
144
|
+
const subscribe = useMemo(
|
|
145
|
+
() => (onChange: () => void) => {
|
|
146
|
+
return sync.store.subscribe((changedEntity?: string) => {
|
|
147
|
+
if (!changedEntity || changedEntity === entity) {
|
|
148
|
+
onChange();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
[sync, entity]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const snapshotCache = useRef<{ row: T | null; sig: string }>({
|
|
156
|
+
row: null,
|
|
157
|
+
sig: "__init__",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const getSnapshot = useCallback((): T | null => {
|
|
161
|
+
const row = sync.store.get(entity, id) as Row | null;
|
|
162
|
+
const sig = JSON.stringify(row);
|
|
163
|
+
if (sig !== snapshotCache.current.sig) {
|
|
164
|
+
snapshotCache.current = { row: (row as T) ?? null, sig };
|
|
165
|
+
}
|
|
166
|
+
if (row !== null && loading.current) loading.current = false;
|
|
167
|
+
return snapshotCache.current.row;
|
|
168
|
+
}, [sync, entity, id]);
|
|
169
|
+
|
|
170
|
+
const getServerSnapshot = useCallback((): T | null => null, []);
|
|
171
|
+
|
|
172
|
+
const data = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
173
|
+
|
|
174
|
+
const refetch = useCallback(() => {
|
|
175
|
+
loading.current = true;
|
|
176
|
+
error.current = null;
|
|
177
|
+
sync.pull().catch((e: unknown) => {
|
|
178
|
+
error.current = e instanceof Error ? e : new Error(String(e));
|
|
179
|
+
});
|
|
180
|
+
}, [sync]);
|
|
181
|
+
|
|
182
|
+
return { data, loading: loading.current, error: error.current, refetch };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Client-side filter application (matches the server's operator set)
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
function applyClientFilter(rows: Row[], options?: QueryOptions): Row[] {
|
|
190
|
+
if (!options) return rows;
|
|
191
|
+
|
|
192
|
+
let out = rows.slice();
|
|
193
|
+
if (options.where) {
|
|
194
|
+
out = out.filter((row) => matchesWhere(row, options.where!));
|
|
195
|
+
}
|
|
196
|
+
if (options.orderBy) {
|
|
197
|
+
for (const [field, dir] of Object.entries(options.orderBy)) {
|
|
198
|
+
out.sort((a, b) => compare(a[field], b[field], dir));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (typeof options.limit === "number") {
|
|
202
|
+
out = out.slice(0, options.limit);
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function matchesWhere(row: Row, where: QueryFilter): boolean {
|
|
208
|
+
for (const [key, val] of Object.entries(where)) {
|
|
209
|
+
if (key === "$order" || key === "$limit") continue;
|
|
210
|
+
const rowVal = row[key];
|
|
211
|
+
|
|
212
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
213
|
+
// Operator object.
|
|
214
|
+
for (const [op, opVal] of Object.entries(val as Record<string, unknown>)) {
|
|
215
|
+
switch (op) {
|
|
216
|
+
case "$not":
|
|
217
|
+
if (rowVal === opVal) return false;
|
|
218
|
+
break;
|
|
219
|
+
case "$gt":
|
|
220
|
+
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal > opVal))
|
|
221
|
+
return false;
|
|
222
|
+
break;
|
|
223
|
+
case "$gte":
|
|
224
|
+
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal >= opVal))
|
|
225
|
+
return false;
|
|
226
|
+
break;
|
|
227
|
+
case "$lt":
|
|
228
|
+
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal < opVal))
|
|
229
|
+
return false;
|
|
230
|
+
break;
|
|
231
|
+
case "$lte":
|
|
232
|
+
if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal <= opVal))
|
|
233
|
+
return false;
|
|
234
|
+
break;
|
|
235
|
+
case "$like":
|
|
236
|
+
if (
|
|
237
|
+
!(typeof rowVal === "string" && typeof opVal === "string" && rowVal.includes(opVal))
|
|
238
|
+
)
|
|
239
|
+
return false;
|
|
240
|
+
break;
|
|
241
|
+
case "$in":
|
|
242
|
+
if (!Array.isArray(opVal) || !(opVal as unknown[]).includes(rowVal)) return false;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
if (rowVal !== val) return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function compare(a: unknown, b: unknown, dir: "asc" | "desc"): number {
|
|
254
|
+
const mult = dir === "desc" ? -1 : 1;
|
|
255
|
+
if (a === b) return 0;
|
|
256
|
+
if (a === undefined || a === null) return mult;
|
|
257
|
+
if (b === undefined || b === null) return -mult;
|
|
258
|
+
if (typeof a === "number" && typeof b === "number") return (a - b) * mult;
|
|
259
|
+
return String(a).localeCompare(String(b)) * mult;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// useMutation — call a server-side TypeScript function
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
export interface UseMutationReturn<TArgs, TResult> {
|
|
267
|
+
mutate: (args: TArgs) => Promise<TResult>;
|
|
268
|
+
mutateAsync: (args: TArgs) => Promise<TResult>;
|
|
269
|
+
loading: boolean;
|
|
270
|
+
data: TResult | null;
|
|
271
|
+
error: Error | null;
|
|
272
|
+
reset: () => void;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Hook for calling a server-side mutation/action function.
|
|
277
|
+
*
|
|
278
|
+
* ```tsx
|
|
279
|
+
* const placeBid = useMutation<{lotId: string; amount: number}, {accepted: boolean}>(
|
|
280
|
+
* "placeBid"
|
|
281
|
+
* );
|
|
282
|
+
*
|
|
283
|
+
* const onClick = async () => {
|
|
284
|
+
* const result = await placeBid.mutate({ lotId: "lot_1", amount: 150 });
|
|
285
|
+
* if (result.accepted) alert("Bid placed!");
|
|
286
|
+
* };
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
export function useMutation<TArgs = Record<string, unknown>, TResult = unknown>(
|
|
290
|
+
fnName: string,
|
|
291
|
+
options: { token?: string } = {}
|
|
292
|
+
): UseMutationReturn<TArgs, TResult> {
|
|
293
|
+
const [loading, setLoading] = useState(false);
|
|
294
|
+
const [data, setData] = useState<TResult | null>(null);
|
|
295
|
+
const [error, setError] = useState<Error | null>(null);
|
|
296
|
+
const tokenRef = useRef(options.token);
|
|
297
|
+
tokenRef.current = options.token;
|
|
298
|
+
|
|
299
|
+
// mounted guard: a mutate() kicked off right before unmount used to
|
|
300
|
+
// resolve after cleanup and call set{Data,Error,Loading} on a dead
|
|
301
|
+
// component, producing React warnings in dev and silently wasted work
|
|
302
|
+
// in prod. Skip state updates when the component is gone.
|
|
303
|
+
const mounted = useRef(true);
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
mounted.current = true;
|
|
306
|
+
return () => {
|
|
307
|
+
mounted.current = false;
|
|
308
|
+
};
|
|
309
|
+
}, []);
|
|
310
|
+
|
|
311
|
+
const mutate = useCallback(
|
|
312
|
+
async (args: TArgs): Promise<TResult> => {
|
|
313
|
+
if (mounted.current) setLoading(true);
|
|
314
|
+
if (mounted.current) setError(null);
|
|
315
|
+
try {
|
|
316
|
+
const result = await callFn<TResult>(
|
|
317
|
+
fnName,
|
|
318
|
+
args as Record<string, unknown>,
|
|
319
|
+
{ token: tokenRef.current }
|
|
320
|
+
);
|
|
321
|
+
if (mounted.current) setData(result);
|
|
322
|
+
return result;
|
|
323
|
+
} catch (e) {
|
|
324
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
325
|
+
if (mounted.current) setError(err);
|
|
326
|
+
throw err;
|
|
327
|
+
} finally {
|
|
328
|
+
if (mounted.current) setLoading(false);
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
[fnName]
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const reset = useCallback(() => {
|
|
335
|
+
if (!mounted.current) return;
|
|
336
|
+
setData(null);
|
|
337
|
+
setError(null);
|
|
338
|
+
}, []);
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
mutate,
|
|
342
|
+
mutateAsync: mutate,
|
|
343
|
+
loading,
|
|
344
|
+
data,
|
|
345
|
+
error,
|
|
346
|
+
reset,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// useInfiniteQuery — paginated live query with loadMore()
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
export interface UseInfiniteQueryReturn<T> {
|
|
355
|
+
data: T[];
|
|
356
|
+
loading: boolean;
|
|
357
|
+
hasMore: boolean;
|
|
358
|
+
loadMore: () => void;
|
|
359
|
+
error: Error | null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Paginated query hook that accumulates pages as you `loadMore()`.
|
|
364
|
+
*
|
|
365
|
+
* ```tsx
|
|
366
|
+
* const { data, hasMore, loadMore, loading } = useInfiniteQuery<Todo>(
|
|
367
|
+
* sync, "Todo", { pageSize: 20 }
|
|
368
|
+
* );
|
|
369
|
+
* ```
|
|
370
|
+
*/
|
|
371
|
+
export function useInfiniteQuery<T = Row>(
|
|
372
|
+
sync: SyncEngine,
|
|
373
|
+
entity: string,
|
|
374
|
+
options: { pageSize?: number } = {}
|
|
375
|
+
): UseInfiniteQueryReturn<T> {
|
|
376
|
+
const pageSize = options.pageSize ?? 20;
|
|
377
|
+
const [data, setData] = useState<T[]>([]);
|
|
378
|
+
const [hasMore, setHasMore] = useState(true);
|
|
379
|
+
const [loading, setLoading] = useState(false);
|
|
380
|
+
const [error, setError] = useState<Error | null>(null);
|
|
381
|
+
const offsetRef = useRef<number>(0);
|
|
382
|
+
|
|
383
|
+
// Mounted guard + in-flight ref. Two related issues:
|
|
384
|
+
// 1. setState after unmount — same problem as useMutation.
|
|
385
|
+
// 2. Concurrent loadMore() calls read stale `loading`/`hasMore` from
|
|
386
|
+
// the render closure (the guard at the top of loadMore reads the
|
|
387
|
+
// last-rendered value, not the live one). Use a ref for the
|
|
388
|
+
// in-flight bit so back-to-back loadMore() can't queue duplicate
|
|
389
|
+
// `loadPage` calls.
|
|
390
|
+
const mounted = useRef(true);
|
|
391
|
+
const inFlight = useRef(false);
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
mounted.current = true;
|
|
394
|
+
return () => {
|
|
395
|
+
mounted.current = false;
|
|
396
|
+
};
|
|
397
|
+
}, []);
|
|
398
|
+
|
|
399
|
+
const loadMore = useCallback(() => {
|
|
400
|
+
if (inFlight.current || !hasMore) return;
|
|
401
|
+
inFlight.current = true;
|
|
402
|
+
if (mounted.current) setLoading(true);
|
|
403
|
+
if (mounted.current) setError(null);
|
|
404
|
+
sync
|
|
405
|
+
.loadPage(entity, { offset: offsetRef.current, limit: pageSize })
|
|
406
|
+
.then((result) => {
|
|
407
|
+
offsetRef.current += result.data.length;
|
|
408
|
+
if (mounted.current) {
|
|
409
|
+
setHasMore(result.hasMore);
|
|
410
|
+
setData((prev) => [...prev, ...(result.data as T[])]);
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
.catch((e: unknown) => {
|
|
414
|
+
if (mounted.current) {
|
|
415
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
.finally(() => {
|
|
419
|
+
inFlight.current = false;
|
|
420
|
+
if (mounted.current) setLoading(false);
|
|
421
|
+
});
|
|
422
|
+
}, [sync, entity, pageSize, hasMore]);
|
|
423
|
+
|
|
424
|
+
// Load first page on mount.
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
if (data.length === 0 && hasMore && !loading) {
|
|
427
|
+
loadMore();
|
|
428
|
+
}
|
|
429
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
430
|
+
}, []);
|
|
431
|
+
|
|
432
|
+
return { data, loading, hasMore, loadMore, error };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// usePaginatedQuery — Convex-compatible status enum API
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
export type PaginatedQueryStatus =
|
|
440
|
+
| "LoadingFirstPage"
|
|
441
|
+
| "CanLoadMore"
|
|
442
|
+
| "LoadingMore"
|
|
443
|
+
| "Exhausted";
|
|
444
|
+
|
|
445
|
+
export interface UsePaginatedQueryReturn<T> {
|
|
446
|
+
/** Rows loaded so far, across all pages. */
|
|
447
|
+
results: T[];
|
|
448
|
+
/** State-machine value — render based on this rather than booleans. */
|
|
449
|
+
status: PaginatedQueryStatus;
|
|
450
|
+
/** Fetch the next page. Idempotent: no-op while loading or exhausted. */
|
|
451
|
+
loadMore: (numItems?: number) => void;
|
|
452
|
+
/** The most recent error, if any. Resets on the next successful load. */
|
|
453
|
+
error: Error | null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Cursor-paginated live query. Pairs with `ctx.db.paginate()` server-side
|
|
458
|
+
* and the `GET /api/entities/:entity/cursor` endpoint.
|
|
459
|
+
*
|
|
460
|
+
* ```tsx
|
|
461
|
+
* const { results, status, loadMore } = usePaginatedQuery<Order>(
|
|
462
|
+
* sync,
|
|
463
|
+
* "Order",
|
|
464
|
+
* { initialNumItems: 20 }
|
|
465
|
+
* );
|
|
466
|
+
*
|
|
467
|
+
* return (
|
|
468
|
+
* <>
|
|
469
|
+
* {results.map(o => <Row key={o.id} order={o} />)}
|
|
470
|
+
* {status === "CanLoadMore" && <button onClick={() => loadMore()}>More</button>}
|
|
471
|
+
* {status === "LoadingMore" && <Spinner />}
|
|
472
|
+
* {status === "Exhausted" && <footer>end</footer>}
|
|
473
|
+
* </>
|
|
474
|
+
* );
|
|
475
|
+
* ```
|
|
476
|
+
*
|
|
477
|
+
* Same engine as `useInfiniteQuery`; different surface. Prefer this one in
|
|
478
|
+
* new code — the `status` enum makes exhaustive rendering easier to get
|
|
479
|
+
* right than `hasMore/loading` booleans.
|
|
480
|
+
*/
|
|
481
|
+
export function usePaginatedQuery<T = Row>(
|
|
482
|
+
sync: SyncEngine,
|
|
483
|
+
entity: string,
|
|
484
|
+
options: { initialNumItems?: number } = {},
|
|
485
|
+
): UsePaginatedQueryReturn<T> {
|
|
486
|
+
const initial = options.initialNumItems ?? 20;
|
|
487
|
+
const inner = useInfiniteQuery<T>(sync, entity, { pageSize: initial });
|
|
488
|
+
|
|
489
|
+
let status: PaginatedQueryStatus;
|
|
490
|
+
if (inner.loading && inner.data.length === 0) {
|
|
491
|
+
status = "LoadingFirstPage";
|
|
492
|
+
} else if (inner.loading) {
|
|
493
|
+
status = "LoadingMore";
|
|
494
|
+
} else if (!inner.hasMore) {
|
|
495
|
+
status = "Exhausted";
|
|
496
|
+
} else {
|
|
497
|
+
status = "CanLoadMore";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
results: inner.data,
|
|
502
|
+
status,
|
|
503
|
+
loadMore: () => inner.loadMore(),
|
|
504
|
+
error: inner.error,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// Raw hooks (backward-compat) — exposes useSyncExternalStore triples
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Low-level hook returning `{subscribe, getSnapshot, getServerSnapshot}` for
|
|
514
|
+
* `useSyncExternalStore`. Prefer [`useQuery`] above for most cases; use this
|
|
515
|
+
* when you need precise control over subscription timing.
|
|
516
|
+
*/
|
|
517
|
+
export function useQueryRaw(sync: SyncEngine, entity: string) {
|
|
518
|
+
let cache: Row[] = sync.store.list(entity);
|
|
519
|
+
let cacheKey = JSON.stringify(cache);
|
|
520
|
+
|
|
521
|
+
const subscribe = (callback: () => void) => {
|
|
522
|
+
return sync.store.subscribe(() => {
|
|
523
|
+
const next = sync.store.list(entity);
|
|
524
|
+
const nextKey = JSON.stringify(next);
|
|
525
|
+
if (nextKey !== cacheKey) {
|
|
526
|
+
cache = next;
|
|
527
|
+
cacheKey = nextKey;
|
|
528
|
+
callback();
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const getSnapshot = () => cache;
|
|
534
|
+
const getServerSnapshot = () => [] as Row[];
|
|
535
|
+
|
|
536
|
+
return { subscribe, getSnapshot, getServerSnapshot };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function useQueryOneRaw(sync: SyncEngine, entity: string, id: string) {
|
|
540
|
+
let cache: Row | null = sync.store.get(entity, id);
|
|
541
|
+
let cacheKey = JSON.stringify(cache);
|
|
542
|
+
|
|
543
|
+
const subscribe = (callback: () => void) => {
|
|
544
|
+
return sync.store.subscribe(() => {
|
|
545
|
+
const next = sync.store.get(entity, id);
|
|
546
|
+
const nextKey = JSON.stringify(next);
|
|
547
|
+
if (nextKey !== cacheKey) {
|
|
548
|
+
cache = next;
|
|
549
|
+
cacheKey = nextKey;
|
|
550
|
+
callback();
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const getSnapshot = () => cache;
|
|
556
|
+
const getServerSnapshot = () => null as Row | null;
|
|
557
|
+
|
|
558
|
+
return { subscribe, getSnapshot, getServerSnapshot };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
// Legacy CRUD mutations (sync-engine-backed) — renamed to avoid collision
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Entity-level CRUD helpers backed by the sync engine (optimistic updates).
|
|
567
|
+
* Separate from [`useMutation`] which calls server-side TypeScript functions.
|
|
568
|
+
*/
|
|
569
|
+
export function useEntityMutation(sync: SyncEngine, entity: string) {
|
|
570
|
+
return {
|
|
571
|
+
insert: (data: Row) => sync.insert(entity, data),
|
|
572
|
+
update: (id: string, data: Partial<Row>) => sync.update(entity, id, data),
|
|
573
|
+
remove: (id: string) => sync.delete(entity, id),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export const useLiveList = useQueryRaw;
|
|
578
|
+
export const useLiveRow = useQueryOneRaw;
|
|
579
|
+
|
|
580
|
+
export function useInsert(sync: SyncEngine, entity: string) {
|
|
581
|
+
return (data: Row) => sync.insert(entity, data);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function useUpdate(sync: SyncEngine, entity: string) {
|
|
585
|
+
return (id: string, data: Partial<Row>) => sync.update(entity, id, data);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function useDelete(sync: SyncEngine, entity: string) {
|
|
589
|
+
return (id: string) => sync.delete(entity, id);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function useAction(
|
|
593
|
+
sync: SyncEngine,
|
|
594
|
+
entity: string,
|
|
595
|
+
actionFn: (data: Row) => Promise<void>
|
|
596
|
+
) {
|
|
597
|
+
return async (data: Row) => {
|
|
598
|
+
sync.store.optimisticInsert(entity, data);
|
|
599
|
+
try {
|
|
600
|
+
await actionFn(data);
|
|
601
|
+
} catch {
|
|
602
|
+
// Revert on failure — next pull will correct.
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
// useFn — legacy alias for useMutation (kept for back-compat)
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
export interface UseFnReturn<TResult> {
|
|
612
|
+
call: (args?: Record<string, unknown>) => Promise<TResult>;
|
|
613
|
+
loading: boolean;
|
|
614
|
+
data: TResult | null;
|
|
615
|
+
error: Error | null;
|
|
616
|
+
reset: () => void;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Call a server-side function with loading/error/data state.
|
|
621
|
+
* Prefer [`useMutation`] for new code — same functionality, better API.
|
|
622
|
+
*/
|
|
623
|
+
export function useFn<TResult = unknown>(
|
|
624
|
+
name: string,
|
|
625
|
+
options: { token?: string } = {}
|
|
626
|
+
): UseFnReturn<TResult> {
|
|
627
|
+
const m = useMutation<Record<string, unknown>, TResult>(name, options);
|
|
628
|
+
return {
|
|
629
|
+
call: (args: Record<string, unknown> = {}) => m.mutate(args),
|
|
630
|
+
loading: m.loading,
|
|
631
|
+
data: m.data,
|
|
632
|
+
error: m.error,
|
|
633
|
+
reset: m.reset,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
// useAggregate — live count/sum/avg/groupBy queries for dashboards
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Aggregate spec — server matches this shape in
|
|
643
|
+
* `POST /api/aggregate/:entity`. The server auto-injects an `orgId`
|
|
644
|
+
* clamp into `where` when the caller has a tenant, so a malicious
|
|
645
|
+
* client can't sum across orgs.
|
|
646
|
+
*/
|
|
647
|
+
export interface AggregateSpec {
|
|
648
|
+
/** "*" for COUNT(*), a column name for COUNT(col). */
|
|
649
|
+
count?: string;
|
|
650
|
+
/** Columns to sum. */
|
|
651
|
+
sum?: string[];
|
|
652
|
+
/** Columns to average. */
|
|
653
|
+
avg?: string[];
|
|
654
|
+
/** Columns to take the minimum of. */
|
|
655
|
+
min?: string[];
|
|
656
|
+
/** Columns to take the maximum of. */
|
|
657
|
+
max?: string[];
|
|
658
|
+
/** Columns to COUNT DISTINCT. */
|
|
659
|
+
countDistinct?: string[];
|
|
660
|
+
/**
|
|
661
|
+
* Group keys. Each entry is either a column name, or a date-bucket
|
|
662
|
+
* spec `{ field, bucket }` where bucket ∈ hour/day/week/month/year.
|
|
663
|
+
*/
|
|
664
|
+
groupBy?: (string | { field: string; bucket: "hour" | "day" | "week" | "month" | "year" })[];
|
|
665
|
+
/** Equality filter applied before aggregation. */
|
|
666
|
+
where?: Record<string, unknown>;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export interface UseAggregateReturn<Row = Record<string, unknown>> {
|
|
670
|
+
data: Row[] | null;
|
|
671
|
+
loading: boolean;
|
|
672
|
+
error: Error | null;
|
|
673
|
+
/** Re-run the query. Rarely needed — the hook refreshes on sync notify. */
|
|
674
|
+
refresh: () => void;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Run an aggregate query and keep it fresh as the sync store mutates.
|
|
679
|
+
*
|
|
680
|
+
* The hook re-fetches whenever the given entity changes in the local
|
|
681
|
+
* sync replica — so charts stay live without polling. Subscribes to
|
|
682
|
+
* the entity's sync events; any change triggers a debounced re-fetch.
|
|
683
|
+
*
|
|
684
|
+
* ```tsx
|
|
685
|
+
* const { data } = useAggregate(sync, "Order", {
|
|
686
|
+
* count: "*",
|
|
687
|
+
* groupBy: [{ field: "createdAt", bucket: "day" }],
|
|
688
|
+
* where: { status: "delivered" },
|
|
689
|
+
* });
|
|
690
|
+
* ```
|
|
691
|
+
*/
|
|
692
|
+
export function useAggregate<Row = Record<string, unknown>>(
|
|
693
|
+
sync: SyncEngine,
|
|
694
|
+
entity: string,
|
|
695
|
+
spec: AggregateSpec,
|
|
696
|
+
): UseAggregateReturn<Row> {
|
|
697
|
+
const [data, setData] = useState<Row[] | null>(null);
|
|
698
|
+
const [loading, setLoading] = useState(true);
|
|
699
|
+
const [error, setError] = useState<Error | null>(null);
|
|
700
|
+
// Stringify the spec so we only refetch when the semantic query changes,
|
|
701
|
+
// not on every parent render (spec object is usually a literal).
|
|
702
|
+
const specKey = JSON.stringify(spec);
|
|
703
|
+
|
|
704
|
+
const run = useCallback(async () => {
|
|
705
|
+
setLoading(true);
|
|
706
|
+
setError(null);
|
|
707
|
+
try {
|
|
708
|
+
const baseUrl = getBaseUrl();
|
|
709
|
+
const token = getReactStorage().get(storageKey("token"));
|
|
710
|
+
const res = await fetch(`${baseUrl}/api/aggregate/${entity}`, {
|
|
711
|
+
method: "POST",
|
|
712
|
+
headers: {
|
|
713
|
+
"Content-Type": "application/json",
|
|
714
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
715
|
+
},
|
|
716
|
+
body: specKey,
|
|
717
|
+
});
|
|
718
|
+
const json = (await res.json()) as { rows?: Row[]; error?: { message: string } };
|
|
719
|
+
if (!res.ok) {
|
|
720
|
+
throw new Error(json.error?.message || `HTTP ${res.status}`);
|
|
721
|
+
}
|
|
722
|
+
setData(json.rows ?? []);
|
|
723
|
+
} catch (e) {
|
|
724
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
725
|
+
} finally {
|
|
726
|
+
setLoading(false);
|
|
727
|
+
}
|
|
728
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
729
|
+
}, [entity, specKey]);
|
|
730
|
+
|
|
731
|
+
// Initial fetch + refetch on spec change.
|
|
732
|
+
useEffect(() => {
|
|
733
|
+
void run();
|
|
734
|
+
}, [run]);
|
|
735
|
+
|
|
736
|
+
// Live refresh: re-run whenever the sync store notifies a change for
|
|
737
|
+
// this entity (or any entity — pessimistic, but debounced). Keeps
|
|
738
|
+
// charts in sync with writes without manual polling.
|
|
739
|
+
useEffect(() => {
|
|
740
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
741
|
+
const unsub = sync.store.subscribe((changedEntity?: string) => {
|
|
742
|
+
if (changedEntity && changedEntity !== entity) return;
|
|
743
|
+
if (pending) clearTimeout(pending);
|
|
744
|
+
// 150ms debounce — burst writes (bulk import, WS replay) collapse
|
|
745
|
+
// into a single refetch instead of hammering the aggregate endpoint.
|
|
746
|
+
pending = setTimeout(() => {
|
|
747
|
+
void run();
|
|
748
|
+
}, 150);
|
|
749
|
+
});
|
|
750
|
+
return () => {
|
|
751
|
+
if (pending) clearTimeout(pending);
|
|
752
|
+
unsub();
|
|
753
|
+
};
|
|
754
|
+
}, [sync, entity, run]);
|
|
755
|
+
|
|
756
|
+
return { data, loading, error, refresh: run };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ---------------------------------------------------------------------------
|
|
760
|
+
// useSearch — faceted full-text search with live facet count updates
|
|
761
|
+
// ---------------------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
export interface SearchSpec {
|
|
764
|
+
/** Free-text match across the entity's declared `text` fields. */
|
|
765
|
+
query?: string;
|
|
766
|
+
/** Equality filters. Keys must be facet fields in the entity's schema. */
|
|
767
|
+
filters?: Record<string, string | number | boolean>;
|
|
768
|
+
/** Facet fields to return counts for. If omitted, all declared facets. */
|
|
769
|
+
facets?: string[];
|
|
770
|
+
/** Sort by `[field, "asc" | "desc"]`. Field must be in `sortable`. */
|
|
771
|
+
sort?: [string, "asc" | "desc"];
|
|
772
|
+
/** Zero-indexed page. Default 0. */
|
|
773
|
+
page?: number;
|
|
774
|
+
/** Results per page. Clamped server-side to 1..=100. Default 20. */
|
|
775
|
+
pageSize?: number;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export interface UseSearchReturn<T = Row> {
|
|
779
|
+
/** The current page of hits, already sorted. */
|
|
780
|
+
hits: T[];
|
|
781
|
+
/** `{facet: {value: count}}` for every declared (or requested) facet. */
|
|
782
|
+
facetCounts: Record<string, Record<string, number>>;
|
|
783
|
+
/** Total hit count across all pages. */
|
|
784
|
+
total: number;
|
|
785
|
+
/** Server-reported query latency in ms. */
|
|
786
|
+
tookMs: number;
|
|
787
|
+
loading: boolean;
|
|
788
|
+
error: Error | null;
|
|
789
|
+
refresh: () => Promise<void>;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Live faceted search hook. Wraps the `POST /api/search/:entity`
|
|
794
|
+
* endpoint, re-runs the query when the sync replica signals a write
|
|
795
|
+
* on the target entity, and returns ranked hits plus live facet
|
|
796
|
+
* counts in one call.
|
|
797
|
+
*
|
|
798
|
+
* ```tsx
|
|
799
|
+
* const { hits, facetCounts, total, loading } = useSearch<Product>(
|
|
800
|
+
* sync, "Product",
|
|
801
|
+
* {
|
|
802
|
+
* query: "red sneakers",
|
|
803
|
+
* filters: { category: "shoes" },
|
|
804
|
+
* facets: ["brand", "color"],
|
|
805
|
+
* sort: ["price", "desc"],
|
|
806
|
+
* page: 0, pageSize: 20,
|
|
807
|
+
* },
|
|
808
|
+
* );
|
|
809
|
+
* ```
|
|
810
|
+
*
|
|
811
|
+
* Live-update model matches `useAggregate`: subscribes to the sync
|
|
812
|
+
* store and re-fetches on any change for this entity. Facet counts
|
|
813
|
+
* reflect server-computed bitmap intersections — adding/removing a
|
|
814
|
+
* Product row drops the freshly-recomputed counts back into the UI
|
|
815
|
+
* in under 100ms on typical catalogs.
|
|
816
|
+
*/
|
|
817
|
+
export function useSearch<T = Row>(
|
|
818
|
+
sync: SyncEngine,
|
|
819
|
+
entity: string,
|
|
820
|
+
spec: SearchSpec,
|
|
821
|
+
): UseSearchReturn<T> {
|
|
822
|
+
const [hits, setHits] = useState<T[]>([]);
|
|
823
|
+
const [facetCounts, setFacetCounts] = useState<
|
|
824
|
+
Record<string, Record<string, number>>
|
|
825
|
+
>({});
|
|
826
|
+
const [total, setTotal] = useState(0);
|
|
827
|
+
const [tookMs, setTookMs] = useState(0);
|
|
828
|
+
const [loading, setLoading] = useState(true);
|
|
829
|
+
const [error, setError] = useState<Error | null>(null);
|
|
830
|
+
|
|
831
|
+
// Key the debounce on the semantic query shape so parent re-renders
|
|
832
|
+
// with the same spec literal don't trigger spurious fetches.
|
|
833
|
+
const specKey = JSON.stringify(spec);
|
|
834
|
+
|
|
835
|
+
// Monotonic request counter + AbortController — every `run()` grabs
|
|
836
|
+
// a fresh id, aborts the previous in-flight request at the transport,
|
|
837
|
+
// and refuses to apply its results if a newer request kicked off
|
|
838
|
+
// before it resolved. Without this, typing quickly would race: the
|
|
839
|
+
// older slower response would overwrite the newer one and the UI
|
|
840
|
+
// would show stale hits / facet counts.
|
|
841
|
+
const requestIdRef = useRef(0);
|
|
842
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
843
|
+
|
|
844
|
+
const run = useCallback(async () => {
|
|
845
|
+
requestIdRef.current += 1;
|
|
846
|
+
const myId = requestIdRef.current;
|
|
847
|
+
abortRef.current?.abort();
|
|
848
|
+
const controller = new AbortController();
|
|
849
|
+
abortRef.current = controller;
|
|
850
|
+
|
|
851
|
+
setLoading(true);
|
|
852
|
+
setError(null);
|
|
853
|
+
try {
|
|
854
|
+
const baseUrl = getBaseUrl();
|
|
855
|
+
const token = getReactStorage().get(storageKey("token"));
|
|
856
|
+
const body = JSON.stringify({
|
|
857
|
+
query: spec.query ?? "",
|
|
858
|
+
filters: spec.filters ?? {},
|
|
859
|
+
facets: spec.facets ?? [],
|
|
860
|
+
sort: spec.sort,
|
|
861
|
+
page: spec.page ?? 0,
|
|
862
|
+
page_size: spec.pageSize ?? 20,
|
|
863
|
+
});
|
|
864
|
+
const res = await fetch(`${baseUrl}/api/search/${entity}`, {
|
|
865
|
+
method: "POST",
|
|
866
|
+
headers: {
|
|
867
|
+
"Content-Type": "application/json",
|
|
868
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
869
|
+
},
|
|
870
|
+
body,
|
|
871
|
+
signal: controller.signal,
|
|
872
|
+
});
|
|
873
|
+
const json = (await res.json()) as {
|
|
874
|
+
hits?: T[];
|
|
875
|
+
facet_counts?: Record<string, Record<string, number>>;
|
|
876
|
+
total?: number;
|
|
877
|
+
took_ms?: number;
|
|
878
|
+
error?: { message: string };
|
|
879
|
+
};
|
|
880
|
+
if (myId !== requestIdRef.current) return; // stale — newer in flight
|
|
881
|
+
if (!res.ok) {
|
|
882
|
+
throw new Error(json.error?.message ?? `HTTP ${res.status}`);
|
|
883
|
+
}
|
|
884
|
+
setHits(json.hits ?? []);
|
|
885
|
+
setFacetCounts(json.facet_counts ?? {});
|
|
886
|
+
setTotal(json.total ?? 0);
|
|
887
|
+
setTookMs(json.took_ms ?? 0);
|
|
888
|
+
} catch (e) {
|
|
889
|
+
if (myId !== requestIdRef.current) return; // stale — ignore
|
|
890
|
+
if ((e as Error)?.name === "AbortError") return;
|
|
891
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
892
|
+
} finally {
|
|
893
|
+
if (myId === requestIdRef.current) setLoading(false);
|
|
894
|
+
}
|
|
895
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
896
|
+
}, [entity, specKey]);
|
|
897
|
+
|
|
898
|
+
// Initial fetch + re-fetch when the semantic spec changes.
|
|
899
|
+
useEffect(() => {
|
|
900
|
+
void run();
|
|
901
|
+
}, [run]);
|
|
902
|
+
|
|
903
|
+
// Live refresh: subscribe to sync events, re-run on any change that
|
|
904
|
+
// touches this entity. 150ms debounce coalesces burst writes (WS
|
|
905
|
+
// replay, bulk import) into one refetch.
|
|
906
|
+
useEffect(() => {
|
|
907
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
908
|
+
const unsub = sync.store.subscribe((changedEntity?: string) => {
|
|
909
|
+
if (changedEntity && changedEntity !== entity) return;
|
|
910
|
+
if (pending) clearTimeout(pending);
|
|
911
|
+
pending = setTimeout(() => {
|
|
912
|
+
void run();
|
|
913
|
+
}, 150);
|
|
914
|
+
});
|
|
915
|
+
return () => {
|
|
916
|
+
if (pending) clearTimeout(pending);
|
|
917
|
+
unsub();
|
|
918
|
+
};
|
|
919
|
+
}, [sync, entity, run]);
|
|
920
|
+
|
|
921
|
+
return { hits, facetCounts, total, tookMs, loading, error, refresh: run };
|
|
922
|
+
}
|