@dxos/echo-pipeline 0.3.11-main.00a28cb → 0.3.11-main.0999ea0

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.
@@ -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
10
  import { type Message, NetworkAdapter, type PeerId, Repo } 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);
79
+ });
80
+
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
+ }
75
146
  });
76
147
 
77
- test('doubled connection', async () => {});
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,135 @@ 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
+ });
266
+ });
267
+
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();
103
314
  });
104
- });
105
315
 
106
- type Context = { client: Trigger<TestAdapter>; host: Trigger<TestAdapter> };
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
+ });
107
357
 
108
358
  class TestAdapter extends NetworkAdapter {
109
- constructor(public readonly context: Context, public readonly role: 'host' | 'client') {
359
+ static createPair() {
360
+ const adapter1: TestAdapter = new TestAdapter({
361
+ send: (message: Message) => sleep(10).then(() => adapter2.receive(message)),
362
+ });
363
+ const adapter2: TestAdapter = new TestAdapter({
364
+ send: (message: Message) => sleep(10).then(() => adapter1.receive(message)),
365
+ });
366
+
367
+ return [adapter1, adapter2];
368
+ }
369
+
370
+ public onConnect = new Trigger();
371
+
372
+ constructor(private readonly _params: { send: (message: Message) => void }) {
110
373
  super();
111
374
  }
112
375
 
@@ -118,32 +381,26 @@ class TestAdapter extends NetworkAdapter {
118
381
 
119
382
  override connect(peerId: PeerId) {
120
383
  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
- });
384
+ this.onConnect.wake();
385
+ }
386
+
387
+ peerCandidate(peerId: PeerId) {
388
+ invariant(peerId, 'PeerId is required');
389
+ this.emit('peer-candidate', { peerId, peerMetadata: {} });
390
+ }
391
+
392
+ peerDisconnected(peerId: PeerId) {
393
+ invariant(peerId, 'PeerId is required');
394
+ this.emit('peer-disconnected', { peerId });
131
395
  }
132
396
 
133
397
  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
- });
398
+ log('send', { from: message.senderId, to: message.targetId, type: message.type });
399
+ this._params.send(message);
142
400
  }
143
401
 
144
402
  override disconnect() {
145
403
  this.peerId = undefined;
146
- this.context[this.role].reset();
147
404
  }
148
405
 
