@dxos/echo-pipeline 0.6.5 → 0.6.6-staging.23d123d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/lib/browser/{chunk-2MII6KJX.mjs → chunk-P6XSIJKM.mjs} +2184 -2132
  2. package/dist/lib/browser/chunk-P6XSIJKM.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +1 -1
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/testing/index.mjs +8 -8
  6. package/dist/lib/browser/testing/index.mjs.map +3 -3
  7. package/dist/lib/node/{chunk-6MWU4MHX.cjs → chunk-IYTGTZ7D.cjs} +2177 -2125
  8. package/dist/lib/node/chunk-IYTGTZ7D.cjs.map +7 -0
  9. package/dist/lib/node/index.cjs +35 -35
  10. package/dist/lib/node/index.cjs.map +1 -1
  11. package/dist/lib/node/meta.json +1 -1
  12. package/dist/lib/node/testing/index.cjs +18 -18
  13. package/dist/lib/node/testing/index.cjs.map +3 -3
  14. package/dist/types/src/automerge/automerge-host.d.ts +1 -0
  15. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  16. package/dist/types/src/automerge/echo-network-adapter.d.ts +2 -1
  17. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
  18. package/dist/types/src/automerge/echo-replicator.d.ts +10 -1
  19. package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
  20. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
  21. package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
  22. package/dist/types/src/automerge/network-protocol.d.ts +3 -28
  23. package/dist/types/src/automerge/network-protocol.d.ts.map +1 -1
  24. package/dist/types/src/space/space-manager.d.ts +3 -1
  25. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  26. package/dist/types/src/space/space-protocol.d.ts +10 -3
  27. package/dist/types/src/space/space-protocol.d.ts.map +1 -1
  28. package/dist/types/src/space/space.d.ts.map +1 -1
  29. package/dist/types/src/testing/test-network-adapter.d.ts +2 -1
  30. package/dist/types/src/testing/test-network-adapter.d.ts.map +1 -1
  31. package/package.json +33 -33
  32. package/src/automerge/automerge-host.ts +13 -1
  33. package/src/automerge/automerge-repo.test.ts +380 -366
  34. package/src/automerge/echo-network-adapter.test.ts +1 -0
  35. package/src/automerge/echo-network-adapter.ts +8 -0
  36. package/src/automerge/echo-replicator.ts +11 -1
  37. package/src/automerge/mesh-echo-replicator-connection.ts +18 -0
  38. package/src/automerge/mesh-echo-replicator.ts +10 -2
  39. package/src/automerge/network-protocol.ts +8 -34
  40. package/src/space/space-manager.ts +15 -2
  41. package/src/space/space-protocol.ts +42 -5
  42. package/src/space/space.ts +4 -4
  43. package/src/testing/test-network-adapter.ts +5 -3
  44. package/dist/lib/browser/chunk-2MII6KJX.mjs.map +0 -7
  45. package/dist/lib/node/chunk-6MWU4MHX.cjs.map +0 -7
@@ -7,37 +7,42 @@ import waitForExpect from 'wait-for-expect';
7
7
 
8
8
  import { asyncTimeout, sleep } from '@dxos/async';
9
9
  import {
10
- next as A,
11
- type Heads,
12
10
  change,
13
11
  clone,
14
12
  equals,
15
13
  from,
16
14
  getBackend,
17
15
  getHeads,
16
+ type Heads,
17
+ next as A,
18
18
  save,
19
19
  saveSince,
20
20
  } from '@dxos/automerge/automerge';
21
21
  import {
22
- type Message,
23
- Repo,
24
- type PeerId,
22
+ type AutomergeUrl,
23
+ type DocHandle,
25
24
  type DocumentId,
25
+ generateAutomergeUrl,
26
26
  type HandleState,
27
- type AutomergeUrl,
28
27
  parseAutomergeUrl,
29
- generateAutomergeUrl,
28
+ type StorageAdapterInterface,
29
+ type PeerId,
30
+ Repo,
31
+ type SharePolicy,
30
32
  } from '@dxos/automerge/automerge-repo';
31
33
  import { randomBytes } from '@dxos/crypto';
32
34
  import { PublicKey } from '@dxos/keys';
33
35
  import { createTestLevel } from '@dxos/kv-store/testing';
34
36
  import { TestBuilder as TeleportBuilder, TestPeer as TeleportPeer } from '@dxos/teleport/testing';
35
37
  import { afterTest, describe, openAndClose, test } from '@dxos/test';
38
+ import { nonNullable, range } from '@dxos/util';
36
39
 
37
40
  import { EchoNetworkAdapter } from './echo-network-adapter';
38
41
  import { LevelDBStorageAdapter } from './leveldb-storage-adapter';
39
42
  import { MeshEchoReplicator } from './mesh-echo-replicator';
40
- import { TestAdapter } from '../testing';
43
+ import { TestAdapter, type TestConnectionStateProvider } from '../testing';
44
+
45
+ const HOST_AND_CLIENT: [string, string] = ['host', 'client'];
41
46
 
