@rebasepro/client 0.10.0 → 0.10.1-canary.0a881d4
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/auth.d.ts +1 -0
- package/dist/collection.d.ts +55 -3
- package/dist/index.d.ts +23 -0
- package/dist/index.es.js +2076 -26
- 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 +6 -4
- package/src/auth.ts +110 -3
- package/src/collection.ts +118 -2
- package/src/index.ts +64 -17
- 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,86 @@ 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
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Recover from a 401 on an ordinary API request.
|
|
202
|
+
*
|
|
203
|
+
* Returns `true` when the caller should retry — we minted a fresh access
|
|
204
|
+
* token. When the refresh is rejected *fatally* (the refresh token itself
|
|
205
|
+
* is invalid, expired or revoked) this client can no longer act as the
|
|
206
|
+
* user at all, so we drop the session and emit `SIGNED_OUT`. UIs gate on
|
|
207
|
+
* that event, so they show their login screen instead of leaving the user
|
|
208
|
+
* staring at "Invalid or expired token" on every view.
|
|
209
|
+
*
|
|
210
|
+
* Transient failures (offline, 5xx, backend restarting) keep the session:
|
|
211
|
+
* the scheduled refresh backs off and retries, and the token is very
|
|
212
|
+
* likely still good once the backend answers again.
|
|
213
|
+
*/
|
|
214
|
+
async function handleUnauthorized(): Promise<boolean> {
|
|
215
|
+
// No session to recover: the 401 is just an anonymous caller hitting a
|
|
216
|
+
// protected route. Emitting SIGNED_OUT here would fire sign-out
|
|
217
|
+
// handlers for a user who was never signed in.
|
|
218
|
+
if (!currentSession) return false;
|
|
219
|
+
|
|
220
|
+
// Nothing to refresh *with* — the access token is dead and there is no
|
|
221
|
+
// way back. Same end state as a rejected refresh token.
|
|
222
|
+
if (authFlowMode !== "cookie" && !currentSession.refreshToken) {
|
|
223
|
+
abandonSessionLocally();
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
await refreshSession();
|
|
229
|
+
return true;
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (isFatalRefreshError(err)) {
|
|
232
|
+
abandonSessionLocally();
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
174
238
|
async function attemptScheduledRefresh(attempt: number) {
|
|
175
239
|
try {
|
|
176
240
|
await refreshSession();
|
|
177
241
|
// On success, refreshSession() re-schedules the next refresh itself.
|
|
178
242
|
} catch (err) {
|
|
179
243
|
if (isFatalRefreshError(err)) {
|
|
180
|
-
|
|
244
|
+
abandonSessionLocally();
|
|
181
245
|
return;
|
|
182
246
|
}
|
|
183
247
|
if (attempt >= MAX_REFRESH_RETRIES) {
|
|
184
|
-
|
|
248
|
+
abandonSessionLocally();
|
|
185
249
|
return;
|
|
186
250
|
}
|
|
187
251
|
// Transient failure — back off and retry rather than dropping the session.
|
|
@@ -395,10 +459,52 @@ redirectUri });
|
|
|
395
459
|
emit("SIGNED_OUT", null);
|
|
396
460
|
}
|
|
397
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Serialise refreshes across TABS, not just within one.
|
|
464
|
+
*
|
|
465
|
+
* The in-flight promise below covers callers inside a single JavaScript
|
|
466
|
+
* context. It does nothing about the far more common case: two tabs of the
|
|
467
|
+
* same app booting together, each firing its own /refresh with the same
|
|
468
|
+
* cookie. The server tolerates that now (superseded tokens stay usable for
|
|
469
|
+
* a grace window), but tolerating a stampede is not the same as avoiding
|
|
470
|
+
* one, and every extra rotation is another chance to end up holding a
|
|
471
|
+
* token whose response never arrived.
|
|
472
|
+
*
|
|
473
|
+
* Web Locks are best-effort on purpose. supabase-js shipped this and then
|
|
474
|
+
* spent a year fielding deadlock reports — a lock held by a crashed or
|
|
475
|
+
* frozen tab must never be able to wedge sign-in — so a lock we cannot
|
|
476
|
+
* take within the timeout is simply not taken, and the refresh proceeds
|
|
477
|
+
* unserialised, exactly as it did before.
|
|
478
|
+
*/
|
|
479
|
+
const REFRESH_LOCK_NAME = "rebase-auth-refresh";
|
|
480
|
+
const REFRESH_LOCK_TIMEOUT_MS = 5000;
|
|
481
|
+
|
|
482
|
+
async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
483
|
+
const locks = (globalThis as { navigator?: { locks?: LockManager } }).navigator?.locks;
|
|
484
|
+
if (!locks?.request) return fn();
|
|
485
|
+
|
|
486
|
+
const controller = new AbortController();
|
|
487
|
+
const giveUp = setTimeout(() => controller.abort(), REFRESH_LOCK_TIMEOUT_MS);
|
|
488
|
+
try {
|
|
489
|
+
return await locks.request(
|
|
490
|
+
REFRESH_LOCK_NAME,
|
|
491
|
+
{ signal: controller.signal },
|
|
492
|
+
async () => fn()
|
|
493
|
+
) as T;
|
|
494
|
+
} catch (e) {
|
|
495
|
+
// AbortError means only that we waited long enough for the lock.
|
|
496
|
+
// Anything the callback itself threw has to keep propagating.
|
|
497
|
+
if ((e as { name?: string })?.name !== "AbortError") throw e;
|
|
498
|
+
return fn();
|
|
499
|
+
} finally {
|
|
500
|
+
clearTimeout(giveUp);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
398
504
|
function refreshSession(): Promise<RebaseSession> {
|
|
399
505
|
// Share a single in-flight refresh across concurrent callers.
|
|
400
506
|
if (inFlightRefresh) return inFlightRefresh;
|
|
401
|
-
inFlightRefresh = doRefreshSession().finally(() => {
|
|
507
|
+
inFlightRefresh = withRefreshLock(() => doRefreshSession()).finally(() => {
|
|
402
508
|
inFlightRefresh = null;
|
|
403
509
|
});
|
|
404
510
|
return inFlightRefresh;
|
|
@@ -691,6 +797,7 @@ refreshToken: session.refreshToken };
|
|
|
691
797
|
signInWithSpotify,
|
|
692
798
|
signOut,
|
|
693
799
|
refreshSession,
|
|
800
|
+
handleUnauthorized,
|
|
694
801
|
getUser,
|
|
695
802
|
findUserByEmail,
|
|
696
803
|
updateUser,
|
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 ─────────────────────────────────────────────────────────────────
|
|
@@ -288,14 +320,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
288
320
|
/** One channel object per name — see `realtime.channel`. */
|
|
289
321
|
const realtimeChannels = new Map<string, RebaseRealtimeChannel>();
|
|
290
322
|
if (resolvedWsUrl) {
|
|
291
|
-
const wsOnUnauthorized = options.onUnauthorized || (
|
|
292
|
-
try {
|
|
293
|
-
await auth.refreshSession();
|
|
294
|
-
return true;
|
|
295
|
-
} catch (e) {
|
|
296
|
-
return false;
|
|
297
|
-
}
|
|
298
|
-
});
|
|
323
|
+
const wsOnUnauthorized = options.onUnauthorized || (() => auth.handleUnauthorized());
|
|
299
324
|
|
|
300
325
|
ws = new RebaseWebSocketClient({
|
|
301
326
|
websocketUrl: resolvedWsUrl,
|
|
@@ -334,14 +359,11 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
334
359
|
// options.onUnauthorized — because the transport was already created above
|
|
335
360
|
// and captured the (undefined) value from the config closure.
|
|
336
361
|
if (!options.onUnauthorized) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
});
|
|
362
|
+
// `handleUnauthorized` (not a bare `refreshSession`) so that a refresh
|
|
363
|
+
// the server rejects outright drops the session and emits SIGNED_OUT —
|
|
364
|
+
// otherwise the app keeps thinking it is signed in and every view just
|
|
365
|
+
// renders "Invalid or expired token".
|
|
366
|
+
transport.setOnUnauthorized(() => auth.handleUnauthorized());
|
|
345
367
|
}
|
|
346
368
|
|
|
347
369
|
/**
|
|
@@ -398,12 +420,33 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
398
420
|
return undefined;
|
|
399
421
|
}
|
|
400
422
|
|
|
423
|
+
// Offline layer: wraps every collection client with a read cache and a
|
|
424
|
+
// write queue. Replay goes through *unwrapped* clients (the factory below)
|
|
425
|
+
// so a failing replay can never re-queue itself.
|
|
426
|
+
const offlineManager = options.offline
|
|
427
|
+
? new OfflineManager(
|
|
428
|
+
typeof options.offline === "object" ? options.offline : {},
|
|
429
|
+
(slug) => createCollectionClient(transport, slug)
|
|
430
|
+
)
|
|
431
|
+
: undefined;
|
|
432
|
+
|
|
433
|
+
if (offlineManager) {
|
|
434
|
+
// Cache and queue are partitioned per user: cached rows are RLS-scoped
|
|
435
|
+
// to whoever fetched them, and queued writes must replay as the user
|
|
436
|
+
// who made them — a shared browser must never mix the two.
|
|
437
|
+
offlineManager.setScope(auth.getSession()?.user?.uid);
|
|
438
|
+
auth.onAuthStateChange((event, session) => {
|
|
439
|
+
offlineManager.setScope(event === "SIGNED_OUT" ? undefined : session?.user?.uid);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
401
443
|
const collectionClients = new Map<string, CollectionClient<Record<string, unknown>>>();
|
|
402
444
|
let untypedWarned = false;
|
|
403
445
|
|
|
404
446
|
function collection(slug: string): CollectionClient<Record<string, unknown>> {
|
|
405
447
|
if (!collectionClients.has(slug)) {
|
|
406
|
-
|
|
448
|
+
const inner = createCollectionClient(transport, slug, ws);
|
|
449
|
+
collectionClients.set(slug, offlineManager ? offlineManager.wrap(slug, inner) : inner);
|
|
407
450
|
}
|
|
408
451
|
return collectionClients.get(slug)!;
|
|
409
452
|
}
|
|
@@ -510,6 +553,9 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
510
553
|
// Permanent: nothing queued afterwards may redial and keep the
|
|
511
554
|
// event loop alive, which is the reason this method exists.
|
|
512
555
|
ws?.disconnect(true);
|
|
556
|
+
// The offline retry timer is unref'd but the `online` listener is
|
|
557
|
+
// not, and neither should outlive the client.
|
|
558
|
+
offlineManager?.dispose();
|
|
513
559
|
},
|
|
514
560
|
setToken: transport.setToken,
|
|
515
561
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
@@ -526,6 +572,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
526
572
|
return res.data ?? (res as T);
|
|
527
573
|
},
|
|
528
574
|
data: dataProxy,
|
|
575
|
+
...(offlineManager ? { offline: offlineManager.api } : {}),
|
|
529
576
|
} as unknown as CreateRebaseClientResult<DB>;
|
|
530
577
|
|
|
531
578
|
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
|
+
});
|