@rebasepro/client 0.10.0 → 0.10.1-canary.14e53ae

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/src/auth.ts CHANGED
@@ -166,22 +166,48 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
166
166
  */
167
167
  function isFatalRefreshError(err: unknown): boolean {
168
168
  if (!(err instanceof RebaseApiError)) return false; // network/other → transient
169
+ // Another tab (or a retry of our own request) rotated the token we
170
+ // were holding. In cookie mode the jar may ALREADY contain the
171
+ // replacement, so this is the one 401 that is worth retrying: giving
172
+ // up here is precisely the bug where opening a second tab signs you
173
+ // out of both.
174
+ if (err.code === "TOKEN_ALREADY_USED") return false;
169
175
  if (err.code === "INVALID_TOKEN" || err.code === "TOKEN_EXPIRED") return true;
170
176
  // 401/403 are auth failures; other statuses (incl. 5xx, 0) are transient.
171
177
  return err.status === 401 || err.status === 403;
172
178
  }
173
179
 
180
+ /**
181
+ * Drop this client's session without telling the server.
182
+ *
183
+ * `signOut()` is a user action: it POSTs /logout, which revokes the whole
184
+ * sign-in. That is the wrong hammer for a refresh that failed. Our token
185
+ * may be stale precisely because a sibling tab holds a live one, and
186
+ * logging out on its behalf would turn one tab's bad luck into everybody
187
+ * being signed out — the exact failure this work exists to remove.
188
+ */
189
+ function abandonSessionLocally() {
190
+ currentSession = null;
191
+ clearStoredSession();
192
+ if (refreshTimeout) {
193
+ clearTimeout(refreshTimeout);
194
+ refreshTimeout = null;
195
+ }
196
+ transport.setToken(null);
197
+ emit("SIGNED_OUT", null);
198
+ }
199
+
174
200
  async function attemptScheduledRefresh(attempt: number) {
175
201
  try {
176
202
  await refreshSession();
177
203
  // On success, refreshSession() re-schedules the next refresh itself.
178
204
  } catch (err) {
179
205
  if (isFatalRefreshError(err)) {
180
- signOut();
206
+ abandonSessionLocally();
181
207
  return;
182
208
  }
183
209
  if (attempt >= MAX_REFRESH_RETRIES) {
184
- signOut();
210
+ abandonSessionLocally();
185
211
  return;
186
212
  }
187
213
  // Transient failure — back off and retry rather than dropping the session.
@@ -395,10 +421,52 @@ redirectUri });
395
421
  emit("SIGNED_OUT", null);
396
422
  }
397
423
 
