index.ts189 lines · main
1/**
2 * @briven/vue — Vue 3 composables for briven.
3 *
4 * import { setBrivenClient, useQuery, useMutation } from '@briven/vue';
5 * import { createBrivenClient } from '@briven/client';
6 *
7 * // once at app boot, before app.mount()
8 * setBrivenClient(createBrivenClient({
9 * projectId: 'p_...',
10 * apiOrigin: 'https://api.briven.tech',
11 * wsOrigin: 'wss://ws.briven.tech',
12 * token: () => session.token,
13 * }));
14 *
15 * <script setup lang="ts">
16 * import { useQuery, useMutation } from '@briven/vue';
17 *
18 * const { data, error, isLoading } = useQuery<Note[]>('listNotes', { userId });
19 * const addNote = useMutation<{ body: string }, Note>('addNote');
20 * </script>
21 *
22 * <template>
23 * <p v-if="isLoading">loading…</p>
24 * <p v-else-if="error">{{ error.message }}</p>
25 * <li v-for="n in data ?? []" :key="n.id">{{ n.body }}</li>
26 * </template>
27 */
28
29import { onScopeDispose, ref, watch, type Ref } from 'vue';
30
31import type { BrivenClient, InvokeFrame, SubscribeHandle } from '@briven/client';
32
33let activeClient: BrivenClient | null = null;
34
35/**
36 * Set the BrivenClient instance every composable will use. Call once at
37 * app boot, before `app.mount()`. Throws if called more than once with
38 * a different client (avoids accidental re-init in HMR).
39 */
40export function setBrivenClient(client: BrivenClient): void {
41 if (activeClient && activeClient !== client) {
42 throw new Error(
43 'setBrivenClient called twice with different clients — call it once at boot',
44 );
45 }
46 activeClient = client;
47}
48
49export function getBrivenClient(): BrivenClient {
50 if (!activeClient) {
51 throw new Error(
52 'no BrivenClient set — call setBrivenClient(createBrivenClient({...})) at app boot',
53 );
54 }
55 return activeClient;
56}
57
58export interface UseQueryResult<T> {
59 readonly data: Ref<T | undefined>;
60 readonly error: Ref<{ code: string; message: string } | undefined>;
61 readonly isLoading: Ref<boolean>;
62 readonly durationMs: Ref<number | undefined>;
63 /** Force a re-fetch outside the normal subscription cycle. */
64 refetch(): void;
65}
66
67/**
68 * Subscribe to a briven function. The returned refs update on initial
69 * value and on every push from realtime when an underlying table
70 * changes. Subscription closes on `onScopeDispose` (when the component
71 * unmounts).
72 */
73export function useQuery<TResult = unknown>(
74 functionName: string,
75 args: unknown = {},
76): UseQueryResult<TResult> {
77 const client = getBrivenClient();
78 const data = ref<TResult | undefined>(undefined) as Ref<TResult | undefined>;
79 const error = ref<{ code: string; message: string } | undefined>(undefined);
80 const isLoading = ref<boolean>(true);
81 const durationMs = ref<number | undefined>(undefined);
82
83 let handle: SubscribeHandle | null = null;
84 let cancelled = false;
85
86 function start(): void {
87 cancelled = false;
88 isLoading.value = true;
89 error.value = undefined;
90 handle = client.subscribe(functionName, args, (frame: InvokeFrame) => {
91 if (cancelled) return;
92 if (frame.ok) {
93 data.value = frame.value as TResult;
94 error.value = undefined;
95 isLoading.value = false;
96 durationMs.value = frame.durationMs;
97 } else {
98 error.value = { code: frame.code, message: frame.message };
99 isLoading.value = false;
100 durationMs.value = frame.durationMs;
101 }
102 });
103 }
104
105 function stop(): void {
106 cancelled = true;
107 handle?.close();
108 handle = null;
109 }
110
111 start();
112
113 // Re-subscribe if `args` is a reactive ref/object that the caller
114 // wants the query to track. Watch the JSON-stable shape so adding
115 // unrelated keys doesn't cycle.
116 watch(
117 () => stableKey(args),
118 () => {
119 stop();
120 start();
121 },
122 );
123
124 onScopeDispose(stop);
125
126 function refetch(): void {
127 stop();
128 start();
129 }
130
131 return { data, error, isLoading, durationMs, refetch };
132}
133
134export interface UseMutationResult<TArgs, TResult> {
135 readonly isPending: Ref<boolean>;
136 readonly error: Ref<{ code: string; message: string } | undefined>;
137 readonly data: Ref<TResult | undefined>;
138 mutate(args: TArgs): Promise<TResult | undefined>;
139 reset(): void;
140}
141
142/**
143 * One-shot mutation over HTTP. Mutations don't auto-subscribe — reactive
144 * queries affected by the write receive their own fresh frames via the
145 * realtime LISTEN/NOTIFY pipeline.
146 */
147export function useMutation<TArgs = unknown, TResult = unknown>(
148 functionName: string,
149): UseMutationResult<TArgs, TResult> {
150 const client = getBrivenClient();
151 const isPending = ref<boolean>(false);
152 const error = ref<{ code: string; message: string } | undefined>(undefined);
153 const data = ref<TResult | undefined>(undefined) as Ref<TResult | undefined>;
154
155 async function mutate(args: TArgs): Promise<TResult | undefined> {
156 isPending.value = true;
157 error.value = undefined;
158 data.value = undefined;
159 const frame = await client.invoke(functionName, args);
160 isPending.value = false;
161 if (frame.ok) {
162 data.value = frame.value as TResult;
163 return frame.value as TResult;
164 }
165 error.value = { code: frame.code, message: frame.message };
166 return undefined;
167 }
168
169 function reset(): void {
170 isPending.value = false;
171 error.value = undefined;
172 data.value = undefined;
173 }
174
175 return { isPending, error, data, mutate, reset };
176}
177
178function stableKey(value: unknown): string {
179 return JSON.stringify(value, (_, v) => {
180 if (v && typeof v === 'object' && !Array.isArray(v)) {
181 const sorted: Record<string, unknown> = {};
182 for (const k of Object.keys(v as Record<string, unknown>).sort()) {
183 sorted[k] = (v as Record<string, unknown>)[k];
184 }
185 return sorted;
186 }
187 return v;
188 });
189}