@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.
- package/dist/lib/browser/{chunk-KMWJLYEQ.mjs → chunk-VQQD32DM.mjs} +18 -18
- package/dist/lib/browser/{chunk-KMWJLYEQ.mjs.map → chunk-VQQD32DM.mjs.map} +3 -3
- package/dist/lib/browser/index.mjs +471 -189
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +2 -8
- package/dist/lib/browser/testing/index.mjs.map +4 -4
- package/dist/lib/node/{chunk-YZA42CKA.cjs → chunk-P7L7ICAH.cjs} +21 -21
- package/dist/lib/node/{chunk-YZA42CKA.cjs.map → chunk-P7L7ICAH.cjs.map} +3 -3
- package/dist/lib/node/index.cjs +482 -207
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +13 -18
- package/dist/lib/node/testing/index.cjs.map +4 -4
- package/dist/types/src/automerge/automerge-doc-loader.d.ts +3 -3
- package/dist/types/src/automerge/automerge-doc-loader.d.ts.map +1 -1
- package/dist/types/src/automerge/automerge-host.d.ts +15 -6
- package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-network-adapter.d.ts +26 -0
- package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -0
- package/dist/types/src/automerge/echo-replicator.d.ts +43 -0
- package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -0
- package/dist/types/src/automerge/index.d.ts +1 -2
- package/dist/types/src/automerge/index.d.ts.map +1 -1
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +3 -2
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
- package/dist/types/src/automerge/migrations.d.ts +1 -1
- package/dist/types/src/automerge/migrations.d.ts.map +1 -1
- package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
- package/dist/types/src/space/control-pipeline.d.ts +1 -1
- package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +0 -1
- package/dist/types/src/testing/index.d.ts.map +1 -1
- package/package.json +33 -30
- package/src/automerge/automerge-doc-loader.test.ts +1 -1
- package/src/automerge/automerge-doc-loader.ts +26 -16
- package/src/automerge/automerge-host.test.ts +33 -16
- package/src/automerge/automerge-host.ts +105 -21
- package/src/automerge/automerge-repo.test.ts +38 -6
- package/src/automerge/echo-network-adapter.ts +155 -0
- package/src/automerge/echo-replicator.ts +56 -0
- package/src/automerge/index.ts +1 -2
- package/src/automerge/leveldb-storage-adapter.ts +2 -2
- package/src/automerge/migrations.ts +1 -1
- package/src/automerge/storage-adapter.test.ts +1 -1
- package/src/pipeline/pipeline.ts +1 -0
- package/src/space/control-pipeline.ts +2 -2
- package/src/testing/index.ts +0 -1
- package/dist/types/src/automerge/level.test.d.ts +0 -2
- package/dist/types/src/automerge/level.test.d.ts.map +0 -1
- package/dist/types/src/automerge/reference.d.ts +0 -15
- package/dist/types/src/automerge/reference.d.ts.map +0 -1
- package/dist/types/src/automerge/types.d.ts +0 -73
- package/dist/types/src/automerge/types.d.ts.map +0 -1
- package/dist/types/src/testing/level.d.ts +0 -3
- package/dist/types/src/testing/level.d.ts.map +0 -1
- package/src/automerge/level.test.ts +0 -82
- package/src/automerge/reference.ts +0 -31
- package/src/automerge/types.ts +0 -86
- 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
|
|
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(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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 {
|
|
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({
|
|
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({
|
|
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('
|
|
342
|
+
test('loadRange', async () => {
|
|
336
343
|
const root = `/tmp/${randomBytes(16).toString('hex')}`;
|
|
337
344
|
{
|
|
338
|
-
const
|
|
339
|
-
const adapter = new
|
|
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
|
|
348
|
-
const adapter = new
|
|
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
|
|
371
|
+
test('removeRange', async () => {
|
|
360
372
|
const root = `/tmp/${randomBytes(16).toString('hex')}`;
|
|
361
373
|
{
|
|
362
|
-
const
|
|
363
|
-
const adapter = new
|
|
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
|
|
371
|
-
const adapter = new
|
|
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 {
|
|
6
|
-
import { next as automerge } from '@dxos/automerge/automerge';
|
|
7
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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,
|
|
78
|
+
constructor({ directory, db, indexMetadataStore }: AutomergeHostParams) {
|
|
64
79
|
this._directory = directory;
|
|
65
80
|
this._db = db;
|
|
66
|
-
this.
|
|
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:
|
|
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 = (
|
|
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({
|
|
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(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 {
|
|
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 {
|
|
13
|
+
import { LevelDBStorageAdapter } from './leveldb-storage-adapter';
|
|
11
14
|
|
|
12
15
|
describe('AutomergeRepo', () => {
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
+
};
|