@bunbase-ae/react-sdk 1.0.0 → 1.0.1-next.3.ee6c18d

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 CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@bunbase-ae/react-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.1-next.3.ee6c18d",
4
4
  "type": "module",
5
5
  "description": "React hooks for BunBase — caching, mutations, auth, realtime",
6
+ "homepage": "https://docs-bunbase.palmcode.ae/sdk/react",
6
7
  "files": [
7
8
  "src"
8
9
  ],
package/src/cache.ts CHANGED
@@ -24,6 +24,18 @@ export interface CacheEntry {
24
24
  /** Stored so focus-triggered refetch can re-run without external reference. */
25
25
  fetcher: (() => Promise<unknown>) | null;
26
26
  staleTime: number;
27
+ retryableError: boolean;
28
+ /** Number of consecutive fetch failures — used to compute exponential backoff. */
29
+ consecutiveErrors: number;
30
+ /** Epoch ms before which fetch() is a no-op (exponential backoff window). */
31
+ backoffUntil: number;
32
+ /**
33
+ * True when this entry was explicitly invalidated (via `invalidate()`) and has
34
+ * not yet been re-fetched. Subscription callbacks use this flag to distinguish
35
+ * "app-triggered invalidation → fetch" from "settled fetch → re-render only",
36
+ * which prevents error/success notifications from spawning a new fetch cycle.
37
+ */
38
+ invalidated: boolean;
27
39
  }
28
40
 
29
41
  // ─── Storage adapter ─────────────────────────────────────────────────────────
@@ -159,6 +171,10 @@ export class QueryCache {
159
171
  promise: null,
160
172
  fetcher: null,
161
173
  staleTime: stored.staleTime,
174
+ retryableError: false,
175
+ consecutiveErrors: 0,
176
+ backoffUntil: 0,
177
+ invalidated: false,
162
178
  });
