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 | |
| 29 | import { onScopeDispose, ref, watch, type Ref } from 'vue'; |
| 30 | |
| 31 | import type { BrivenClient, InvokeFrame, SubscribeHandle } from '@briven/client'; |
| 32 | |
| 33 | let 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 | */ |
| 40 | export 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 | |
| 49 | export 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 | |
| 58 | export 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 | */ |
| 73 | export 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 | |
| 134 | export 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 | */ |
| 147 | export 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 | |
| 178 | function 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 | } |