@rebasepro/client 0.10.0 → 0.10.1-canary.a54c057

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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Persistence backends for the SDK's offline support.
3
+ *
4
+ * The store is a dumb, namespaced key/value surface with two areas: a read
5
+ * cache (query results, LRU-evicted) and a mutation queue (writes made while
6
+ * offline, replayed in order). All scoping — per-user prefixes, sequence
7
+ * numbers, key layout — is owned by the `OfflineManager`; the store only
8
+ * promises that `listQueue` returns entries in lexicographic key order, which
9
+ * is what makes the padded sequence numbers a FIFO.
10
+ *
11
+ * Two implementations ship with the SDK:
12
+ * - {@link IndexedDBOfflineStore} — the browser default; survives reloads.
13
+ * - {@link MemoryOfflineStore} — the fallback everywhere IndexedDB does not
14
+ * exist (Node, React Native, tests); survives only the process.
15
+ *
16
+ * Environments with neither (React Native + AsyncStorage, Electron main, …)
17
+ * implement this interface and pass it via `offline.store`.
18
+ */
19
+ /** A cached value plus the moment it was written, for LRU eviction. */
20
+ export interface OfflineCacheEntry {
21
+ value: unknown;
22
+ cachedAt: number;
23
+ }
24
+ /**
25
+ * A write made while offline, waiting to be replayed against the server.
26
+ *
27
+ * `seq` orders the queue globally (not per collection): a create in one
28
+ * collection may be the parent a later insert in another references, so
29
+ * replay must preserve the order the app issued the writes in.
30
+ */
31
+ export interface PendingMutation {
32
+ seq: number;
33
+ collection: string;
34
+ type: "create" | "createMany" | "update" | "delete";
35
+ /** Target row id for update/delete, and the (client-generated) id of an offline create. */
36
+ id?: string | number;
37
+ /**
38
+ * True when the SDK minted this create's id itself. Only such creates may
39
+ * cancel out against a later offline delete: a freshly generated UUID
40
+ * cannot name a row the server already has, while a caller-supplied id
41
+ * can — and there the delete must still replay to remove the server row.
42
+ */
43
+ generatedId?: boolean;
44
+ /** The payload: a row for create/update, an array of rows for createMany. */
45
+ data?: Record<string, unknown> | Record<string, unknown>[];
46
+ upsert?: boolean;
47
+ queuedAt: number;
48
+ }
49
+ export interface OfflineStore {
50
+ getCache(key: string): Promise<OfflineCacheEntry | undefined>;
51
+ setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
52
+ deleteCache(keys: string[]): Promise<void>;
53
+ /** Every cache key starting with `prefix`, with its write time (for eviction). */
54
+ listCache(prefix: string): Promise<{
55
+ key: string;
56
+ cachedAt: number;
57
+ }[]>;
58
+ enqueue(key: string, mutation: PendingMutation): Promise<void>;
59
+ dequeue(key: string): Promise<void>;
60
+ /** Queued mutations whose key starts with `prefix`, in lexicographic key order. */
61
+ listQueue(prefix: string): Promise<PendingMutation[]>;
62
+ /** Remove every cache entry and queued mutation whose key starts with `prefix`. */
63
+ clear(prefix: string): Promise<void>;
64
+ }
65
+ /**
66
+ * In-memory store: the default outside the browser and the workhorse of the
67
+ * test suite. Values are deep-copied on the way in and out so a caller
68
+ * mutating a returned row cannot silently edit the "persisted" copy — the
69
+ * IndexedDB implementation gets the same guarantee for free from structured
70
+ * cloning, and the two must not differ in aliasing behaviour.
71
+ */
72
+ export declare class MemoryOfflineStore implements OfflineStore {
73
+ private cache;
74
+ private queue;
75
+ getCache(key: string): Promise<OfflineCacheEntry | undefined>;
76
+ setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
77
+ deleteCache(keys: string[]): Promise<void>;
78
+ listCache(prefix: string): Promise<{
79
+ key: string;
80
+ cachedAt: number;
81
+ }[]>;
82
+ enqueue(key: string, mutation: PendingMutation): Promise<void>;
83
+ dequeue(key: string): Promise<void>;
84
+ listQueue(prefix: string): Promise<PendingMutation[]>;
85
+ clear(prefix: string): Promise<void>;
86
+ }
87
+ /**
88
+ * IndexedDB-backed store — the browser default, so cached reads and queued
89
+ * writes survive a reload or a browser restart. Everything lives in one
90
+ * database with two object stores; keys are the manager's full prefixed
91
+ * strings, so multiple users (scopes) share the database without ever
92
+ * sharing entries.
93
+ */
94
+ export declare class IndexedDBOfflineStore implements OfflineStore {
95
+ private dbPromise?;
96
+ private open;
97
+ private store;
98
+ getCache(key: string): Promise<OfflineCacheEntry | undefined>;
99
+ setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
100
+ deleteCache(keys: string[]): Promise<void>;
101
+ listCache(prefix: string): Promise<{
102
+ key: string;
103
+ cachedAt: number;
104
+ }[]>;
105
+ enqueue(key: string, mutation: PendingMutation): Promise<void>;
106
+ dequeue(key: string): Promise<void>;
107
+ listQueue(prefix: string): Promise<PendingMutation[]>;
108
+ clear(prefix: string): Promise<void>;
109
+ }
@@ -0,0 +1,129 @@
1
+ import { SDKCollectionClient } from "@rebasepro/types";
2
+ import { CollectionClient } from "./collection";
3
+ import { OfflineStore, PendingMutation } from "./offline-store";
4
+ /**
5
+ * Offline support for the SDK data layer.
6
+ *
7
+ * Reads are network-first: a successful `find`/`findById`/`count` refreshes a
8
+ * local cache, and a network failure falls back to the last cached answer.
9
+ * Writes made while offline are queued and replayed, in the order the app
10
+ * issued them, when connectivity returns. Until then every read — cached or
11
+ * fresh — has the queued writes overlaid, so the app sees its own changes
12
+ * immediately instead of watching them flicker away on the next fetch.
13
+ *
14
+ * What this deliberately is NOT (yet): a local query engine. A cached query
15
+ * is only ever answered for the exact same parameters, and queued creates are
16
+ * folded into filtered queries' totals never — we cannot evaluate a `where`
17
+ * clause client-side, so filtered results only overlay updates and deletes by
18
+ * row id, which are always safe.
19
+ */
20
+ export interface OfflineConfig {
21
+ /**
22
+ * Persistence backend. Defaults to IndexedDB in the browser and an
23
+ * in-memory store elsewhere; pass a custom implementation (e.g. backed by
24
+ * AsyncStorage in React Native) to persist in other environments.
25
+ */
26
+ store?: OfflineStore;
27
+ /**
28
+ * Cached query results kept per collection; the least recently written
29
+ * are evicted beyond this. Defaults to 50.
30
+ */
31
+ maxCachedQueriesPerCollection?: number;
32
+ /**
33
+ * How often to retry replaying the queue while it is non-empty, in
34
+ * milliseconds. The `online` browser event and a sign-in also trigger a
35
+ * replay; this timer is the safety net for environments with neither.
36
+ * Defaults to 30 000.
37
+ */
38
+ syncIntervalMs?: number;
39
+ /**
40
+ * Called when the server *rejects* a queued mutation during replay (a
41
+ * 4xx/5xx response — validation, RLS, a since-deleted row). The mutation
42
+ * is dropped: keeping it would jam the queue behind an error that will
43
+ * never resolve. Network failures are not errors — the mutation stays
44
+ * queued for the next attempt.
45
+ */
46
+ onSyncError?: (error: Error, mutation: PendingMutation) => void;
47
+ }
48
+ /** What `client.offline` exposes to the app. */
49
+ export interface OfflineApi {
50
+ /** Replay the queue now. Resolves with what was flushed and what remains. */
51
+ sync(): Promise<{
52
+ flushed: number;
53
+ remaining: number;
54
+ }>;
55
+ /** The queued mutations for the current user, oldest first. */
56
+ pending(): Promise<PendingMutation[]>;
57
+ /**
58
+ * Drop the current user's queued mutations AND cached reads. Destructive:
59
+ * queued writes are lost, not replayed. For "discard my offline changes"
60
+ * flows, not for sign-out (scoping already isolates users).
61
+ */
62
+ clear(): Promise<void>;
63
+ /** Subscribe to queue-size changes (for a "pending changes" badge). */
64
+ onQueueChange(listener: (count: number) => void): () => void;
65
+ }
66
+ type AnyRow = Record<string, unknown>;
67
+ type InnerFactory = (slug: string) => SDKCollectionClient<AnyRow>;
68
+ export declare class OfflineManager {
69
+ private readonly store;
70
+ private readonly maxCachedQueries;
71
+ private readonly onSyncError?;
72
+ private readonly syncIntervalMs;
73
+ private readonly createInner;
74
+ private readonly inners;
75
+ private scope;
76
+ /** In-memory mirror of the current scope's queue, kept in seq order. */
77
+ private queue;
78
+ private loadPromise?;
79
+ private nextSeq;
80
+ /** Serializes enqueues so concurrent writes get distinct, ordered seqs. */
81
+ private enqueueChain;
82
+ private flushPromise?;
83
+ private queueListeners;
84
+ private syncTimer?;
85
+ private readonly onOnline;
86
+ readonly api: OfflineApi;
87
+ constructor(config: OfflineConfig, createInner: InnerFactory);
88
+ /**
89
+ * Cache and queue are partitioned per signed-in user: cached rows are
90
+ * RLS-filtered for the user who fetched them, and queued writes must
91
+ * replay under the credentials that made them — so neither may ever leak
92
+ * across a sign-out/sign-in on a shared browser.
93
+ */
94
+ setScope(uid: string | undefined): void;
95
+ /** Release the online listener and retry timer (client.close()). */
96
+ dispose(): void;
97
+ wrap<M extends AnyRow>(slug: string, inner: CollectionClient<M>): CollectionClient<M>;
98
+ private pendingFor;
99
+ private overlayFind;
100
+ private overlayCount;
101
+ /**
102
+ * Fold the queued ops for one row into a base value. `touched` separates
103
+ * "the queue says this row does not exist" (a pending delete → undefined)
104
+ * from "the queue has nothing to say" — the caller falls back differently.
105
+ */
106
+ private composeRowFromQueue;
107
+ private overlayRow;
108
+ private ensureLoaded;
109
+ private enqueue;
110
+ sync(): Promise<{
111
+ flushed: number;
112
+ remaining: number;
113
+ }>;
114
+ private replay;
115
+ private drop;
116
+ /** Replay uses unwrapped clients: a failure must never re-enqueue itself. */
117
+ private innerFor;
118
+ private notifyQueue;
119
+ private startTimer;
120
+ private stopTimer;
121
+ private findKey;
122
+ private countKey;
123
+ private rowKey;
124
+ private queueKey;
125
+ private cacheGet;
126
+ private cacheSet;
127
+ private cacheDelete;
128
+ }
129
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.10.1-canary.a54c057",
5
5
  "description": "HTTP SDK client for the Rebase custom backend",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -29,9 +29,9 @@
