@rebasepro/client 0.10.0 → 0.10.1-canary.31c773c
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/README.md +9 -1
- package/dist/collection.d.ts +55 -3
- package/dist/index.d.ts +23 -0
- package/dist/index.es.js +1963 -4
- package/dist/index.es.js.map +1 -1
- package/dist/offline-codec.d.ts +4 -0
- package/dist/offline-connectivity.d.ts +78 -0
- package/dist/offline-query.d.ts +51 -0
- package/dist/offline-store.d.ts +150 -0
- package/dist/offline.d.ts +306 -0
- package/package.json +5 -4
- package/src/collection.ts +118 -2
- package/src/index.ts +58 -1
- package/src/offline-codec.ts +79 -0
- package/src/offline-connectivity.test.ts +157 -0
- package/src/offline-connectivity.ts +207 -0
- package/src/offline-idb-store.test.ts +286 -0
- package/src/offline-integration.test.ts +175 -0
- package/src/offline-query.test.ts +249 -0
- package/src/offline-query.ts +356 -0
- package/src/offline-store.ts +353 -0
- package/src/offline-sync-engine.test.ts +676 -0
- package/src/offline.test.ts +719 -0
- package/src/offline.ts +1647 -0
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()
|
|
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
|
-
|
|
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
|
+
});
|