@dxos/echo-pipeline 0.6.1-main.ff751ec → 0.6.1
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.
- package/dist/lib/browser/{chunk-O3MAK5PY.mjs → chunk-DMUP426Q.mjs} +17 -5
- package/dist/lib/browser/chunk-DMUP426Q.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +284 -184
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +1 -1
- package/dist/lib/node/{chunk-QATBVT6S.cjs → chunk-NH5WJKOW.cjs} +20 -8
- package/dist/lib/node/chunk-NH5WJKOW.cjs.map +7 -0
- package/dist/lib/node/index.cjs +327 -231
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +11 -11
- package/dist/types/src/automerge/automerge-host.d.ts +6 -2
- package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-network-adapter.d.ts +2 -2
- package/dist/types/src/automerge/echo-network-adapter.test.d.ts +2 -0
- package/dist/types/src/automerge/echo-network-adapter.test.d.ts.map +1 -0
- package/dist/types/src/automerge/echo-replicator.d.ts +5 -6
- package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
- package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +35 -0
- package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -0
- package/dist/types/src/automerge/mesh-echo-replicator.d.ts +2 -2
- package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
- package/dist/types/src/db-host/data-service.d.ts +11 -3
- package/dist/types/src/db-host/data-service.d.ts.map +1 -1
- package/package.json +33 -33
- package/src/automerge/automerge-host.ts +57 -11
- package/src/automerge/automerge-repo.test.ts +117 -1
- package/src/automerge/echo-network-adapter.test.ts +131 -0
- package/src/automerge/echo-network-adapter.ts +4 -4
- package/src/automerge/echo-replicator.ts +6 -9
- package/src/automerge/mesh-echo-replicator-connection.ts +130 -0
- package/src/automerge/mesh-echo-replicator.ts +15 -123
- package/src/db-host/data-service.ts +38 -5
- package/dist/lib/browser/chunk-O3MAK5PY.mjs.map +0 -7
- package/dist/lib/node/chunk-QATBVT6S.cjs.map +0 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/echo-pipeline",
|
|
3
|
-
"version": "0.6.1
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "ECHO database.",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -39,38 +39,38 @@
|
|
|
39
39
|
"crc-32": "^1.2.2",
|
|
40
40
|
"level": "^8.0.1",
|
|
41
41
|
"level-transcoder": "^1.0.1",
|
|
42
|
-
"@dxos/
|
|
43
|
-
"@dxos/
|
|
44
|
-
"@dxos/
|
|
45
|
-
"@dxos/credentials": "0.6.1
|
|
46
|
-
"@dxos/crypto": "0.6.1
|
|
47
|
-
"@dxos/
|
|
48
|
-
"@dxos/
|
|
49
|
-
"@dxos/
|
|
50
|
-
"@dxos/
|
|
51
|
-
"@dxos/hypercore": "0.6.1
|
|
52
|
-
"@dxos/
|
|
53
|
-
"@dxos/
|
|
54
|
-
"@dxos/
|
|
55
|
-
"@dxos/
|
|
56
|
-
"@dxos/
|
|
57
|
-
"@dxos/
|
|
58
|
-
"@dxos/
|
|
59
|
-
"@dxos/
|
|
60
|
-
"@dxos/network-manager": "0.6.1
|
|
61
|
-
"@dxos/node-std": "0.6.1
|
|
62
|
-
"@dxos/protocols": "0.6.1
|
|
63
|
-
"@dxos/random-access-storage": "0.6.1
|
|
64
|
-
"@dxos/rpc": "0.6.1
|
|
65
|
-
"@dxos/teleport": "0.6.1
|
|
66
|
-
"@dxos/teleport-extension-
|
|
67
|
-
"@dxos/teleport-extension-
|
|
68
|
-
"@dxos/teleport-extension-
|
|
69
|
-
"@dxos/teleport-extension-replicator": "0.6.1
|
|
70
|
-
"@dxos/timeframe": "0.6.1
|
|
71
|
-
"@dxos/tracing": "0.6.1
|
|
72
|
-
"@dxos/
|
|
73
|
-
"@dxos/
|
|
42
|
+
"@dxos/automerge": "0.6.1",
|
|
43
|
+
"@dxos/context": "0.6.1",
|
|
44
|
+
"@dxos/codec-protobuf": "0.6.1",
|
|
45
|
+
"@dxos/credentials": "0.6.1",
|
|
46
|
+
"@dxos/crypto": "0.6.1",
|
|
47
|
+
"@dxos/debug": "0.6.1",
|
|
48
|
+
"@dxos/echo-schema": "0.6.1",
|
|
49
|
+
"@dxos/feed-store": "0.6.1",
|
|
50
|
+
"@dxos/echo-protocol": "0.6.1",
|
|
51
|
+
"@dxos/hypercore": "0.6.1",
|
|
52
|
+
"@dxos/invariant": "0.6.1",
|
|
53
|
+
"@dxos/indexing": "0.6.1",
|
|
54
|
+
"@dxos/async": "0.6.1",
|
|
55
|
+
"@dxos/keyring": "0.6.1",
|
|
56
|
+
"@dxos/keys": "0.6.1",
|
|
57
|
+
"@dxos/kv-store": "0.6.1",
|
|
58
|
+
"@dxos/log": "0.6.1",
|
|
59
|
+
"@dxos/messaging": "0.6.1",
|
|
60
|
+
"@dxos/network-manager": "0.6.1",
|
|
61
|
+
"@dxos/node-std": "0.6.1",
|
|
62
|
+
"@dxos/protocols": "0.6.1",
|
|
63
|
+
"@dxos/random-access-storage": "0.6.1",
|
|
64
|
+
"@dxos/rpc": "0.6.1",
|
|
65
|
+
"@dxos/teleport": "0.6.1",
|
|
66
|
+
"@dxos/teleport-extension-automerge-replicator": "0.6.1",
|
|
67
|
+
"@dxos/teleport-extension-object-sync": "0.6.1",
|
|
68
|
+
"@dxos/teleport-extension-gossip": "0.6.1",
|
|
69
|
+
"@dxos/teleport-extension-replicator": "0.6.1",
|
|
70
|
+
"@dxos/timeframe": "0.6.1",
|
|
71
|
+
"@dxos/tracing": "0.6.1",
|
|
72
|
+
"@dxos/typings": "0.6.1",
|
|
73
|
+
"@dxos/util": "0.6.1"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"fast-check": "^3.19.0",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getHeads,
|
|
10
10
|
isAutomerge,
|
|
11
11
|
save,
|
|
12
|
+
equals as headsEquals,
|
|
12
13
|
type Doc,
|
|
13
14
|
type Heads,
|
|
14
15
|
} from '@dxos/automerge/automerge';
|
|
@@ -22,13 +23,16 @@ import {
|
|
|
22
23
|
type StorageAdapterInterface,
|
|
23
24
|
} from '@dxos/automerge/automerge-repo';
|
|
24
25
|
import { type Stream } from '@dxos/codec-protobuf';
|
|
25
|
-
import {
|
|
26
|
+
import { Context, Resource, cancelWithContext, type Lifecycle } from '@dxos/context';
|
|
26
27
|
import { type SpaceDoc } from '@dxos/echo-protocol';
|
|
27
28
|
import { type IndexMetadataStore } from '@dxos/indexing';
|
|
29
|
+
import { invariant } from '@dxos/invariant';
|
|
28
30
|
import { PublicKey } from '@dxos/keys';
|
|
29
31
|
import { type LevelDB } from '@dxos/kv-store';
|
|
32
|
+
import { log } from '@dxos/log';
|
|
30
33
|
import { objectPointerCodec } from '@dxos/protocols';
|
|
31
34
|
import {
|
|
35
|
+
type DocHeadsList,
|
|
32
36
|
type FlushRequest,
|
|
33
37
|
type HostInfo,
|
|
34
38
|
type SyncRepoRequest,
|
|
@@ -68,6 +72,7 @@ export type CreateDocOptions = {
|
|
|
68
72
|
*/
|
|
69
73
|
@trace.resource()
|
|
70
74
|
export class AutomergeHost extends Resource {
|
|
75
|
+
private readonly _db: LevelDB;
|
|
71
76
|
private readonly _indexMetadataStore: IndexMetadataStore;
|
|
72
77
|
private readonly _echoNetworkAdapter = new EchoNetworkAdapter({
|
|
73
78
|
getContainingSpaceForDocument: this._getContainingSpaceForDocument.bind(this),
|
|
@@ -83,6 +88,7 @@ export class AutomergeHost extends Resource {
|
|
|
83
88
|
|
|
84
89
|
constructor({ db, indexMetadataStore }: AutomergeHostParams) {
|
|
85
90
|
super();
|
|
91
|
+
this._db = db;
|
|
86
92
|
this._storage = new LevelDBStorageAdapter({
|
|
87
93
|
db: db.sublevel('automerge'),
|
|
88
94
|
callbacks: {
|
|
@@ -134,6 +140,10 @@ export class AutomergeHost extends Resource {
|
|
|
134
140
|
return this._repo;
|
|
135
141
|
}
|
|
136
142
|
|
|
143
|
+
get loadedDocsCount(): number {
|
|
144
|
+
return Object.keys(this._repo.handles).length;
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
async addReplicator(replicator: EchoReplicator) {
|
|
138
148
|
await this._echoNetworkAdapter.addReplicator(replicator);
|
|
139
149
|
}
|
|
@@ -182,14 +192,53 @@ export class AutomergeHost extends Resource {
|
|
|
182
192
|
}
|
|
183
193
|
}
|
|
184
194
|
|
|
195
|
+
async waitUntilHeadsReplicated(heads: DocHeadsList): Promise<void> {
|
|
196
|
+
await Promise.all(
|
|
197
|
+
heads.entries?.map(async ({ documentId, heads }) => {
|
|
198
|
+
if (!heads || heads.length === 0) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const currentHeads = this.getHeads(documentId as DocumentId);
|
|
203
|
+
if (currentHeads !== null && headsEquals(currentHeads, heads)) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const handle = await this.loadDoc(Context.default(), documentId as DocumentId);
|
|
208
|
+
await waitForHeads(handle, heads);
|
|
209
|
+
}) ?? [],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Flush to disk also so that the indexer can pick up the changes.
|
|
213
|
+
await this._repo.flush((heads.entries?.map((entry) => entry.documentId) ?? []) as DocumentId[]);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async reIndexHeads(documentIds: DocumentId[]) {
|
|
217
|
+
for (const documentId of documentIds) {
|
|
218
|
+
log.info('re-indexing heads for document', { documentId });
|
|
219
|
+
const handle = this._repo.find(documentId);
|
|
220
|
+
await handle.whenReady(['ready', 'requesting']);
|
|
221
|
+
if (handle.inState(['requesting'])) {
|
|
222
|
+
log.warn('document is not available locally, skipping', { documentId });
|
|
223
|
+
continue; // Handle not available locally.
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const doc = handle.docSync();
|
|
227
|
+
invariant(doc);
|
|
228
|
+
|
|
229
|
+
const heads = getHeads(doc);
|
|
230
|
+
const batch = this._db.batch();
|
|
231
|
+
this._headsStore.setHeads(documentId, heads, batch);
|
|
232
|
+
await batch.write();
|
|
233
|
+
}
|
|
234
|
+
log.info('done re-indexing heads');
|
|
235
|
+
}
|
|
236
|
+
|
|
185
237
|
// TODO(dmaretskyi): Share based on HALO permissions and space affinity.
|
|
186
238
|
// Hosts, running in the worker, don't share documents unless requested by other peers.
|
|
187
239
|
// NOTE: If both peers return sharePolicy=false the replication will not happen
|
|
188
240
|
// https://github.com/automerge/automerge-repo/pull/292
|
|
189
|
-
private async _sharePolicy(
|
|
190
|
-
peerId: PeerId /* device key */,
|
|
191
|
-
documentId?: DocumentId /* space key */,
|
|
192
|
-
): Promise<boolean> {
|
|
241
|
+
private async _sharePolicy(peerId: PeerId, documentId?: DocumentId): Promise<boolean> {
|
|
193
242
|
if (peerId.startsWith('client-')) {
|
|
194
243
|
return false; // Only send docs to clients if they are requested.
|
|
195
244
|
}
|
|
@@ -200,7 +249,7 @@ export class AutomergeHost extends Resource {
|
|
|
200
249
|
|
|
201
250
|
const peerMetadata = this.repo.peerMetadataByPeerId[peerId];
|
|
202
251
|
if (isEchoPeerMetadata(peerMetadata)) {
|
|
203
|
-
return this._echoNetworkAdapter.
|
|
252
|
+
return this._echoNetworkAdapter.shouldAdvertise(peerId, { documentId });
|
|
204
253
|
}
|
|
205
254
|
|
|
206
255
|
return false;
|
|
@@ -349,9 +398,9 @@ export const getSpaceKeyFromDoc = (doc: Doc<SpaceDoc>): string | null => {
|
|
|
349
398
|
};
|
|
350
399
|
|
|
351
400
|
const waitForHeads = async (handle: DocHandle<SpaceDoc>, heads: Heads) => {
|
|
352
|
-
await handle.whenReady();
|
|
353
401
|
const unavailableHeads = new Set(heads);
|
|
354
402
|
|
|
403
|
+
await handle.whenReady();
|
|
355
404
|
await Event.wrap<DocHandleChangePayload<SpaceDoc>>(handle, 'change').waitForCondition(() => {
|
|
356
405
|
// Check if unavailable heads became available.
|
|
357
406
|
for (const changeHash of unavailableHeads.values()) {
|
|
@@ -360,10 +409,7 @@ const waitForHeads = async (handle: DocHandle<SpaceDoc>, heads: Heads) => {
|
|
|
360
409
|
}
|
|
361
410
|
}
|
|
362
411
|
|
|
363
|
-
|
|
364
|
-
return true;
|
|
365
|
-
}
|
|
366
|
-
return false;
|
|
412
|
+
return unavailableHeads.size === 0;
|
|
367
413
|
});
|
|
368
414
|
};
|
|
369
415
|
|
|
@@ -7,7 +7,14 @@ import waitForExpect from 'wait-for-expect';
|
|
|
7
7
|
|
|
8
8
|
import { asyncTimeout, sleep } from '@dxos/async';
|
|
9
9
|
import { type Heads, change, clone, equals, from, getBackend, getHeads } from '@dxos/automerge/automerge';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type Message,
|
|
12
|
+
Repo,
|
|
13
|
+
type PeerId,
|
|
14
|
+
type DocumentId,
|
|
15
|
+
type HandleState,
|
|
16
|
+
type AutomergeUrl,
|
|
17
|
+
} from '@dxos/automerge/automerge-repo';
|
|
11
18
|
import { randomBytes } from '@dxos/crypto';
|
|
12
19
|
import { PublicKey } from '@dxos/keys';
|
|
13
20
|
import { createTestLevel } from '@dxos/kv-store/testing';
|
|
@@ -92,6 +99,74 @@ describe('AutomergeRepo', () => {
|
|
|
92
99
|
expect(equals(a, c)).to.be.true;
|
|
93
100
|
});
|
|
94
101
|
|
|
102
|
+
test('documents missing from local storage go to requesting state', async () => {
|
|
103
|
+
const hostAdapter: TestAdapter = new TestAdapter({
|
|
104
|
+
send: (message: Message) => {
|
|
105
|
+
console.log('hostAdapter.send', message);
|
|
106
|
+
clientAdapter.receive(message);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const clientAdapter: TestAdapter = new TestAdapter({
|
|
110
|
+
send: (message: Message) => {
|
|
111
|
+
console.log('clientAdapter.send', message);
|
|
112
|
+
if (message.type !== 'doc-unavailable' && message.type !== 'sync') {
|
|
113
|
+
hostAdapter.receive(message);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const host = new Repo({
|
|
119
|
+
network: [hostAdapter],
|
|
120
|
+
});
|
|
121
|
+
const _client = new Repo({
|
|
122
|
+
network: [clientAdapter],
|
|
123
|
+
});
|
|
124
|
+
hostAdapter.ready();
|
|
125
|
+
clientAdapter.ready();
|
|
126
|
+
await hostAdapter.onConnect.wait();
|
|
127
|
+
await clientAdapter.onConnect.wait();
|
|
128
|
+
hostAdapter.peerCandidate(clientAdapter.peerId!);
|
|
129
|
+
clientAdapter.peerCandidate(hostAdapter.peerId!);
|
|
130
|
+
|
|
131
|
+
const url = 'automerge:3JN8F3Z4dUWEEKKFN7WE9gEGvVUT';
|
|
132
|
+
const handle = host.find(url as AutomergeUrl);
|
|
133
|
+
await handle.whenReady(['requesting']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('documents on disk go to ready state', async () => {
|
|
137
|
+
const level = createTestLevel();
|
|
138
|
+
const storage = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
|
|
139
|
+
await openAndClose(level, storage);
|
|
140
|
+
|
|
141
|
+
let url: AutomergeUrl | undefined;
|
|
142
|
+
{
|
|
143
|
+
const repo = new Repo({
|
|
144
|
+
network: [],
|
|
145
|
+
storage,
|
|
146
|
+
});
|
|
147
|
+
const handle = repo.create<{ field?: string }>();
|
|
148
|
+
url = handle.url;
|
|
149
|
+
await repo.flush();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
{
|
|
153
|
+
const repo = new Repo({
|
|
154
|
+
network: [],
|
|
155
|
+
storage,
|
|
156
|
+
});
|
|
157
|
+
const handle = repo.find(url as AutomergeUrl);
|
|
158
|
+
|
|
159
|
+
let requestingState = false;
|
|
160
|
+
queueMicrotask(async () => {
|
|
161
|
+
await handle.whenReady(['requesting']);
|
|
162
|
+
requestingState = true;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await handle.whenReady(['ready']);
|
|
166
|
+
expect(requestingState).to.be.false;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
95
170
|
describe('network', () => {
|
|
96
171
|
test('basic networking', async () => {
|
|
97
172
|
const hostAdapter: TestAdapter = new TestAdapter({
|
|
@@ -514,5 +589,46 @@ describe('AutomergeRepo', () => {
|
|
|
514
589
|
await asyncTimeout(docB.whenReady(), 1_000);
|
|
515
590
|
}
|
|
516
591
|
});
|
|
592
|
+
|
|
593
|
+
test('documents loaded from disk get replicated', async () => {
|
|
594
|
+
const level = createTestLevel();
|
|
595
|
+
const storage = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
|
|
596
|
+
await openAndClose(level, storage);
|
|
597
|
+
|
|
598
|
+
let url: AutomergeUrl | undefined;
|
|
599
|
+
{
|
|
600
|
+
const peer1 = new Repo({
|
|
601
|
+
storage,
|
|
602
|
+
});
|
|
603
|
+
const handle = peer1.create({ text: 'foo' });
|
|
604
|
+
await peer1.flush();
|
|
605
|
+
url = handle.url;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const [adapter1, adapter2] = TestAdapter.createPair();
|
|
609
|
+
const peer1 = new Repo({
|
|
610
|
+
network: [adapter1],
|
|
611
|
+
storage,
|
|
612
|
+
sharePolicy: async () => true,
|
|
613
|
+
});
|
|
614
|
+
const peer2 = new Repo({
|
|
615
|
+
network: [adapter2],
|
|
616
|
+
sharePolicy: async () => true,
|
|
617
|
+
});
|
|
618
|
+
adapter1.ready();
|
|
619
|
+
adapter2.ready();
|
|
620
|
+
await adapter1.onConnect.wait();
|
|
621
|
+
await adapter2.onConnect.wait();
|
|
622
|
+
adapter1.peerCandidate(adapter2.peerId!);
|
|
623
|
+
adapter2.peerCandidate(adapter1.peerId!);
|
|
624
|
+
|
|
625
|
+
// Load doc on peer1
|
|
626
|
+
const hostHandle = peer1.find(url as AutomergeUrl);
|
|
627
|
+
|
|
628
|
+
// Doc should be pushed to peer2
|
|
629
|
+
await waitForExpect(() => {
|
|
630
|
+
expect(peer2.handles[hostHandle.documentId]).to.not.be.undefined;
|
|
631
|
+
});
|
|
632
|
+
});
|
|
517
633
|
});
|
|
518
634
|
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { expect } from 'chai';
|
|
6
|
+
|
|
7
|
+
import { sleep, Trigger, waitForCondition } from '@dxos/async';
|
|
8
|
+
import { cbor, type PeerId } from '@dxos/automerge/automerge-repo';
|
|
9
|
+
import { invariant } from '@dxos/invariant';
|
|
10
|
+
import { PublicKey } from '@dxos/keys';
|
|
11
|
+
import { type SyncMessage } from '@dxos/protocols/proto/dxos/mesh/teleport/automerge';
|
|
12
|
+
import {
|
|
13
|
+
type AutomergeReplicator,
|
|
14
|
+
type AutomergeReplicatorCallbacks,
|
|
15
|
+
type AutomergeReplicatorFactory,
|
|
16
|
+
} from '@dxos/teleport-extension-automerge-replicator';
|
|
17
|
+
import { afterTest, describe, test } from '@dxos/test';
|
|
18
|
+
|
|
19
|
+
import { EchoNetworkAdapter } from './echo-network-adapter';
|
|
20
|
+
import { MeshEchoReplicator } from './mesh-echo-replicator';
|
|
21
|
+
|
|
22
|
+
const PEER_ID = 'peerA' as PeerId;
|
|
23
|
+
const ANOTHER_PEER_ID = 'peerB' as PeerId;
|
|
24
|
+
const PAYLOAD = new Uint8Array([42]);
|
|
25
|
+
|
|
26
|
+
describe('EchoNetworkAdapter', () => {
|
|
27
|
+
test('peer-candidate emitted when replication starts', async () => {
|
|
28
|
+
const controller = createReplicatorController();
|
|
29
|
+
const adapter = await createConnectedAdapter(controller.replicator);
|
|
30
|
+
const peerInfo = new Trigger<any>();
|
|
31
|
+
adapter.on('peer-candidate', (payload) => peerInfo.wake(payload));
|
|
32
|
+
await controller.connectPeer(ANOTHER_PEER_ID);
|
|
33
|
+
const emittedInfo = await peerInfo.wait();
|
|
34
|
+
expect(emittedInfo.peerId).to.eq(ANOTHER_PEER_ID);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('message emitted when sync message is received', async () => {
|
|
38
|
+
const controller = createReplicatorController();
|
|
39
|
+
const adapter = await createConnectedAdapter(controller.replicator);
|
|
40
|
+
const messageReceived = new Trigger<any>();
|
|
41
|
+
adapter.on('message', (message) => messageReceived.wake(message));
|
|
42
|
+
const callbacks = await controller.connectPeer(ANOTHER_PEER_ID);
|
|
43
|
+
await callbacks.onSyncMessage!(encodeSyncPayload(PAYLOAD));
|
|
44
|
+
const receivedMessage = await messageReceived.wait();
|
|
45
|
+
expect(receivedMessage).to.deep.eq(PAYLOAD);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('peer disconnects when onClose callback is invoked', async () => {
|
|
49
|
+
const controller = createReplicatorController();
|
|
50
|
+
const adapter = await createConnectedAdapter(controller.replicator);
|
|
51
|
+
const callbacks = await controller.connectPeer(ANOTHER_PEER_ID);
|
|
52
|
+
const onDisconnected = new Trigger<any>();
|
|
53
|
+
adapter.on('peer-disconnected', (payload) => onDisconnected.wake(payload));
|
|
54
|
+
await callbacks.onClose!();
|
|
55
|
+
const disconnectedPeer = await onDisconnected.wait();
|
|
56
|
+
expect(disconnectedPeer.peerId).to.eq(ANOTHER_PEER_ID);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('peer disconnects when message sending fails', async () => {
|
|
60
|
+
let errored = false;
|
|
61
|
+
const controller = createReplicatorController(async () => {
|
|
62
|
+
errored = true;
|
|
63
|
+
throw new Error();
|
|
64
|
+
});
|
|
65
|
+
const adapter = await createConnectedAdapter(controller.replicator);
|
|
66
|
+
await controller.connectPeer(ANOTHER_PEER_ID);
|
|
67
|
+
const onDisconnected = new Trigger<any>();
|
|
68
|
+
adapter.on('peer-disconnected', (payload) => onDisconnected.wake(payload));
|
|
69
|
+
adapter.send(newSyncMessage(PEER_ID, ANOTHER_PEER_ID, PAYLOAD));
|
|
70
|
+
const disconnectedPeer = await onDisconnected.wait();
|
|
71
|
+
expect(disconnectedPeer.peerId).to.eq(ANOTHER_PEER_ID);
|
|
72
|
+
expect(errored).to.be.true;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('message sending is queued', async () => {
|
|
76
|
+
let sentTotal = 0;
|
|
77
|
+
let sendInProgress = false;
|
|
78
|
+
const controller = createReplicatorController(async () => {
|
|
79
|
+
invariant(!sendInProgress);
|
|
80
|
+
sendInProgress = true;
|
|
81
|
+
await sleep(5);
|
|
82
|
+
sendInProgress = false;
|
|
83
|
+
sentTotal++;
|
|
84
|
+
});
|
|
85
|
+
const adapter = await createConnectedAdapter(controller.replicator);
|
|
86
|
+
await controller.connectPeer(ANOTHER_PEER_ID);
|
|
87
|
+
const totalMessages = 5;
|
|
88
|
+
for (let i = 0; i < totalMessages; i++) {
|
|
89
|
+
adapter.send(newSyncMessage(PEER_ID, ANOTHER_PEER_ID, PAYLOAD));
|
|
90
|
+
}
|
|
91
|
+
await waitForCondition({ condition: () => sentTotal === totalMessages });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const createConnectedAdapter = async (replicator: MeshEchoReplicator) => {
|
|
95
|
+
const adapter = new EchoNetworkAdapter({ getContainingSpaceForDocument: async () => null });
|
|
96
|
+
adapter.connect(PEER_ID);
|
|
97
|
+
await adapter.open();
|
|
98
|
+
afterTest(() => adapter.close());
|
|
99
|
+
await adapter.addReplicator(replicator);
|
|
100
|
+
return adapter;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const createReplicatorController = (sendSyncMessage?: (message: SyncMessage) => Promise<void>) => {
|
|
104
|
+
const replicator = new MeshEchoReplicator();
|
|
105
|
+
return {
|
|
106
|
+
replicator,
|
|
107
|
+
connectPeer: async (peerId: string) => {
|
|
108
|
+
let callbacks: AutomergeReplicatorCallbacks | undefined;
|
|
109
|
+
const extensionFactory: AutomergeReplicatorFactory = (params) => {
|
|
110
|
+
callbacks = params[1];
|
|
111
|
+
return { sendSyncMessage } as AutomergeReplicator;
|
|
112
|
+
};
|
|
113
|
+
replicator.createExtension(extensionFactory);
|
|
114
|
+
invariant(callbacks);
|
|
115
|
+
await callbacks.onStartReplication!({ id: peerId }, PublicKey.random());
|
|
116
|
+
return callbacks;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const newSyncMessage = (from: PeerId, to: PeerId, payload: Uint8Array) => ({
|
|
122
|
+
type: 'sync',
|
|
123
|
+
senderId: from,
|
|
124
|
+
targetId: to,
|
|
125
|
+
data: payload,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const encodeSyncPayload = (payload: Uint8Array): SyncMessage => ({
|
|
129
|
+
payload: cbor.encode(payload),
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { synchronized, Trigger } from '@dxos/async';
|
|
6
6
|
import { type Message, NetworkAdapter, 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
11
|
|
|
12
|
-
import { type EchoReplicator, type ReplicatorConnection, type
|
|
12
|
+
import { type EchoReplicator, type ReplicatorConnection, type ShouldAdvertiseParams } from './echo-replicator';
|
|
13
13
|
|
|
14
14
|
export type EchoNetworkAdapterParams = {
|
|
15
15
|
getContainingSpaceForDocument: (documentId: string) => Promise<PublicKey | null>;
|
|
@@ -110,13 +110,13 @@ export class EchoNetworkAdapter extends NetworkAdapter {
|
|
|
110
110
|
this._replicators.delete(replicator);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
async
|
|
113
|
+
async shouldAdvertise(peerId: PeerId, params: ShouldAdvertiseParams): Promise<boolean> {
|
|
114
114
|
const connection = this._connections.get(peerId);
|
|
115
115
|
if (!connection) {
|
|
116
116
|
return false;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
return connection.connection.
|
|
119
|
+
return connection.connection.shouldAdvertise(params);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
private _onConnectionOpen(connection: ReplicatorConnection) {
|
|
@@ -23,13 +23,11 @@ export interface EchoReplicatorContext {
|
|
|
23
23
|
*/
|
|
24
24
|
get peerId(): string;
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
getContainingSpaceForDocument(documentId: string): Promise<PublicKey | null>;
|
|
27
27
|
|
|
28
|
+
onConnectionOpen(connection: ReplicatorConnection): void;
|
|
28
29
|
onConnectionClosed(connection: ReplicatorConnection): void;
|
|
29
|
-
|
|
30
30
|
onConnectionAuthScopeChanged(connection: ReplicatorConnection): void;
|
|
31
|
-
|
|
32
|
-
getContainingSpaceForDocument(documentId: string): Promise<PublicKey | null>;
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
export interface ReplicatorConnection {
|
|
@@ -49,13 +47,12 @@ export interface ReplicatorConnection {
|
|
|
49
47
|
writable: WritableStream<Message>;
|
|
50
48
|
|
|
51
49
|
/**
|
|
52
|
-
* @returns true if the document should be
|
|
53
|
-
*
|
|
54
|
-
* The remote peer can still request the document by it's id bypassing this check.
|
|
50
|
+
* @returns true if the document should be advertised to this peer.
|
|
51
|
+
* The remote peer can still request the document by its id bypassing this check.
|
|
55
52
|
*/
|
|
56
|
-
|
|
53
|
+
shouldAdvertise(params: ShouldAdvertiseParams): Promise<boolean>;
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
export type
|
|
56
|
+
export type ShouldAdvertiseParams = {
|
|
60
57
|
documentId: string;
|
|
61
58
|
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { cbor, type Message } from '@dxos/automerge/automerge-repo';
|
|
6
|
+
import { Resource } from '@dxos/context';
|
|
7
|
+
import { invariant } from '@dxos/invariant';
|
|
8
|
+
import { type PublicKey } from '@dxos/keys';
|
|
9
|
+
import { log } from '@dxos/log';
|
|
10
|
+
import { AutomergeReplicator, type AutomergeReplicatorFactory } from '@dxos/teleport-extension-automerge-replicator';
|
|
11
|
+
|
|
12
|
+
import type { ReplicatorConnection, ShouldAdvertiseParams } from './echo-replicator';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_FACTORY: AutomergeReplicatorFactory = (params) => new AutomergeReplicator(...params);
|
|
15
|
+
|
|
16
|
+
export type MeshReplicatorConnectionParams = {
|
|
17
|
+
ownPeerId: string;
|
|
18
|
+
onRemoteConnected: () => void;
|
|
19
|
+
onRemoteDisconnected: () => void;
|
|
20
|
+
shouldAdvertise: (params: ShouldAdvertiseParams) => Promise<boolean>;
|
|
21
|
+
replicatorFactory?: AutomergeReplicatorFactory;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class MeshReplicatorConnection extends Resource implements ReplicatorConnection {
|
|
25
|
+
public readable: ReadableStream<Message>;
|
|
26
|
+
public writable: WritableStream<Message>;
|
|
27
|
+
public remoteDeviceKey: PublicKey | null = null;
|
|
28
|
+
|
|
29
|
+
public readonly replicatorExtension: AutomergeReplicator;
|
|
30
|
+
|
|
31
|
+
private _remotePeerId: string | null = null;
|
|
32
|
+
private _isEnabled = false;
|
|
33
|
+
|
|
34
|
+
constructor(private readonly _params: MeshReplicatorConnectionParams) {
|
|
35
|
+
super();
|
|
36
|
+
|
|
37
|
+
let readableStreamController!: ReadableStreamDefaultController<Message>;
|
|
38
|
+
this.readable = new ReadableStream<Message>({
|
|
39
|
+
start: (controller) => {
|
|
40
|
+
readableStreamController = controller;
|
|
41
|
+
this._ctx.onDispose(() => controller.close());
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.writable = new WritableStream<Message>({
|
|
46
|
+
write: async (message: Message, controller) => {
|
|
47
|
+
invariant(this._isEnabled, 'Writing to a disabled connection');
|
|
48
|
+
try {
|
|
49
|
+
await this.replicatorExtension.sendSyncMessage({ payload: cbor.encode(message) });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
controller.error(err);
|
|
52
|
+
this._disconnectIfEnabled();
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const createAutomergeReplicator = this._params.replicatorFactory ?? DEFAULT_FACTORY;
|
|
58
|
+
this.replicatorExtension = createAutomergeReplicator([
|
|
59
|
+
{
|
|
60
|
+
peerId: this._params.ownPeerId,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
onStartReplication: async (info, remotePeerId /** Teleport ID */) => {
|
|
64
|
+
// Note: We store only one extension per peer.
|
|
65
|
+
// There can be a case where two connected peers have more than one teleport connection between them
|
|
66
|
+
// and each of them uses different teleport connections to send messages.
|
|
67
|
+
// It works because we receive messages from all teleport connections and Automerge Repo dedup them.
|
|
68
|
+
// TODO(mykola): Use only one teleport connection per peer.
|
|
69
|
+
|
|
70
|
+
// TODO(dmaretskyi): Critical bug.
|
|
71
|
+
// - two peers get connected via swarm 1
|
|
72
|
+
// - they get connected via swarm 2
|
|
73
|
+
// - swarm 1 gets disconnected
|
|
74
|
+
// - automerge repo thinks that peer 2 got disconnected even though swarm 2 is still active
|
|
75
|
+
|
|
76
|
+
this.remoteDeviceKey = remotePeerId;
|
|
77
|
+
|
|
78
|
+
// Set automerge id.
|
|
79
|
+
this._remotePeerId = info.id;
|
|
80
|
+
|
|
81
|
+
log('onStartReplication', { id: info.id, thisPeerId: this.peerId, remotePeerId: remotePeerId.toHex() });
|
|
82
|
+
|
|
83
|
+
this._params.onRemoteConnected();
|
|
84
|
+
},
|
|
85
|
+
onSyncMessage: async ({ payload }) => {
|
|
86
|
+
if (!this._isEnabled) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const message = cbor.decode(payload) as Message;
|
|
90
|
+
// Note: automerge Repo dedup messages.
|
|
91
|
+
readableStreamController.enqueue(message);
|
|
92
|
+
},
|
|
93
|
+
onClose: async () => {
|
|
94
|
+
this._disconnectIfEnabled();
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private _disconnectIfEnabled() {
|
|
101
|
+
if (this._isEnabled) {
|
|
102
|
+
this._params.onRemoteDisconnected();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get peerId(): string {
|
|
107
|
+
invariant(this._remotePeerId != null, 'Remote peer has not connected yet.');
|
|
108
|
+
return this._remotePeerId;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async shouldAdvertise(params: ShouldAdvertiseParams): Promise<boolean> {
|
|
112
|
+
return this._params.shouldAdvertise(params);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Start exchanging messages with the remote peer.
|
|
117
|
+
* Call after the remote peer has connected.
|
|
118
|
+
*/
|
|
119
|
+
enable() {
|
|
120
|
+
invariant(this._remotePeerId != null, 'Remote peer has not connected yet.');
|
|
121
|
+
this._isEnabled = true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Stop exchanging messages with the remote peer.
|
|
126
|
+
*/
|
|
127
|
+
disable() {
|
|
128
|
+
this._isEnabled = false;
|
|
129
|
+
}
|
|
130
|
+
}
|