424
+ /**
425
+ * Serialise refreshes across TABS, not just within one.
426
+ *
427
+ * The in-flight promise below covers callers inside a single JavaScript
428
+ * context. It does nothing about the far more common case: two tabs of the
429
+ * same app booting together, each firing its own /refresh with the same
430
+ * cookie. The server tolerates that now (superseded tokens stay usable for
431
+ * a grace window), but tolerating a stampede is not the same as avoiding
432
+ * one, and every extra rotation is another chance to end up holding a
433
+ * token whose response never arrived.
434
+ *
435
+ * Web Locks are best-effort on purpose. supabase-js shipped this and then
436
+ * spent a year fielding deadlock reports — a lock held by a crashed or
437
+ * frozen tab must never be able to wedge sign-in — so a lock we cannot
438
+ * take within the timeout is simply not taken, and the refresh proceeds
439
+ * unserialised, exactly as it did before.
440
+ */
441
+ const REFRESH_LOCK_NAME = "rebase-auth-refresh";
442
+ const REFRESH_LOCK_TIMEOUT_MS = 5000;
443
+
444
+ async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
445
+ const locks = (globalThis as { navigator?: { locks?: LockManager } }).navigator?.locks;
446
+ if (!locks?.request) return fn();
447
+
448
+ const controller = new AbortController();
449
+ const giveUp = setTimeout(() => controller.abort(), REFRESH_LOCK_TIMEOUT_MS);
450
+ try {
451
+ return await locks.request(
452
+ REFRESH_LOCK_NAME,
453
+ { signal: controller.signal },
454
+ async () => fn()
455
+ ) as T;
456
+ } catch (e) {
457
+ // AbortError means only that we waited long enough for the lock.
458
+ // Anything the callback itself threw has to keep propagating.
459
+ if ((e as { name?: string })?.name !== "AbortError") throw e;
460
+ return fn();
461
+ } finally {
462
+ clearTimeout(giveUp);
463
+ }
464
+ }
465
+
398
466
  function refreshSession(): Promise<RebaseSession> {
399
467
  // Share a single in-flight refresh across concurrent callers.
400
468
  if (inFlightRefresh) return inFlightRefresh;
401
- inFlightRefresh = doRefreshSession().finally(() => {
469
+ inFlightRefresh = withRefreshLock(() => doRefreshSession()).finally(() => {
402
470
  inFlightRefresh = null;
403
471
  });
404
472
  return inFlightRefresh;
package/src/collection.ts CHANGED
@@ -11,6 +11,43 @@ import {
11
11
 
12
12
  import { SDKQueryBuilder } from "./sdk_query_builder";
13
13
 
14
+ /**
15
+ * A live query result: a normal {@link FindResult} plus what an interface
16
+ * needs to decide whether to show a "saving…" or "offline" affordance over it.
17
+ *
18
+ * The three flags are always `false` on a client without offline support —
19
+ * every result there came straight from the server.
20
+ */
21
+ export interface LiveResult<M extends Record<string, unknown>> extends FindResult<M> {
22
+ /** The data came from the local database, not from a completed request. */
23
+ fromCache: boolean;
24
+ /** At least one row here carries a write the server has not accepted yet. */
25
+ hasPendingWrites: boolean;
26
+ /**
27
+ * The local database may not hold every row the server would have
28
+ * returned, so treat this as a best effort rather than a complete answer.
29
+ */
30
+ partial: boolean;
31
+ /** The most recent revalidation failure, when the last one failed. */
32
+ error?: Error;
33
+ }
34
+
35
+ /** Snapshot metadata for a single observed row. */
36
+ export interface RowSnapshotMeta {
37
+ fromCache: boolean;
38
+ hasPendingWrites: boolean;
39
+ }
40
+
41
+ export interface ObserveOptions {
42
+ /**
43
+ * Keep the subscription live off the realtime socket, so changes made by
44
+ * other clients arrive without a refetch. On by default whenever realtime
45
+ * is enabled on the client; pass `false` for a one-shot read that still
46
+ * reports offline/pending metadata.
47
+ */
48
+ realtime?: boolean;
49
+ }
50
+
14
51
  /**
15
52
  * The concrete, HTTP-backed implementation of the public
16
53
  * {@link SDKCollectionClient} contract — flat rows (no Entity wrapper), plus
@@ -18,8 +55,8 @@ import { SDKQueryBuilder } from "./sdk_query_builder";
18
55
  *
19
56
  * This is what `createRebaseClient().data.<collection>` returns. It is not a
20
57
  * separate API from {@link SDKCollectionClient}; it only widens it with
21
- * `count()`. Program against {@link SDKCollectionClient} when you want a
22
- * transport-agnostic type.
58
+ * `count()` and the reactive `observe()` pair. Program against
59
+ * {@link SDKCollectionClient} when you want a transport-agnostic type.
23
60
  */
24
61
  export interface CollectionClient<
25
62
  M extends Record<string, unknown> = Record<string, unknown>,
@@ -27,6 +64,36 @@ export interface CollectionClient<
27
64
  U = Partial<M>
28
65
  > extends SDKCollectionClient<M, I, U> {
29
66
  count(params?: FindParams): Promise<number>;
67
+
68
+ /**
69
+ * Subscribe to a query's results.
70
+ *
71
+ * This is the reactive read primitive, and the one to reach for in a UI:
72
+ * unlike `find()` it keeps emitting. On a client with `offline` enabled it
73
+ * is local-first — the first emission comes from the local database, with
74
+ * no request in the way — and re-emits on every local write, every queued
75
+ * write reaching the server, and every rollback. With realtime enabled it
76
+ * also re-emits on changes made by other clients.
77
+ *
78
+ * Emissions are de-duplicated: a refresh that changes nothing does not
79
+ * call back.
80
+ *
81
+ * @returns An unsubscribe function.
82
+ */
83
+ observe(
84
+ params: FindParams | undefined,
85
+ onResult: (result: LiveResult<M>) => void,
86
+ onError?: (error: Error) => void,
87
+ options?: ObserveOptions
88
+ ): () => void;
89
+
90
+ /** {@link CollectionClient.observe} for a single row. */
91
+ observeById(
92
+ id: string | number,
93
+ onResult: (row: M | undefined, meta: RowSnapshotMeta) => void,
94
+ onError?: (error: Error) => void,
95
+ options?: ObserveOptions
96
+ ): () => void;
30
97
  }
31
98
 
32
99
  export function createCollectionClient<M extends Record<string, unknown> = Record<string, unknown>>(transport: Transport, slug: string, ws?: RebaseWebSocketClient): CollectionClient<M> {
@@ -111,6 +178,55 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
111
178
  return raw.count ?? 0;
112
179
  },
113
180
 
181
+ // Reactive reads. Without the offline layer there is no local database
182
+ // to read from, so this is a fetch plus — when realtime is available —
183
+ // the live subscription that keeps it current.
184
+ observe(
185
+ params: FindParams | undefined,
186
+ onResult: (result: LiveResult<M>) => void,
187
+ onError?: (error: Error) => void,
188
+ options?: ObserveOptions
189
+ ) {
190
+ let closed = false;
191
+ const emit = (result: FindResult<M>) => {
192
+ if (closed) return;
193
+ onResult({ ...result, fromCache: false, hasPendingWrites: false, partial: false });
194
+ };
195
+ client.find(params).then(emit).catch((error) => {
196
+ if (!closed) onError?.(error as Error);
197
+ });
198
+ const live = options?.realtime !== false && client.listen
199
+ ? client.listen(params, emit, onError)
200
+ : undefined;
201
+ return () => {
202
+ closed = true;
203
+ live?.();
204
+ };
205
+ },
206
+
207
+ observeById(
208
+ id: string | number,
209
+ onResult: (row: M | undefined, meta: RowSnapshotMeta) => void,
210
+ onError?: (error: Error) => void,
211
+ options?: ObserveOptions
212
+ ) {
213
+ let closed = false;
214
+ const emit = (row: M | undefined) => {
215
+ if (closed) return;
216
+ onResult(row, { fromCache: false, hasPendingWrites: false });
217
+ };
218
+ client.findById(id).then(emit).catch((error) => {
219
+ if (!closed) onError?.(error as Error);
220
+ });
221
+ const live = options?.realtime !== false && client.listenById
222
+ ? client.listenById(id, emit, onError)
223
+ : undefined;
224
+ return () => {
225
+ closed = true;
226
+ live?.();
227
+ };
228
+ },
229
+
114
230
  // Fluent builder instantiation
115
231
  where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
116
232
  const builder = new SDKQueryBuilder<M>(client);
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { createStorage } from "./storage";
11
11
  import { ClientStorageSourceRegistry } from "./storage-registry";
12
12
  import { RebaseWebSocketClient } from "./websocket";
13
13
  import { RebaseRealtimeChannel, type ChannelOptions } from "./realtime-channel";
14
+ import { OfflineManager, type OfflineApi, type OfflineConfig } from "./offline";
14
15
  import {
15
16
  DEFAULT_STORAGE_SOURCE_KEY,
16
17
  InsertOf,
@@ -88,6 +89,20 @@ export type {
88
89
  ChannelHistoryResult
89
90
  } from "./realtime-channel";
90
91
 
92
+ // Offline: config, the `client.offline` surface, and the metadata a UI needs
93
+ // to reflect sync state. `isOfflineError` distinguishes "there was no network
94
+ // and nothing local to answer with" from a request that genuinely failed.
95
+ // The store contract is public so other environments (React Native/
96
+ // AsyncStorage, Electron, …) can supply their own persistence;
97
+ // `MemoryOfflineStore` is exported for tests and as the reference
98
+ // implementation, while the IndexedDB store is wired automatically in the
99
+ // browser and needs no direct construction.
100
+ export type { OfflineApi, OfflineConfig, OfflineStatus } from "./offline";
101
+ export { isOfflineError } from "./offline";
102
+ export type { LiveResult, ObserveOptions, RowSnapshotMeta } from "./collection";
103
+ export type { OfflineStore, OfflineCacheEntry, OfflineCacheRecord, PendingMutation, MutationRollback } from "./offline-store";
104
+ export { MemoryOfflineStore } from "./offline-store";
105
+
91
106
  export interface CreateRebaseClientOptions extends RebaseClientConfig {
92
107
  auth?: CreateAuthOptions;
93
108
  admin?: CreateAdminOptions;
@@ -108,6 +123,21 @@ export interface CreateRebaseClientOptions extends RebaseClientConfig {
108
123
  * correct slugs via this map before falling back to automatic snake_casing.
109
124
  */
110
125
  collections?: Record<string, string>;
126
+ /**
127
+ * Local-first sync for the data layer.
128
+ *
129
+ * `true` enables it with defaults: reads populate a local row database and
130
+ * fall back to it (evaluating filters and sorts locally) when the network
131
+ * is gone, writes made offline apply immediately and replay in order when
132
+ * it returns, and `observe()` becomes a live query that emits from the
133
+ * local database first. A rejected write is rolled back. Pass an
134
+ * {@link OfflineConfig} to control the store, cache sizes, retry backoff,
135
+ * or rejection handling.
136
+ *
137
+ * Local rows and queued writes are partitioned per signed-in user, and
138
+ * shared across tabs. Off by default.
139
+ */
140
+ offline?: boolean | OfflineConfig;
111
141
  }
112
142
 
113
143
  // ─── Typed Data Proxy ────────────────────────────────────────────────────────
@@ -186,6 +216,8 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
186
216
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
187
217
  collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
188
218
  data: TypedDataLayer<DB>;
219
+ /** Present only when the client was created with `offline` enabled. */
220
+ offline?: OfflineApi;
189
221
  };
190
222
 
191
223
  // ─── Factory ─────────────────────────────────────────────────────────────────
@@ -398,12 +430,33 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
398
430
  return undefined;
399
431
  }
400
432
 
433
+ // Offline layer: wraps every collection client with a read cache and a
434
+ // write queue. Replay goes through *unwrapped* clients (the factory below)
435
+ // so a failing replay can never re-queue itself.
436
+ const offlineManager = options.offline
437
+ ? new OfflineManager(
438
+ typeof options.offline === "object" ? options.offline : {},
439
+ (slug) => createCollectionClient(transport, slug)
440
+ )
441
+ : undefined;
442
+
443
+ if (offlineManager) {
444
+ // Cache and queue are partitioned per user: cached rows are RLS-scoped
445
+ // to whoever fetched them, and queued writes must replay as the user
446
+ // who made them — a shared browser must never mix the two.
447
+ offlineManager.setScope(auth.getSession()?.user?.uid);
448
+ auth.onAuthStateChange((event, session) => {
449
+ offlineManager.setScope(event === "SIGNED_OUT" ? undefined : session?.user?.uid);
450
+ });
451
+ }
452
+
401
453
  const collectionClients = new Map<string, CollectionClient<Record<string, unknown>>>();
402
454
  let untypedWarned = false;
403
455
 
404
456
  function collection(slug: string): CollectionClient<Record<string, unknown>> {
405
457
  if (!collectionClients.has(slug)) {
406
- collectionClients.set(slug, createCollectionClient(transport, slug, ws));
458
+ const inner = createCollectionClient(transport, slug, ws);
459
+ collectionClients.set(slug, offlineManager ? offlineManager.wrap(slug, inner) : inner);
407
460
  }
408
461
  return collectionClients.get(slug)!;
409
462
  }
@@ -510,6 +563,9 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
510
563
  // Permanent: nothing queued afterwards may redial and keep the
511
564
  // event loop alive, which is the reason this method exists.
512
565
  ws?.disconnect(true);
566
+ // The offline retry timer is unref'd but the `online` listener is
567
+ // not, and neither should outlive the client.
568
+ offlineManager?.dispose();
513
569
  },
514
570
  setToken: transport.setToken,
515
571
  setAuthTokenGetter: transport.setAuthTokenGetter,
@@ -526,6 +582,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
526
582
  return res.data ?? (res as T);
527
583
  },
528
584
  data: dataProxy,
585
+ ...(offlineManager ? { offline: offlineManager.api } : {}),
529
586
  } as unknown as CreateRebaseClientResult<DB>;
530
587
 
531
588
  return target;
@@ -0,0 +1,79 @@
1
+ import { EntityReference, EntityRelation, GeoPoint, Vector } from "@rebasepro/types";
2
+ import { rebaseReviver } from "./reviver";
3
+
4
+ /**
5
+ * Lossless round-tripping of rows through the offline store.
6
+ *
7
+ * Both persistence backends move values by structured clone, which keeps
8
+ * `Date` but flattens every class instance to a plain object. For
9
+ * `EntityReference`/`EntityRelation` that is harmless — they carry their own
10
+ * `__type` discriminator, so the JSON reviver can rebuild them — but
11
+ * `GeoPoint` and `Vector` do not, and would come back out of the cache as
12
+ * anonymous `{ latitude, longitude }` / `{ value }` bags. A row read from the
13
+ * cache must be indistinguishable from the same row read from the network, so
14
+ * those two are tagged on the way in and revived on the way out.
15
+ *
16
+ * Type tests here are structural rather than `instanceof`, because a structured
17
+ * clone can arrive from another realm — an iframe, a worker, or the polyfill
18
+ * the tests run against — where the constructor identity differs but the value
19
+ * is the real thing. Only *plain* objects are walked; anything else is passed
20
+ * through whole, so a class instance is never quietly reduced to `{}`.
21
+ */
22
+
23
+ function isDate(value: unknown): value is Date {
24
+ return Object.prototype.toString.call(value) === "[object Date]";
25
+ }
26
+
27
+ /** An object literal — not a Date, RegExp, Map, or any class instance. */
28
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
29
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
30
+ const proto = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null;
31
+ if (proto === null || proto === Object.prototype) return true;
32
+ // A literal cloned out of another realm has a different `Object.prototype`
33
+ // but is still, in every way that matters here, a plain object.
34
+ return proto.constructor?.name === "Object";
35
+ }
36
+
37
+ function dehydrateValue(value: unknown): unknown {
38
+ if (value === null || value === undefined) return value;
39
+ if (value instanceof GeoPoint) {
40
+ return { __type: "GeoPoint", latitude: value.latitude, longitude: value.longitude };
41
+ }
42
+ if (value instanceof Vector) return { __type: "Vector", value: [...value.value] };
43
+ // EntityReference/EntityRelation already serialize themselves via `__type`
44
+ // own properties, so a structured clone is enough for the reviver below.
45
+ if (value instanceof EntityReference || value instanceof EntityRelation) return value;
46
+ if (Array.isArray(value)) return value.map(dehydrateValue);
47
+ if (isPlainObject(value)) {
48
+ const out: Record<string, unknown> = {};
49
+ for (const [key, inner] of Object.entries(value)) out[key] = dehydrateValue(inner);
50
+ return out;
51
+ }
52
+ return value;
53
+ }
54
+
55
+ function hydrateValue(value: unknown): unknown {
56
+ if (value === null || value === undefined || isDate(value)) return value;
57
+ if (Array.isArray(value)) return value.map(hydrateValue);
58
+ if (typeof value === "object") {
59
+ const revived = rebaseReviver("", value);
60
+ // The reviver recognised it — hand back the class instance untouched
61
+ // rather than walking into its (now private) internals.
62
+ if (revived !== value) return revived;
63
+ if (!isPlainObject(value)) return value;
64
+ const out: Record<string, unknown> = {};
65
+ for (const [key, inner] of Object.entries(value)) out[key] = hydrateValue(inner);
66
+ return out;
67
+ }
68
+ return value;
69
+ }
70
+
71
+ /** Prepare a row for the store. */
72
+ export function dehydrateRow<T extends Record<string, unknown>>(row: T): Record<string, unknown> {
73
+ return dehydrateValue(row) as Record<string, unknown>;
74
+ }
75
+
76
+ /** Restore a row read back from the store. */
77
+ export function hydrateRow<T extends Record<string, unknown>>(row: Record<string, unknown>): T {
78
+ return hydrateValue(row) as T;
79
+ }
@@ -0,0 +1,157 @@
1
+ import { ConnectivityMonitor, isNetworkError, isRetryableError } from "./offline-connectivity";
2
+ import { RebaseApiError } from "./transport";
3
+
4
+ /**
5
+ * The monitor decides whether a request is worth sending at all, so getting it
6
+ * wrong is either an app that hangs on every read during an outage, or one that
7
+ * never notices the network came back.
8
+ */
9
+ describe("network error classification", () => {
10
+ it("recognises a request that never reached the server", () => {
11
+ expect(isNetworkError(new TypeError("Failed to fetch"))).toBe(true);
12
+ expect(isNetworkError(new TypeError("fetch failed"))).toBe(true);
13
+ expect(isNetworkError(Object.assign(new Error("aborted"), { name: "AbortError" }))).toBe(true);
14
+ expect(isNetworkError(Object.assign(new Error("timed out"), { name: "TimeoutError" }))).toBe(true);
15
+ expect(isNetworkError(new RebaseApiError("no response", { status: 0 }))).toBe(true);
16
+ });
17
+
18
+ it("does not mistake a server's answer for a dead network", () => {
19
+ expect(isNetworkError(new RebaseApiError("nope", { status: 403 }))).toBe(false);
20
+ expect(isNetworkError(new RebaseApiError("boom", { status: 500 }))).toBe(false);
21
+ // A programming error must propagate, not be swallowed as "offline".
22
+ expect(isNetworkError(new RangeError("bug"))).toBe(false);
23
+ });
24
+
25
+ it("retries what will pass and gives up on what will not", () => {
26
+ expect(isRetryableError(new TypeError("Failed to fetch"))).toBe(true);
27
+ expect(isRetryableError(new RebaseApiError("busy", { status: 429 }))).toBe(true);
28
+ expect(isRetryableError(new RebaseApiError("down", { status: 503 }))).toBe(true);
29
+ expect(isRetryableError(new RebaseApiError("gateway", { status: 502 }))).toBe(true);
30
+
31
+ expect(isRetryableError(new RebaseApiError("invalid", { status: 400 }))).toBe(false);
32
+ expect(isRetryableError(new RebaseApiError("denied", { status: 403 }))).toBe(false);
33
+ expect(isRetryableError(new RebaseApiError("gone", { status: 404 }))).toBe(false);
34
+ // A 500 is far more often a bug the same payload will hit again than a
35
+ // blip, and retrying it forever jams every write queued behind it.
36
+ expect(isRetryableError(new RebaseApiError("boom", { status: 500 }))).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe("ConnectivityMonitor", () => {
41
+ function createMonitor(overrides: Partial<ConstructorParameters<typeof ConnectivityMonitor>[0]> = {}) {
42
+ let now = 1_000_000;
43
+ const timers: { fn: () => void; at: number }[] = [];
44
+ const monitor = new ConnectivityMonitor({
45
+ initialBackoffMs: 100,
46
+ maxBackoffMs: 800,
47
+ now: () => now,
48
+ setTimer: ((fn: () => void, ms: number) => {
49
+ timers.push({ fn, at: now + ms });
50
+ return timers.length as unknown as ReturnType<typeof setTimeout>;
51
+ }) as never,
52
+ clearTimer: (() => undefined) as never,
53
+ ...overrides
54
+ });
55
+ const advance = (ms: number) => {
56
+ now += ms;
57
+ for (const timer of timers.splice(0)) {
58
+ if (timer.at <= now) timer.fn();
59
+ else timers.push(timer);
60
+ }
61
+ };
62
+ return { monitor, advance, at: () => now };
63
+ }
64
+
65
+ it("starts willing to try", () => {
66
+ const { monitor } = createMonitor();
67
+ expect(monitor.isOnline()).toBe(true);
68
+ expect(monitor.shouldAttempt()).toBe(true);
69
+ });
70
+
71
+ it("stops attempting after a failure, until the backoff window opens", () => {
72
+ const { monitor, advance } = createMonitor();
73
+ monitor.markFailure();
74
+
75
+ expect(monitor.isOnline()).toBe(false);
76
+ // This is the whole point: the second read during an outage costs
77
+ // nothing instead of another timeout.
78
+ expect(monitor.shouldAttempt()).toBe(false);
79
+
80
+ advance(200);
81
+ expect(monitor.shouldAttempt()).toBe(true);
82
+ });
83
+
84
+ it("doubles the delay on repeated failures and caps it", () => {
85
+ const { monitor, advance } = createMonitor();
86
+ monitor.markFailure();
87
+ const first = monitor.msUntilRetry();
88
+ advance(first);
89
+
90
+ monitor.markFailure();
91
+ const second = monitor.msUntilRetry();
92
+ expect(second).toBeGreaterThan(first);
93
+
94
+ for (let i = 0; i < 10; i++) {
95
+ advance(monitor.msUntilRetry());
96
+ monitor.markFailure();
97
+ }
98
+ // 800 plus the 20% jitter ceiling.
99
+ expect(monitor.msUntilRetry()).toBeLessThanOrEqual(800 * 1.2);
100
+ });
101
+
102
+ it("resets the backoff once a request gets through", () => {
103
+ const { monitor, advance } = createMonitor();
104
+ monitor.markFailure();
105
+ advance(monitor.msUntilRetry());
106
+ monitor.markFailure();
107
+ monitor.markSuccess();
108
+
109
+ expect(monitor.isOnline()).toBe(true);
110
+ expect(monitor.shouldAttempt()).toBe(true);
111
+ monitor.markFailure();
112
+ // Back to the initial delay, not to where the doubling had reached.
113
+ expect(monitor.msUntilRetry()).toBeLessThanOrEqual(100 * 1.2);
114
+ });
115
+
116
+ it("fires the retry hook when the window opens", () => {
117
+ const { monitor, advance } = createMonitor();
118
+ let retries = 0;
119
+ monitor.onRetryDue = () => { retries++; };
120
+
121
+ monitor.markFailure();
122
+ expect(retries).toBe(0);
123
+ advance(200);
124
+ expect(retries).toBe(1);
125
+ });
126
+
127
+ it("backs off without claiming the connection is gone", () => {
128
+ const { monitor } = createMonitor();
129
+ // A 429 means the server answered — the app is demonstrably online and
130
+ // an "offline" badge would be a lie.
131
+ monitor.deferRetry();
132
+ expect(monitor.isOnline()).toBe(true);
133
+ expect(monitor.shouldAttempt()).toBe(true);
134
+ });
135
+
136
+ it("notifies listeners on each transition, and only on transitions", () => {
137
+ const { monitor } = createMonitor();
138
+ const seen: boolean[] = [];
139
+ monitor.onChange((online) => seen.push(online));
140
+
141
+ monitor.markFailure();
142
+ monitor.markFailure();
143
+ monitor.markSuccess();
144
+ monitor.markSuccess();
145
+
146
+ expect(seen).toEqual([false, true]);
147
+ });
148
+
149
+ it("keeps attempting when backoff suppression is off", () => {
150
+ // Without a retry timer nothing would ever reopen the window, so
151
+ // suppressing attempts would strand the client offline forever.
152
+ const { monitor } = createMonitor({ respectBackoff: false });
153
+ monitor.markFailure();
154
+ expect(monitor.isOnline()).toBe(false);
155
+ expect(monitor.shouldAttempt()).toBe(true);
156
+ });
157
+ });