@rebasepro/client 0.10.0 → 0.10.1-canary.31c773c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,676 @@
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
+ function guard(slug: string, op: string, id?: unknown) {
35
+ calls.push({ collection: slug, op, id });
36
+ if (!state.online) throw new TypeError("fetch failed");
37
+ const rejection = rejections.get(`${op}:${String(id)}`) ?? rejections.get(`${op}:*`);
38
+ if (rejection) throw rejection;
39
+ }
40
+
41
+ function client(slug: string): CollectionClient<Row> {
42
+ const fake = {
43
+ async find(params?: FindParams) {
44
+ guard(slug, "find");
45
+ return runLocalQuery([...table(slug).values()], params);
46
+ },
47
+ async findById(id: string | number) {
48
+ guard(slug, "findById", id);
49
+ return table(slug).get(String(id));
50
+ },
51
+ async create(data: Row, id?: string | number) {
52
+ const wanted = id ?? data.id;
53
+ guard(slug, "create", wanted);
54
+ const rowId = idRewrites.get(String(wanted)) ?? wanted ?? `srv-${table(slug).size + 1}`;
55
+ const row = { ...data, id: rowId };
56
+ table(slug).set(String(rowId), row);
57
+ return row;
58
+ },
59
+ async createMany(rows: Row[]) {
60
+ guard(slug, "createMany");
61
+ return rows.map((r) => {
62
+ const rowId = idRewrites.get(String(r.id)) ?? r.id ?? `srv-${table(slug).size + 1}`;
63
+ const row = { ...r, id: rowId };
64
+ table(slug).set(String(rowId), row);
65
+ return row;
66
+ });
67
+ },
68
+ async update(id: string | number, data: Row) {
69
+ guard(slug, "update", id);
70
+ const existing = table(slug).get(String(id));
71
+ if (!existing) throw new RebaseApiError("Not found", { status: 404 });
72
+ const row = { ...existing, ...data, id };
73
+ table(slug).set(String(id), row);
74
+ return row;
75
+ },
76
+ async delete(id: string | number) {
77
+ guard(slug, "delete", id);
78
+ table(slug).delete(String(id));
79
+ },
80
+ async count(params?: FindParams) {
81
+ guard(slug, "count");
82
+ return [...table(slug).values()].filter((r) => matchesParams(r, params)).length;
83
+ }
84
+ };
85
+ return fake as unknown as CollectionClient<Row>;
86
+ }
87
+
88
+ return {
89
+ state,
90
+ table,
91
+ calls,
92
+ client,
93
+ rejections,
94
+ idRewrites,
95
+ countCalls: (op: string) => calls.filter((c) => c.op === op).length
96
+ };
97
+ }
98
+
99
+ function createManager(server: ReturnType<typeof createFakeServer>, options: {
100
+ onSyncError?: (error: Error, mutation: PendingMutation) => void;
101
+ store?: MemoryOfflineStore;
102
+ /** Default 0 (no backoff suppression); pass a value to exercise backoff. */
103
+ syncIntervalMs?: number;
104
+ maxRetries?: number;
105
+ } = {}) {
106
+ const store = options.store ?? new MemoryOfflineStore();
107
+ const manager = new OfflineManager(
108
+ {
109
+ store,
110
+ syncIntervalMs: options.syncIntervalMs ?? 0,
111
+ maxRetries: options.maxRetries,
112
+ onSyncError: options.onSyncError
113
+ },
114
+ (slug) => server.client(slug)
115
+ );
116
+ const wrap = (slug: string) => manager.wrap(slug, server.client(slug));
117
+ return { manager, store, wrap };
118
+ }
119
+
120
+ describe("the local database", () => {
121
+ it("answers findById for a row it only ever saw inside a list", async () => {
122
+ const server = createFakeServer();
123
+ const { wrap } = createManager(server);
124
+ const posts = wrap("posts");
125
+ await server.client("posts").create({ title: "a" }, "p1");
126
+ await posts.find();
127
+
128
+ server.state.online = false;
129
+ // A response cache keyed by request would have nothing to say here.
130
+ expect(await posts.findById("p1")).toMatchObject({ title: "a" });
131
+ });
132
+
133
+ it("shows an edit made in one query in every other query holding that row", async () => {
134
+ const server = createFakeServer();
135
+ const { wrap } = createManager(server);
136
+ const posts = wrap("posts");
137
+ await server.client("posts").create({ title: "a", status: "draft" }, "p1");
138
+ const drafts: FindParams = { where: { status: ["==", "draft"] } };
139
+ await posts.find();
140
+ await posts.find(drafts);
141
+
142
+ server.state.online = false;
143
+ await posts.update("p1", { title: "edited" });
144
+
145
+ expect((await posts.find()).data[0].title).toBe("edited");
146
+ expect((await posts.find(drafts)).data[0].title).toBe("edited");
147
+ expect(await posts.findById("p1")).toMatchObject({ title: "edited" });
148
+ });
149
+
150
+ it("answers a query the server has never been asked, from the rows it holds", async () => {
151
+ const server = createFakeServer();
152
+ const { wrap } = createManager(server);
153
+ const posts = wrap("posts");
154
+ await server.client("posts").create({ title: "a", n: 1 }, "p1");
155
+ await server.client("posts").create({ title: "b", n: 2 }, "p2");
156
+ await posts.find();
157
+
158
+ server.state.online = false;
159
+ const result = await posts.find({ where: { n: [">", 1] }, orderBy: ["n", "desc"] });
160
+ expect(result.data.map((r) => r.id)).toEqual(["p2"]);
161
+ });
162
+
163
+ it("does not resurrect a row the server says is gone", async () => {
164
+ const server = createFakeServer();
165
+ const { wrap } = createManager(server);
166
+ const posts = wrap("posts");
167
+ await server.client("posts").create({ title: "a" }, "p1");
168
+ await posts.find();
169
+
170
+ server.table("posts").delete("p1");
171
+ expect(await posts.findById("p1")).toBeUndefined();
172
+
173
+ server.state.online = false;
174
+ expect(await posts.findById("p1")).toBeUndefined();
175
+ });
176
+
177
+ it("does not blame an eviction on a deletion when reporting a total", async () => {
178
+ const server = createFakeServer();
179
+ const store = new MemoryOfflineStore();
180
+ const manager = new OfflineManager(
181
+ { store, syncIntervalMs: 0, maxCachedRowsPerCollection: 2 },
182
+ (slug) => server.client(slug)
183
+ );
184
+ const posts = manager.wrap("posts", server.client("posts"));
185
+ for (const id of ["p1", "p2", "p3"]) {
186
+ await server.client("posts").create({ title: id }, id);
187
+ }
188
+ await posts.find();
189
+
190
+ server.state.online = false;
191
+ const result = await posts.find();
192
+ // One row fell out of the cache to stay under the cap. The page is
193
+ // short, but the server's count of what exists has not changed.
194
+ expect(result.data.length).toBe(2);
195
+ expect(result.meta.total).toBe(3);
196
+ manager.dispose();
197
+ });
198
+
199
+ it("evicts the coldest rows past the cap but never one with unsent writes", async () => {
200
+ const server = createFakeServer();
201
+ const store = new MemoryOfflineStore();
202
+ const manager = new OfflineManager(
203
+ { store, syncIntervalMs: 0, maxCachedRowsPerCollection: 2 },
204
+ (slug) => server.client(slug)
205
+ );
206
+ const posts = manager.wrap("posts", server.client("posts"));
207
+ for (const id of ["p1", "p2", "p3"]) {
208
+ await server.client("posts").create({ title: id }, id);
209
+ }
210
+ await posts.find();
211
+
212
+ server.state.online = false;
213
+ await posts.update("p1", { title: "pending edit" });
214
+ // p1 is the oldest, and would go first if the queue were not consulted.
215
+ await posts.find();
216
+
217
+ expect(await posts.findById("p1")).toMatchObject({ title: "pending edit" });
218
+ manager.dispose();
219
+ });
220
+ });
221
+
222
+ describe("offline writes", () => {
223
+ it("stops attempting the network once it knows the connection is gone", async () => {
224
+ const server = createFakeServer();
225
+ const { wrap } = createManager(server, { syncIntervalMs: 60_000 });
226
+ const posts = wrap("posts");
227
+ await posts.find();
228
+
229
+ server.state.online = false;
230
+ await posts.create({ title: "one" });
231
+ const afterFirst = server.countCalls("create");
232
+
233
+ await posts.create({ title: "two" });
234
+ await posts.create({ title: "three" });
235
+
236
+ // The first write learns the network is down; the rest must not each
237
+ // pay for that lesson again.
238
+ expect(afterFirst).toBe(1);
239
+ expect(server.countCalls("create")).toBe(1);
240
+ });
241
+
242
+ it("keeps a write local when the connection has already failed", async () => {
243
+ const server = createFakeServer();
244
+ const { manager, wrap } = createManager(server, { syncIntervalMs: 60_000 });
245
+ const posts = wrap("posts");
246
+ await posts.find();
247
+ server.state.online = false;
248
+ await posts.create({ title: "one" });
249
+
250
+ const row = await posts.create({ title: "two" });
251
+ expect(row.title).toBe("two");
252
+ expect(await manager.api.pending()).toHaveLength(2);
253
+ expect((await posts.find()).data.map((r) => r.title).sort()).toEqual(["one", "two"]);
254
+ });
255
+
256
+ it("reports the queue depth and connectivity through status()", async () => {
257
+ const server = createFakeServer();
258
+ const { manager, wrap } = createManager(server);
259
+ const posts = wrap("posts");
260
+ const seen: OfflineStatus[] = [];
261
+ manager.api.onStatusChange((status) => seen.push(status));
262
+ await posts.find();
263
+
264
+ expect(manager.api.status()).toMatchObject({ online: true, pending: 0, syncing: false });
265
+
266
+ server.state.online = false;
267
+ await posts.create({ title: "queued" });
268
+ expect(manager.api.status()).toMatchObject({ online: false, pending: 1 });
269
+ expect(seen.some((s) => s.online === false)).toBe(true);
270
+
271
+ server.state.online = true;
272
+ await manager.sync();
273
+ expect(manager.api.status()).toMatchObject({ online: true, pending: 0, syncing: false });
274
+ expect(manager.api.status().lastSyncedAt).toBeGreaterThan(0);
275
+ // `syncing` has to be observable while it is true, or a spinner can
276
+ // never be shown.
277
+ expect(seen.some((s) => s.syncing)).toBe(true);
278
+ });
279
+ });
280
+
281
+ describe("rollback", () => {
282
+ it("undoes a rejected update and puts the previous value back", async () => {
283
+ const server = createFakeServer();
284
+ const onSyncError = jest.fn();
285
+ const { manager, wrap } = createManager(server, { onSyncError });
286
+ const posts = wrap("posts");
287
+ await server.client("posts").create({ title: "original" }, "p1");
288
+ await posts.find();
289
+
290
+ server.state.online = false;
291
+ await posts.update("p1", { title: "optimistic" });
292
+ expect(await posts.findById("p1")).toMatchObject({ title: "optimistic" });
293
+
294
+ server.state.online = true;
295
+ server.rejections.set("update:p1", new RebaseApiError("denied", { status: 403 }));
296
+ await manager.sync();
297
+
298
+ expect(await posts.findById("p1")).toMatchObject({ title: "original" });
299
+ expect(onSyncError).toHaveBeenCalledTimes(1);
300
+ expect(manager.api.status().lastError).toBe("denied");
301
+ });
302
+
303
+ it("removes a rejected create from the local database", async () => {
304
+ const server = createFakeServer();
305
+ const onSyncError = jest.fn();
306
+ const { manager, wrap } = createManager(server, { onSyncError });
307
+ const posts = wrap("posts");
308
+ await posts.find();
309
+
310
+ server.state.online = false;
311
+ const row = await posts.create({ title: "doomed" });
312
+ expect((await posts.find()).data).toHaveLength(1);
313
+
314
+ server.state.online = true;
315
+ server.rejections.set("create:*", new RebaseApiError("invalid", { status: 400 }));
316
+ await manager.sync();
317
+
318
+ expect((await posts.find()).data).toHaveLength(0);
319
+ expect(await posts.findById(row.id as string).catch(() => undefined)).toBeUndefined();
320
+ expect(await manager.api.pending()).toHaveLength(0);
321
+ });
322
+
323
+ it("restores a rejected delete", async () => {
324
+ const server = createFakeServer();
325
+ const { manager, wrap } = createManager(server, { onSyncError: () => undefined });
326
+ const posts = wrap("posts");
327
+ await server.client("posts").create({ title: "keep me" }, "p1");
328
+ await posts.find();
329
+
330
+ server.state.online = false;
331
+ await posts.delete("p1");
332
+ expect(await posts.findById("p1")).toBeUndefined();
333
+
334
+ server.state.online = true;
335
+ server.rejections.set("delete:p1", new RebaseApiError("denied", { status: 403 }));
336
+ await manager.sync();
337
+
338
+ expect(await posts.findById("p1")).toMatchObject({ title: "keep me" });
339
+ });
340
+
341
+ it("discards the edits that were built on a rejected create", async () => {
342
+ const server = createFakeServer();
343
+ const onSyncError = jest.fn();
344
+ const { manager, wrap } = createManager(server, { onSyncError });
345
+ const posts = wrap("posts");
346
+ await posts.find();
347
+
348
+ server.state.online = false;
349
+ const row = await posts.create({ title: "doomed" }, "x1");
350
+ // A different row in between stops the update coalescing into the
351
+ // create, so this really is a second queued mutation.
352
+ await posts.create({ title: "unrelated" }, "x2");
353
+ await posts.update("x1", { title: "edited" });
354
+ expect(await manager.api.pending()).toHaveLength(3);
355
+
356
+ server.state.online = true;
357
+ server.rejections.set("create:x1", new RebaseApiError("invalid", { status: 400 }));
358
+ await manager.sync();
359
+
360
+ // The edit could only have failed too, and keeping it would leave the
361
+ // local database claiming a row the server does not have.
362
+ expect(onSyncError.mock.calls.map((c) => (c[1] as PendingMutation).type)).toEqual(["create", "update"]);
363
+ expect(server.table("posts").has("x1")).toBe(false);
364
+ expect(server.table("posts").has("x2")).toBe(true);
365
+ expect(await manager.api.pending()).toHaveLength(0);
366
+ expect(row.id).toBe("x1");
367
+ });
368
+
369
+ it("keeps a later create that re-establishes the row on its own", async () => {
370
+ const server = createFakeServer();
371
+ const onSyncError = jest.fn();
372
+ const { manager, wrap } = createManager(server, { onSyncError });
373
+ const posts = wrap("posts");
374
+ await posts.find();
375
+
376
+ server.state.online = false;
377
+ // Update a row the server does not have: this one is going to 404.
378
+ await posts.update("p9", { title: "orphan edit" });
379
+ await posts.create({ title: "recreated" }, "p9");
380
+
381
+ server.state.online = true;
382
+ await manager.sync();
383
+
384
+ // The create overwrites the row outright, so it never depended on the
385
+ // rejected update — dropping it would lose a write the server accepts.
386
+ expect(server.table("posts").get("p9")).toMatchObject({ title: "recreated" });
387
+ expect(onSyncError.mock.calls.map((c) => (c[1] as PendingMutation).type)).toEqual(["update"]);
388
+ });
389
+ });
390
+
391
+ describe("replay", () => {
392
+ it("moves the local row when the server assigns its own id", async () => {
393
+ const server = createFakeServer();
394
+ const { manager, wrap } = createManager(server);
395
+ const posts = wrap("posts");
396
+ await posts.find();
397
+
398
+ server.state.online = false;
399
+ const local = await posts.create({ title: "renamed" });
400
+ server.idRewrites.set(String(local.id), "srv-99");
401
+
402
+ server.state.online = true;
403
+ await manager.sync();
404
+
405
+ expect(await posts.findById("srv-99")).toMatchObject({ title: "renamed" });
406
+ // The temporary id must not linger as a phantom row.
407
+ expect((await posts.find()).data.map((r) => r.id)).toEqual(["srv-99"]);
408
+ });
409
+
410
+ it("re-points queued writes at the id the server chose", async () => {
411
+ const server = createFakeServer();
412
+ const { manager, wrap } = createManager(server);
413
+ const posts = wrap("posts");
414
+ await posts.find();
415
+
416
+ server.state.online = false;
417
+ const local = await posts.create({ title: "first" });
418
+ await posts.create({ title: "other" }, "other");
419
+ await posts.update(local.id as string, { title: "second" });
420
+ server.idRewrites.set(String(local.id), "srv-7");
421
+
422
+ server.state.online = true;
423
+ await manager.sync();
424
+
425
+ // The update was queued against an id that never existed server-side.
426
+ expect(server.table("posts").get("srv-7")).toMatchObject({ title: "second" });
427
+ expect(await manager.api.pending()).toHaveLength(0);
428
+ });
429
+
430
+ it("retries a mutation the server was merely too busy for", async () => {
431
+ const server = createFakeServer();
432
+ const onSyncError = jest.fn();
433
+ const { manager, wrap } = createManager(server, { onSyncError });
434
+ const posts = wrap("posts");
435
+ await posts.find();
436
+
437
+ server.state.online = false;
438
+ await posts.create({ title: "later" }, "p1");
439
+
440
+ server.state.online = true;
441
+ server.rejections.set("create:p1", new RebaseApiError("slow down", { status: 429 }));
442
+ expect(await manager.sync()).toMatchObject({ flushed: 0, remaining: 1 });
443
+ expect(onSyncError).not.toHaveBeenCalled();
444
+
445
+ server.rejections.clear();
446
+ expect(await manager.sync()).toMatchObject({ flushed: 1, remaining: 0 });
447
+ expect(server.table("posts").get("p1")).toMatchObject({ title: "later" });
448
+ });
449
+
450
+ it("gives up on a mutation that keeps being deferred", async () => {
451
+ const server = createFakeServer();
452
+ const onSyncError = jest.fn();
453
+ const { manager, wrap } = createManager(server, { onSyncError, maxRetries: 2 });
454
+ const posts = wrap("posts");
455
+ await posts.find();
456
+
457
+ server.state.online = false;
458
+ await posts.create({ title: "stuck" }, "p1");
459
+
460
+ server.state.online = true;
461
+ server.rejections.set("create:*", new RebaseApiError("down", { status: 503 }));
462
+ await manager.sync();
463
+ expect(await manager.api.pending()).toHaveLength(1);
464
+ await manager.sync();
465
+
466
+ // Otherwise it jams every write queued behind it, forever.
467
+ expect(await manager.api.pending()).toHaveLength(0);
468
+ expect(onSyncError).toHaveBeenCalledTimes(1);
469
+ });
470
+
471
+ it("keeps an unsent edit visible while the write before it lands", async () => {
472
+ const server = createFakeServer();
473
+ const { manager, wrap } = createManager(server);
474
+ const posts = wrap("posts");
475
+ await posts.find();
476
+
477
+ server.state.online = false;
478
+ await posts.create({ title: "v1", n: 1 }, "p1");
479
+ await posts.create({ title: "unrelated" }, "p2");
480
+ await posts.update("p1", { title: "v2" });
481
+
482
+ // Replay the create only, by making the update fail on the network.
483
+ server.state.online = true;
484
+ server.rejections.set("update:p1", new TypeError("fetch failed"));
485
+ await manager.sync();
486
+
487
+ // The create landed; the update has not — and the row must still show
488
+ // it, or the user watches their edit snap back and then reappear.
489
+ expect(await posts.findById("p1")).toMatchObject({ title: "v2", n: 1 });
490
+ expect(await manager.api.pending()).toHaveLength(1);
491
+ });
492
+ });
493
+
494
+ describe("live queries", () => {
495
+ it("emits from the local database before the network answers", async () => {
496
+ const server = createFakeServer();
497
+ const { wrap } = createManager(server);
498
+ const posts = wrap("posts");
499
+ await server.client("posts").create({ title: "a" }, "p1");
500
+ await posts.find();
501
+
502
+ const results: LiveResult<Row>[] = [];
503
+ const stop = posts.observe(undefined, (r) => results.push(r));
504
+ await settle();
505
+
506
+ expect(results.length).toBeGreaterThanOrEqual(1);
507
+ expect(results[0].data.map((r) => r.id)).toEqual(["p1"]);
508
+ expect(results[0].fromCache).toBe(true);
509
+ stop();
510
+ });
511
+
512
+ it("re-emits when a local write changes the rows it covers", async () => {
513
+ const server = createFakeServer();
514
+ const { wrap } = createManager(server);
515
+ const posts = wrap("posts");
516
+ await server.client("posts").create({ title: "a" }, "p1");
517
+ await posts.find();
518
+
519
+ const results: LiveResult<Row>[] = [];
520
+ const stop = posts.observe(undefined, (r) => results.push(r));
521
+ await settle();
522
+ const before = results.length;
523
+
524
+ server.state.online = false;
525
+ await posts.update("p1", { title: "edited" });
526
+ await settle();
527
+
528
+ expect(results.length).toBeGreaterThan(before);
529
+ const last = results[results.length - 1];
530
+ expect(last.data[0].title).toBe("edited");
531
+ expect(last.hasPendingWrites).toBe(true);
532
+ stop();
533
+ });
534
+
535
+ it("does not call back when a refresh changes nothing", async () => {
536
+ const server = createFakeServer();
537
+ const { wrap } = createManager(server);
538
+ const posts = wrap("posts");
539
+ await server.client("posts").create({ title: "a" }, "p1");
540
+ await posts.find();
541
+
542
+ const results: LiveResult<Row>[] = [];
543
+ const stop = posts.observe(undefined, (r) => results.push(r));
544
+ await settle();
545
+ const settled = results.length;
546
+
547
+ await posts.find();
548
+ await settle();
549
+ // Re-rendering a list that did not change is the difference between a
550
+ // live query and a polling loop.
551
+ expect(results.length).toBe(settled);
552
+ stop();
553
+ });
554
+
555
+ it("stops emitting once unsubscribed", async () => {
556
+ const server = createFakeServer();
557
+ const { wrap } = createManager(server);
558
+ const posts = wrap("posts");
559
+ await posts.find();
560
+
561
+ const results: LiveResult<Row>[] = [];
562
+ const stop = posts.observe(undefined, (r) => results.push(r));
563
+ await settle();
564
+ stop();
565
+ const after = results.length;
566
+
567
+ server.state.online = false;
568
+ await posts.create({ title: "ignored" });
569
+ await settle();
570
+ expect(results.length).toBe(after);
571
+ });
572
+
573
+ it("reports a failed first read instead of emitting an empty list", async () => {
574
+ const server = createFakeServer();
575
+ const { wrap } = createManager(server);
576
+ const posts = wrap("posts");
577
+ server.state.online = false;
578
+
579
+ const results: LiveResult<Row>[] = [];
580
+ const errors: Error[] = [];
581
+ const stop = posts.observe(undefined, (r) => results.push(r), (e) => errors.push(e));
582
+ await settle();
583
+
584
+ expect(results).toHaveLength(0);
585
+ expect(errors).toHaveLength(1);
586
+ stop();
587
+ });
588
+
589
+ it("observes a single row, including its disappearance", async () => {
590
+ const server = createFakeServer();
591
+ const { wrap } = createManager(server);
592
+ const posts = wrap("posts");
593
+ await server.client("posts").create({ title: "a" }, "p1");
594
+ await posts.findById("p1");
595
+
596
+ const seen: (Row | undefined)[] = [];
597
+ const stop = posts.observeById("p1", (row) => seen.push(row));
598
+ await settle();
599
+ expect(seen[seen.length - 1]).toMatchObject({ title: "a" });
600
+
601
+ server.state.online = false;
602
+ await posts.delete("p1");
603
+ await settle();
604
+ expect(seen[seen.length - 1]).toBeUndefined();
605
+ stop();
606
+ });
607
+
608
+ it("empties on sign-out instead of leaving the previous user's rows on screen", async () => {
609
+ const server = createFakeServer();
610
+ const { manager, wrap } = createManager(server);
611
+ const posts = wrap("posts");
612
+ manager.setScope("user-a");
613
+ await server.client("posts").create({ title: "a-private" }, "p1");
614
+ await posts.find();
615
+
616
+ const results: LiveResult<Row>[] = [];
617
+ const stop = posts.observe(undefined, (r) => results.push(r));
618
+ await settle();
619
+ expect(results[results.length - 1].data).toHaveLength(1);
620
+
621
+ server.state.online = false;
622
+ manager.setScope(undefined);
623
+ await settle();
624
+
625
+ expect(results[results.length - 1].data).toHaveLength(0);
626
+ stop();
627
+ });
628
+
629
+ it("flags a result the local database cannot vouch for", async () => {
630
+ const server = createFakeServer();
631
+ const { wrap } = createManager(server);
632
+ const posts = wrap("posts");
633
+ await server.client("posts").create({ title: "searchable" }, "p1");
634
+
635
+ const results: LiveResult<Row>[] = [];
636
+ const stop = posts.observe({ searchString: "search" }, (r) => results.push(r));
637
+ await settle();
638
+
639
+ // The server ran real full-text search; the local engine only ever
640
+ // approximates it, so a cached answer must say so.
641
+ expect(results[results.length - 1].partial).toBe(true);
642
+ stop();
643
+ });
644
+ });
645
+
646
+ describe("counts", () => {
647
+ it("folds queued writes into a server count", async () => {
648
+ const server = createFakeServer();
649
+ const { wrap } = createManager(server);
650
+ const posts = wrap("posts");
651
+ await server.client("posts").create({ title: "a", status: "draft" }, "p1");
652
+ const drafts: FindParams = { where: { status: ["==", "draft"] } };
653
+ expect(await posts.count(drafts)).toBe(1);
654
+
655
+ server.state.online = false;
656
+ await posts.create({ title: "b", status: "draft" });
657
+ await posts.create({ title: "c", status: "published" });
658
+
659
+ // Only the row that matches the filter may move the number.
660
+ expect(await posts.count(drafts)).toBe(2);
661
+ });
662
+
663
+ it("subtracts a queued delete", async () => {
664
+ const server = createFakeServer();
665
+ const { wrap } = createManager(server);
666
+ const posts = wrap("posts");
667
+ await server.client("posts").create({ title: "a" }, "p1");
668
+ await server.client("posts").create({ title: "b" }, "p2");
669
+ await posts.find();
670
+ expect(await posts.count()).toBe(2);
671
+
672
+ server.state.online = false;
673
+ await posts.delete("p1");
674
+ expect(await posts.count()).toBe(1);
675
+ });
676
+ });