29
29
  "./package.json": "./package.json"
30
30
  },
31
31
  "dependencies": {
32
- "@rebasepro/utils": "0.10.0",
33
- "@rebasepro/types": "0.10.0",
34
- "@rebasepro/common": "0.10.0"
32
+ "@rebasepro/common": "0.10.1-canary.a54c057",
33
+ "@rebasepro/types": "0.10.1-canary.a54c057",
34
+ "@rebasepro/utils": "0.10.1-canary.a54c057"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@jest/globals": "^30.4.1",
@@ -39,6 +39,7 @@
39
39
  "@types/node": "^25.9.3",
40
40
  "@types/ws": "^8.18.1",
41
41
  "cross-env": "^10.1.0",
42
+ "fake-indexeddb": "^6.2.5",
42
43
  "jest": "^30.4.2",
43
44
  "ts-jest": "^29.4.11",
44
45
  "tsd": "^0.33.0",
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,15 @@ export type {
88
89
  ChannelHistoryResult
89
90
  } from "./realtime-channel";
90
91
 
92
+ // Offline: config + the `client.offline` surface, plus the store contract so
93
+ // other environments (React Native/AsyncStorage, Electron, …) can supply
94
+ // their own persistence. `MemoryOfflineStore` is exported for tests and as
95
+ // the reference implementation; the IndexedDB store is wired automatically
96
+ // in the browser and needs no direct construction.
97
+ export type { OfflineApi, OfflineConfig } from "./offline";
98
+ export type { OfflineStore, OfflineCacheEntry, PendingMutation } from "./offline-store";
99
+ export { MemoryOfflineStore } from "./offline-store";
100
+
91
101
  export interface CreateRebaseClientOptions extends RebaseClientConfig {
92
102
  auth?: CreateAuthOptions;
93
103
  admin?: CreateAdminOptions;
@@ -108,6 +118,15 @@ export interface CreateRebaseClientOptions extends RebaseClientConfig {
108
118
  * correct slugs via this map before falling back to automatic snake_casing.
109
119
  */
110
120
  collections?: Record<string, string>;
121
+ /**
122
+ * Offline support for the data layer. `true` enables it with defaults
123
+ * (network-first reads served from cache on network failure, writes
124
+ * queued and replayed when connectivity returns); pass an
125
+ * {@link OfflineConfig} to control the store, cache size, or sync error
126
+ * handling. Cached reads and queued writes are partitioned per signed-in
127
+ * user. Off by default.
128
+ */
129
+ offline?: boolean | OfflineConfig;
111
130
  }
112
131
 
113
132
  // ─── Typed Data Proxy ────────────────────────────────────────────────────────
@@ -186,6 +205,8 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
186
205
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
187
206
  collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
188
207
  data: TypedDataLayer<DB>;
208
+ /** Present only when the client was created with `offline` enabled. */
209
+ offline?: OfflineApi;
189
210
  };
190
211
 
191
212
  // ─── Factory ─────────────────────────────────────────────────────────────────
@@ -398,12 +419,33 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
398
419
  return undefined;
399
420
  }
