@pylonsync/sync 0.22.47 → 0.22.58
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 +171 -2
- package/dist/multi-tab-orchestrator.d.ts +4 -0
- package/dist/mutation-queue.d.ts +62 -1
- package/dist/test-harness/env.d.ts +2 -0
- package/dist/test-harness/server.d.ts +104 -0
- package/dist/types.d.ts +10 -0
- package/dist/upload-queue.d.ts +190 -0
- package/dist/upload.d.ts +37 -0
- package/package.json +1 -1
- package/src/first-sync-requests.test.ts +147 -0
- package/src/index.ts +598 -58
- package/src/multi-tab-orchestrator.test.ts +2 -0
- package/src/multi-tab-orchestrator.ts +13 -0
- package/src/mutation-queue.ts +162 -7
- package/src/offline-conflict.test.ts +110 -0
- package/src/outbox.test.ts +333 -0
- package/src/session-before-pull.test.ts +51 -0
- package/src/session-dedupe.test.ts +77 -0
- package/src/snapshot-catch-up.test.ts +101 -0
- package/src/test-harness/env.ts +3 -0
- package/src/test-harness/server.ts +145 -0
- package/src/test-harness/transport.ts +26 -0
- package/src/types.ts +10 -0
- package/src/upload-queue.ts +593 -0
- package/src/upload.ts +130 -0
- package/src/uploads.test.ts +222 -0
|
@@ -36,6 +36,8 @@ function makeRig(opts: { isLeader?: boolean } = {}) {
|
|
|
36
36
|
onMutationsAcked: record("mutationsAcked"),
|
|
37
37
|
onMutationsFailed: record("mutationsFailed"),
|
|
38
38
|
onMutationsQueued: record("mutationsQueued"),
|
|
39
|
+
onMutationsRemoved: record("mutationsRemoved"),
|
|
40
|
+
onUploadsChanged: record("uploadsChanged"),
|
|
39
41
|
onSyncedReceived: record("synced"),
|
|
40
42
|
onSyncStatusRequested: record("syncStatusRequested"),
|
|
41
43
|
onBinaryReceived: record("binary"),
|
|
@@ -95,6 +95,10 @@ export interface MultiTabOrchestratorHooks {
|
|
|
95
95
|
/** Leader → followers: the leader's push failed transiently and
|
|
96
96
|
* these op ids stay queued for retry. */
|
|
97
97
|
onMutationsQueued(opIds: string[]): void;
|
|
98
|
+
/** Another tab retried or dismissed these failed writes. */
|
|
99
|
+
onMutationsRemoved(opIds: string[]): void;
|
|
100
|
+
/** Another tab changed the upload queue in storage. */
|
|
101
|
+
onUploadsChanged(): void;
|
|
98
102
|
/** Leader → follower: a binary frame from the WS. Engine routes
|
|
99
103
|
* to its local binary handlers. */
|
|
100
104
|
onBinaryReceived(bytes: Uint8Array): void;
|
|
@@ -404,6 +408,15 @@ export class MultiTabOrchestrator {
|
|
|
404
408
|
if (Array.isArray(opIds)) this.hooks.onMutationsQueued(opIds);
|
|
405
409
|
break;
|
|
406
410
|
}
|
|
411
|
+
case "uploads-changed": {
|
|
412
|
+
this.hooks.onUploadsChanged();
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case "mutations-removed": {
|
|
416
|
+
const opIds = msg.opIds as string[] | undefined;
|
|
417
|
+
if (Array.isArray(opIds)) this.hooks.onMutationsRemoved(opIds);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
407
420
|
case "sub-register": {
|
|
408
421
|
// Follower → leader. Leader-only — no work to do on a follower
|
|
409
422
|
// because it can't act on a peer's request.
|
package/src/mutation-queue.ts
CHANGED
|
@@ -55,6 +55,9 @@ export interface PendingMutation {
|
|
|
55
55
|
error?: string;
|
|
56
56
|
/** Server error code for a failed mutation, when the server sent one. */
|
|
57
57
|
errorCode?: string;
|
|
58
|
+
/** When the write was made, in ms since the epoch. Missing on writes
|
|
59
|
+
* queued by an older client; those sort first. */
|
|
60
|
+
createdAt?: number;
|
|
58
61
|
/** User id the write was made as (`null` = anonymous). Persisted with
|
|
59
62
|
* the queue so a write is only ever pushed as the user who made it.
|
|
60
63
|
* `undefined` = unknown (queued by an older client). */
|
|
@@ -71,6 +74,49 @@ export interface PendingMutation {
|
|
|
71
74
|
prevRow?: Row | null;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/** One write as the outbox shows it. */
|
|
78
|
+
export interface OutboxItem {
|
|
79
|
+
/** Op id. Pass it to `retryWrite` / `dismissWrite`. */
|
|
80
|
+
id: string;
|
|
81
|
+
entity: string;
|
|
82
|
+
rowId: string;
|
|
83
|
+
/** `"upload"` is a file from `queueUpload`; `entity`/`rowId` is the
|
|
84
|
+
* record it attaches to. */
|
|
85
|
+
kind: "insert" | "update" | "delete" | "upload";
|
|
86
|
+
/** The fields the write sets. Empty for a delete. For an upload:
|
|
87
|
+
* `field`, `urlField`, `filename`, `contentType`, `size`. */
|
|
88
|
+
data: Row;
|
|
89
|
+
/** When the write was made, in ms since the epoch (0 when unknown). */
|
|
90
|
+
createdAt: number;
|
|
91
|
+
/** The server's message, for a failed write. */
|
|
92
|
+
error?: string;
|
|
93
|
+
/** The server's error code (`POLICY_DENIED`, `VALIDATION_FAILED`, ...),
|
|
94
|
+
* for a failed write. */
|
|
95
|
+
errorCode?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Writes not yet on the server, and writes the server rejected. */
|
|
99
|
+
export interface Outbox {
|
|
100
|
+
pending: OutboxItem[];
|
|
101
|
+
failed: OutboxItem[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function toOutboxItem(m: PendingMutation): OutboxItem {
|
|
105
|
+
const item: OutboxItem = {
|
|
106
|
+
id: m.id,
|
|
107
|
+
entity: m.change.entity,
|
|
108
|
+
rowId: m.change.row_id,
|
|
109
|
+
kind: m.change.kind,
|
|
110
|
+
data: { ...(m.change.data ?? {}) },
|
|
111
|
+
createdAt: m.createdAt ?? 0,
|
|
112
|
+
};
|
|
113
|
+
if (m.status === "failed") {
|
|
114
|
+
if (m.error !== undefined) item.error = m.error;
|
|
115
|
+
if (m.errorCode !== undefined) item.errorCode = m.errorCode;
|
|
116
|
+
}
|
|
117
|
+
return item;
|
|
118
|
+
}
|
|
119
|
+
|
|
74
120
|
/**
|
|
75
121
|
* Optional persistence backend. The default IndexedDB persistence
|
|
76
122
|
* layer supports incremental writes with `saveChanges`. Custom backends
|
|
@@ -94,11 +140,32 @@ export class MutationQueue {
|
|
|
94
140
|
/** Callers waiting for a mutation's outcome, by op id. Memory-only:
|
|
95
141
|
* a promise cannot outlive the page that created it. */
|
|
96
142
|
private waiters = new Map<string, OutcomeWaiter[]>();
|
|
143
|
+
/** Called after every change to what the queue holds or to a write's
|
|
144
|
+
* status. The engine turns it into a store notification so the outbox
|
|
145
|
+
* re-renders. */
|
|
146
|
+
private changeListener: (() => void) | null = null;
|
|
147
|
+
/** Bumped on every change; lets readers cache a snapshot. */
|
|
148
|
+
private _version = 0;
|
|
97
149
|
|
|
98
150
|
constructor(persistence?: MutationQueuePersistence) {
|
|
99
151
|
this.persistence = persistence;
|
|
100
152
|
}
|
|
101
153
|
|
|
154
|
+
/** Set the one listener called after each queue change. */
|
|
155
|
+
setChangeListener(listener: (() => void) | null): void {
|
|
156
|
+
this.changeListener = listener;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Changes so far. Equal versions mean the same contents. */
|
|
160
|
+
version(): number {
|
|
161
|
+
return this._version;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private changed(): void {
|
|
165
|
+
this._version += 1;
|
|
166
|
+
this.changeListener?.();
|
|
167
|
+
}
|
|
168
|
+
|
|
102
169
|
/**
|
|
103
170
|
* Attach a persistence backend after construction. The SyncEngine
|
|
104
171
|
* swaps in IndexedDB-backed persistence once the DB has opened.
|
|
@@ -125,7 +192,10 @@ export class MutationQueue {
|
|
|
125
192
|
mergedAny = true;
|
|
126
193
|
}
|
|
127
194
|
}
|
|
128
|
-
if (mergedAny)
|
|
195
|
+
if (mergedAny) {
|
|
196
|
+
this.flush();
|
|
197
|
+
this.changed();
|
|
198
|
+
}
|
|
129
199
|
} catch (err) {
|
|
130
200
|
// Broken storage shouldn't prevent the app from running — warn
|
|
131
201
|
// and degrade to memory-only mode.
|
|
@@ -146,18 +216,26 @@ export class MutationQueue {
|
|
|
146
216
|
prevRow?: Row | null,
|
|
147
217
|
owner?: string | null,
|
|
148
218
|
ownerPending?: boolean,
|
|
219
|
+
createdAt?: number,
|
|
149
220
|
): string {
|
|
150
221
|
const id =
|
|
151
222
|
typeof change.op_id === "string" && change.op_id.length > 0
|
|
152
223
|
? change.op_id
|
|
153
|
-
:
|
|
224
|
+
: newOpId();
|
|
154
225
|
if (this.queue.has(id)) return id;
|
|
155
226
|
const changeWithOp: ClientChange = { ...change, op_id: id };
|
|
156
|
-
const entry: PendingMutation = {
|
|
227
|
+
const entry: PendingMutation = {
|
|
228
|
+
id,
|
|
229
|
+
change: changeWithOp,
|
|
230
|
+
status: "pending",
|
|
231
|
+
prevRow,
|
|
232
|
+
createdAt: createdAt ?? Date.now(),
|
|
233
|
+
};
|
|
157
234
|
if (owner !== undefined) entry.owner = owner;
|
|
158
235
|
if (ownerPending) entry.ownerPending = true;
|
|
159
236
|
this.queue.set(id, entry);
|
|
160
237
|
this.flush([entry]);
|
|
238
|
+
this.changed();
|
|
161
239
|
return id;
|
|
162
240
|
}
|
|
163
241
|
|
|
@@ -169,6 +247,61 @@ export class MutationQueue {
|
|
|
169
247
|
return pending;
|
|
170
248
|
}
|
|
171
249
|
|
|
250
|
+
/** Writes the server rejected, oldest first. They stay until the user
|
|
251
|
+
* retries or dismisses them. */
|
|
252
|
+
failed(): PendingMutation[] {
|
|
253
|
+
return byCreatedAt(Array.from(this.queue.values()).filter((m) => m.status === "failed"));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Queue a failed write again under a new op id, with `data` (when
|
|
258
|
+
* given) merged over the original change's fields. The failed entry is
|
|
259
|
+
* removed in the same persistence write, so a reload never sees both.
|
|
260
|
+
* The new write keeps the original's owner and `createdAt`. `prevRow`
|
|
261
|
+
* is the row before the caller re-applied the write locally, for the
|
|
262
|
+
* rollback if the server rejects it again. Returns the new op id.
|
|
263
|
+
*
|
|
264
|
+
* The server forgets a rejected op id (it never applied it), so the new
|
|
265
|
+
* id cannot be applied alongside the old one.
|
|
266
|
+
*/
|
|
267
|
+
retry(id: string, data: Row | undefined, prevRow: Row | null | undefined): string {
|
|
268
|
+
const m = this.queue.get(id);
|
|
269
|
+
if (!m || m.status !== "failed") {
|
|
270
|
+
throw new Error(`no failed write with id ${id}`);
|
|
271
|
+
}
|
|
272
|
+
const newId = newOpId();
|
|
273
|
+
const change: ClientChange = { ...m.change, op_id: newId };
|
|
274
|
+
if (data !== undefined && m.change.kind !== "delete") {
|
|
275
|
+
change.data = { ...(m.change.data ?? {}), ...data };
|
|
276
|
+
if (m.change.kind === "insert") change.data.id = m.change.row_id;
|
|
277
|
+
}
|
|
278
|
+
const entry: PendingMutation = {
|
|
279
|
+
id: newId,
|
|
280
|
+
change,
|
|
281
|
+
status: "pending",
|
|
282
|
+
prevRow,
|
|
283
|
+
createdAt: m.createdAt,
|
|
284
|
+
};
|
|
285
|
+
if (m.owner !== undefined) entry.owner = m.owner;
|
|
286
|
+
this.queue.delete(id);
|
|
287
|
+
this.queue.set(newId, entry);
|
|
288
|
+
this.flush([entry], [id]);
|
|
289
|
+
this.changed();
|
|
290
|
+
return newId;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Drop a failed write after the user has seen it. Returns false when
|
|
294
|
+
* there is no failed write with that id (pending writes cannot be
|
|
295
|
+
* dismissed: they may already be on the server). */
|
|
296
|
+
dismiss(id: string): boolean {
|
|
297
|
+
const m = this.queue.get(id);
|
|
298
|
+
if (!m || m.status !== "failed") return false;
|
|
299
|
+
this.queue.delete(id);
|
|
300
|
+
this.flush([], [id]);
|
|
301
|
+
this.changed();
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
172
305
|
/** Look up a queued mutation by op id (any status). Used by the
|
|
173
306
|
* follower's mutations-failed handler to reach the captured
|
|
174
307
|
* `prevRow` for rollback. */
|
|
@@ -237,7 +370,10 @@ export class MutationQueue {
|
|
|
237
370
|
changed.push(m);
|
|
238
371
|
}
|
|
239
372
|
}
|
|
240
|
-
if (changed.length > 0)
|
|
373
|
+
if (changed.length > 0) {
|
|
374
|
+
this.flush(changed);
|
|
375
|
+
this.changed();
|
|
376
|
+
}
|
|
241
377
|
for (const id of ids) this.resolveWaiters(id);
|
|
242
378
|
}
|
|
243
379
|
|
|
@@ -248,7 +384,10 @@ export class MutationQueue {
|
|
|
248
384
|
m.error = error;
|
|
249
385
|
if (errorCode) m.errorCode = errorCode;
|
|
250
386
|
}
|
|
251
|
-
if (m)
|
|
387
|
+
if (m) {
|
|
388
|
+
this.flush([m]);
|
|
389
|
+
this.changed();
|
|
390
|
+
}
|
|
252
391
|
const waiters = this.waiters.get(id);
|
|
253
392
|
if (!waiters) return;
|
|
254
393
|
this.waiters.delete(id);
|
|
@@ -283,7 +422,10 @@ export class MutationQueue {
|
|
|
283
422
|
removedIds.push(id);
|
|
284
423
|
}
|
|
285
424
|
}
|
|
286
|
-
if (removedIds.length > 0)
|
|
425
|
+
if (removedIds.length > 0) {
|
|
426
|
+
this.flush([], removedIds);
|
|
427
|
+
this.changed();
|
|
428
|
+
}
|
|
287
429
|
}
|
|
288
430
|
|
|
289
431
|
/** Stamp every write whose owner was pending with the user the first
|
|
@@ -307,6 +449,7 @@ export class MutationQueue {
|
|
|
307
449
|
const m = this.get(id);
|
|
308
450
|
this.queue.delete(id);
|
|
309
451
|
this.flush([], [id]);
|
|
452
|
+
if (m) this.changed();
|
|
310
453
|
const waiters = this.waiters.get(id);
|
|
311
454
|
if (!waiters) return;
|
|
312
455
|
this.waiters.delete(id);
|
|
@@ -326,8 +469,9 @@ export class MutationQueue {
|
|
|
326
469
|
/** Remove a specific mutation by id. Used by the UI after user
|
|
327
470
|
* ack of failures. */
|
|
328
471
|
remove(id: string): void {
|
|
329
|
-
this.queue.delete(id);
|
|
472
|
+
if (!this.queue.delete(id)) return;
|
|
330
473
|
this.flush([], [id]);
|
|
474
|
+
this.changed();
|
|
331
475
|
}
|
|
332
476
|
|
|
333
477
|
/**
|
|
@@ -345,6 +489,7 @@ export class MutationQueue {
|
|
|
345
489
|
const dropped = this.queue;
|
|
346
490
|
this.queue = new Map();
|
|
347
491
|
this.flush();
|
|
492
|
+
if (dropped.size > 0) this.changed();
|
|
348
493
|
// Nobody will push these writes any more. Reject their waiters so a
|
|
349
494
|
// caller awaiting the outcome does not hang.
|
|
350
495
|
for (const m of dropped.values()) {
|
|
@@ -378,6 +523,16 @@ export class MutationQueue {
|
|
|
378
523
|
}
|
|
379
524
|
}
|
|
380
525
|
|
|
526
|
+
/** A new op id. The server deduplicates pushes by it. */
|
|
527
|
+
function newOpId(): string {
|
|
528
|
+
return `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Oldest first; writes without `createdAt` (older clients) first of all. */
|
|
532
|
+
export function byCreatedAt(list: PendingMutation[]): PendingMutation[] {
|
|
533
|
+
return list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
|
534
|
+
}
|
|
535
|
+
|
|
381
536
|
function rejectionFor(m: PendingMutation): MutationRejectedError {
|
|
382
537
|
return new MutationRejectedError(m.error ?? "The server rejected the write.", {
|
|
383
538
|
code: m.errorCode,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// What happens today when two clients edit the same row while offline and
|
|
2
|
+
// then reconnect (Plan 013 step 5). This records the behavior; it does not
|
|
3
|
+
// change it.
|
|
4
|
+
//
|
|
5
|
+
// Result: the server applies each update as a field-level patch in the order
|
|
6
|
+
// the pushes arrive. Different fields both survive. On the same field the
|
|
7
|
+
// last push wins. Both pushes come back "applied", so neither client is told
|
|
8
|
+
// that another edit replaced its value: the earlier writer's edit is
|
|
9
|
+
// overwritten in silence, and its screen changes when the other edit syncs.
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { SyncEngine, type Row } from "./index";
|
|
14
|
+
import type { Storage } from "./storage";
|
|
15
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
16
|
+
|
|
17
|
+
class MemoryStorage implements Storage {
|
|
18
|
+
private map = new Map<string, string>();
|
|
19
|
+
get(key: string) {
|
|
20
|
+
return this.map.get(key) ?? null;
|
|
21
|
+
}
|
|
22
|
+
set(key: string, value: string) {
|
|
23
|
+
this.map.set(key, value);
|
|
24
|
+
}
|
|
25
|
+
remove(key: string) {
|
|
26
|
+
this.map.delete(key);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("two clients edit one row offline", () => {
|
|
31
|
+
let env: TestEnv | null = null;
|
|
32
|
+
let second: SyncEngine | null = null;
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
second?.stop();
|
|
35
|
+
second = null;
|
|
36
|
+
if (env) await env.dispose();
|
|
37
|
+
env = null;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function twoClients() {
|
|
41
|
+
// Client A is the harness engine; client B is a second engine against
|
|
42
|
+
// the same server, signed in as another rep.
|
|
43
|
+
env = createTestEnv({ transport: "poll", appName: "rep-a" });
|
|
44
|
+
env.signIn({ userId: "rep-a" });
|
|
45
|
+
env.server.seed("Lead", [{ id: "l1", status: "new", note: "", phone: "" }]);
|
|
46
|
+
await env.start();
|
|
47
|
+
const storage = new MemoryStorage();
|
|
48
|
+
storage.set("pylon:rep-b:token", env.server.signIn({ userId: "rep-b" }));
|
|
49
|
+
second = new SyncEngine({
|
|
50
|
+
baseUrl: "http://test.invalid",
|
|
51
|
+
appName: "rep-b",
|
|
52
|
+
persist: false,
|
|
53
|
+
storage,
|
|
54
|
+
transport: "poll",
|
|
55
|
+
multiTab: false,
|
|
56
|
+
});
|
|
57
|
+
await second.start();
|
|
58
|
+
await env.flush();
|
|
59
|
+
return { a: env.engine, b: second, server: env.server };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Let each push's catch-up pull finish, then pull the other's edit
|
|
63
|
+
* (on the poll transport nothing pushes it). */
|
|
64
|
+
async function syncBoth(a: SyncEngine, b: SyncEngine) {
|
|
65
|
+
await env!.flush(20);
|
|
66
|
+
await a.pull();
|
|
67
|
+
await b.pull();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function offlineUpdate(engine: SyncEngine, patch: Row) {
|
|
71
|
+
env!.server.primeNextPushOutcome({ kind: "network" });
|
|
72
|
+
await engine.update("Lead", "l1", patch);
|
|
73
|
+
expect(engine.pendingCount()).toBe(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test("different fields: both edits survive", async () => {
|
|
77
|
+
const { a, b, server } = await twoClients();
|
|
78
|
+
await offlineUpdate(a, { status: "sold" });
|
|
79
|
+
await offlineUpdate(b, { note: "dog in yard" });
|
|
80
|
+
|
|
81
|
+
await a.push();
|
|
82
|
+
await b.push();
|
|
83
|
+
await syncBoth(a, b);
|
|
84
|
+
|
|
85
|
+
const [row] = await server.listEntityRows("Lead", env!.token);
|
|
86
|
+
expect(row).toMatchObject({ status: "sold", note: "dog in yard" });
|
|
87
|
+
expect(a.store.get("Lead", "l1")).toMatchObject({ status: "sold", note: "dog in yard" });
|
|
88
|
+
expect(b.store.get("Lead", "l1")).toMatchObject({ status: "sold", note: "dog in yard" });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("same field: the last push wins and neither client is told", async () => {
|
|
92
|
+
const { a, b, server } = await twoClients();
|
|
93
|
+
await offlineUpdate(a, { status: "sold" });
|
|
94
|
+
await offlineUpdate(b, { status: "not interested" });
|
|
95
|
+
|
|
96
|
+
// A reconnects first, then B.
|
|
97
|
+
await a.push();
|
|
98
|
+
await b.push();
|
|
99
|
+
await syncBoth(a, b);
|
|
100
|
+
|
|
101
|
+
const [row] = await server.listEntityRows("Lead", env!.token);
|
|
102
|
+
expect(row).toMatchObject({ status: "not interested" });
|
|
103
|
+
// A's edit was replaced: A now shows B's value...
|
|
104
|
+
expect(a.store.get("Lead", "l1")).toMatchObject({ status: "not interested" });
|
|
105
|
+
// ...and nothing reports it. Both writes were applied, so neither
|
|
106
|
+
// outbox has a failed write.
|
|
107
|
+
expect(a.outbox()).toEqual({ pending: [], failed: [] });
|
|
108
|
+
expect(b.outbox()).toEqual({ pending: [], failed: [] });
|
|
109
|
+
});
|
|
110
|
+
});
|