163
179
  } catch {
164
180
  // Corrupt entry — skip silently.
@@ -212,10 +228,18 @@ export class QueryCache {
212
228
 
213
229
  isStale(key: string): boolean {
214
230
  const e = this.entries.get(key);
215
- if (!e || e.status !== "success") return true;
231
+ if (!e) return true;
232
+ // Errored entries have staleAt = backoffUntil; they are stale once the backoff expires.
233
+ if (e.status === "error") return Date.now() >= e.staleAt;
234
+ if (e.status !== "success") return true;
216
235
  return Date.now() >= e.staleAt;
217
236
  }
218
237
 
238
+ /** True only when this entry was explicitly invalidated and hasn't been re-fetched yet. */
239
+ isInvalidated(key: string): boolean {
240
+ return this.entries.get(key)?.invalidated === true;
241
+ }
242
+
219
243
  // ─── Fetch ──────────────────────────────────────────────────────────────────
220
244
 
221
245
  /**
@@ -235,6 +259,9 @@ export class QueryCache {
235
259
  // Data is fresh — nothing to do.
236
260
  if (existing?.status === "success" && Date.now() < existing.staleAt) return;
237
261
 
262
+ // Respect exponential backoff after consecutive errors.
263
+ if (existing?.backoffUntil && Date.now() < existing.backoffUntil) return;
264
+
238
265
  // Keep existing data visible while refetching (SWR).
239
266
  const entry: CacheEntry = {
240
267
  data: existing?.data,
@@ -244,6 +271,10 @@ export class QueryCache {
244
271
  promise: null,
245
272
  fetcher,
246
273
  staleTime,
274
+ retryableError: false,
275
+ consecutiveErrors: existing?.consecutiveErrors ?? 0,
276
+ backoffUntil: existing?.backoffUntil ?? 0,
277
+ invalidated: false,
247
278
  };
248
279
 
249
280
  const promise: Promise<void> = fetcher().then(
@@ -259,6 +290,10 @@ export class QueryCache {
259
290
  promise: null,
260
291
  fetcher,
261
292
  staleTime,
293
+ retryableError: false,
294
+ consecutiveErrors: 0,
295
+ backoffUntil: 0,
296
+ invalidated: false,
262
297
  };
263
298
  this.entries.set(key, next);
264
299
  this.persist(key, next);
@@ -268,14 +303,22 @@ export class QueryCache {
268
303
  (err: unknown) => {
269
304
  const current = this.entries.get(key);
270
305
  if (current?.promise !== promise) return;
306
+ const consecutiveErrors = (current.consecutiveErrors ?? 0) + 1;
307
+ // Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s cap.
308
+ const backoffMs = Math.min(1_000 * 2 ** (consecutiveErrors - 1), 30_000);
309
+ const backoffUntil = Date.now() + backoffMs;
271
310
  this.entries.set(key, {
272
311
  data: current?.data,
273
312
  error: err instanceof Error ? err : new Error(String(err)),
274
313
  status: "error",
275
- staleAt: 0,
314
+ staleAt: backoffUntil,
276
315
  promise: null,
277
316
  fetcher,
278
317
  staleTime,
318
+ retryableError: isRetryableError(err),
319
+ consecutiveErrors,
320
+ backoffUntil,
321
+ invalidated: false,
279
322
  });
280
323
  this.notify(key);
281
324
  },
@@ -292,7 +335,17 @@ export class QueryCache {
292
335
  refetch(key: string): void {
293
336
  const entry = this.entries.get(key);
294
337
  if (!entry?.fetcher) return;
295
- this.entries.set(key, { ...entry, staleAt: 0, promise: null });
338
+ // Clear backoff and reset error state so the forced refetch always proceeds.
339
+ this.entries.set(key, {
340
+ ...entry,
341
+ error: null,
342
+ status: entry.data !== undefined ? "success" : "loading",
343
+ staleAt: 0,
344
+ promise: null,
345
+ retryableError: false,
346
+ backoffUntil: 0,
347
+ invalidated: true,
348
+ });
296
349
  this.fetch(key, entry.fetcher, entry.staleTime);
297
350
  }
298
351
 
@@ -303,13 +356,27 @@ export class QueryCache {
303
356
  *
304
357
  * Data is KEPT so components remain in a loaded state (not loading) while the
305
358
  * background re-fetch runs. Subscribers should call `cache.fetch()` when they
306
- * receive a notification and find the entry is stale.
359
+ * receive a notification and find the entry is invalidated.
307
360
  */
308
361
  invalidate(prefix: string): void {
309
362
  const toNotify: string[] = [];
310
363
  for (const [key, entry] of this.entries) {
311
364
  if (key.startsWith(prefix)) {
312
- this.entries.set(key, { ...entry, staleAt: 0, promise: null });
365
+ this.entries.set(key, {
366
+ ...entry,
367
+ error: null,
368
+ status:
369
+ entry.status === "error"
370
+ ? entry.data !== undefined
371
+ ? "success"
372
+ : "loading"
373
+ : entry.status,
374
+ staleAt: 0,
375
+ promise: null,
376
+ retryableError: false,
377
+ backoffUntil: 0,
378
+ invalidated: true,
379
+ });
313
380
  toNotify.push(key);
314
381
  }
315
382
  }
@@ -331,6 +398,10 @@ export class QueryCache {
331
398
  promise: null,
332
399
  fetcher: null,
333
400
  staleTime,
401
+ retryableError: false,
402
+ consecutiveErrors: 0,
403
+ backoffUntil: 0,
404
+ invalidated: false,
334
405
  };
335
406
  this.entries.set(key, entry);
336
407
  this.persist(key, entry);
@@ -354,6 +425,10 @@ export class QueryCache {
354
425
  promise: null,
355
426
  fetcher: existing?.fetcher ?? null,
356
427
  staleTime,
428
+ retryableError: false,
429
+ consecutiveErrors: 0,
430
+ backoffUntil: 0,
431
+ invalidated: false,
357
432
  };
358
433
  this.entries.set(key, entry);
359
434
  this.persist(key, entry);
@@ -390,14 +465,20 @@ export class QueryCache {
390
465
  }
391
466
  }
392
467
 
393
- /** Re-fetch all stale or errored entries. Called on window focus. */
468
+ /** Re-fetch all stale entries. Called on window focus. */
394
469
  refetchStale(): void {
395
470
  for (const [key, entry] of this.entries) {
396
471
  if (!entry.fetcher) continue;
397
- const isStale = entry.status === "error" || Date.now() >= entry.staleAt;
398
- if (isStale && !entry.promise) {
472
+ // Errored entries have staleAt = backoffUntil, so the same check handles them.
473
+ if (!entry.promise && Date.now() >= entry.staleAt) {
399
474
  this.fetch(key, entry.fetcher, entry.staleTime);
400
475
  }
401
476
  }
402
477
  }
403
478
  }
479
+
480
+ function isRetryableError(error: unknown): boolean {
481
+ if (!error || typeof error !== "object" || !("status" in error)) return true;
482
+ const status = (error as { status?: unknown }).status;
483
+ return typeof status !== "number" || status < 400 || status >= 500;
484
+ }
package/src/useAuth.ts CHANGED
@@ -170,5 +170,6 @@ export function useAuth<T>(selector?: (s: UseAuthResult) => T): UseAuthResult |
170
170
  return useSyncExternalStore(
171
171
  useCallback((fn) => client.auth.subscribeSnapshot(fn), [client]),
172
172
  getSnapshot,
173
+ getSnapshot,
173
174
  ) as UseAuthResult | T;
174
175
  }
package/src/useList.ts CHANGED
@@ -74,7 +74,10 @@ export function useList<
74
74
  useEffect(
75
75
  () =>
76
76
  cache.subscribe(cacheKey, () => {
77
- if (enabledRef.current && cache.isStale(cacheKey)) {
77
+ // Only re-fetch when the entry was explicitly invalidated (invalidate() /
78
+ // refetch() / interval). Do NOT re-fetch on error or success notifications —
79
+ // that would create a tight retry loop bypassing refetchInterval and backoff.
80
+ if (enabledRef.current && cache.isInvalidated(cacheKey)) {
78
81
  cache.fetch(cacheKey, () => fetcherRef.current(), staleTimeRef.current);
79
82
  }
80
83
  rerender();
package/src/useRecord.ts CHANGED
@@ -47,7 +47,8 @@ export function useRecord<
47
47
  useEffect(() => {
48
48
  if (!cacheKey) return;
49
49
  return cache.subscribe(cacheKey, () => {
50
- if (cacheKey && cache.isStale(cacheKey)) {
50
+ // Only re-fetch on explicit invalidation — not on error/success notifications.
51
+ if (cacheKey && cache.isInvalidated(cacheKey)) {
51
52
  cache.fetch(cacheKey, () => fetcherRef.current(), staleTimeRef.current);
52
53
  }
53
54
  rerender();