@dxos/echo-pipeline 0.6.2 → 0.6.3-main.0308ae2

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 (66) hide show
  1. package/dist/lib/browser/{chunk-UJQ5VS5V.mjs → chunk-6MJEONOX.mjs} +2569 -1066
  2. package/dist/lib/browser/chunk-6MJEONOX.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +12 -1049
  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 +224 -2
  7. package/dist/lib/browser/testing/index.mjs.map +4 -4
  8. package/dist/lib/node/{chunk-RH6TDRML.cjs → chunk-PT5LWMPA.cjs} +3185 -1710
  9. package/dist/lib/node/chunk-PT5LWMPA.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +37 -1056
  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 +238 -13
  14. package/dist/lib/node/testing/index.cjs.map +4 -4
  15. package/dist/types/src/automerge/automerge-host.d.ts +29 -2
  16. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  17. package/dist/types/src/automerge/collection-synchronizer.d.ts +61 -0
  18. package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -0
  19. package/dist/types/src/automerge/collection-synchronizer.test.d.ts +2 -0
  20. package/dist/types/src/automerge/collection-synchronizer.test.d.ts.map +1 -0
  21. package/dist/types/src/automerge/echo-network-adapter.d.ts +9 -2
  22. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
  23. package/dist/types/src/automerge/echo-replicator.d.ts +7 -0
  24. package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
  25. package/dist/types/src/automerge/heads-store.d.ts +1 -1
  26. package/dist/types/src/automerge/heads-store.d.ts.map +1 -1
  27. package/dist/types/src/automerge/index.d.ts +2 -0
  28. package/dist/types/src/automerge/index.d.ts.map +1 -1
  29. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +3 -1
  30. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
  31. package/dist/types/src/automerge/mesh-echo-replicator.d.ts +2 -2
  32. package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
  33. package/dist/types/src/automerge/network-protocol.d.ts +31 -0
  34. package/dist/types/src/automerge/network-protocol.d.ts.map +1 -0
  35. package/dist/types/src/automerge/space-collection.d.ts +4 -0
  36. package/dist/types/src/automerge/space-collection.d.ts.map +1 -0
  37. package/dist/types/src/db-host/data-service.d.ts +2 -1
  38. package/dist/types/src/db-host/data-service.d.ts.map +1 -1
  39. package/dist/types/src/db-host/documents-synchronizer.d.ts +1 -1
  40. package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
  41. package/dist/types/src/testing/index.d.ts +1 -0
  42. package/dist/types/src/testing/index.d.ts.map +1 -1
  43. package/dist/types/src/testing/test-replicator.d.ts +46 -0
  44. package/dist/types/src/testing/test-replicator.d.ts.map +1 -0
  45. package/package.json +33 -33
  46. package/src/automerge/automerge-host.test.ts +76 -14
  47. package/src/automerge/automerge-host.ts +219 -32
  48. package/src/automerge/automerge-repo.test.ts +2 -1
  49. package/src/automerge/collection-synchronizer.test.ts +91 -0
  50. package/src/automerge/collection-synchronizer.ts +204 -0
  51. package/src/automerge/echo-network-adapter.test.ts +5 -1
  52. package/src/automerge/echo-network-adapter.ts +69 -4
  53. package/src/automerge/echo-replicator.ts +9 -0
  54. package/src/automerge/heads-store.ts +6 -9
  55. package/src/automerge/index.ts +2 -0
  56. package/src/automerge/mesh-echo-replicator-connection.ts +6 -1
  57. package/src/automerge/mesh-echo-replicator.ts +28 -7
  58. package/src/automerge/network-protocol.ts +45 -0
  59. package/src/automerge/space-collection.ts +14 -0
  60. package/src/db-host/data-service.ts +26 -12
  61. package/src/db-host/documents-synchronizer.ts +17 -5
  62. package/src/metadata/metadata-store.ts +1 -1
  63. package/src/testing/index.ts +1 -0
  64. package/src/testing/test-replicator.ts +194 -0
  65. package/dist/lib/browser/chunk-UJQ5VS5V.mjs.map +0 -7
  66. package/dist/lib/node/chunk-RH6TDRML.cjs.map +0 -7
