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