@rebasepro/client 0.17.3 → 0.18.1

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.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. package/src/websocket.ts +0 -1837
@@ -1,340 +0,0 @@
1
- import "fake-indexeddb/auto";
2
- import { IndexedDBOfflineStore, PendingMutation } from "./offline-store";
3
- import { OfflineManager } from "./offline";
4
- import { RebaseApiError } from "./transport";
5
- import { dehydrateRow, hydrateRow } from "./offline-codec";
6
- import { EntityReference, EntityRelation, GeoPoint, Vector } from "@rebasepro/types";
7
- import type { CollectionClient } from "./collection";
8
-
9
- /**
10
- * The IndexedDB store against fake-indexeddb — the same structured-clone
11
- * semantics and key ordering as a real browser, without one. All tests share
12
- * one fake database (like real tabs share one), so each uses its own key
13
- * prefix; the prefix isolation test is exactly what makes that safe.
14
- */
15
- describe("IndexedDBOfflineStore", () => {
16
- const store = new IndexedDBOfflineStore();
17
-
18
- function mutation(seq: number, extra: Partial<PendingMutation> = {}): PendingMutation {
19
- return {
20
- mutationId: String(seq).padStart(10, "0"),
21
- collection: "posts",
22
- type: "create",
23
- id: `id-${seq}`,
24
- data: { id: `id-${seq}` },
25
- queuedAt: Date.now(),
26
- ...extra
27
- };
28
- }
29
-
30
- function queueKey(scope: string, seq: number): string {
31
- return `${scope}|${String(seq).padStart(10, "0")}`;
32
- }
33
-
34
- it("round-trips cache entries, including Date values", async () => {
35
- const createdAt = new Date("2026-07-01T12:00:00Z");
36
- await store.setCache("t1|find|posts|", { value: { data: [{ id: "p1", createdAt }] }, cachedAt: 111 });
37
-
38
- const entry = await store.getCache("t1|find|posts|");
39
- expect(entry?.cachedAt).toBe(111);
40
- const row = (entry?.value as { data: { id: string; createdAt: Date }[] }).data[0];
41
- // Not toBeInstanceOf: fake-indexeddb clones through another realm, so
42
- // the constructor identity differs while the value is a real Date.
43
- expect(Object.prototype.toString.call(row.createdAt)).toBe("[object Date]");
44
- expect(row.createdAt.toISOString()).toBe(createdAt.toISOString());
45
- });
46
-
47
- it("returns undefined for a missing cache key", async () => {
48
- expect(await store.getCache("t2|nope")).toBeUndefined();
49
- });
50
-
51
- it("lists only the keys under a prefix, with their write times", async () => {
52
- await store.setCache("t3|find|posts|a", { value: 1, cachedAt: 10 });
53
- await store.setCache("t3|find|posts|b", { value: 2, cachedAt: 20 });
54
- await store.setCache("t3|find|users|a", { value: 3, cachedAt: 30 });
55
-
56
- const listed = await store.listCache("t3|find|posts|");
57
- expect(listed.map((e) => e.key).sort()).toEqual(["t3|find|posts|a", "t3|find|posts|b"]);
58
- expect(listed.find((e) => e.key.endsWith("|a"))?.cachedAt).toBe(10);
59
- });
60
-
61
- it("deletes several cache keys at once", async () => {
62
- await store.setCache("t4|x", { value: 1, cachedAt: 1 });
63
- await store.setCache("t4|y", { value: 2, cachedAt: 2 });
64
- await store.deleteCache(["t4|x", "t4|y"]);
65
- expect(await store.getCache("t4|x")).toBeUndefined();
66
- expect(await store.getCache("t4|y")).toBeUndefined();
67
- });
68
-
69
- it("returns the queue in mutation-id order even when written out of order", async () => {
70
- await store.enqueue(queueKey("t5", 2), mutation(2));
71
- await store.enqueue(queueKey("t5", 10), mutation(10));
72
- await store.enqueue(queueKey("t5", 1), mutation(1));
73
-
74
- const queue = await store.listQueue("t5|");
75
- expect(queue.map((m) => m.id)).toEqual(["id-1", "id-2", "id-10"]);
76
- });
77
-
78
- it("dequeues a single mutation", async () => {
79
- await store.enqueue(queueKey("t6", 1), mutation(1));
80
- await store.enqueue(queueKey("t6", 2), mutation(2));
81
- await store.dequeue(queueKey("t6", 1));
82
- expect((await store.listQueue("t6|")).map((m) => m.id)).toEqual(["id-2"]);
83
- });
84
-
85
- it("clear() removes one prefix's cache and queue and nothing else", async () => {
86
- await store.setCache("t7a|find|posts|", { value: 1, cachedAt: 1 });
87
- await store.enqueue(queueKey("t7a", 1), mutation(1));
88
- await store.setCache("t7b|find|posts|", { value: 2, cachedAt: 2 });
89
- await store.enqueue(queueKey("t7b", 1), mutation(1));
90
-
91
- await store.clear("t7a|");
92
-
93
- expect(await store.getCache("t7a|find|posts|")).toBeUndefined();
94
- expect(await store.listQueue("t7a|")).toHaveLength(0);
95
- expect(await store.getCache("t7b|find|posts|")).toBeDefined();
96
- expect(await store.listQueue("t7b|")).toHaveLength(1);
97
- });
98
-
99
- it("persists across store instances", async () => {
100
- await store.setCache("t8|find|posts|", { value: 42, cachedAt: 1 });
101
- await store.enqueue(queueKey("t8", 1), mutation(1));
102
-
103
- const reopened = new IndexedDBOfflineStore();
104
- expect((await reopened.getCache("t8|find|posts|"))?.value).toBe(42);
105
- expect(await reopened.listQueue("t8|")).toHaveLength(1);
106
- });
107
-
108
- it("writes a batch in one transaction and reads the values back by prefix", async () => {
109
- await store.setCacheMany([
110
- { key: "t9|row|posts|a", entry: { value: { id: "a", n: 1 }, cachedAt: 5 } },
111
- { key: "t9|row|posts|b", entry: { value: { id: "b", n: 2 }, cachedAt: 6 } },
112
- { key: "t9|row|users|c", entry: { value: { id: "c" }, cachedAt: 7 } }
113
- ]);
114
-
115
- const entries = await store.listCacheEntries("t9|row|posts|");
116
- expect(entries.map((e) => e.key)).toEqual(["t9|row|posts|a", "t9|row|posts|b"]);
117
- expect(entries.map((e) => (e.value as { n: number }).n)).toEqual([1, 2]);
118
- expect(entries[1].cachedAt).toBe(6);
119
- });
120
- });
121
-
122
- describe("row codec round-trip", () => {
123
- /**
124
- * Both stores move values by structured clone, which keeps `Date` but
125
- * flattens class instances. A row read back from the cache has to be
126
- * indistinguishable from the same row read from the network, or an app
127
- * that calls `row.point.latitude` works online and throws offline.
128
- */
129
- it("survives the store as the same shapes it went in as", async () => {
130
- const store = new IndexedDBOfflineStore();
131
- const row = {
132
- id: "r1",
133
- at: new Date("2026-07-01T00:00:00Z"),
134
- point: new GeoPoint(41.4, 2.2),
135
- embedding: new Vector([0.1, 0.2]),
136
- author: new EntityReference({ id: "u1", path: "users" }),
137
- tag: new EntityRelation("t1", "tags"),
138
- nested: { deep: [new GeoPoint(1, 2)] },
139
- plain: "text"
140
- };
141
-
142
- await store.setCache("codec|row|things|r1", { value: dehydrateRow(row), cachedAt: 1 });
143
- const back = hydrateRow((await store.getCache("codec|row|things|r1"))!.value as Record<string, unknown>);
144
-
145
- expect(back.point).toBeInstanceOf(GeoPoint);
146
- expect((back.point as GeoPoint).latitude).toBe(41.4);
147
- expect(back.embedding).toBeInstanceOf(Vector);
148
- expect((back.embedding as Vector).value).toEqual([0.1, 0.2]);
149
- expect(back.author).toBeInstanceOf(EntityReference);
150
- expect((back.author as EntityReference).pathWithId).toBe("users/u1");
151
- expect(back.tag).toBeInstanceOf(EntityRelation);
152
- expect(((back.nested as { deep: GeoPoint[] }).deep[0])).toBeInstanceOf(GeoPoint);
153
- expect(Object.prototype.toString.call(back.at)).toBe("[object Date]");
154
- expect(back.plain).toBe("text");
155
- });
156
- });
157
-
158
- /**
159
- * Empty the shared queue between the manager tests.
160
- *
161
- * `IDB_NAME` is a hardcoded `"rebase-offline"`, so every
162
- * `new IndexedDBOfflineStore()` in this file opens the *same* database. The
163
- * `IndexedDBOfflineStore` block above shares it deliberately — each of its
164
- * tests uses its own key prefix, and one of them exists to prove that
165
- * isolation holds. The two `OfflineManager` blocks below do not: a manager
166
- * queues under no prefix, so without this they inherit each other's pending
167
- * writes.
168
- *
169
- * CI failed both manager tests on 2026-07-28 with exactly the symptoms this
170
- * coupling would produce — "replays them from a fresh one" asserted
171
- * `flushed: 1` and got `2`, and the cross-tab read came back empty. It has not
172
- * been reproduced locally (14 runs, including randomized order), so treat this
173
- * as removing a real coupling rather than as a confirmed diagnosis: if CI fails
174
- * here again, the shared queue is no longer the explanation and the next place
175
- * to look is timing inside the manager itself.
176
- */
177
- async function clearOfflineQueue(): Promise<void> {
178
- await new Promise<void>((resolve, reject) => {
179
- const request = indexedDB.deleteDatabase("rebase-offline");
180
- request.onsuccess = () => resolve();
181
- request.onerror = () => reject(request.error);
182
- // A still-open connection blocks the delete; the manager tests each
183
- // build their own stores, so resolving here keeps a stray one from
184
- // hanging the suite rather than failing it.
185
- request.onblocked = () => resolve();
186
- });
187
- }
188
-
189
- describe("two tabs over one database", () => {
190
- beforeEach(clearOfflineQueue);
191
-
192
- /**
193
- * Wait until the other tab has caught up.
194
- *
195
- * BroadcastChannel delivery is asynchronous and the receiving tab then does
196
- * its own IndexedDB work, so there is no fixed number of turns that is
197
- * both enough and not wasteful. Draining three macrotasks was enough on an
198
- * idle machine and not enough when the other package suites run alongside
199
- * this one — which is how this file failed under `pnpm -r test` while
200
- * passing every time on its own.
201
- *
202
- * Polling a condition is what the test actually means, and it costs one
203
- * turn in the common case rather than three.
204
- */
205
- const settleUntil = async (done: () => boolean | Promise<boolean>, deadlineMs = 5_000) => {
206
- const started = Date.now();
207
- for (;;) {
208
- if (await done()) return;
209
- if (Date.now() - started > deadlineMs) {
210
- throw new Error(`condition never held within ${deadlineMs}ms`);
211
- }
212
- await new Promise((resolve) => setTimeout(resolve, 5));
213
- }
214
- };
215
-
216
- function createServer() {
217
- const table = new Map<string, Record<string, unknown>>();
218
- const state = { online: true };
219
- const client = {
220
- async find() {
221
- if (!state.online) throw new TypeError("fetch failed");
222
- const data = [...table.values()];
223
- return { data, meta: { total: data.length, limit: 20, offset: 0, hasMore: false } };
224
- },
225
- async findById(id: string | number) {
226
- if (!state.online) throw new TypeError("fetch failed");
227
- return table.get(String(id));
228
- },
229
- async create(data: Record<string, unknown>) {
230
- if (!state.online) throw new TypeError("fetch failed");
231
- table.set(String(data.id), data);
232
- return data;
233
- },
234
- async createMany() { throw new RebaseApiError("unused", { status: 500 }); },
235
- async update() { throw new RebaseApiError("unused", { status: 500 }); },
236
- async delete() { throw new RebaseApiError("unused", { status: 500 }); },
237
- async count() { return table.size; }
238
- } as unknown as CollectionClient<Record<string, unknown>>;
239
- return { table, state, client };
240
- }
241
-
242
- /**
243
- * Web Locks is how two tabs avoid replaying the same queue at the same
244
- * time. Node has no implementation, so this is the browser's — a plain
245
- * mutex keyed by name, which is all the manager asks of it.
246
- */
247
- function installWebLocks(): () => void {
248
- const held = new Map<string, Promise<unknown>>();
249
- const locks = {
250
- request: (name: string, fn: () => Promise<unknown>) => {
251
- const previous = held.get(name) ?? Promise.resolve();
252
- const next = previous.then(fn, fn);
253
- held.set(name, next.then(() => undefined, () => undefined));
254
- return next;
255
- }
256
- };
257
- Object.defineProperty(globalThis.navigator, "locks", { value: locks, configurable: true });
258
- return () => { delete (globalThis.navigator as { locks?: unknown }).locks; };
259
- }
260
-
261
- it("shows one tab's offline write in the other, and queues it only once", async () => {
262
- const uninstall = installWebLocks();
263
- const server = createServer();
264
- const tabA = new OfflineManager({ store: new IndexedDBOfflineStore(), syncIntervalMs: 0 }, () => server.client);
265
- const tabB = new OfflineManager({ store: new IndexedDBOfflineStore(), syncIntervalMs: 0 }, () => server.client);
266
- const postsA = tabA.wrap("xtab", server.client);
267
- const postsB = tabB.wrap("xtab", server.client);
268
-
269
- await postsA.find();
270
- await postsB.find();
271
-
272
- server.state.online = false;
273
- await postsA.create({ title: "from tab A" }, "xt1");
274
- await settleUntil(async () => (await postsB.find()).data.some((r) => r.id === "xt1"));
275
-
276
- // Without cross-tab propagation this tab would be showing a list that
277
- // is already wrong, and would keep showing it until a refetch.
278
- expect((await postsB.find()).data.map((r) => r.id)).toContain("xt1");
279
- expect(await tabB.api.pending()).toHaveLength(1);
280
-
281
- // Both tabs share the queue, so the write must reach the server once —
282
- // the lock serialises them, and the loser re-reads a drained queue.
283
- server.state.online = true;
284
- const [a, b] = await Promise.all([tabA.sync(), tabB.sync()]);
285
- expect(a.flushed + b.flushed).toBe(1);
286
- expect(server.table.get("xt1")).toMatchObject({ title: "from tab A" });
287
-
288
- tabA.dispose();
289
- tabB.dispose();
290
- uninstall();
291
- });
292
- });
293
-
294
- describe("OfflineManager over IndexedDB", () => {
295
- beforeEach(clearOfflineQueue);
296
-
297
- it("queues offline writes in one manager and replays them from a fresh one", async () => {
298
- const table = new Map<string, Record<string, unknown>>();
299
- const state = { online: true };
300
- const fakeClient = {
301
- async find() {
302
- if (!state.online) throw new TypeError("fetch failed");
303
- const data = [...table.values()];
304
- return { data, meta: { total: data.length, limit: 20, offset: 0, hasMore: false } };
305
- },
306
- async findById(id: string | number) {
307
- if (!state.online) throw new TypeError("fetch failed");
308
- return table.get(String(id));
309
- },
310
- async create(data: Record<string, unknown>) {
311
- if (!state.online) throw new TypeError("fetch failed");
312
- table.set(String(data.id), data);
313
- return data;
314
- },
315
- async createMany() { throw new RebaseApiError("unused", { status: 500 }); },
316
- async update() { throw new RebaseApiError("unused", { status: 500 }); },
317
- async delete() { throw new RebaseApiError("unused", { status: 500 }); },
318
- async count() { return table.size; }
319
- } as unknown as CollectionClient<Record<string, unknown>>;
320
-
321
- const first = new OfflineManager(
322
- { store: new IndexedDBOfflineStore(), syncIntervalMs: 0 },
323
- () => fakeClient
324
- );
325
- state.online = false;
326
- const posts = first.wrap("posts", fakeClient);
327
- const row = await posts.create({ title: "persisted offline" });
328
- expect(await posts.findById(row.id as string)).toMatchObject({ title: "persisted offline" });
329
- first.dispose();
330
-
331
- state.online = true;
332
- const second = new OfflineManager(
333
- { store: new IndexedDBOfflineStore(), syncIntervalMs: 0 },
334
- () => fakeClient
335
- );
336
- expect(await second.sync()).toEqual({ flushed: 1, remaining: 0 });
337
- expect(table.get(String(row.id))).toMatchObject({ title: "persisted offline" });
338
- second.dispose();
339
- });
340
- });
@@ -1,180 +0,0 @@
1
- import { createRebaseClient, RebaseApiError, type LiveResult } 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
- // Tokenless clients in a Node environment, on purpose — that is what the
12
- // anonymous-server-client guard warns about (covered by
13
- // src/anonymous-client-guard.test.ts). Here it is only noise.
14
- beforeEach(() => { jest.spyOn(console, "warn").mockImplementation(() => undefined); });
15
-
16
- function jsonResponse(body: unknown): Response {
17
- return new Response(JSON.stringify(body), {
18
- status: 200,
19
- headers: { "Content-Type": "application/json" }
20
- });
21
- }
22
-
23
- function createHarness(offline: boolean | { store: MemoryOfflineStore; syncIntervalMs: number } =
24
- { store: new MemoryOfflineStore(), syncIntervalMs: 0 }) {
25
- const network = { online: true, serverError: false };
26
- const requests: { method: string; url: string; body?: unknown }[] = [];
27
- const fetchStub: typeof fetch = async (input, init) => {
28
- const url = String(input);
29
- if (!network.online) throw new TypeError("fetch failed");
30
- if (network.serverError) {
31
- return new Response(JSON.stringify({ error: { message: "boom", code: "internal" } }), {
32
- status: 500,
33
- headers: { "Content-Type": "application/json" }
34
- });
35
- }
36
- requests.push({
37
- method: init?.method ?? "GET",
38
- url,
39
- body: init?.body ? JSON.parse(String(init.body)) : undefined
40
- });
41
- if (url.includes("/data/posts/count")) return jsonResponse({ count: 1 });
42
- if (url.includes("/data/posts") && (init?.method ?? "GET") === "GET") {
43
- return jsonResponse({
44
- data: [{ id: "p1", title: "hello" }],
45
- meta: { total: 1, limit: 20, offset: 0, hasMore: false }
46
- });
47
- }
48
- if (url.includes("/data/posts") && init?.method === "POST") {
49
- return jsonResponse(JSON.parse(String(init?.body)));
50
- }
51
- return jsonResponse({});
52
- };
53
-
54
- const client = createRebaseClient({
55
- baseUrl: "http://localhost:9999",
56
- realtime: false,
57
- fetch: fetchStub,
58
- offline
59
- });
60
- return { client, network, requests, fetchStub };
61
- }
62
-
63
- it("exposes client.offline only when enabled", () => {
64
- const { client } = createHarness();
65
- expect(client.offline).toBeDefined();
66
-
67
- const plain = createRebaseClient({
68
- baseUrl: "http://localhost:9999",
69
- realtime: false
70
- });
71
- expect(plain.offline).toBeUndefined();
72
- });
73
-
74
- it("serves cached reads and queues writes through the data proxy, then syncs", async () => {
75
- const { client, network, requests } = createHarness();
76
- const posts = client.data.collection("posts");
77
-
78
- const online = await posts.find();
79
- expect(online.data).toEqual([{ id: "p1", title: "hello" }]);
80
-
81
- network.online = false;
82
- const cached = await posts.find();
83
- expect(cached.data).toEqual([{ id: "p1", title: "hello" }]);
84
-
85
- const draft = await posts.create({ title: "offline draft" });
86
- expect(draft.title).toBe("offline draft");
87
- expect(await client.offline!.pending()).toHaveLength(1);
88
-
89
- // The pending create is visible in the cached, filterless find.
90
- const overlaid = await posts.find();
91
- expect(overlaid.data.map((r) => r.title)).toEqual(["hello", "offline draft"]);
92
- expect(overlaid.meta.total).toBe(2);
93
-
94
- network.online = true;
95
- const result = await client.offline!.sync();
96
- expect(result).toEqual({ flushed: 1, remaining: 0 });
97
-
98
- const replayed = requests.find((r) => r.method === "POST" && r.url.endsWith("/data/posts"));
99
- expect(replayed?.body).toMatchObject({ title: "offline draft", id: draft.id });
100
- });
101
-
102
- it("offline: true works in Node via the in-memory fallback store", async () => {
103
- const { client, network } = createHarness(true);
104
- const posts = client.data.collection("posts");
105
-
106
- await posts.find();
107
- network.online = false;
108
- expect((await posts.find()).data).toEqual([{ id: "p1", title: "hello" }]);
109
-
110
- await posts.create({ title: "queued" });
111
- expect(await client.offline!.pending()).toHaveLength(1);
112
- });
113
-
114
- it("routes fluent-builder queries through the cache", async () => {
115
- const { client, network, requests } = createHarness();
116
- const posts = client.data.collection("posts");
117
-
118
- const online = await posts.where("title", "==", "hello").find();
119
- expect(online.data).toHaveLength(1);
120
- // The filter must actually reach the wire — otherwise this test would
121
- // pass by caching an unfiltered query.
122
- expect(requests.some((r) => r.url.includes("title="))).toBe(true);
123
-
124
- network.online = false;
125
- const cached = await posts.where("title", "==", "hello").find();
126
- expect(cached.data).toEqual(online.data);
127
- // A filter the server never answered here is still evaluated locally
128
- // against the rows that *are* cached — and this one matches none.
129
- expect((await posts.where("title", "==", "other").find()).data).toEqual([]);
130
- expect((await posts.where("title", "==", "hello").limit(5).find()).data).toEqual(online.data);
131
- });
132
-
133
- it("does not serve cache on a server error — only on network failure", async () => {
134
- const { client, network } = createHarness();
135
- const posts = client.data.collection("posts");
136
- await posts.find();
137
-
138
- network.serverError = true;
139
- await expect(posts.find()).rejects.toThrow(RebaseApiError);
140
- });
141
-
142
- it("drives a live query through the data proxy, offline included", async () => {
143
- const { client, network } = createHarness();
144
- const posts = client.data.collection("posts");
145
- await posts.find();
146
-
147
- const results: LiveResult<Record<string, unknown>>[] = [];
148
- const stop = posts.observe(undefined, (r) => results.push(r));
149
- await new Promise((resolve) => setTimeout(resolve, 0));
150
- expect(results[0].data.map((r) => r.id)).toEqual(["p1"]);
151
-
152
- network.online = false;
153
- await posts.create({ title: "written offline" });
154
- await new Promise((resolve) => setTimeout(resolve, 0));
155
-
156
- const last = results[results.length - 1];
157
- expect(last.data.map((r) => r.title)).toEqual(["hello", "written offline"]);
158
- expect(last.hasPendingWrites).toBe(true);
159
- expect(client.offline!.status()).toMatchObject({ online: false, pending: 1 });
160
- stop();
161
- });
162
-
163
- it("observes without offline support too, straight off the network", async () => {
164
- const { fetchStub } = createHarness();
165
- const plain = createRebaseClient({
166
- baseUrl: "http://localhost:9999",
167
- realtime: false,
168
- fetch: fetchStub
169
- });
170
-
171
- const results: LiveResult<Record<string, unknown>>[] = [];
172
- const stop = plain.data.collection("posts").observe(undefined, (r) => results.push(r));
173
- await new Promise((resolve) => setTimeout(resolve, 0));
174
-
175
- expect(results[0].data.map((r) => r.id)).toEqual(["p1"]);
176
- // No local database, so nothing here is ever cached, pending or partial.
177
- expect(results[0]).toMatchObject({ fromCache: false, hasPendingWrites: false, partial: false });
178
- stop();
179
- });
180
- });