@dxos/echo-pipeline 0.3.11-main.ec7a2b3 → 0.3.11-main.ee2b64c

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 (37) hide show
  1. package/dist/lib/browser/{chunk-WDT56L4E.mjs → chunk-IOUMNVGJ.mjs} +266 -79
  2. package/dist/lib/browser/chunk-IOUMNVGJ.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +1 -3
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/testing/index.mjs +5 -5
  6. package/dist/lib/browser/testing/index.mjs.map +3 -3
  7. package/dist/lib/node/{chunk-C6GFWBRK.cjs → chunk-PDU65RAS.cjs} +260 -77
  8. package/dist/lib/node/chunk-PDU65RAS.cjs.map +7 -0
  9. package/dist/lib/node/index.cjs +26 -28
  10. package/dist/lib/node/index.cjs.map +2 -2
  11. package/dist/lib/node/meta.json +1 -1
  12. package/dist/lib/node/testing/index.cjs +20 -20
  13. package/dist/lib/node/testing/index.cjs.map +3 -3
  14. package/dist/types/src/automerge/automerge-host.d.ts +27 -2
  15. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  16. package/dist/types/src/automerge/index.d.ts +1 -1
  17. package/dist/types/src/automerge/index.d.ts.map +1 -1
  18. package/dist/types/src/space/data-pipeline.d.ts.map +1 -1
  19. package/dist/types/src/space/space-manager.d.ts +2 -2
  20. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  21. package/dist/types/src/space/space-protocol.d.ts.map +1 -1
  22. package/dist/types/src/space/space.d.ts +1 -1
  23. package/dist/types/src/space/space.d.ts.map +1 -1
  24. package/dist/types/src/testing/database-test-rig.d.ts.map +1 -1
  25. package/package.json +33 -33
  26. package/src/automerge/automerge-host.test.ts +353 -35
  27. package/src/automerge/automerge-host.ts +138 -21
  28. package/src/automerge/index.ts +1 -1
  29. package/src/pipeline/pipeline-stress.test.ts +9 -2
  30. package/src/space/data-pipeline.ts +4 -3
  31. package/src/space/space-manager.ts +3 -3
  32. package/src/space/space-protocol.ts +4 -0
  33. package/src/space/space.ts +8 -3
  34. package/src/testing/database-test-rig.ts +4 -1
  35. package/src/testing/test-agent-builder.ts +1 -1
  36. package/dist/lib/browser/chunk-WDT56L4E.mjs.map +0 -7
  37. package/dist/lib/node/chunk-C6GFWBRK.cjs.map +0 -7
@@ -4,16 +4,18 @@
4
4
 
5
5
  import { randomBytes } from 'crypto';
6
6
  import expect from 'expect';
7
+ import waitForExpect from 'wait-for-expect';
7
8
 
8
9
  import { Trigger, asyncTimeout, sleep } from '@dxos/async';
9
- import { type Message, NetworkAdapter, type PeerId, Repo } from '@dxos/automerge/automerge-repo';
10
+ import { type Message, NetworkAdapter, type PeerId, Repo, type HandleState } from '@dxos/automerge/automerge-repo';
10
11
  import { invariant } from '@dxos/invariant';
11
12
  import { log } from '@dxos/log';
12
13
  import { StorageType, createStorage } from '@dxos/random-access-storage';
13
- import { describe, test } from '@dxos/test';
14
+ import { TestBuilder as TeleportBuilder, TestPeer as TeleportPeer } from '@dxos/teleport/testing';
15
+ import { afterTest, describe, test } from '@dxos/test';
14
16
  import { arrayToBuffer, bufferToArray } from '@dxos/util';
15
17
 
16
- import { AutomergeHost, AutomergeStorageAdapter } from './automerge-host';
18
+ import { AutomergeHost, AutomergeStorageAdapter, MeshNetworkAdapter } from './automerge-host';
17
19
 
