@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/README.md +9 -1
- package/dist/collection.d.ts +55 -3
- package/dist/index.d.ts +23 -0
- package/dist/index.es.js +2045 -10
- 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/dist/transport.d.ts +16 -0
- package/package.json +5 -4
- package/src/auth.ts +71 -3
- 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 +727 -0
- package/src/offline.test.ts +719 -0
- package/src/offline.ts +1687 -0
- package/src/storage.ts +11 -3
- package/src/transport.ts +17 -0
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
|
-
|
|
206
|
+
abandonSessionLocally();
|
|
181
207
|
return;
|
|
182
208
|
}
|
|
183
209
|
if (attempt >= MAX_REFRESH_RETRIES) {
|
|
184
|
-
|
|
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()
|
|
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
|
+
});
|