index.ts205 lines · main
1/**
2 * @briven/react — React hooks for briven.
3 *
4 * import { BrivenProvider, useQuery, useMutation } from '@briven/react';
5 * import { createBrivenClient } from '@briven/client';
6 *
7 * const client = createBrivenClient({
8 * projectId: 'p_...',
9 * apiOrigin: 'https://api.briven.tech',
10 * wsOrigin: 'wss://ws.briven.tech',
11 * token: () => session.token,
12 * });
13 *
14 * <BrivenProvider client={client}>
15 * <App />
16 * </BrivenProvider>
17 *
18 * function NoteList({ userId }: { userId: string }) {
19 * const { data, error, isLoading } = useQuery<Note[]>('listNotes', { userId });
20 * // re-renders automatically when any table 'listNotes' touched changes
21 * }
22 */
23
24import {
25 createContext,
26 createElement,
27 useCallback,
28 useContext,
29 useEffect,
30 useRef,
31 useState,
32 type ReactNode,
33} from 'react';
34
35import type { BrivenClient, InvokeFrame, SubscribeHandle } from '@briven/client';
36
37const Ctx = createContext<BrivenClient | null>(null);
38
39export interface BrivenProviderProps {
40 client: BrivenClient;
41 children: ReactNode;
42}
43
44export function BrivenProvider({ client, children }: BrivenProviderProps) {
45 return createElement(Ctx.Provider, { value: client }, children);
46}
47
48export function useBrivenClient(): BrivenClient {
49 const client = useContext(Ctx);
50 if (!client) {
51 throw new Error('useBrivenClient must be used inside <BrivenProvider client={...}>');
52 }
53 return client;
54}
55
56export interface UseQueryResult<T> {
57 readonly data: T | undefined;
58 readonly error: { code: string; message: string } | undefined;
59 readonly isLoading: boolean;
60 readonly durationMs: number | undefined;
61 /** Force a re-fetch outside the normal subscription cycle. */
62 refetch(): void;
63}
64
65/**
66 * Subscribe to a briven function. The component re-renders on the initial
67 * value and on every push from realtime when an underlying table changes.
68 *
69 * `args` is JSON-stringified to detect changes — pass plain objects only.
70 */
71export function useQuery<TResult = unknown>(
72 functionName: string,
73 args: unknown = {},
74): UseQueryResult<TResult> {
75 const client = useBrivenClient();
76 const [state, setState] = useState<{
77 data: TResult | undefined;
78 error: { code: string; message: string } | undefined;
79 isLoading: boolean;
80 durationMs: number | undefined;
81 }>({ data: undefined, error: undefined, isLoading: true, durationMs: undefined });
82
83 const argsKey = stableKey(args);
84 const handleRef = useRef<SubscribeHandle | null>(null);
85
86 useEffect(() => {
87 let cancelled = false;
88 setState((s) => ({ ...s, isLoading: true, error: undefined }));
89
90 const handle = client.subscribe(functionName, args, (frame: InvokeFrame) => {
91 if (cancelled) return;
92 if (frame.ok) {
93 setState({
94 data: frame.value as TResult,
95 error: undefined,
96 isLoading: false,
97 durationMs: frame.durationMs,
98 });
99 } else {
100 setState((s) => ({
101 ...s,
102 error: { code: frame.code, message: frame.message },
103 isLoading: false,
104 durationMs: frame.durationMs,
105 }));
106 }
107 });
108 handleRef.current = handle;
109
110 return () => {
111 cancelled = true;
112 handle.close();
113 handleRef.current = null;
114 };
115 // argsKey captures arg shape; functionName + client identity are stable.
116 }, [client, functionName, argsKey]);
117
118 const refetch = useCallback(() => {
119 handleRef.current?.close();
120 setState((s) => ({ ...s, isLoading: true }));
121 handleRef.current = client.subscribe(functionName, args, (frame: InvokeFrame) => {
122 if (frame.ok) {
123 setState({
124 data: frame.value as TResult,
125 error: undefined,
126 isLoading: false,
127 durationMs: frame.durationMs,
128 });
129 } else {
130 setState((s) => ({
131 ...s,
132 error: { code: frame.code, message: frame.message },
133 isLoading: false,
134 durationMs: frame.durationMs,
135 }));
136 }
137 });
138 }, [client, functionName, argsKey]);
139
140 return { ...state, refetch };
141}
142
143export interface UseMutationResult<TArgs, TResult> {
144 readonly isPending: boolean;
145 readonly error: { code: string; message: string } | undefined;
146 readonly data: TResult | undefined;
147 mutate(args: TArgs): Promise<TResult | undefined>;
148 reset(): void;
149}
150
151/**
152 * One-shot mutation over HTTP. Mutations don't auto-subscribe — the
153 * realtime triggers fire from the postgres side, so any open `useQuery`
154 * affected by this mutation will receive a fresh frame on its own.
155 */
156export function useMutation<TArgs = unknown, TResult = unknown>(
157 functionName: string,
158): UseMutationResult<TArgs, TResult> {
159 const client = useBrivenClient();
160 const [state, setState] = useState<{
161 isPending: boolean;
162 error: { code: string; message: string } | undefined;
163 data: TResult | undefined;
164 }>({ isPending: false, error: undefined, data: undefined });
165
166 const mutate = useCallback(
167 async (args: TArgs): Promise<TResult | undefined> => {
168 setState({ isPending: true, error: undefined, data: undefined });
169 const frame = await client.invoke(functionName, args);
170 if (frame.ok) {
171 setState({ isPending: false, error: undefined, data: frame.value as TResult });
172 return frame.value as TResult;
173 }
174 setState({
175 isPending: false,
176 error: { code: frame.code, message: frame.message },
177 data: undefined,
178 });
179 return undefined;
180 },
181 [client, functionName],
182 );
183
184 const reset = useCallback(() => {
185 setState({ isPending: false, error: undefined, data: undefined });
186 }, []);
187
188 return { ...state, mutate, reset };
189}
190
191function stableKey(value: unknown): string {
192 // Order keys alphabetically so `{a:1, b:2}` and `{b:2, a:1}` produce
193 // the same subscription identity. Cheap; not bullet-proof for cycles
194 // (callers should pass plain JSON values).
195 return JSON.stringify(value, (_, v) => {
196 if (v && typeof v === 'object' && !Array.isArray(v)) {
197 const sorted: Record<string, unknown> = {};
198 for (const k of Object.keys(v as Record<string, unknown>).sort()) {
199 sorted[k] = (v as Record<string, unknown>)[k];
200 }
201 return sorted;
202 }
203 return v;
204 });
205}