400
421
 
422
+ // Offline layer: wraps every collection client with a read cache and a
423
+ // write queue. Replay goes through *unwrapped* clients (the factory below)
424
+ // so a failing replay can never re-queue itself.
425
+ const offlineManager = options.offline
426
+ ? new OfflineManager(
427
+ typeof options.offline === "object" ? options.offline : {},
428
+ (slug) => createCollectionClient(transport, slug)
429
+ )
430
+ : undefined;
431
+
432
+ if (offlineManager) {
433
+ // Cache and queue are partitioned per user: cached rows are RLS-scoped
434
+ // to whoever fetched them, and queued writes must replay as the user
435
+ // who made them — a shared browser must never mix the two.
436
+ offlineManager.setScope(auth.getSession()?.user?.uid);
437
+ auth.onAuthStateChange((event, session) => {
438
+ offlineManager.setScope(event === "SIGNED_OUT" ? undefined : session?.user?.uid);
439
+ });
440
+ }
441
+
401
442
  const collectionClients = new Map<string, CollectionClient<Record<string, unknown>>>();
402
443
  let untypedWarned = false;
403
444
 
404
445
  function collection(slug: string): CollectionClient<Record<string, unknown>> {
405
446
  if (!collectionClients.has(slug)) {
406
- collectionClients.set(slug, createCollectionClient(transport, slug, ws));
447
+ const inner = createCollectionClient(transport, slug, ws);
448
+ collectionClients.set(slug, offlineManager ? offlineManager.wrap(slug, inner) : inner);
407
449
  }
408
450
  return collectionClients.get(slug)!;
409
451
  }