@@ -3,16 +3,30 @@
3
3
  //
4
4
 
5
5
  import { synchronized, Trigger } from '@dxos/async';
6
- import { type Message, NetworkAdapter, type PeerId, type PeerMetadata } from '@dxos/automerge/automerge-repo';
6
+ import { NetworkAdapter, type Message, type PeerId, type PeerMetadata } from '@dxos/automerge/automerge-repo';
7
7
  import { LifecycleState } from '@dxos/context';
8
8
  import { invariant } from '@dxos/invariant';
9
9
  import { type PublicKey } from '@dxos/keys';
10
10
  import { log } from '@dxos/log';
11
-
12
- import { type EchoReplicator, type ReplicatorConnection, type ShouldAdvertiseParams } from './echo-replicator';
11
+ import { nonNullable } from '@dxos/util';
12
+
13
+ import {
14
+ type EchoReplicator,
15
+ type ReplicatorConnection,
16
+ type ShouldAdvertiseParams,
17
+ type ShouldSyncCollectionParams,
18
+ } from './echo-replicator';
19
+ import {
20
+ isCollectionQueryMessage,
21
+ isCollectionStateMessage,
22
+ type CollectionQueryMessage,
23
+ type CollectionStateMessage,
24
+ } from './network-protocol';
13
25
 
14
26
  export type EchoNetworkAdapterParams = {
15
27
  getContainingSpaceForDocument: (documentId: string) => Promise<PublicKey | null>;
28
+ onCollectionStateQueried: (collectionId: string, peerId: PeerId) => void;
29
+ onCollectionStateReceived: (collectionId: string, peerId: PeerId, state: unknown) => void;
16
30
  };
17
31
 
18
32
  /**
@@ -119,6 +133,47 @@ export class EchoNetworkAdapter extends NetworkAdapter {
119
133
  return connection.connection.shouldAdvertise(params);
120
134
  }
121
135
 
136
+ shouldSyncCollection(peerId: PeerId, params: ShouldSyncCollectionParams): boolean {
137
+ const connection = this._connections.get(peerId);
138
+ if (!connection) {
139
+ return false;
140
+ }
141
+
142
+ return connection.connection.shouldSyncCollection(params);
143
+ }
144
+
145
+ queryCollectionState(collectionId: string, targetId: PeerId): void {
146
+ const message: CollectionQueryMessage = {
147
+ type: 'collection-query',
148
+ senderId: this.peerId as PeerId,
149
+ targetId,
150
+ collectionId,
151
+ };
152
+ this.send(message);
153
+ }
154
+
155
+ sendCollectionState(collectionId: string, targetId: PeerId, state: unknown): void {
156
+ const message: CollectionStateMessage = {
157
+ type: 'collection-state',
158
+ senderId: this.peerId as PeerId,
159
+ targetId,
160
+ collectionId,
161
+ state,
162
+ };
163
+ this.send(message);
164
+ }
165
+
166
+ // TODO(dmaretskyi): Remove.
167
+ getPeersInterestedInCollection(collectionId: string): PeerId[] {
168
+ return Array.from(this._connections.values())
169
+ .map((connection) => {
170
+ return connection.connection.shouldSyncCollection({ collectionId })
171
+ ? (connection.connection.peerId as PeerId)
172
+ : null;
173
+ })
174
+ .filter(nonNullable);
175
+ }
176
+
122
177
  private _onConnectionOpen(connection: ReplicatorConnection) {
123
178
  log('Connection opened', { peerId: connection.peerId });
124
179
  invariant(!this._connections.has(connection.peerId as PeerId));
@@ -136,7 +191,7 @@ export class EchoNetworkAdapter extends NetworkAdapter {
136
191
  break;
137
192
  }
138
193
 
139
- this.emit('message', value);
194
+ this._onMessage(value);
140
195
  }
141
196
  } catch (err) {
142
197
  if (connectionEntry.isOpen) {
@@ -149,6 +204,16 @@ export class EchoNetworkAdapter extends NetworkAdapter {
149
204
  this._emitPeerCandidate(connection);
150
205
  }
151
206
 
207
+ private _onMessage(message: Message) {
208
+ if (isCollectionQueryMessage(message)) {
209
+ this._params.onCollectionStateQueried(message.collectionId, message.senderId);
210
+ } else if (isCollectionStateMessage(message)) {
211
+ this._params.onCollectionStateReceived(message.collectionId, message.senderId, message.state);
212
+ } else {
213
+ this.emit('message', message);
214
+ }
215
+ }
216
+
152
217
  /**
153
218
  * Trigger doc-synchronizer shared documents set recalculation. Happens on peer-candidate.
154
219
  * TODO(y): replace with a proper API call when sharePolicy update becomes supported by automerge-repo
@@ -51,8 +51,17 @@ export interface ReplicatorConnection {
51
51
  * The remote peer can still request the document by its id bypassing this check.
52
52
  */