42
47
  describe('AutomergeRepo', () => {
43
48
  test('change events', () => {
@@ -58,9 +63,7 @@ describe('AutomergeRepo', () => {
58
63
  });
59
64
 
60
65
  test('flush', async () => {
61
- const level = createTestLevel();
62
- const storage = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
63
- await openAndClose(level, storage);
66
+ const storage = await createLevelAdapter();
64
67
 
65
68
  const repo = new Repo({
66
69
  network: [],
@@ -78,10 +81,6 @@ describe('AutomergeRepo', () => {
78
81
  });
79
82
 
80
83
  test('getChangeByHash', async () => {
81
- const level = createTestLevel();
82
- const storage = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
83
- await openAndClose(level, storage);
84
-
85
84
  const doc = from({ foo: 'bar' });
86
85
  const copy = clone(doc);
87
86
  const newDoc = change(copy, 'change', (doc: any) => {
@@ -113,59 +112,27 @@ describe('AutomergeRepo', () => {
113
112
  });
114
113
 
115
114
  test('documents missing from local storage go to requesting state', async () => {
116
- const hostAdapter: TestAdapter = new TestAdapter({
117
- send: (message: Message) => {
118
- console.log('hostAdapter.send', message);
119
- clientAdapter.receive(message);
120
- },
121
- });
122
- const clientAdapter: TestAdapter = new TestAdapter({
123
- send: (message: Message) => {
124
- if (message.type !== 'doc-unavailable' && message.type !== 'sync') {
125
- hostAdapter.receive(message);
126
- }
127
- },
128
- });
129
-
130
- const host = new Repo({
131
- network: [hostAdapter],
132
- });
133
- const _client = new Repo({
134
- network: [clientAdapter],
135
- });
136
- hostAdapter.ready();
137
- clientAdapter.ready();
138
- await hostAdapter.onConnect.wait();
139
- await clientAdapter.onConnect.wait();
140
- hostAdapter.peerCandidate(clientAdapter.peerId!);
141
- clientAdapter.peerCandidate(hostAdapter.peerId!);
142
-
115
+ const { repos, adapters } = await createHostClientRepoTopology();
116
+ const [host] = repos;
117
+ await connectAdapters(adapters);
143
118
  const url = 'automerge:3JN8F3Z4dUWEEKKFN7WE9gEGvVUT';
144
119
  const handle = host.find(url as AutomergeUrl);
145
120
  await handle.whenReady(['requesting']);
146
121
  });
147
122
 
148
123
  test('documents on disk go to ready state', async () => {
149
- const level = createTestLevel();
150
- const storage = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
151
- await openAndClose(level, storage);
124
+ const storage = await createLevelAdapter();
152
125
 
153
126
  let url: AutomergeUrl | undefined;
154
127
  {
155
- const repo = new Repo({
156
- network: [],
157
- storage,
158
- });
128
+ const repo = new Repo({ network: [], storage });
159
129
  const handle = repo.create<{ field?: string }>();
160
130
  url = handle.url;
161
131
  await repo.flush();
162
132
  }
163
133
 
164
134
  {
165
- const repo = new Repo({
166
- network: [],
167
- storage,
168
- });
135
+ const repo = new Repo({ network: [], storage });
169
136
  const handle = repo.find(url as AutomergeUrl);
170
137
 
171
138
  let requestingState = false;
@@ -181,25 +148,9 @@ describe('AutomergeRepo', () => {
181
148
 
182
149
  describe('network', () => {
183
150
  test('basic networking', async () => {
184
- const hostAdapter: TestAdapter = new TestAdapter({
185
- send: (message: Message) => clientAdapter.receive(message),
186
- });
187
- const clientAdapter: TestAdapter = new TestAdapter({
188
- send: (message: Message) => hostAdapter.receive(message),
189
- });
190
-
191
- const host = new Repo({
192
- network: [hostAdapter],
193
- });
194
- const client = new Repo({
195
- network: [clientAdapter],
196
- });
197
- hostAdapter.ready();
198
- clientAdapter.ready();
199
- await hostAdapter.onConnect.wait();
200
- await clientAdapter.onConnect.wait();
201
- hostAdapter.peerCandidate(clientAdapter.peerId!);
202
- clientAdapter.peerCandidate(hostAdapter.peerId!);
151
+ const { repos, adapters } = await createHostClientRepoTopology();
152
+ const [host, client] = repos;
153
+ await connectAdapters(adapters);
203
154
 
204
155
  const handle = host.create();
205
156
  const text = 'Hello world';
@@ -212,25 +163,13 @@ describe('AutomergeRepo', () => {
212
163
  });
213
164
 
214
165
  test('share policy gets enabled afterwards', async () => {
215
- const [hostAdapter, clientAdapter] = TestAdapter.createPair();
216
166
  let sharePolicy = false;
217
167
 
218
- const host = new Repo({
219
- network: [hostAdapter],
220
- peerId: 'host' as PeerId,
221
- sharePolicy: async () => sharePolicy,
222
- });
223
- const client = new Repo({
224
- network: [clientAdapter],
225
- peerId: 'client' as PeerId,
168
+ const { repos, adapters } = await createHostClientRepoTopology({
226
169
  sharePolicy: async () => sharePolicy,
227
170
  });
228
- hostAdapter.ready();
229
- clientAdapter.ready();
230
- await hostAdapter.onConnect.wait();
231
- await clientAdapter.onConnect.wait();
232
- hostAdapter.peerCandidate(clientAdapter.peerId!);
233
- clientAdapter.peerCandidate(hostAdapter.peerId!);
171
+ const [host, client] = repos;
172
+ await connectAdapters(adapters);
234
173
 
235
174
  const handle = host.create();
236
175
  const text = 'Hello world';
@@ -253,35 +192,25 @@ describe('AutomergeRepo', () => {
253
192
  });
254
193
 
255
194
  test('two documents and share policy switching', async () => {
256
- const [hostAdapter, clientAdapter] = TestAdapter.createPair();
257
195
  const allowedDocs: DocumentId[] = [];
258
196
 
259
- const host: Repo = new Repo({
260
- network: [hostAdapter],
261
- peerId: 'host' as PeerId,
262
- sharePolicy: async (_, docId) => (docId ? allowedDocs.includes(docId) && !!host.handles[docId] : false),
263
- });
264
-
265
- const client: Repo = new Repo({
266
- network: [clientAdapter],
267
- peerId: 'client' as PeerId,
268
- sharePolicy: async (_, docId) => (docId ? allowedDocs.includes(docId) && !!client.handles[docId] : false),
197
+ const { repos, adapters } = await createHostClientRepoTopology({
198
+ sharePolicy: async (peerId, docId): Promise<boolean> => {
199
+ if (peerId === 'host') {
200
+ return docId ? allowedDocs.includes(docId) && !!client.handles[docId] : false;
201
+ } else {
202
+ return docId ? allowedDocs.includes(docId) && !!host.handles[docId] : false;
203
+ }
204
+ },
269
205
  });
206
+ const [host, client] = repos;
270
207
 
271
208
  const firstHandle = host.create();
272
209
  firstHandle.change((doc: any) => (doc.text = 'Hello world'));
273
210
  await host.find(firstHandle.url).whenReady();
274
211
  allowedDocs.push(firstHandle.documentId);
275
212
 
276
- {
277
- // Initiate connection.
278
- hostAdapter.ready();
279
- clientAdapter.ready();
280
- await hostAdapter.onConnect.wait();
281
- await clientAdapter.onConnect.wait();
282
- hostAdapter.peerCandidate(clientAdapter.peerId!);
283
- clientAdapter.peerCandidate(hostAdapter.peerId!);
284
- }
213
+ await connectAdapters(adapters);
285
214
 
286
215
  {
287
216
  const firstDocOnClient = client.find(firstHandle.url);
@@ -304,27 +233,11 @@ describe('AutomergeRepo', () => {
304
233
  test('recovering from a lost connection', async () => {
305
234
  let connectionState: 'on' | 'off' = 'on';
306
235
 
307
- const hostAdapter: TestAdapter = new TestAdapter({
308
- send: (message: Message) => connectionState === 'on' && sleep(10).then(() => clientAdapter.receive(message)),
309
- });
310
- const clientAdapter: TestAdapter = new TestAdapter({
311
- send: (message: Message) => connectionState === 'on' && sleep(10).then(() => hostAdapter.receive(message)),
236
+ const { repos, adapters } = await createHostClientRepoTopology({
237
+ connectionStateProvider: () => connectionState,
312
238
  });
313
-
314
- const host = new Repo({
315
- network: [hostAdapter],
316
- });
317
- const client = new Repo({
318
- network: [clientAdapter],
319
- });
320
-
321
- // Establish connection.
322
- hostAdapter.ready();
323
- clientAdapter.ready();
324
- await hostAdapter.onConnect.wait();
325
- await clientAdapter.onConnect.wait();
326
- hostAdapter.peerCandidate(clientAdapter.peerId!);
327
- clientAdapter.peerCandidate(hostAdapter.peerId!);
239
+ const [host, client] = repos;
240
+ await connectAdapters(adapters);
328
241
 
329
242
  const handle = host.create();
330
243
  const docOnClient = client.find(handle.url);
@@ -354,10 +267,7 @@ describe('AutomergeRepo', () => {
354
267
  const onlineText = 'This has been written after the connection was re-established';
355
268
  {
356
269
  connectionState = 'on';
357
- hostAdapter.peerDisconnected(clientAdapter.peerId!);
358
- clientAdapter.peerDisconnected(hostAdapter.peerId!);
359
- hostAdapter.peerCandidate(clientAdapter.peerId!);
360
- clientAdapter.peerCandidate(hostAdapter.peerId!);
270
+ await reconnectAdapters(adapters);
361
271
 
362
272
  handle.change((doc: any) => {
363
273
  doc.onlineText = onlineText;
@@ -368,126 +278,17 @@ describe('AutomergeRepo', () => {
368
278
  }
369
279
  });
370
280
 
371
- test('integration test with teleport', async () => {
372
- const [spaceKey] = PublicKey.randomSequence();
373
-
374
- const createAutomergeRepo = async () => {
375
- const meshAdapter = new MeshEchoReplicator();
376
- const echoAdapter = new EchoNetworkAdapter({
377
- getContainingSpaceForDocument: async () => spaceKey,
378
- onCollectionStateQueried: () => {},
379
- onCollectionStateReceived: () => {},
380
- });
381
- const repo = new Repo({
382
- network: [echoAdapter],
383
- });
384
- await echoAdapter.open();
385
- await echoAdapter.whenConnected();
386
- await echoAdapter.addReplicator(meshAdapter);
387
- return { repo, meshAdapter };
388
- };
389
- const peer1 = await createAutomergeRepo();
390
- const peer2 = await createAutomergeRepo();
391
-
392
- const handle = peer1.repo.create();
393
-
394
- const teleportBuilder = new TeleportBuilder();
395
- afterTest(() => teleportBuilder.destroy());
396
-
397
- const [teleportPeer1, teleportPeer2] = teleportBuilder.createPeers({ factory: () => new TeleportPeer() });
398
- {
399
- // Initiate connection.
400
- const [connection1, connection2] = await teleportBuilder.connect(teleportPeer1, teleportPeer2);
401
- connection1.teleport.addExtension('automerge', peer1.meshAdapter.createExtension());
402
- connection2.teleport.addExtension('automerge', peer2.meshAdapter.createExtension());
403
-
404
- // Test connection.
405
- const text = 'Hello world';
406
- handle.change((doc: any) => {
407
- doc.text = text;
408
- });
409
- await waitForExpect(async () => {
410
- const docOnPeer2 = peer2.repo.find(handle.url);
411
- const doc = await asyncTimeout(docOnPeer2.doc(), 1000);
412
- expect(doc.text).to.eq(text);
413
- }, 1000);
414
- }
415
-
416
- const offlineText = 'This has been written while the connection was off';
417
- {
418
- // Disconnect peers.
419
- await teleportBuilder.disconnect(teleportPeer1, teleportPeer2);
420
-
421
- // Make offline changes.
422
- const offlineText = 'This has been written while the connection was off';
423
- handle.change((doc: any) => {
424
- doc.offlineText = offlineText;
425
- });
426
- const docOnPeer2 = peer2.repo.find(handle.url);
427
- await sleep(100);
428
- expect((await asyncTimeout(docOnPeer2.doc(), 1000)).offlineText).to.be.undefined;
429
- }
430
-
431
- {
432
- const docOnPeer2 = peer2.repo.find(handle.url);
433
- const receivedUpdate = new Promise((resolve, reject) => docOnPeer2.once('heads-changed', resolve));
434
-
435
- // Reconnect peers.
436
- const [connection1, connection2] = await teleportBuilder.connect(teleportPeer1, teleportPeer2);
437
- connection1.teleport.addExtension('automerge', peer1.meshAdapter.createExtension());
438
- connection2.teleport.addExtension('automerge', peer2.meshAdapter.createExtension());
439
-
440
- // Wait for offline changes to be synced.
441
- await receivedUpdate;
442
- await docOnPeer2.whenReady();
443
- expect((await asyncTimeout(docOnPeer2.doc(), 1000)).offlineText).to.eq(offlineText);
444
-
445
- // Test connection.
446
- const onlineText = 'This has been written after the connection was re-established';
447
- const receivedOnlineUpdate = new Promise((resolve, reject) => docOnPeer2.once('heads-changed', resolve));
448
- handle.change((doc: any) => {
449
- doc.onlineText = onlineText;
450
- });
451
- await receivedOnlineUpdate;
452
- await docOnPeer2.whenReady();
453
- expect((await asyncTimeout(docOnPeer2.doc(), 1000)).onlineText).to.eq(onlineText);
454
- }
455
- });
456
-
457
281
  test('replication though a 4 peer chain', async () => {
458
- const pairAB = TestAdapter.createPair();
459
- const pairBC = TestAdapter.createPair();
460
- const pairCD = TestAdapter.createPair();
461
-
462
- const repoA = new Repo({
463
- peerId: 'A' as any,
464
- network: [pairAB[0]],
465
- sharePolicy: async () => true,
466
- });
467
- const _repoB = new Repo({
468
- peerId: 'B' as any,
469
- network: [pairAB[1], pairBC[0]],
470
- sharePolicy: async () => true,
282
+ const { repos, adapters } = await createRepoTopology({
283
+ peers: ['A', 'B', 'C', 'D'],
284
+ connections: [
285
+ ['A', 'B'],
286
+ ['B', 'C'],
287
+ ['C', 'D'],
288
+ ],
471
289
  });
472
- const _repoC = new Repo({
473
- peerId: 'C' as any,
474
- network: [pairBC[1], pairCD[0]],
475
- sharePolicy: async () => true,
476
- });
477
- const repoD = new Repo({
478
- peerId: 'D' as any,
479
- network: [pairCD[1]],
480
- sharePolicy: async () => true,
481
- });
482
-
483
- for (const pair of [pairAB, pairBC, pairCD]) {
484
- pair[0].ready();
485
- pair[1].ready();
486
- await pair[0].onConnect.wait();
487
- await pair[1].onConnect.wait();
488
- pair[0].peerCandidate(pair[1].peerId!);
489
- pair[1].peerCandidate(pair[0].peerId!);
490
- }
290
+ const [repoA, _, __, repoD] = repos;
291
+ await connectAdapters(adapters);
491
292
 
492
293
  const docA = repoA.create();
493
294
  // NOTE: Doesn't work if the doc is empty.
@@ -503,33 +304,15 @@ describe('AutomergeRepo', () => {
503
304
  });
504
305
 
505
306
  test('replication though a 3 peer chain', async () => {
506
- const pairAB = TestAdapter.createPair();
507
- const pairBC = TestAdapter.createPair();
508
-
509
- const repoA = new Repo({
510
- peerId: 'A' as any,
511
- network: [pairAB[0]],
512
- sharePolicy: async () => true,
513
- });
514
- const repoB = new Repo({
515
- peerId: 'B' as any,
516
- network: [pairAB[1], pairBC[0]],
517
- sharePolicy: async () => true,
518
- });
519
- const repoC = new Repo({
520
- peerId: 'C' as any,
521
- network: [pairBC[1]],
522
- sharePolicy: async () => true,
307
+ const { repos, adapters } = await createRepoTopology({
308
+ peers: ['A', 'B', 'C'],
309
+ connections: [
310
+ ['A', 'B'],
311
+ ['B', 'C'],
312
+ ],
523
313
  });
524
-
525
- for (const pair of [pairAB, pairBC]) {
526
- pair[0].ready();
527
- pair[1].ready();
528
- await pair[0].onConnect.wait();
529
- await pair[1].onConnect.wait();
530
- pair[0].peerCandidate(pair[1].peerId!);
531
- pair[1].peerCandidate(pair[0].peerId!);
532
- }
314
+ const [repoA, repoB, repoC] = repos;
315
+ await connectAdapters(adapters);
533
316
 
534
317
  const docA = repoA.create();
535
318
  // NOTE: Doesn't work if the doc is empty.
@@ -537,77 +320,44 @@ describe('AutomergeRepo', () => {
537
320
  doc.text = 'Hello world';
538
321
  });
539
322
 
540
- const _docB = repoB.find(docA.url);
323
+ const _ = repoB.find(docA.url);
541
324
  const docC = repoC.find(docA.url);
542
-
543
325
  await docC.whenReady();
544
326
  });
545
327
 
546
328
  test('replicate document after request', async () => {
547
- const [adapter1, adapter2] = TestAdapter.createPair();
548
- const repoA = new Repo({
549
- peerId: 'A' as any,
550
- network: [adapter1],
551
- sharePolicy: async () => true,
552
- });
553
- const repoB = new Repo({
554
- peerId: 'B' as any,
555
- network: [adapter2],
556
- sharePolicy: async () => true,
557
- });
329
+ const { repos, adapters } = await createHostClientRepoTopology();
330
+ const [host, client] = repos;
331
+ const [[adapter1, adapter2]] = adapters;
558
332
 
559
333
  const unavailable: HandleState = 'unavailable';
334
+ await connectAdapters(adapters, { noEmitPeerCandidate: true });
560
335
 
561
- {
562
- // Connect repos.
563
- adapter1.ready();
564
- adapter2.ready();
565
- await adapter1.onConnect.wait();
566
- await adapter2.onConnect.wait();
567
- }
568
-
569
- const docA = repoA.create();
336
+ const docA = host.create();
570
337
  // NOTE: Doesn't work if the doc is empty.
571
338
  docA.change((doc: any) => {
572
339
  doc.text = 'Hello world';
573
340
  });
574
341
 
575
- const docB = repoB.find(docA.url);
576
- {
577
- // Request document from repoB.
578
- await asyncTimeout(docB.whenReady([unavailable]), 1_000);
579
- }
342
+ const docB = client.find(docA.url);
580
343
 
581
- {
582
- // Failing to find a document.
583
- // await (docB.whenReady([unavailable]), 1_000);
584
- }
344
+ // Request document from repoB.
345
+ await asyncTimeout(docB.whenReady([unavailable]), 1_000);
585
346
 
586
- {
587
- adapter1.peerCandidate(adapter2.peerId!);
588
- await sleep(100);
589
- adapter2.peerCandidate(adapter1.peerId!);
590
- }
591
-
592
- {
593
- await asyncTimeout(docB.whenReady([unavailable]), 1_000);
594
- }
347
+ adapter1.peerCandidate(adapter2.peerId!);
348
+ await sleep(100);
349
+ adapter2.peerCandidate(adapter1.peerId!);
595
350
 
596
- {
597
- // Note: Retry hack.
598
- adapter1.peerDisconnected(adapter2.peerId!);
599
- adapter2.peerDisconnected(adapter1.peerId!);
600
- adapter1.peerCandidate(adapter2.peerId!);
601
- adapter2.peerCandidate(adapter1.peerId!);
351
+ // Wait becomes unavailable.
352
+ await asyncTimeout(docB.whenReady([unavailable]), 1_000);
602
353
 
603
- await asyncTimeout(docB.whenReady(), 1_000);
604
- }
354
+ // Wait gets loaded after reconnect.
355
+ await reconnectAdapters(adapters);
356
+ await asyncTimeout(docB.whenReady(), 1_000);
605
357
  });
606
358
 
607
359
  test('documents loaded from disk get replicated', async () => {
608
- const level = createTestLevel();
609
- const storage = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
610
- await openAndClose(level, storage);
360
+ const storage = await createLevelAdapter();
611
361
 
612
362
  let url: AutomergeUrl | undefined;
613
363
  {
@@ -619,29 +369,17 @@ describe('AutomergeRepo', () => {
619
369
  url = handle.url;
620
370
  }
621
371
 
622
- const [adapter1, adapter2] = TestAdapter.createPair();
623
- const peer1 = new Repo({
624
- network: [adapter1],
625
- storage,
626
- sharePolicy: async () => true,
627
- });
628
- const peer2 = new Repo({
629
- network: [adapter2],
630
- sharePolicy: async () => true,
631
- });
632
- adapter1.ready();
633
- adapter2.ready();
634
- await adapter1.onConnect.wait();
635
- await adapter2.onConnect.wait();
636
- adapter1.peerCandidate(adapter2.peerId!);
637
- adapter2.peerCandidate(adapter1.peerId!);
372
+ const { repos, adapters } = await createHostClientRepoTopology({ storages: [storage] });
373
+ const [peer1, peer2] = repos;
374
+ await connectAdapters(adapters);
638
375
 
639
376
  // Load doc on peer1
640
377
  const hostHandle = peer1.find(url as AutomergeUrl);
641
378
 
642
379
  // Doc should be pushed to peer2
643
380
  await waitForExpect(() => {
644
- expect(peer2.handles[hostHandle.documentId]).to.not.be.undefined;
381
+ expect(hostHandle.docSync().text).not.to.be.undefined;
382
+ expect(peer2.handles[hostHandle.documentId].docSync()).to.deep.eq(hostHandle.docSync());
645
383
  });
646
384
  });
647
385
 
@@ -716,37 +454,18 @@ describe('AutomergeRepo', () => {
716
454
  });
717
455
 
718
456
  test('two repo sync docs on `update` call', async () => {
719
- const [adapter1, adapter2] = TestAdapter.createPair();
720
- const repoA = new Repo({
721
- peerId: 'A' as any,
722
- network: [adapter1],
723
- sharePolicy: async () => true,
724
- });
725
- const repoB = new Repo({
726
- peerId: 'B' as any,
727
- network: [adapter2],
728
- sharePolicy: async () => true,
729
- });
730
-
731
- {
732
- // Connect repos.
733
- adapter1.ready();
734
- adapter2.ready();
735
- await adapter1.onConnect.wait();
736
- await adapter2.onConnect.wait();
737
- adapter1.peerCandidate(adapter2.peerId!);
738
- adapter2.peerCandidate(adapter1.peerId!);
739
- }
457
+ const { repos, adapters } = await createHostClientRepoTopology();
458
+ const [repoA, repoB] = repos;
459
+ await connectAdapters(adapters);
740
460
 
741
461
  const handleA = repoA.create();
742
462
  const handleB = repoB.find(handleA.url);
743
463
 
744
464
  const text = 'Hello world';
745
465
  handleA.update((doc: any) => {
746
- const newDoc = A.change(doc, (doc: any) => {
466
+ return A.change(doc, (doc: any) => {
747
467
  doc.text = text;
748
468
  });
749
- return newDoc;
750
469
  });
751
470
 
752
471
  expect(handleA.docSync().text).to.equal(text);
@@ -755,4 +474,299 @@ describe('AutomergeRepo', () => {
755
474
  expect(handleB.docSync().text).to.equal(text);
756
475
  });
757
476
  });
477
+
478
+ describe('teleport', () => {
479
+ test('integration test with teleport', async () => {
480
+ const [spaceKey] = PublicKey.randomSequence();
481
+
482
+ const teleportBuilder = new TeleportBuilder();
483
+ afterTest(() => teleportBuilder.destroy());
484
+
485
+ const peer1 = await createTeleportTestPeer(teleportBuilder, spaceKey);
486
+ const peer2 = await createTeleportTestPeer(teleportBuilder, spaceKey);
487
+
488
+ const handle = peer1.repo.create();
489
+ {
490
+ // Initiate connection and test the connection.
491
+ await connectPeers(spaceKey, teleportBuilder, peer1, peer2);
492
+ const text = 'Hello world';
493
+ handle.change((doc: any) => {
494
+ doc.text = text;
495
+ });
496
+ await waitForExpect(async () => {
497
+ const docOnPeer2 = peer2.repo.find(handle.url);
498
+ const doc = await asyncTimeout(docOnPeer2.doc(), 1000);
499
+ expect(doc.text).to.eq(text);
500
+ }, 1000);
501
+ }
502
+
503
+ const offlineText = 'This has been written while the connection was off';
504
+ {
505
+ // Disconnect peers and make offline changes.
506
+ await teleportBuilder.disconnect(peer1.teleport, peer2.teleport);
507
+ const offlineText = 'This has been written while the connection was off';
508
+ handle.change((doc: any) => {
509
+ doc.offlineText = offlineText;
510
+ });
511
+ const docOnPeer2 = peer2.repo.find(handle.url);
512
+ await sleep(100);
513
+ expect((await asyncTimeout(docOnPeer2.doc(), 1000)).offlineText).to.be.undefined;
514
+ }
515
+
516
+ {
517
+ // Wait for offline changes to be synced after reconnect.
518
+ const docOnPeer2 = peer2.repo.find(handle.url);
519
+ await docChangeAfterAction(docOnPeer2, () => connectPeers(spaceKey, teleportBuilder, peer1, peer2));
520
+ await docOnPeer2.whenReady();
521
+ expect((await asyncTimeout(docOnPeer2.doc(), 1000)).offlineText).to.eq(offlineText);
522
+
523
+ // Test connection.
524
+ const onlineText = 'This has been written after the connection was re-established';
525
+ await docChangeAfterAction(docOnPeer2, async () => {
526
+ handle.change((doc: any) => {
527
+ doc.onlineText = onlineText;
528
+ });
529
+ });
530
+ await docOnPeer2.whenReady();
531
+ expect((await asyncTimeout(docOnPeer2.doc(), 1000)).onlineText).to.eq(onlineText);
532
+ }
533
+ });
534
+
535
+ test('pulls a document if it is in a remote collection', async () => {
536
+ const [spaceKey] = PublicKey.randomSequence();
537
+
538
+ const teleportBuilder = new TeleportBuilder();
539
+ afterTest(() => teleportBuilder.destroy());
540
+
541
+ const peerWithDocs = await createTeleportPeerWithStoredDocs(teleportBuilder, spaceKey, async (repo) => {
542
+ return range(2, (idx) => {
543
+ const document = repo.create();
544
+ document.change((doc: any) => (doc.text = 'hello ' + idx));
545
+ return document;
546
+ });
547
+ });
548
+ const [docInRemoteCollection, docNotInRemoteCollection] = peerWithDocs.documents;
549
+
550
+ // Create a peer that shares one of the collections.
551
+ const peer2 = await createTeleportTestPeer(teleportBuilder, spaceKey, {
552
+ localDocuments: [],
553
+ remoteCollections: { [peerWithDocs.peerId]: [docInRemoteCollection.documentId] },
554
+ });
555
+ await connectPeers(spaceKey, teleportBuilder, peerWithDocs.peer, peer2);
556
+
557
+ const shouldNotFindDoc = peer2.repo.find(docNotInRemoteCollection.url);
558
+ const shouldFindDoc = peer2.repo.find(docInRemoteCollection.url);
559
+ await shouldFindDoc.whenReady();
560
+ expect(shouldFindDoc.docSync()).to.deep.eq(docInRemoteCollection.docSync());
561
+ await shouldNotFindDoc.whenReady(['unavailable']);
562
+ });
563
+
564
+ /**
565
+ * This can happen if there's no local document and sharePolicy returns true to every connected
566
+ * peer to try getting a document from it. Remote collection check has to be performed.
567
+ */
568
+ test('document is not shared with peer who did not request it', async () => {
569
+ const [spaceKey, anotherSpaceKey] = PublicKey.randomSequence();
570
+
571
+ const teleportBuilder = new TeleportBuilder();
572
+ afterTest(() => teleportBuilder.destroy());
573
+
574
+ const peerWithDocs = await createTeleportPeerWithStoredDocs(teleportBuilder, spaceKey, async (repo) => {
575
+ const document = repo.create();
576
+ document.change((doc: any) => (doc.text = 'hello'));
577
+ return [document];
578
+ });
579
+ const [document] = peerWithDocs.documents;
580
+
581
+ const peer2 = await createTeleportTestPeer(teleportBuilder, spaceKey, {
582
+ localDocuments: [],
583
+ remoteCollections: { [peerWithDocs.peerId]: [document.documentId] },
584
+ });
585
+ const peerFromAnotherSpace = await createTeleportTestPeer(teleportBuilder, anotherSpaceKey, {
586
+ localDocuments: [],
587
+ });
588
+ await connectPeers(spaceKey, teleportBuilder, peerWithDocs.peer, peer2);
589
+ await connectPeers(anotherSpaceKey, teleportBuilder, peer2, peerFromAnotherSpace);
590
+
591
+ const loadedDocument = peer2.repo.find(document.url);
592
+ await loadedDocument.whenReady();
593
+
594
+ await sleep(200);
595
+ const doc = peerFromAnotherSpace.repo.find(document.url);
596
+ await doc.whenReady(['unavailable']);
597
+ });
598
+
599
+ test('collection-sync with chain of peers', async () => {
600
+ const [spaceKey] = PublicKey.randomSequence();
601
+
602
+ const teleportBuilder = new TeleportBuilder();
603
+ afterTest(() => teleportBuilder.destroy());
604
+
605
+ const peerWithDocs = await createTeleportPeerWithStoredDocs(teleportBuilder, spaceKey, async (repo) => {
606
+ const document = repo.create();
607
+ document.change((doc: any) => (doc.text = 'hello'));
608
+ return [document];
609
+ });
610
+ const [document] = peerWithDocs.documents;
611
+
612
+ const peer2Id = 'peer2';
613
+ const peer2 = await createTeleportTestPeer(teleportBuilder, spaceKey, {
614
+ peerId: peer2Id,
615
+ localDocuments: [],
616
+ remoteCollections: { [peerWithDocs.peerId]: [document.documentId] },
617
+ });
618
+ const peer3 = await createTeleportTestPeer(teleportBuilder, spaceKey, {
619
+ localDocuments: [],
620
+ remoteCollections: { [peer2Id]: [document.documentId] },
621
+ });
622
+ await connectPeers(spaceKey, teleportBuilder, peerWithDocs.peer, peer2);
623
+ await connectPeers(spaceKey, teleportBuilder, peer2, peer3);
624
+
625
+ const loadedDocument = peer2.repo.find(document.url);
626
+ await loadedDocument.whenReady();
627
+
628
+ const doc = peer3.repo.find(document.url);
629
+ await doc.whenReady();
630
+ expect(doc.docSync()).to.deep.eq(document.docSync());
631
+ });
632
+ });
633
+
634
+ const createLevelAdapter = async () => {
635
+ const level = createTestLevel();
636
+ const storage = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
637
+ await openAndClose(level, storage);
638
+ return storage;
639
+ };
640
+
641
+ const createTeleportPeerWithStoredDocs = async (
642
+ teleportBuilder: TeleportBuilder,
643
+ spaceKey: PublicKey,
644
+ createDocCallback: (repo: Repo) => Promise<A.Doc<any>[]>,
645
+ ) => {
646
+ const peerId = 'A';
647
+ const storage = await createLevelAdapter();
648
+ const peer = await createTeleportTestPeer(teleportBuilder, spaceKey, { storage, peerId });
649
+ const documents = await createDocCallback(peer.repo);
650
+ await peer.repo.flush();
651
+ // Reload the first peer so that documents are NOT pushed to the new connection.
652
+ const reloadedPeer = await createTeleportTestPeer(teleportBuilder, spaceKey, { storage, peerId });
653
+ return { peer: reloadedPeer, peerId, documents };
654
+ };
758
655
  });
656
+
657
+ type ConnectedRepoOptions = {
658
+ storages?: StorageAdapterInterface[];
659
+ connectionStateProvider?: TestConnectionStateProvider;
660
+ sharePolicy?: SharePolicy;
661
+ };
662
+
663
+ const createRepoTopology = async <Peers extends string[], Peer extends string = Peers[number]>(args: {
664
+ peers: Peers;
665
+ connections: [Peer, Peer][];
666
+ options?: ConnectedRepoOptions;
667
+ }) => {
668
+ const adapters = args.connections.map(
669
+ () => TestAdapter.createPair(args.options?.connectionStateProvider) as [TestAdapter, TestAdapter],
670
+ );
671
+ const repos = args.peers.map((peerId, peerIndex) => {
672
+ const network = adapters
673
+ .map((pair, idx) => {
674
+ return args.connections[idx].includes(peerId as Peer)
675
+ ? peerId === args.connections[idx][0]
676
+ ? pair[0]
677
+ : pair[1]
678
+ : null;
679
+ })
680
+ .filter(nonNullable);
681
+ return new Repo({
682
+ peerId: peerId as PeerId,
683
+ storage: args.options?.storages?.[peerIndex],
684
+ network,
685
+ sharePolicy: args.options?.sharePolicy ?? (async () => true),
686
+ });
687
+ });
688
+ return { repos, adapters };
689
+ };
690
+
691
+ const connectAdapters = async (pairs: [TestAdapter, TestAdapter][], options?: { noEmitPeerCandidate?: boolean }) => {
692
+ for (const pair of pairs) {
693
+ pair[0].ready();
694
+ pair[1].ready();
695
+ await pair[0].onConnect.wait();
696
+ await pair[1].onConnect.wait();
697
+ if (!options?.noEmitPeerCandidate) {
698
+ pair[0].peerCandidate(pair[1].peerId!);
699
+ pair[1].peerCandidate(pair[0].peerId!);
700
+ }
701
+ }
702
+ };
703
+
704
+ const createHostClientRepoTopology = (options?: ConnectedRepoOptions) =>
705
+ createRepoTopology({ peers: HOST_AND_CLIENT, connections: [HOST_AND_CLIENT], options });
706
+
707
+ const reconnectAdapters = async (pairs: [TestAdapter, TestAdapter][]) => {
708
+ for (const pair of pairs) {
709
+ // Note: Retry hack.
710
+ pair[0].peerDisconnected(pair[1].peerId!);
711
+ pair[1].peerDisconnected(pair[0].peerId!);
712
+ pair[0].peerCandidate(pair[1].peerId!);
713
+ pair[1].peerCandidate(pair[0].peerId!);
714
+ }
715
+ };
716
+
717
+ const docChangeAfterAction = async (handle: DocHandle<any>, action: () => Promise<void>) => {
718
+ const receivedUpdate = new Promise((resolve) => handle.once('heads-changed', resolve));
719
+ await action();
720
+ return receivedUpdate;
721
+ };
722
+
723
+ const createTeleportTestPeer = async (
724
+ teleportBuilder: TeleportBuilder,
725
+ spaceKey: PublicKey,
726
+ options?: {
727
+ peerId?: string;
728
+ storage?: StorageAdapterInterface;
729
+ localDocuments?: string[];
730
+ remoteCollections?: { [peerId: string]: string[] };
731
+ },
732
+ ): Promise<TeleportTestPeer> => {
733
+ const meshAdapter = new MeshEchoReplicator();
734
+ const echoAdapter = new EchoNetworkAdapter({
735
+ // If a document is in the remote collection we don't have it locally, so can't get spaceKey from it.
736
+ getContainingSpaceForDocument: async (documentId) => {
737
+ return options?.localDocuments ? (options.localDocuments.includes(documentId) ? spaceKey : null) : spaceKey;
738
+ },
739
+ isDocumentInRemoteCollection: async (params) => {
740
+ return options?.remoteCollections?.[params.peerId]?.includes(params.documentId) ?? false;
741
+ },
742
+ onCollectionStateQueried: () => {},
743
+ onCollectionStateReceived: () => {},
744
+ });
745
+ const repo = new Repo({
746
+ peerId: options?.peerId as PeerId,
747
+ network: [echoAdapter],
748
+ storage: options?.storage,
749
+ sharePolicy: async (peerId, documentId) =>
750
+ documentId ? echoAdapter.shouldAdvertise(peerId, { documentId }) : false,
751
+ });
752
+ await echoAdapter.open();
753
+ await echoAdapter.whenConnected();
754
+ await echoAdapter.addReplicator(meshAdapter);
755
+ const [teleport] = teleportBuilder.createPeers({ factory: () => new TeleportPeer() });
756
+ return { repo, meshAdapter, teleport };
757
+ };
758
+
759
+ const connectPeers = async (
760
+ spaceKey: PublicKey,
761
+ builder: TeleportBuilder,
762
+ peer1: TeleportTestPeer,
763
+ peer2: TeleportTestPeer,
764
+ ) => {
765
+ const [connection1, connection2] = await builder.connect(peer1.teleport, peer2.teleport);
766
+ await peer1.meshAdapter.authorizeDevice(spaceKey, peer2.teleport.peerId);
767
+ connection1.teleport.addExtension('automerge', peer1.meshAdapter.createExtension());
768
+ await peer2.meshAdapter.authorizeDevice(spaceKey, peer1.teleport.peerId);
769
+ connection2.teleport.addExtension('automerge', peer2.meshAdapter.createExtension());
770
+ };
771
+
772
+ type TeleportTestPeer = { repo: Repo; meshAdapter: MeshEchoReplicator; teleport: TeleportPeer };