18
20
  describe('AutomergeHost', () => {
19
21
  test('can create documents', () => {
@@ -46,13 +48,12 @@ describe('AutomergeHost', () => {
46
48
  });
47
49
 
48
50
  test('basic networking', async () => {
49
- type Context = { client: Trigger<TestAdapter>; host: Trigger<TestAdapter> };
50
- const context: Context = {
51
- client: new Trigger<TestAdapter>(),
52
- host: new Trigger<TestAdapter>(),
53
- };
54
- const hostAdapter = new TestAdapter(context, 'host');
55
- const clientAdapter = new TestAdapter(context, 'client');
51
+ const hostAdapter: TestAdapter = new TestAdapter({
52
+ send: (message: Message) => clientAdapter.receive(message),
53
+ });
54
+ const clientAdapter: TestAdapter = new TestAdapter({
55
+ send: (message: Message) => hostAdapter.receive(message),
56
+ });
56
57
 
57
58
  const host = new Repo({
58
59
  network: [hostAdapter],
@@ -62,6 +63,10 @@ describe('AutomergeHost', () => {
62
63
  });
63
64
  hostAdapter.ready();
64
65
  clientAdapter.ready();
66
+ await hostAdapter.onConnect.wait();
67
+ await clientAdapter.onConnect.wait();
68
+ hostAdapter.peerCandidate(clientAdapter.peerId!);
69
+ clientAdapter.peerCandidate(hostAdapter.peerId!);
65
70
 
66
71
  const handle = host.create();
67
72
  const text = 'Hello world';
@@ -70,11 +75,147 @@ describe('AutomergeHost', () => {
70
75
  });
71
76
 
72
77
  const docOnClient = client.find(handle.url);
73
- await asyncTimeout(docOnClient.whenReady(), 3_000);
74
- expect(docOnClient.docSync().text).toEqual(text);
78
+ expect((await asyncTimeout(docOnClient.doc(), 1000)).text).toEqual(text);
75
79
  });
76
80
 
77
- test('doubled connection', async () => {});
81
+ test('recovering from a lost connection', async () => {
82
+ let connectionState: 'on' | 'off' = 'on';
83
+
84
+ const hostAdapter: TestAdapter = new TestAdapter({
85
+ send: (message: Message) => connectionState === 'on' && sleep(10).then(() => clientAdapter.receive(message)),
86
+ });
87
+ const clientAdapter: TestAdapter = new TestAdapter({
88
+ send: (message: Message) => connectionState === 'on' && sleep(10).then(() => hostAdapter.receive(message)),
89
+ });
90
+
91
+ const host = new Repo({
92
+ network: [hostAdapter],
93
+ });
94
+ const client = new Repo({
95
+ network: [clientAdapter],
96
+ });
97
+
98
+ // Establish connection.
99
+ hostAdapter.ready();
100
+ clientAdapter.ready();
101
+ await hostAdapter.onConnect.wait();
102
+ await clientAdapter.onConnect.wait();
103
+ hostAdapter.peerCandidate(clientAdapter.peerId!);
104
+ clientAdapter.peerCandidate(hostAdapter.peerId!);
105
+
106
+ const handle = host.create();
107
+ const docOnClient = client.find(handle.url);
108
+ {
109
+ const sanityText = 'Hello world';
110
+ handle.change((doc: any) => {
111
+ doc.sanityText = sanityText;
112
+ });
113
+
114
+ expect((await asyncTimeout(docOnClient.doc(), 1000)).sanityText).toEqual(sanityText);
115
+ }
116
+
117
+ // Disrupt connection.
118
+ const offlineText = 'This has been written while the connection was off';
119
+ {
120
+ connectionState = 'off';
121
+
122
+ handle.change((doc: any) => {
123
+ doc.offlineText = offlineText;
124
+ });
125
+
126
+ await sleep(100);
127
+ expect((await asyncTimeout(docOnClient.doc(), 1000)).offlineText).toBeUndefined();
128
+ }
129
+
130
+ // Re-establish connection.
131
+ const onlineText = 'This has been written after the connection was re-established';
132
+ {
133
+ connectionState = 'on';
134
+ hostAdapter.peerDisconnected(clientAdapter.peerId!);
135
+ clientAdapter.peerDisconnected(hostAdapter.peerId!);
136
+ hostAdapter.peerCandidate(clientAdapter.peerId!);
137
+ clientAdapter.peerCandidate(hostAdapter.peerId!);
138
+
139
+ handle.change((doc: any) => {
140
+ doc.onlineText = onlineText;
141
+ });
142
+ await sleep(100);
143
+ expect((await asyncTimeout(docOnClient.doc(), 1000)).onlineText).toEqual(onlineText);
144
+ expect((await asyncTimeout(docOnClient.doc(), 1000)).offlineText).toEqual(offlineText);
145
+ }
146
+ });
147
+
148
+ test('integration test with teleport', async () => {
149
+ const createAutomergeRepo = () => {
150
+ const meshAdapter = new MeshNetworkAdapter();
151
+ const repo = new Repo({
152
+ network: [meshAdapter],
153
+ });
154
+ meshAdapter.ready();
155
+ return { repo, meshAdapter };
156
+ };
157
+ const peer1 = createAutomergeRepo();
158
+ const peer2 = createAutomergeRepo();
159
+ const handle = peer1.repo.create();
160
+
161
+ const teleportBuilder = new TeleportBuilder();
162
+ afterTest(() => teleportBuilder.destroy());
163
+
164
+ const [teleportPeer1, teleportPeer2] = teleportBuilder.createPeers({ factory: () => new TeleportPeer() });
165
+ {
166
+ // Initiate connection.
167
+ const [connection1, connection2] = await teleportBuilder.connect(teleportPeer1, teleportPeer2);
168
+ connection1.teleport.addExtension('automerge', peer1.meshAdapter.createExtension());
169
+ connection2.teleport.addExtension('automerge', peer2.meshAdapter.createExtension());
170
+
171
+ // Test connection.
172
+ const text = 'Hello world';
173
+ handle.change((doc: any) => {
174
+ doc.text = text;
175
+ });
176
+ const docOnPeer2 = peer2.repo.find(handle.url);
177
+ await waitForExpect(async () => expect((await asyncTimeout(docOnPeer2.doc(), 1000)).text).toEqual(text), 1000);
178
+ }
179
+
180
+ const offlineText = 'This has been written while the connection was off';
181
+ {
182
+ // Disconnect peers.
183
+ await teleportBuilder.disconnect(teleportPeer1, teleportPeer2);
184
+
185
+ // Make offline changes.
186
+ const offlineText = 'This has been written while the connection was off';
187
+ handle.change((doc: any) => {
188
+ doc.offlineText = offlineText;
189
+ });
190
+ const docOnPeer2 = peer2.repo.find(handle.url);
191
+ await sleep(100);
192
+ expect((await asyncTimeout(docOnPeer2.doc(), 1000)).offlineText).toBeUndefined();
193
+ }
194
+
195
+ {
196
+ // Reconnect peers.
197
+ const [connection1, connection2] = await teleportBuilder.connect(teleportPeer1, teleportPeer2);
198
+ connection1.teleport.addExtension('automerge', peer1.meshAdapter.createExtension());
199
+ connection2.teleport.addExtension('automerge', peer2.meshAdapter.createExtension());
200
+
201
+ // Wait for offline changes to be synced.
202
+ const docOnPeer2 = peer2.repo.find(handle.url);
203
+ await waitForExpect(
204
+ async () => expect((await asyncTimeout(docOnPeer2.doc(), 1000)).offlineText).toEqual(offlineText),
205
+ 1000,
206
+ );
207
+
208
+ // Test connection.
209
+ const onlineText = 'This has been written after the connection was re-established';
210
+ handle.change((doc: any) => {
211
+ doc.onlineText = onlineText;
212
+ });
213
+ await waitForExpect(
214
+ async () => expect((await asyncTimeout(docOnPeer2.doc(), 1000)).onlineText).toEqual(onlineText),
215
+ 1000,
216
+ );
217
+ }
218
+ });
78
219
 
79
220
  describe('storage', () => {
80
221
  test('load range on node', async () => {
@@ -100,13 +241,196 @@ describe('AutomergeHost', () => {
100
241
  ]);
101
242
  }
102
243
  });
244
+
245
+ test('removeRange on node', async () => {
246
+ const root = `/tmp/${randomBytes(16).toString('hex')}`;
247
+ {
248
+ const storage = createStorage({ type: StorageType.NODE, root });
249
+ const adapter = new AutomergeStorageAdapter(storage.createDirectory());
250
+ await adapter.save(['test', '1'], bufferToArray(Buffer.from('one')));
251
+ await adapter.save(['test', '2'], bufferToArray(Buffer.from('two')));
252
+ await adapter.save(['bar', '1'], bufferToArray(Buffer.from('bar')));
253
+ }
254
+
255
+ {
256
+ const storage = createStorage({ type: StorageType.NODE, root });
257
+ const adapter = new AutomergeStorageAdapter(storage.createDirectory());
258
+ await adapter.removeRange(['test']);
259
+ const range = await adapter.loadRange(['test']);
260
+ expect(range.map((chunk) => arrayToBuffer(chunk.data!).toString())).toEqual([]);
261
+ const range2 = await adapter.loadRange(['bar']);
262
+ expect(range2.map((chunk) => arrayToBuffer(chunk.data!).toString())).toEqual(['bar']);
263
+ expect(range2.map((chunk) => chunk.key)).toEqual([['bar', '1']]);
264
+ }
265
+ });
103
266
  });
104
- });
105
267
 
106
- type Context = { client: Trigger<TestAdapter>; host: Trigger<TestAdapter> };
268
+ test('replication though a 4 peer chain', async () => {
269
+ const pairAB = TestAdapter.createPair();
270
+ const pairBC = TestAdapter.createPair();
271
+ const pairCD = TestAdapter.createPair();
272
+
273
+ const repoA = new Repo({
274
+ peerId: 'A' as any,
275
+ network: [pairAB[0]],
276
+ sharePolicy: async () => true,
277
+ });
278
+ const _repoB = new Repo({
279
+ peerId: 'B' as any,
280
+ network: [pairAB[1], pairBC[0]],
281
+ sharePolicy: async () => true,
282
+ });
283
+ const _repoC = new Repo({
284
+ peerId: 'C' as any,
285
+ network: [pairBC[1], pairCD[0]],
286
+ sharePolicy: async () => true,
287
+ });
288
+ const repoD = new Repo({
289
+ peerId: 'D' as any,
290
+ network: [pairCD[1]],
291
+ sharePolicy: async () => true,
292
+ });
293
+
294
+ for (const pair of [pairAB, pairBC, pairCD]) {
295
+ pair[0].ready();
296
+ pair[1].ready();
297
+ await pair[0].onConnect.wait();
298
+ await pair[1].onConnect.wait();
299
+ pair[0].peerCandidate(pair[1].peerId!);
300
+ pair[1].peerCandidate(pair[0].peerId!);
301
+ }
302
+
303
+ const docA = repoA.create();
304
+ // NOTE: Doesn't work if the doc is empty.
305
+ docA.change((doc: any) => {
306
+ doc.text = 'Hello world';
307
+ });
308
+
309
+ // If we wait here for replication to finish naturally, the test will pass.
310
+
311
+ const docD = repoD.find(docA.url);
312
+
313
+ await docD.whenReady();
314
+ });
315
+
316
+ test('replication though a 3 peer chain', async () => {
317
+ const pairAB = TestAdapter.createPair();
318
+ const pairBC = TestAdapter.createPair();
319
+
320
+ const repoA = new Repo({
321
+ peerId: 'A' as any,
322
+ network: [pairAB[0]],
323
+ sharePolicy: async () => true,
324
+ });
325
+ const repoB = new Repo({
326
+ peerId: 'B' as any,
327
+ network: [pairAB[1], pairBC[0]],
328
+ sharePolicy: async () => true,
329
+ });
330
+ const repoC = new Repo({
331
+ peerId: 'C' as any,
332
+ network: [pairBC[1]],
333
+ sharePolicy: async () => true,
334
+ });
335
+
336
+ for (const pair of [pairAB, pairBC]) {
337
+ pair[0].ready();
338
+ pair[1].ready();
339
+ await pair[0].onConnect.wait();
340
+ await pair[1].onConnect.wait();
341
+ pair[0].peerCandidate(pair[1].peerId!);
342
+ pair[1].peerCandidate(pair[0].peerId!);
343
+ }
344
+
345
+ const docA = repoA.create();
346
+ // NOTE: Doesn't work if the doc is empty.
347
+ docA.change((doc: any) => {
348
+ doc.text = 'Hello world';
349
+ });
350
+
351
+ const _docB = repoB.find(docA.url);
352
+ const docC = repoC.find(docA.url);
353
+
354
+ await docC.whenReady();
355
+ });
356
+
357
+ test('replicate document after request', async () => {
358
+ const [adapter1, adapter2] = TestAdapter.createPair();
359
+ const repoA = new Repo({
360
+ peerId: 'A' as any,
361
+ network: [adapter1],
362
+ sharePolicy: async () => true,
363
+ });
364
+ const repoB = new Repo({
365
+ peerId: 'B' as any,
366
+ network: [adapter2],
367
+ sharePolicy: async () => true,
368
+ });
369
+
370
+ const unavailable: HandleState = 'unavailable';
371
+
372
+ {
373
+ // Connect repos.
374
+ adapter1.ready();
375
+ adapter2.ready();
376
+ await adapter1.onConnect.wait();
377
+ await adapter2.onConnect.wait();
378
+ }
379
+
380
+ const docA = repoA.create();
381
+ // NOTE: Doesn't work if the doc is empty.
382
+ docA.change((doc: any) => {
383
+ doc.text = 'Hello world';
384
+ });
385
+
386
+ const docB = repoB.find(docA.url);
387
+ {
388
+ // Request document from repoB.
389
+ await asyncTimeout(docB.whenReady([unavailable]), 1_000);
390
+ }
391
+
392
+ {
393
+ // Failing to find a document.
394
+ // await (docB.whenReady([unavailable]), 1_000);
395
+ }
396
+
397
+ {
398
+ adapter1.peerCandidate(adapter2.peerId!);
399
+ await sleep(100);
400
+ adapter2.peerCandidate(adapter1.peerId!);
401
+ }
402
+
403
+ {
404
+ await asyncTimeout(docB.whenReady([unavailable]), 1_000);
405
+ }
406
+
407
+ {
408
+ // Note: Retry hack.
409
+ adapter1.peerDisconnected(adapter2.peerId!);
410
+ adapter2.peerDisconnected(adapter1.peerId!);
411
+ adapter1.peerCandidate(adapter2.peerId!);
412
+ adapter2.peerCandidate(adapter1.peerId!);
413
+
414
+ await asyncTimeout(docB.whenReady(), 1_000);
415
+ }
416
+ });
417
+ });
107
418
 
108
419
  class TestAdapter extends NetworkAdapter {
109
- constructor(public readonly context: Context, public readonly role: 'host' | 'client') {
420
+ static createPair() {
421
+ const adapter1: TestAdapter = new TestAdapter({
422
+ send: (message: Message) => sleep(10).then(() => adapter2.receive(message)),
423
+ });
424
+ const adapter2: TestAdapter = new TestAdapter({
425
+ send: (message: Message) => sleep(10).then(() => adapter1.receive(message)),
426
+ });
427
+
428
+ return [adapter1, adapter2];
429
+ }
430
+
431
+ public onConnect = new Trigger();
432
+
433
+ constructor(private readonly _params: { send: (message: Message) => void }) {
110
434
  super();
111
435
  }
112
436
 
@@ -118,32 +442,26 @@ class TestAdapter extends NetworkAdapter {
118
442
 
119
443
  override connect(peerId: PeerId) {
120
444
  this.peerId = peerId;
121
- this.context[this.role].wake(this);
122
- this.context[this.role === 'host' ? 'client' : 'host']
123
- .wait()
124
- .then((adapter) => {
125
- invariant(adapter.peerId, 'Peer id is not set');
126
- this.emit('peer-candidate', { peerId: adapter.peerId });
127
- })
128
- .catch((error) => {
129
- log.catch(error);
130
- });
445
+ this.onConnect.wake();
446
+ }
447
+
448
+ peerCandidate(peerId: PeerId) {
449
+ invariant(peerId, 'PeerId is required');
450
+ this.emit('peer-candidate', { peerId, peerMetadata: {} });
451
+ }
452
+
453
+ peerDisconnected(peerId: PeerId) {
454
+ invariant(peerId, 'PeerId is required');
455
+ this.emit('peer-disconnected', { peerId });
131
456
  }
132
457
 
133
458
  override send(message: Message) {
134
- this.context[this.role === 'host' ? 'client' : 'host']
135
- .wait()
136
- .then((adapter) => {
137
- adapter.receive(message);
138
- })
139
- .catch((error) => {
140
- log.catch(error);
141
- });
459
+ log('send', { from: message.senderId, to: message.targetId, type: message.type });
460
+ this._params.send(message);
142
461
  }
143
462
 
144
463
  override disconnect() {
145
464
  this.peerId = undefined;
146
- this.context[this.role].reset();
147
465
  }
148
466
 
149
467
  receive(message: Message) {