@bunbase-ae/react-sdk 1.0.0

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/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@bunbase-ae/react-sdk",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "React hooks for BunBase — caching, mutations, auth, realtime",
6
+ "files": [
7
+ "src"
8
+ ],
9
+ "main": "./src/index.ts",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./src/index.ts",
13
+ "types": "./src/index.ts"
14
+ }
15
+ },
16
+ "peerDependencies": {
17
+ "react": ">=19",
18
+ "react-dom": ">=19",
19
+ "@bunbase-ae/js": ">=1.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/react": "19.2.14",
23
+ "@types/react-dom": "19.2.3"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ }
28
+ }
@@ -0,0 +1,106 @@
1
+ import type { BunBaseClient } from "@bunbase-ae/js";
2
+ import { createContext, type ReactNode, useContext, useEffect, useState } from "react";
3
+ import { QueryCache, type QueryCacheStorage } from "./cache";
4
+
5
+ interface BunBaseContextValue {
6
+ client: BunBaseClient;
7
+ cache: QueryCache;
8
+ }
9
+
10
+ const BunBaseContext = createContext<BunBaseContextValue | null>(null);
11
+
12
+ export function BunBaseProvider({
13
+ client,
14
+ cacheStorage,
15
+ storagePrefix,
16
+ clearCacheOnLogout = false,
17
+ fallback = null,
18
+ children,
19
+ }: {
20
+ client: BunBaseClient;
21
+ /**
22
+ * Optional persistent storage adapter. Pass `localStorageAdapter` for web,
23
+ * or an AsyncStorage-compatible adapter for React Native.
24
+ *
25
+ * When provided, the cache is hydrated from storage before children render,
26
+ * eliminating loading states on subsequent visits.
27
+ */
28
+ cacheStorage?: QueryCacheStorage;
29
+ /**
30
+ * Prefix for storage keys. Default: `"bb_cache:"`.
31
+ * Override to namespace multiple BunBase apps sharing the same storage.
32
+ */
33
+ storagePrefix?: string;
34
+ /**
35
+ * What to clear from the cache (memory + storage) when the user logs out.
36
+ *
37
+ * - `false` (default) — nothing is cleared; all cached data survives logout.
38
+ * Use this when your app has public data you want to keep showing.
39
+ * - `true` — clears the entire cache on logout.
40
+ * - `string[]` — clears only entries whose cache key starts with one of
41
+ * these prefixes. Use this to wipe user-specific collections while keeping
42
+ * public ones cached.
43
+ *
44
+ * @example
45
+ * // Keep public horse listings, clear private orders on logout
46
+ * clearCacheOnLogout={["collection:orders", "collection:my-horses"]}
47
+ */
48
+ clearCacheOnLogout?: boolean | string[];
49
+ /**
50
+ * Rendered while the cache is hydrating from storage.
51
+ * Only relevant when `cacheStorage` is provided. Default: `null`.
52
+ */
53
+ fallback?: ReactNode;
54
+ children: ReactNode;
55
+ }): ReactNode {
56
+ const [cache] = useState(() => new QueryCache({ storage: cacheStorage, storagePrefix }));
57
+ const [hydrated, setHydrated] = useState(!cacheStorage);
58
+
59
+ // Hydrate from storage before first render when a storage adapter is provided.
60
+ useEffect(() => {
61
+ if (!cacheStorage) return;
62
+ cache.hydrate().then(() => setHydrated(true));
63
+ }, [cache, cacheStorage]);
64
+
65
+ // Keep the realtime connection open whenever the user is authenticated so that
66
+ // server-pushed auth events (session_revoked, password_changed, etc.) are delivered
67
+ // immediately — even when no data subscriptions are active.
68
+ useEffect(() => {
69
+ // Connect now if a session is already present (e.g. restored from storage).
70
+ if (client.auth.isAuthenticated()) client.realtime.connect();
71
+
72
+ // Connect on login, disconnect on logout. Clear cache on logout if configured.
73
+ return client.auth.onAuthChange((session) => {
74
+ if (session) {
75
+ client.realtime.connect();
76
+ } else {
77
+ client.realtime.disconnect();
78
+ if (clearCacheOnLogout === true) {
79
+ cache.clear();
80
+ } else if (Array.isArray(clearCacheOnLogout)) {
81
+ for (const prefix of clearCacheOnLogout) cache.clear(prefix);
82
+ }
83
+ }
84
+ });
85
+ }, [client, cache, clearCacheOnLogout]);
86
+
87
+ // Refetch stale entries when the tab becomes visible again.
88
+ useEffect(() => {
89
+ if (typeof document === "undefined") return;
90
+ function onVisibility() {
91
+ if (document.visibilityState === "visible") cache.refetchStale();
92
+ }
93
+ document.addEventListener("visibilitychange", onVisibility);
94
+ return () => document.removeEventListener("visibilitychange", onVisibility);
95
+ }, [cache]);
96
+
97
+ if (!hydrated) return <>{fallback}</>;
98
+
99
+ return <BunBaseContext.Provider value={{ client, cache }}>{children}</BunBaseContext.Provider>;
100
+ }
101
+
102
+ export function useBunBase(): BunBaseContextValue {
103
+ const ctx = useContext(BunBaseContext);
104
+ if (!ctx) throw new Error("useBunBase must be called inside <BunBaseProvider>.");
105
+ return ctx;
106
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,403 @@
1
+ // QueryCache — stale-while-revalidate cache for BunBase React hooks.
2
+ //
3
+ // Semantics:
4
+ // - First fetch: loading → success/error, subscribers notified.
5
+ // - Stale refetch: show stale data immediately, refetch in background, notify when done.
6
+ // - Deduplication: concurrent fetches for the same key share one in-flight Promise.
7
+ // - Invalidation: clear matching keys and notify subscribers to trigger re-fetch.
8
+ // - Shallow equal: background refetch results are compared shallowly to existing data;
9
+ // if nothing changed, subscribers are NOT notified (no re-render).
10
+ // - Storage: optional persistent storage adapter (localStorage, AsyncStorage, etc.)
11
+ // Entries are hydrated on init and flushed on every write.
12
+
13
+ type Notify = () => void;
14
+
15
+ export interface CacheEntry {
16
+ data: unknown;
17
+ error: Error | null;
18
+ /** "loading" = no data yet. "success"/"error" = settled (may be stale). */
19
+ status: "loading" | "success" | "error";
20
+ /** Timestamp (ms) when data becomes stale. 0 = already stale. */
21
+ staleAt: number;
22
+ /** In-flight Promise — used for deduplication. */
23
+ promise: Promise<void> | null;
24
+ /** Stored so focus-triggered refetch can re-run without external reference. */
25
+ fetcher: (() => Promise<unknown>) | null;
26
+ staleTime: number;
27
+ }
28
+
29
+ // ─── Storage adapter ─────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Pluggable storage adapter for persisting the query cache across sessions.
33
+ *
34
+ * Both `localStorage` (via `localStorageAdapter`) and React Native's
35
+ * `AsyncStorage` fit this interface — all methods may return a Promise or a
36
+ * plain value.
37
+ */
38
+ export interface QueryCacheStorage {
39
+ getItem(key: string): string | null | Promise<string | null>;
40
+ setItem(key: string, value: string): void | Promise<void>;
41
+ removeItem(key: string): void | Promise<void>;
42
+ /** Return all keys currently in storage (used for hydration and prefix invalidation). */
43
+ getAllKeys(): string[] | Promise<string[]>;
44
+ }
45
+
46
+ /** Ready-to-use adapter for the browser `localStorage` API. */
47
+ export const localStorageAdapter: QueryCacheStorage = {
48
+ getItem: (key) => localStorage.getItem(key),
49
+ setItem: (key, value) => localStorage.setItem(key, value),
50
+ removeItem: (key) => localStorage.removeItem(key),
51
+ getAllKeys: () => Object.keys(localStorage),
52
+ };
53
+
54
+ // What gets written to storage — excludes non-serialisable fields.
55
+ type StoredEntry = { data: unknown; staleAt: number; staleTime: number };
56
+
57
+ // ─── Shallow equality ────────────────────────────────────────────────────────
58
+ //
59
+ // Compares two values one level deep.
60
+ // - Primitives: strict equality.
61
+ // - Arrays: same length + each element ===.
62
+ // - Objects: same keys + each value ===.
63
+ // Good enough for flat records and ListResult shapes. Deep nesting (expand,
64
+ // nested objects) will still trigger a re-render, which is the safe default.
65
+
66
+ function shallowEqual(a: unknown, b: unknown): boolean {
67
+ if (a === b) return true;
68
+ if (a === null || b === null) return false;
69
+ if (typeof a !== "object" || typeof b !== "object") return false;
70
+
71
+ if (Array.isArray(a) && Array.isArray(b)) {
72
+ if (a.length !== b.length) return false;
73
+ for (let i = 0; i < a.length; i++) {
74
+ if (a[i] !== b[i]) return false;
75
+ }
76
+ return true;
77
+ }
78
+
79
+ if (Array.isArray(a) || Array.isArray(b)) return false;
80
+
81
+ const aObj = a as Record<string, unknown>;
82
+ const bObj = b as Record<string, unknown>;
83
+ const aKeys = Object.keys(aObj);
84
+ const bKeys = Object.keys(bObj);
85
+ if (aKeys.length !== bKeys.length) return false;
86
+ for (const key of aKeys) {
87
+ if (!(key in bObj) || aObj[key] !== bObj[key]) return false;
88
+ }
89
+ return true;
90
+ }
91
+
92
+ // ─── QueryCache ───────────────────────────────────────────────────────────────
93
+
94
+ export interface QueryCacheOptions {
95
+ storage?: QueryCacheStorage;
96
+ /**
97
+ * Prefix applied to every storage key to avoid collisions with other data
98
+ * in the same storage. Default: `"bb_cache:"`.
99
+ */
100
+ storagePrefix?: string;
101
+ }
102
+
103
+ export class QueryCache {
104
+ private readonly entries = new Map<string, CacheEntry>();
105
+ private readonly subs = new Map<string, Set<Notify>>();
106
+ private readonly focusSubs = new Set<Notify>();
107
+ private readonly storage: QueryCacheStorage | undefined;
108
+ private readonly prefix: string;
109
+
110
+ constructor(options: QueryCacheOptions = {}) {
111
+ this.storage = options.storage;
112
+ this.prefix = options.storagePrefix ?? "bb_cache:";
113
+ }
114
+
115
+ // ─── Storage helpers ──────────────────────────────────────────────────────
116
+
117
+ private storageKey(key: string): string {
118
+ return `${this.prefix}${key}`;
119
+ }
120
+
121
+ private persist(key: string, entry: CacheEntry): void {
122
+ if (!this.storage) return;
123
+ const stored: StoredEntry = {
124
+ data: entry.data,
125
+ staleAt: entry.staleAt,
126
+ staleTime: entry.staleTime,
127
+ };
128
+ // Fire-and-forget — storage writes must not block the in-memory cache.
129
+ Promise.resolve(this.storage.setItem(this.storageKey(key), JSON.stringify(stored))).catch(
130
+ () => {},
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Hydrate the in-memory cache from storage.
136
+ * Call this once before rendering and await it — entries will be available
137
+ * immediately so hooks show data on the very first render without a loading state.
138
+ * Stale entries are hydrated too; hooks will background-refetch them as usual.
139
+ */
140
+ async hydrate(): Promise<void> {
141
+ if (!this.storage) return;
142
+ try {
143
+ const allKeys = await this.storage.getAllKeys();
144
+ const cacheKeys = allKeys.filter((k) => k.startsWith(this.prefix));
145
+ await Promise.all(
146
+ cacheKeys.map(async (storageKey) => {
147
+ try {
148
+ const raw = await this.storage?.getItem(storageKey);
149
+ if (!raw) return;
150
+ const stored = JSON.parse(raw) as StoredEntry;
151
+ const internalKey = storageKey.slice(this.prefix.length);
152
+ // Don't overwrite entries that may have been set before hydration.
153
+ if (this.entries.has(internalKey)) return;
154
+ this.entries.set(internalKey, {
155
+ data: stored.data,
156
+ error: null,
157
+ status: "success",
158
+ staleAt: stored.staleAt,
159
+ promise: null,
160
+ fetcher: null,
161
+ staleTime: stored.staleTime,
162
+ });
163
+ } catch {
164
+ // Corrupt entry — skip silently.
165
+ }
166
+ }),
167
+ );
168
+ } catch {
169
+ // Storage unavailable (e.g. privacy mode) — start with empty cache.
170
+ }
171
+ }
172
+
173
+ // ─── Subscriptions ──────────────────────────────────────────────────────────
174
+
175
+ subscribe(key: string, fn: Notify): () => void {
176
+ let set = this.subs.get(key);
177
+ if (!set) {
178
+ set = new Set();
179
+ this.subs.set(key, set);
180
+ }
181
+ set.add(fn);
182
+ return () => {
183
+ const s = this.subs.get(key);
184
+ if (!s) return;
185
+ s.delete(fn);
186
+ if (s.size === 0) this.subs.delete(key);
187
+ };
188
+ }
189
+
190
+ /** Register a callback that fires when the window regains focus. */
191
+ onFocus(fn: Notify): () => void {
192
+ this.focusSubs.add(fn);
193
+ return () => this.focusSubs.delete(fn);
194
+ }
195
+
196
+ /** Called by BunBaseProvider on visibilitychange. */
197
+ triggerFocus(): void {
198
+ for (const fn of this.focusSubs) fn();
199
+ }
200
+
201
+ private notify(key: string): void {
202
+ const set = this.subs.get(key);
203
+ if (!set) return;
204
+ for (const fn of set) fn();
205
+ }
206
+
207
+ // ─── Read ───────────────────────────────────────────────────────────────────
208
+
209
+ get(key: string): CacheEntry | undefined {
210
+ return this.entries.get(key);
211
+ }
212
+
213
+ isStale(key: string): boolean {
214
+ const e = this.entries.get(key);
215
+ if (!e || e.status !== "success") return true;
216
+ return Date.now() >= e.staleAt;
217
+ }
218
+
219
+ // ─── Fetch ──────────────────────────────────────────────────────────────────
220
+
221
+ /**
222
+ * Fetch data for `key` using `fetcher`.
223
+ *
224
+ * - If data is fresh: no-op (returns immediately).
225
+ * - If stale or missing: fetches in background; shows stale data while fetching (SWR).
226
+ * - If already in flight: deduplicates (does nothing).
227
+ * - If new data is shallowly equal to existing: updates staleAt but skips notify.
228
+ */
229
+ fetch(key: string, fetcher: () => Promise<unknown>, staleTime: number): void {
230
+ const existing = this.entries.get(key);
231
+
232
+ // Deduplicate concurrent fetches.
233
+ if (existing?.promise) return;
234
+
235
+ // Data is fresh — nothing to do.
236
+ if (existing?.status === "success" && Date.now() < existing.staleAt) return;
237
+
238
+ // Keep existing data visible while refetching (SWR).
239
+ const entry: CacheEntry = {
240
+ data: existing?.data,
241
+ error: null,
242
+ status: existing?.data !== undefined ? "success" : "loading",
243
+ staleAt: existing?.staleAt ?? 0,
244
+ promise: null,
245
+ fetcher,
246
+ staleTime,
247
+ };
248
+
249
+ const promise: Promise<void> = fetcher().then(
250
+ (data) => {
251
+ if (this.entries.get(key)?.promise !== promise) return; // superseded
252
+ const prev = this.entries.get(key);
253
+ const changed = !shallowEqual(prev?.data, data);
254
+ const next: CacheEntry = {
255
+ data,
256
+ error: null,
257
+ status: "success",
258
+ staleAt: Date.now() + staleTime,
259
+ promise: null,
260
+ fetcher,
261
+ staleTime,
262
+ };
263
+ this.entries.set(key, next);
264
+ this.persist(key, next);
265
+ // Skip re-render if data is identical.
266
+ if (changed) this.notify(key);
267
+ },
268
+ (err: unknown) => {
269
+ const current = this.entries.get(key);
270
+ if (current?.promise !== promise) return;
271
+ this.entries.set(key, {
272
+ data: current?.data,
273
+ error: err instanceof Error ? err : new Error(String(err)),
274
+ status: "error",
275
+ staleAt: 0,
276
+ promise: null,
277
+ fetcher,
278
+ staleTime,
279
+ });
280
+ this.notify(key);
281
+ },
282
+ );
283
+
284
+ entry.promise = promise;
285
+ this.entries.set(key, entry);
286
+
287
+ // Only notify for loading state when there's no cached data to show yet.
288
+ if (existing?.data === undefined) this.notify(key);
289
+ }
290
+
291
+ /** Force a re-fetch regardless of freshness. */
292
+ refetch(key: string): void {
293
+ const entry = this.entries.get(key);
294
+ if (!entry?.fetcher) return;
295
+ this.entries.set(key, { ...entry, staleAt: 0, promise: null });
296
+ this.fetch(key, entry.fetcher, entry.staleTime);
297
+ }
298
+
299
+ // ─── Invalidation ────────────────────────────────────────────────────────────
300
+
301
+ /**
302
+ * Mark all entries whose key starts with `prefix` as stale and notify subscribers.
303
+ *
304
+ * Data is KEPT so components remain in a loaded state (not loading) while the
305
+ * background re-fetch runs. Subscribers should call `cache.fetch()` when they
306
+ * receive a notification and find the entry is stale.
307
+ */
308
+ invalidate(prefix: string): void {
309
+ const toNotify: string[] = [];
310
+ for (const [key, entry] of this.entries) {
311
+ if (key.startsWith(prefix)) {
312
+ this.entries.set(key, { ...entry, staleAt: 0, promise: null });
313
+ toNotify.push(key);
314
+ }
315
+ }
316
+ for (const key of toNotify) this.notify(key);
317
+ }
318
+
319
+ /**
320
+ * Write data only if the key has no existing entry.
321
+ * Used to pre-populate record caches from list results without
322
+ * overwriting fresher data from a direct fetch or realtime event.
323
+ */
324
+ setDataIfMissing(key: string, data: unknown, staleTime: number): void {
325
+ if (this.entries.has(key)) return;
326
+ const entry: CacheEntry = {
327
+ data,
328
+ error: null,
329
+ status: "success",
330
+ staleAt: Date.now() + staleTime,
331
+ promise: null,
332
+ fetcher: null,
333
+ staleTime,
334
+ };
335
+ this.entries.set(key, entry);
336
+ this.persist(key, entry);
337
+ this.notify(key);
338
+ }
339
+
340
+ /** Manually set cache data (optimistic updates, mutation results, realtime events). */
341
+ setData(key: string, data: unknown): void {
342
+ const existing = this.entries.get(key);
343
+ // Skip notify if data hasn't changed — still bump staleAt.
344
+ if (shallowEqual(existing?.data, data)) {
345
+ if (existing) existing.staleAt = Date.now() + existing.staleTime;
346
+ return;
347
+ }
348
+ const staleTime = existing?.staleTime ?? 30_000;
349
+ const entry: CacheEntry = {
350
+ data,
351
+ error: null,
352
+ status: "success",
353
+ staleAt: Date.now() + staleTime,
354
+ promise: null,
355
+ fetcher: existing?.fetcher ?? null,
356
+ staleTime,
357
+ };
358
+ this.entries.set(key, entry);
359
+ this.persist(key, entry);
360
+ this.notify(key);
361
+ }
362
+
363
+ /**
364
+ * Remove entries from memory and persistent storage.
365
+ *
366
+ * @param prefix - If provided, only entries whose key starts with this string
367
+ * are removed. Omit to clear everything.
368
+ *
369
+ * Subscribers are notified so components transition back to a loading state.
370
+ * Fire-and-forget for storage — the in-memory cache is always updated synchronously.
371
+ *
372
+ * @example
373
+ * // Clear everything (e.g. full sign-out)
374
+ * cache.clear();
375
+ *
376
+ * // Clear only user-specific collections (keep public data)
377
+ * cache.clear("collection:orders");
378
+ */
379
+ clear(prefix?: string): void {
380
+ const toDelete: string[] = [];
381
+ for (const key of this.entries.keys()) {
382
+ if (!prefix || key.startsWith(prefix)) toDelete.push(key);
383
+ }
384
+ for (const key of toDelete) {
385
+ this.entries.delete(key);
386
+ if (this.storage) {
387
+ Promise.resolve(this.storage.removeItem(this.storageKey(key))).catch(() => {});
388
+ }
389
+ this.notify(key);
390
+ }
391
+ }
392
+
393
+ /** Re-fetch all stale or errored entries. Called on window focus. */
394
+ refetchStale(): void {
395
+ for (const [key, entry] of this.entries) {
396
+ if (!entry.fetcher) continue;
397
+ const isStale = entry.status === "error" || Date.now() >= entry.staleAt;
398
+ if (isStale && !entry.promise) {
399
+ this.fetch(key, entry.fetcher, entry.staleTime);
400
+ }
401
+ }
402
+ }
403
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ // @bunbase/react — React hooks for BunBase
2
+ //
3
+ // Usage:
4
+ // import { BunBaseProvider, useList, useCreate, useAuth } from "@bunbase-ae/react-sdk;
5
+ //
6
+ // const client = new BunBaseClient({ url: "https://api.example.com" });
7
+ //
8
+ // function App() {
9
+ // return (
10
+ // <BunBaseProvider client={client}>
11
+ // <Posts />
12
+ // </BunBaseProvider>
13
+ // );
14
+ // }
15
+ //
16
+ // function Posts() {
17
+ // const { data, loading, error } = useList("posts", { sort: "-_created_at" });
18
+ // const create = useCreate("posts");
19
+ // // ...
20
+ // }
21
+
22
+ export type { WithExpand } from "@bunbase-ae/js";
23
+ export { BunBaseProvider, useBunBase } from "./Context";
24
+ export type { CacheEntry, QueryCacheOptions, QueryCacheStorage } from "./cache";
25
+ export { localStorageAdapter, QueryCache } from "./cache";
26
+ export type { UploadOptions } from "./types";
27
+ export type { UseAuthResult } from "./useAuth";
28
+ export { useAuth } from "./useAuth";
29
+ export type { UseListOptions, UseListResult } from "./useList";
30
+ export { useList } from "./useList";
31
+ export type { MutationResult } from "./useMutation";
32
+ export { useCreate, useDelete, useUpdate } from "./useMutation";
33
+ export type { UsePresenceResult, UseRealtimeOptions, UseRealtimeResult } from "./useRealtime";
34
+ export {
35
+ useCollectionEvents,
36
+ usePresence,
37
+ useRealtime,
38
+ useRecordEvents,
39
+ } from "./useRealtime";
40
+ export type { UseRecordOptions, UseRecordResult } from "./useRecord";
41
+ export { useRecord } from "./useRecord";
42
+ export type { UseUploadResult } from "./useUpload";
43
+ export { useUpload } from "./useUpload";
package/src/types.ts ADDED
@@ -0,0 +1 @@
1
+ export type { SignedUploadResult, UploadOptions } from "@bunbase-ae/js";
package/src/useAuth.ts ADDED
@@ -0,0 +1,174 @@
1
+ // useAuth — reactive auth state with optional selector to prevent extra re-renders.
2
+ //
3
+ // Basic usage (re-renders on any auth state change):
4
+ // const { user, isAuthenticated, login, logout } = useAuth();
5
+ //
6
+ // Selector usage (re-renders ONLY when the selected value changes):
7
+ // const user = useAuth(s => s.user);
8
+ // const isAuth = useAuth(s => s.isAuthenticated);
9
+ // const { user, loading } = useAuth(s => ({ user: s.user, loading: s.loading }));
10
+ //
11
+ // Shallow equality is used for selector results — objects are compared by their
12
+ // own enumerable properties so { user, loading } won't trigger a re-render when
13
+ // neither value changed.
14
+
15
+ import type { AuthUser, LoginResult } from "@bunbase-ae/js";
16
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
17
+ import { useBunBase } from "./Context";
18
+
19
+ export interface UseAuthResult {
20
+ user: AuthUser | null;
21
+ /** True while the initial auth check is running. */
22
+ loading: boolean;
23
+ error: Error | null;
24
+ isAuthenticated: boolean;
25
+ login: (email: string, password: string) => Promise<LoginResult>;
26
+ register: (email: string, password: string) => Promise<void>;
27
+ logout: () => Promise<void>;
28
+ clearError: () => void;
29
+ }
30
+
31
+ // Shallow equality — primitives by identity, objects by own property identity.
32
+ function shallowEqual(a: unknown, b: unknown): boolean {
33
+ if (a === b) return true;
34
+ if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false;
35
+ const ka = Object.keys(a as object);
36
+ const kb = Object.keys(b as object);
37
+ if (ka.length !== kb.length) return false;
38
+ for (const k of ka) {
39
+ if ((a as Record<string, unknown>)[k] !== (b as Record<string, unknown>)[k]) return false;
40
+ }
41
+ return true;
42
+ }
43
+
44
+ export function useAuth(): UseAuthResult;
45
+ export function useAuth<T>(selector: (s: UseAuthResult) => T): T;
46
+ export function useAuth<T>(selector?: (s: UseAuthResult) => T): UseAuthResult | T {
47
+ const { client } = useBunBase();
48
+
49
+ // ─── Actions ─────────────────────────────────────────────────────────────────
50
+
51
+ const login = useCallback(
52
+ async (email: string, password: string): Promise<LoginResult> => {
53
+ client.auth.patchSnapshot({ error: null });
54
+ try {
55
+ return await client.auth.login({ email, password });
56
+ } catch (err) {
57
+ const e = err instanceof Error ? err : new Error(String(err));
58
+ client.auth.patchSnapshot({ error: e });
59
+ throw e;
60
+ }
61
+ },
62
+ [client],
63
+ );
64
+
65
+ const register = useCallback(
66
+ async (email: string, password: string): Promise<void> => {
67
+ client.auth.patchSnapshot({ error: null });
68
+ try {
69
+ await client.auth.register({ email, password });
70
+ } catch (err) {
71
+ const e = err instanceof Error ? err : new Error(String(err));
72
+ client.auth.patchSnapshot({ error: e });
73
+ throw e;
74
+ }
75
+ },
76
+ [client],
77
+ );
78
+
79
+ const logout = useCallback(async (): Promise<void> => {
80
+ client.auth.patchSnapshot({ error: null });
81
+ try {
82
+ await client.auth.logout();
83
+ } catch (err) {
84
+ const e = err instanceof Error ? err : new Error(String(err));
85
+ client.auth.patchSnapshot({ error: e });
86
+ throw e;
87
+ }
88
+ }, [client]);
89
+
90
+ const clearError = useCallback(() => {
91
+ client.auth.patchSnapshot({ error: null });
92
+ }, [client]);
93
+
94
+ // ─── External store subscription ─────────────────────────────────────────────
95
+
96
+ // Stable refs — updated every render so the getSnapshot closure always sees the
97
+ // latest selector/actions without needing to be in the useCallback deps array.
98
+ const actionsRef = useRef({ login, register, logout, clearError });
99
+ actionsRef.current = { login, register, logout, clearError };
100
+
101
+ const selectorRef = useRef(selector);
102
+ selectorRef.current = selector;
103
+
104
+ // Tracks the last returned value so we can return the same reference when
105
+ // shallowly equal — this is what causes useSyncExternalStore to bail out.
106
+ const prevRef = useRef<UseAuthResult | T | undefined>(undefined);
107
+
108
+ // getSnapshot is called by useSyncExternalStore on every store notification.
109
+ // It returns a stable reference when nothing the caller cares about changed.
110
+ const getSnapshot = useCallback((): UseAuthResult | T => {
111
+ const snap = client.auth.getSnapshot();
112
+ const full: UseAuthResult = {
113
+ ...snap,
114
+ isAuthenticated: snap.user !== null,
115
+ ...actionsRef.current,
116
+ };
117
+
118
+ if (!selectorRef.current) {
119
+ // No selector: compare full object with previous — actions are stable
120
+ // useCallbacks so this mainly changes when user/loading/error change.
121
+ if (shallowEqual(full, prevRef.current)) return prevRef.current as UseAuthResult;
122
+ prevRef.current = full;
123
+ return full;
124
+ }
125
+
126
+ const selected = selectorRef.current(full);
127
+ if (shallowEqual(selected, prevRef.current)) return prevRef.current as T;
128
+ prevRef.current = selected;
129
+ return selected;
130
+ }, [client]);
131
+
132
+ // ─── Session validation on mount ─────────────────────────────────────────────
133
+
134
+ // Silently validate/refresh the session. If a cached user already exists the
135
+ // UI shows it immediately and this just confirms it in the background.
136
+ useEffect(() => {
137
+ if (!client.auth.isAuthenticated()) return;
138
+ if (!client.auth.getCachedUser()) {
139
+ client.auth.patchSnapshot({ loading: true });
140
+ }
141
+ client.auth.me().then(
142
+ () => client.auth.patchSnapshot({ loading: false }),
143
+ () => client.auth.patchSnapshot({ user: null, loading: false }),
144
+ );
145
+ }, [client]);
146
+
147
+ // ─── Token change listener ────────────────────────────────────────────────────
148
+
149
+ // Fires on login, logout, silent refresh, and server-pushed session revocation.
150
+ useEffect(() => {
151
+ return client.auth.onAuthChange((session) => {
152
+ if (!session) {
153
+ // user: null is already handled by AuthClient's onTokenChange listener,
154
+ // but be explicit here too in case this fires before that listener runs.
155
+ client.auth.patchSnapshot({ user: null, loading: false });
156
+ } else {
157
+ // user is already in the snapshot (set by login/register/etc before this fires).
158
+ // Call me() anyway to get a fresh validated user object from the server.
159
+ if (!client.auth.getCachedUser()) {
160
+ client.auth.patchSnapshot({ loading: true });
161
+ }
162
+ client.auth.me().then(
163
+ () => client.auth.patchSnapshot({ loading: false }),
164
+ () => client.auth.patchSnapshot({ user: null, loading: false }),
165
+ );
166
+ }
167
+ });
168
+ }, [client]);
169
+
170
+ return useSyncExternalStore(
171
+ useCallback((fn) => client.auth.subscribeSnapshot(fn), [client]),
172
+ getSnapshot,
173
+ ) as UseAuthResult | T;
174
+ }
package/src/useList.ts ADDED
@@ -0,0 +1,157 @@
1
+ // useList — fetch and cache a collection list with SWR semantics.
2
+
3
+ import type { BunBaseRecord, ListQuery, ListResult, WithExpand } from "@bunbase-ae/js";
4
+ import { useCallback, useEffect, useReducer, useRef } from "react";
5
+ import { useBunBase } from "./Context";
6
+ import type { CacheEntry } from "./cache";
7
+
8
+ // Stable JSON — sorts keys so query object identity doesn't matter.
9
+ function stableKey(value: unknown): string {
10
+ return (
11
+ JSON.stringify(value, (_, v) =>
12
+ v !== null && typeof v === "object" && !Array.isArray(v)
13
+ ? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort())
14
+ : (v as unknown),
15
+ ) ?? "{}"
16
+ );
17
+ }
18
+
19
+ export interface UseListResult<T, TExpand extends Record<string, unknown> = Record<string, never>> {
20
+ data: ListResult<WithExpand<T & BunBaseRecord, TExpand>> | undefined;
21
+ /** True while the very first fetch is in flight (no cached data yet). */
22
+ loading: boolean;
23
+ /** True while a background refetch is running with stale data still visible. */
24
+ isRefetching: boolean;
25
+ error: Error | null;
26
+ refetch: () => void;
27
+ }
28
+
29
+ export interface UseListOptions {
30
+ /** ms before cached data is considered stale. Default: 30 000. */
31
+ staleTime?: number;
32
+ /** ms between automatic background re-fetches. Default: none. */
33
+ refetchInterval?: number;
34
+ /** Set false to skip fetching entirely. Default: true. */
35
+ enabled?: boolean;
36
+ /** Subscribe to realtime collection events and auto-invalidate. Default: false. */
37
+ realtime?: boolean;
38
+ }
39
+
40
+ export function useList<
41
+ T extends Record<string, unknown> = Record<string, unknown>,
42
+ TExpand extends Record<string, unknown> = Record<string, never>,
43
+ >(
44
+ collection: string,
45
+ query: ListQuery<T> = {},
46
+ options: UseListOptions = {},
47
+ ): UseListResult<T, TExpand> {
48
+ const { client, cache } = useBunBase();
49
+ const staleTime = options.staleTime ?? 30_000;
50
+ const enabled = options.enabled !== false;
51
+ const cacheKey = `${collection}:list:${stableKey(query)}`;
52
+
53
+ // Always points at the latest fetcher — avoids stale-closure bugs.
54
+ const fetcherRef = useRef<() => Promise<unknown>>(() => Promise.resolve(null));
55
+ // Update every render so the closure captures the latest query.
56
+ useEffect(() => {
57
+ fetcherRef.current = () => client.collection<T>(collection).list(query);
58
+ });
59
+
60
+ // Stable refs so the subscribe callback below doesn't need to re-register
61
+ // when staleTime or enabled change.
62
+ const staleTimeRef = useRef(staleTime);
63
+ const enabledRef = useRef(enabled);
64
+ useEffect(() => {
65
+ staleTimeRef.current = staleTime;
66
+ enabledRef.current = enabled;
67
+ });
68
+
69
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
70
+
71
+ // Re-render whenever this cache key updates. Also triggers a background
72
+ // re-fetch immediately when the notification arrives and the entry is stale
73
+ // (e.g. after cache.invalidate() from a realtime event or mutation).
74
+ useEffect(
75
+ () =>
76
+ cache.subscribe(cacheKey, () => {
77
+ if (enabledRef.current && cache.isStale(cacheKey)) {
78
+ cache.fetch(cacheKey, () => fetcherRef.current(), staleTimeRef.current);
79
+ }
80
+ rerender();
81
+ }),
82
+ [cache, cacheKey],
83
+ );
84
+
85
+ // Fetch when the key or enabled flag changes.
86
+ useEffect(() => {
87
+ if (!enabled) return;
88
+ cache.fetch(cacheKey, () => fetcherRef.current(), staleTime);
89
+ }, [cache, cacheKey, staleTime, enabled]);
90
+
91
+ // Refetch on window focus when data is stale.
92
+ useEffect(() => {
93
+ if (!enabled) return;
94
+ return cache.onFocus(() => {
95
+ if (cache.isStale(cacheKey)) {
96
+ cache.fetch(cacheKey, () => fetcherRef.current(), staleTime);
97
+ }
98
+ });
99
+ }, [cache, cacheKey, staleTime, enabled]);
100
+
101
+ // Periodic background refetch.
102
+ useEffect(() => {
103
+ if (!options.refetchInterval || !enabled) return;
104
+ const id = setInterval(() => {
105
+ cache.invalidate(cacheKey);
106
+ cache.fetch(cacheKey, () => fetcherRef.current(), staleTime);
107
+ }, options.refetchInterval);
108
+ return () => clearInterval(id);
109
+ }, [cache, cacheKey, staleTime, options.refetchInterval, enabled]);
110
+
111
+ // Realtime: smart cache sync on any collection change.
112
+ // update → write directly to get cache (instant) + invalidate list
113
+ // delete → remove get cache entry + invalidate list
114
+ // create → invalidate list only (can't safely insert into sorted/filtered list)
115
+ useEffect(() => {
116
+ if (!options.realtime || !enabled) return;
117
+ return client.realtime.subscribe(`collection:${collection}`, (event) => {
118
+ const id = event.record.id;
119
+ if (event.event === "update") {
120
+ cache.setData(`${collection}:get:${id}`, event.record);
121
+ cache.invalidate(`${collection}:list`);
122
+ } else if (event.event === "delete") {
123
+ cache.invalidate(`${collection}:get:${id}`);
124
+ cache.invalidate(`${collection}:list`);
125
+ } else {
126
+ cache.invalidate(`${collection}:list`);
127
+ }
128
+ });
129
+ }, [client, collection, cache, options.realtime, enabled]);
130
+
131
+ const entry = cache.get(cacheKey);
132
+
133
+ // Populate individual record caches from list results so that navigating
134
+ // to a detail page loads instantly without a round-trip.
135
+ // Uses setDataIfMissing so fresher data (realtime events, direct fetches) is never overwritten.
136
+ const lastEntryRef = useRef<CacheEntry | undefined>(undefined);
137
+ useEffect(() => {
138
+ if (entry === lastEntryRef.current) return;
139
+ lastEntryRef.current = entry;
140
+ if (entry?.status !== "success" || !entry.data) return;
141
+ const result = entry.data as ListResult<WithExpand<T & BunBaseRecord, TExpand>>;
142
+ for (const item of result.items) {
143
+ cache.setDataIfMissing(`${collection}:get:${item.id}`, item, staleTime);
144
+ }
145
+ }, [entry, cache, collection, staleTime]);
146
+
147
+ return {
148
+ data: entry?.data as ListResult<WithExpand<T & BunBaseRecord, TExpand>> | undefined,
149
+ loading: !entry || entry.status === "loading",
150
+ isRefetching: !!(entry?.data !== undefined && entry.promise),
151
+ error: entry?.error ?? null,
152
+ refetch: useCallback(() => {
153
+ cache.invalidate(cacheKey);
154
+ cache.fetch(cacheKey, () => fetcherRef.current(), staleTime);
155
+ }, [cache, cacheKey, staleTime]),
156
+ };
157
+ }
@@ -0,0 +1,141 @@
1
+ // Mutation hooks — create, update, delete with automatic cache invalidation.
2
+
3
+ import type { BunBaseRecord } from "@bunbase-ae/js";
4
+ import { useCallback, useState } from "react";
5
+ import { useBunBase } from "./Context";
6
+
7
+ export interface MutationResult<TData, TArgs extends unknown[]> {
8
+ mutate: (...args: TArgs) => Promise<TData>;
9
+ loading: boolean;
10
+ error: Error | null;
11
+ data: TData | undefined;
12
+ reset: () => void;
13
+ }
14
+
15
+ function useError() {
16
+ const [error, setError] = useState<Error | null>(null);
17
+ // stable ref — setError from useState is always stable, so no deps needed.
18
+ const capture = useCallback((err: unknown): Error => {
19
+ const e = err instanceof Error ? err : new Error(String(err));
20
+ setError(e);
21
+ return e;
22
+ }, []);
23
+ return { error, setError, capture };
24
+ }
25
+
26
+ // ─── useCreate ───────────────────────────────────────────────────────────────
27
+
28
+ export function useCreate<T extends Record<string, unknown> = Record<string, unknown>>(
29
+ collection: string,
30
+ ): MutationResult<T & BunBaseRecord, [Partial<T>]> {
31
+ const { client, cache } = useBunBase();
32
+ const [loading, setLoading] = useState(false);
33
+ const [data, setData] = useState<(T & BunBaseRecord) | undefined>(undefined);
34
+ const { error, setError, capture } = useError();
35
+
36
+ const mutate = useCallback(
37
+ async (payload: Partial<T>): Promise<T & BunBaseRecord> => {
38
+ setLoading(true);
39
+ setError(null);
40
+ try {
41
+ const record = await client.collection<T>(collection).create(payload);
42
+ setData(record);
43
+ // Invalidate all list queries for this collection so they re-fetch.
44
+ cache.invalidate(`${collection}:list`);
45
+ return record;
46
+ } catch (err) {
47
+ throw capture(err);
48
+ } finally {
49
+ setLoading(false);
50
+ }
51
+ },
52
+ [client, collection, cache, setError, capture],
53
+ );
54
+
55
+ return {
56
+ mutate,
57
+ loading,
58
+ error,
59
+ data,
60
+ reset: useCallback(() => {
61
+ setData(undefined);
62
+ setError(null);
63
+ }, [setError]),
64
+ };
65
+ }
66
+
67
+ // ─── useUpdate ───────────────────────────────────────────────────────────────
68
+
69
+ export function useUpdate<T extends Record<string, unknown> = Record<string, unknown>>(
70
+ collection: string,
71
+ ): MutationResult<T & BunBaseRecord, [string, Partial<T>]> {
72
+ const { client, cache } = useBunBase();
73
+ const [loading, setLoading] = useState(false);
74
+ const [data, setData] = useState<(T & BunBaseRecord) | undefined>(undefined);
75
+ const { error, setError, capture } = useError();
76
+
77
+ const mutate = useCallback(
78
+ async (id: string, patch: Partial<T>): Promise<T & BunBaseRecord> => {
79
+ setLoading(true);
80
+ setError(null);
81
+ try {
82
+ const record = await client.collection<T>(collection).update(id, patch);
83
+ setData(record);
84
+ // Write the updated record into the single-record cache immediately.
85
+ cache.setData(`${collection}:get:${id}`, record);
86
+ // Invalidate list queries so any ordering/filter changes are reflected.
87
+ cache.invalidate(`${collection}:list`);
88
+ return record;
89
+ } catch (err) {
90
+ throw capture(err);
91
+ } finally {
92
+ setLoading(false);
93
+ }
94
+ },
95
+ [client, collection, cache, setError, capture],
96
+ );
97
+
98
+ return {
99
+ mutate,
100
+ loading,
101
+ error,
102
+ data,
103
+ reset: useCallback(() => {
104
+ setData(undefined);
105
+ setError(null);
106
+ }, [setError]),
107
+ };
108
+ }
109
+
110
+ // ─── useDelete ───────────────────────────────────────────────────────────────
111
+
112
+ export function useDelete(collection: string): MutationResult<void, [string]> {
113
+ const { client, cache } = useBunBase();
114
+ const [loading, setLoading] = useState(false);
115
+ const { error, setError, capture } = useError();
116
+
117
+ const mutate = useCallback(
118
+ async (id: string): Promise<void> => {
119
+ setLoading(true);
120
+ setError(null);
121
+ try {
122
+ await client.collection(collection).delete(id);
123
+ // Wipe all cached data for this collection (list + single-record).
124
+ cache.invalidate(`${collection}:`);
125
+ } catch (err) {
126
+ throw capture(err);
127
+ } finally {
128
+ setLoading(false);
129
+ }
130
+ },
131
+ [client, collection, cache, setError, capture],
132
+ );
133
+
134
+ return {
135
+ mutate,
136
+ loading,
137
+ error,
138
+ data: undefined,
139
+ reset: useCallback(() => setError(null), [setError]),
140
+ };
141
+ }
@@ -0,0 +1,126 @@
1
+ // useRealtime — subscribe to collection or record changes.
2
+ //
3
+ // All hooks auto-reconnect (handled by RealtimeClient) and unsubscribe on unmount.
4
+
5
+ import type { RealtimeEvent, SubscribeOptions } from "@bunbase-ae/js";
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ import { useBunBase } from "./Context";
8
+
9
+ // ─── useRealtime ─────────────────────────────────────────────────────────────
10
+
11
+ export interface UseRealtimeOptions extends SubscribeOptions {
12
+ /**
13
+ * When true, invalidate all cache entries for the event's collection on every
14
+ * incoming change. Pairs well with useList / useRecord for live-updating UIs.
15
+ */
16
+ invalidateCache?: boolean;
17
+ }
18
+
19
+ export interface UseRealtimeResult {
20
+ /** True once the WebSocket connection is open. */
21
+ connected: boolean;
22
+ }
23
+
24
+ export function useRealtime<T extends Record<string, unknown> = Record<string, unknown>>(
25
+ channel: string,
26
+ callback?: (event: RealtimeEvent<T>) => void,
27
+ options: UseRealtimeOptions = {},
28
+ ): UseRealtimeResult {
29
+ const { client, cache } = useBunBase();
30
+ const [connected, setConnected] = useState(false);
31
+
32
+ // Always call the latest callback without re-subscribing.
33
+ const cbRef = useRef(callback);
34
+ cbRef.current = callback;
35
+
36
+ // Stable ref for subscribe options so effect doesn't re-run on object identity changes.
37
+ const optsRef = useRef(options);
38
+ optsRef.current = options;
39
+
40
+ useEffect(() => {
41
+ if (!channel) return;
42
+
43
+ const { events, filter, ids } = optsRef.current;
44
+
45
+ const unsub = client.realtime.subscribe<T>(
46
+ channel,
47
+ (event) => {
48
+ cbRef.current?.(event);
49
+ if (optsRef.current.invalidateCache) {
50
+ const col = event.collection;
51
+ const id = event.record.id;
52
+ if (event.event === "update") {
53
+ // Write updated record directly to get cache — instant UI, no round-trip.
54
+ cache.setData(`${col}:get:${id}`, event.record);
55
+ // Invalidate list cache so any ordering/filter changes are reflected.
56
+ cache.invalidate(`${col}:list`);
57
+ } else if (event.event === "delete") {
58
+ cache.invalidate(`${col}:get:${id}`);
59
+ cache.invalidate(`${col}:list`);
60
+ } else {
61
+ // create — can't safely insert into sorted/filtered lists.
62
+ cache.invalidate(`${col}:list`);
63
+ }
64
+ }
65
+ },
66
+ { events, filter, ids },
67
+ );
68
+
69
+ const unconnect = client.realtime.onConnect(() => setConnected(true));
70
+
71
+ return () => {
72
+ unsub();
73
+ unconnect();
74
+ };
75
+ }, [client, channel, cache]);
76
+
77
+ return { connected };
78
+ }
79
+
80
+ // ─── useCollectionEvents ─────────────────────────────────────────────────────
81
+
82
+ /** Subscribe to all changes in a collection. */
83
+ export function useCollectionEvents<T extends Record<string, unknown> = Record<string, unknown>>(
84
+ collection: string,
85
+ callback?: (event: RealtimeEvent<T>) => void,
86
+ options: UseRealtimeOptions = {},
87
+ ): UseRealtimeResult {
88
+ return useRealtime<T>(`collection:${collection}`, callback, options);
89
+ }
90
+
91
+ // ─── useRecordEvents ─────────────────────────────────────────────────────────
92
+
93
+ /** Subscribe to changes on a specific record. Pass null/undefined to skip. */
94
+ export function useRecordEvents<T extends Record<string, unknown> = Record<string, unknown>>(
95
+ collection: string,
96
+ id: string | null | undefined,
97
+ callback?: (event: RealtimeEvent<T>) => void,
98
+ ): UseRealtimeResult {
99
+ return useRealtime<T>(id ? `record:${collection}:${id}` : "", callback);
100
+ }
101
+
102
+ // ─── usePresence ─────────────────────────────────────────────────────────────
103
+
104
+ export interface UsePresenceResult {
105
+ users: string[];
106
+ refresh: () => void;
107
+ }
108
+
109
+ /** Query which users are present on a channel. Refreshes on mount. */
110
+ export function usePresence(channel: string): UsePresenceResult {
111
+ const { client } = useBunBase();
112
+ const [users, setUsers] = useState<string[]>([]);
113
+
114
+ const refresh = useCallback(() => {
115
+ client.realtime
116
+ .presence(channel)
117
+ .then(setUsers)
118
+ .catch(() => {});
119
+ }, [client, channel]);
120
+
121
+ useEffect(() => {
122
+ refresh();
123
+ }, [refresh]);
124
+
125
+ return { users, refresh };
126
+ }
@@ -0,0 +1,95 @@
1
+ // useRecord — fetch and cache a single collection record.
2
+
3
+ import type { BunBaseRecord, GetQuery, WithExpand } from "@bunbase-ae/js";
4
+ import { useCallback, useEffect, useReducer, useRef } from "react";
5
+ import { useBunBase } from "./Context";
6
+
7
+ export interface UseRecordResult<
8
+ T,
9
+ TExpand extends Record<string, unknown> = Record<string, never>,
10
+ > {
11
+ data: WithExpand<T & BunBaseRecord, TExpand> | undefined;
12
+ loading: boolean;
13
+ error: Error | null;
14
+ refetch: () => void;
15
+ }
16
+
17
+ export interface UseRecordOptions extends GetQuery {
18
+ staleTime?: number;
19
+ }
20
+
21
+ export function useRecord<
22
+ T extends Record<string, unknown> = Record<string, unknown>,
23
+ TExpand extends Record<string, unknown> = Record<string, never>,
24
+ >(
25
+ collection: string,
26
+ /** Pass null or undefined to skip fetching (e.g. while id is loading). */
27
+ id: string | null | undefined,
28
+ options: UseRecordOptions = {},
29
+ ): UseRecordResult<T, TExpand> {
30
+ const { client, cache } = useBunBase();
31
+ const staleTime = options.staleTime ?? 30_000;
32
+ // Null id = no fetch.
33
+ const cacheKey = id ? `${collection}:get:${id}` : null;
34
+
35
+ const fetcherRef = useRef<() => Promise<unknown>>(null as unknown as () => Promise<unknown>);
36
+ if (id) {
37
+ fetcherRef.current = () => client.collection<T>(collection).get(id, { expand: options.expand });
38
+ }
39
+
40
+ const staleTimeRef = useRef(staleTime);
41
+ useEffect(() => {
42
+ staleTimeRef.current = staleTime;
43
+ });
44
+
45
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
46
+
47
+ useEffect(() => {
48
+ if (!cacheKey) return;
49
+ return cache.subscribe(cacheKey, () => {
50
+ if (cacheKey && cache.isStale(cacheKey)) {
51
+ cache.fetch(cacheKey, () => fetcherRef.current(), staleTimeRef.current);
52
+ }
53
+ rerender();
54
+ });
55
+ }, [cache, cacheKey]);
56
+
57
+ useEffect(() => {
58
+ if (!cacheKey) return;
59
+ cache.fetch(cacheKey, () => fetcherRef.current(), staleTime);
60
+ }, [cache, cacheKey, staleTime]);
61
+
62
+ useEffect(() => {
63
+ if (!cacheKey) return;
64
+ return cache.onFocus(() => {
65
+ if (cacheKey && cache.isStale(cacheKey)) {
66
+ cache.fetch(cacheKey, () => fetcherRef.current(), staleTime);
67
+ }
68
+ });
69
+ }, [cache, cacheKey, staleTime]);
70
+
71
+ // Realtime: update single-record cache when the record changes.
72
+ useEffect(() => {
73
+ if (!id || !cacheKey) return;
74
+ return client.realtime.subscribe(`record:${collection}:${id}`, (event) => {
75
+ if (event.event === "delete") {
76
+ cache.invalidate(cacheKey);
77
+ } else {
78
+ cache.setData(cacheKey, event.record);
79
+ }
80
+ });
81
+ }, [client, collection, id, cache, cacheKey]);
82
+
83
+ const entry = cacheKey ? cache.get(cacheKey) : undefined;
84
+
85
+ return {
86
+ data: entry?.data as WithExpand<T & BunBaseRecord, TExpand> | undefined,
87
+ loading: !!cacheKey && (!entry || entry.status === "loading"),
88
+ error: entry?.error ?? null,
89
+ refetch: useCallback(() => {
90
+ if (!cacheKey) return;
91
+ cache.invalidate(cacheKey);
92
+ cache.fetch(cacheKey, () => fetcherRef.current(), staleTime);
93
+ }, [cache, cacheKey, staleTime]),
94
+ };
95
+ }
@@ -0,0 +1,65 @@
1
+ // useUpload — file upload with progress tracking.
2
+
3
+ import type { FileRecord } from "@bunbase-ae/js";
4
+ import { useCallback, useState } from "react";
5
+ import { useBunBase } from "./Context";
6
+ import type { UploadOptions } from "./types";
7
+
8
+ export interface UseUploadResult {
9
+ upload: (file: File | Blob, options?: Omit<UploadOptions, "onProgress">) => Promise<FileRecord>;
10
+ /** True while upload is in progress. */
11
+ loading: boolean;
12
+ /** Upload progress from 0 to 1. Resets to 0 on each new upload. */
13
+ progress: number;
14
+ error: Error | null;
15
+ /** The uploaded FileRecord. Set after a successful upload. */
16
+ data: FileRecord | undefined;
17
+ reset: () => void;
18
+ }
19
+
20
+ export function useUpload(): UseUploadResult {
21
+ const { client } = useBunBase();
22
+ const [loading, setLoading] = useState(false);
23
+ const [progress, setProgress] = useState(0);
24
+ const [error, setError] = useState<Error | null>(null);
25
+ const [data, setData] = useState<FileRecord | undefined>(undefined);
26
+
27
+ const upload = useCallback(
28
+ async (
29
+ file: File | Blob,
30
+ options: Omit<UploadOptions, "onProgress"> = {},
31
+ ): Promise<FileRecord> => {
32
+ setLoading(true);
33
+ setProgress(0);
34
+ setError(null);
35
+ try {
36
+ const record = await client.storage.upload(file, {
37
+ ...options,
38
+ onProgress: (p) => setProgress(p),
39
+ });
40
+ setData(record);
41
+ return record;
42
+ } catch (err) {
43
+ const e = err instanceof Error ? err : new Error(String(err));
44
+ setError(e);
45
+ throw e;
46
+ } finally {
47
+ setLoading(false);
48
+ }
49
+ },
50
+ [client],
51
+ );
52
+
53
+ return {
54
+ upload,
55
+ loading,
56
+ progress,
57
+ error,
58
+ data,
59
+ reset: useCallback(() => {
60
+ setError(null);
61
+ setData(undefined);
62
+ setProgress(0);
63
+ }, []),
64
+ };
65
+ }