@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/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
+ }