@dxos/echo-pipeline 0.8.3 → 0.8.4-main.1c7ec43d41
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-35I6ERLG.mjs → neutral/chunk-VIHYDCCC.mjs} +313 -709
- package/dist/lib/neutral/chunk-VIHYDCCC.mjs.map +7 -0
- package/dist/lib/neutral/chunk-YBC6ONBZ.mjs +247 -0
- package/dist/lib/neutral/chunk-YBC6ONBZ.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/filter/index.mjs +3 -2
- package/dist/lib/neutral/index.mjs +7065 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/{browser → neutral}/testing/index.mjs +90 -311
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/automerge/automerge-host-subduction.test.d.ts +2 -0
- package/dist/types/src/automerge/automerge-host-subduction.test.d.ts.map +1 -0
- package/dist/types/src/automerge/automerge-host.d.ts +99 -54
- package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
- package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts +2 -0
- package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts.map +1 -0
- package/dist/types/src/automerge/automerge-repo.test.d.ts +1 -1
- package/dist/types/src/automerge/automerge-repo.test.d.ts.map +1 -1
- package/dist/types/src/automerge/automerge-subduction.test.d.ts +2 -0
- package/dist/types/src/automerge/automerge-subduction.test.d.ts.map +1 -0
- package/dist/types/src/automerge/collection-synchronizer.d.ts +25 -4
- package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-data-monitor.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-network-adapter.d.ts +23 -10
- package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-replicator.d.ts +38 -15
- package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
- package/dist/types/src/automerge/handle-state.d.ts +52 -0
- package/dist/types/src/automerge/handle-state.d.ts.map +1 -0
- package/dist/types/src/automerge/heads-store.d.ts +9 -2
- package/dist/types/src/automerge/heads-store.d.ts.map +1 -1
- package/dist/types/src/automerge/index.d.ts +2 -1
- package/dist/types/src/automerge/index.d.ts.map +1 -1
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +9 -5
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
- package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +9 -8
- package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
- package/dist/types/src/automerge/mesh-echo-replicator.d.ts +6 -5
- package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
- package/dist/types/src/automerge/network-protocol.d.ts.map +1 -1
- package/dist/types/src/automerge/space-collection.d.ts.map +1 -1
- package/dist/types/src/common/codec.d.ts +1 -1
- package/dist/types/src/common/codec.d.ts.map +1 -1
- package/dist/types/src/common/feeds.d.ts.map +1 -1
- package/dist/types/src/common/space-id.d.ts.map +1 -1
- package/dist/types/src/db-host/automerge-data-source.d.ts +35 -0
- package/dist/types/src/db-host/automerge-data-source.d.ts.map +1 -0
- package/dist/types/src/db-host/automerge-data-source.test.d.ts +2 -0
- package/dist/types/src/db-host/automerge-data-source.test.d.ts.map +1 -0
- package/dist/types/src/db-host/automerge-metrics.d.ts.map +1 -1
- package/dist/types/src/db-host/data-service.d.ts +8 -7
- package/dist/types/src/db-host/data-service.d.ts.map +1 -1
- package/dist/types/src/db-host/database-root.d.ts +9 -3
- package/dist/types/src/db-host/database-root.d.ts.map +1 -1
- package/dist/types/src/db-host/documents-synchronizer.d.ts +9 -8
- package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
- package/dist/types/src/db-host/echo-host.d.ts +62 -27
- package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
- package/dist/types/src/db-host/index.d.ts +2 -0
- package/dist/types/src/db-host/index.d.ts.map +1 -1
- package/dist/types/src/db-host/invalidation-hint.d.ts +19 -0
- package/dist/types/src/db-host/invalidation-hint.d.ts.map +1 -0
- package/dist/types/src/db-host/local-queue-service.d.ts +19 -0
- package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
- package/dist/types/src/db-host/query-invalidation.test.d.ts +2 -0
- package/dist/types/src/db-host/query-invalidation.test.d.ts.map +1 -0
- package/dist/types/src/db-host/query-service.d.ts +19 -11
- package/dist/types/src/db-host/query-service.d.ts.map +1 -1
- package/dist/types/src/db-host/queue-data-source.d.ts +38 -0
- package/dist/types/src/db-host/queue-data-source.d.ts.map +1 -0
- package/dist/types/src/db-host/queue-service.test.d.ts +2 -0
- package/dist/types/src/db-host/queue-service.test.d.ts.map +1 -0
- package/dist/types/src/db-host/space-state-manager.d.ts +4 -3
- package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
- package/dist/types/src/db-host/stub.d.ts +11 -0
- package/dist/types/src/db-host/stub.d.ts.map +1 -0
- package/dist/types/src/edge/echo-edge-replicator.d.ts +10 -7
- package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -1
- package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts +39 -0
- package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts.map +1 -0
- package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts +2 -0
- package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts.map +1 -0
- package/dist/types/src/edge/index.d.ts +1 -0
- package/dist/types/src/edge/index.d.ts.map +1 -1
- package/dist/types/src/edge/inflight-request-limiter.d.ts.map +1 -1
- package/dist/types/src/filter/filter-match.d.ts +4 -1
- package/dist/types/src/filter/filter-match.d.ts.map +1 -1
- package/dist/types/src/metadata/metadata-store.d.ts +1 -1
- package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
- package/dist/types/src/pipeline/message-selector.d.ts.map +1 -1
- package/dist/types/src/pipeline/pipeline.d.ts +3 -3
- package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
- package/dist/types/src/pipeline/timeframe-clock.d.ts.map +1 -1
- package/dist/types/src/query/errors.d.ts +22 -10
- package/dist/types/src/query/errors.d.ts.map +1 -1
- package/dist/types/src/query/plan.d.ts +66 -8
- package/dist/types/src/query/plan.d.ts.map +1 -1
- package/dist/types/src/query/query-executor.d.ts +66 -11
- package/dist/types/src/query/query-executor.d.ts.map +1 -1
- package/dist/types/src/query/query-planner.d.ts +12 -1
- package/dist/types/src/query/query-planner.d.ts.map +1 -1
- package/dist/types/src/space/admission-discovery-extension.d.ts.map +1 -1
- package/dist/types/src/space/auth.d.ts +3 -3
- package/dist/types/src/space/auth.d.ts.map +1 -1
- package/dist/types/src/space/control-pipeline.d.ts +5 -4
- package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
- package/dist/types/src/space/space-manager.d.ts +8 -8
- package/dist/types/src/space/space-manager.d.ts.map +1 -1
- package/dist/types/src/space/space-protocol.d.ts +10 -9
- package/dist/types/src/space/space-protocol.d.ts.map +1 -1
- package/dist/types/src/space/space.d.ts +6 -6
- package/dist/types/src/space/space.d.ts.map +1 -1
- package/dist/types/src/testing/change-metadata.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +0 -2
- package/dist/types/src/testing/index.d.ts.map +1 -1
- package/dist/types/src/testing/test-agent-builder.d.ts +2 -2
- package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
- package/dist/types/src/testing/test-feed-builder.d.ts.map +1 -1
- package/dist/types/src/testing/test-network-adapter.d.ts +1 -1
- package/dist/types/src/testing/test-network-adapter.d.ts.map +1 -1
- package/dist/types/src/testing/test-replicator.d.ts +14 -13
- package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +1 -1
- package/dist/types/src/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +56 -54
- package/src/automerge/automerge-host-subduction.test.ts +247 -0
- package/src/automerge/automerge-host.test.ts +65 -39
- package/src/automerge/automerge-host.ts +589 -206
- package/src/automerge/automerge-repo-subduction.test.ts +1049 -0
- package/src/automerge/automerge-repo.test.ts +349 -91
- package/src/automerge/automerge-subduction.test.ts +330 -0
- package/src/automerge/collection-synchronizer.test.ts +53 -17
- package/src/automerge/collection-synchronizer.ts +119 -36
- package/src/automerge/echo-data-monitor.ts +1 -1
- package/src/automerge/echo-network-adapter.test.ts +5 -4
- package/src/automerge/echo-network-adapter.ts +74 -30
- package/src/automerge/echo-replicator.ts +41 -15
- package/src/automerge/handle-state.ts +62 -0
- package/src/automerge/heads-store.ts +42 -3
- package/src/automerge/index.ts +2 -1
- package/src/automerge/leveldb-storage-adapter.ts +40 -11
- package/src/automerge/mesh-echo-replicator-connection.ts +12 -8
- package/src/automerge/mesh-echo-replicator.ts +15 -11
- package/src/automerge/storage-adapter.test.ts +1 -1
- package/src/common/space-id.ts +1 -1
- package/src/db-host/automerge-data-source.test.ts +299 -0
- package/src/db-host/automerge-data-source.ts +187 -0
- package/src/db-host/data-service.ts +25 -26
- package/src/db-host/database-root.ts +20 -8
- package/src/db-host/documents-synchronizer.test.ts +143 -14
- package/src/db-host/documents-synchronizer.ts +72 -55
- package/src/db-host/echo-host.ts +348 -96
- package/src/db-host/index.ts +2 -0
- package/src/db-host/invalidation-hint.ts +50 -0
- package/src/db-host/local-queue-service.ts +127 -0
- package/src/db-host/query-invalidation.test.ts +270 -0
- package/src/db-host/query-service.ts +111 -106
- package/src/db-host/queue-data-source.ts +160 -0
- package/src/db-host/queue-service.test.ts +145 -0
- package/src/db-host/space-state-manager.ts +12 -10
- package/src/db-host/stub.ts +33 -0
- package/src/edge/echo-edge-replicator.test.ts +12 -10
- package/src/edge/echo-edge-replicator.ts +144 -58
- package/src/edge/echo-edge-subduction-replicator.test.ts +158 -0
- package/src/edge/echo-edge-subduction-replicator.ts +377 -0
- package/src/edge/index.ts +1 -0
- package/src/filter/filter-match.test.ts +68 -32
- package/src/filter/filter-match.ts +184 -20
- package/src/metadata/metadata-store.ts +11 -8
- package/src/pipeline/pipeline-stress.test.ts +1 -1
- package/src/pipeline/pipeline.test.ts +2 -2
- package/src/pipeline/pipeline.ts +9 -7
- package/src/query/errors.ts +3 -1
- package/src/query/plan.ts +82 -8
- package/src/query/query-executor.ts +1199 -164
- package/src/query/query-planner.test.ts +993 -81
- package/src/query/query-planner.ts +467 -27
- package/src/space/admission-discovery-extension.ts +3 -3
- package/src/space/auth.ts +6 -6
- package/src/space/control-pipeline.test.ts +5 -4
- package/src/space/control-pipeline.ts +16 -13
- package/src/space/space-manager.browser.test.ts +1 -1
- package/src/space/space-manager.ts +16 -20
- package/src/space/space-protocol.browser.test.ts +15 -14
- package/src/space/space-protocol.test.ts +15 -14
- package/src/space/space-protocol.ts +22 -18
- package/src/space/space.test.ts +3 -3
- package/src/space/space.ts +11 -11
- package/src/testing/index.ts +0 -2
- package/src/testing/test-agent-builder.ts +3 -3
- package/src/testing/test-network-adapter.ts +24 -3
- package/src/testing/test-replicator.ts +19 -20
- package/src/util.ts +7 -3
- package/dist/lib/browser/chunk-35I6ERLG.mjs.map +0 -7
- package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
- package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
- package/dist/lib/browser/chunk-TQJTKNMS.mjs +0 -126
- package/dist/lib/browser/chunk-TQJTKNMS.mjs.map +0 -7
- package/dist/lib/browser/index.mjs +0 -4743
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/testing/index.mjs.map +0 -7
- package/dist/lib/node/chunk-HOPOFWAL.cjs +0 -147
- package/dist/lib/node/chunk-HOPOFWAL.cjs.map +0 -7
- package/dist/lib/node/chunk-JXX6LF5U.cjs +0 -2084
- package/dist/lib/node/chunk-JXX6LF5U.cjs.map +0 -7
- package/dist/lib/node/chunk-Q7SFCCGT.cjs +0 -33
- package/dist/lib/node/chunk-Q7SFCCGT.cjs.map +0 -7
- package/dist/lib/node/filter/index.cjs +0 -32
- package/dist/lib/node/filter/index.cjs.map +0 -7
- package/dist/lib/node/index.cjs +0 -4699
- package/dist/lib/node/index.cjs.map +0 -7
- package/dist/lib/node/meta.json +0 -1
- package/dist/lib/node/testing/index.cjs +0 -753
- package/dist/lib/node/testing/index.cjs.map +0 -7
- package/dist/lib/node-esm/chunk-5BHLPT24.mjs +0 -2067
- package/dist/lib/node-esm/chunk-5BHLPT24.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-RVK35BS7.mjs +0 -126
- package/dist/lib/node-esm/chunk-RVK35BS7.mjs.map +0 -7
- package/dist/lib/node-esm/filter/index.mjs +0 -11
- package/dist/lib/node-esm/filter/index.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -4743
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/testing/index.mjs +0 -742
- package/dist/lib/node-esm/testing/index.mjs.map +0 -7
- package/dist/types/src/db-host/documents-iterator.d.ts +0 -7
- package/dist/types/src/db-host/documents-iterator.d.ts.map +0 -1
- package/dist/types/src/testing/test-data.d.ts +0 -18
- package/dist/types/src/testing/test-data.d.ts.map +0 -1
- package/dist/types/src/testing/test-schema.d.ts +0 -37
- package/dist/types/src/testing/test-schema.d.ts.map +0 -1
- package/src/db-host/documents-iterator.ts +0 -74
- package/src/testing/test-data.ts +0 -127
- package/src/testing/test-schema.ts +0 -53
- /package/dist/lib/{browser → neutral}/filter/index.mjs.map +0 -0
|
@@ -3,57 +3,60 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
type Doc,
|
|
7
|
+
type Heads,
|
|
6
8
|
getBackend,
|
|
7
9
|
getHeads,
|
|
8
|
-
isAutomerge,
|
|
9
10
|
equals as headsEquals,
|
|
11
|
+
isAutomerge,
|
|
10
12
|
save,
|
|
11
|
-
type Doc,
|
|
12
|
-
type Heads,
|
|
13
13
|
} from '@automerge/automerge';
|
|
14
14
|
import {
|
|
15
|
-
type DocHandleChangePayload,
|
|
16
|
-
Repo,
|
|
17
15
|
type AnyDocumentId,
|
|
18
16
|
type DocHandle,
|
|
17
|
+
type DocHandleChangePayload,
|
|
19
18
|
type DocumentId,
|
|
19
|
+
type DocumentQuery,
|
|
20
20
|
type PeerCandidatePayload,
|
|
21
21
|
type PeerDisconnectedPayload,
|
|
22
22
|
type PeerId,
|
|
23
|
+
Repo,
|
|
23
24
|
type StorageAdapterInterface,
|
|
24
25
|
type StorageKey,
|
|
26
|
+
type SubductionPolicy,
|
|
25
27
|
interpretAsDocumentId,
|
|
26
|
-
|
|
28
|
+
initSubduction,
|
|
27
29
|
} from '@automerge/automerge-repo';
|
|
30
|
+
import { type MemorySigner } from '@automerge/automerge-subduction';
|
|
28
31
|
|
|
29
|
-
import { Event, asyncTimeout } from '@dxos/async';
|
|
30
|
-
import { Context, Resource, cancelWithContext
|
|
31
|
-
import {
|
|
32
|
-
import { type IndexMetadataStore } from '@dxos/indexing';
|
|
32
|
+
import { DeferredTask, Event, asyncTimeout } from '@dxos/async';
|
|
33
|
+
import { Context, type Lifecycle, Resource, cancelWithContext } from '@dxos/context';
|
|
34
|
+
import { type CollectionId, DatabaseDirectory } from '@dxos/echo-protocol';
|
|
33
35
|
import { invariant } from '@dxos/invariant';
|
|
34
36
|
import { PublicKey } from '@dxos/keys';
|
|
35
37
|
import { type LevelDB } from '@dxos/kv-store';
|
|
36
38
|
import { log } from '@dxos/log';
|
|
37
|
-
import {
|
|
39
|
+
import { type SpaceSyncState } from '@dxos/protocols/proto/dxos/echo/service';
|
|
38
40
|
import { type DocHeadsList, type FlushRequest } from '@dxos/protocols/proto/dxos/echo/service';
|
|
39
41
|
import { trace } from '@dxos/tracing';
|
|
40
|
-
import { bufferToArray } from '@dxos/util';
|
|
42
|
+
import { ComplexSet, bufferToArray, defaultMap, isNonNullable, range } from '@dxos/util';
|
|
41
43
|
|
|
42
|
-
import { CollectionSynchronizer, diffCollectionState
|
|
44
|
+
import { type CollectionState, CollectionSynchronizer, diffCollectionState } from './collection-synchronizer';
|
|
43
45
|
import { type EchoDataMonitor } from './echo-data-monitor';
|
|
44
46
|
import { EchoNetworkAdapter, isEchoPeerMetadata } from './echo-network-adapter';
|
|
45
|
-
import { type
|
|
47
|
+
import { type AutomergeReplicator, type RemoteDocumentExistenceCheckProps } from './echo-replicator';
|
|
48
|
+
import { getHandleState } from './handle-state';
|
|
46
49
|
import { HeadsStore } from './heads-store';
|
|
47
|
-
import {
|
|
50
|
+
import { type BeforeSaveProps, LevelDBStorageAdapter } from './leveldb-storage-adapter';
|
|
48
51
|
|
|
49
52
|
export type PeerIdProvider = () => string | undefined;
|
|
50
53
|
|
|
51
54
|
export type RootDocumentSpaceKeyProvider = (documentId: string) => PublicKey | undefined;
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
db: LevelDB;
|
|
56
|
+
const SUBDUCTION_SERVICE_NAME = 'dxos-subduction';
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
export type AutomergeHostProps = {
|
|
59
|
+
db: LevelDB;
|
|
57
60
|
dataMonitor?: EchoDataMonitor;
|
|
58
61
|
|
|
59
62
|
/**
|
|
@@ -61,10 +64,26 @@ export type AutomergeHostParams = {
|
|
|
61
64
|
*/
|
|
62
65
|
peerIdProvider?: PeerIdProvider;
|
|
63
66
|
getSpaceKeyByRootDocumentId?: RootDocumentSpaceKeyProvider;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Enable Subduction sedimentree transport.
|
|
70
|
+
*
|
|
71
|
+
* When `false` (default), the host wires {@link EchoNetworkAdapter} as a classical
|
|
72
|
+
* automerge-repo network adapter and skips all Subduction-specific initialization
|
|
73
|
+
* (WASM init, signer generation, subduction policy / adapters). When `true`, the
|
|
74
|
+
* host runs Subduction as the document byte transport.
|
|
75
|
+
*/
|
|
76
|
+
useSubduction?: boolean;
|
|
64
77
|
};
|
|
65
78
|
|
|
66
79
|
export type LoadDocOptions = {
|
|
67
80
|
timeout?: number;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* If true, the document will be fetched from the network if it is not found in the local storage.
|
|
84
|
+
* Setting this to false does not guarantee that the document will not be fetched from the network.
|
|
85
|
+
*/
|
|
86
|
+
fetchFromNetwork?: boolean;
|
|
68
87
|
};
|
|
69
88
|
|
|
70
89
|
export type CreateDocOptions = {
|
|
@@ -72,19 +91,42 @@ export type CreateDocOptions = {
|
|
|
72
91
|
* Import the document together with its history.
|
|
73
92
|
*/
|
|
74
93
|
preserveHistory?: boolean;
|
|
75
|
-
};
|
|
76
94
|
|
|
77
|
-
|
|
78
|
-
allowableStates: ['ready', 'requesting'] satisfies HandleState[],
|
|
95
|
+
documentId?: DocumentId;
|
|
79
96
|
};
|
|
80
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Maximum amount of documents to sync in a single bundle.
|
|
100
|
+
*/
|
|
101
|
+
const BUNDLE_SIZE = 100;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Maximum amount of concurrent tasks to run when pushing or pulling bundles.
|
|
105
|
+
*/
|
|
106
|
+
const BUNDLE_SYNC_CONCURRENCY = 2;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* If the number of documents to sync is greater than this threshold, we will use bundles.
|
|
110
|
+
*/
|
|
111
|
+
const BUNDLE_SYNC_THRESHOLD = 50;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Only announce documents that are known to require sync.
|
|
115
|
+
*/
|
|
116
|
+
const OPTIMIZED_SHARE_POLICY = true;
|
|
117
|
+
|
|
81
118
|
/**
|
|
82
119
|
* Abstracts over the AutomergeRepo.
|
|
120
|
+
*
|
|
121
|
+
* Runs Subduction as the document byte transport ({@link Repo.subductionAdapters}), while
|
|
122
|
+
* the DXOS-specific {@link CollectionSynchronizer} rides on the same {@link EchoNetworkAdapter}
|
|
123
|
+
* via `sync-request` / `sync-state` control messages that are intercepted at the adapter
|
|
124
|
+
* level and never reach the Subduction sedimentree layer. Bundle sync remains available
|
|
125
|
+
* for replicators that opt in.
|
|
83
126
|
*/
|
|
84
|
-
@trace.resource()
|
|
127
|
+
@trace.resource({ lifecycle: true })
|
|
85
128
|
export class AutomergeHost extends Resource {
|
|
86
129
|
private readonly _db: LevelDB;
|
|
87
|
-
private readonly _indexMetadataStore: IndexMetadataStore;
|
|
88
130
|
private readonly _echoNetworkAdapter: EchoNetworkAdapter;
|
|
89
131
|
|
|
90
132
|
private readonly _collectionSynchronizer = new CollectionSynchronizer({
|
|
@@ -97,6 +139,14 @@ export class AutomergeHost extends Resource {
|
|
|
97
139
|
private _storage!: StorageAdapterInterface & Lifecycle;
|
|
98
140
|
private readonly _headsStore: HeadsStore;
|
|
99
141
|
|
|
142
|
+
private _syncTask: DeferredTask | undefined = undefined;
|
|
143
|
+
/**
|
|
144
|
+
* Cache of collections that would be synced on next sync task run.
|
|
145
|
+
*/
|
|
146
|
+
private readonly _collectionsToSync = new ComplexSet<{ collectionId: string; peerId: PeerId }>(
|
|
147
|
+
({ collectionId, peerId }) => `${collectionId}|${peerId}`,
|
|
148
|
+
);
|
|
149
|
+
|
|
100
150
|
@trace.info()
|
|
101
151
|
private _peerId!: PeerId;
|
|
102
152
|
|
|
@@ -110,15 +160,44 @@ export class AutomergeHost extends Resource {
|
|
|
110
160
|
*/
|
|
111
161
|
public readonly documentsSaved = new Event();
|
|
112
162
|
|
|
163
|
+
private readonly _headsUpdates = new Map<DocumentId, Heads>();
|
|
164
|
+
private _onHeadsChangedTask?: DeferredTask;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Documents created in this session.
|
|
168
|
+
*/
|
|
169
|
+
private _createdDocuments = new Set<DocumentId>();
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Documents that need to be synced based on the result of collection-sync.
|
|
173
|
+
*/
|
|
174
|
+
private _documentsToSync = new Set<DocumentId>();
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Documents that are not available locally that should be requested.
|
|
178
|
+
*/
|
|
179
|
+
private _documentsToRequest = new Set<DocumentId>();
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Documents requested by remote peers.
|
|
183
|
+
*/
|
|
184
|
+
private _documentsRequested = new Map<PeerId, Set<DocumentId>>();
|
|
185
|
+
|
|
186
|
+
private _sharePolicyChangedTask?: DeferredTask;
|
|
187
|
+
|
|
188
|
+
private _signer: MemorySigner | undefined = undefined;
|
|
189
|
+
private readonly _useSubduction: boolean;
|
|
190
|
+
|
|
113
191
|
constructor({
|
|
114
192
|
db,
|
|
115
|
-
indexMetadataStore,
|
|
116
193
|
dataMonitor,
|
|
117
194
|
peerIdProvider,
|
|
118
195
|
getSpaceKeyByRootDocumentId,
|
|
119
|
-
|
|
196
|
+
useSubduction = false,
|
|
197
|
+
}: AutomergeHostProps) {
|
|
120
198
|
super();
|
|
121
199
|
this._db = db;
|
|
200
|
+
this._useSubduction = useSubduction;
|
|
122
201
|
this._storage = new LevelDBStorageAdapter({
|
|
123
202
|
db: db.sublevel('automerge'),
|
|
124
203
|
callbacks: {
|
|
@@ -134,27 +213,71 @@ export class AutomergeHost extends Resource {
|
|
|
134
213
|
onCollectionStateReceived: this._onCollectionStateReceived.bind(this),
|
|
135
214
|
monitor: dataMonitor,
|
|
136
215
|
});
|
|
216
|
+
this._echoNetworkAdapter.documentRequested.on(({ peerId, documentId }) => {
|
|
217
|
+
defaultMap(this._documentsRequested, peerId, () => new Set()).add(documentId);
|
|
218
|
+
if (!this._useSubduction) {
|
|
219
|
+
this._sharePolicyChangedTask!.schedule();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
137
222
|
this._headsStore = new HeadsStore({ db: db.sublevel('heads') });
|
|
138
|
-
this._indexMetadataStore = indexMetadataStore;
|
|
139
223
|
this._peerIdProvider = peerIdProvider;
|
|
140
224
|
this._getSpaceKeyByRootDocumentId = getSpaceKeyByRootDocumentId;
|
|
141
225
|
}
|
|
142
226
|
|
|
143
|
-
protected override async _open(): Promise<void> {
|
|
227
|
+
protected override async _open(ctx: Context): Promise<void> {
|
|
144
228
|
this._peerId = `host-${this._peerIdProvider?.() ?? PublicKey.random().toHex()}` as PeerId;
|
|
145
229
|
|
|
230
|
+
this._onHeadsChangedTask = new DeferredTask(this._ctx, async () => {
|
|
231
|
+
const docHeads = Array.from(this._headsUpdates.entries());
|
|
232
|
+
this._headsUpdates.clear();
|
|
233
|
+
this._onHeadsChanged(docHeads);
|
|
234
|
+
});
|
|
235
|
+
|
|
146
236
|
await this._storage.open?.();
|
|
147
237
|
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
238
|
+
// `Repo` unconditionally constructs a Subduction `SubductionSource` (with a fresh
|
|
239
|
+
// `MemorySigner` when none is injected) regardless of whether we register any
|
|
240
|
+
// `subductionAdapters`. That source's signer needs the Subduction WASM module
|
|
241
|
+
// initialized first — `Repo` imports from `@automerge/automerge-subduction/slim`,
|
|
242
|
+
// which does not auto-init. So WASM init runs in both modes.
|
|
243
|
+
await initSubduction();
|
|
244
|
+
|
|
245
|
+
if (this._useSubduction) {
|
|
246
|
+
const { MemorySigner } = await import('@automerge/automerge-subduction');
|
|
247
|
+
this._signer ??= MemorySigner.generate();
|
|
248
|
+
|
|
249
|
+
this._repo = new Repo({
|
|
250
|
+
peerId: this._peerId as PeerId,
|
|
251
|
+
shareConfig: this._shareConfig,
|
|
252
|
+
subductionPolicy: this._subductionPolicy,
|
|
253
|
+
storage: this._storage,
|
|
254
|
+
network: [],
|
|
255
|
+
signer: this._signer,
|
|
256
|
+
subductionAdapters: [
|
|
257
|
+
{
|
|
258
|
+
adapter: this._echoNetworkAdapter,
|
|
259
|
+
serviceName: SUBDUCTION_SERVICE_NAME,
|
|
260
|
+
// DXOS hosts are always clients — the edge DO is the single `accept` peer in
|
|
261
|
+
// the DXOS-client <-> edge topology. `connect` uses Subduction's discovery mode,
|
|
262
|
+
// so peer-to-peer connections (e.g., mesh replicator, test networks) also work
|
|
263
|
+
// with `connect` on both sides.
|
|
264
|
+
role: 'connect',
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
});
|
|
268
|
+
} else {
|
|
269
|
+
// Classical automerge-repo wiring: the EchoNetworkAdapter is registered as a
|
|
270
|
+
// network adapter and document bytes flow through the standard sync protocol.
|
|
271
|
+
// `Repo` will internally construct an unused `MemorySigner` for its always-on
|
|
272
|
+
// `SubductionSource`; with no `subductionAdapters` passed in that source has no
|
|
273
|
+
// peers to talk to, so it's effectively dormant.
|
|
274
|
+
this._repo = new Repo({
|
|
275
|
+
peerId: this._peerId as PeerId,
|
|
276
|
+
shareConfig: this._shareConfig,
|
|
277
|
+
storage: this._storage,
|
|
278
|
+
network: [this._echoNetworkAdapter],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
158
281
|
|
|
159
282
|
let updatingAuthScope = false;
|
|
160
283
|
Event.wrap(this._echoNetworkAdapter, 'peer-candidate').on(
|
|
@@ -169,8 +292,7 @@ export class AutomergeHost extends Resource {
|
|
|
169
292
|
this._collectionSynchronizer.remoteStateUpdated.on(this._ctx, ({ collectionId, peerId, newDocsAppeared }) => {
|
|
170
293
|
this._onRemoteCollectionStateUpdated(collectionId, peerId);
|
|
171
294
|
this.collectionStateUpdated.emit({ collectionId: collectionId as CollectionId });
|
|
172
|
-
|
|
173
|
-
if (newDocsAppeared) {
|
|
295
|
+
if (!this._useSubduction && newDocsAppeared) {
|
|
174
296
|
updatingAuthScope = true;
|
|
175
297
|
try {
|
|
176
298
|
this._echoNetworkAdapter.onConnectionAuthScopeChanged(peerId);
|
|
@@ -180,24 +302,42 @@ export class AutomergeHost extends Resource {
|
|
|
180
302
|
}
|
|
181
303
|
});
|
|
182
304
|
|
|
305
|
+
this._syncTask = new DeferredTask(this._ctx, async () => {
|
|
306
|
+
const collectionToSync = Array.from(this._collectionsToSync.values());
|
|
307
|
+
this._collectionsToSync.clear();
|
|
308
|
+
if (collectionToSync.length === 0) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await Promise.all(
|
|
312
|
+
collectionToSync.map(async ({ collectionId, peerId }) => {
|
|
313
|
+
try {
|
|
314
|
+
await this._handleCollectionSync(this._ctx, collectionId, peerId);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
log.error('failed to sync collection', { collectionId, peerId, err });
|
|
317
|
+
}
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
this._sharePolicyChangedTask = new DeferredTask(this._ctx, async () => {
|
|
323
|
+
log('share policy changed');
|
|
324
|
+
this._repo.shareConfigChanged();
|
|
325
|
+
});
|
|
326
|
+
|
|
183
327
|
await this._echoNetworkAdapter.open();
|
|
184
|
-
await this._collectionSynchronizer.open();
|
|
185
|
-
await this._echoNetworkAdapter.open();
|
|
328
|
+
await this._collectionSynchronizer.open(ctx);
|
|
186
329
|
await this._echoNetworkAdapter.whenConnected();
|
|
187
330
|
}
|
|
188
331
|
|
|
189
|
-
protected override async _close(): Promise<void> {
|
|
190
|
-
await this._collectionSynchronizer.close();
|
|
332
|
+
protected override async _close(ctx: Context): Promise<void> {
|
|
333
|
+
await this._collectionSynchronizer.close(ctx);
|
|
334
|
+
await this._repo.flush().catch((err) => log.warn('failed to flush repo before shutdown', { err }));
|
|
335
|
+
await this._repo.shutdown().catch((err) => log.warn('failed to shutdown repo', { err }));
|
|
191
336
|
await this._storage.close?.();
|
|
192
337
|
await this._echoNetworkAdapter.close();
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* @deprecated To be abstracted away.
|
|
198
|
-
*/
|
|
199
|
-
get repo(): Repo {
|
|
200
|
-
return this._repo;
|
|
338
|
+
this._syncTask = undefined;
|
|
339
|
+
this._onHeadsChangedTask = undefined;
|
|
340
|
+
this._sharePolicyChangedTask = undefined;
|
|
201
341
|
}
|
|
202
342
|
|
|
203
343
|
get peerId(): PeerId {
|
|
@@ -208,40 +348,54 @@ export class AutomergeHost extends Resource {
|
|
|
208
348
|
return Object.keys(this._repo.handles).length;
|
|
209
349
|
}
|
|
210
350
|
|
|
211
|
-
|
|
212
|
-
|
|
351
|
+
get handles(): Readonly<Record<DocumentId, DocHandle<any>>> {
|
|
352
|
+
return this._repo.handles;
|
|
213
353
|
}
|
|
214
354
|
|
|
215
|
-
async
|
|
355
|
+
async addReplicator(ctx: Context, replicator: AutomergeReplicator): Promise<void> {
|
|
356
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
357
|
+
await this._echoNetworkAdapter.addReplicator(ctx, replicator);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async removeReplicator(replicator: AutomergeReplicator): Promise<void> {
|
|
361
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
216
362
|
await this._echoNetworkAdapter.removeReplicator(replicator);
|
|
217
363
|
}
|
|
218
364
|
|
|
219
|
-
/**
|
|
220
|
-
* Loads the document handle from the repo and waits for it to be ready.
|
|
221
|
-
*/
|
|
222
365
|
async loadDoc<T>(ctx: Context, documentId: AnyDocumentId, opts?: LoadDocOptions): Promise<DocHandle<T>> {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
366
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
367
|
+
// Readiness lives on the `DocumentQuery`, not the `DocHandle` — see
|
|
368
|
+
// {@link getHandleState}.
|
|
369
|
+
const progress = this._repo.findWithProgress<T>(documentId as DocumentId);
|
|
370
|
+
const state = progress.peek();
|
|
371
|
+
if (state.state === 'ready') {
|
|
372
|
+
return state.handle;
|
|
230
373
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (!opts?.timeout) {
|
|
235
|
-
await cancelWithContext(ctx, handle.whenReady());
|
|
236
|
-
} else {
|
|
237
|
-
await cancelWithContext(ctx, asyncTimeout(handle.whenReady(), opts.timeout));
|
|
238
|
-
}
|
|
374
|
+
if (!this._useSubduction) {
|
|
375
|
+
this._documentsToRequest.add(progress.documentId);
|
|
376
|
+
this._sharePolicyChangedTask!.schedule();
|
|
239
377
|
}
|
|
378
|
+
const readyPromise = progress.whenReady();
|
|
379
|
+
return opts?.timeout
|
|
380
|
+
? await cancelWithContext(ctx, asyncTimeout(readyPromise, opts.timeout))
|
|
381
|
+
: await cancelWithContext(ctx, readyPromise);
|
|
382
|
+
}
|
|
240
383
|
|
|
241
|
-
|
|
384
|
+
/**
|
|
385
|
+
* Returns the live {@link DocumentQuery} for a document — exposes the
|
|
386
|
+
* always-attached `DocHandle` and the actual readiness state
|
|
387
|
+
* (`'loading' | 'ready' | 'unavailable' | 'failed'`) via `peek()` /
|
|
388
|
+
* `subscribe()` / `whenReady()`. Use this to observe sync state without
|
|
389
|
+
* forcing a wait. See {@link getHandleState} for why callers must read
|
|
390
|
+
* liveness off the query rather than the `DocHandle` itself.
|
|
391
|
+
*/
|
|
392
|
+
findWithProgress<T>(documentId: AnyDocumentId): DocumentQuery<T> {
|
|
393
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
394
|
+
return this._repo.findWithProgress<T>(documentId as DocumentId) as DocumentQuery<T>;
|
|
242
395
|
}
|
|
243
396
|
|
|
244
|
-
async exportDoc(
|
|
397
|
+
async exportDoc(id: AnyDocumentId): Promise<Uint8Array> {
|
|
398
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
245
399
|
const documentId = interpretAsDocumentId(id);
|
|
246
400
|
|
|
247
401
|
const chunks = await this._storage.loadRange([documentId]);
|
|
@@ -251,10 +405,16 @@ export class AutomergeHost extends Resource {
|
|
|
251
405
|
/**
|
|
252
406
|
* Create new persisted document.
|
|
253
407
|
*/
|
|
254
|
-
createDoc<T>(initialValue?: T | Doc<T> | Uint8Array, opts?: CreateDocOptions): DocHandle<T
|
|
408
|
+
async createDoc<T>(initialValue?: T | Doc<T> | Uint8Array, opts?: CreateDocOptions): Promise<DocHandle<T>> {
|
|
409
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
255
410
|
if (opts?.preserveHistory) {
|
|
256
411
|
if (initialValue instanceof Uint8Array) {
|
|
257
|
-
|
|
412
|
+
const handle = this._repo.import<T>(initialValue, { docId: opts?.documentId });
|
|
413
|
+
this._createdDocuments.add(handle.documentId);
|
|
414
|
+
if (!this._useSubduction) {
|
|
415
|
+
this._sharePolicyChangedTask!.schedule();
|
|
416
|
+
}
|
|
417
|
+
return handle;
|
|
258
418
|
}
|
|
259
419
|
|
|
260
420
|
if (!isAutomerge(initialValue)) {
|
|
@@ -262,17 +422,31 @@ export class AutomergeHost extends Resource {
|
|
|
262
422
|
}
|
|
263
423
|
|
|
264
424
|
// TODO(dmaretskyi): There's a more efficient way.
|
|
265
|
-
|
|
425
|
+
const handle = this._repo.import<T>(save(initialValue as Doc<T>), { docId: opts?.documentId });
|
|
426
|
+
this._createdDocuments.add(handle.documentId);
|
|
427
|
+
if (!this._useSubduction) {
|
|
428
|
+
this._sharePolicyChangedTask!.schedule();
|
|
429
|
+
}
|
|
430
|
+
return handle;
|
|
266
431
|
} else {
|
|
267
432
|
if (initialValue instanceof Uint8Array) {
|
|
268
433
|
throw new Error('Cannot create document from Uint8Array without preserving history');
|
|
269
434
|
}
|
|
270
435
|
|
|
271
|
-
|
|
436
|
+
if (opts?.documentId) {
|
|
437
|
+
throw new Error('Cannot prefil document id when not importing an existing doc');
|
|
438
|
+
}
|
|
439
|
+
const handle = await this._repo.create2<T>(initialValue);
|
|
440
|
+
this._createdDocuments.add(handle.documentId);
|
|
441
|
+
if (!this._useSubduction) {
|
|
442
|
+
this._sharePolicyChangedTask!.schedule();
|
|
443
|
+
}
|
|
444
|
+
return handle;
|
|
272
445
|
}
|
|
273
446
|
}
|
|
274
447
|
|
|
275
|
-
async waitUntilHeadsReplicated(heads: DocHeadsList): Promise<void> {
|
|
448
|
+
async waitUntilHeadsReplicated(ctx: Context, heads: DocHeadsList): Promise<void> {
|
|
449
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
276
450
|
const entries = heads.entries;
|
|
277
451
|
if (!entries?.length) {
|
|
278
452
|
return;
|
|
@@ -285,33 +459,39 @@ export class AutomergeHost extends Resource {
|
|
|
285
459
|
return false;
|
|
286
460
|
}
|
|
287
461
|
const currentHeads = documentHeads[index];
|
|
288
|
-
return !(currentHeads !== null && headsEquals(currentHeads, targetHeads));
|
|
462
|
+
return !(currentHeads !== null && currentHeads !== undefined && headsEquals(currentHeads, targetHeads));
|
|
289
463
|
});
|
|
290
464
|
if (headsToWait.length > 0) {
|
|
291
465
|
await Promise.all(
|
|
292
|
-
headsToWait.map(async (entry
|
|
293
|
-
const handle = await this.loadDoc<DatabaseDirectory>(
|
|
466
|
+
headsToWait.map(async (entry) => {
|
|
467
|
+
const handle = await this.loadDoc<DatabaseDirectory>(ctx, entry.documentId as DocumentId);
|
|
294
468
|
await waitForHeads(handle, entry.heads!);
|
|
295
469
|
}),
|
|
296
470
|
);
|
|
297
471
|
}
|
|
298
472
|
|
|
299
473
|
// Flush to disk handles loaded to memory also so that the indexer can pick up the changes.
|
|
300
|
-
await this._repo.flush(
|
|
301
|
-
documentIds.filter((documentId) => this._repo.handles[documentId] && this._repo.handles[documentId].isReady()),
|
|
302
|
-
);
|
|
474
|
+
await this._repo.flush(documentIds.filter((documentId) => getHandleState(this._repo, documentId) === 'ready'));
|
|
303
475
|
}
|
|
304
476
|
|
|
305
477
|
async reIndexHeads(documentIds: DocumentId[]): Promise<void> {
|
|
478
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
306
479
|
for (const documentId of documentIds) {
|
|
307
480
|
log('re-indexing heads for document', { documentId });
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
481
|
+
// `Repo.find()` resolves on `'ready'` and rejects on `'unavailable'`,
|
|
482
|
+
// so the handle is guaranteed to hold data here.
|
|
483
|
+
let handle: DocHandle<any>;
|
|
484
|
+
try {
|
|
485
|
+
handle = await this._repo.find(documentId);
|
|
486
|
+
} catch (err) {
|
|
487
|
+
log.error('failed to find document', { documentId, err });
|
|
488
|
+
continue;
|
|
312
489
|
}
|
|
313
490
|
|
|
314
491
|
const heads = handle.heads();
|
|
492
|
+
if (!heads) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
315
495
|
const batch = this._db.batch();
|
|
316
496
|
this._headsStore.setHeads(documentId, heads, batch);
|
|
317
497
|
await batch.write();
|
|
@@ -319,30 +499,62 @@ export class AutomergeHost extends Resource {
|
|
|
319
499
|
log('done re-indexing heads');
|
|
320
500
|
}
|
|
321
501
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
502
|
+
/**
|
|
503
|
+
* Share policy for the Repo's classical automerge-repo sync path (CollectionSynchronizer /
|
|
504
|
+
* DocSynchronizer — see `src/synchronizer/DocSynchronizer.ts#resolveSharePolicy`).
|
|
505
|
+
*
|
|
506
|
+
* NOTE: Subduction replication does NOT consult `shareConfig`.
|
|
507
|
+
* {@link AutomergeReplicator}s via {@link EchoNetworkAdapter}.
|
|
508
|
+
*/
|
|
509
|
+
private readonly _shareConfig = {
|
|
510
|
+
access: async (_peerId: PeerId, _documentId?: DocumentId): Promise<boolean> => {
|
|
511
|
+
// Access-on-request is always allowed; per-doc authorization happens in the replicator.
|
|
512
|
+
return true;
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
// TODO(dmaretskyi): Share based on HALO permissions and space affinity.
|
|
516
|
+
// Hosts, running in the worker, don't share documents unless requested by other peers.
|
|
517
|
+
// NOTE: If both peers return sharePolicy=false the replication will not happen
|
|
518
|
+
// https://github.com/automerge/automerge-repo/pull/292
|
|
519
|
+
// Called for all loaded documents so they could be advertised to the sync server.
|
|
520
|
+
announce: async (peerId: PeerId, documentId?: DocumentId): Promise<boolean> => {
|
|
521
|
+
if (!documentId) {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
330
524
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
525
|
+
if (OPTIMIZED_SHARE_POLICY) {
|
|
526
|
+
if (
|
|
527
|
+
!this._createdDocuments.has(documentId) &&
|
|
528
|
+
!this._documentsToSync.has(documentId) &&
|
|
529
|
+
!this._documentsToRequest.has(documentId)
|
|
530
|
+
) {
|
|
531
|
+
// Skip advertising documents that don't need to be synced.
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
334
535
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
536
|
+
const peerMetadata = this._repo.peerMetadataByPeerId[peerId];
|
|
537
|
+
if (isEchoPeerMetadata(peerMetadata)) {
|
|
538
|
+
return this._echoNetworkAdapter.shouldAdvertise(peerId, { documentId });
|
|
539
|
+
}
|
|
339
540
|
|
|
340
|
-
|
|
341
|
-
|
|
541
|
+
return false;
|
|
542
|
+
},
|
|
543
|
+
};
|
|
342
544
|
|
|
343
|
-
|
|
545
|
+
/**
|
|
546
|
+
* Authorization policy consulted by the Subduction sedimentree protocol.
|
|
547
|
+
*/
|
|
548
|
+
private readonly _subductionPolicy: SubductionPolicy = {
|
|
549
|
+
authorizeConnect: async (_peerId) => {},
|
|
550
|
+
authorizeFetch: async (_peerId, _sedimentreeId) => {},
|
|
551
|
+
authorizePut: async (_requestor, _author, _sedimentreeId) => {},
|
|
552
|
+
filterAuthorizedFetch: async (_peerId, sedimentreeIds) => sedimentreeIds,
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
private async _beforeSave({ path, batch }: BeforeSaveProps): Promise<void> {
|
|
344
556
|
const handle = this._repo.handles[path[0] as DocumentId];
|
|
345
|
-
if (!handle ||
|
|
557
|
+
if (!handle || getHandleState(this._repo, handle.documentId) !== 'ready') {
|
|
346
558
|
return;
|
|
347
559
|
}
|
|
348
560
|
const doc = handle.doc();
|
|
@@ -352,46 +564,41 @@ export class AutomergeHost extends Resource {
|
|
|
352
564
|
|
|
353
565
|
const heads = getHeads(doc);
|
|
354
566
|
this._headsStore.setHeads(handle.documentId, heads, batch);
|
|
355
|
-
|
|
356
|
-
const spaceKey = DatabaseDirectory.getSpaceKey(doc) ?? undefined;
|
|
357
|
-
const objectIds = Object.keys(doc.objects ?? {});
|
|
358
|
-
const encodedIds = objectIds.map((objectId) =>
|
|
359
|
-
objectPointerCodec.encode({ documentId: handle.documentId, objectId, spaceKey }),
|
|
360
|
-
);
|
|
361
|
-
const idToLastHash = new Map(encodedIds.map((id) => [id, heads]));
|
|
362
|
-
this._indexMetadataStore.markDirty(idToLastHash, batch);
|
|
363
567
|
}
|
|
364
568
|
|
|
365
569
|
private _shouldSyncCollection(collectionId: string, peerId: PeerId): boolean {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return false;
|
|
570
|
+
// Under Subduction the Repo's `peerMetadataByPeerId` is not populated for peers that only
|
|
571
|
+
// speak Subduction (no classical peer message). Query the adapter directly — it maps
|
|
572
|
+
// peerId -> connection and the per-connection `shouldSyncCollection` gates the answer.
|
|
573
|
+
return this._echoNetworkAdapter.shouldSyncCollection(peerId, { collectionId });
|
|
372
574
|
}
|
|
373
575
|
|
|
374
576
|
/**
|
|
375
577
|
* Called by AutomergeStorageAdapter after levelDB batch commit.
|
|
376
578
|
*/
|
|
377
579
|
private async _afterSave(path: StorageKey): Promise<void> {
|
|
378
|
-
this.
|
|
580
|
+
if (!this.isOpen) {
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
379
583
|
|
|
380
584
|
const documentId = path[0] as DocumentId;
|
|
381
|
-
const
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
585
|
+
const handle = this._repo.handles[documentId];
|
|
586
|
+
if (!handle) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const document = handle.doc();
|
|
590
|
+
if (!document) {
|
|
591
|
+
return;
|
|
385
592
|
}
|
|
386
|
-
this.documentsSaved.emit();
|
|
387
|
-
}
|
|
388
593
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
594
|
+
const heads = getHeads(document);
|
|
595
|
+
this._headsUpdates.set(documentId, heads);
|
|
596
|
+
invariant(this._onHeadsChangedTask, 'onHeadsChangedTask is not initialized');
|
|
597
|
+
this._onHeadsChangedTask.schedule();
|
|
598
|
+
this.documentsSaved.emit();
|
|
392
599
|
}
|
|
393
600
|
|
|
394
|
-
private async _isDocumentInRemoteCollection(params:
|
|
601
|
+
private async _isDocumentInRemoteCollection(params: RemoteDocumentExistenceCheckProps): Promise<boolean> {
|
|
395
602
|
for (const collectionId of this._collectionSynchronizer.getRegisteredCollectionIds()) {
|
|
396
603
|
const remoteCollections = this._collectionSynchronizer.getRemoteCollectionStates(collectionId);
|
|
397
604
|
const remotePeerDocs = remoteCollections.get(params.peerId as PeerId)?.documents;
|
|
@@ -403,21 +610,28 @@ export class AutomergeHost extends Resource {
|
|
|
403
610
|
}
|
|
404
611
|
|
|
405
612
|
private async _getContainingSpaceForDocument(documentId: string): Promise<PublicKey | null> {
|
|
613
|
+
// This runs inside share-policy resolution (see `MeshEchoReplicator.shouldAdvertise` ->
|
|
614
|
+
// `_shareConfig.announce` -> `DocSynchronizer.#resolveSharePolicy`). It must NOT block
|
|
615
|
+
// on the document becoming ready: under classical sync the network source's availability
|
|
616
|
+
// is itself gated on share policy returning, so awaiting `progress.whenReady()` here
|
|
617
|
+
// deadlocks the load (the document never transitions out of `'loading'`).
|
|
618
|
+
//
|
|
619
|
+
// Read the spaceKey iff the document is already loaded; otherwise let the share policy
|
|
620
|
+
// fall through to the `_getSpaceKeyByRootDocumentId` lookup or the
|
|
621
|
+
// `isDocumentInRemoteCollection` check on the caller.
|
|
406
622
|
const handle = this._repo.handles[documentId as any];
|
|
407
|
-
if (handle.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
623
|
+
if (handle && getHandleState(this._repo, documentId as DocumentId) === 'ready') {
|
|
624
|
+
const doc = handle.doc();
|
|
625
|
+
if (doc) {
|
|
626
|
+
const spaceKeyHex = DatabaseDirectory.getSpaceKey(doc);
|
|
627
|
+
if (spaceKeyHex) {
|
|
628
|
+
return PublicKey.from(spaceKeyHex);
|
|
629
|
+
}
|
|
414
630
|
}
|
|
415
631
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
* We don't have a document to check access block locally, so we need to rely on external sources (space metada).
|
|
420
|
-
*/
|
|
632
|
+
|
|
633
|
+
// Edge case on initial space setup: a peer may be sharing the space root document
|
|
634
|
+
// with us after a successful invitation, before our local handle has any data.
|
|
421
635
|
const rootDocSpaceKey = this._getSpaceKeyByRootDocumentId?.(documentId);
|
|
422
636
|
if (rootDocSpaceKey) {
|
|
423
637
|
return rootDocSpaceKey;
|
|
@@ -429,24 +643,31 @@ export class AutomergeHost extends Resource {
|
|
|
429
643
|
/**
|
|
430
644
|
* Flush documents to disk.
|
|
431
645
|
*/
|
|
432
|
-
@trace.span({ showInBrowserTimeline: true })
|
|
433
|
-
async flush({ documentIds }: FlushRequest = {}): Promise<void> {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const handle = this._repo.handles[documentId as DocumentId];
|
|
438
|
-
return handle && handle.isReady();
|
|
439
|
-
});
|
|
646
|
+
@trace.span({ showInBrowserTimeline: true, showInRemoteTracing: false })
|
|
647
|
+
async flush(ctx: Context, { documentIds }: FlushRequest = {}): Promise<void> {
|
|
648
|
+
const loadedDocuments = (documentIds ?? Object.keys(this._repo.handles)).filter(
|
|
649
|
+
(documentId): documentId is DocumentId => getHandleState(this._repo, documentId as DocumentId) === 'ready',
|
|
650
|
+
);
|
|
440
651
|
await this._repo.flush(loadedDocuments);
|
|
652
|
+
|
|
653
|
+
// Ensure that document versions have propagated across the system.
|
|
654
|
+
await this._onHeadsChangedTask?.runBlocking();
|
|
441
655
|
}
|
|
442
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Returns current heads of each requested document.
|
|
659
|
+
*
|
|
660
|
+
* Loaded handles are read directly; unloaded documents fall back to the {@link HeadsStore},
|
|
661
|
+
* then to reconstruction from the automerge storage chunks (for docs persisted before
|
|
662
|
+
* the HeadsStore was populated).
|
|
663
|
+
*/
|
|
443
664
|
async getHeads(documentIds: DocumentId[]): Promise<(Heads | undefined)[]> {
|
|
444
665
|
const result: (Heads | undefined)[] = [];
|
|
445
666
|
const storeRequestIds: DocumentId[] = [];
|
|
446
667
|
const storeResultIndices: number[] = [];
|
|
447
668
|
for (const documentId of documentIds) {
|
|
448
669
|
const handle = this._repo.handles[documentId];
|
|
449
|
-
if (handle &&
|
|
670
|
+
if (handle && getHandleState(this._repo, documentId) === 'ready' && handle.doc()) {
|
|
450
671
|
result.push(getHeads(handle.doc()!));
|
|
451
672
|
} else {
|
|
452
673
|
storeRequestIds.push(documentId);
|
|
@@ -463,6 +684,13 @@ export class AutomergeHost extends Resource {
|
|
|
463
684
|
return result;
|
|
464
685
|
}
|
|
465
686
|
|
|
687
|
+
/**
|
|
688
|
+
* Iterate over all document heads stored on disk.
|
|
689
|
+
*/
|
|
690
|
+
listDocumentHeads(): AsyncGenerator<{ documentId: DocumentId; heads: Heads }> {
|
|
691
|
+
return this._headsStore.iterateAll();
|
|
692
|
+
}
|
|
693
|
+
|
|
466
694
|
//
|
|
467
695
|
// Collection sync.
|
|
468
696
|
//
|
|
@@ -479,8 +707,8 @@ export class AutomergeHost extends Resource {
|
|
|
479
707
|
this._collectionSynchronizer.refreshCollection(collectionId);
|
|
480
708
|
}
|
|
481
709
|
|
|
482
|
-
async getCollectionSyncState(collectionId: string): Promise<
|
|
483
|
-
const result:
|
|
710
|
+
async getCollectionSyncState(collectionId: string): Promise<SpaceSyncState> {
|
|
711
|
+
const result: SpaceSyncState = {
|
|
484
712
|
peers: [],
|
|
485
713
|
};
|
|
486
714
|
|
|
@@ -493,13 +721,15 @@ export class AutomergeHost extends Resource {
|
|
|
493
721
|
|
|
494
722
|
for (const [peerId, state] of remoteState) {
|
|
495
723
|
const diff = diffCollectionState(localState, state);
|
|
496
|
-
result.peers
|
|
724
|
+
result.peers!.push({
|
|
497
725
|
peerId,
|
|
498
726
|
missingOnRemote: diff.missingOnRemote.length,
|
|
499
727
|
missingOnLocal: diff.missingOnLocal.length,
|
|
500
728
|
differentDocuments: diff.different.length,
|
|
501
|
-
localDocumentCount: Object.
|
|
502
|
-
remoteDocumentCount: Object.
|
|
729
|
+
localDocumentCount: Object.entries(localState.documents).filter(([_, heads]) => heads.length > 0).length,
|
|
730
|
+
remoteDocumentCount: Object.entries(state.documents).filter(([_, heads]) => heads.length > 0).length,
|
|
731
|
+
totalDocumentCount: new Set([...Object.keys(localState.documents), ...Object.keys(state.documents)]).size,
|
|
732
|
+
unsyncedDocumentCount: new Set([...diff.missingOnLocal, ...diff.missingOnRemote, ...diff.different]).size,
|
|
503
733
|
});
|
|
504
734
|
}
|
|
505
735
|
|
|
@@ -515,6 +745,16 @@ export class AutomergeHost extends Resource {
|
|
|
515
745
|
heads.map((heads, index) => [documentIds[index], heads ?? []]),
|
|
516
746
|
);
|
|
517
747
|
this._collectionSynchronizer.setLocalCollectionState(collectionId, { documents });
|
|
748
|
+
|
|
749
|
+
// Proactively push our updated local state to peers that are interested in this collection.
|
|
750
|
+
// This reduces reliance on the next periodic query and prevents replication stalls in fast
|
|
751
|
+
// paths where the remote queries before our local state is ready.
|
|
752
|
+
const interestedPeers = this._echoNetworkAdapter.getPeersInterestedInCollection(collectionId);
|
|
753
|
+
if (interestedPeers.length > 0) {
|
|
754
|
+
for (const peerId of interestedPeers) {
|
|
755
|
+
this._sendCollectionState(collectionId, peerId, { documents });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
518
758
|
}
|
|
519
759
|
|
|
520
760
|
async clearLocalCollectionState(collectionId: string): Promise<void> {
|
|
@@ -546,6 +786,11 @@ export class AutomergeHost extends Resource {
|
|
|
546
786
|
}
|
|
547
787
|
|
|
548
788
|
private _onRemoteCollectionStateUpdated(collectionId: string, peerId: PeerId): void {
|
|
789
|
+
this._collectionsToSync.add({ collectionId, peerId });
|
|
790
|
+
this._syncTask?.schedule();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private async _handleCollectionSync(ctx: Context, collectionId: string, peerId: PeerId) {
|
|
549
794
|
const localState = this._collectionSynchronizer.getLocalCollectionState(collectionId);
|
|
550
795
|
const remoteState = this._collectionSynchronizer.getRemoteCollectionStates(collectionId).get(peerId);
|
|
551
796
|
|
|
@@ -554,36 +799,192 @@ export class AutomergeHost extends Resource {
|
|
|
554
799
|
}
|
|
555
800
|
|
|
556
801
|
const { different, missingOnLocal, missingOnRemote } = diffCollectionState(localState, remoteState);
|
|
557
|
-
const toReplicate = [...missingOnLocal, ...missingOnRemote, ...different];
|
|
558
802
|
|
|
559
|
-
if (
|
|
803
|
+
if (different.length === 0 && missingOnLocal.length === 0 && missingOnRemote.length === 0) {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const toReplicateWithoutBatching = [...different];
|
|
808
|
+
const bundleSyncEnabled = this._echoNetworkAdapter.bundleSyncEnabledForPeer(peerId);
|
|
809
|
+
if (bundleSyncEnabled && missingOnRemote.length >= BUNDLE_SYNC_THRESHOLD) {
|
|
810
|
+
log('pushing bundle', { amount: missingOnRemote.length });
|
|
811
|
+
const { syncInteractively } = await this._pushInBundles(ctx, peerId, missingOnRemote);
|
|
812
|
+
toReplicateWithoutBatching.push(...syncInteractively);
|
|
813
|
+
} else {
|
|
814
|
+
log.verbose('failed to push bundle, replicating interactively', {
|
|
815
|
+
collectionId,
|
|
816
|
+
peerId,
|
|
817
|
+
amount: missingOnRemote.length,
|
|
818
|
+
});
|
|
819
|
+
toReplicateWithoutBatching.push(...missingOnRemote);
|
|
820
|
+
}
|
|
821
|
+
if (bundleSyncEnabled && missingOnLocal.length >= BUNDLE_SYNC_THRESHOLD) {
|
|
822
|
+
log('pulling bundle', { amount: missingOnLocal.length });
|
|
823
|
+
const { syncInteractively } = await this._pullInBundles(ctx, peerId, missingOnLocal);
|
|
824
|
+
toReplicateWithoutBatching.push(...syncInteractively);
|
|
825
|
+
} else {
|
|
826
|
+
log.verbose('failed to pull bundle, replicating interactively', {
|
|
827
|
+
collectionId,
|
|
828
|
+
peerId,
|
|
829
|
+
amount: missingOnLocal.length,
|
|
830
|
+
});
|
|
831
|
+
toReplicateWithoutBatching.push(...missingOnLocal);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (toReplicateWithoutBatching.length === 0) {
|
|
560
835
|
return;
|
|
561
836
|
}
|
|
562
837
|
|
|
563
838
|
log('replicating documents after collection sync', {
|
|
564
839
|
collectionId,
|
|
565
840
|
peerId,
|
|
566
|
-
|
|
567
|
-
count:
|
|
841
|
+
toReplicateWithoutBatching,
|
|
842
|
+
count: toReplicateWithoutBatching.length,
|
|
568
843
|
});
|
|
569
844
|
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
845
|
+
// Trigger replication of the missing documents. `findWithProgress` is the
|
|
846
|
+
// fire-and-forget trigger — it creates a DocHandle and attaches sources. Under classical
|
|
847
|
+
// sync this triggers automerge-repo's doc-synchronizer; under Subduction it registers a
|
|
848
|
+
// query for the sedimentreeId. Either way, once bytes arrive `_afterSave` populates
|
|
849
|
+
// `HeadsStore` so collection sync sees the updated heads on the next diff.
|
|
850
|
+
for (const documentId of toReplicateWithoutBatching) {
|
|
851
|
+
this._documentsToSync.add(documentId);
|
|
852
|
+
this._repo.findWithProgress(documentId as DocumentId);
|
|
853
|
+
}
|
|
854
|
+
if (!this._useSubduction) {
|
|
855
|
+
this._sharePolicyChangedTask!.schedule();
|
|
573
856
|
}
|
|
574
857
|
}
|
|
575
858
|
|
|
576
|
-
|
|
859
|
+
// TODO(mykola): Add retries of batches https://gist.github.com/mykola-vrmchk/fde270259e9209fcbf1331e5abbf12cf
|
|
860
|
+
private async _pushInBundles(
|
|
861
|
+
ctx: Context,
|
|
862
|
+
peerId: PeerId,
|
|
863
|
+
documentIds: DocumentId[],
|
|
864
|
+
): Promise<{ syncInteractively: DocumentId[] }> {
|
|
865
|
+
const documentsToPush = [...documentIds];
|
|
866
|
+
const syncInteractively: DocumentId[] = [];
|
|
867
|
+
|
|
868
|
+
// Push bundles in parallel with BUNDLE_SYNC_CONCURRENCY max concurrent tasks.
|
|
869
|
+
while (documentsToPush.length > 0) {
|
|
870
|
+
await Promise.all(
|
|
871
|
+
range(BUNDLE_SYNC_CONCURRENCY).map(async () => {
|
|
872
|
+
const bundle = documentsToPush.splice(0, BUNDLE_SIZE);
|
|
873
|
+
if (bundle.length === 0) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
await this._pushBundle(ctx, peerId, bundle).catch((err) => {
|
|
877
|
+
log.warn('failed to push bundle, replicating interactively', { peerId, bundle, err });
|
|
878
|
+
syncInteractively.push(...bundle);
|
|
879
|
+
});
|
|
880
|
+
}),
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return { syncInteractively };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private async _pushBundle(ctx: Context, peerId: PeerId, documentIds: DocumentId[]): Promise<void> {
|
|
888
|
+
if (this._ctx.disposed) {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const docs = documentIds.map((documentId) => {
|
|
893
|
+
if (getHandleState(this._repo, documentId) !== 'ready') {
|
|
894
|
+
log.warn('document not ready, skipping', { documentId });
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const handle = this._repo.handles[documentId];
|
|
898
|
+
const doc = handle.doc();
|
|
899
|
+
if (!doc) {
|
|
900
|
+
log.warn('document not available, skipping', { documentId });
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
documentId,
|
|
905
|
+
data: save(doc),
|
|
906
|
+
heads: getHeads(doc),
|
|
907
|
+
};
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
await this._echoNetworkAdapter.pushBundle(ctx, peerId, docs.filter(isNonNullable));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private async _pullInBundles(
|
|
914
|
+
ctx: Context,
|
|
915
|
+
peerId: PeerId,
|
|
916
|
+
documentIds: DocumentId[],
|
|
917
|
+
): Promise<{ syncInteractively: DocumentId[] }> {
|
|
918
|
+
const documentsToPull = [...documentIds];
|
|
919
|
+
const syncInteractively: DocumentId[] = [];
|
|
920
|
+
const docsToImport: Record<DocumentId, Uint8Array> = {};
|
|
921
|
+
|
|
922
|
+
// Pull bundles in parallel with BUNDLE_SYNC_CONCURRENCY max concurrent tasks.
|
|
923
|
+
while (documentsToPull.length > 0) {
|
|
924
|
+
await Promise.all(
|
|
925
|
+
range(BUNDLE_SYNC_CONCURRENCY).map(async () => {
|
|
926
|
+
const bundle = documentsToPull.splice(0, BUNDLE_SIZE);
|
|
927
|
+
if (bundle.length === 0) {
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const result = await this._pullBundle(ctx, peerId, bundle).catch((err) => {
|
|
931
|
+
log.warn('failed to pull bundle, replicating interactively', { peerId, bundle, err });
|
|
932
|
+
syncInteractively.push(...bundle);
|
|
933
|
+
});
|
|
934
|
+
if (result) {
|
|
935
|
+
Object.assign(docsToImport, result.docsToImport);
|
|
936
|
+
}
|
|
937
|
+
}),
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
for (const [documentId, data] of Object.entries(docsToImport)) {
|
|
942
|
+
this._repo.import(data, { docId: documentId as DocumentId });
|
|
943
|
+
}
|
|
944
|
+
await this._repo.flush(Object.keys(docsToImport) as DocumentId[]);
|
|
945
|
+
|
|
946
|
+
return { syncInteractively };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
private async _pullBundle(
|
|
950
|
+
ctx: Context,
|
|
951
|
+
peerId: PeerId,
|
|
952
|
+
documentIds: DocumentId[],
|
|
953
|
+
): Promise<{ docsToImport: Record<DocumentId, Uint8Array> } | undefined> {
|
|
954
|
+
if (this._ctx.disposed) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
// NOTE: We are expecting that documents that are being pulled are not present locally, so we are pulling all changes.
|
|
958
|
+
const docHeads = Object.fromEntries(documentIds.map((documentId) => [documentId, []]));
|
|
959
|
+
const bundle = await this._echoNetworkAdapter.pullBundle(ctx, peerId, docHeads);
|
|
960
|
+
return { docsToImport: bundle };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private _onHeadsChanged(docHeads: [DocumentId, Heads][]): void {
|
|
577
964
|
const collectionsChanged = new Set<CollectionId>();
|
|
965
|
+
|
|
578
966
|
for (const collectionId of this._collectionSynchronizer.getRegisteredCollectionIds()) {
|
|
579
967
|
const state = this._collectionSynchronizer.getLocalCollectionState(collectionId);
|
|
580
|
-
if (state
|
|
581
|
-
|
|
582
|
-
|
|
968
|
+
if (!state) {
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
let newState: CollectionState | undefined;
|
|
972
|
+
|
|
973
|
+
for (const [documentId, heads] of docHeads) {
|
|
974
|
+
if (documentId in state.documents) {
|
|
975
|
+
if (!newState) {
|
|
976
|
+
newState = structuredClone(state);
|
|
977
|
+
}
|
|
978
|
+
newState.documents[documentId] = heads;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (newState) {
|
|
583
983
|
this._collectionSynchronizer.setLocalCollectionState(collectionId, newState);
|
|
584
984
|
collectionsChanged.add(collectionId as CollectionId);
|
|
585
985
|
}
|
|
586
986
|
}
|
|
987
|
+
|
|
587
988
|
for (const collectionId of collectionsChanged) {
|
|
588
989
|
this.collectionStateUpdated.emit({ collectionId });
|
|
589
990
|
}
|
|
@@ -593,15 +994,29 @@ export class AutomergeHost extends Resource {
|
|
|
593
994
|
const waitForHeads = async (handle: DocHandle<DatabaseDirectory>, heads: Heads) => {
|
|
594
995
|
const unavailableHeads = new Set(heads);
|
|
595
996
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
997
|
+
// Check the current doc first, then subscribe to `change` to catch later
|
|
998
|
+
// updates. (We can't use `handle.whenReady()` to gate the subscription —
|
|
999
|
+
// see {@link getHandleState} for why `DocHandle.*` state is unusable in
|
|
1000
|
+
// this fork.)
|
|
1001
|
+
const checkPresentHeads = () => {
|
|
1002
|
+
const doc = handle.doc();
|
|
1003
|
+
if (!doc) {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
599
1006
|
for (const changeHash of unavailableHeads.values()) {
|
|
600
|
-
if (changeIsPresentInDoc(
|
|
1007
|
+
if (changeIsPresentInDoc(doc, changeHash)) {
|
|
601
1008
|
unavailableHeads.delete(changeHash);
|
|
602
1009
|
}
|
|
603
1010
|
}
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
checkPresentHeads();
|
|
1014
|
+
if (unavailableHeads.size === 0) {
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
604
1017
|
|
|
1018
|
+
await Event.wrap<DocHandleChangePayload<DatabaseDirectory>>(handle, 'change').waitForCondition(() => {
|
|
1019
|
+
checkPresentHeads();
|
|
605
1020
|
return unavailableHeads.size === 0;
|
|
606
1021
|
});
|
|
607
1022
|
};
|
|
@@ -619,35 +1034,3 @@ const decodeCollectionState = (state: unknown): CollectionState => {
|
|
|
619
1034
|
const encodeCollectionState = (state: CollectionState): unknown => {
|
|
620
1035
|
return state;
|
|
621
1036
|
};
|
|
622
|
-
|
|
623
|
-
export type CollectionSyncState = {
|
|
624
|
-
peers: PeerSyncState[];
|
|
625
|
-
};
|
|
626
|
-
|
|
627
|
-
export type PeerSyncState = {
|
|
628
|
-
peerId: PeerId;
|
|
629
|
-
/**
|
|
630
|
-
* Documents that are present locally but not on the remote peer.
|
|
631
|
-
*/
|
|
632
|
-
missingOnRemote: number;
|
|
633
|
-
|
|
634
|
-
/**
|
|
635
|
-
* Documents that are present on the remote peer but not locally.
|
|
636
|
-
*/
|
|
637
|
-
missingOnLocal: number;
|
|
638
|
-
|
|
639
|
-
/**
|
|
640
|
-
* Documents that are present on both peers but have different heads.
|
|
641
|
-
*/
|
|
642
|
-
differentDocuments: number;
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* Total number of documents locally.
|
|
646
|
-
*/
|
|
647
|
-
localDocumentCount: number;
|
|
648
|
-
|
|
649
|
-
/**
|
|
650
|
-
* Total number of documents on the remote peer.
|
|
651
|
-
*/
|
|
652
|
-
remoteDocumentCount: number;
|
|
653
|
-
};
|