53
53
  shouldAdvertise(params: ShouldAdvertiseParams): Promise<boolean>;
54
+
55
+ /**
56
+ * @returns true if the collection should be synced to this peer.
57
+ */
58
+ shouldSyncCollection(params: ShouldSyncCollectionParams): boolean;
54
59
  }
55
60
 
56
61
  export type ShouldAdvertiseParams = {
57
62
  documentId: string;
58
63
  };
64
+
65
+ export type ShouldSyncCollectionParams = {
66
+ collectionId: string;
67
+ };
@@ -26,14 +26,11 @@ export class HeadsStore {
26
26
  });
27
27
  }
28
28
 
29
- async getHeads(documentId: DocumentId): Promise<Heads | undefined> {
30
- try {
31
- return await this._db.get<DocumentId, Heads>(documentId, { keyEncoding: 'utf8', valueEncoding: headsEncoding });
32
- } catch (err: any) {
33
- if (err.notFound) {
34
- return undefined;
35
- }
36
- throw err;
37
- }
29
+ // TODO(dmaretskyi): Make batched.
30
+ async getHeads(documentIds: DocumentId[]): Promise<Array<Heads | undefined>> {
31
+ return this._db.getMany<DocumentId, Heads>(documentIds, {
32
+ keyEncoding: 'utf8',
33
+ valueEncoding: headsEncoding,
34
+ });
38
35
  }
39
36
  }
@@ -6,3 +6,5 @@ export * from './automerge-host';
6
6
  export * from './leveldb-storage-adapter';
7
7
  export * from './mesh-echo-replicator';
8
8
  export * from './echo-replicator';
9
+ export { diffCollectionState } from './collection-synchronizer';
10
+ export * from './space-collection';
@@ -9,7 +9,7 @@ import { type PublicKey } from '@dxos/keys';
9
9
  import { log } from '@dxos/log';
10
10
  import { AutomergeReplicator, type AutomergeReplicatorFactory } from '@dxos/teleport-extension-automerge-replicator';
11
11
 
12
- import type { ReplicatorConnection, ShouldAdvertiseParams } from './echo-replicator';
12
+ import type { ReplicatorConnection, ShouldAdvertiseParams, ShouldSyncCollectionParams } from './echo-replicator';
13
13
 
14
14
  const DEFAULT_FACTORY: AutomergeReplicatorFactory = (params) => new AutomergeReplicator(...params);
15
15
 
