@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.
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,857 +0,0 @@
1
- import { jest } from "@jest/globals";
2
- import { OfflineManager, type LiveResult, type OfflineStatus } from "./offline";
3
- import { MemoryOfflineStore, type PendingMutation } from "./offline-store";
4
- import { RebaseApiError } from "./transport";
5
- import type { CollectionClient } from "./collection";
6
- import type { FindParams } from "./transport";
7
- import { matchesParams, runLocalQuery } from "./offline-query";
8
-
9
- type Row = Record<string, unknown>;
10
-
11
- /** Let queued microtasks (and the observers they feed) run. */
12
- const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
13
-
14
- /**
15
- * A fake backend that actually evaluates queries, so a test can tell the
16
- * difference between "the local engine answered" and "the server did".
17
- * `state.online` reproduces fetch's network failure, and `rejectWith` lets a
18
- * test make the server refuse one specific write — the case rollback exists for.
19
- */
20
- function createFakeServer() {
21
- const state = { online: true };
22
- const tables = new Map<string, Map<string, Row>>();
23
- const calls: { collection: string; op: string; id?: unknown }[] = [];
24
- /** Keyed by `${op}:${id}`, or `${op}:*` for any row. */
25
- const rejections = new Map<string, RebaseApiError>();
26
- /** Ids the server insists on assigning itself, keyed by the client's id. */
27
- const idRewrites = new Map<string, string>();
28
-
29
- function table(slug: string): Map<string, Row> {
30
- if (!tables.has(slug)) tables.set(slug, new Map());
31
- return tables.get(slug)!;
32
- }
33
-
34
- /**
35
- * Requests parked mid-flight, keyed `${op}:${id}`. Holding a request open is
36
- * the only way to reproduce anything that goes wrong *while* an op is on the
37
- * wire — the queue treats an in-flight op differently from a queued one, and
38
- * a fake that returns immediately can never be in that state.
39
- */
40
- const gates = new Map<string, Promise<void>>();
41
-
42
- function guard(slug: string, op: string, id?: unknown) {
43
- calls.push({ collection: slug, op, id });
44
- if (!state.online) throw new TypeError("fetch failed");
45
- const rejection = rejections.get(`${op}:${String(id)}`) ?? rejections.get(`${op}:*`);
46
- if (rejection) throw rejection;
47
- }
48
-
49
- /** Await after `guard`, so a parked request has already been counted. */
50
- function hold(op: string, id?: unknown): Promise<void> | undefined {
51
- return gates.get(`${op}:${String(id)}`);
52
- }
53
-
54
- function client(slug: string): CollectionClient<Row> {
55
- const fake = {
56
- async find(params?: FindParams) {
57
- guard(slug, "find");
58
- return runLocalQuery([...table(slug).values()], params);
59
- },
60
- async findById(id: string | number) {
61
- guard(slug, "findById", id);
62
- return table(slug).get(String(id));
63
- },
64
- async create(data: Row, id?: string | number) {
65
- const wanted = id ?? data.id;
66
- guard(slug, "create", wanted);
67
- await hold("create", wanted);
68
- const rowId = idRewrites.get(String(wanted)) ?? wanted ?? `srv-${table(slug).size + 1}`;
69
- const row = { ...data, id: rowId };
70
- table(slug).set(String(rowId), row);
71
- return row;
72
- },
73
- async createMany(rows: Row[]) {
74
- guard(slug, "createMany");
75
- return rows.map((r) => {
76
- const rowId = idRewrites.get(String(r.id)) ?? r.id ?? `srv-${table(slug).size + 1}`;
77
- const row = { ...r, id: rowId };
78
- table(slug).set(String(rowId), row);
79
- return row;
80
- });
81
- },
82
- async update(id: string | number, data: Row) {
83
- guard(slug, "update", id);
84
- const existing = table(slug).get(String(id));
85
- if (!existing) throw new RebaseApiError("Not found", { status: 404 });
86
- const row = { ...existing, ...data, id };
87
- table(slug).set(String(id), row);
88
- return row;
89
- },
90
- async delete(id: string | number) {
91
- guard(slug, "delete", id);
92
- table(slug).delete(String(id));
93
- },
94
- async count(params?: FindParams) {
95
- guard(slug, "count");
96
- return [...table(slug).values()].filter((r) => matchesParams(r, params)).length;
97
- }
98
- };
99
- return fake as unknown as CollectionClient<Row>;
100
- }
101
-
102
- return {
103
- state,
104
- table,
105
- calls,
106
- client,
107
- rejections,
108
- idRewrites,
109
- gates,
110
- countCalls: (op: string) => calls.filter((c) => c.op === op).length
111
- };
112
- }
113
-
114
- function createManager(server: ReturnType<typeof createFakeServer>, options: {
115
- onSyncError?: (error: Error, mutation: PendingMutation) => void;
116
- store?: MemoryOfflineStore;
117
- /** Default 0 (no backoff suppression); pass a value to exercise backoff. */
118
- syncIntervalMs?: number;
119
- maxRetries?: number;
120
- } = {}) {
121
- const store = options.store ?? new MemoryOfflineStore();
122
- const manager = new OfflineManager(
123
- {
124
- store,
125
- syncIntervalMs: options.syncIntervalMs ?? 0,
126
- maxRetries: options.maxRetries,
127
- onSyncError: options.onSyncError
128
- },
129
- (slug) => server.client(slug)
130
- );
131
- const wrap = (slug: string) => manager.wrap(slug, server.client(slug));
132
- return { manager, store, wrap };
133
- }
134
-
135
- describe("the local database", () => {
136
- it("answers findById for a row it only ever saw inside a list", async () => {
137
- const server = createFakeServer();
138
- const { wrap } = createManager(server);
139
- const posts = wrap("posts");
140
- await server.client("posts").create({ title: "a" }, "p1");
141
- await posts.find();
142
-
143
- server.state.online = false;
144
- // A response cache keyed by request would have nothing to say here.
145
- expect(await posts.findById("p1")).toMatchObject({ title: "a" });
146
- });
147
-
148
- it("shows an edit made in one query in every other query holding that row", async () => {
149
- const server = createFakeServer();
150
- const { wrap } = createManager(server);
151
- const posts = wrap("posts");
152
- await server.client("posts").create({ title: "a", status: "draft" }, "p1");
153
- const drafts: FindParams = { where: { status: ["==", "draft"] } };
154
- await posts.find();
155
- await posts.find(drafts);
156
-
157
- server.state.online = false;
158
- await posts.update("p1", { title: "edited" });
159
-
160
- expect((await posts.find()).data[0].title).toBe("edited");
161
- expect((await posts.find(drafts)).data[0].title).toBe("edited");
162
- expect(await posts.findById("p1")).toMatchObject({ title: "edited" });
163
- });
164
-
165
- it("answers a query the server has never been asked, from the rows it holds", async () => {
166
- const server = createFakeServer();
167
- const { wrap } = createManager(server);
168
- const posts = wrap("posts");
169
- await server.client("posts").create({ title: "a", n: 1 }, "p1");
170
- await server.client("posts").create({ title: "b", n: 2 }, "p2");
171
- await posts.find();
172
-
173
- server.state.online = false;
174
- const result = await posts.find({ where: { n: [">", 1] }, orderBy: ["n", "desc"] });
175
- expect(result.data.map((r) => r.id)).toEqual(["p2"]);
176
- });
177
-
178
- it("does not resurrect a row the server says is gone", async () => {
179
- const server = createFakeServer();
180
- const { wrap } = createManager(server);
181
- const posts = wrap("posts");
182
- await server.client("posts").create({ title: "a" }, "p1");
183
- await posts.find();
184
-
185
- server.table("posts").delete("p1");
186
- expect(await posts.findById("p1")).toBeUndefined();
187
-
188
- server.state.online = false;
189
- expect(await posts.findById("p1")).toBeUndefined();
190
- });
191
-
192
- it("does not blame an eviction on a deletion when reporting a total", async () => {
193
- const server = createFakeServer();
194
- const store = new MemoryOfflineStore();
195
- const manager = new OfflineManager(
196
- { store, syncIntervalMs: 0, maxCachedRowsPerCollection: 2 },
197
- (slug) => server.client(slug)
198
- );
199
- const posts = manager.wrap("posts", server.client("posts"));
200
- for (const id of ["p1", "p2", "p3"]) {
201
- await server.client("posts").create({ title: id }, id);
202
- }
203
- await posts.find();
204
-
205
- server.state.online = false;
206
- const result = await posts.find();
207
- // One row fell out of the cache to stay under the cap. The page is
208
- // short, but the server's count of what exists has not changed.
209
- expect(result.data.length).toBe(2);
210
- expect(result.meta.total).toBe(3);
211
- manager.dispose();
212
- });
213
-
214
- it("evicts the coldest rows past the cap but never one with unsent writes", async () => {
215
- const server = createFakeServer();
216
- const store = new MemoryOfflineStore();
217
- const manager = new OfflineManager(
218
- { store, syncIntervalMs: 0, maxCachedRowsPerCollection: 2 },
219
- (slug) => server.client(slug)
220
- );
221
- const posts = manager.wrap("posts", server.client("posts"));
222
- for (const id of ["p1", "p2", "p3"]) {
223
- await server.client("posts").create({ title: id }, id);
224
- }
225
- await posts.find();
226
-
227
- server.state.online = false;
228
- await posts.update("p1", { title: "pending edit" });
229
- // p1 is the oldest, and would go first if the queue were not consulted.
230
- await posts.find();
231
-
232
- expect(await posts.findById("p1")).toMatchObject({ title: "pending edit" });
233
- manager.dispose();
234
- });
235
- });
236
-
237
- describe("offline writes", () => {
238
- it("stops attempting the network once it knows the connection is gone", async () => {
239
- const server = createFakeServer();
240
- const { wrap } = createManager(server, { syncIntervalMs: 60_000 });
241
- const posts = wrap("posts");
242
- await posts.find();
243
-
244
- server.state.online = false;
245
- await posts.create({ title: "one" });
246
- const afterFirst = server.countCalls("create");
247
-
248
- await posts.create({ title: "two" });
249
- await posts.create({ title: "three" });
250
-
251
- // The first write learns the network is down; the rest must not each
252
- // pay for that lesson again.
253
- expect(afterFirst).toBe(1);
254
- expect(server.countCalls("create")).toBe(1);
255
- });
256
-
257
- it("keeps a write local when the connection has already failed", async () => {
258
- const server = createFakeServer();
259
- const { manager, wrap } = createManager(server, { syncIntervalMs: 60_000 });
260
- const posts = wrap("posts");
261
- await posts.find();
262
- server.state.online = false;
263
- await posts.create({ title: "one" });
264
-
265
- const row = await posts.create({ title: "two" });
266
- expect(row.title).toBe("two");
267
- expect(await manager.api.pending()).toHaveLength(2);
268
- expect((await posts.find()).data.map((r) => r.title).sort()).toEqual(["one", "two"]);
269
- });
270
-
271
- it("reports the queue depth and connectivity through status()", async () => {
272
- const server = createFakeServer();
273
- const { manager, wrap } = createManager(server);
274
- const posts = wrap("posts");
275
- const seen: OfflineStatus[] = [];
276
- manager.api.onStatusChange((status) => seen.push(status));
277
- await posts.find();
278
-
279
- expect(manager.api.status()).toMatchObject({ online: true, pending: 0, syncing: false });
280
-
281
- server.state.online = false;
282
- await posts.create({ title: "queued" });
283
- expect(manager.api.status()).toMatchObject({ online: false, pending: 1 });
284
- expect(seen.some((s) => s.online === false)).toBe(true);
285
-
286
- server.state.online = true;
287
- await manager.sync();
288
- expect(manager.api.status()).toMatchObject({ online: true, pending: 0, syncing: false });
289
- expect(manager.api.status().lastSyncedAt).toBeGreaterThan(0);
290
- // `syncing` has to be observable while it is true, or a spinner can
291
- // never be shown.
292
- expect(seen.some((s) => s.syncing)).toBe(true);
293
- });
294
- });
295
-
296
- describe("rollback", () => {
297
- it("undoes a rejected update and puts the previous value back", async () => {
298
- const server = createFakeServer();
299
- const onSyncError = jest.fn();
300
- const { manager, wrap } = createManager(server, { onSyncError });
301
- const posts = wrap("posts");
302
- await server.client("posts").create({ title: "original" }, "p1");
303
- await posts.find();
304
-
305
- server.state.online = false;
306
- await posts.update("p1", { title: "optimistic" });
307
- expect(await posts.findById("p1")).toMatchObject({ title: "optimistic" });
308
-
309
- server.state.online = true;
310
- server.rejections.set("update:p1", new RebaseApiError("denied", { status: 403 }));
311
- await manager.sync();
312
-
313
- expect(await posts.findById("p1")).toMatchObject({ title: "original" });
314
- expect(onSyncError).toHaveBeenCalledTimes(1);
315
- expect(manager.api.status().lastError).toBe("denied");
316
- });
317
-
318
- it("removes a rejected create from the local database", async () => {
319
- const server = createFakeServer();
320
- const onSyncError = jest.fn();
321
- const { manager, wrap } = createManager(server, { onSyncError });
322
- const posts = wrap("posts");
323
- await posts.find();
324
-
325
- server.state.online = false;
326
- const row = await posts.create({ title: "doomed" });
327
- expect((await posts.find()).data).toHaveLength(1);
328
-
329
- server.state.online = true;
330
- server.rejections.set("create:*", new RebaseApiError("invalid", { status: 400 }));
331
- await manager.sync();
332
-
333
- expect((await posts.find()).data).toHaveLength(0);
334
- expect(await posts.findById(row.id as string).catch(() => undefined)).toBeUndefined();
335
- expect(await manager.api.pending()).toHaveLength(0);
336
- });
337
-
338
- it("restores a rejected delete", async () => {
339
- const server = createFakeServer();
340
- const { manager, wrap } = createManager(server, { onSyncError: () => undefined });
341
- const posts = wrap("posts");
342
- await server.client("posts").create({ title: "keep me" }, "p1");
343
- await posts.find();
344
-
345
- server.state.online = false;
346
- await posts.delete("p1");
347
- expect(await posts.findById("p1")).toBeUndefined();
348
-
349
- server.state.online = true;
350
- server.rejections.set("delete:p1", new RebaseApiError("denied", { status: 403 }));
351
- await manager.sync();
352
-
353
- expect(await posts.findById("p1")).toMatchObject({ title: "keep me" });
354
- });
355
-
356
- it("discards the edits that were built on a rejected create", async () => {
357
- const server = createFakeServer();
358
- const onSyncError = jest.fn();
359
- const { manager, wrap } = createManager(server, { onSyncError });
360
- const posts = wrap("posts");
361
- await posts.find();
362
-
363
- server.state.online = false;
364
- const row = await posts.create({ title: "doomed" }, "x1");
365
- // A different row in between stops the update coalescing into the
366
- // create, so this really is a second queued mutation.
367
- await posts.create({ title: "unrelated" }, "x2");
368
- await posts.update("x1", { title: "edited" });
369
- expect(await manager.api.pending()).toHaveLength(3);
370
-
371
- server.state.online = true;
372
- server.rejections.set("create:x1", new RebaseApiError("invalid", { status: 400 }));
373
- await manager.sync();
374
-
375
- // The edit could only have failed too, and keeping it would leave the
376
- // local database claiming a row the server does not have.
377
- expect(onSyncError.mock.calls.map((c) => (c[1] as PendingMutation).type)).toEqual(["create", "update"]);
378
- expect(server.table("posts").has("x1")).toBe(false);
379
- expect(server.table("posts").has("x2")).toBe(true);
380
- expect(await manager.api.pending()).toHaveLength(0);
381
- expect(row.id).toBe("x1");
382
- });
383
-
384
- it("keeps a later create that re-establishes the row on its own", async () => {
385
- const server = createFakeServer();
386
- const onSyncError = jest.fn();
387
- const { manager, wrap } = createManager(server, { onSyncError });
388
- const posts = wrap("posts");
389
- await posts.find();
390
-
391
- server.state.online = false;
392
- // Update a row the server does not have: this one is going to 404.
393
- await posts.update("p9", { title: "orphan edit" });
394
- await posts.create({ title: "recreated" }, "p9");
395
-
396
- server.state.online = true;
397
- await manager.sync();
398
-
399
- // The create overwrites the row outright, so it never depended on the
400
- // rejected update — dropping it would lose a write the server accepts.
401
- expect(server.table("posts").get("p9")).toMatchObject({ title: "recreated" });
402
- expect(onSyncError.mock.calls.map((c) => (c[1] as PendingMutation).type)).toEqual(["update"]);
403
- });
404
- });
405
-
406
- describe("replay", () => {
407
- it("moves the local row when the server assigns its own id", async () => {
408
- const server = createFakeServer();
409
- const { manager, wrap } = createManager(server);
410
- const posts = wrap("posts");
411
- await posts.find();
412
-
413
- server.state.online = false;
414
- const local = await posts.create({ title: "renamed" });
415
- server.idRewrites.set(String(local.id), "srv-99");
416
-
417
- server.state.online = true;
418
- await manager.sync();
419
-
420
- expect(await posts.findById("srv-99")).toMatchObject({ title: "renamed" });
421
- // The temporary id must not linger as a phantom row.
422
- expect((await posts.find()).data.map((r) => r.id)).toEqual(["srv-99"]);
423
- });
424
-
425
- it("re-points queued writes at the id the server chose", async () => {
426
- const server = createFakeServer();
427
- const { manager, wrap } = createManager(server);
428
- const posts = wrap("posts");
429
- await posts.find();
430
-
431
- server.state.online = false;
432
- const local = await posts.create({ title: "first" });
433
- await posts.create({ title: "other" }, "other");
434
- await posts.update(local.id as string, { title: "second" });
435
- server.idRewrites.set(String(local.id), "srv-7");
436
-
437
- server.state.online = true;
438
- await manager.sync();
439
-
440
- // The update was queued against an id that never existed server-side.
441
- expect(server.table("posts").get("srv-7")).toMatchObject({ title: "second" });
442
- expect(await manager.api.pending()).toHaveLength(0);
443
- });
444
-
445
- it("retries a mutation the server was merely too busy for", async () => {
446
- const server = createFakeServer();
447
- const onSyncError = jest.fn();
448
- const { manager, wrap } = createManager(server, { onSyncError });
449
- const posts = wrap("posts");
450
- await posts.find();
451
-
452
- server.state.online = false;
453
- await posts.create({ title: "later" }, "p1");
454
-
455
- server.state.online = true;
456
- server.rejections.set("create:p1", new RebaseApiError("slow down", { status: 429 }));
457
- expect(await manager.sync()).toMatchObject({ flushed: 0, remaining: 1 });
458
- expect(onSyncError).not.toHaveBeenCalled();
459
-
460
- server.rejections.clear();
461
- expect(await manager.sync()).toMatchObject({ flushed: 1, remaining: 0 });
462
- expect(server.table("posts").get("p1")).toMatchObject({ title: "later" });
463
- });
464
-
465
- it("gives up on a mutation that keeps being deferred", async () => {
466
- const server = createFakeServer();
467
- const onSyncError = jest.fn();
468
- const { manager, wrap } = createManager(server, { onSyncError, maxRetries: 2 });
469
- const posts = wrap("posts");
470
- await posts.find();
471
-
472
- server.state.online = false;
473
- await posts.create({ title: "stuck" }, "p1");
474
-
475
- server.state.online = true;
476
- server.rejections.set("create:*", new RebaseApiError("down", { status: 503 }));
477
- await manager.sync();
478
- expect(await manager.api.pending()).toHaveLength(1);
479
- await manager.sync();
480
-
481
- // Otherwise it jams every write queued behind it, forever.
482
- expect(await manager.api.pending()).toHaveLength(0);
483
- expect(onSyncError).toHaveBeenCalledTimes(1);
484
- });
485
-
486
- it("keeps an unsent edit visible while the write before it lands", async () => {
487
- const server = createFakeServer();
488
- const { manager, wrap } = createManager(server);
489
- const posts = wrap("posts");
490
- await posts.find();
491
-
492
- server.state.online = false;
493
- await posts.create({ title: "v1", n: 1 }, "p1");
494
- await posts.create({ title: "unrelated" }, "p2");
495
- await posts.update("p1", { title: "v2" });
496
-
497
- // Replay the create only, by making the update fail on the network.
498
- server.state.online = true;
499
- server.rejections.set("update:p1", new TypeError("fetch failed"));
500
- await manager.sync();
501
-
502
- // The create landed; the update has not — and the row must still show
503
- // it, or the user watches their edit snap back and then reappear.
504
- expect(await posts.findById("p1")).toMatchObject({ title: "v2", n: 1 });
505
- expect(await manager.api.pending()).toHaveLength(1);
506
- });
507
- });
508
-
509
- describe("live queries", () => {
510
- it("emits from the local database when the network cannot answer", async () => {
511
- const server = createFakeServer();
512
- const { wrap } = createManager(server);
513
- const posts = wrap("posts");
514
- await server.client("posts").create({ title: "a" }, "p1");
515
- await posts.find();
516
-
517
- server.state.online = false;
518
- const results: LiveResult<Row>[] = [];
519
- const stop = posts.observe(undefined, (r) => results.push(r));
520
- await settle();
521
-
522
- // The subscription still delivers, and — once its own revalidation has
523
- // failed — says the rows came from the local database.
524
- expect(results.length).toBeGreaterThanOrEqual(1);
525
- expect(results[0].data.map((r) => r.id)).toEqual(["p1"]);
526
- expect(results[results.length - 1].fromCache).toBe(true);
527
- stop();
528
- });
529
-
530
- it("re-emits when a local write changes the rows it covers", async () => {
531
- const server = createFakeServer();
532
- const { wrap } = createManager(server);
533
- const posts = wrap("posts");
534
- await server.client("posts").create({ title: "a" }, "p1");
535
- await posts.find();
536
-
537
- const results: LiveResult<Row>[] = [];
538
- const stop = posts.observe(undefined, (r) => results.push(r));
539
- await settle();
540
- const before = results.length;
541
-
542
- server.state.online = false;
543
- await posts.update("p1", { title: "edited" });
544
- await settle();
545
-
546
- expect(results.length).toBeGreaterThan(before);
547
- const last = results[results.length - 1];
548
- expect(last.data[0].title).toBe("edited");
549
- expect(last.hasPendingWrites).toBe(true);
550
- stop();
551
- });
552
-
553
- it("distinguishes a served-from-cache result from a fresh one", async () => {
554
- const server = createFakeServer();
555
- const { wrap } = createManager(server);
556
- const posts = wrap("posts");
557
- await server.client("posts").create({ title: "a" }, "p1");
558
-
559
- const results: LiveResult<Row>[] = [];
560
- const stop = posts.observe(undefined, (r) => results.push(r));
561
- await settle();
562
- // The request completed, so this is the server's answer — a flag that
563
- // is always `true` tells an interface nothing.
564
- expect(results[results.length - 1].fromCache).toBe(false);
565
-
566
- server.state.online = false;
567
- await posts.find();
568
- await settle();
569
- expect(results[results.length - 1].fromCache).toBe(true);
570
-
571
- server.state.online = true;
572
- await posts.find();
573
- await settle();
574
- expect(results[results.length - 1].fromCache).toBe(false);
575
- stop();
576
- });
577
-
578
- it("reports a single observed row as fresh only while the server has confirmed it", async () => {
579
- const server = createFakeServer();
580
- const { manager, wrap } = createManager(server);
581
- const posts = wrap("posts");
582
- await server.client("posts").create({ title: "a" }, "p1");
583
-
584
- const seen: { fromCache: boolean; hasPendingWrites: boolean }[] = [];
585
- const stop = posts.observeById("p1", (_row, meta) => seen.push(meta));
586
- await settle();
587
- expect(seen[seen.length - 1]).toEqual({ fromCache: false, hasPendingWrites: false });
588
-
589
- server.state.online = false;
590
- await posts.update("p1", { title: "edited offline" });
591
- await settle();
592
- expect(seen[seen.length - 1]).toEqual({ fromCache: true, hasPendingWrites: true });
593
-
594
- server.state.online = true;
595
- await manager.sync();
596
- await settle();
597
- expect(seen[seen.length - 1]).toEqual({ fromCache: false, hasPendingWrites: false });
598
- stop();
599
- });
600
-
601
- it("does not call back when a refresh changes nothing", async () => {
602
- const server = createFakeServer();
603
- const { wrap } = createManager(server);
604
- const posts = wrap("posts");
605
- await server.client("posts").create({ title: "a" }, "p1");
606
- await posts.find();
607
-
608
- const results: LiveResult<Row>[] = [];
609
- const stop = posts.observe(undefined, (r) => results.push(r));
610
- await settle();
611
- const settled = results.length;
612
-
613
- await posts.find();
614
- await settle();
615
- // Re-rendering a list that did not change is the difference between a
616
- // live query and a polling loop.
617
- expect(results.length).toBe(settled);
618
- stop();
619
- });
620
-
621
- it("stops emitting once unsubscribed", async () => {
622
- const server = createFakeServer();
623
- const { wrap } = createManager(server);
624
- const posts = wrap("posts");
625
- await posts.find();
626
-
627
- const results: LiveResult<Row>[] = [];
628
- const stop = posts.observe(undefined, (r) => results.push(r));
629
- await settle();
630
- stop();
631
- const after = results.length;
632
-
633
- server.state.online = false;
634
- await posts.create({ title: "ignored" });
635
- await settle();
636
- expect(results.length).toBe(after);
637
- });
638
-
639
- it("reports a failed first read instead of emitting an empty list", async () => {
640
- const server = createFakeServer();
641
- const { wrap } = createManager(server);
642
- const posts = wrap("posts");
643
- server.state.online = false;
644
-
645
- const results: LiveResult<Row>[] = [];
646
- const errors: Error[] = [];
647
- const stop = posts.observe(undefined, (r) => results.push(r), (e) => errors.push(e));
648
- await settle();
649
-
650
- expect(results).toHaveLength(0);
651
- expect(errors).toHaveLength(1);
652
- stop();
653
- });
654
-
655
- it("observes a single row, including its disappearance", async () => {
656
- const server = createFakeServer();
657
- const { wrap } = createManager(server);
658
- const posts = wrap("posts");
659
- await server.client("posts").create({ title: "a" }, "p1");
660
- await posts.findById("p1");
661
-
662
- const seen: (Row | undefined)[] = [];
663
- const stop = posts.observeById("p1", (row) => seen.push(row));
664
- await settle();
665
- expect(seen[seen.length - 1]).toMatchObject({ title: "a" });
666
-
667
- server.state.online = false;
668
- await posts.delete("p1");
669
- await settle();
670
- expect(seen[seen.length - 1]).toBeUndefined();
671
- stop();
672
- });
673
-
674
- it("empties on sign-out instead of leaving the previous user's rows on screen", async () => {
675
- const server = createFakeServer();
676
- const { manager, wrap } = createManager(server);
677
- const posts = wrap("posts");
678
- manager.setScope("user-a");
679
- await server.client("posts").create({ title: "a-private" }, "p1");
680
- await posts.find();
681
-
682
- const results: LiveResult<Row>[] = [];
683
- const stop = posts.observe(undefined, (r) => results.push(r));
684
- await settle();
685
- expect(results[results.length - 1].data).toHaveLength(1);
686
-
687
- server.state.online = false;
688
- manager.setScope(undefined);
689
- await settle();
690
-
691
- expect(results[results.length - 1].data).toHaveLength(0);
692
- stop();
693
- });
694
-
695
- it("flags a result the local database cannot vouch for", async () => {
696
- const server = createFakeServer();
697
- const { wrap } = createManager(server);
698
- const posts = wrap("posts");
699
- await server.client("posts").create({ title: "searchable" }, "p1");
700
-
701
- const results: LiveResult<Row>[] = [];
702
- const stop = posts.observe({ searchString: "search" }, (r) => results.push(r));
703
- await settle();
704
-
705
- // The server ran real full-text search; the local engine only ever
706
- // approximates it, so a cached answer must say so.
707
- expect(results[results.length - 1].partial).toBe(true);
708
- stop();
709
- });
710
- });
711
-
712
- describe("counts", () => {
713
- it("folds queued writes into a server count", async () => {
714
- const server = createFakeServer();
715
- const { wrap } = createManager(server);
716
- const posts = wrap("posts");
717
- await server.client("posts").create({ title: "a", status: "draft" }, "p1");
718
- const drafts: FindParams = { where: { status: ["==", "draft"] } };
719
- expect(await posts.count(drafts)).toBe(1);
720
-
721
- server.state.online = false;
722
- await posts.create({ title: "b", status: "draft" });
723
- await posts.create({ title: "c", status: "published" });
724
-
725
- // Only the row that matches the filter may move the number.
726
- expect(await posts.count(drafts)).toBe(2);
727
- });
728
-
729
- it("subtracts a queued delete", async () => {
730
- const server = createFakeServer();
731
- const { wrap } = createManager(server);
732
- const posts = wrap("posts");
733
- await server.client("posts").create({ title: "a" }, "p1");
734
- await server.client("posts").create({ title: "b" }, "p2");
735
- await posts.find();
736
- expect(await posts.count()).toBe(2);
737
-
738
- server.state.online = false;
739
- await posts.delete("p1");
740
- expect(await posts.count()).toBe(1);
741
- });
742
- });
743
-
744
- describe("a write made while another is on the wire", () => {
745
- /**
746
- * `flush` awaits `replay(op)` with `op` still sitting at the head of the
747
- * queue, so for the whole duration of that request the in-flight op is also
748
- * the tail — and both of `enqueue`'s shortcuts reach for the tail.
749
- */
750
- it("does not swallow an edit that lands mid-request", async () => {
751
- const server = createFakeServer();
752
- const { wrap, manager } = createManager(server);
753
- const posts = wrap("posts");
754
-
755
- server.state.online = false;
756
- await posts.create({ title: "first" }, "p1");
757
-
758
- // Park the create so the edit below arrives while it is in flight.
759
- let release!: () => void;
760
- server.gates.set("create:p1", new Promise<void>((resolve) => { release = resolve; }));
761
-
762
- server.state.online = true;
763
- const syncing = manager.sync();
764
- await settle();
765
-
766
- await posts.update("p1", { title: "edited" });
767
- release();
768
- await syncing;
769
- await manager.sync();
770
-
771
- // Coalesced into the in-flight create, this edit was dropped with it on
772
- // ACK: never sent, and gone from the queue. The user's second keystroke
773
- // vanished with no error anywhere.
774
- expect(server.table("posts").get("p1")).toMatchObject({ title: "edited" });
775
- });
776
-
777
- it("does not strand a row when a delete cancels out an in-flight create", async () => {
778
- const server = createFakeServer();
779
- const { wrap, manager } = createManager(server);
780
- const posts = wrap("posts");
781
-
782
- server.state.online = false;
783
- const created = await posts.create({ title: "doomed" });
784
- const id = String((created as Row).id);
785
-
786
- let release!: () => void;
787
- server.gates.set(`create:${id}`, new Promise<void>((resolve) => { release = resolve; }));
788
-
789
- server.state.online = true;
790
- const syncing = manager.sync();
791
- await settle();
792
-
793
- // Cancel-out assumes the server never saw the create. It is reading it
794
- // right now, so the row lands — and if the delete is discarded with it,
795
- // nothing will ever remove it.
796
- await posts.delete(id);
797
- release();
798
- await syncing;
799
- await manager.sync();
800
-
801
- expect(server.table("posts").has(id)).toBe(false);
802
- });
803
- });
804
-
805
- describe("replaying a create whose response was lost", () => {
806
- it("keeps the row when the server says it is already there", async () => {
807
- const server = createFakeServer();
808
- const errors: Error[] = [];
809
- const { wrap, manager } = createManager(server, {
810
- onSyncError: (error) => errors.push(error)
811
- });
812
- const posts = wrap("posts");
813
-
814
- server.state.online = false;
815
- const created = await posts.create({ title: "saved" });
816
- const id = String((created as Row).id);
817
-
818
- // The first attempt committed; only the ACK was lost. The replay
819
- // therefore finds the row already present.
820
- server.table("posts").set(id, { id, title: "saved" });
821
- server.rejections.set(`create:${id}`, new RebaseApiError("duplicate key", { status: 409, code: "23505" }));
822
-
823
- server.state.online = true;
824
- await manager.sync();
825
-
826
- // Read with the network down, or this falls through to the server and
827
- // passes on the row seeded above no matter what the local store did —
828
- // which is exactly how this test first passed against the bug.
829
- server.state.online = false;
830
- // The old behaviour rolled the write back and deleted the local row —
831
- // the one case where the row genuinely exists on the server.
832
- expect(await posts.findById(id)).toMatchObject({ title: "saved" });
833
- expect(errors).toHaveLength(0);
834
- expect(manager.api.status().pending).toBe(0);
835
- });
836
-
837
- it("still reports a real conflict on a caller-supplied id", async () => {
838
- const server = createFakeServer();
839
- const errors: Error[] = [];
840
- const { wrap, manager } = createManager(server, {
841
- onSyncError: (error) => errors.push(error)
842
- });
843
- const posts = wrap("posts");
844
-
845
- server.state.online = false;
846
- // The caller chose this id, so a duplicate may well be someone else's
847
- // row. Swallowing that would hide a genuine collision.
848
- await posts.create({ title: "mine" }, "chosen-1");
849
- server.rejections.set("create:chosen-1", new RebaseApiError("duplicate key", { status: 409, code: "23505" }));
850
-
851
- server.state.online = true;
852
- await manager.sync();
853
-
854
- expect(errors).toHaveLength(1);
855
- expect(manager.api.status().pending).toBe(0);
856
- });
857
- });