149
406
  receive(message: Message) {
@@ -2,6 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import { Trigger } from '@dxos/async';
5
6
  import {
6
7
  Repo,
7
8
  NetworkAdapter,
@@ -15,12 +16,13 @@ import {
15
16
  import { IndexedDBStorageAdapter } from '@dxos/automerge/automerge-repo-storage-indexeddb';
16
17
  import { Stream } from '@dxos/codec-protobuf';
17
18
  import { invariant } from '@dxos/invariant';
19
+ import { PublicKey } from '@dxos/keys';
18
20
  import { log } from '@dxos/log';
19
21
  import { type HostInfo, type SyncRepoRequest, type SyncRepoResponse } from '@dxos/protocols/proto/dxos/echo/service';
20
22
  import { type PeerInfo } from '@dxos/protocols/proto/dxos/mesh/teleport/automerge';
21
23
  import { StorageType, type Directory } from '@dxos/random-access-storage';
22
24
  import { AutomergeReplicator } from '@dxos/teleport-extension-automerge-replicator';
23
- import { arrayToBuffer, bufferToArray } from '@dxos/util';
25
+ import { ComplexMap, ComplexSet, arrayToBuffer, bufferToArray, defaultMap } from '@dxos/util';
24
26
 
25
27
  export class AutomergeHost {
26
28
  private readonly _repo: Repo;
@@ -28,6 +30,11 @@ export class AutomergeHost {
28
30
  private readonly _clientNetwork: LocalHostNetworkAdapter;
29
31
  private readonly _storage: StorageAdapter;
30
32
 
33
+ /**
34
+ * spaceKey -> deviceKey[]
35
+ */
36
+ private readonly _authorizedDevices = new ComplexMap<PublicKey, ComplexSet<PublicKey>>(PublicKey.hash);
37
+
31
38
  constructor(storageDirectory: Directory) {
32
39
  this._meshNetwork = new MeshNetworkAdapter();
33
40
  this._clientNetwork = new LocalHostNetworkAdapter();
@@ -38,11 +45,52 @@ export class AutomergeHost {
38
45
  ? new IndexedDBStorageAdapter(storageDirectory.path, 'data')
39
46
  : new AutomergeStorageAdapter(storageDirectory);
40
47
  this._repo = new Repo({
48
+ peerId: `host-${PublicKey.random().toHex()}` as PeerId,
41
49
  network: [this._clientNetwork, this._meshNetwork],
42
50
  storage: this._storage,
43
51
 
44
52
  // TODO(dmaretskyi): Share based on HALO permissions and space affinity.
45
- sharePolicy: async (peerId, documentId) => true, // Share everything.
53
+ // Hosts, running in the worker, don't share documents unless requested by other peers.
54
+ sharePolicy: async (peerId /* device key */, documentId /* space key */) => {
55
+ if (peerId.startsWith('client-')) {
56
+ return true;
57
+ }
58
+
59
+ if (!documentId) {
60
+ return false;
61
+ }
62
+
63
+ const doc = this._repo.handles[documentId]?.docSync();
64
+ if (!doc) {
65
+ log('doc not found for share policy check', { peerId, documentId });
66
+ return false;
67
+ }
68
+
69
+ try {
70
+ if (!doc.experimental_spaceKey) {
71
+ log.warn('space key not found for share policy check', { peerId, documentId });
72
+ return false;
73
+ }
74
+
75
+ const spaceKey = PublicKey.from(doc.experimental_spaceKey);
76
+ const authorizedDevices = this._authorizedDevices.get(spaceKey);
77
+
78
+ // TODO(mykola): Hack, stop abusing `peerMetadata` field.
79
+ const deviceKeyHex = (this.repo.peerMetadataByPeerId[peerId] as any)?.dxos_deviceKey;
80
+ if (!deviceKeyHex) {
81
+ log.warn('device key not found for share policy check', { peerId, documentId });
82
+ return false;
83
+ }
84
+ const deviceKey = PublicKey.from(deviceKeyHex);
85
+
86
+ const isAuthorized = authorizedDevices?.has(deviceKey) ?? false;
87
+ log.info('share policy check', { peerId, documentId, deviceKey, spaceKey, isAuthorized });
88
+ return isAuthorized;
89
+ } catch (err) {
90
+ log.catch(err);
91
+ return false;
92
+ }
93
+ }, // Share everything.
46
94
  });
47
95
  this._clientNetwork.ready();
48
96
  this._meshNetwork.ready();
@@ -53,6 +101,7 @@ export class AutomergeHost {
53
101
  }
54
102
 
55
103
  async close() {
104
+ this._storage instanceof AutomergeStorageAdapter && (await this._storage.close());
56
105
  await this._clientNetwork.close();
57
106
  }
58
107
 
@@ -68,7 +117,7 @@ export class AutomergeHost {
68
117
  return this._clientNetwork.sendSyncMessage(request);
69
118
  }
70
119
 
71
- getHostInfo(): HostInfo {
120
+ async getHostInfo(): Promise<HostInfo> {
72
121
  return this._clientNetwork.getHostInfo();
73
122
  }
74
123
 
@@ -79,6 +128,10 @@ export class AutomergeHost {
79
128
  createExtension(): AutomergeReplicator {
80
129
  return this._meshNetwork.createExtension();
81
130
  }
131
+
132
+ authorizeDevice(spaceKey: PublicKey, deviceKey: PublicKey) {
133
+ defaultMap(this._authorizedDevices, spaceKey, () => new ComplexSet(PublicKey.hash)).add(deviceKey);
134
+ }
82
135
  }
83
136
 
84
137
  type ClientSyncState = {
@@ -104,8 +157,11 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
104
157
  });
105
158
  }
106
159
 
160
+ private _connected = new Trigger();
161
+
107
162
  override connect(peerId: PeerId): void {
108
163
  this.peerId = peerId;
164
+ this._connected.wake();
109
165
  // No-op. Client always connects first
110
166
  }
111
167
 
@@ -146,18 +202,26 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
146
202
  },
147
203
  });
148
204
 
149
- this.emit('peer-candidate', {
150
- peerId,
151
- });
205
+ this._connected
206
+ .wait({ timeout: 1_000 })
207
+ .then(() => {
208
+ this.emit('peer-candidate', {
209
+ peerMetadata: {},
210
+ peerId,
211
+ });
212
+ })
213
+ .catch((err) => log.catch(err));
152
214
  });
153
215
  }
154
216
 
155
217
  async sendSyncMessage({ id, syncMessage }: SyncRepoRequest): Promise<void> {
218
+ await this._connected.wait({ timeout: 1_000 });
156
219
  const message = cbor.decode(syncMessage!) as Message;
157
220
  this.emit('message', message);
158
221
  }
159
222
 