@@ -510,6 +552,9 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
510
552
  // Permanent: nothing queued afterwards may redial and keep the
511
553
  // event loop alive, which is the reason this method exists.
512
554
  ws?.disconnect(true);
555
+ // The offline retry timer is unref'd but the `online` listener is
556
+ // not, and neither should outlive the client.
557
+ offlineManager?.dispose();
513
558
  },
514
559
  setToken: transport.setToken,
515
560
  setAuthTokenGetter: transport.setAuthTokenGetter,
@@ -526,6 +571,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
526
571
  return res.data ?? (res as T);
527
572
  },
528
573
  data: dataProxy,
574
+ ...(offlineManager ? { offline: offlineManager.api } : {}),
529
575
  } as unknown as CreateRebaseClientResult<DB>;
530
576
 
531
577
  return target;
@@ -0,0 +1,151 @@
1
+ import "fake-indexeddb/auto";
2
+ import { IndexedDBOfflineStore, PendingMutation } from "./offline-store";
3
+ import { OfflineManager } from "./offline";
4
+ import { RebaseApiError } from "./transport";
5
+ import type { CollectionClient } from "./collection";
6
+
7
+ /**
8
+ * The IndexedDB store against fake-indexeddb — the same structured-clone
9
+ * semantics and key ordering as a real browser, without one. All tests share
10
+ * one fake database (like real tabs share one), so each uses its own key
11
+ * prefix; the prefix isolation test is exactly what makes that safe.
12
+ */
13
+ describe("IndexedDBOfflineStore", () => {
14
+ const store = new IndexedDBOfflineStore();
15
+
16
+ function mutation(scope: string, seq: number, extra: Partial<PendingMutation> = {}): PendingMutation {
17
+ return {
18
+ seq,
19
+ collection: "posts",
20
+ type: "create",
21
+ id: `id-${seq}`,
22
+ data: { id: `id-${seq}` },
23
+ queuedAt: Date.now(),
24
+ ...extra
25
+ };
26
+ }
27
+
28
+ function queueKey(scope: string, seq: number): string {
29
+ return `${scope}|${String(seq).padStart(16, "0")}`;
30
+ }
31
+
32
+ it("round-trips cache entries, including Date values", async () => {
33
+ const createdAt = new Date("2026-07-01T12:00:00Z");
34
+ await store.setCache("t1|find|posts|", { value: { data: [{ id: "p1", createdAt }] }, cachedAt: 111 });
35
+
36
+ const entry = await store.getCache("t1|find|posts|");
37
+ expect(entry?.cachedAt).toBe(111);
38
+ const row = (entry?.value as { data: { id: string; createdAt: Date }[] }).data[0];
39
+ // Not toBeInstanceOf: fake-indexeddb clones through another realm, so
40
+ // the constructor identity differs while the value is a real Date.
41
+ expect(Object.prototype.toString.call(row.createdAt)).toBe("[object Date]");
42
+ expect(row.createdAt.toISOString()).toBe(createdAt.toISOString());
43
+ });
44
+
45
+ it("returns undefined for a missing cache key", async () => {
46
+ expect(await store.getCache("t2|nope")).toBeUndefined();
47
+ });
48
+
49
+ it("lists only the keys under a prefix, with their write times", async () => {
50
+ await store.setCache("t3|find|posts|a", { value: 1, cachedAt: 10 });
51
+ await store.setCache("t3|find|posts|b", { value: 2, cachedAt: 20 });
52
+ await store.setCache("t3|find|users|a", { value: 3, cachedAt: 30 });
53
+
54
+ const listed = await store.listCache("t3|find|posts|");
55
+ expect(listed.map((e) => e.key).sort()).toEqual(["t3|find|posts|a", "t3|find|posts|b"]);
56
+ expect(listed.find((e) => e.key.endsWith("|a"))?.cachedAt).toBe(10);
57
+ });
58
+
59
+ it("deletes several cache keys at once", async () => {
60
+ await store.setCache("t4|x", { value: 1, cachedAt: 1 });
61
+ await store.setCache("t4|y", { value: 2, cachedAt: 2 });
62
+ await store.deleteCache(["t4|x", "t4|y"]);
63
+ expect(await store.getCache("t4|x")).toBeUndefined();
64
+ expect(await store.getCache("t4|y")).toBeUndefined();
65
+ });
66
+
67
+ it("returns the queue in seq order even when written out of order", async () => {
68
+ await store.enqueue(queueKey("t5", 2), mutation("t5", 2));
69
+ await store.enqueue(queueKey("t5", 10), mutation("t5", 10));
70
+ await store.enqueue(queueKey("t5", 1), mutation("t5", 1));
71
+
72
+ const queue = await store.listQueue("t5|");
73
+ expect(queue.map((m) => m.seq)).toEqual([1, 2, 10]);
74
+ });
75
+
76
+ it("dequeues a single mutation", async () => {
77
+ await store.enqueue(queueKey("t6", 1), mutation("t6", 1));
78
+ await store.enqueue(queueKey("t6", 2), mutation("t6", 2));
79
+ await store.dequeue(queueKey("t6", 1));
80
+ expect((await store.listQueue("t6|")).map((m) => m.seq)).toEqual([2]);
81
+ });
82
+
83
+ it("clear() removes one prefix's cache and queue and nothing else", async () => {
84
+ await store.setCache("t7a|find|posts|", { value: 1, cachedAt: 1 });
85
+ await store.enqueue(queueKey("t7a", 1), mutation("t7a", 1));
86
+ await store.setCache("t7b|find|posts|", { value: 2, cachedAt: 2 });
87
+ await store.enqueue(queueKey("t7b", 1), mutation("t7b", 1));
88
+
89
+ await store.clear("t7a|");
90
+
91
+ expect(await store.getCache("t7a|find|posts|")).toBeUndefined();
92
+ expect(await store.listQueue("t7a|")).toHaveLength(0);
93
+ expect(await store.getCache("t7b|find|posts|")).toBeDefined();
94
+ expect(await store.listQueue("t7b|")).toHaveLength(1);
95
+ });
96
+
97
+ it("persists across store instances", async () => {
98
+ await store.setCache("t8|find|posts|", { value: 42, cachedAt: 1 });
99
+ await store.enqueue(queueKey("t8", 1), mutation("t8", 1));
100
+
101
+ const reopened = new IndexedDBOfflineStore();
102
+ expect((await reopened.getCache("t8|find|posts|"))?.value).toBe(42);
103
+ expect(await reopened.listQueue("t8|")).toHaveLength(1);
104
+ });
105
+ });
106
+
107
+ describe("OfflineManager over IndexedDB", () => {
108
+ it("queues offline writes in one manager and replays them from a fresh one", async () => {
109
+ const table = new Map<string, Record<string, unknown>>();
110
+ const state = { online: true };
111
+ const fakeClient = {
112
+ async find() {
113
+ if (!state.online) throw new TypeError("fetch failed");
114
+ const data = [...table.values()];
115
+ return { data, meta: { total: data.length, limit: 20, offset: 0, hasMore: false } };
116
+ },
117
+ async findById(id: string | number) {
118
+ if (!state.online) throw new TypeError("fetch failed");
119
+ return table.get(String(id));
120
+ },
121
+ async create(data: Record<string, unknown>) {
122
+ if (!state.online) throw new TypeError("fetch failed");
123
+ table.set(String(data.id), data);
124
+ return data;
125
+ },
126
+ async createMany() { throw new RebaseApiError("unused", { status: 500 }); },
127
+ async update() { throw new RebaseApiError("unused", { status: 500 }); },
128
+ async delete() { throw new RebaseApiError("unused", { status: 500 }); },
129
+ async count() { return table.size; }
130
+ } as unknown as CollectionClient<Record<string, unknown>>;
131
+
132
+ const first = new OfflineManager(
133
+ { store: new IndexedDBOfflineStore(), syncIntervalMs: 0 },
134
+ () => fakeClient
135
+ );
136
+ state.online = false;
137
+ const posts = first.wrap("posts", fakeClient);
138
+ const row = await posts.create({ title: "persisted offline" });
139
+ expect(await posts.findById(row.id as string)).toMatchObject({ title: "persisted offline" });
140
+ first.dispose();
141
+
142
+ state.online = true;
143
+ const second = new OfflineManager(
144
+ { store: new IndexedDBOfflineStore(), syncIntervalMs: 0 },
145
+ () => fakeClient
146
+ );
147
+ expect(await second.sync()).toEqual({ flushed: 1, remaining: 0 });
148
+ expect(table.get(String(row.id))).toMatchObject({ title: "persisted offline" });
149
+ second.dispose();
150
+ });
151
+ });
@@ -0,0 +1,134 @@
1
+ import { createRebaseClient, RebaseApiError } from "./index";
2
+ import { MemoryOfflineStore } from "./offline-store";
3
+
4
+ /**
5
+ * End-to-end wiring test: a real client (real transport, real collection
6
+ * client, real offline manager) against a fetch stub, proving the `offline`
7
+ * option actually threads through `createRebaseClient` — the unit suite in
8
+ * offline.test.ts covers the manager against fakes, not this plumbing.
9
+ */
10
+ describe("createRebaseClient({ offline })", () => {
11
+ function jsonResponse(body: unknown): Response {
12
+ return new Response(JSON.stringify(body), {
13
+ status: 200,
14
+ headers: { "Content-Type": "application/json" }
15
+ });
16
+ }
17
+
18
+ function createHarness(offline: boolean | { store: MemoryOfflineStore; syncIntervalMs: number } =
19
+ { store: new MemoryOfflineStore(), syncIntervalMs: 0 }) {
20
+ const network = { online: true, serverError: false };
21
+ const requests: { method: string; url: string; body?: unknown }[] = [];
22
+ const fetchStub: typeof fetch = async (input, init) => {
23
+ const url = String(input);
24
+ if (!network.online) throw new TypeError("fetch failed");
25
+ if (network.serverError) {
26
+ return new Response(JSON.stringify({ error: { message: "boom", code: "internal" } }), {
27
+ status: 500,
28
+ headers: { "Content-Type": "application/json" }
29
+ });
30
+ }
31
+ requests.push({
32
+ method: init?.method ?? "GET",
33
+ url,
34
+ body: init?.body ? JSON.parse(String(init.body)) : undefined
35
+ });
36
+ if (url.includes("/data/posts/count")) return jsonResponse({ count: 1 });
37
+ if (url.includes("/data/posts") && (init?.method ?? "GET") === "GET") {
38
+ return jsonResponse({
39
+ data: [{ id: "p1", title: "hello" }],
40
+ meta: { total: 1, limit: 20, offset: 0, hasMore: false }
41
+ });
42
+ }
43
+ if (url.includes("/data/posts") && init?.method === "POST") {
44
+ return jsonResponse(JSON.parse(String(init?.body)));
45
+ }
46
+ return jsonResponse({});
47
+ };
48
+
49
+ const client = createRebaseClient({
50
+ baseUrl: "http://localhost:9999",
51
+ realtime: false,
52
+ fetch: fetchStub,
53
+ offline
54
+ });
55
+ return { client, network, requests };
56
+ }
57
+
58
+ it("exposes client.offline only when enabled", () => {
59
+ const { client } = createHarness();
60
+ expect(client.offline).toBeDefined();
61
+
62
+ const plain = createRebaseClient({
63
+ baseUrl: "http://localhost:9999",
64
+ realtime: false
65
+ });
66
+ expect(plain.offline).toBeUndefined();
67
+ });
68
+
69
+ it("serves cached reads and queues writes through the data proxy, then syncs", async () => {
70
+ const { client, network, requests } = createHarness();
71
+ const posts = client.data.collection("posts");
72
+
73
+ const online = await posts.find();
74
+ expect(online.data).toEqual([{ id: "p1", title: "hello" }]);
75
+
76
+ network.online = false;
77
+ const cached = await posts.find();
78
+ expect(cached.data).toEqual([{ id: "p1", title: "hello" }]);
79
+
80
+ const draft = await posts.create({ title: "offline draft" });
81
+ expect(draft.title).toBe("offline draft");
82
+ expect(await client.offline!.pending()).toHaveLength(1);
83
+
84
+ // The pending create is visible in the cached, filterless find.
85
+ const overlaid = await posts.find();
86
+ expect(overlaid.data.map((r) => r.title)).toEqual(["hello", "offline draft"]);
87
+ expect(overlaid.meta.total).toBe(2);
88
+
89
+ network.online = true;
90
+ const result = await client.offline!.sync();
91
+ expect(result).toEqual({ flushed: 1, remaining: 0 });
92
+
93
+ const replayed = requests.find((r) => r.method === "POST" && r.url.endsWith("/data/posts"));
94
+ expect(replayed?.body).toMatchObject({ title: "offline draft", id: draft.id });
95
+ });
96
+
97
+ it("offline: true works in Node via the in-memory fallback store", async () => {
98
+ const { client, network } = createHarness(true);
99
+ const posts = client.data.collection("posts");
100
+
101
+ await posts.find();
102
+ network.online = false;
103
+ expect((await posts.find()).data).toEqual([{ id: "p1", title: "hello" }]);
104
+
105
+ await posts.create({ title: "queued" });
106
+ expect(await client.offline!.pending()).toHaveLength(1);
107
+ });
108
+
109
+ it("routes fluent-builder queries through the cache", async () => {
110
+ const { client, network, requests } = createHarness();
111
+ const posts = client.data.collection("posts");
112
+
113
+ const online = await posts.where("title", "==", "hello").find();
114
+ expect(online.data).toHaveLength(1);
115
+ // The filter must actually reach the wire — otherwise this test would
116
+ // pass by caching an unfiltered query.
117
+ expect(requests.some((r) => r.url.includes("title="))).toBe(true);
118
+
119
+ network.online = false;
120
+ const cached = await posts.where("title", "==", "hello").find();
121
+ expect(cached.data).toEqual(online.data);
122
+ // A different filter is a different cache key, so it must miss.
123
+ await expect(posts.where("title", "==", "other").find()).rejects.toThrow(TypeError);
124
+ });
125
+
126
+ it("does not serve cache on a server error — only on network failure", async () => {
127
+ const { client, network } = createHarness();
128
+ const posts = client.data.collection("posts");
129
+ await posts.find();
130
+
131
+ network.serverError = true;
132
+ await expect(posts.find()).rejects.toThrow(RebaseApiError);
133
+ });
134
+ });