@rebasepro/client 0.10.0 → 0.10.1-canary.14e53ae

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,719 @@
1
+ import { jest } from "@jest/globals";
2
+ import { isOfflineError, OfflineManager } from "./offline";
3
+ import { MemoryOfflineStore, PendingMutation } from "./offline-store";
4
+ import { RebaseApiError } from "./transport";
5
+ import type { CollectionClient } from "./collection";
6
+ import type { FindParams } from "./transport";
7
+
8
+ type Row = Record<string, unknown>;
9
+
10
+ /**
11
+ * A fake collection client backed by an in-memory table, with a switch that
12
+ * makes every call fail the way fetch does when the network is down. This is
13
+ * exactly the failure the manager keys on, so the tests exercise the real
14
+ * online/offline decision logic rather than a mock of it.
15
+ */
16
+ function createFakeServer() {
17
+ const state = { online: true };
18
+ const tables = new Map<string, Map<string, Row>>();
19
+ const log: { collection: string; op: string; id?: unknown; upsert?: boolean }[] = [];
20
+
21
+ function table(slug: string): Map<string, Row> {
22
+ if (!tables.has(slug)) tables.set(slug, new Map());
23
+ return tables.get(slug)!;
24
+ }
25
+
26
+ function assertOnline() {
27
+ if (!state.online) throw new TypeError("fetch failed");
28
+ }
29
+
30
+ function client(slug: string): CollectionClient<Row> {
31
+ const rows = () => [...table(slug).values()];
32
+ const fake = {
33
+ async find(_params?: FindParams) {
34
+ assertOnline();
35
+ const data = rows();
36
+ return { data, meta: { total: data.length, limit: 20, offset: 0, hasMore: false } };
37
+ },
38
+ async findById(id: string | number) {
39
+ assertOnline();
40
+ return table(slug).get(String(id));
41
+ },
42
+ async create(data: Partial<Row>, id?: string | number) {
43
+ assertOnline();
44
+ const rowId = id ?? data.id ?? `srv-${table(slug).size + 1}`;
45
+ log.push({ collection: slug, op: "create", id: rowId });
46
+ const row = { ...data, id: rowId };
47
+ table(slug).set(String(rowId), row);
48
+ return row;
49
+ },
50
+ async createMany(data: Partial<Row>[], options?: { upsert?: boolean }) {
51
+ assertOnline();
52
+ log.push({ collection: slug, op: "createMany", upsert: options?.upsert });
53
+ return data.map((d) => {
54
+ const row = { ...d, id: d.id ?? `srv-${table(slug).size + 1}` };
55
+ table(slug).set(String(row.id), row);
56
+ return row;
57
+ });
58
+ },
59
+ async update(id: string | number, data: Partial<Row>) {
60
+ assertOnline();
61
+ log.push({ collection: slug, op: "update", id });
62
+ const existing = table(slug).get(String(id));
63
+ if (!existing) {
64
+ throw new RebaseApiError("Not found", { status: 404 });
65
+ }
66
+ const row = { ...existing, ...data };
67
+ table(slug).set(String(id), row);
68
+ return row;
69
+ },
70
+ async delete(id: string | number) {
71
+ assertOnline();
72
+ log.push({ collection: slug, op: "delete", id });
73
+ table(slug).delete(String(id));
74
+ },
75
+ async count(_params?: FindParams) {
76
+ assertOnline();
77
+ return table(slug).size;
78
+ }
79
+ };
80
+ return fake as unknown as CollectionClient<Row>;
81
+ }
82
+
83
+ return { state, table, log, client };
84
+ }
85
+
86
+ function createManager(server: ReturnType<typeof createFakeServer>, options: {
87
+ onSyncError?: (error: Error, mutation: PendingMutation) => void;
88
+ store?: MemoryOfflineStore;
89
+ } = {}) {
90
+ const store = options.store ?? new MemoryOfflineStore();
91
+ const manager = new OfflineManager(
92
+ { store, syncIntervalMs: 0, onSyncError: options.onSyncError },
93
+ (slug) => server.client(slug)
94
+ );
95
+ const wrap = (slug: string) => manager.wrap(slug, server.client(slug));
96
+ return { manager, store, wrap };
97
+ }
98
+
99
+ describe("OfflineManager", () => {
100
+ describe("cached reads", () => {
101
+ it("serves the last cached find result when the network fails", async () => {
102
+ const server = createFakeServer();
103
+ const { wrap } = createManager(server);
104
+ const posts = wrap("posts");
105
+ await server.client("posts").create({ title: "hello" }, "p1");
106
+
107
+ const online = await posts.find();
108
+ expect(online.data).toHaveLength(1);
109
+
110
+ server.state.online = false;
111
+ const offline = await posts.find();
112
+ expect(offline.data).toEqual(online.data);
113
+ expect(offline.meta.total).toBe(1);
114
+ });
115
+
116
+ it("reports an offline read that has nothing local to answer with", async () => {
117
+ const server = createFakeServer();
118
+ const { wrap } = createManager(server);
119
+ server.state.online = false;
120
+ const error = await wrap("posts").find().catch((e) => e);
121
+ // A typed, recognisable failure rather than fetch's bare TypeError:
122
+ // "you are offline and I have nothing" is a distinct condition from
123
+ // "the request blew up", and apps need to tell them apart.
124
+ expect(isOfflineError(error)).toBe(true);
125
+ expect(error.message).toContain("posts");
126
+ });
127
+
128
+ it("does not swallow server errors into the cache path", async () => {
129
+ const server = createFakeServer();
130
+ const { wrap } = createManager(server);
131
+ const posts = wrap("posts");
132
+ await posts.find(); // caches an empty result
133
+ await expect(posts.update("missing", { title: "x" })).rejects.toThrow(RebaseApiError);
134
+ });
135
+
136
+ it("serves a cached findById and count when offline", async () => {
137
+ const server = createFakeServer();
138
+ const { wrap } = createManager(server);
139
+ const posts = wrap("posts");
140
+ await server.client("posts").create({ title: "hello" }, "p1");
141
+
142
+ expect(await posts.findById("p1")).toMatchObject({ title: "hello" });
143
+ expect(await posts.count()).toBe(1);
144
+
145
+ server.state.online = false;
146
+ expect(await posts.findById("p1")).toMatchObject({ title: "hello" });
147
+ expect(await posts.count()).toBe(1);
148
+ });
149
+ });
150
+
151
+ describe("offline writes", () => {
152
+ it("queues an offline create and returns an optimistic row with a generated id", async () => {
153
+ const server = createFakeServer();
154
+ const { manager, wrap } = createManager(server);
155
+ const posts = wrap("posts");
156
+ server.state.online = false;
157
+
158
+ const row = await posts.create({ title: "draft" });
159
+ expect(row.title).toBe("draft");
160
+ expect(typeof row.id).toBe("string");
161
+
162
+ const pending = await manager.api.pending();
163
+ expect(pending).toHaveLength(1);
164
+ expect(pending[0]).toMatchObject({ collection: "posts", type: "create" });
165
+
166
+ // The app immediately sees its own write.
167
+ expect(await posts.findById(row.id as string)).toMatchObject({ title: "draft" });
168
+ });
169
+
170
+ it("overlays queued creates, updates and deletes onto cached finds", async () => {
171
+ const server = createFakeServer();
172
+ const { wrap } = createManager(server);
173
+ const posts = wrap("posts");
174
+ await server.client("posts").create({ title: "a" }, "p1");
175
+ await server.client("posts").create({ title: "b" }, "p2");
176
+ await posts.find();
177
+ await posts.count();
178
+
179
+ server.state.online = false;
180
+ const created = await posts.create({ title: "c" });
181
+ await posts.update("p1", { title: "a2" });
182
+ await posts.delete("p2");
183
+
184
+ const res = await posts.find();
185
+ expect(res.data.map((r) => r.title).sort()).toEqual(["a2", "c"]);
186
+ expect(res.meta.total).toBe(2);
187
+ expect(res.data.find((r) => r.id === created.id)).toBeDefined();
188
+ expect(await posts.count()).toBe(2);
189
+ });
190
+
191
+ it("puts an offline create into the filtered lists it belongs to, and only those", async () => {
192
+ const server = createFakeServer();
193
+ const { wrap } = createManager(server);
194
+ const posts = wrap("posts");
195
+ await server.client("posts").create({ title: "a", status: "draft" }, "p1");
196
+ const drafts: FindParams = { where: { status: ["==", "draft"] } };
197
+ const published: FindParams = { where: { status: ["==", "published"] } };
198
+ await posts.find(drafts);
199
+ await posts.find(published);
200
+
201
+ server.state.online = false;
202
+ await posts.create({ title: "b", status: "draft" });
203
+ await posts.update("p1", { title: "a2" });
204
+
205
+ const inDrafts = await posts.find(drafts);
206
+ expect(inDrafts.data.map((r) => r.title).sort()).toEqual(["a2", "b"]);
207
+ expect(inDrafts.meta.total).toBe(2);
208
+
209
+ // The same row must not leak into a list whose filter it fails.
210
+ expect((await posts.find(published)).data).toHaveLength(0);
211
+ });
212
+
213
+ it("takes a row out of a list when an offline edit moves it out of the filter", async () => {
214
+ const server = createFakeServer();
215
+ const { wrap } = createManager(server);
216
+ const posts = wrap("posts");
217
+ await server.client("posts").create({ title: "a", status: "draft" }, "p1");
218
+ await server.client("posts").create({ title: "b", status: "draft" }, "p2");
219
+ const drafts: FindParams = { where: { status: ["==", "draft"] } };
220
+ await posts.find(drafts);
221
+
222
+ server.state.online = false;
223
+ await posts.update("p1", { status: "published" });
224
+
225
+ const res = await posts.find(drafts);
226
+ expect(res.data.map((r) => r.id)).toEqual(["p2"]);
227
+ expect(res.meta.total).toBe(1);
228
+ });
229
+
230
+ it("reports a pending delete of a cached row as gone", async () => {
231
+ const server = createFakeServer();
232
+ const { wrap } = createManager(server);
233
+ const posts = wrap("posts");
234
+ await server.client("posts").create({ title: "a" }, "p1");
235
+ await posts.findById("p1");
236
+
237
+ server.state.online = false;
238
+ await posts.delete("p1");
239
+ expect(await posts.findById("p1")).toBeUndefined();
240
+ });
241
+ });
242
+
243
+ describe("sync", () => {
244
+ it("replays queued mutations in the order they were made", async () => {
245
+ const server = createFakeServer();
246
+ const { manager, wrap } = createManager(server);
247
+ const posts = wrap("posts");
248
+ await server.client("posts").create({ title: "old" }, "p0");
249
+ server.log.length = 0;
250
+
251
+ // Two different rows, so tail coalescing (tested separately)
252
+ // cannot collapse the queue and hide the ordering.
253
+ server.state.online = false;
254
+ const row = await posts.create({ title: "draft" });
255
+ await posts.update("p0", { title: "final" });
256
+
257
+ server.state.online = true;
258
+ const result = await manager.sync();
259
+ expect(result).toEqual({ flushed: 2, remaining: 0 });
260
+ expect(server.log.map((l) => l.op)).toEqual(["create", "update"]);
261
+ expect(server.table("posts").get(String(row.id))).toMatchObject({ title: "draft" });
262
+ expect(server.table("posts").get("p0")).toMatchObject({ title: "final" });
263
+ expect(await manager.api.pending()).toHaveLength(0);
264
+ });
265
+
266
+ it("keeps the queue when the network is still down", async () => {
267
+ const server = createFakeServer();
268
+ const { manager, wrap } = createManager(server);
269
+ server.state.online = false;
270
+ await wrap("posts").create({ title: "draft" });
271
+
272
+ const result = await manager.sync();
273
+ expect(result).toEqual({ flushed: 0, remaining: 1 });
274
+ expect(await manager.api.pending()).toHaveLength(1);
275
+ });
276
+
277
+ it("drops a server-rejected mutation, reports it, and keeps flushing", async () => {
278
+ const server = createFakeServer();
279
+ const onSyncError = jest.fn<(error: Error, mutation: PendingMutation) => void>();
280
+ const { manager, wrap } = createManager(server, { onSyncError });
281
+ const posts = wrap("posts");
282
+
283
+ server.state.online = false;
284
+ await posts.update("does-not-exist", { title: "x" }); // will 404 on replay
285
+ await posts.create({ title: "kept" }, "p9");
286
+
287
+ server.state.online = true;
288
+ const result = await manager.sync();
289
+ expect(result).toEqual({ flushed: 1, remaining: 0 });
290
+ expect(onSyncError).toHaveBeenCalledTimes(1);
291
+ expect(onSyncError.mock.calls[0][1]).toMatchObject({ type: "update", id: "does-not-exist" });
292
+ expect(server.table("posts").get("p9")).toMatchObject({ title: "kept" });
293
+ });
294
+
295
+ it("notifies queue-size listeners as writes queue and drain", async () => {
296
+ const server = createFakeServer();
297
+ const { manager, wrap } = createManager(server);
298
+ const counts: number[] = [];
299
+ manager.api.onQueueChange((n) => counts.push(n));
300
+
301
+ server.state.online = false;
302
+ await wrap("posts").create({ title: "a" });
303
+ server.state.online = true;
304
+ await manager.sync();
305
+
306
+ expect(counts).toContain(1);
307
+ expect(counts[counts.length - 1]).toBe(0);
308
+ });
309
+ });
310
+
311
+ describe("per-user scoping", () => {
312
+ it("a scope switch during a slow queue load cannot leak the old user's queue", async () => {
313
+ const server = createFakeServer();
314
+ const store = new MemoryOfflineStore();
315
+
316
+ // Seed user A's persisted queue from a previous "session".
317
+ const seeder = createManager(server, { store });
318
+ seeder.manager.setScope("user-a");
319
+ server.state.online = false;
320
+ await seeder.wrap("posts").create({ title: "a-op" }, "pa");
321
+ seeder.manager.dispose();
322
+
323
+ // A store whose queue load resolves only when released — the
324
+ // scope switch lands while user A's load is still in flight.
325
+ let release!: () => void;
326
+ const gate = new Promise<void>((resolve) => { release = resolve; });
327
+ const slowStore = Object.create(store) as MemoryOfflineStore;
328
+ slowStore.listQueue = async (prefix: string) => {
329
+ if (prefix === "user-a|") await gate;
330
+ return store.listQueue(prefix);
331
+ };
332
+
333
+ const { manager } = createManager(server, { store: slowStore as MemoryOfflineStore });
334
+ manager.setScope("user-a");
335
+ const stalled = manager.api.pending(); // starts the gated load
336
+ manager.setScope("user-b");
337
+ const pendingB = await manager.api.pending();
338
+ release();
339
+ await stalled.catch(() => undefined);
340
+
341
+ expect(pendingB).toHaveLength(0);
342
+ expect(await manager.api.pending()).toHaveLength(0);
343
+ });
344
+
345
+ it("never serves one user's cache or queue to another", async () => {
346
+ const server = createFakeServer();
347
+ const store = new MemoryOfflineStore();
348
+ const { manager, wrap } = createManager(server, { store });
349
+ const posts = wrap("posts");
350
+
351
+ manager.setScope("user-a");
352
+ await server.client("posts").create({ title: "a-private" }, "p1");
353
+ await posts.find();
354
+ server.state.online = false;
355
+ await posts.create({ title: "a-pending" });
356
+ expect(await manager.api.pending()).toHaveLength(1);
357
+
358
+ manager.setScope("user-b");
359
+ expect(await manager.api.pending()).toHaveLength(0);
360
+ expect(isOfflineError(await posts.find().catch((e) => e))).toBe(true);
361
+
362
+ // Coming back, user A finds both again.
363
+ manager.setScope("user-a");
364
+ expect(await manager.api.pending()).toHaveLength(1);
365
+ expect((await posts.find()).data.some((r) => r.title === "a-private")).toBe(true);
366
+ });
367
+ });
368
+
369
+ describe("coalescing", () => {
370
+ it("merges an update into the queued create it edits", async () => {
371
+ const server = createFakeServer();
372
+ const { manager, wrap } = createManager(server);
373
+ const posts = wrap("posts");
374
+ server.state.online = false;
375
+
376
+ const row = await posts.create({ title: "draft", status: "new" });
377
+ await posts.update(row.id as string, { title: "draft 2" });
378
+ await posts.update(row.id as string, { title: "draft 3" });
379
+
380
+ const pending = await manager.api.pending();
381
+ expect(pending).toHaveLength(1);
382
+ expect(pending[0]).toMatchObject({ type: "create" });
383
+ expect(pending[0].data).toMatchObject({ title: "draft 3", status: "new", id: row.id });
384
+
385
+ server.state.online = true;
386
+ await manager.sync();
387
+ expect(server.log).toHaveLength(1);
388
+ expect(server.table("posts").get(String(row.id))).toMatchObject({ title: "draft 3" });
389
+ });
390
+
391
+ it("merges consecutive updates to the same row", async () => {
392
+ const server = createFakeServer();
393
+ const { manager, wrap } = createManager(server);
394
+ const posts = wrap("posts");
395
+ await server.client("posts").create({ title: "a", views: 0 }, "p1");
396
+ server.state.online = false;
397
+
398
+ await posts.update("p1", { title: "b" });
399
+ await posts.update("p1", { views: 1 });
400
+
401
+ const pending = await manager.api.pending();
402
+ expect(pending).toHaveLength(1);
403
+ expect(pending[0].data).toMatchObject({ title: "b", views: 1 });
404
+ });
405
+
406
+ it("never merges across an op for a different row — order is preserved", async () => {
407
+ const server = createFakeServer();
408
+ const { manager, wrap } = createManager(server);
409
+ const posts = wrap("posts");
410
+ await server.client("posts").create({ title: "a" }, "pa");
411
+ await server.client("posts").create({ title: "b" }, "pb");
412
+ server.log.length = 0;
413
+ server.state.online = false;
414
+
415
+ await posts.update("pa", { title: "a1" });
416
+ await posts.update("pb", { title: "b1" });
417
+ await posts.update("pa", { title: "a2" });
418
+
419
+ expect(await manager.api.pending()).toHaveLength(3);
420
+ server.state.online = true;
421
+ await manager.sync();
422
+ expect(server.log.map((l) => l.id)).toEqual(["pa", "pb", "pa"]);
423
+ expect(server.table("posts").get("pa")).toMatchObject({ title: "a2" });
424
+ });
425
+
426
+ it("cancels a queued create (and its updates) when the row is deleted offline", async () => {
427
+ const server = createFakeServer();
428
+ const { manager, wrap } = createManager(server);
429
+ const posts = wrap("posts");
430
+ server.state.online = false;
431
+
432
+ const row = await posts.create({ title: "ephemeral" });
433
+ await posts.update(row.id as string, { title: "edited" });
434
+ await posts.delete(row.id as string);
435
+
436
+ expect(await manager.api.pending()).toHaveLength(0);
437
+ server.state.online = true;
438
+ expect(await manager.sync()).toEqual({ flushed: 0, remaining: 0 });
439
+ expect(server.log).toHaveLength(0);
440
+ });
441
+
442
+ it("does not cancel out a create that reused a caller-supplied id — the delete must reach the server", async () => {
443
+ const server = createFakeServer();
444
+ const { manager, wrap } = createManager(server);
445
+ const posts = wrap("posts");
446
+ await server.client("posts").create({ title: "original" }, "p1");
447
+ server.log.length = 0;
448
+ server.state.online = false;
449
+
450
+ // The offline create's id names a row the server already has, so
451
+ // dropping create+delete as a no-op pair would resurrect it.
452
+ await posts.create({ title: "replacement" }, "p1");
453
+ await posts.delete("p1");
454
+
455
+ expect(await manager.api.pending()).toHaveLength(2);
456
+ server.state.online = true;
457
+ await manager.sync();
458
+ expect(server.table("posts").has("p1")).toBe(false);
459
+ });
460
+
461
+ it("still queues the delete of a row the server already has", async () => {
462
+ const server = createFakeServer();
463
+ const { manager, wrap } = createManager(server);
464
+ const posts = wrap("posts");
465
+ await server.client("posts").create({ title: "a" }, "p1");
466
+ server.log.length = 0;
467
+ server.state.online = false;
468
+
469
+ await posts.update("p1", { title: "a1" });
470
+ await posts.delete("p1");
471
+
472
+ // No pending create for p1, so nothing cancels: the update and
473
+ // the delete replay in order.
474
+ expect(await manager.api.pending()).toHaveLength(2);
475
+ server.state.online = true;
476
+ await manager.sync();
477
+ expect(server.log.map((l) => l.op)).toEqual(["update", "delete"]);
478
+ expect(server.table("posts").has("p1")).toBe(false);
479
+ });
480
+ });
481
+
482
+ describe("createMany", () => {
483
+ it("queues a bulk create offline, overlays it, and replays it with its upsert flag", async () => {
484
+ const server = createFakeServer();
485
+ const { manager, wrap } = createManager(server);
486
+ const posts = wrap("posts");
487
+ await posts.find();
488
+ server.state.online = false;
489
+
490
+ const rows = await posts.createMany([{ title: "a" }, { title: "b" }], { upsert: true });
491
+ expect(rows).toHaveLength(2);
492
+ expect(rows.every((r) => typeof r.id === "string")).toBe(true);
493
+
494
+ const overlaid = await posts.find();
495
+ expect(overlaid.data.map((r) => r.title)).toEqual(["a", "b"]);
496
+ expect(overlaid.meta.total).toBe(2);
497
+ expect(await posts.findById(rows[0].id as string)).toMatchObject({ title: "a" });
498
+
499
+ server.state.online = true;
500
+ await manager.sync();
501
+ expect(server.log).toEqual([{ collection: "posts", op: "createMany", upsert: true }]);
502
+ expect(server.table("posts").size).toBe(2);
503
+ });
504
+ });
505
+
506
+ describe("concurrency", () => {
507
+ it("gives concurrent offline writes distinct, ordered mutation ids", async () => {
508
+ const server = createFakeServer();
509
+ const { manager, wrap } = createManager(server);
510
+ const posts = wrap("posts");
511
+ server.state.online = false;
512
+
513
+ await Promise.all([
514
+ posts.create({ title: "one" }, "c1"),
515
+ posts.create({ title: "two" }, "c2"),
516
+ posts.create({ title: "three" }, "c3")
517
+ ]);
518
+
519
+ const pending = await manager.api.pending();
520
+ expect(pending.map((m) => m.id)).toEqual(["c1", "c2", "c3"]);
521
+ const ids = pending.map((m) => m.mutationId);
522
+ expect([...new Set(ids)]).toHaveLength(3);
523
+ // Lexicographic order is replay order — that is the whole contract
524
+ // the store's prefix listing relies on.
525
+ expect(ids).toEqual([...ids].sort());
526
+
527
+ server.state.online = true;
528
+ await manager.sync();
529
+ expect(server.log.map((l) => l.id)).toEqual(["c1", "c2", "c3"]);
530
+ });
531
+
532
+ it("coalesces concurrent sync calls into one flush", async () => {
533
+ const server = createFakeServer();
534
+ const { manager, wrap } = createManager(server);
535
+ server.state.online = false;
536
+ await wrap("posts").create({ title: "a" }, "p1");
537
+
538
+ server.state.online = true;
539
+ const [first, second] = await Promise.all([manager.sync(), manager.sync()]);
540
+ expect(first).toBe(second);
541
+ expect(server.log).toHaveLength(1);
542
+ });
543
+ });
544
+
545
+ describe("cache maintenance", () => {
546
+ it("evicts the least recently written queries beyond the per-collection cap", async () => {
547
+ const server = createFakeServer();
548
+ const store = new MemoryOfflineStore();
549
+ const manager = new OfflineManager(
550
+ { store, syncIntervalMs: 0, maxCachedQueriesPerCollection: 2 },
551
+ (slug) => server.client(slug)
552
+ );
553
+ const posts = manager.wrap("posts", server.client("posts"));
554
+ await server.client("posts").create({ title: "a" }, "p1");
555
+
556
+ await posts.find({ limit: 1 });
557
+ await posts.find({ limit: 2 });
558
+ await posts.find({ limit: 3 });
559
+
560
+ const kept = (await store.listCache("anon|q|posts|")).map((e) => e.key).sort();
561
+ expect(kept).toEqual(["anon|q|posts|?limit=2", "anon|q|posts|?limit=3"]);
562
+
563
+ // Evicting a snapshot costs the server's page composition, not the
564
+ // rows: the query still answers, from the local database.
565
+ server.state.online = false;
566
+ expect((await posts.find({ limit: 1 })).data).toHaveLength(1);
567
+ expect((await posts.find({ limit: 2 })).data).toHaveLength(1);
568
+ });
569
+
570
+ it("eviction never touches another collection's queries or cached rows", async () => {
571
+ const server = createFakeServer();
572
+ const store = new MemoryOfflineStore();
573
+ const manager = new OfflineManager(
574
+ { store, syncIntervalMs: 0, maxCachedQueriesPerCollection: 1 },
575
+ (slug) => server.client(slug)
576
+ );
577
+ const posts = manager.wrap("posts", server.client("posts"));
578
+ const users = manager.wrap("users", server.client("users"));
579
+ await server.client("posts").create({ title: "a" }, "p1");
580
+ await server.client("users").create({ name: "u" }, "u1");
581
+
582
+ await users.find();
583
+ await posts.findById("p1");
584
+ await posts.find({ limit: 1 });
585
+ await posts.find({ limit: 2 }); // evicts posts {limit:1}, nothing else
586
+
587
+ server.state.online = false;
588
+ expect((await users.find()).data).toHaveLength(1);
589
+ expect(await posts.findById("p1")).toMatchObject({ title: "a" });
590
+ });
591
+
592
+ it("clear() drops the queue and the cache together", async () => {
593
+ const server = createFakeServer();
594
+ const { manager, wrap } = createManager(server);
595
+ const posts = wrap("posts");
596
+ await server.client("posts").create({ title: "a" }, "p1");
597
+ server.log.length = 0;
598
+ await posts.find();
599
+
600
+ server.state.online = false;
601
+ await posts.create({ title: "pending" });
602
+ await manager.api.clear();
603
+
604
+ expect(await manager.api.pending()).toHaveLength(0);
605
+ expect(isOfflineError(await posts.find().catch((e) => e))).toBe(true);
606
+
607
+ server.state.online = true;
608
+ expect(await manager.sync()).toEqual({ flushed: 0, remaining: 0 });
609
+ expect(server.log).toHaveLength(0);
610
+ });
611
+
612
+ it("an online update refreshes the cached row", async () => {
613
+ const server = createFakeServer();
614
+ const { wrap } = createManager(server);
615
+ const posts = wrap("posts");
616
+ await server.client("posts").create({ title: "old" }, "p1");
617
+ await posts.findById("p1");
618
+ await posts.update("p1", { title: "new" });
619
+
620
+ server.state.online = false;
621
+ expect(await posts.findById("p1")).toMatchObject({ title: "new" });
622
+ });
623
+ });
624
+
625
+ describe("survives a restart", () => {
626
+ it("replays a queue persisted by a previous manager instance", async () => {
627
+ const server = createFakeServer();
628
+ const store = new MemoryOfflineStore();
629
+ const first = createManager(server, { store });
630
+ server.state.online = false;
631
+ await first.wrap("posts").create({ title: "from-last-session" }, "p1");
632
+ first.manager.dispose();
633
+
634
+ server.state.online = true;
635
+ const second = createManager(server, { store });
636
+ const result = await second.manager.sync();
637
+ expect(result).toEqual({ flushed: 1, remaining: 0 });
638
+ expect(server.table("posts").get("p1")).toMatchObject({ title: "from-last-session" });
639
+ });
640
+
641
+ it("randomized: coalescing + replay always ends in the state of a verbatim replay", async () => {
642
+ // The invariant that keeps coalescing honest: for ANY offline op
643
+ // sequence, syncing the (coalesced) queue must leave the server in
644
+ // exactly the state of applying every op one-by-one, in order.
645
+ // The oracle applies server semantics directly: create overwrites,
646
+ // update on a missing row is rejected (a dropped 404 at replay),
647
+ // delete of a missing row is a no-op.
648
+ for (let seed = 1; seed <= 20; seed++) {
649
+ let s = seed;
650
+ const rnd = () => (s = (s * 48271) % 2147483647) / 2147483647;
651
+ const pick = <T,>(arr: T[]): T => arr[Math.floor(rnd() * arr.length)];
652
+
653
+ const server = createFakeServer();
654
+ const { manager, wrap } = createManager(server, { onSyncError: () => undefined });
655
+ const posts = wrap("posts");
656
+ await server.client("posts").create({ title: "seed-1" }, "r1");
657
+ await server.client("posts").create({ title: "seed-2" }, "r2");
658
+
659
+ const oracle = new Map<string, Row>();
660
+ for (const [id, row] of server.table("posts")) oracle.set(id, { ...row });
661
+
662
+ server.state.online = false;
663
+ const ids = ["r1", "r2", "r3", "r4", "r5"];
664
+ for (let i = 0; i < 30; i++) {
665
+ const id = pick(ids);
666
+ const type = pick(["create", "update", "delete", "update"] as const);
667
+ if (type === "create") {
668
+ if (rnd() < 0.5) {
669
+ // Generated id — the only kind that may cancel out
670
+ // against a later delete.
671
+ const row = await posts.create({ title: `c${i}` });
672
+ ids.push(String(row.id));
673
+ oracle.set(String(row.id), { title: `c${i}`, id: row.id });
674
+ } else {
675
+ await posts.create({ title: `c${i}` }, id);
676
+ oracle.set(id, { title: `c${i}`, id });
677
+ }
678
+ } else if (type === "update") {
679
+ await posts.update(id, { v: i });
680
+ const existing = oracle.get(id);
681
+ if (existing) oracle.set(id, { ...existing, v: i });
682
+ } else {
683
+ await posts.delete(id);
684
+ oracle.delete(id);
685
+ }
686
+ }
687
+
688
+ server.state.online = true;
689
+ const { remaining } = await manager.sync();
690
+ expect(remaining).toBe(0);
691
+
692
+ const actual = Object.fromEntries(server.table("posts"));
693
+ const expected = Object.fromEntries(oracle);
694
+ expect(actual).toEqual(expected);
695
+ manager.dispose();
696
+ }
697
+ });
698
+
699
+ it("keeps replay order across a restart", async () => {
700
+ const server = createFakeServer();
701
+ const store = new MemoryOfflineStore();
702
+ const first = createManager(server, { store });
703
+ server.state.online = false;
704
+ await first.wrap("posts").create({ title: "first" }, "p1");
705
+ first.manager.dispose();
706
+
707
+ const second = createManager(server, { store });
708
+ await second.wrap("posts").create({ title: "second" }, "p2");
709
+
710
+ const pending = await second.manager.api.pending();
711
+ expect(pending.map((m) => m.id)).toEqual(["p1", "p2"]);
712
+ expect(pending[0].mutationId < pending[1].mutationId).toBe(true);
713
+
714
+ server.state.online = true;
715
+ await second.manager.sync();
716
+ expect(server.log.map((l) => l.id)).toEqual(["p1", "p2"]);
717
+ });
718
+ });
719
+ });