160
- getHostInfo(): HostInfo {
223
+ async getHostInfo(): Promise<HostInfo> {
224
+ await this._connected.wait({ timeout: 1_000 });
161
225
  invariant(this.peerId, 'Peer id not set.');
162
226
  return {
163
227
  peerId: this.peerId,
@@ -172,7 +236,7 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
172
236
  /**
173
237
  * Used to replicate with other peers over the network.
174
238
  */
175
- class MeshNetworkAdapter extends NetworkAdapter {
239
+ export class MeshNetworkAdapter extends NetworkAdapter {
176
240
  private readonly _extensions: Map<string, AutomergeReplicator> = new Map();
177
241
 
178
242
  /**
@@ -210,7 +274,7 @@ class MeshNetworkAdapter extends NetworkAdapter {
210
274
  peerId: this.peerId,
211
275
  },
212
276
  {
213
- onStartReplication: async (info) => {
277
+ onStartReplication: async (info, remotePeerId /** Teleport ID */) => {
214
278
  // Note: We store only one extension per peer.
215
279
  // There can be a case where two connected peers have more than one teleport connection between them
216
280
  // and each of them uses different teleport connections to send messages.
@@ -224,6 +288,10 @@ class MeshNetworkAdapter extends NetworkAdapter {
224
288
  // TODO(mykola): Fix race condition?
225
289
  this._extensions.set(info.id, extension);
226
290
  this.emit('peer-candidate', {
291
+ // TODO(mykola): Hack, stop abusing `peerMetadata` field.
292
+ peerMetadata: {
293
+ dxos_deviceKey: remotePeerId.toHex(),
294
+ } as any,
227
295
  peerId: info.id as PeerId,
228
296
  });
229
297
  },
@@ -233,10 +301,13 @@ class MeshNetworkAdapter extends NetworkAdapter {
233
301
  this.emit('message', message);
234
302
  },
235
303
  onClose: async () => {
236
- peerInfo &&
237
- this.emit('peer-disconnected', {
238
- peerId: peerInfo.id as PeerId,
239
- });
304
+ if (!peerInfo) {
305
+ return;
306
+ }
307
+ this.emit('peer-disconnected', {
308
+ peerId: peerInfo.id as PeerId,
309
+ });
310
+ this._extensions.delete(peerInfo.id);
240
311
  },
241
312
  },
242
313
  );
@@ -245,11 +316,18 @@ class MeshNetworkAdapter extends NetworkAdapter {
245
316
  }
246
317
 
247
318
  export class AutomergeStorageAdapter extends StorageAdapter {
319
+ // TODO(mykola): Hack for restricting automerge Repo to access storage if Host is `closed`.
320
+ // Automerge Repo do not have any lifetime management.
321
+ private _state: 'opened' | 'closed' = 'opened';
322
+
248
323
  constructor(private readonly _directory: Directory) {
249
324
  super();
250
325
  }
251
326
 
252
327
  override async load(key: StorageKey): Promise<Uint8Array | undefined> {
328
+ if (this._state !== 'opened') {
329
+ return undefined;
330
+ }
253
331
  const filename = this._getFilename(key);
254
332
  const file = this._directory.getOrCreateFile(filename);
255
333
  const { size } = await file.stat();
@@ -261,6 +339,9 @@ export class AutomergeStorageAdapter extends StorageAdapter {
261
339
  }
262
340
 
263
341
  override async save(key: StorageKey, data: Uint8Array): Promise<void> {
342
+ if (this._state !== 'opened') {
343
+ return undefined;
344
+ }
264
345
  const filename = this._getFilename(key);
265
346
  const file = this._directory.getOrCreateFile(filename);
266
347
  await file.write(0, arrayToBuffer(data));
@@ -270,13 +351,19 @@ export class AutomergeStorageAdapter extends StorageAdapter {
270
351
  }
271
352
 
272
353
  override async remove(key: StorageKey): Promise<void> {
354
+ if (this._state !== 'opened') {
355
+ return undefined;
356
+ }
273
357
  // TODO(dmaretskyi): Better deletion.
274
358
  const filename = this._getFilename(key);
275
359
  const file = this._directory.getOrCreateFile(filename);
276
- await file.truncate?.(0);
360
+ await file.destroy();
277
361
  }
278
362
 
279
363
  override async loadRange(keyPrefix: StorageKey): Promise<Chunk[]> {
364
+ if (this._state !== 'opened') {
365
+ return [];
366
+ }
280
367
  const filename = this._getFilename(keyPrefix);
281
368
  const entries = await this._directory.list();
282
369
  return Promise.all(
@@ -295,18 +382,25 @@ export class AutomergeStorageAdapter extends StorageAdapter {
295
382
  }
296
383
 
297
384
  override async removeRange(keyPrefix: StorageKey): Promise<void> {
385
+ if (this._state !== 'opened') {
386
+ return undefined;
387
+ }
298
388
  const filename = this._getFilename(keyPrefix);
299
389
  const entries = await this._directory.list();
300
390
  await Promise.all(
301
391
  entries
302
392
  .filter((entry) => entry.startsWith(filename))
303
393
  .map(async (entry): Promise<void> => {
304
- const file = this._directory.getOrCreateFile(filename);
305
- await file.truncate?.(0);
394
+ const file = this._directory.getOrCreateFile(entry);
395
+ await file.destroy();
306
396
  }),
307
397
  );
308
398
  }
309
399
 
400
+ async close(): Promise<void> {
401
+ this._state = 'closed';
402
+ }
403
+
310
404
  private _getFilename(key: StorageKey): string {
311
405
  return key.map((k) => k.replaceAll('%', '%25').replaceAll('-', '%2D')).join('-');
312
406
  }
@@ -2,4 +2,4 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- export * from './automerge-host';
5
+ export { AutomergeHost } from './automerge-host';