@@ -18,6 +18,7 @@ export type MeshReplicatorConnectionParams = {
18
18
  onRemoteConnected: () => void;
19
19
  onRemoteDisconnected: () => void;
20
20
  shouldAdvertise: (params: ShouldAdvertiseParams) => Promise<boolean>;
21
+ shouldSyncCollection: (params: ShouldSyncCollectionParams) => boolean;
21
22
  replicatorFactory?: AutomergeReplicatorFactory;
22
23
  };
23
24
 
@@ -112,6 +113,10 @@ export class MeshReplicatorConnection extends Resource implements ReplicatorConn
112
113
  return this._params.shouldAdvertise(params);
113
114
  }
114
115
 
116
+ shouldSyncCollection(params: ShouldSyncCollectionParams): boolean {
117
+ return this._params.shouldSyncCollection(params);
118
+ }
119
+
115
120
  /**
116
121
  * Start exchanging messages with the remote peer.
117
122
  * Call after the remote peer has connected.
@@ -3,16 +3,18 @@
3
3
  //
4
4
 
5
5
  import { invariant } from '@dxos/invariant';
6
- import { PublicKey } from '@dxos/keys';
6
+ import { PublicKey, type SpaceId } from '@dxos/keys';
7
7
  import { log } from '@dxos/log';
8
8
  import {
9
9
  type AutomergeReplicator,
10
10
  type AutomergeReplicatorFactory,
11
11
  } from '@dxos/teleport-extension-automerge-replicator';
12
- import { ComplexMap, ComplexSet, defaultMap } from '@dxos/util';
12
+ import { ComplexSet, defaultMap } from '@dxos/util';
13
13
 
14
14
  import { type EchoReplicator, type EchoReplicatorContext, type ShouldAdvertiseParams } from './echo-replicator';
15
15
  import { MeshReplicatorConnection } from './mesh-echo-replicator-connection';
16
+ import { getSpaceIdFromCollectionId } from './space-collection';
17
+ import { createIdFromSpaceKey } from '../space';
16
18
 
17
19
  // TODO(dmaretskyi): Move out of @dxos/echo-pipeline.
18
20
 
@@ -27,9 +29,9 @@ export class MeshEchoReplicator implements EchoReplicator {
27
29
  private readonly _connectionsPerPeer = new Map<string, MeshReplicatorConnection>();
28
30
 
29
31
  /**
30
- * spaceKey -> deviceKey[]
32
+ * spaceId -> deviceKey[]
31
33
  */
32
- private readonly _authorizedDevices = new ComplexMap<PublicKey, ComplexSet<PublicKey>>(PublicKey.hash);
34
+ private readonly _authorizedDevices = new Map<SpaceId, ComplexSet<PublicKey>>();
33
35
 
34
36
  private _context: EchoReplicatorContext | null = null;
35
37
 
@@ -88,7 +90,9 @@ export class MeshEchoReplicator implements EchoReplicator {
88
90
  return false;
89
91
  }
90
92
 
91
- const authorizedDevices = this._authorizedDevices.get(spaceKey);
93
+ const spaceId = await createIdFromSpaceKey(spaceKey);
94
+
95
+ const authorizedDevices = this._authorizedDevices.get(spaceId);
92
96
 
93
97
  if (!connection.remoteDeviceKey) {
94
98
  log('device key not found for share policy check', {
@@ -113,15 +117,32 @@ export class MeshEchoReplicator implements EchoReplicator {
113
117
  return false;
114
118
  }
115
119
  },
120
+ shouldSyncCollection: ({ collectionId }) => {
121
+ const spaceId = getSpaceIdFromCollectionId(collectionId);
122
+
123
+ const authorizedDevices = this._authorizedDevices.get(spaceId);
124
+
125
+ if (!connection.remoteDeviceKey) {
126
+ log('device key not found for collection sync check', {
127
+ peerId: connection.peerId,
128
+ collectionId,
129
+ });
130
+ return false;
131
+ }
132
+
133
+ const isAuthorized = authorizedDevices?.has(connection.remoteDeviceKey) ?? false;
134
+ return isAuthorized;
135
+ },
116
136
  });
