@dxos/echo-pipeline 0.5.0 → 0.5.1-main.0ba1ecb

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 (60) hide show
  1. package/dist/lib/browser/{chunk-KMWJLYEQ.mjs → chunk-VQQD32DM.mjs} +18 -18
  2. package/dist/lib/browser/{chunk-KMWJLYEQ.mjs.map → chunk-VQQD32DM.mjs.map} +3 -3
  3. package/dist/lib/browser/index.mjs +471 -189
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +2 -8
  7. package/dist/lib/browser/testing/index.mjs.map +4 -4
  8. package/dist/lib/node/{chunk-YZA42CKA.cjs → chunk-P7L7ICAH.cjs} +21 -21
  9. package/dist/lib/node/{chunk-YZA42CKA.cjs.map → chunk-P7L7ICAH.cjs.map} +3 -3
  10. package/dist/lib/node/index.cjs +482 -207
  11. package/dist/lib/node/index.cjs.map +4 -4
  12. package/dist/lib/node/meta.json +1 -1
  13. package/dist/lib/node/testing/index.cjs +13 -18
  14. package/dist/lib/node/testing/index.cjs.map +4 -4
  15. package/dist/types/src/automerge/automerge-doc-loader.d.ts +3 -3
  16. package/dist/types/src/automerge/automerge-doc-loader.d.ts.map +1 -1
  17. package/dist/types/src/automerge/automerge-host.d.ts +15 -6
  18. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  19. package/dist/types/src/automerge/echo-network-adapter.d.ts +26 -0
  20. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -0
  21. package/dist/types/src/automerge/echo-replicator.d.ts +43 -0
  22. package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -0
  23. package/dist/types/src/automerge/index.d.ts +1 -2
  24. package/dist/types/src/automerge/index.d.ts.map +1 -1
  25. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +3 -2
  26. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
  27. package/dist/types/src/automerge/migrations.d.ts +1 -1
  28. package/dist/types/src/automerge/migrations.d.ts.map +1 -1
  29. package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
  30. package/dist/types/src/space/control-pipeline.d.ts +1 -1
  31. package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
  32. package/dist/types/src/testing/index.d.ts +0 -1
  33. package/dist/types/src/testing/index.d.ts.map +1 -1
  34. package/package.json +33 -30
  35. package/src/automerge/automerge-doc-loader.test.ts +1 -1
  36. package/src/automerge/automerge-doc-loader.ts +26 -16
  37. package/src/automerge/automerge-host.test.ts +33 -16
  38. package/src/automerge/automerge-host.ts +105 -21
  39. package/src/automerge/automerge-repo.test.ts +38 -6
  40. package/src/automerge/echo-network-adapter.ts +155 -0
  41. package/src/automerge/echo-replicator.ts +56 -0
  42. package/src/automerge/index.ts +1 -2
  43. package/src/automerge/leveldb-storage-adapter.ts +2 -2
  44. package/src/automerge/migrations.ts +1 -1
  45. package/src/automerge/storage-adapter.test.ts +1 -1
  46. package/src/pipeline/pipeline.ts +1 -0
  47. package/src/space/control-pipeline.ts +2 -2
  48. package/src/testing/index.ts +0 -1
  49. package/dist/types/src/automerge/level.test.d.ts +0 -2
  50. package/dist/types/src/automerge/level.test.d.ts.map +0 -1
  51. package/dist/types/src/automerge/reference.d.ts +0 -15
  52. package/dist/types/src/automerge/reference.d.ts.map +0 -1
  53. package/dist/types/src/automerge/types.d.ts +0 -73
  54. package/dist/types/src/automerge/types.d.ts.map +0 -1
  55. package/dist/types/src/testing/level.d.ts +0 -3
  56. package/dist/types/src/testing/level.d.ts.map +0 -1
  57. package/src/automerge/level.test.ts +0 -82
  58. package/src/automerge/reference.ts +0 -31
  59. package/src/automerge/types.ts +0 -86
  60. package/src/testing/level.ts +0 -11
@@ -6,13 +6,12 @@ import { Event } from '@dxos/async';
6
6
  import { type DocHandle, type AutomergeUrl, type DocumentId, type Repo } from '@dxos/automerge/automerge-repo';
