@rebasepro/client 0.17.3 → 0.18.0
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 +4 -0
- package/dist/auth.d.ts +80 -0
- package/dist/functions.d.ts +6 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.es.js +471 -94
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -1
- package/dist/offline.d.ts +23 -1
- package/dist/query-contract.types.d.ts +30 -0
- package/dist/realtime-channel.d.ts +29 -1
- package/dist/sdk_query_builder.d.ts +21 -2
- package/dist/transport.d.ts +24 -0
- package/package.json +28 -15
- package/src/admin.ts +0 -90
- package/src/anonymous-client-guard.test.ts +0 -190
- package/src/api-keys.ts +0 -87
- package/src/auth-listener-errors.test.ts +0 -57
- package/src/auth-refresh-overflow.test.ts +0 -89
- package/src/auth.ts +0 -982
- package/src/backups.ts +0 -40
- package/src/client-close.test.ts +0 -80
- package/src/collection-listen-meta.test.ts +0 -105
- package/src/collection-observe.test.ts +0 -138
- package/src/collection.test.ts +0 -293
- package/src/collection.ts +0 -525
- package/src/cron.test.ts +0 -164
- package/src/cron.ts +0 -62
- package/src/data-proxy.test.ts +0 -183
- package/src/errors.ts +0 -9
- package/src/functions.ts +0 -82
- package/src/index.ts +0 -639
- package/src/like-pattern-redos.test.ts +0 -61
- package/src/offline-codec.ts +0 -79
- package/src/offline-connectivity.test.ts +0 -191
- package/src/offline-connectivity.ts +0 -255
- package/src/offline-idb-store.test.ts +0 -340
- package/src/offline-integration.test.ts +0 -180
- package/src/offline-query.test.ts +0 -431
- package/src/offline-query.ts +0 -529
- package/src/offline-store.ts +0 -357
- package/src/offline-sync-engine.test.ts +0 -857
- package/src/offline.test.ts +0 -897
- package/src/offline.ts +0 -1928
- package/src/query-contract.types.ts +0 -206
- package/src/query_builder.ts +0 -1
- package/src/realtime-channel.test.ts +0 -542
- package/src/realtime-channel.ts +0 -539
- package/src/realtime-concurrent-subscribe.test.ts +0 -102
- package/src/realtime-error-surfacing.test.ts +0 -105
- package/src/realtime-optout.test.ts +0 -279
- package/src/realtime-row-identity.test.ts +0 -254
- package/src/realtime-subscription-key.test.ts +0 -92
- package/src/reviver.ts +0 -39
- package/src/sdk_query_builder.ts +0 -206
- package/src/storage-key-encoding.test.ts +0 -65
- package/src/storage-registry.ts +0 -102
- package/src/storage.ts +0 -253
- package/src/transport-baseurl.test.ts +0 -101
- package/src/transport.ts +0 -505
- package/src/vector-search-listen.test.ts +0 -42
- package/src/vector-search-query.test.ts +0 -55
- package/src/websocket-url.test.ts +0 -97
- 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
|
-
});
|