117
137
  this._connections.add(connection);
118
138
 
119
139
  return connection.replicatorExtension;
120
140
  }
121
141
 
122
- authorizeDevice(spaceKey: PublicKey, deviceKey: PublicKey) {
142
+ async authorizeDevice(spaceKey: PublicKey, deviceKey: PublicKey) {
123
143
  log('authorizeDevice', { spaceKey, deviceKey });
124
- defaultMap(this._authorizedDevices, spaceKey, () => new ComplexSet(PublicKey.hash)).add(deviceKey);
144
+ const spaceId = await createIdFromSpaceKey(spaceKey);
145
+ defaultMap(this._authorizedDevices, spaceId, () => new ComplexSet(PublicKey.hash)).add(deviceKey);
125
146
  for (const connection of this._connections) {
126
147
  if (connection.remoteDeviceKey && connection.remoteDeviceKey.equals(deviceKey)) {
127
148
  if (this._connectionsPerPeer.has(connection.peerId)) {
@@ -0,0 +1,45 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import type { Message, PeerId } from '@dxos/automerge/automerge-repo';
6
+
7
+ export const MESSAGE_TYPE_COLLECTION_QUERY = 'collection-query';
8
+
9
+ export type CollectionQueryMessage = {
10
+ type: typeof MESSAGE_TYPE_COLLECTION_QUERY;
11
+ senderId: PeerId;
12
+ targetId: PeerId;
13
+ collectionId: string;
14
+
15
+ /**
16
+ * Identifier of the current state.
17
+ * Remote peer will skip sending the state if it has the same tag.
18
+ */
19
+ stateTag?: string;
20
+ };
21
+
22
+ export const isCollectionQueryMessage = (message: Message): message is CollectionQueryMessage =>
23
+ message.type === MESSAGE_TYPE_COLLECTION_QUERY;
24
+
25
+ export const MESSAGE_TYPE_COLLECTION_STATE = 'collection-state';
26
+
27
+ export type CollectionStateMessage = {
28
+ type: typeof MESSAGE_TYPE_COLLECTION_STATE;
29
+ senderId: PeerId;
30
+ targetId: PeerId;
31
+ collectionId: string;
32
+
33
+ /**
34
+ * State representation is implementation-defined.
35
+ */
36
+ state?: unknown;
37
+
38
+ /**
39
+ * Identifier of the current state.
40
+ */
41
+ stateTag?: string;
42
+ };
43
+
44
+ export const isCollectionStateMessage = (message: Message): message is CollectionStateMessage =>
45
+ message.type === MESSAGE_TYPE_COLLECTION_STATE;
@@ -0,0 +1,14 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { invariant } from '@dxos/invariant';
6
+ import { SpaceId } from '@dxos/keys';
7
+
8
+ export const deriveCollectionIdFromSpaceId = (spaceId: SpaceId): string => `space:${spaceId}`;
9
+
10
+ export const getSpaceIdFromCollectionId = (collectionId: string): SpaceId => {
11
+ const spaceId = collectionId.replace(/^space:/, '');
12
+ invariant(SpaceId.isValid(spaceId));
13
+ return spaceId;
14
+ };
@@ -5,10 +5,10 @@
5
5
  import { type DocumentId } from '@dxos/automerge/automerge-repo';
6
6
  import { type RequestOptions, Stream } from '@dxos/codec-protobuf';
7
7
  import { invariant } from '@dxos/invariant';
8
+ import { SpaceId } from '@dxos/keys';
8
9
  import { log } from '@dxos/log';
9
10
  import {
10
11
  type DataService,
11
- type DocHeadsList,
12
12
  type FlushRequest,
13
13
  type SubscribeRequest,
14
14
  type BatchedDocumentUpdates,
@@ -18,10 +18,12 @@ import {
18
18
  type ReIndexHeadsRequest,
19
19
  type WaitUntilHeadsReplicatedRequest,
20
20
  type UpdateRequest,
21
+ type GetSpaceSyncStateRequest,
22
+ type SpaceSyncState,
21
23
  } from '@dxos/protocols/proto/dxos/echo/service';
22
24
 
23
25
  import { DocumentsSynchronizer } from './documents-synchronizer';
24
- import { type AutomergeHost } from '../automerge';
26
+ import { deriveCollectionIdFromSpaceId, type AutomergeHost } from '../automerge';
25
27
 
26
28
  export type DataServiceParams = {
27
29
  automergeHost: AutomergeHost;
@@ -91,18 +93,14 @@ export class DataServiceImpl implements DataService {
91
93
  }
92
94
 
93
95
  async getDocumentHeads(request: GetDocumentHeadsRequest): Promise<GetDocumentHeadsResponse> {
94
- const entries = await Promise.all(
95
- request.documentIds?.map(async (documentId): Promise<DocHeadsList.Entry> => {
96
- const heads = await this._automergeHost.getHeads(documentId as DocumentId);
97
- return {
98
- documentId,
99
- heads,
100
- };
101
- }) ?? [],
102
- );
96
+ const documentIds = request.documentIds;
97
+ if (!documentIds) {
98
+ return { heads: { entries: [] } };
99
+ }
100
+ const heads = await this._automergeHost.getHeads(documentIds as DocumentId[]);
103
101
  return {
104
102
  heads: {
105
- entries,
103
+ entries: heads.map((heads, idx) => ({ documentId: documentIds[idx], heads })),
106
104
  },
107
105
  };
108
106
  }
@@ -121,4 +119,20 @@ export class DataServiceImpl implements DataService {
121
119
  async updateIndexes() {
122
120
  await this._updateIndexes();
123
121
  }
122
+
123
+ async getSpaceSyncState(
124
+ request: GetSpaceSyncStateRequest,
125
+ options?: RequestOptions | undefined,
126
+ ): Promise<SpaceSyncState> {
127
+ invariant(SpaceId.isValid(request.spaceId));
128
+ const collectionId = deriveCollectionIdFromSpaceId(request.spaceId);
129
+ const state = await this._automergeHost.getCollectionSyncState(collectionId);
130
+
131
+ return {
132
+ peers: state.peers.map((peer) => ({
133
+ peerId: peer.peerId,
134
+ documentsToReconcile: peer.differentDocuments,
135
+ })),
136
+ };
137
+ }
124
138
  }
@@ -44,14 +44,26 @@ export class DocumentsSynchronizer extends Resource {
44
44
  super();
45
45
  }
46
46
 
47
- async addDocuments(documentIds: DocumentId[]) {
47
+ addDocuments(documentIds: DocumentId[], retryCounter = 0) {
48
+ if (retryCounter > 3) {
49
+ log.warn('Failed to load document, retry limit reached', { documentIds });
50
+ return;
51
+ }
52
+
48
53
  for (const documentId of documentIds) {
49
54
  const doc = this._params.repo.find(documentId as DocumentId);
50
- await doc.whenReady();
51
- this._startSync(doc);
52
- this._pendingUpdates.add(doc.documentId);
55
+ doc
56
+ .whenReady()
57
+ .then(() => {
58
+ this._startSync(doc);
59
+ this._pendingUpdates.add(doc.documentId);
60
+ this._sendUpdatesJob!.trigger();
61
+ })
62
+ .catch((error) => {
63
+ log.warn('Failed to load document, wraparound', { documentId, error });
64
+ this.addDocuments([documentId], retryCounter + 1);
65
+ });
53
66
  }
54
- this._sendUpdatesJob!.trigger();
55
67
  }
56
68
 
57
69
  removeDocuments(documentIds: DocumentId[]) {
@@ -150,7 +150,7 @@ export class MetadataStore {
150
150
 
151
151
  // post-processing
152
152
  this._metadata.spaces?.forEach((space) => {
153
- space.state ??= SpaceState.ACTIVE;
153
+ space.state ??= SpaceState.SPACE_ACTIVE;
154
154
  });
155
155
  } catch (err: any) {
156
156
  log.error('failed to load metadata', { err });
@@ -6,3 +6,4 @@ export * from './change-metadata';
6
6
  export * from './test-agent-builder';
7
7
  export * from './test-feed-builder';
8
8
  export * from './test-network-adapter';
9
+ export * from './test-replicator';
@@ -0,0 +1,194 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { sleep, synchronized } from '@dxos/async';
6
+ import { type Message } from '@dxos/automerge/automerge-repo';
7
+ import { type Context, LifecycleState, Resource } from '@dxos/context';
8
+ import { invariant } from '@dxos/invariant';
9
+ import { log } from '@dxos/log';
10
+ import { AutomergeReplicator, type AutomergeReplicatorFactory } from '@dxos/teleport-extension-automerge-replicator';
11
+
12
+ import type {
13
+ EchoReplicator,
14
+ EchoReplicatorContext,
15
+ ReplicatorConnection,
16
+ ShouldAdvertiseParams,
17
+ ShouldSyncCollectionParams,
18
+ } from '../automerge';
19
+
20
+ export type TestReplicatorNetworkOptions = {
21
+ latency?: number;
22
+ };
23
+
24
+ export class TestReplicationNetwork extends Resource {
25
+ private readonly _replicators = new Set<TestReplicator>();
26
+ private readonly _latency?: number = undefined;
27
+
28
+ constructor(options: TestReplicatorNetworkOptions = {}) {
29
+ super();
30
+ this._latency = options.latency;
31
+ }
32
+
33
+ protected override async _close(ctx: Context): Promise<void> {
34
+ for (const replicator of this._replicators) {
35
+ for (const connection of replicator.connections) {
36
+ void connection.writable.abort();
37
+ void connection.readable.cancel();
38
+ }
39
+ }
40
+ }
41
+
42
+ async createReplicator(): Promise<TestReplicator> {
43
+ const replicator = new TestReplicator({
44
+ onConnect: async () => {
45
+ invariant(this._lifecycleState === LifecycleState.OPEN);
46
+ await this._connectReplicator(replicator);
47
+ },
48
+ onDisconnect: async () => {
49
+ invariant(this._lifecycleState === LifecycleState.OPEN);
50
+ await this._disconnectReplicator(replicator);
51
+ },
52
+ });
53
+ this._replicators.add(replicator);
54
+ return replicator;
55
+ }
56
+
57
+ @synchronized
58
+ private async _connectReplicator(replicator: TestReplicator) {
59
+ for (const otherReplicator of this._replicators.values()) {
60
+ if (otherReplicator === replicator || !otherReplicator.connected) {
61
+ continue;
62
+ }
63
+ log('create connection', { from: replicator.context!.peerId, to: otherReplicator.context!.peerId });
64
+ const [connection1, connection2] = this._createConnectionPair(
65
+ replicator.context!.peerId,
66
+ otherReplicator.context!.peerId,
67
+ );
68
+ await replicator.context!.onConnectionOpen(connection1);
69
+ await otherReplicator.context!.onConnectionOpen(connection2);
70
+ }
71
+ }
72
+
73
+ private async _disconnectReplicator(replicator: TestReplicator) {
74
+ for (const connection of replicator.connections) {
75
+ await replicator.context!.onConnectionClosed(connection);
76
+ await connection.otherSide!.owningReplicator!.removeConnection(connection.otherSide!);
77
+ }
78
+ }
79
+
80
+ private _createConnectionPair(peer1: string, peer2: string): [TestReplicatorConnection, TestReplicatorConnection] {
81
+ const LOG = false;
82
+
83
+ const forward = new TransformStream({
84
+ transform: async (message, controller) => {
85
+ if (LOG) {
86
+ log.info('replicate', { from: peer1, to: peer2, message });
87
+ }
88
+
89
+ if (this._latency !== undefined) {
90
+ await sleep(this._latency);
91
+ }
92
+
93
+ controller.enqueue(message);
94
+ },
95
+ });
96
+ const backwards = new TransformStream({
97
+ transform: async (message, controller) => {
98
+ if (LOG) {
99
+ log.info('replicate', { from: peer2, to: peer1, message });
100
+ }
101
+
102
+ if (this._latency !== undefined) {
103
+ await sleep(this._latency);
104
+ }
105
+
106
+ controller.enqueue(message);
107
+ },
108
+ });
109
+
110
+ const connection1 = new TestReplicatorConnection(peer2, backwards.readable, forward.writable);
111
+ const connection2 = new TestReplicatorConnection(peer1, forward.readable, backwards.writable);
112
+ connection1.otherSide = connection2;
113
+ connection2.otherSide = connection1;
114
+ return [connection1, connection2];
115
+ }
116
+ }
117
+
118
+ type TestReplicatorParams = {
119
+ onConnect: () => Promise<void>;
120
+ onDisconnect: () => Promise<void>;
121
+ };
122
+
123
+ export class TestReplicator implements EchoReplicator {
124
+ constructor(private readonly _params: TestReplicatorParams) {}
125
+
126
+ public connected = false;
127
+ public context: EchoReplicatorContext | undefined = undefined;
128
+ public connections = new Set<TestReplicatorConnection>();
129
+
130
+ async connect(context: EchoReplicatorContext): Promise<void> {
131
+ log('connect', { peerId: context.peerId });
132
+ this.context = context;
133
+ this.connected = true;
134
+ await this._params.onConnect();
135
+ }
136
+
137
+ async disconnect(): Promise<void> {
138
+ log('disconnect', { peerId: this.context!.peerId });
139
+ this.connected = false;
140
+ await this._params.onDisconnect();
141
+ }
142
+
143
+ async addConnection(connection: TestReplicatorConnection): Promise<void> {
144
+ connection.owningReplicator = this;
145
+ this.connections.add(connection);
146
+ this.context!.onConnectionOpen(connection);
147
+ }
148
+
149
+ async removeConnection(connection: TestReplicatorConnection): Promise<void> {
150
+ connection.owningReplicator = undefined;
151
+ this.context!.onConnectionClosed(connection);
152
+ this.connections.delete(connection);
153
+ }
154
+ }
155
+
156
+ export class TestReplicatorConnection implements ReplicatorConnection {
157
+ public otherSide: TestReplicatorConnection | undefined = undefined;
158
+ public owningReplicator: TestReplicator | undefined = undefined;
159
+
160
+ constructor(
161
+ public readonly peerId: string,
162
+ public readonly readable: ReadableStream<Message>,
163
+ public readonly writable: WritableStream<Message>,
164
+ ) {}
165
+
166
+ async shouldAdvertise(params: ShouldAdvertiseParams): Promise<boolean> {
167
+ return true;
168
+ }
169
+
170
+ shouldSyncCollection(params: ShouldSyncCollectionParams): boolean {
171
+ return true;
172
+ }
173
+ }
174
+
175
+ export const testAutomergeReplicatorFactory: AutomergeReplicatorFactory = (params) => {
176
+ return new AutomergeReplicator(
177
+ {
178
+ ...params[0],
179
+ sendSyncRetryPolicy: {
180
+ retryBackoff: 20,
181
+ retriesBeforeBackoff: 2,
182
+ maxRetries: 3,
183
+ },
184
+ },
185
+ params[1],
186
+ );
187
+ };
188
+
189
+ export const brokenAutomergeReplicatorFactory: AutomergeReplicatorFactory = (params) => {
190
+ params[1]!.onSyncMessage = () => {
191
+ throw new Error();
192
+ };
193
+ return testAutomergeReplicatorFactory(params);
194
+ };