index.ts167 lines · main
| 1 | /** |
| 2 | * @briven/svelte — Svelte stores for briven. |
| 3 | * |
| 4 | * import { setBrivenClient, query, mutation } from '@briven/svelte'; |
| 5 | * import { createBrivenClient } from '@briven/client'; |
| 6 | * |
| 7 | * // once at app boot |
| 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 lang="ts"> |
| 16 | * import { query, mutation } from '@briven/svelte'; |
| 17 | * |
| 18 | * const notes = query<Note[]>('listNotes', { userId }); |
| 19 | * const addNote = mutation<{ body: string }, Note>('addNote'); |
| 20 | * </script> |
| 21 | * |
| 22 | * {#if $notes.isLoading}loading… |
| 23 | * {:else if $notes.error}{$notes.error.message} |
| 24 | * {:else} |
| 25 | * {#each $notes.data ?? [] as n}<li>{n.body}</li>{/each} |
| 26 | * {/if} |
| 27 | * |
| 28 | * Re-fetch + unsubscribe lifecycle is automatic — the store closes its |
| 29 | * subscription when no Svelte component is subscribed (reference-counted |
| 30 | * by Svelte's store contract). |
| 31 | */ |
| 32 | |
| 33 | import { readable, writable, type Readable } from 'svelte/store'; |
| 34 | |
| 35 | import type { BrivenClient, InvokeFrame, SubscribeHandle } from '@briven/client'; |
| 36 | |
| 37 | let activeClient: BrivenClient | null = null; |
| 38 | |
| 39 | /** |
| 40 | * Set the BrivenClient instance every store will use. Call once at app |
| 41 | * boot. Throws if called more than once with a different client (avoids |
| 42 | * accidental re-init in HMR). |
| 43 | */ |
| 44 | export function setBrivenClient(client: BrivenClient): void { |
| 45 | if (activeClient && activeClient !== client) { |
| 46 | throw new Error( |
| 47 | 'setBrivenClient called twice with different clients — call it once at boot', |
| 48 | ); |
| 49 | } |
| 50 | activeClient = client; |
| 51 | } |
| 52 | |
| 53 | export function getBrivenClient(): BrivenClient { |
| 54 | if (!activeClient) { |
| 55 | throw new Error( |
| 56 | 'no BrivenClient set — call setBrivenClient(createBrivenClient({...})) at app boot', |
| 57 | ); |
| 58 | } |
| 59 | return activeClient; |
| 60 | } |
| 61 | |
| 62 | export interface QueryState<T> { |
| 63 | readonly data: T | undefined; |
| 64 | readonly error: { code: string; message: string } | undefined; |
| 65 | readonly isLoading: boolean; |
| 66 | readonly durationMs: number | undefined; |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Subscribe to a briven function. Returns a Svelte readable store that |
| 71 | * starts subscribing on first $-bind and unsubscribes when the last |
| 72 | * subscriber drops away. |
| 73 | */ |
| 74 | export function query<TResult = unknown>( |
| 75 | functionName: string, |
| 76 | args: unknown = {}, |
| 77 | ): Readable<QueryState<TResult>> { |
| 78 | const client = getBrivenClient(); |
| 79 | return readable<QueryState<TResult>>( |
| 80 | { data: undefined, error: undefined, isLoading: true, durationMs: undefined }, |
| 81 | (set) => { |
| 82 | let cancelled = false; |
| 83 | const handle: SubscribeHandle = client.subscribe( |
| 84 | functionName, |
| 85 | args, |
| 86 | (frame: InvokeFrame) => { |
| 87 | if (cancelled) return; |
| 88 | if (frame.ok) { |
| 89 | set({ |
| 90 | data: frame.value as TResult, |
| 91 | error: undefined, |
| 92 | isLoading: false, |
| 93 | durationMs: frame.durationMs, |
| 94 | }); |
| 95 | } else { |
| 96 | set({ |
| 97 | data: undefined, |
| 98 | error: { code: frame.code, message: frame.message }, |
| 99 | isLoading: false, |
| 100 | durationMs: frame.durationMs, |
| 101 | }); |
| 102 | } |
| 103 | }, |
| 104 | ); |
| 105 | return () => { |
| 106 | cancelled = true; |
| 107 | handle.close(); |
| 108 | }; |
| 109 | }, |
| 110 | ); |
| 111 | } |
| 112 | |
| 113 | export interface MutationState<TResult> { |
| 114 | readonly isPending: boolean; |
| 115 | readonly error: { code: string; message: string } | undefined; |
| 116 | readonly data: TResult | undefined; |
| 117 | } |
| 118 | |
| 119 | export interface MutationStore<TArgs, TResult> extends Readable<MutationState<TResult>> { |
| 120 | mutate(args: TArgs): Promise<TResult | undefined>; |
| 121 | reset(): void; |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * One-shot mutation over HTTP. Use the returned store's `.mutate(args)` |
| 126 | * to fire the call; its state stream gives you `isPending` / `error` / |
| 127 | * `data` for the UI. Mutations don't auto-subscribe — reactive queries |
| 128 | * affected by the write receive their own fresh frames via the |
| 129 | * realtime LISTEN/NOTIFY pipeline. |
| 130 | */ |
| 131 | export function mutation<TArgs = unknown, TResult = unknown>( |
| 132 | functionName: string, |
| 133 | ): MutationStore<TArgs, TResult> { |
| 134 | const client = getBrivenClient(); |
| 135 | const initial: MutationState<TResult> = { |
| 136 | isPending: false, |
| 137 | error: undefined, |
| 138 | data: undefined, |
| 139 | }; |
| 140 | const store = writable<MutationState<TResult>>(initial); |
| 141 | |
| 142 | async function mutate(args: TArgs): Promise<TResult | undefined> { |
| 143 | store.set({ isPending: true, error: undefined, data: undefined }); |
| 144 | const frame = await client.invoke(functionName, args); |
| 145 | if (frame.ok) { |
| 146 | const value = frame.value as TResult; |
| 147 | store.set({ isPending: false, error: undefined, data: value }); |
| 148 | return value; |
| 149 | } |
| 150 | store.set({ |
| 151 | isPending: false, |
| 152 | error: { code: frame.code, message: frame.message }, |
| 153 | data: undefined, |
| 154 | }); |
| 155 | return undefined; |
| 156 | } |
| 157 | |
| 158 | function reset(): void { |
| 159 | store.set(initial); |
| 160 | } |
| 161 | |
| 162 | return { |
| 163 | subscribe: store.subscribe, |
| 164 | mutate, |
| 165 | reset, |
| 166 | }; |
| 167 | } |