@dxos/echo-pipeline 0.8.3 → 0.8.4-main.05e74ebcff
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/LICENSE +102 -5
- package/README.md +1 -1
- package/dist/lib/neutral/chunk-46DCRDA7.mjs +271 -0
- package/dist/lib/neutral/chunk-46DCRDA7.mjs.map +7 -0
- package/dist/lib/{browser/chunk-35I6ERLG.mjs → neutral/chunk-LR7UFVQX.mjs} +313 -709
- package/dist/lib/neutral/chunk-LR7UFVQX.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/filter/index.mjs +3 -2
- package/dist/lib/neutral/index.mjs +7332 -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 +119 -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 +46 -15
- package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
- package/dist/types/src/automerge/errors.d.ts +35 -0
- package/dist/types/src/automerge/errors.d.ts.map +1 -0
- 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 +3 -1
- package/dist/types/src/automerge/index.d.ts.map +1 -1
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +11 -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 +13 -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 +30 -8
- package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
- package/dist/types/src/db-host/echo-host.d.ts +74 -28
- 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 +59 -55
- package/src/automerge/automerge-host-subduction.test.ts +263 -0
- package/src/automerge/automerge-host.test.ts +162 -39
- package/src/automerge/automerge-host.ts +679 -201
- package/src/automerge/automerge-repo-subduction.test.ts +1054 -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 +98 -17
- package/src/automerge/collection-synchronizer.ts +141 -37
- 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 +76 -31
- package/src/automerge/echo-replicator.ts +50 -15
- package/src/automerge/errors.ts +10 -0
- package/src/automerge/handle-state.ts +62 -0
- package/src/automerge/heads-store.ts +42 -3
- package/src/automerge/index.ts +3 -1
- package/src/automerge/leveldb-storage-adapter.ts +77 -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 +35 -26
- package/src/db-host/database-root.ts +20 -8
- package/src/db-host/documents-synchronizer.test.ts +145 -14
- package/src/db-host/documents-synchronizer.ts +163 -51
- package/src/db-host/echo-host.ts +384 -99
- 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 +230 -21
- 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 +84 -9
- package/src/query/query-executor.ts +1197 -165
- package/src/query/query-planner.test.ts +993 -81
- package/src/query/query-planner.ts +503 -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,34 @@ 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
|
+
* Controls whether `loadDoc` is allowed to wait on the network.
|
|
84
|
+
*
|
|
85
|
+
* - `true` / unset (default): announce that we want the doc and wait
|
|
86
|
+
* until any source (storage OR network) delivers it.
|
|
87
|
+
* - `false`: probe local storage only. If chunks for the doc exist
|
|
88
|
+
* on disk, wait for storage to populate the handle; otherwise
|
|
89
|
+
* throw `'unavailable'` immediately without ever consulting the
|
|
90
|
+
* network. Note that this does not guarantee that the document
|
|
91
|
+
* will not be fetched from the network — an inbound peer announce
|
|
92
|
+
* can still deliver bytes — but the host will never request it.
|
|
93
|
+
*/
|
|
94
|
+
fetchFromNetwork?: boolean;
|
|
68
95
|
};
|
|
69
96
|
|
|
70
97
|
export type CreateDocOptions = {
|
|
@@ -72,19 +99,42 @@ export type CreateDocOptions = {
|
|
|
72
99
|
* Import the document together with its history.
|
|
73
100
|
*/
|
|
74
101
|
preserveHistory?: boolean;
|
|
75
|
-
};
|
|
76
102
|
|
|
77
|
-
|
|
78
|
-
allowableStates: ['ready', 'requesting'] satisfies HandleState[],
|
|
103
|
+
documentId?: DocumentId;
|
|
79
104
|
};
|
|
80
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Maximum amount of documents to sync in a single bundle.
|
|
108
|
+
*/
|
|
109
|
+
const BUNDLE_SIZE = 100;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Maximum amount of concurrent tasks to run when pushing or pulling bundles.
|
|
113
|
+
*/
|
|
114
|
+
const BUNDLE_SYNC_CONCURRENCY = 2;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* If the number of documents to sync is greater than this threshold, we will use bundles.
|
|
118
|
+
*/
|
|
119
|
+
const BUNDLE_SYNC_THRESHOLD = 50;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Only announce documents that are known to require sync.
|
|
123
|
+
*/
|
|
124
|
+
const OPTIMIZED_SHARE_POLICY = true;
|
|
125
|
+
|
|
81
126
|
/**
|
|
82
127
|
* Abstracts over the AutomergeRepo.
|
|
128
|
+
*
|
|
129
|
+
* Runs Subduction as the document byte transport ({@link Repo.subductionAdapters}), while
|
|
130
|
+
* the DXOS-specific {@link CollectionSynchronizer} rides on the same {@link EchoNetworkAdapter}
|
|
131
|
+
* via `sync-request` / `sync-state` control messages that are intercepted at the adapter
|
|
132
|
+
* level and never reach the Subduction sedimentree layer. Bundle sync remains available
|
|
133
|
+
* for replicators that opt in.
|
|
83
134
|
*/
|
|
84
|
-
@trace.resource()
|
|
135
|
+
@trace.resource({ lifecycle: true })
|
|
85
136
|
export class AutomergeHost extends Resource {
|
|
86
137
|
private readonly _db: LevelDB;
|
|
87
|
-
private readonly _indexMetadataStore: IndexMetadataStore;
|
|
88
138
|
private readonly _echoNetworkAdapter: EchoNetworkAdapter;
|
|
89
139
|
|
|
90
140
|
private readonly _collectionSynchronizer = new CollectionSynchronizer({
|
|
@@ -97,6 +147,14 @@ export class AutomergeHost extends Resource {
|
|
|
97
147
|
private _storage!: StorageAdapterInterface & Lifecycle;
|
|
98
148
|
private readonly _headsStore: HeadsStore;
|
|
99
149
|
|
|
150
|
+
private _syncTask: DeferredTask | undefined = undefined;
|
|
151
|
+
/**
|
|
152
|
+
* Cache of collections that would be synced on next sync task run.
|
|
153
|
+
*/
|
|
154
|
+
private readonly _collectionsToSync = new ComplexSet<{ collectionId: string; peerId: PeerId }>(
|
|
155
|
+
({ collectionId, peerId }) => `${collectionId}|${peerId}`,
|
|
156
|
+
);
|
|
157
|
+
|
|
100
158
|
@trace.info()
|
|
101
159
|
private _peerId!: PeerId;
|
|
102
160
|
|
|
@@ -110,15 +168,44 @@ export class AutomergeHost extends Resource {
|
|
|
110
168
|
*/
|
|
111
169
|
public readonly documentsSaved = new Event();
|
|
112
170
|
|
|
171
|
+
private readonly _headsUpdates = new Map<DocumentId, Heads>();
|
|
172
|
+
private _onHeadsChangedTask?: DeferredTask;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Documents created in this session.
|
|
176
|
+
*/
|
|
177
|
+
private _createdDocuments = new Set<DocumentId>();
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Documents that need to be synced based on the result of collection-sync.
|
|
181
|
+
*/
|
|
182
|
+
private _documentsToSync = new Set<DocumentId>();
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Documents that are not available locally that should be requested.
|
|
186
|
+
*/
|
|
187
|
+
private _documentsToRequest = new Set<DocumentId>();
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Documents requested by remote peers.
|
|
191
|
+
*/
|
|
192
|
+
private _documentsRequested = new Map<PeerId, Set<DocumentId>>();
|
|
193
|
+
|
|
194
|
+
private _sharePolicyChangedTask?: DeferredTask;
|
|
195
|
+
|
|
196
|
+
private _signer: MemorySigner | undefined = undefined;
|
|
197
|
+
private readonly _useSubduction: boolean;
|
|
198
|
+
|
|
113
199
|
constructor({
|
|
114
200
|
db,
|
|
115
|
-
indexMetadataStore,
|
|
116
201
|
dataMonitor,
|
|
117
202
|
peerIdProvider,
|
|
118
203
|
getSpaceKeyByRootDocumentId,
|
|
119
|
-
|
|
204
|
+
useSubduction = false,
|
|
205
|
+
}: AutomergeHostProps) {
|
|
120
206
|
super();
|
|
121
207
|
this._db = db;
|
|
208
|
+
this._useSubduction = useSubduction;
|
|
122
209
|
this._storage = new LevelDBStorageAdapter({
|
|
123
210
|
db: db.sublevel('automerge'),
|
|
124
211
|
callbacks: {
|
|
@@ -134,27 +221,71 @@ export class AutomergeHost extends Resource {
|
|
|
134
221
|
onCollectionStateReceived: this._onCollectionStateReceived.bind(this),
|
|
135
222
|
monitor: dataMonitor,
|
|
136
223
|
});
|
|
224
|
+
this._echoNetworkAdapter.documentRequested.on(({ peerId, documentId }) => {
|
|
225
|
+
defaultMap(this._documentsRequested, peerId, () => new Set()).add(documentId);
|
|
226
|
+
if (!this._useSubduction) {
|
|
227
|
+
this._sharePolicyChangedTask!.schedule();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
137
230
|
this._headsStore = new HeadsStore({ db: db.sublevel('heads') });
|
|
138
|
-
this._indexMetadataStore = indexMetadataStore;
|
|
139
231
|
this._peerIdProvider = peerIdProvider;
|
|
140
232
|
this._getSpaceKeyByRootDocumentId = getSpaceKeyByRootDocumentId;
|
|
141
233
|
}
|
|
142
234
|
|
|
143
|
-
protected override async _open(): Promise<void> {
|
|
235
|
+
protected override async _open(ctx: Context): Promise<void> {
|
|
144
236
|
this._peerId = `host-${this._peerIdProvider?.() ?? PublicKey.random().toHex()}` as PeerId;
|
|
145
237
|
|
|
238
|
+
this._onHeadsChangedTask = new DeferredTask(this._ctx, async () => {
|
|
239
|
+
const docHeads = Array.from(this._headsUpdates.entries());
|
|
240
|
+
this._headsUpdates.clear();
|
|
241
|
+
this._onHeadsChanged(docHeads);
|
|
242
|
+
});
|
|
243
|
+
|
|
146
244
|
await this._storage.open?.();
|
|
147
245
|
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
246
|
+
// `Repo` unconditionally constructs a Subduction `SubductionSource` (with a fresh
|
|
247
|
+
// `MemorySigner` when none is injected) regardless of whether we register any
|
|
248
|
+
// `subductionAdapters`. That source's signer needs the Subduction WASM module
|
|
249
|
+
// initialized first — `Repo` imports from `@automerge/automerge-subduction/slim`,
|
|
250
|
+
// which does not auto-init. So WASM init runs in both modes.
|
|
251
|
+
await initSubduction();
|
|
252
|
+
|
|
253
|
+
if (this._useSubduction) {
|
|
254
|
+
const { MemorySigner } = await import('@automerge/automerge-subduction');
|
|
255
|
+
this._signer ??= MemorySigner.generate();
|
|
256
|
+
|
|
257
|
+
this._repo = new Repo({
|
|
258
|
+
peerId: this._peerId as PeerId,
|
|
259
|
+
shareConfig: this._shareConfig,
|
|
260
|
+
subductionPolicy: this._subductionPolicy,
|
|
261
|
+
storage: this._storage,
|
|
262
|
+
network: [],
|
|
263
|
+
signer: this._signer,
|
|
264
|
+
subductionAdapters: [
|
|
265
|
+
{
|
|
266
|
+
adapter: this._echoNetworkAdapter,
|
|
267
|
+
serviceName: SUBDUCTION_SERVICE_NAME,
|
|
268
|
+
// DXOS hosts are always clients — the edge DO is the single `accept` peer in
|
|
269
|
+
// the DXOS-client <-> edge topology. `connect` uses Subduction's discovery mode,
|
|
270
|
+
// so peer-to-peer connections (e.g., mesh replicator, test networks) also work
|
|
271
|
+
// with `connect` on both sides.
|
|
272
|
+
role: 'connect',
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
});
|
|
276
|
+
} else {
|
|
277
|
+
// Classical automerge-repo wiring: the EchoNetworkAdapter is registered as a
|
|
278
|
+
// network adapter and document bytes flow through the standard sync protocol.
|
|
279
|
+
// `Repo` will internally construct an unused `MemorySigner` for its always-on
|
|
280
|
+
// `SubductionSource`; with no `subductionAdapters` passed in that source has no
|
|
281
|
+
// peers to talk to, so it's effectively dormant.
|
|
282
|
+
this._repo = new Repo({
|
|
283
|
+
peerId: this._peerId as PeerId,
|
|
284
|
+
shareConfig: this._shareConfig,
|
|
285
|
+
storage: this._storage,
|
|
286
|
+
network: [this._echoNetworkAdapter],
|
|
287
|
+
});
|
|
288
|
+
}
|
|
158
289
|
|
|
159
290
|
let updatingAuthScope = false;
|
|
160
291
|
Event.wrap(this._echoNetworkAdapter, 'peer-candidate').on(
|
|
@@ -169,8 +300,7 @@ export class AutomergeHost extends Resource {
|
|
|
169
300
|
this._collectionSynchronizer.remoteStateUpdated.on(this._ctx, ({ collectionId, peerId, newDocsAppeared }) => {
|
|
170
301
|
this._onRemoteCollectionStateUpdated(collectionId, peerId);
|
|
171
302
|
this.collectionStateUpdated.emit({ collectionId: collectionId as CollectionId });
|
|
172
|
-
|
|
173
|
-
if (newDocsAppeared) {
|
|
303
|
+
if (!this._useSubduction && newDocsAppeared) {
|
|
174
304
|
updatingAuthScope = true;
|
|
175
305
|
try {
|
|
176
306
|
this._echoNetworkAdapter.onConnectionAuthScopeChanged(peerId);
|
|
@@ -180,24 +310,50 @@ export class AutomergeHost extends Resource {
|
|
|
180
310
|
}
|
|
181
311
|
});
|
|
182
312
|
|
|
313
|
+
this._syncTask = new DeferredTask(this._ctx, async () => {
|
|
314
|
+
const collectionToSync = Array.from(this._collectionsToSync.values());
|
|
315
|
+
this._collectionsToSync.clear();
|
|
316
|
+
if (collectionToSync.length === 0) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
await Promise.all(
|
|
320
|
+
collectionToSync.map(async ({ collectionId, peerId }) => {
|
|
321
|
+
try {
|
|
322
|
+
await this._handleCollectionSync(this._ctx, collectionId, peerId);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
log.error('failed to sync collection', { collectionId, peerId, err });
|
|
325
|
+
}
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
this._sharePolicyChangedTask = new DeferredTask(this._ctx, async () => {
|
|
331
|
+
log('share policy changed');
|
|
332
|
+
this._repo.shareConfigChanged();
|
|
333
|
+
});
|
|
334
|
+
|
|
183
335
|
await this._echoNetworkAdapter.open();
|
|
184
|
-
await this._collectionSynchronizer.open();
|
|
185
|
-
await this._echoNetworkAdapter.open();
|
|
336
|
+
await this._collectionSynchronizer.open(ctx);
|
|
186
337
|
await this._echoNetworkAdapter.whenConnected();
|
|
187
338
|
}
|
|
188
339
|
|
|
189
|
-
protected override async _close(): Promise<void> {
|
|
190
|
-
|
|
340
|
+
protected override async _close(ctx: Context): Promise<void> {
|
|
341
|
+
// Drain any in-flight `_onHeadsChangedTask` before the `Resource` base
|
|
342
|
+
// disposes `this._ctx`. Without this, a concurrent `flush()` call (e.g.
|
|
343
|
+
// from a client `db.flush()` RPC racing with shutdown) hits its
|
|
344
|
+
// `runBlocking` after the ctx has been disposed and throws
|
|
345
|
+
// `ContextDisposedError`, which escapes as an unhandled rejection at the
|
|
346
|
+
// fire-and-forget originating caller.
|
|
347
|
+
await this._onHeadsChangedTask?.join();
|
|
348
|
+
|
|
349
|
+
await this._collectionSynchronizer.close(ctx);
|
|
350
|
+
await this._repo.flush().catch((err) => log.warn('failed to flush repo before shutdown', { err }));
|
|
351
|
+
await this._repo.shutdown().catch((err) => log.warn('failed to shutdown repo', { err }));
|
|
191
352
|
await this._storage.close?.();
|
|
192
353
|
await this._echoNetworkAdapter.close();
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* @deprecated To be abstracted away.
|
|
198
|
-
*/
|
|
199
|
-
get repo(): Repo {
|
|
200
|
-
return this._repo;
|
|
354
|
+
this._syncTask = undefined;
|
|
355
|
+
this._onHeadsChangedTask = undefined;
|
|
356
|
+
this._sharePolicyChangedTask = undefined;
|
|
201
357
|
}
|
|
202
358
|
|
|
203
359
|
get peerId(): PeerId {
|
|
@@ -208,53 +364,123 @@ export class AutomergeHost extends Resource {
|
|
|
208
364
|
return Object.keys(this._repo.handles).length;
|
|
209
365
|
}
|
|
210
366
|
|
|
211
|
-
|
|
212
|
-
|
|
367
|
+
get handles(): Readonly<Record<DocumentId, DocHandle<any>>> {
|
|
368
|
+
return this._repo.handles;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async addReplicator(ctx: Context, replicator: AutomergeReplicator): Promise<void> {
|
|
372
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
373
|
+
await this._echoNetworkAdapter.addReplicator(ctx, replicator);
|
|
213
374
|
}
|
|
214
375
|
|
|
215
|
-
async removeReplicator(replicator:
|
|
376
|
+
async removeReplicator(replicator: AutomergeReplicator): Promise<void> {
|
|
377
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
216
378
|
await this._echoNetworkAdapter.removeReplicator(replicator);
|
|
217
379
|
}
|
|
218
380
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
381
|
+
async loadDoc<T>(ctx: Context, documentId: AnyDocumentId, opts?: LoadDocOptions): Promise<DocHandle<T> | null> {
|
|
382
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
383
|
+
// Readiness lives on the `DocumentQuery`, not the `DocHandle` — see
|
|
384
|
+
// {@link getHandleState}.
|
|
385
|
+
const progress = this._repo.findWithProgress<T>(documentId as DocumentId);
|
|
386
|
+
const initial = progress.peek();
|
|
387
|
+
if (initial.state === 'ready') {
|
|
388
|
+
return initial.handle;
|
|
227
389
|
}
|
|
228
|
-
|
|
229
|
-
|
|
390
|
+
|
|
391
|
+
// Default: when `fetchFromNetwork` is unset, behave as if it were
|
|
392
|
+
// `true` — wait on any source. Only an explicit `false` activates
|
|
393
|
+
// the storage-only branch.
|
|
394
|
+
if (opts?.fetchFromNetwork !== false) {
|
|
395
|
+
// Network branch: announce that we want the doc, then fall through
|
|
396
|
+
// to `whenReady()` and wait for any source (storage or network) to
|
|
397
|
+
// deliver it.
|
|
398
|
+
if (!this._useSubduction) {
|
|
399
|
+
this._documentsToRequest.add(progress.documentId);
|
|
400
|
+
this._sharePolicyChangedTask!.schedule();
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
// Note: This is a Hack.
|
|
404
|
+
// Storage-only branch. The subduction fork's `DocumentQuery` merged
|
|
405
|
+
// storage/network into a single `'loading'` state, so we can't tell
|
|
406
|
+
// them apart via `progress.peek()`. Workaround: probe storage
|
|
407
|
+
// directly and throw `'unavailable'` if nothing is on disk, without
|
|
408
|
+
// ever scheduling a network announce. See `fetchFromNetwork` JSDoc
|
|
409
|
+
// for the residual inbound-announce race.
|
|
410
|
+
// TODO(mykola): replace with per-source state inspection once the
|
|
411
|
+
// patched fork exposes it on `DocumentProgress`.
|
|
412
|
+
const chunks = await this._storage.loadRange([progress.documentId]);
|
|
413
|
+
const onDisk = chunks.some((chunk) => chunk.data && chunk.data.length > 0);
|
|
414
|
+
if (!onDisk) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
230
417
|
}
|
|
231
418
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
await cancelWithContext(ctx,
|
|
236
|
-
|
|
237
|
-
|
|
419
|
+
const readyPromise = progress.whenReady();
|
|
420
|
+
try {
|
|
421
|
+
return opts?.timeout
|
|
422
|
+
? await cancelWithContext(ctx, asyncTimeout(readyPromise, opts.timeout))
|
|
423
|
+
: await cancelWithContext(ctx, readyPromise);
|
|
424
|
+
} catch (error: any) {
|
|
425
|
+
if (isDocumentUnavailableError(error) && opts?.fetchFromNetwork === false) {
|
|
426
|
+
return null;
|
|
238
427
|
}
|
|
428
|
+
throw error;
|
|
239
429
|
}
|
|
430
|
+
}
|
|
240
431
|
|
|
241
|
-
|
|
432
|
+
/**
|
|
433
|
+
* Returns the live {@link DocumentQuery} for a document — exposes the
|
|
434
|
+
* always-attached `DocHandle` and the actual readiness state
|
|
435
|
+
* (`'loading' | 'ready' | 'unavailable' | 'failed'`) via `peek()` /
|
|
436
|
+
* `subscribe()` / `whenReady()`. Use this to observe sync state without
|
|
437
|
+
* forcing a wait. See {@link getHandleState} for why callers must read
|
|
438
|
+
* liveness off the query rather than the `DocHandle` itself.
|
|
439
|
+
*/
|
|
440
|
+
findWithProgress<T>(documentId: AnyDocumentId): DocumentQuery<T> {
|
|
441
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
442
|
+
return this._repo.findWithProgress<T>(documentId as DocumentId) as DocumentQuery<T>;
|
|
242
443
|
}
|
|
243
444
|
|
|
244
|
-
async exportDoc(
|
|
445
|
+
async exportDoc(id: AnyDocumentId): Promise<Uint8Array> {
|
|
446
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
245
447
|
const documentId = interpretAsDocumentId(id);
|
|
246
448
|
|
|
247
449
|
const chunks = await this._storage.loadRange([documentId]);
|
|
248
450
|
return bufferToArray(Buffer.concat(chunks.map((c) => c.data!)));
|
|
249
451
|
}
|
|
250
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Probe local storage to determine whether the document has any persisted
|
|
455
|
+
* chunks. Does not request the document from the network and does not
|
|
456
|
+
* touch the in-memory `Repo`. Returns `true` iff at least one non-empty
|
|
457
|
+
* chunk exists on disk for the document.
|
|
458
|
+
*
|
|
459
|
+
* Intended for query-driven (disk-only) loads that need to know quickly
|
|
460
|
+
* whether a document is locally available without waiting on network
|
|
461
|
+
* latency. See `DocumentsSynchronizer.addDocuments` and the `requesting`
|
|
462
|
+
* transition in `DocHandleProxy`.
|
|
463
|
+
*/
|
|
464
|
+
async hasDocOnDisk(id: AnyDocumentId): Promise<boolean> {
|
|
465
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
466
|
+
const documentId = interpretAsDocumentId(id);
|
|
467
|
+
const chunks = await this._storage.loadRange([documentId]);
|
|
468
|
+
return chunks.some((chunk) => chunk.data != null && chunk.data.length > 0);
|
|
469
|
+
}
|
|
470
|
+
|
|
251
471
|
/**
|
|
252
472
|
* Create new persisted document.
|
|
253
473
|
*/
|
|
254
|
-
createDoc<T>(initialValue?: T | Doc<T> | Uint8Array, opts?: CreateDocOptions): DocHandle<T
|
|
474
|
+
async createDoc<T>(initialValue?: T | Doc<T> | Uint8Array, opts?: CreateDocOptions): Promise<DocHandle<T>> {
|
|
475
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
255
476
|
if (opts?.preserveHistory) {
|
|
256
477
|
if (initialValue instanceof Uint8Array) {
|
|
257
|
-
|
|
478
|
+
const handle = this._repo.import<T>(initialValue, { docId: opts?.documentId });
|
|
479
|
+
this._createdDocuments.add(handle.documentId);
|
|
480
|
+
if (!this._useSubduction) {
|
|
481
|
+
this._sharePolicyChangedTask!.schedule();
|
|
482
|
+
}
|
|
483
|
+
return handle;
|
|
258
484
|
}
|
|
259
485
|
|
|
260
486
|
if (!isAutomerge(initialValue)) {
|
|
@@ -262,17 +488,31 @@ export class AutomergeHost extends Resource {
|
|
|
262
488
|
}
|
|
263
489
|
|
|
264
490
|
// TODO(dmaretskyi): There's a more efficient way.
|
|
265
|
-
|
|
491
|
+
const handle = this._repo.import<T>(save(initialValue as Doc<T>), { docId: opts?.documentId });
|
|
492
|
+
this._createdDocuments.add(handle.documentId);
|
|
493
|
+
if (!this._useSubduction) {
|
|
494
|
+
this._sharePolicyChangedTask!.schedule();
|
|
495
|
+
}
|
|
496
|
+
return handle;
|
|
266
497
|
} else {
|
|
267
498
|
if (initialValue instanceof Uint8Array) {
|
|
268
499
|
throw new Error('Cannot create document from Uint8Array without preserving history');
|
|
269
500
|
}
|
|
270
501
|
|
|
271
|
-
|
|
502
|
+
if (opts?.documentId) {
|
|
503
|
+
throw new Error('Cannot prefil document id when not importing an existing doc');
|
|
504
|
+
}
|
|
505
|
+
const handle = await this._repo.create2<T>(initialValue);
|
|
506
|
+
this._createdDocuments.add(handle.documentId);
|
|
507
|
+
if (!this._useSubduction) {
|
|
508
|
+
this._sharePolicyChangedTask!.schedule();
|
|
509
|
+
}
|
|
510
|
+
return handle;
|
|
272
511
|
}
|
|
273
512
|
}
|
|
274
513
|
|
|
275
|
-
async waitUntilHeadsReplicated(heads: DocHeadsList): Promise<void> {
|
|
514
|
+
async waitUntilHeadsReplicated(ctx: Context, heads: DocHeadsList): Promise<void> {
|
|
515
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
276
516
|
const entries = heads.entries;
|
|
277
517
|
if (!entries?.length) {
|
|
278
518
|
return;
|
|
@@ -285,33 +525,40 @@ export class AutomergeHost extends Resource {
|
|
|
285
525
|
return false;
|
|
286
526
|
}
|
|
287
527
|
const currentHeads = documentHeads[index];
|
|
288
|
-
return !(currentHeads !== null && headsEquals(currentHeads, targetHeads));
|
|
528
|
+
return !(currentHeads !== null && currentHeads !== undefined && headsEquals(currentHeads, targetHeads));
|
|
289
529
|
});
|
|
290
530
|
if (headsToWait.length > 0) {
|
|
291
531
|
await Promise.all(
|
|
292
|
-
headsToWait.map(async (entry
|
|
293
|
-
const handle = await this.loadDoc<DatabaseDirectory>(
|
|
532
|
+
headsToWait.map(async (entry) => {
|
|
533
|
+
const handle = await this.loadDoc<DatabaseDirectory>(ctx, entry.documentId as DocumentId);
|
|
534
|
+
invariant(handle, 'Document handle must be available when waiting for heads replication.');
|
|
294
535
|
await waitForHeads(handle, entry.heads!);
|
|
295
536
|
}),
|
|
296
537
|
);
|
|
297
538
|
}
|
|
298
539
|
|
|
299
540
|
// 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
|
-
);
|
|
541
|
+
await this._repo.flush(documentIds.filter((documentId) => getHandleState(this._repo, documentId) === 'ready'));
|
|
303
542
|
}
|
|
304
543
|
|
|
305
544
|
async reIndexHeads(documentIds: DocumentId[]): Promise<void> {
|
|
545
|
+
invariant(this.isOpen, 'AutomergeHost is not open');
|
|
306
546
|
for (const documentId of documentIds) {
|
|
307
547
|
log('re-indexing heads for document', { documentId });
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
548
|
+
// `Repo.find()` resolves on `'ready'` and rejects on `'unavailable'`,
|
|
549
|
+
// so the handle is guaranteed to hold data here.
|
|
550
|
+
let handle: DocHandle<any>;
|
|
551
|
+
try {
|
|
552
|
+
handle = await this._repo.find(documentId);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
log.error('failed to find document', { documentId, err });
|
|
555
|
+
continue;
|
|
312
556
|
}
|
|
313
557
|
|
|
314
558
|
const heads = handle.heads();
|
|
559
|
+
if (!heads) {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
315
562
|
const batch = this._db.batch();
|
|
316
563
|
this._headsStore.setHeads(documentId, heads, batch);
|
|
317
564
|
await batch.write();
|
|
@@ -319,30 +566,62 @@ export class AutomergeHost extends Resource {
|
|
|
319
566
|
log('done re-indexing heads');
|
|
320
567
|
}
|
|
321
568
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
569
|
+
/**
|
|
570
|
+
* Share policy for the Repo's classical automerge-repo sync path (CollectionSynchronizer /
|
|
571
|
+
* DocSynchronizer — see `src/synchronizer/DocSynchronizer.ts#resolveSharePolicy`).
|
|
572
|
+
*
|
|
573
|
+
* NOTE: Subduction replication does NOT consult `shareConfig`.
|
|
574
|
+
* {@link AutomergeReplicator}s via {@link EchoNetworkAdapter}.
|
|
575
|
+
*/
|
|
576
|
+
private readonly _shareConfig = {
|
|
577
|
+
access: async (_peerId: PeerId, _documentId?: DocumentId): Promise<boolean> => {
|
|
578
|
+
// Access-on-request is always allowed; per-doc authorization happens in the replicator.
|
|
579
|
+
return true;
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
// TODO(dmaretskyi): Share based on HALO permissions and space affinity.
|
|
583
|
+
// Hosts, running in the worker, don't share documents unless requested by other peers.
|
|
584
|
+
// NOTE: If both peers return sharePolicy=false the replication will not happen
|
|
585
|
+
// https://github.com/automerge/automerge-repo/pull/292
|
|
586
|
+
// Called for all loaded documents so they could be advertised to the sync server.
|
|
587
|
+
announce: async (peerId: PeerId, documentId?: DocumentId): Promise<boolean> => {
|
|
588
|
+
if (!documentId) {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
330
591
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
592
|
+
if (OPTIMIZED_SHARE_POLICY) {
|
|
593
|
+
if (
|
|
594
|
+
!this._createdDocuments.has(documentId) &&
|
|
595
|
+
!this._documentsToSync.has(documentId) &&
|
|
596
|
+
!this._documentsToRequest.has(documentId)
|
|
597
|
+
) {
|
|
598
|
+
// Skip advertising documents that don't need to be synced.
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
334
602
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
603
|
+
const peerMetadata = this._repo.peerMetadataByPeerId[peerId];
|
|
604
|
+
if (isEchoPeerMetadata(peerMetadata)) {
|
|
605
|
+
return this._echoNetworkAdapter.shouldAdvertise(peerId, { documentId });
|
|
606
|
+
}
|
|
339
607
|
|
|
340
|
-
|
|
341
|
-
|
|
608
|
+
return false;
|
|
609
|
+
},
|
|
610
|
+
};
|
|
342
611
|
|
|
343
|
-
|
|
612
|
+
/**
|
|
613
|
+
* Authorization policy consulted by the Subduction sedimentree protocol.
|
|
614
|
+
*/
|
|
615
|
+
private readonly _subductionPolicy: SubductionPolicy = {
|
|
616
|
+
authorizeConnect: async (_peerId) => {},
|
|
617
|
+
authorizeFetch: async (_peerId, _sedimentreeId) => {},
|
|
618
|
+
authorizePut: async (_requestor, _author, _sedimentreeId) => {},
|
|
619
|
+
filterAuthorizedFetch: async (_peerId, sedimentreeIds) => sedimentreeIds,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
private async _beforeSave({ path, batch }: BeforeSaveProps): Promise<void> {
|
|
344
623
|
const handle = this._repo.handles[path[0] as DocumentId];
|
|
345
|
-
if (!handle ||
|
|
624
|
+
if (!handle || getHandleState(this._repo, handle.documentId) !== 'ready') {
|
|
346
625
|
return;
|
|
347
626
|
}
|
|
348
627
|
const doc = handle.doc();
|
|
@@ -352,46 +631,41 @@ export class AutomergeHost extends Resource {
|
|
|
352
631
|
|
|
353
632
|
const heads = getHeads(doc);
|
|
354
633
|
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
634
|
}
|
|
364
635
|
|
|
365
636
|
private _shouldSyncCollection(collectionId: string, peerId: PeerId): boolean {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return false;
|
|
637
|
+
// Under Subduction the Repo's `peerMetadataByPeerId` is not populated for peers that only
|
|
638
|
+
// speak Subduction (no classical peer message). Query the adapter directly — it maps
|
|
639
|
+
// peerId -> connection and the per-connection `shouldSyncCollection` gates the answer.
|
|
640
|
+
return this._echoNetworkAdapter.shouldSyncCollection(peerId, { collectionId });
|
|
372
641
|
}
|
|
373
642
|
|
|
374
643
|
/**
|
|
375
644
|
* Called by AutomergeStorageAdapter after levelDB batch commit.
|
|
376
645
|
*/
|
|
377
646
|
private async _afterSave(path: StorageKey): Promise<void> {
|
|
378
|
-
this.
|
|
647
|
+
if (!this.isOpen) {
|
|
648
|
+
return undefined;
|
|
649
|
+
}
|
|
379
650
|
|
|
380
651
|
const documentId = path[0] as DocumentId;
|
|
381
|
-
const
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
652
|
+
const handle = this._repo.handles[documentId];
|
|
653
|
+
if (!handle) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const document = handle.doc();
|
|
657
|
+
if (!document) {
|
|
658
|
+
return;
|
|
385
659
|
}
|
|
386
|
-
this.documentsSaved.emit();
|
|
387
|
-
}
|
|
388
660
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
661
|
+
const heads = getHeads(document);
|
|
662
|
+
this._headsUpdates.set(documentId, heads);
|
|
663
|
+
invariant(this._onHeadsChangedTask, 'onHeadsChangedTask is not initialized');
|
|
664
|
+
this._onHeadsChangedTask.schedule();
|
|
665
|
+
this.documentsSaved.emit();
|
|
392
666
|
}
|
|
393
667
|
|
|
394
|
-
private async _isDocumentInRemoteCollection(params:
|
|
668
|
+
private async _isDocumentInRemoteCollection(params: RemoteDocumentExistenceCheckProps): Promise<boolean> {
|
|
395
669
|
for (const collectionId of this._collectionSynchronizer.getRegisteredCollectionIds()) {
|
|
396
670
|
const remoteCollections = this._collectionSynchronizer.getRemoteCollectionStates(collectionId);
|
|
397
671
|
const remotePeerDocs = remoteCollections.get(params.peerId as PeerId)?.documents;
|
|
@@ -403,21 +677,28 @@ export class AutomergeHost extends Resource {
|
|
|
403
677
|
}
|
|
404
678
|
|
|
405
679
|
private async _getContainingSpaceForDocument(documentId: string): Promise<PublicKey | null> {
|
|
680
|
+
// This runs inside share-policy resolution (see `MeshEchoReplicator.shouldAdvertise` ->
|
|
681
|
+
// `_shareConfig.announce` -> `DocSynchronizer.#resolveSharePolicy`). It must NOT block
|
|
682
|
+
// on the document becoming ready: under classical sync the network source's availability
|
|
683
|
+
// is itself gated on share policy returning, so awaiting `progress.whenReady()` here
|
|
684
|
+
// deadlocks the load (the document never transitions out of `'loading'`).
|
|
685
|
+
//
|
|
686
|
+
// Read the spaceKey iff the document is already loaded; otherwise let the share policy
|
|
687
|
+
// fall through to the `_getSpaceKeyByRootDocumentId` lookup or the
|
|
688
|
+
// `isDocumentInRemoteCollection` check on the caller.
|
|
406
689
|
const handle = this._repo.handles[documentId as any];
|
|
407
|
-
if (handle.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
690
|
+
if (handle && getHandleState(this._repo, documentId as DocumentId) === 'ready') {
|
|
691
|
+
const doc = handle.doc();
|
|
692
|
+
if (doc) {
|
|
693
|
+
const spaceKeyHex = DatabaseDirectory.getSpaceKey(doc);
|
|
694
|
+
if (spaceKeyHex) {
|
|
695
|
+
return PublicKey.from(spaceKeyHex);
|
|
696
|
+
}
|
|
414
697
|
}
|
|
415
698
|
}
|
|
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
|
-
*/
|
|
699
|
+
|
|
700
|
+
// Edge case on initial space setup: a peer may be sharing the space root document
|
|
701
|
+
// with us after a successful invitation, before our local handle has any data.
|
|
421
702
|
const rootDocSpaceKey = this._getSpaceKeyByRootDocumentId?.(documentId);
|
|
422
703
|
if (rootDocSpaceKey) {
|
|
423
704
|
return rootDocSpaceKey;
|
|
@@ -429,24 +710,43 @@ export class AutomergeHost extends Resource {
|
|
|
429
710
|
/**
|
|
430
711
|
* Flush documents to disk.
|
|
431
712
|
*/
|
|
432
|
-
@trace.span({ showInBrowserTimeline: true })
|
|
433
|
-
async flush({ documentIds }: FlushRequest = {}): Promise<void> {
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
713
|
+
@trace.span({ showInBrowserTimeline: true, showInRemoteTracing: false })
|
|
714
|
+
async flush(ctx: Context, { documentIds }: FlushRequest = {}): Promise<void> {
|
|
715
|
+
// Concurrent `db.flush()` RPCs can arrive while the host is in or past
|
|
716
|
+
// teardown. `runBlocking` below would throw `ContextDisposedError` on a
|
|
717
|
+
// disposed ctx; treat a closed host as a no-op flush instead (nothing to
|
|
718
|
+
// propagate, the resource is gone). The host can also transition from
|
|
719
|
+
// open to closed *during* the `_repo.flush()` await — re-check before
|
|
720
|
+
// reaching `runBlocking`.
|
|
721
|
+
if (!this.isOpen) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const loadedDocuments = (documentIds ?? Object.keys(this._repo.handles)).filter(
|
|
725
|
+
(documentId): documentId is DocumentId => getHandleState(this._repo, documentId as DocumentId) === 'ready',
|
|
726
|
+
);
|
|
440
727
|
await this._repo.flush(loadedDocuments);
|
|
728
|
+
if (!this.isOpen) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Ensure that document versions have propagated across the system.
|
|
733
|
+
await this._onHeadsChangedTask?.runBlocking();
|
|
441
734
|
}
|
|
442
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Returns current heads of each requested document.
|
|
738
|
+
*
|
|
739
|
+
* Loaded handles are read directly; unloaded documents fall back to the {@link HeadsStore},
|
|
740
|
+
* then to reconstruction from the automerge storage chunks (for docs persisted before
|
|
741
|
+
* the HeadsStore was populated).
|
|
742
|
+
*/
|
|
443
743
|
async getHeads(documentIds: DocumentId[]): Promise<(Heads | undefined)[]> {
|
|
444
744
|
const result: (Heads | undefined)[] = [];
|
|
445
745
|
const storeRequestIds: DocumentId[] = [];
|
|
446
746
|
const storeResultIndices: number[] = [];
|
|
447
747
|
for (const documentId of documentIds) {
|
|
448
748
|
const handle = this._repo.handles[documentId];
|
|
449
|
-
if (handle &&
|
|
749
|
+
if (handle && getHandleState(this._repo, documentId) === 'ready' && handle.doc()) {
|
|
450
750
|
result.push(getHeads(handle.doc()!));
|
|
451
751
|
} else {
|
|
452
752
|
storeRequestIds.push(documentId);
|
|
@@ -463,6 +763,13 @@ export class AutomergeHost extends Resource {
|
|
|
463
763
|
return result;
|
|
464
764
|
}
|
|
465
765
|
|
|
766
|
+
/**
|
|
767
|
+
* Iterate over all document heads stored on disk.
|
|
768
|
+
*/
|
|
769
|
+
listDocumentHeads(): AsyncGenerator<{ documentId: DocumentId; heads: Heads }> {
|
|
770
|
+
return this._headsStore.iterateAll();
|
|
771
|
+
}
|
|
772
|
+
|
|
466
773
|
//
|
|
467
774
|
// Collection sync.
|
|
468
775
|
//
|
|
@@ -479,8 +786,8 @@ export class AutomergeHost extends Resource {
|
|
|
479
786
|
this._collectionSynchronizer.refreshCollection(collectionId);
|
|
480
787
|
}
|
|
481
788
|
|
|
482
|
-
async getCollectionSyncState(collectionId: string): Promise<
|
|
483
|
-
const result:
|
|
789
|
+
async getCollectionSyncState(collectionId: string): Promise<SpaceSyncState> {
|
|
790
|
+
const result: SpaceSyncState = {
|
|
484
791
|
peers: [],
|
|
485
792
|
};
|
|
486
793
|
|
|
@@ -493,13 +800,15 @@ export class AutomergeHost extends Resource {
|
|
|
493
800
|
|
|
494
801
|
for (const [peerId, state] of remoteState) {
|
|
495
802
|
const diff = diffCollectionState(localState, state);
|
|
496
|
-
result.peers
|
|
803
|
+
result.peers!.push({
|
|
497
804
|
peerId,
|
|
498
805
|
missingOnRemote: diff.missingOnRemote.length,
|
|
499
806
|
missingOnLocal: diff.missingOnLocal.length,
|
|
500
807
|
differentDocuments: diff.different.length,
|
|
501
|
-
localDocumentCount: Object.
|
|
502
|
-
remoteDocumentCount: Object.
|
|
808
|
+
localDocumentCount: Object.entries(localState.documents).filter(([_, heads]) => heads.length > 0).length,
|
|
809
|
+
remoteDocumentCount: Object.entries(state.documents).filter(([_, heads]) => heads.length > 0).length,
|
|
810
|
+
totalDocumentCount: new Set([...Object.keys(localState.documents), ...Object.keys(state.documents)]).size,
|
|
811
|
+
unsyncedDocumentCount: new Set([...diff.missingOnLocal, ...diff.missingOnRemote, ...diff.different]).size,
|
|
503
812
|
});
|
|
504
813
|
}
|
|
505
814
|
|
|
@@ -515,6 +824,16 @@ export class AutomergeHost extends Resource {
|
|
|
515
824
|
heads.map((heads, index) => [documentIds[index], heads ?? []]),
|
|
516
825
|
);
|
|
517
826
|
this._collectionSynchronizer.setLocalCollectionState(collectionId, { documents });
|
|
827
|
+
|
|
828
|
+
// Proactively push our updated local state to peers that are interested in this collection.
|
|
829
|
+
// This reduces reliance on the next periodic query and prevents replication stalls in fast
|
|
830
|
+
// paths where the remote queries before our local state is ready.
|
|
831
|
+
const interestedPeers = this._echoNetworkAdapter.getPeersInterestedInCollection(collectionId);
|
|
832
|
+
if (interestedPeers.length > 0) {
|
|
833
|
+
for (const peerId of interestedPeers) {
|
|
834
|
+
this._sendCollectionState(collectionId, peerId, { documents });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
518
837
|
}
|
|
519
838
|
|
|
520
839
|
async clearLocalCollectionState(collectionId: string): Promise<void> {
|
|
@@ -546,6 +865,11 @@ export class AutomergeHost extends Resource {
|
|
|
546
865
|
}
|
|
547
866
|
|
|
548
867
|
private _onRemoteCollectionStateUpdated(collectionId: string, peerId: PeerId): void {
|
|
868
|
+
this._collectionsToSync.add({ collectionId, peerId });
|
|
869
|
+
this._syncTask?.schedule();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private async _handleCollectionSync(ctx: Context, collectionId: string, peerId: PeerId) {
|
|
549
873
|
const localState = this._collectionSynchronizer.getLocalCollectionState(collectionId);
|
|
550
874
|
const remoteState = this._collectionSynchronizer.getRemoteCollectionStates(collectionId).get(peerId);
|
|
551
875
|
|
|
@@ -554,36 +878,204 @@ export class AutomergeHost extends Resource {
|
|
|
554
878
|
}
|
|
555
879
|
|
|
556
880
|
const { different, missingOnLocal, missingOnRemote } = diffCollectionState(localState, remoteState);
|
|
557
|
-
const toReplicate = [...missingOnLocal, ...missingOnRemote, ...different];
|
|
558
881
|
|
|
559
|
-
if (
|
|
882
|
+
if (different.length === 0 && missingOnLocal.length === 0 && missingOnRemote.length === 0) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const toReplicateWithoutBatching = [...different];
|
|
887
|
+
const bundleSyncEnabled = this._echoNetworkAdapter.bundleSyncEnabledForPeer(peerId);
|
|
888
|
+
if (bundleSyncEnabled && missingOnRemote.length >= BUNDLE_SYNC_THRESHOLD) {
|
|
889
|
+
log('pushing bundle', { amount: missingOnRemote.length });
|
|
890
|
+
const { syncInteractively } = await this._pushInBundles(ctx, peerId, missingOnRemote);
|
|
891
|
+
toReplicateWithoutBatching.push(...syncInteractively);
|
|
892
|
+
} else {
|
|
893
|
+
log.verbose('failed to push bundle, replicating interactively', {
|
|
894
|
+
collectionId,
|
|
895
|
+
peerId,
|
|
896
|
+
amount: missingOnRemote.length,
|
|
897
|
+
});
|
|
898
|
+
toReplicateWithoutBatching.push(...missingOnRemote);
|
|
899
|
+
}
|
|
900
|
+
if (bundleSyncEnabled && missingOnLocal.length >= BUNDLE_SYNC_THRESHOLD) {
|
|
901
|
+
log('pulling bundle', { amount: missingOnLocal.length });
|
|
902
|
+
const { syncInteractively } = await this._pullInBundles(ctx, peerId, missingOnLocal);
|
|
903
|
+
toReplicateWithoutBatching.push(...syncInteractively);
|
|
904
|
+
} else {
|
|
905
|
+
log.verbose('failed to pull bundle, replicating interactively', {
|
|
906
|
+
collectionId,
|
|
907
|
+
peerId,
|
|
908
|
+
amount: missingOnLocal.length,
|
|
909
|
+
});
|
|
910
|
+
toReplicateWithoutBatching.push(...missingOnLocal);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (toReplicateWithoutBatching.length === 0) {
|
|
560
914
|
return;
|
|
561
915
|
}
|
|
562
916
|
|
|
563
917
|
log('replicating documents after collection sync', {
|
|
564
918
|
collectionId,
|
|
565
919
|
peerId,
|
|
566
|
-
|
|
567
|
-
count:
|
|
920
|
+
toReplicateWithoutBatching,
|
|
921
|
+
count: toReplicateWithoutBatching.length,
|
|
568
922
|
});
|
|
569
923
|
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
924
|
+
// Trigger replication of the missing documents. `findWithProgress` is the
|
|
925
|
+
// fire-and-forget trigger — it creates a DocHandle and attaches sources. Under classical
|
|
926
|
+
// sync this triggers automerge-repo's doc-synchronizer; under Subduction it registers a
|
|
927
|
+
// query for the sedimentreeId. Either way, once bytes arrive `_afterSave` populates
|
|
928
|
+
// `HeadsStore` so collection sync sees the updated heads on the next diff.
|
|
929
|
+
for (const documentId of toReplicateWithoutBatching) {
|
|
930
|
+
this._documentsToSync.add(documentId);
|
|
931
|
+
this._repo.findWithProgress(documentId as DocumentId);
|
|
932
|
+
}
|
|
933
|
+
if (!this._useSubduction) {
|
|
934
|
+
this._sharePolicyChangedTask!.schedule();
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// TODO(mykola): Add retries of batches https://gist.github.com/mykola-vrmchk/fde270259e9209fcbf1331e5abbf12cf
|
|
939
|
+
private async _pushInBundles(
|
|
940
|
+
ctx: Context,
|
|
941
|
+
peerId: PeerId,
|
|
942
|
+
documentIds: DocumentId[],
|
|
943
|
+
): Promise<{ syncInteractively: DocumentId[] }> {
|
|
944
|
+
const documentsToPush = [...documentIds];
|
|
945
|
+
const syncInteractively: DocumentId[] = [];
|
|
946
|
+
|
|
947
|
+
// Push bundles in parallel with BUNDLE_SYNC_CONCURRENCY max concurrent tasks.
|
|
948
|
+
while (documentsToPush.length > 0) {
|
|
949
|
+
await Promise.all(
|
|
950
|
+
range(BUNDLE_SYNC_CONCURRENCY).map(async () => {
|
|
951
|
+
const bundle = documentsToPush.splice(0, BUNDLE_SIZE);
|
|
952
|
+
if (bundle.length === 0) {
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
await this._pushBundle(ctx, peerId, bundle).catch((err) => {
|
|
956
|
+
log.warn('failed to push bundle, replicating interactively', { peerId, bundle, err });
|
|
957
|
+
syncInteractively.push(...bundle);
|
|
958
|
+
});
|
|
959
|
+
}),
|
|
960
|
+
);
|
|
573
961
|
}
|
|
962
|
+
|
|
963
|
+
return { syncInteractively };
|
|
574
964
|
}
|
|
575
965
|
|
|
576
|
-
private
|
|
966
|
+
private async _pushBundle(ctx: Context, peerId: PeerId, documentIds: DocumentId[]): Promise<void> {
|
|
967
|
+
if (this._ctx.disposed) {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const docs = documentIds.map((documentId) => {
|
|
972
|
+
if (getHandleState(this._repo, documentId) !== 'ready') {
|
|
973
|
+
log.warn('document not ready, skipping', { documentId });
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const handle = this._repo.handles[documentId];
|
|
977
|
+
const doc = handle.doc();
|
|
978
|
+
if (!doc) {
|
|
979
|
+
log.warn('document not available, skipping', { documentId });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
documentId,
|
|
984
|
+
data: save(doc),
|
|
985
|
+
heads: getHeads(doc),
|
|
986
|
+
};
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
await this._echoNetworkAdapter.pushBundle(ctx, peerId, docs.filter(isNonNullable));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private async _pullInBundles(
|
|
993
|
+
ctx: Context,
|
|
994
|
+
peerId: PeerId,
|
|
995
|
+
documentIds: DocumentId[],
|
|
996
|
+
): Promise<{ syncInteractively: DocumentId[] }> {
|
|
997
|
+
const documentsToPull = [...documentIds];
|
|
998
|
+
const syncInteractively: DocumentId[] = [];
|
|
999
|
+
const docsToImport: Record<DocumentId, Uint8Array> = {};
|
|
1000
|
+
|
|
1001
|
+
// Pull bundles in parallel with BUNDLE_SYNC_CONCURRENCY max concurrent tasks.
|
|
1002
|
+
while (documentsToPull.length > 0) {
|
|
1003
|
+
await Promise.all(
|
|
1004
|
+
range(BUNDLE_SYNC_CONCURRENCY).map(async () => {
|
|
1005
|
+
const bundle = documentsToPull.splice(0, BUNDLE_SIZE);
|
|
1006
|
+
if (bundle.length === 0) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const result = await this._pullBundle(ctx, peerId, bundle).catch((err) => {
|
|
1010
|
+
log.warn('failed to pull bundle, replicating interactively', { peerId, bundle, err });
|
|
1011
|
+
syncInteractively.push(...bundle);
|
|
1012
|
+
});
|
|
1013
|
+
if (result) {
|
|
1014
|
+
Object.assign(docsToImport, result.docsToImport);
|
|
1015
|
+
}
|
|
1016
|
+
}),
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
for (const [documentId, data] of Object.entries(docsToImport)) {
|
|
1021
|
+
this._repo.import(data, { docId: documentId as DocumentId });
|
|
1022
|
+
}
|
|
1023
|
+
await this._repo.flush(Object.keys(docsToImport) as DocumentId[]);
|
|
1024
|
+
|
|
1025
|
+
return { syncInteractively };
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
private async _pullBundle(
|
|
1029
|
+
ctx: Context,
|
|
1030
|
+
peerId: PeerId,
|
|
1031
|
+
documentIds: DocumentId[],
|
|
1032
|
+
): Promise<{ docsToImport: Record<DocumentId, Uint8Array> } | undefined> {
|
|
1033
|
+
if (this._ctx.disposed) {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
// NOTE: We are expecting that documents that are being pulled are not present locally, so we are pulling all changes.
|
|
1037
|
+
const docHeads = Object.fromEntries(documentIds.map((documentId) => [documentId, []]));
|
|
1038
|
+
const bundle = await this._echoNetworkAdapter.pullBundle(ctx, peerId, docHeads);
|
|
1039
|
+
return { docsToImport: bundle };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
private _onHeadsChanged(docHeads: [DocumentId, Heads][]): void {
|
|
577
1043
|
const collectionsChanged = new Set<CollectionId>();
|
|
1044
|
+
|
|
578
1045
|
for (const collectionId of this._collectionSynchronizer.getRegisteredCollectionIds()) {
|
|
579
1046
|
const state = this._collectionSynchronizer.getLocalCollectionState(collectionId);
|
|
580
|
-
if (state
|
|
581
|
-
|
|
1047
|
+
if (!state) {
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
let newState: CollectionState | undefined;
|
|
1051
|
+
|
|
1052
|
+
for (const [documentId, heads] of docHeads) {
|
|
1053
|
+
const current = state.documents[documentId];
|
|
1054
|
+
// Collection membership is owned by `updateLocalCollectionState` (driven by
|
|
1055
|
+
// `SpaceStateManager.spaceDocumentListUpdated`). `_afterSave` fires for every
|
|
1056
|
+
// chunk written — including the space root, system docs, and transiently-fetched
|
|
1057
|
+
// docs that haven't been admitted to any collection — so we only refresh heads
|
|
1058
|
+
// for documents the membership path has already registered. Adding new keys
|
|
1059
|
+
// here would leak non-collection docs into the broadcast state and race with
|
|
1060
|
+
// the authoritative rebuild in `updateLocalCollectionState`.
|
|
1061
|
+
if (current === undefined) {
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
if (headsEquals(current, heads)) {
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
if (!newState) {
|
|
1068
|
+
newState = structuredClone(state);
|
|
1069
|
+
}
|
|
582
1070
|
newState.documents[documentId] = heads;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (newState) {
|
|
583
1074
|
this._collectionSynchronizer.setLocalCollectionState(collectionId, newState);
|
|
584
1075
|
collectionsChanged.add(collectionId as CollectionId);
|
|
585
1076
|
}
|
|
586
1077
|
}
|
|
1078
|
+
|
|
587
1079
|
for (const collectionId of collectionsChanged) {
|
|
588
1080
|
this.collectionStateUpdated.emit({ collectionId });
|
|
589
1081
|
}
|
|
@@ -593,15 +1085,29 @@ export class AutomergeHost extends Resource {
|
|
|
593
1085
|
const waitForHeads = async (handle: DocHandle<DatabaseDirectory>, heads: Heads) => {
|
|
594
1086
|
const unavailableHeads = new Set(heads);
|
|
595
1087
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
1088
|
+
// Check the current doc first, then subscribe to `change` to catch later
|
|
1089
|
+
// updates. (We can't use `handle.whenReady()` to gate the subscription —
|
|
1090
|
+
// see {@link getHandleState} for why `DocHandle.*` state is unusable in
|
|
1091
|
+
// this fork.)
|
|
1092
|
+
const checkPresentHeads = () => {
|
|
1093
|
+
const doc = handle.doc();
|
|
1094
|
+
if (!doc) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
599
1097
|
for (const changeHash of unavailableHeads.values()) {
|
|
600
|
-
if (changeIsPresentInDoc(
|
|
1098
|
+
if (changeIsPresentInDoc(doc, changeHash)) {
|
|
601
1099
|
unavailableHeads.delete(changeHash);
|
|
602
1100
|
}
|
|
603
1101
|
}
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
checkPresentHeads();
|
|
1105
|
+
if (unavailableHeads.size === 0) {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
604
1108
|
|
|
1109
|
+
await Event.wrap<DocHandleChangePayload<DatabaseDirectory>>(handle, 'change').waitForCondition(() => {
|
|
1110
|
+
checkPresentHeads();
|
|
605
1111
|
return unavailableHeads.size === 0;
|
|
606
1112
|
});
|
|
607
1113
|
};
|
|
@@ -620,34 +1126,6 @@ const encodeCollectionState = (state: CollectionState): unknown => {
|
|
|
620
1126
|
return state;
|
|
621
1127
|
};
|
|
622
1128
|
|
|
623
|
-
|
|
624
|
-
|
|
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;
|
|
1129
|
+
const isDocumentUnavailableError = (error: Error): boolean => {
|
|
1130
|
+
return error.message.includes('unavailable');
|
|
653
1131
|
};
|