@rebasepro/client 0.10.0 → 0.10.1-canary.a54c057
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +591 -3
- package/dist/index.es.js.map +1 -1
- package/dist/offline-store.d.ts +109 -0
- package/dist/offline.d.ts +129 -0
- package/package.json +5 -4
- package/src/index.ts +47 -1
- package/src/offline-idb-store.test.ts +151 -0
- package/src/offline-integration.test.ts +134 -0
- package/src/offline-store.ts +232 -0
- package/src/offline.test.ts +688 -0
- package/src/offline.ts +621 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { 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("rethrows the network error when nothing is cached", async () => {
|
|
117
|
+
const server = createFakeServer();
|
|
118
|
+
const { wrap } = createManager(server);
|
|
119
|
+
server.state.online = false;
|
|
120
|
+
await expect(wrap("posts").find()).rejects.toThrow(TypeError);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not swallow server errors into the cache path", async () => {
|
|
124
|
+
const server = createFakeServer();
|
|
125
|
+
const { wrap } = createManager(server);
|
|
126
|
+
const posts = wrap("posts");
|
|
127
|
+
await posts.find(); // caches an empty result
|
|
128
|
+
await expect(posts.update("missing", { title: "x" })).rejects.toThrow(RebaseApiError);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("serves a cached findById and count when offline", async () => {
|
|
132
|
+
const server = createFakeServer();
|
|
133
|
+
const { wrap } = createManager(server);
|
|
134
|
+
const posts = wrap("posts");
|
|
135
|
+
await server.client("posts").create({ title: "hello" }, "p1");
|
|
136
|
+
|
|
137
|
+
expect(await posts.findById("p1")).toMatchObject({ title: "hello" });
|
|
138
|
+
expect(await posts.count()).toBe(1);
|
|
139
|
+
|
|
140
|
+
server.state.online = false;
|
|
141
|
+
expect(await posts.findById("p1")).toMatchObject({ title: "hello" });
|
|
142
|
+
expect(await posts.count()).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("offline writes", () => {
|
|
147
|
+
it("queues an offline create and returns an optimistic row with a generated id", async () => {
|
|
148
|
+
const server = createFakeServer();
|
|
149
|
+
const { manager, wrap } = createManager(server);
|
|
150
|
+
const posts = wrap("posts");
|
|
151
|
+
server.state.online = false;
|
|
152
|
+
|
|
153
|
+
const row = await posts.create({ title: "draft" });
|
|
154
|
+
expect(row.title).toBe("draft");
|
|
155
|
+
expect(typeof row.id).toBe("string");
|
|
156
|
+
|
|
157
|
+
const pending = await manager.api.pending();
|
|
158
|
+
expect(pending).toHaveLength(1);
|
|
159
|
+
expect(pending[0]).toMatchObject({ collection: "posts", type: "create" });
|
|
160
|
+
|
|
161
|
+
// The app immediately sees its own write.
|
|
162
|
+
expect(await posts.findById(row.id as string)).toMatchObject({ title: "draft" });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("overlays queued creates, updates and deletes onto cached finds", async () => {
|
|
166
|
+
const server = createFakeServer();
|
|
167
|
+
const { wrap } = createManager(server);
|
|
168
|
+
const posts = wrap("posts");
|
|
169
|
+
await server.client("posts").create({ title: "a" }, "p1");
|
|
170
|
+
await server.client("posts").create({ title: "b" }, "p2");
|
|
171
|
+
await posts.find();
|
|
172
|
+
await posts.count();
|
|
173
|
+
|
|
174
|
+
server.state.online = false;
|
|
175
|
+
const created = await posts.create({ title: "c" });
|
|
176
|
+
await posts.update("p1", { title: "a2" });
|
|
177
|
+
await posts.delete("p2");
|
|
178
|
+
|
|
179
|
+
const res = await posts.find();
|
|
180
|
+
expect(res.data.map((r) => r.title).sort()).toEqual(["a2", "c"]);
|
|
181
|
+
expect(res.meta.total).toBe(2);
|
|
182
|
+
expect(res.data.find((r) => r.id === created.id)).toBeDefined();
|
|
183
|
+
expect(await posts.count()).toBe(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("never appends queued creates to a filtered query, but still applies updates and deletes", async () => {
|
|
187
|
+
const server = createFakeServer();
|
|
188
|
+
const { wrap } = createManager(server);
|
|
189
|
+
const posts = wrap("posts");
|
|
190
|
+
await server.client("posts").create({ title: "a", status: "draft" }, "p1");
|
|
191
|
+
const filter: FindParams = { where: { status: ["==", "draft"] } };
|
|
192
|
+
await posts.find(filter);
|
|
193
|
+
|
|
194
|
+
server.state.online = false;
|
|
195
|
+
await posts.create({ title: "b", status: "draft" });
|
|
196
|
+
await posts.update("p1", { title: "a2" });
|
|
197
|
+
|
|
198
|
+
const res = await posts.find(filter);
|
|
199
|
+
// The queued create may or may not match the filter — we cannot
|
|
200
|
+
// evaluate it client-side, so it must not be guessed into the result.
|
|
201
|
+
expect(res.data).toHaveLength(1);
|
|
202
|
+
expect(res.data[0].title).toBe("a2");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("reports a pending delete of a cached row as gone", async () => {
|
|
206
|
+
const server = createFakeServer();
|
|
207
|
+
const { wrap } = createManager(server);
|
|
208
|
+
const posts = wrap("posts");
|
|
209
|
+
await server.client("posts").create({ title: "a" }, "p1");
|
|
210
|
+
await posts.findById("p1");
|
|
211
|
+
|
|
212
|
+
server.state.online = false;
|
|
213
|
+
await posts.delete("p1");
|
|
214
|
+
expect(await posts.findById("p1")).toBeUndefined();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("sync", () => {
|
|
219
|
+
it("replays queued mutations in the order they were made", async () => {
|
|
220
|
+
const server = createFakeServer();
|
|
221
|
+
const { manager, wrap } = createManager(server);
|
|
222
|
+
const posts = wrap("posts");
|
|
223
|
+
await server.client("posts").create({ title: "old" }, "p0");
|
|
224
|
+
server.log.length = 0;
|
|
225
|
+
|
|
226
|
+
// Two different rows, so tail coalescing (tested separately)
|
|
227
|
+
// cannot collapse the queue and hide the ordering.
|
|
228
|
+
server.state.online = false;
|
|
229
|
+
const row = await posts.create({ title: "draft" });
|
|
230
|
+
await posts.update("p0", { title: "final" });
|
|
231
|
+
|
|
232
|
+
server.state.online = true;
|
|
233
|
+
const result = await manager.sync();
|
|
234
|
+
expect(result).toEqual({ flushed: 2, remaining: 0 });
|
|
235
|
+
expect(server.log.map((l) => l.op)).toEqual(["create", "update"]);
|
|
236
|
+
expect(server.table("posts").get(String(row.id))).toMatchObject({ title: "draft" });
|
|
237
|
+
expect(server.table("posts").get("p0")).toMatchObject({ title: "final" });
|
|
238
|
+
expect(await manager.api.pending()).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("keeps the queue when the network is still down", async () => {
|
|
242
|
+
const server = createFakeServer();
|
|
243
|
+
const { manager, wrap } = createManager(server);
|
|
244
|
+
server.state.online = false;
|
|
245
|
+
await wrap("posts").create({ title: "draft" });
|
|
246
|
+
|
|
247
|
+
const result = await manager.sync();
|
|
248
|
+
expect(result).toEqual({ flushed: 0, remaining: 1 });
|
|
249
|
+
expect(await manager.api.pending()).toHaveLength(1);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("drops a server-rejected mutation, reports it, and keeps flushing", async () => {
|
|
253
|
+
const server = createFakeServer();
|
|
254
|
+
const onSyncError = jest.fn<(error: Error, mutation: PendingMutation) => void>();
|
|
255
|
+
const { manager, wrap } = createManager(server, { onSyncError });
|
|
256
|
+
const posts = wrap("posts");
|
|
257
|
+
|
|
258
|
+
server.state.online = false;
|
|
259
|
+
await posts.update("does-not-exist", { title: "x" }); // will 404 on replay
|
|
260
|
+
await posts.create({ title: "kept" }, "p9");
|
|
261
|
+
|
|
262
|
+
server.state.online = true;
|
|
263
|
+
const result = await manager.sync();
|
|
264
|
+
expect(result).toEqual({ flushed: 1, remaining: 0 });
|
|
265
|
+
expect(onSyncError).toHaveBeenCalledTimes(1);
|
|
266
|
+
expect(onSyncError.mock.calls[0][1]).toMatchObject({ type: "update", id: "does-not-exist" });
|
|
267
|
+
expect(server.table("posts").get("p9")).toMatchObject({ title: "kept" });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("notifies queue-size listeners as writes queue and drain", async () => {
|
|
271
|
+
const server = createFakeServer();
|
|
272
|
+
const { manager, wrap } = createManager(server);
|
|
273
|
+
const counts: number[] = [];
|
|
274
|
+
manager.api.onQueueChange((n) => counts.push(n));
|
|
275
|
+
|
|
276
|
+
server.state.online = false;
|
|
277
|
+
await wrap("posts").create({ title: "a" });
|
|
278
|
+
server.state.online = true;
|
|
279
|
+
await manager.sync();
|
|
280
|
+
|
|
281
|
+
expect(counts).toContain(1);
|
|
282
|
+
expect(counts[counts.length - 1]).toBe(0);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("per-user scoping", () => {
|
|
287
|
+
it("a scope switch during a slow queue load cannot leak the old user's queue", async () => {
|
|
288
|
+
const server = createFakeServer();
|
|
289
|
+
const store = new MemoryOfflineStore();
|
|
290
|
+
|
|
291
|
+
// Seed user A's persisted queue from a previous "session".
|
|
292
|
+
const seeder = createManager(server, { store });
|
|
293
|
+
seeder.manager.setScope("user-a");
|
|
294
|
+
server.state.online = false;
|
|
295
|
+
await seeder.wrap("posts").create({ title: "a-op" }, "pa");
|
|
296
|
+
seeder.manager.dispose();
|
|
297
|
+
|
|
298
|
+
// A store whose queue load resolves only when released — the
|
|
299
|
+
// scope switch lands while user A's load is still in flight.
|
|
300
|
+
let release!: () => void;
|
|
301
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
302
|
+
const slowStore = Object.create(store) as MemoryOfflineStore;
|
|
303
|
+
slowStore.listQueue = async (prefix: string) => {
|
|
304
|
+
if (prefix === "user-a|") await gate;
|
|
305
|
+
return store.listQueue(prefix);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const { manager } = createManager(server, { store: slowStore as MemoryOfflineStore });
|
|
309
|
+
manager.setScope("user-a");
|
|
310
|
+
const stalled = manager.api.pending(); // starts the gated load
|
|
311
|
+
manager.setScope("user-b");
|
|
312
|
+
const pendingB = await manager.api.pending();
|
|
313
|
+
release();
|
|
314
|
+
await stalled.catch(() => undefined);
|
|
315
|
+
|
|
316
|
+
expect(pendingB).toHaveLength(0);
|
|
317
|
+
expect(await manager.api.pending()).toHaveLength(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("never serves one user's cache or queue to another", async () => {
|
|
321
|
+
const server = createFakeServer();
|
|
322
|
+
const store = new MemoryOfflineStore();
|
|
323
|
+
const { manager, wrap } = createManager(server, { store });
|
|
324
|
+
const posts = wrap("posts");
|
|
325
|
+
|
|
326
|
+
manager.setScope("user-a");
|
|
327
|
+
await server.client("posts").create({ title: "a-private" }, "p1");
|
|
328
|
+
await posts.find();
|
|
329
|
+
server.state.online = false;
|
|
330
|
+
await posts.create({ title: "a-pending" });
|
|
331
|
+
expect(await manager.api.pending()).toHaveLength(1);
|
|
332
|
+
|
|
333
|
+
manager.setScope("user-b");
|
|
334
|
+
expect(await manager.api.pending()).toHaveLength(0);
|
|
335
|
+
await expect(posts.find()).rejects.toThrow(TypeError);
|
|
336
|
+
|
|
337
|
+
// Coming back, user A finds both again.
|
|
338
|
+
manager.setScope("user-a");
|
|
339
|
+
expect(await manager.api.pending()).toHaveLength(1);
|
|
340
|
+
expect((await posts.find()).data.some((r) => r.title === "a-private")).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe("coalescing", () => {
|
|
345
|
+
it("merges an update into the queued create it edits", async () => {
|
|
346
|
+
const server = createFakeServer();
|
|
347
|
+
const { manager, wrap } = createManager(server);
|
|
348
|
+
const posts = wrap("posts");
|
|
349
|
+
server.state.online = false;
|
|
350
|
+
|
|
351
|
+
const row = await posts.create({ title: "draft", status: "new" });
|
|
352
|
+
await posts.update(row.id as string, { title: "draft 2" });
|
|
353
|
+
await posts.update(row.id as string, { title: "draft 3" });
|
|
354
|
+
|
|
355
|
+
const pending = await manager.api.pending();
|
|
356
|
+
expect(pending).toHaveLength(1);
|
|
357
|
+
expect(pending[0]).toMatchObject({ type: "create" });
|
|
358
|
+
expect(pending[0].data).toMatchObject({ title: "draft 3", status: "new", id: row.id });
|
|
359
|
+
|
|
360
|
+
server.state.online = true;
|
|
361
|
+
await manager.sync();
|
|
362
|
+
expect(server.log).toHaveLength(1);
|
|
363
|
+
expect(server.table("posts").get(String(row.id))).toMatchObject({ title: "draft 3" });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("merges consecutive updates to the same row", async () => {
|
|
367
|
+
const server = createFakeServer();
|
|
368
|
+
const { manager, wrap } = createManager(server);
|
|
369
|
+
const posts = wrap("posts");
|
|
370
|
+
await server.client("posts").create({ title: "a", views: 0 }, "p1");
|
|
371
|
+
server.state.online = false;
|
|
372
|
+
|
|
373
|
+
await posts.update("p1", { title: "b" });
|
|
374
|
+
await posts.update("p1", { views: 1 });
|
|
375
|
+
|
|
376
|
+
const pending = await manager.api.pending();
|
|
377
|
+
expect(pending).toHaveLength(1);
|
|
378
|
+
expect(pending[0].data).toMatchObject({ title: "b", views: 1 });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("never merges across an op for a different row — order is preserved", async () => {
|
|
382
|
+
const server = createFakeServer();
|
|
383
|
+
const { manager, wrap } = createManager(server);
|
|
384
|
+
const posts = wrap("posts");
|
|
385
|
+
await server.client("posts").create({ title: "a" }, "pa");
|
|
386
|
+
await server.client("posts").create({ title: "b" }, "pb");
|
|
387
|
+
server.log.length = 0;
|
|
388
|
+
server.state.online = false;
|
|
389
|
+
|
|
390
|
+
await posts.update("pa", { title: "a1" });
|
|
391
|
+
await posts.update("pb", { title: "b1" });
|
|
392
|
+
await posts.update("pa", { title: "a2" });
|
|
393
|
+
|
|
394
|
+
expect(await manager.api.pending()).toHaveLength(3);
|
|
395
|
+
server.state.online = true;
|
|
396
|
+
await manager.sync();
|
|
397
|
+
expect(server.log.map((l) => l.id)).toEqual(["pa", "pb", "pa"]);
|
|
398
|
+
expect(server.table("posts").get("pa")).toMatchObject({ title: "a2" });
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("cancels a queued create (and its updates) when the row is deleted offline", async () => {
|
|
402
|
+
const server = createFakeServer();
|
|
403
|
+
const { manager, wrap } = createManager(server);
|
|
404
|
+
const posts = wrap("posts");
|
|
405
|
+
server.state.online = false;
|
|
406
|
+
|
|
407
|
+
const row = await posts.create({ title: "ephemeral" });
|
|
408
|
+
await posts.update(row.id as string, { title: "edited" });
|
|
409
|
+
await posts.delete(row.id as string);
|
|
410
|
+
|
|
411
|
+
expect(await manager.api.pending()).toHaveLength(0);
|
|
412
|
+
server.state.online = true;
|
|
413
|
+
expect(await manager.sync()).toEqual({ flushed: 0, remaining: 0 });
|
|
414
|
+
expect(server.log).toHaveLength(0);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("does not cancel out a create that reused a caller-supplied id — the delete must reach the server", async () => {
|
|
418
|
+
const server = createFakeServer();
|
|
419
|
+
const { manager, wrap } = createManager(server);
|
|
420
|
+
const posts = wrap("posts");
|
|
421
|
+
await server.client("posts").create({ title: "original" }, "p1");
|
|
422
|
+
server.log.length = 0;
|
|
423
|
+
server.state.online = false;
|
|
424
|
+
|
|
425
|
+
// The offline create's id names a row the server already has, so
|
|
426
|
+
// dropping create+delete as a no-op pair would resurrect it.
|
|
427
|
+
await posts.create({ title: "replacement" }, "p1");
|
|
428
|
+
await posts.delete("p1");
|
|
429
|
+
|
|
430
|
+
expect(await manager.api.pending()).toHaveLength(2);
|
|
431
|
+
server.state.online = true;
|
|
432
|
+
await manager.sync();
|
|
433
|
+
expect(server.table("posts").has("p1")).toBe(false);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("still queues the delete of a row the server already has", async () => {
|
|
437
|
+
const server = createFakeServer();
|
|
438
|
+
const { manager, wrap } = createManager(server);
|
|
439
|
+
const posts = wrap("posts");
|
|
440
|
+
await server.client("posts").create({ title: "a" }, "p1");
|
|
441
|
+
server.log.length = 0;
|
|
442
|
+
server.state.online = false;
|
|
443
|
+
|
|
444
|
+
await posts.update("p1", { title: "a1" });
|
|
445
|
+
await posts.delete("p1");
|
|
446
|
+
|
|
447
|
+
// No pending create for p1, so nothing cancels: the update and
|
|
448
|
+
// the delete replay in order.
|
|
449
|
+
expect(await manager.api.pending()).toHaveLength(2);
|
|
450
|
+
server.state.online = true;
|
|
451
|
+
await manager.sync();
|
|
452
|
+
expect(server.log.map((l) => l.op)).toEqual(["update", "delete"]);
|
|
453
|
+
expect(server.table("posts").has("p1")).toBe(false);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe("createMany", () => {
|
|
458
|
+
it("queues a bulk create offline, overlays it, and replays it with its upsert flag", async () => {
|
|
459
|
+
const server = createFakeServer();
|
|
460
|
+
const { manager, wrap } = createManager(server);
|
|
461
|
+
const posts = wrap("posts");
|
|
462
|
+
await posts.find();
|
|
463
|
+
server.state.online = false;
|
|
464
|
+
|
|
465
|
+
const rows = await posts.createMany([{ title: "a" }, { title: "b" }], { upsert: true });
|
|
466
|
+
expect(rows).toHaveLength(2);
|
|
467
|
+
expect(rows.every((r) => typeof r.id === "string")).toBe(true);
|
|
468
|
+
|
|
469
|
+
const overlaid = await posts.find();
|
|
470
|
+
expect(overlaid.data.map((r) => r.title)).toEqual(["a", "b"]);
|
|
471
|
+
expect(overlaid.meta.total).toBe(2);
|
|
472
|
+
expect(await posts.findById(rows[0].id as string)).toMatchObject({ title: "a" });
|
|
473
|
+
|
|
474
|
+
server.state.online = true;
|
|
475
|
+
await manager.sync();
|
|
476
|
+
expect(server.log).toEqual([{ collection: "posts", op: "createMany", upsert: true }]);
|
|
477
|
+
expect(server.table("posts").size).toBe(2);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe("concurrency", () => {
|
|
482
|
+
it("gives concurrent offline writes distinct, ordered sequence numbers", async () => {
|
|
483
|
+
const server = createFakeServer();
|
|
484
|
+
const { manager, wrap } = createManager(server);
|
|
485
|
+
const posts = wrap("posts");
|
|
486
|
+
server.state.online = false;
|
|
487
|
+
|
|
488
|
+
await Promise.all([
|
|
489
|
+
posts.create({ title: "one" }, "c1"),
|
|
490
|
+
posts.create({ title: "two" }, "c2"),
|
|
491
|
+
posts.create({ title: "three" }, "c3")
|
|
492
|
+
]);
|
|
493
|
+
|
|
494
|
+
const pending = await manager.api.pending();
|
|
495
|
+
expect(pending.map((m) => m.id)).toEqual(["c1", "c2", "c3"]);
|
|
496
|
+
const seqs = pending.map((m) => m.seq);
|
|
497
|
+
expect([...new Set(seqs)]).toHaveLength(3);
|
|
498
|
+
expect(seqs).toEqual([...seqs].sort((a, b) => a - b));
|
|
499
|
+
|
|
500
|
+
server.state.online = true;
|
|
501
|
+
await manager.sync();
|
|
502
|
+
expect(server.log.map((l) => l.id)).toEqual(["c1", "c2", "c3"]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("coalesces concurrent sync calls into one flush", async () => {
|
|
506
|
+
const server = createFakeServer();
|
|
507
|
+
const { manager, wrap } = createManager(server);
|
|
508
|
+
server.state.online = false;
|
|
509
|
+
await wrap("posts").create({ title: "a" }, "p1");
|
|
510
|
+
|
|
511
|
+
server.state.online = true;
|
|
512
|
+
const [first, second] = await Promise.all([manager.sync(), manager.sync()]);
|
|
513
|
+
expect(first).toBe(second);
|
|
514
|
+
expect(server.log).toHaveLength(1);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
describe("cache maintenance", () => {
|
|
519
|
+
it("evicts the least recently written queries beyond the per-collection cap", async () => {
|
|
520
|
+
const server = createFakeServer();
|
|
521
|
+
const store = new MemoryOfflineStore();
|
|
522
|
+
const manager = new OfflineManager(
|
|
523
|
+
{ store, syncIntervalMs: 0, maxCachedQueriesPerCollection: 2 },
|
|
524
|
+
(slug) => server.client(slug)
|
|
525
|
+
);
|
|
526
|
+
const posts = manager.wrap("posts", server.client("posts"));
|
|
527
|
+
await server.client("posts").create({ title: "a" }, "p1");
|
|
528
|
+
|
|
529
|
+
await posts.find({ limit: 1 });
|
|
530
|
+
await posts.find({ limit: 2 });
|
|
531
|
+
await posts.find({ limit: 3 });
|
|
532
|
+
|
|
533
|
+
server.state.online = false;
|
|
534
|
+
await expect(posts.find({ limit: 1 })).rejects.toThrow(TypeError);
|
|
535
|
+
expect((await posts.find({ limit: 2 })).data).toHaveLength(1);
|
|
536
|
+
expect((await posts.find({ limit: 3 })).data).toHaveLength(1);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("eviction never touches another collection's queries or cached rows", async () => {
|
|
540
|
+
const server = createFakeServer();
|
|
541
|
+
const store = new MemoryOfflineStore();
|
|
542
|
+
const manager = new OfflineManager(
|
|
543
|
+
{ store, syncIntervalMs: 0, maxCachedQueriesPerCollection: 1 },
|
|
544
|
+
(slug) => server.client(slug)
|
|
545
|
+
);
|
|
546
|
+
const posts = manager.wrap("posts", server.client("posts"));
|
|
547
|
+
const users = manager.wrap("users", server.client("users"));
|
|
548
|
+
await server.client("posts").create({ title: "a" }, "p1");
|
|
549
|
+
await server.client("users").create({ name: "u" }, "u1");
|
|
550
|
+
|
|
551
|
+
await users.find();
|
|
552
|
+
await posts.findById("p1");
|
|
553
|
+
await posts.find({ limit: 1 });
|
|
554
|
+
await posts.find({ limit: 2 }); // evicts posts {limit:1}, nothing else
|
|
555
|
+
|
|
556
|
+
server.state.online = false;
|
|
557
|
+
expect((await users.find()).data).toHaveLength(1);
|
|
558
|
+
expect(await posts.findById("p1")).toMatchObject({ title: "a" });
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("clear() drops the queue and the cache together", async () => {
|
|
562
|
+
const server = createFakeServer();
|
|
563
|
+
const { manager, wrap } = createManager(server);
|
|
564
|
+
const posts = wrap("posts");
|
|
565
|
+
await server.client("posts").create({ title: "a" }, "p1");
|
|
566
|
+
server.log.length = 0;
|
|
567
|
+
await posts.find();
|
|
568
|
+
|
|
569
|
+
server.state.online = false;
|
|
570
|
+
await posts.create({ title: "pending" });
|
|
571
|
+
await manager.api.clear();
|
|
572
|
+
|
|
573
|
+
expect(await manager.api.pending()).toHaveLength(0);
|
|
574
|
+
await expect(posts.find()).rejects.toThrow(TypeError);
|
|
575
|
+
|
|
576
|
+
server.state.online = true;
|
|
577
|
+
expect(await manager.sync()).toEqual({ flushed: 0, remaining: 0 });
|
|
578
|
+
expect(server.log).toHaveLength(0);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("an online update refreshes the cached row", async () => {
|
|
582
|
+
const server = createFakeServer();
|
|
583
|
+
const { wrap } = createManager(server);
|
|
584
|
+
const posts = wrap("posts");
|
|
585
|
+
await server.client("posts").create({ title: "old" }, "p1");
|
|
586
|
+
await posts.findById("p1");
|
|
587
|
+
await posts.update("p1", { title: "new" });
|
|
588
|
+
|
|
589
|
+
server.state.online = false;
|
|
590
|
+
expect(await posts.findById("p1")).toMatchObject({ title: "new" });
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
describe("survives a restart", () => {
|
|
595
|
+
it("replays a queue persisted by a previous manager instance", async () => {
|
|
596
|
+
const server = createFakeServer();
|
|
597
|
+
const store = new MemoryOfflineStore();
|
|
598
|
+
const first = createManager(server, { store });
|
|
599
|
+
server.state.online = false;
|
|
600
|
+
await first.wrap("posts").create({ title: "from-last-session" }, "p1");
|
|
601
|
+
first.manager.dispose();
|
|
602
|
+
|
|
603
|
+
server.state.online = true;
|
|
604
|
+
const second = createManager(server, { store });
|
|
605
|
+
const result = await second.manager.sync();
|
|
606
|
+
expect(result).toEqual({ flushed: 1, remaining: 0 });
|
|
607
|
+
expect(server.table("posts").get("p1")).toMatchObject({ title: "from-last-session" });
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("randomized: coalescing + replay always ends in the state of a verbatim replay", async () => {
|
|
611
|
+
// The invariant that keeps coalescing honest: for ANY offline op
|
|
612
|
+
// sequence, syncing the (coalesced) queue must leave the server in
|
|
613
|
+
// exactly the state of applying every op one-by-one, in order.
|
|
614
|
+
// The oracle applies server semantics directly: create overwrites,
|
|
615
|
+
// update on a missing row is rejected (a dropped 404 at replay),
|
|
616
|
+
// delete of a missing row is a no-op.
|
|
617
|
+
for (let seed = 1; seed <= 20; seed++) {
|
|
618
|
+
let s = seed;
|
|
619
|
+
const rnd = () => (s = (s * 48271) % 2147483647) / 2147483647;
|
|
620
|
+
const pick = <T,>(arr: T[]): T => arr[Math.floor(rnd() * arr.length)];
|
|
621
|
+
|
|
622
|
+
const server = createFakeServer();
|
|
623
|
+
const { manager, wrap } = createManager(server, { onSyncError: () => undefined });
|
|
624
|
+
const posts = wrap("posts");
|
|
625
|
+
await server.client("posts").create({ title: "seed-1" }, "r1");
|
|
626
|
+
await server.client("posts").create({ title: "seed-2" }, "r2");
|
|
627
|
+
|
|
628
|
+
const oracle = new Map<string, Row>();
|
|
629
|
+
for (const [id, row] of server.table("posts")) oracle.set(id, { ...row });
|
|
630
|
+
|
|
631
|
+
server.state.online = false;
|
|
632
|
+
const ids = ["r1", "r2", "r3", "r4", "r5"];
|
|
633
|
+
for (let i = 0; i < 30; i++) {
|
|
634
|
+
const id = pick(ids);
|
|
635
|
+
const type = pick(["create", "update", "delete", "update"] as const);
|
|
636
|
+
if (type === "create") {
|
|
637
|
+
if (rnd() < 0.5) {
|
|
638
|
+
// Generated id — the only kind that may cancel out
|
|
639
|
+
// against a later delete.
|
|
640
|
+
const row = await posts.create({ title: `c${i}` });
|
|
641
|
+
ids.push(String(row.id));
|
|
642
|
+
oracle.set(String(row.id), { title: `c${i}`, id: row.id });
|
|
643
|
+
} else {
|
|
644
|
+
await posts.create({ title: `c${i}` }, id);
|
|
645
|
+
oracle.set(id, { title: `c${i}`, id });
|
|
646
|
+
}
|
|
647
|
+
} else if (type === "update") {
|
|
648
|
+
await posts.update(id, { v: i });
|
|
649
|
+
const existing = oracle.get(id);
|
|
650
|
+
if (existing) oracle.set(id, { ...existing, v: i });
|
|
651
|
+
} else {
|
|
652
|
+
await posts.delete(id);
|
|
653
|
+
oracle.delete(id);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
server.state.online = true;
|
|
658
|
+
const { remaining } = await manager.sync();
|
|
659
|
+
expect(remaining).toBe(0);
|
|
660
|
+
|
|
661
|
+
const actual = Object.fromEntries(server.table("posts"));
|
|
662
|
+
const expected = Object.fromEntries(oracle);
|
|
663
|
+
expect(actual).toEqual(expected);
|
|
664
|
+
manager.dispose();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("continues sequence numbers after a restart, keeping replay order", async () => {
|
|
669
|
+
const server = createFakeServer();
|
|
670
|
+
const store = new MemoryOfflineStore();
|
|
671
|
+
const first = createManager(server, { store });
|
|
672
|
+
server.state.online = false;
|
|
673
|
+
await first.wrap("posts").create({ title: "first" }, "p1");
|
|
674
|
+
first.manager.dispose();
|
|
675
|
+
|
|
676
|
+
const second = createManager(server, { store });
|
|
677
|
+
await second.wrap("posts").create({ title: "second" }, "p2");
|
|
678
|
+
|
|
679
|
+
const pending = await second.manager.api.pending();
|
|
680
|
+
expect(pending.map((m) => m.id)).toEqual(["p1", "p2"]);
|
|
681
|
+
expect(pending[0].seq).toBeLessThan(pending[1].seq);
|
|
682
|
+
|
|
683
|
+
server.state.online = true;
|
|
684
|
+
await second.manager.sync();
|
|
685
|
+
expect(server.log.map((l) => l.id)).toEqual(["p1", "p2"]);
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
});
|