7
7
  import { cancelWithContext, type Context } from '@dxos/context';
8
8
  import { warnAfterTimeout } from '@dxos/debug';
9
+ import { type SpaceState, type SpaceDoc } from '@dxos/echo-protocol';
9
10
  import { invariant } from '@dxos/invariant';
10
11
  import { type PublicKey } from '@dxos/keys';
11
12
  import { log } from '@dxos/log';
12
13
  import { trace } from '@dxos/tracing';
13
14
 
14
- import { type SpaceState, type SpaceDoc } from './types';
15
-
16
15
  type SpaceDocumentLinks = SpaceDoc['links'];
17
16
 
18
17
  export interface AutomergeDocumentLoader {
@@ -21,7 +20,7 @@ export interface AutomergeDocumentLoader {
21
20
  getAllHandles(): DocHandle<SpaceDoc>[];
22
21
 
23
22
  loadSpaceRootDocHandle(ctx: Context, spaceState: SpaceState): Promise<void>;
24
- loadObjectDocument(objectId: string): void;
23
+ loadObjectDocument(objectId: string | string[]): void;
25
24
  getSpaceRootDocHandle(): DocHandle<SpaceDoc>;
26
25
  createDocumentForObject(objectId: string): DocHandle<SpaceDoc>;
27
26
  onObjectLinksUpdated(links: SpaceDocumentLinks): void;
@@ -57,7 +56,9 @@ export class AutomergeDocumentLoaderImpl implements AutomergeDocumentLoader {
57
56
  ) {}
58
57
 
59
58
  getAllHandles(): DocHandle<SpaceDoc>[] {
60
- return [...new Set(this._objectDocumentHandles.values())];
59
+ return this._spaceRootDocHandle != null
60
+ ? [this._spaceRootDocHandle, ...new Set(this._objectDocumentHandles.values())]
61
+ : [];
61
62
  }
62
63
 
63
64
  @trace.span({ showInBrowserTimeline: true })
@@ -79,20 +80,29 @@ export class AutomergeDocumentLoaderImpl implements AutomergeDocumentLoader {
79
80
  }
80
81
  }
81
82
 
82
- public loadObjectDocument(objectId: string) {
83
- invariant(this._spaceRootDocHandle);
84
- if (this._objectDocumentHandles.has(objectId) || this._objectsPendingDocumentLoad.has(objectId)) {
85
- return;
83
+ public loadObjectDocument(objectIdOrMany: string | string[]) {
84
+ const objectIds = Array.isArray(objectIdOrMany) ? objectIdOrMany : [objectIdOrMany];
85
+ let hasUrlsToLoad = false;
86
+ const urlsToLoad: SpaceDoc['links'] = {};
87
+ for (const objectId of objectIds) {
88
+ invariant(this._spaceRootDocHandle);
89
+ if (this._objectDocumentHandles.has(objectId) || this._objectsPendingDocumentLoad.has(objectId)) {
90
+ continue;
91
+ }
92
+ const spaceRootDoc = this._spaceRootDocHandle.docSync();
93
+ invariant(spaceRootDoc);
94
+ const documentUrl = (spaceRootDoc.links ?? {})[objectId];
95
+ if (documentUrl == null) {
96
+ this._objectsPendingDocumentLoad.add(objectId);
97
+ log.info('loading delayed until object links are initialized', { objectId });
98
+ } else {
99
+ urlsToLoad[objectId] = documentUrl;
100
+ hasUrlsToLoad = true;
101
+ }
86
102
  }
87
- const spaceRootDoc = this._spaceRootDocHandle.docSync();
88
- invariant(spaceRootDoc);
89
- const documentUrl = (spaceRootDoc.links ?? {})[objectId];
90
- if (documentUrl == null) {
91
- this._objectsPendingDocumentLoad.add(objectId);
92
- log.info('loading delayed until object links are initialized', { objectId });
93
- return;
103
+ if (hasUrlsToLoad) {
104
+ this._loadLinkedObjects(urlsToLoad);
94
105
  }
95
- this._loadLinkedObjects({ [objectId]: documentUrl });
96
106
  }
97
107
 
98
108
  public onObjectLinksUpdated(links: SpaceDocumentLinks) {
@@ -15,17 +15,17 @@ import {
15
15
  type HandleState,
16
16
  type DocumentId,
17
17
  } from '@dxos/automerge/automerge-repo';
18
+ import { IndexMetadataStore } from '@dxos/indexing';
18
19
  import { invariant } from '@dxos/invariant';
20
+ import { createTestLevel } from '@dxos/kv-store/testing';
19
21
  import { log } from '@dxos/log';
20
- import { StorageType, createStorage } from '@dxos/random-access-storage';
21
22
  import { TestBuilder as TeleportBuilder, TestPeer as TeleportPeer } from '@dxos/teleport/testing';
22
- import { afterTest, describe, test } from '@dxos/test';
23
+ import { afterTest, describe, openAndClose, test } from '@dxos/test';
23
24
  import { arrayToBuffer, bufferToArray } from '@dxos/util';
24
25
 
25
26
  import { AutomergeHost } from './automerge-host';
26
- import { AutomergeStorageAdapter } from './automerge-storage-adapter';
27
+ import { LevelDBStorageAdapter } from './leveldb-storage-adapter';
27
28
  import { MeshNetworkAdapter } from './mesh-network-adapter';
28
- import { createTestLevel } from '../testing';
29
29
 
30
30
  describe('AutomergeHost', () => {
31
31
  test('can create documents', async () => {
@@ -34,6 +34,7 @@ describe('AutomergeHost', () => {
34
34
  afterTest(() => level.close());
35
35
  const host = new AutomergeHost({
36
36
  db: level.sublevel('automerge'),
37
+ indexMetadataStore: new IndexMetadataStore({ db: level.sublevel('index-metadata') }),
37
38
  });
38
39
  await host.open();
39
40
  afterTest(() => host.close());
@@ -51,7 +52,10 @@ describe('AutomergeHost', () => {
51
52
  await level.open();
52
53
  afterTest(() => level.close());
53
54
 
54
- const host = new AutomergeHost({ db: level.sublevel('automerge') });
55
+ const host = new AutomergeHost({
56
+ db: level.sublevel('automerge'),
57
+ indexMetadataStore: new IndexMetadataStore({ db: level.sublevel('index-metadata') }),
58
+ });
55
59
  await host.open();
56
60
  const handle = host.repo.create();
57
61
  handle.change((doc: any) => {
@@ -62,7 +66,10 @@ describe('AutomergeHost', () => {
62
66
  await host.repo.flush();
63
67
  await host.close();
64
68
 
65
- const host2 = new AutomergeHost({ db: level.sublevel('automerge') });
69
+ const host2 = new AutomergeHost({
70
+ db: level.sublevel('automerge'),
71
+ indexMetadataStore: new IndexMetadataStore({ db: level.sublevel('index-metadata') }),
72
+ });
66
73
  await host2.open();
67
74
  afterTest(() => host2.close());
68
75
  const handle2 = host2.repo.find(url);
@@ -332,20 +339,25 @@ describe('AutomergeHost', () => {
332
339
  });
333
340
 
334
341
  describe('storage', () => {
335
- test('load range on node', async () => {
342
+ test('loadRange', async () => {
336
343
  const root = `/tmp/${randomBytes(16).toString('hex')}`;
337
344
  {
338
- const storage = createStorage({ type: StorageType.NODE, root });
339
- const adapter = new AutomergeStorageAdapter(storage.createDirectory());
345
+ const level = createTestLevel(root);
346
+ const adapter = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
347
+ await level.open();
348
+ await adapter.open();
340
349
 
341
350
  await adapter.save(['test', '1'], bufferToArray(Buffer.from('one')));
342
351
  await adapter.save(['test', '2'], bufferToArray(Buffer.from('two')));
343
352
  await adapter.save(['bar', '1'], bufferToArray(Buffer.from('bar')));
353
+ await adapter.close();
354
+ await level.close();
344
355
  }
345
356
 
346
357
  {
347
- const storage = createStorage({ type: StorageType.NODE, root });
348
- const adapter = new AutomergeStorageAdapter(storage.createDirectory());
358
+ const level = createTestLevel(root);
359
+ const adapter = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
360
+ await openAndClose(level, adapter);
349
361
 
350
362
  const range = await adapter.loadRange(['test']);
351
363
  expect(range.map((chunk) => arrayToBuffer(chunk.data!).toString())).toEqual(['one', 'two']);
@@ -356,19 +368,24 @@ describe('AutomergeHost', () => {
356
368
  }
357
369
  });
358
370
 
359
- test('removeRange on node', async () => {
371
+ test('removeRange', async () => {
360
372
  const root = `/tmp/${randomBytes(16).toString('hex')}`;
361
373
  {
362
- const storage = createStorage({ type: StorageType.NODE, root });
363
- const adapter = new AutomergeStorageAdapter(storage.createDirectory());
374
+ const level = createTestLevel(root);
375
+ const adapter = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
376
+ await level.open();
377
+ await adapter.open();
364
378
  await adapter.save(['test', '1'], bufferToArray(Buffer.from('one')));
365
379
  await adapter.save(['test', '2'], bufferToArray(Buffer.from('two')));
366
380
  await adapter.save(['bar', '1'], bufferToArray(Buffer.from('bar')));
381
+ await adapter.close();
382
+ await level.close();
367
383
  }
368
384
 
369
385
  {
370
- const storage = createStorage({ type: StorageType.NODE, root });
371
- const adapter = new AutomergeStorageAdapter(storage.createDirectory());
386
+ const level = createTestLevel(root);
387
+ const adapter = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
388
+ await openAndClose(level, adapter);
372
389
  await adapter.removeRange(['test']);
373
390
  const range = await adapter.loadRange(['test']);
374
391
  expect(range.map((chunk) => arrayToBuffer(chunk.data!).toString())).toEqual([]);
@@ -2,13 +2,25 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { asyncTimeout } from '@dxos/async';
6
- import { next as automerge } from '@dxos/automerge/automerge';
7
- import { Repo, type DocumentId, type PeerId, type StorageAdapterInterface } from '@dxos/automerge/automerge-repo';
5
+ import { Event } from '@dxos/async';
6
+ import { type Doc, next as automerge, getBackend, type Heads, getHeads } from '@dxos/automerge/automerge';
7
+ import {
8
+ type DocHandle,
9
+ Repo,
10
+ type DocumentId,
11
+ type PeerId,
12
+ type StorageAdapterInterface,
13
+ type DocHandleChangePayload,
14
+ } from '@dxos/automerge/automerge-repo';
8
15
  import { type Stream } from '@dxos/codec-protobuf';
9
16
  import { Context, type Lifecycle } from '@dxos/context';
17
+ import { type SpaceDoc } from '@dxos/echo-protocol';
18
+ import { type IndexMetadataStore } from '@dxos/indexing';
19
+ import { invariant } from '@dxos/invariant';
10
20
  import { PublicKey } from '@dxos/keys';
21
+ import { type SubLevelDB } from '@dxos/kv-store';
11
22
  import { log } from '@dxos/log';
23
+ import { idCodec } from '@dxos/protocols';
12
24
  import {
13
25
  type FlushRequest,
14
26
  type HostInfo,
@@ -20,11 +32,12 @@ import { type AutomergeReplicator } from '@dxos/teleport-extension-automerge-rep
20
32
  import { trace } from '@dxos/tracing';
21
33
  import { ComplexMap, ComplexSet, defaultMap, mapValues } from '@dxos/util';
22
34
 
23
- import { LevelDBStorageAdapter, type StorageCallbacks } from './leveldb-storage-adapter';
35
+ import { EchoNetworkAdapter } from './echo-network-adapter';
36
+ import { type EchoReplicator } from './echo-replicator';
37
+ import { type BeforeSaveParams, LevelDBStorageAdapter } from './leveldb-storage-adapter';
24
38
  import { LocalHostNetworkAdapter } from './local-host-network-adapter';
25
39
  import { MeshNetworkAdapter } from './mesh-network-adapter';
26
40
  import { levelMigration } from './migrations';
27
- import { type SubLevelDB } from './types';
28
41
 
29
42
  // TODO: Remove
30
43
  export type { DocumentId };
@@ -35,15 +48,17 @@ export type AutomergeHostParams = {
35
48
  * For migration purposes.
36
49
  */
37
50
  directory?: Directory;
38
- storageCallbacks?: StorageCallbacks;
51
+
52
+ indexMetadataStore: IndexMetadataStore;
39
53
  };
40
54
 
41
55
  @trace.resource()
42
56
  export class AutomergeHost {
57
+ private readonly _indexMetadataStore: IndexMetadataStore;
43
58
  private readonly _ctx = new Context();
44
59
  private readonly _directory?: Directory;
45
60
  private readonly _db: SubLevelDB;
46
- private readonly _storageCallbacks?: StorageCallbacks;
61
+ private readonly _echoNetworkAdapter = new EchoNetworkAdapter();
47
62
 
48
63
  private _repo!: Repo;
49
64
  private _meshNetwork!: MeshNetworkAdapter;
@@ -60,10 +75,10 @@ export class AutomergeHost {
60
75
 
61
76
  public _requestedDocs = new Set<string>();
62
77
 
63
- constructor({ directory, db, storageCallbacks }: AutomergeHostParams) {
78
+ constructor({ directory, db, indexMetadataStore }: AutomergeHostParams) {
64
79
  this._directory = directory;
65
80
  this._db = db;
66
- this._storageCallbacks = storageCallbacks;
81
+ this._indexMetadataStore = indexMetadataStore;
67
82
  }
68
83
 
69
84
  async open() {
@@ -71,16 +86,20 @@ export class AutomergeHost {
71
86
  this._directory && (await levelMigration({ db: this._db, directory: this._directory }));
72
87
  this._storage = new LevelDBStorageAdapter({
73
88
  db: this._db,
74
- callbacks: this._storageCallbacks,
89
+ callbacks: {
90
+ beforeSave: async (params) => this._beforeSave(params),
91
+ afterSave: async () => this._afterSave(),
92
+ },
75
93
  });
76
94
  await this._storage.open?.();
77
95
  this._peerId = `host-${PublicKey.random().toHex()}` as PeerId;
78
96
 
79
97
  this._meshNetwork = new MeshNetworkAdapter();
80
98
  this._clientNetwork = new LocalHostNetworkAdapter();
99
+
81
100
  this._repo = new Repo({
82
101
  peerId: this._peerId as PeerId,
83
- network: [this._clientNetwork, this._meshNetwork],
102
+ network: [this._clientNetwork, this._meshNetwork, this._echoNetworkAdapter],
84
103
  storage: this._storage,
85
104
 
86
105
  // TODO(dmaretskyi): Share based on HALO permissions and space affinity.
@@ -94,6 +113,11 @@ export class AutomergeHost {
94
113
  return false;
95
114
  }
96
115
 
116
+ const peerMetadata = this.repo.peerMetadataByPeerId[peerId];
117
+ if ((peerMetadata as any)?.dxos_peerSource === 'EchoNetworkAdapter') {
118
+ return this._echoNetworkAdapter.shouldAdvertize(peerId, { documentId });
119
+ }
120
+
97
121
  const doc = this._repo.handles[documentId]?.docSync();
98
122
  if (!doc) {
99
123
  const isRequested = this._requestedDocs.has(`automerge:${documentId}`);
@@ -111,7 +135,7 @@ export class AutomergeHost {
111
135
  const authorizedDevices = this._authorizedDevices.get(PublicKey.from(spaceKey));
112
136
 
113
137
  // TODO(mykola): Hack, stop abusing `peerMetadata` field.
114
- const deviceKeyHex = (this.repo.peerMetadataByPeerId[peerId] as any)?.dxos_deviceKey;
138
+ const deviceKeyHex = (peerMetadata as any)?.dxos_deviceKey;
115
139
  if (!deviceKeyHex) {
116
140
  log('device key not found for share policy check', { peerId, documentId });
117
141
  return false;
@@ -136,13 +160,16 @@ export class AutomergeHost {
136
160
  });
137
161
  this._clientNetwork.ready();
138
162
  this._meshNetwork.ready();
163
+ await this._echoNetworkAdapter.open();
139
164
 
140
165
  await this._clientNetwork.whenConnected();
166
+ await this._echoNetworkAdapter.whenConnected();
141
167
  }
142
168
 
143
169
  async close() {
144
170
  await this._storage.close?.();
145
171
  await this._clientNetwork.close();
172
+ await this._echoNetworkAdapter.close();
146
173
  await this._ctx.dispose();
147
174
  }
148
175
 
@@ -150,6 +177,39 @@ export class AutomergeHost {
150
177
  return this._repo;
151
178
  }
152
179
 
180
+ async addReplicator(replicator: EchoReplicator) {
181
+ await this._echoNetworkAdapter.addReplicator(replicator);
182
+ }
183
+
184
+ async removeReplicator(replicator: EchoReplicator) {
185
+ await this._echoNetworkAdapter.removeReplicator(replicator);
186
+ }
187
+
188
+ private async _beforeSave({ path, batch }: BeforeSaveParams) {
189
+ const handle = this._repo.handles[path[0] as DocumentId];
190
+ if (!handle) {
191
+ return;
192
+ }
193
+ const doc = handle.docSync();
194
+ if (!doc) {
195
+ return;
196
+ }
197
+
198
+ const lastAvailableHash = getHeads(doc);
199
+
200
+ const objectIds = Object.keys(doc.objects ?? {});
201
+ const encodedIds = objectIds.map((objectId) => idCodec.encode({ documentId: handle.documentId, objectId }));
202
+ const idToLastHash = new Map(encodedIds.map((id) => [id, lastAvailableHash]));
203
+ this._indexMetadataStore.markDirty(idToLastHash, batch);
204
+ }
205
+
206
+ /**
207
+ * Called by AutomergeStorageAdapter after levelDB batch commit.
208
+ */
209
+ private async _afterSave() {
210
+ this._indexMetadataStore.notifyMarkedDirty();
211
+ }
212
+
153
213
  @trace.info({ depth: null })
154
214
  private _automergeDocs() {
155
215
  return mapValues(this._repo.handles, (handle) => ({
@@ -185,16 +245,17 @@ export class AutomergeHost {
185
245
  // Methods for client-services.
186
246
  //
187
247
  @trace.span({ showInBrowserTimeline: true })
188
- async flush({ documentIds }: FlushRequest): Promise<void> {
248
+ async flush({ states }: FlushRequest): Promise<void> {
189
249
  // Note: Wait for all requested documents to be loaded/synced from thin-client.
190
- await Promise.all(documentIds?.map((id) => this._repo.find(id as DocumentId).whenReady()) ?? []);
191
-
192
- // TODO(dmaretskyi): Workaround until the flush issue gets resolved.
193
- try {
194
- await asyncTimeout(this._repo.flush(documentIds as DocumentId[]), 500);
195
- } catch (err) {
196
- log.warn('flush error', { documentIds, err });
197
- }
250
+ await Promise.all(
251
+ states?.map(async ({ heads, documentId }) => {
252
+ invariant(heads, 'heads are required for flush');
253
+ const handle = this.repo.handles[documentId as DocumentId] ?? this._repo.find(documentId as DocumentId);
254
+ await waitForHeads(handle, heads);
255
+ }) ?? [],
256
+ );
257
+
258
+ await this._repo.flush(states?.map(({ documentId }) => documentId as DocumentId));
198
259
  }
199
260
 
200
261
  syncRepo(request: SyncRepoRequest): Stream<SyncRepoResponse> {
@@ -232,3 +293,26 @@ export const getSpaceKeyFromDoc = (doc: any): string | null => {
232
293
 
233
294
  return String(rawSpaceKey);
234
295
  };
296
+
297
+ const waitForHeads = async (handle: DocHandle<SpaceDoc>, heads: Heads) => {
298
+ await handle.whenReady();
299
+ const unavailableHeads = new Set(heads);
300
+
301
+ await Event.wrap<DocHandleChangePayload<SpaceDoc>>(handle, 'change').waitForCondition(() => {
302
+ // Check if unavailable heads became available.
303
+ for (const changeHash of unavailableHeads.values()) {
304
+ if (changeIsPresentInDoc(handle.docSync(), changeHash)) {
305
+ unavailableHeads.delete(changeHash);
306
+ }
307
+ }
308
+
309
+ if (unavailableHeads.size === 0) {
310
+ return true;
311
+ }
312
+ return false;
313
+ });
314
+ };
315
+
316
+ const changeIsPresentInDoc = (doc: Doc<any>, changeHash: string): boolean => {
317
+ return !!getBackend(doc).getChangeByHash(changeHash);
318
+ };
@@ -2,19 +2,25 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { expect } from 'chai';
6
+
7
+ import { change, clone, from, getBackend, getHeads } from '@dxos/automerge/automerge';
5
8
  import { Repo } from '@dxos/automerge/automerge-repo';
6
9
  import { randomBytes } from '@dxos/crypto';
7
- import { StorageType, createStorage } from '@dxos/random-access-storage';
8
- import { describe, test } from '@dxos/test';
10
+ import { createTestLevel } from '@dxos/kv-store/testing';
11
+ import { describe, openAndClose, test } from '@dxos/test';
9
12
 
10
- import { AutomergeStorageAdapter } from './automerge-storage-adapter';
13
+ import { LevelDBStorageAdapter } from './leveldb-storage-adapter';
11
14
 
12
15
  describe('AutomergeRepo', () => {
13
- // Currently failing
14
- test.skip('flush', async () => {
16
+ test('flush', async () => {
17
+ const level = createTestLevel();
18
+ const storage = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
19
+ await openAndClose(level, storage);
20
+
15
21
  const repo = new Repo({
16
22
  network: [],
17
- storage: new AutomergeStorageAdapter(createStorage({ type: StorageType.NODE }).createDirectory()),
23
+ storage,
18
24
  });
19
25
  const handle = repo.create<{ field?: string }>();
20
26
 
@@ -26,4 +32,30 @@ describe('AutomergeRepo', () => {
26
32
  await p;
27
33
  }
28
34
  });
35
+
36
+ test('getChangeByHash', async () => {
37
+ const level = createTestLevel();
38
+ const storage = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
39
+ await openAndClose(level, storage);
40
+
41
+ const doc = from({ foo: 'bar' });
42
+ const copy = clone(doc);
43
+ const newDoc = change(copy, 'change', (doc: any) => {
44
+ doc.foo = 'baz';
45
+ });
46
+
47
+ {
48
+ const heads = getHeads(newDoc);
49
+ const changes = heads.map((hash) => getBackend(newDoc).getChangeByHash(hash));
50
+ expect(changes.length).to.equal(1);
51
+ expect(changes[0]).to.not.be.null;
52
+ }
53
+
54
+ {
55
+ const heads = getHeads(newDoc);
56
+ const changes = heads.map((hash) => getBackend(doc).getChangeByHash(hash));
57
+ expect(changes.length).to.equal(1);
58
+ expect(changes[0]).to.be.null;
59
+ }
60
+ });
29
61
  });
@@ -0,0 +1,155 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { Trigger, synchronized } from '@dxos/async';
6
+ import { type Message, NetworkAdapter, type PeerId, type PeerMetadata } from '@dxos/automerge/automerge-repo';
7
+ import { LifecycleState } from '@dxos/context';
8
+ import { invariant } from '@dxos/invariant';
9
+ import { log } from '@dxos/log';
10
+
11
+ import { type EchoReplicator, type ReplicatorConnection, type ShouldAdvertizeParams } from './echo-replicator';
12
+
13
+ /**
14
+ * Manages a set of {@link EchoReplicator} instances.
15
+ */
16
+ export class EchoNetworkAdapter extends NetworkAdapter {
17
+ private readonly _replicators = new Set<EchoReplicator>();
18
+ /**
19
+ * Remote peer id -> connection.
20
+ */
21
+ private readonly _connections = new Map<PeerId, ConnectionEntry>();
22
+ private _lifecycleState: LifecycleState = LifecycleState.CLOSED;
23
+ private readonly _connected = new Trigger();
24
+
25
+ override connect(peerId: PeerId, peerMetadata?: PeerMetadata | undefined): void {
26
+ this.peerId = peerId;
27
+ this.peerMetadata = peerMetadata;
28
+ this._connected.wake();
29
+ }
30
+
31
+ override send(message: Message): void {
32
+ const connectionEntry = this._connections.get(message.targetId);
33
+ if (!connectionEntry) {
34
+ throw new Error('Connection not found.');
35
+ }
36
+
37
+ // TODO(dmaretskyi): Find a way to enforce backpressure on AM-repo.
38
+ connectionEntry.writer.write(message).catch((err) => {
39
+ if (connectionEntry.isOpen) {
40
+ log.catch(err);
41
+ }
42
+ });
43
+ }
44
+
45
+ override disconnect(): void {
46
+ // No-op
47
+ }
48
+
49
+ @synchronized
50
+ async open() {
51
+ invariant(this._lifecycleState === LifecycleState.CLOSED);
52
+ this._lifecycleState = LifecycleState.OPEN;
53
+
54
+ this.emit('ready', {
55
+ network: this,
56
+ });
57
+ }
58
+
59
+ @synchronized
60
+ async close() {
61
+ invariant(this._lifecycleState === LifecycleState.OPEN);
62
+
63
+ for (const replicator of this._replicators) {
64
+ await replicator.disconnect();
65
+ }
66
+ this._replicators.clear();
67
+
68
+ this._lifecycleState = LifecycleState.CLOSED;
69
+ }
70
+
71
+ async whenConnected() {
72
+ await this._connected.wait({ timeout: 10_000 });
73
+ }
74
+
75
+ @synchronized
76
+ async addReplicator(replicator: EchoReplicator) {
77
+ invariant(this.peerId);
78
+ invariant(!this._replicators.has(replicator));
79
+
80
+ await replicator.connect({
81
+ peerId: this.peerId,
82
+ onConnectionOpen: this._onConnectionOpen.bind(this),
83
+ onConnectionClosed: this._onConnectionClosed.bind(this),
84
+ });
85
+ }
86
+
87
+ @synchronized
88
+ async removeReplicator(replicator: EchoReplicator) {
89
+ invariant(this._replicators.has(replicator));
90
+ await replicator.disconnect();
91
+ }
92
+
93
+ async shouldAdvertize(peerId: PeerId, params: ShouldAdvertizeParams): Promise<boolean> {
94
+ const connection = this._connections.get(peerId);
95
+ if (!connection) {
96
+ return false;
97
+ }
98
+
99
+ return connection.connection.shouldAdvertize(params);
100
+ }
101
+
102
+ private _onConnectionOpen(connection: ReplicatorConnection) {
103
+ invariant(!this._connections.has(connection.peerId as PeerId));
104
+ const reader = connection.readable.getReader();
105
+ const writer = connection.writable.getWriter();
106
+ const connectionEntry: ConnectionEntry = { connection, reader, writer, isOpen: true };
107
+ this._connections.set(connection.peerId as PeerId, connectionEntry);
108
+
109
+ queueMicrotask(async () => {
110
+ try {
111
+ while (true) {
112
+ // TODO(dmaretskyi): Find a way to enforce backpressure on AM-repo.
113
+ const { done, value } = await reader.read();
114
+ if (done) {
115
+ break;
116
+ }
117
+
118
+ this.emit('message', value);
119
+ }
120
+ } catch (err) {
121
+ if (connectionEntry.isOpen) {
122
+ log.catch(err);
123
+ }
124
+ }
125
+ });
126
+
127
+ this.emit('peer-candidate', {
128
+ peerId: connection.peerId as PeerId,
129
+ peerMetadata: {
130
+ // TODO(dmaretskyi): Refactor this.
131
+ dxos_peerSource: 'EchoNetworkAdapter',
132
+ } as any,
133
+ });
134
+ }
135
+
136
+ private _onConnectionClosed(connection: ReplicatorConnection) {
137
+ const entry = this._connections.get(connection.peerId as PeerId);
138
+ invariant(entry);
139
+
140
+ entry.isOpen = false;
141
+ this.emit('peer-disconnected', { peerId: connection.peerId as PeerId });
142
+
143
+ void entry.reader.cancel().catch((err) => log.catch(err));
144
+ void entry.writer.abort().catch((err) => log.catch(err));
145
+
146
+ this._connections.delete(connection.peerId as PeerId);
147
+ }
148
+ }
149
+
150
+ type ConnectionEntry = {
151
+ connection: ReplicatorConnection;
152
+ reader: ReadableStreamDefaultReader<Message>;
153
+ writer: WritableStreamDefaultWriter<Message>;
154
+ isOpen: boolean;
155
+ };