@dxos/echo-pipeline 0.8.3 → 0.8.4-main.1068cf700f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/dist/lib/neutral/chunk-FJPXA75J.mjs +235 -0
  2. package/dist/lib/neutral/chunk-FJPXA75J.mjs.map +7 -0
  3. package/dist/lib/{browser/chunk-35I6ERLG.mjs → neutral/chunk-WIQUYO7H.mjs} +300 -213
  4. package/dist/lib/neutral/chunk-WIQUYO7H.mjs.map +7 -0
  5. package/dist/lib/{browser → neutral}/filter/index.mjs +3 -2
  6. package/dist/lib/{browser → neutral}/index.mjs +2832 -946
  7. package/dist/lib/neutral/index.mjs.map +7 -0
  8. package/dist/lib/neutral/meta.json +1 -0
  9. package/dist/lib/{browser → neutral}/testing/index.mjs +73 -224
  10. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  11. package/dist/types/src/automerge/automerge-host.d.ts +53 -42
  12. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  13. package/dist/types/src/automerge/automerge-repo.test.d.ts +1 -1
  14. package/dist/types/src/automerge/automerge-repo.test.d.ts.map +1 -1
  15. package/dist/types/src/automerge/collection-synchronizer.d.ts +6 -4
  16. package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -1
  17. package/dist/types/src/automerge/echo-network-adapter.d.ts +22 -10
  18. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
  19. package/dist/types/src/automerge/echo-replicator.d.ts +37 -15
  20. package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
  21. package/dist/types/src/automerge/heads-store.d.ts +9 -2
  22. package/dist/types/src/automerge/heads-store.d.ts.map +1 -1
  23. package/dist/types/src/automerge/index.d.ts +1 -1
  24. package/dist/types/src/automerge/index.d.ts.map +1 -1
  25. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +5 -5
  26. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
  27. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +9 -8
  28. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
  29. package/dist/types/src/automerge/mesh-echo-replicator.d.ts +5 -5
  30. package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
  31. package/dist/types/src/common/codec.d.ts +1 -1
  32. package/dist/types/src/common/codec.d.ts.map +1 -1
  33. package/dist/types/src/db-host/automerge-data-source.d.ts +34 -0
  34. package/dist/types/src/db-host/automerge-data-source.d.ts.map +1 -0
  35. package/dist/types/src/db-host/automerge-data-source.test.d.ts +2 -0
  36. package/dist/types/src/db-host/automerge-data-source.test.d.ts.map +1 -0
  37. package/dist/types/src/db-host/data-service.d.ts +5 -4
  38. package/dist/types/src/db-host/data-service.d.ts.map +1 -1
  39. package/dist/types/src/db-host/database-root.d.ts.map +1 -1
  40. package/dist/types/src/db-host/documents-synchronizer.d.ts +6 -5
  41. package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
  42. package/dist/types/src/db-host/echo-host.d.ts +44 -23
  43. package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
  44. package/dist/types/src/db-host/index.d.ts +1 -0
  45. package/dist/types/src/db-host/index.d.ts.map +1 -1
  46. package/dist/types/src/db-host/local-queue-service.d.ts +16 -0
  47. package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
  48. package/dist/types/src/db-host/query-service.d.ts +14 -9
  49. package/dist/types/src/db-host/query-service.d.ts.map +1 -1
  50. package/dist/types/src/db-host/queue-data-source.d.ts +28 -0
  51. package/dist/types/src/db-host/queue-data-source.d.ts.map +1 -0
  52. package/dist/types/src/db-host/queue-service.test.d.ts +2 -0
  53. package/dist/types/src/db-host/queue-service.test.d.ts.map +1 -0
  54. package/dist/types/src/db-host/space-state-manager.d.ts +2 -1
  55. package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
  56. package/dist/types/src/db-host/stub.d.ts +10 -0
  57. package/dist/types/src/db-host/stub.d.ts.map +1 -0
  58. package/dist/types/src/edge/echo-edge-replicator.d.ts +8 -6
  59. package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -1
  60. package/dist/types/src/filter/filter-match.d.ts +4 -1
  61. package/dist/types/src/filter/filter-match.d.ts.map +1 -1
  62. package/dist/types/src/metadata/metadata-store.d.ts +1 -1
  63. package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
  64. package/dist/types/src/pipeline/pipeline.d.ts +3 -3
  65. package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
  66. package/dist/types/src/query/errors.d.ts +21 -9
  67. package/dist/types/src/query/errors.d.ts.map +1 -1
  68. package/dist/types/src/query/plan.d.ts +57 -5
  69. package/dist/types/src/query/plan.d.ts.map +1 -1
  70. package/dist/types/src/query/query-executor.d.ts +49 -10
  71. package/dist/types/src/query/query-executor.d.ts.map +1 -1
  72. package/dist/types/src/query/query-planner.d.ts +9 -0
  73. package/dist/types/src/query/query-planner.d.ts.map +1 -1
  74. package/dist/types/src/space/admission-discovery-extension.d.ts.map +1 -1
  75. package/dist/types/src/space/auth.d.ts +3 -3
  76. package/dist/types/src/space/auth.d.ts.map +1 -1
  77. package/dist/types/src/space/control-pipeline.d.ts +3 -3
  78. package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
  79. package/dist/types/src/space/space-manager.d.ts +7 -7
  80. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  81. package/dist/types/src/space/space-protocol.d.ts +6 -6
  82. package/dist/types/src/space/space-protocol.d.ts.map +1 -1
  83. package/dist/types/src/space/space.d.ts +4 -4
  84. package/dist/types/src/space/space.d.ts.map +1 -1
  85. package/dist/types/src/testing/index.d.ts +0 -2
  86. package/dist/types/src/testing/index.d.ts.map +1 -1
  87. package/dist/types/src/testing/test-agent-builder.d.ts +2 -2
  88. package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
  89. package/dist/types/src/testing/test-network-adapter.d.ts +1 -1
  90. package/dist/types/src/testing/test-network-adapter.d.ts.map +1 -1
  91. package/dist/types/src/testing/test-replicator.d.ts +14 -13
  92. package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
  93. package/dist/types/src/util.d.ts +1 -1
  94. package/dist/types/src/util.d.ts.map +1 -1
  95. package/dist/types/tsconfig.tsbuildinfo +1 -1
  96. package/package.json +53 -45
  97. package/src/automerge/automerge-host.test.ts +55 -24
  98. package/src/automerge/automerge-host.ts +399 -126
  99. package/src/automerge/automerge-repo.test.ts +214 -46
  100. package/src/automerge/collection-synchronizer.test.ts +22 -15
  101. package/src/automerge/collection-synchronizer.ts +57 -22
  102. package/src/automerge/echo-data-monitor.ts +1 -1
  103. package/src/automerge/echo-network-adapter.test.ts +3 -3
  104. package/src/automerge/echo-network-adapter.ts +73 -28
  105. package/src/automerge/echo-replicator.ts +40 -15
  106. package/src/automerge/heads-store.ts +42 -3
  107. package/src/automerge/index.ts +1 -1
  108. package/src/automerge/leveldb-storage-adapter.ts +11 -11
  109. package/src/automerge/mesh-echo-replicator-connection.ts +12 -8
  110. package/src/automerge/mesh-echo-replicator.ts +15 -11
  111. package/src/automerge/storage-adapter.test.ts +1 -1
  112. package/src/common/space-id.ts +1 -1
  113. package/src/db-host/automerge-data-source.test.ts +292 -0
  114. package/src/db-host/automerge-data-source.ts +144 -0
  115. package/src/db-host/data-service.ts +19 -20
  116. package/src/db-host/database-root.ts +2 -2
  117. package/src/db-host/documents-synchronizer.test.ts +73 -13
  118. package/src/db-host/documents-synchronizer.ts +58 -44
  119. package/src/db-host/echo-host.ts +194 -84
  120. package/src/db-host/index.ts +1 -0
  121. package/src/db-host/local-queue-service.ts +147 -0
  122. package/src/db-host/query-service.ts +28 -95
  123. package/src/db-host/queue-data-source.ts +132 -0
  124. package/src/db-host/queue-service.test.ts +145 -0
  125. package/src/db-host/space-state-manager.ts +6 -2
  126. package/src/db-host/stub.ts +28 -0
  127. package/src/edge/echo-edge-replicator.test.ts +8 -6
  128. package/src/edge/echo-edge-replicator.ts +124 -42
  129. package/src/filter/filter-match.test.ts +58 -32
  130. package/src/filter/filter-match.ts +163 -15
  131. package/src/metadata/metadata-store.ts +8 -5
  132. package/src/pipeline/pipeline-stress.test.ts +4 -2
  133. package/src/pipeline/pipeline.test.ts +3 -2
  134. package/src/pipeline/pipeline.ts +10 -7
  135. package/src/query/errors.ts +3 -1
  136. package/src/query/plan.ts +75 -5
  137. package/src/query/query-executor.ts +812 -117
  138. package/src/query/query-planner.test.ts +652 -34
  139. package/src/query/query-planner.ts +276 -13
  140. package/src/space/admission-discovery-extension.ts +3 -3
  141. package/src/space/auth.ts +6 -6
  142. package/src/space/control-pipeline.test.ts +4 -3
  143. package/src/space/control-pipeline.ts +11 -8
  144. package/src/space/space-manager.browser.test.ts +1 -1
  145. package/src/space/space-manager.ts +11 -10
  146. package/src/space/space-protocol.browser.test.ts +2 -2
  147. package/src/space/space-protocol.test.ts +3 -2
  148. package/src/space/space-protocol.ts +17 -14
  149. package/src/space/space.test.ts +1 -1
  150. package/src/space/space.ts +6 -5
  151. package/src/testing/index.ts +0 -2
  152. package/src/testing/test-agent-builder.ts +4 -3
  153. package/src/testing/test-network-adapter.ts +16 -3
  154. package/src/testing/test-replicator.ts +23 -18
  155. package/src/util.ts +7 -3
  156. package/dist/lib/browser/chunk-35I6ERLG.mjs.map +0 -7
  157. package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
  158. package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
  159. package/dist/lib/browser/chunk-TQJTKNMS.mjs +0 -126
  160. package/dist/lib/browser/chunk-TQJTKNMS.mjs.map +0 -7
  161. package/dist/lib/browser/index.mjs.map +0 -7
  162. package/dist/lib/browser/meta.json +0 -1
  163. package/dist/lib/browser/testing/index.mjs.map +0 -7
  164. package/dist/lib/node/chunk-HOPOFWAL.cjs +0 -147
  165. package/dist/lib/node/chunk-HOPOFWAL.cjs.map +0 -7
  166. package/dist/lib/node/chunk-JXX6LF5U.cjs +0 -2084
  167. package/dist/lib/node/chunk-JXX6LF5U.cjs.map +0 -7
  168. package/dist/lib/node/chunk-Q7SFCCGT.cjs +0 -33
  169. package/dist/lib/node/chunk-Q7SFCCGT.cjs.map +0 -7
  170. package/dist/lib/node/filter/index.cjs +0 -32
  171. package/dist/lib/node/filter/index.cjs.map +0 -7
  172. package/dist/lib/node/index.cjs +0 -4699
  173. package/dist/lib/node/index.cjs.map +0 -7
  174. package/dist/lib/node/meta.json +0 -1
  175. package/dist/lib/node/testing/index.cjs +0 -753
  176. package/dist/lib/node/testing/index.cjs.map +0 -7
  177. package/dist/lib/node-esm/chunk-5BHLPT24.mjs +0 -2067
  178. package/dist/lib/node-esm/chunk-5BHLPT24.mjs.map +0 -7
  179. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  180. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  181. package/dist/lib/node-esm/chunk-RVK35BS7.mjs +0 -126
  182. package/dist/lib/node-esm/chunk-RVK35BS7.mjs.map +0 -7
  183. package/dist/lib/node-esm/filter/index.mjs +0 -11
  184. package/dist/lib/node-esm/filter/index.mjs.map +0 -7
  185. package/dist/lib/node-esm/index.mjs +0 -4743
  186. package/dist/lib/node-esm/index.mjs.map +0 -7
  187. package/dist/lib/node-esm/meta.json +0 -1
  188. package/dist/lib/node-esm/testing/index.mjs +0 -742
  189. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  190. package/dist/types/src/db-host/documents-iterator.d.ts +0 -7
  191. package/dist/types/src/db-host/documents-iterator.d.ts.map +0 -1
  192. package/dist/types/src/testing/test-data.d.ts +0 -18
  193. package/dist/types/src/testing/test-data.d.ts.map +0 -1
  194. package/dist/types/src/testing/test-schema.d.ts +0 -37
  195. package/dist/types/src/testing/test-schema.d.ts.map +0 -1
  196. package/src/db-host/documents-iterator.ts +0 -74
  197. package/src/testing/test-data.ts +0 -127
  198. package/src/testing/test-schema.ts +0 -53
  199. /package/dist/lib/{browser → neutral}/filter/index.mjs.map +0 -0
@@ -3,57 +3,55 @@
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 HandleState,
20
20
  type PeerCandidatePayload,
21
21
  type PeerDisconnectedPayload,
22
22
  type PeerId,
23
+ Repo,
23
24
  type StorageAdapterInterface,
24
25
  type StorageKey,
25
26
  interpretAsDocumentId,
26
- type HandleState,
27
27
  } from '@automerge/automerge-repo';
28
+ import type * as Record from 'effect/Record';
28
29
 
29
- import { Event, asyncTimeout } from '@dxos/async';
30
- import { Context, Resource, cancelWithContext, type Lifecycle } from '@dxos/context';
31
- import { DatabaseDirectory, type CollectionId } from '@dxos/echo-protocol';
32
- import { type IndexMetadataStore } from '@dxos/indexing';
30
+ import { DeferredTask, Event, asyncTimeout } from '@dxos/async';
31
+ import { Context, type Lifecycle, Resource, cancelWithContext } from '@dxos/context';
32
+ import { type CollectionId, DatabaseDirectory } from '@dxos/echo-protocol';
33
33
  import { invariant } from '@dxos/invariant';
34
34
  import { PublicKey } from '@dxos/keys';
35
35
  import { type LevelDB } from '@dxos/kv-store';
36
36
  import { log } from '@dxos/log';
37
- import { objectPointerCodec } from '@dxos/protocols';
37
+ import { type SpaceSyncState } from '@dxos/protocols/proto/dxos/echo/service';
38
38
  import { type DocHeadsList, type FlushRequest } from '@dxos/protocols/proto/dxos/echo/service';
39
39
  import { trace } from '@dxos/tracing';
40
- import { bufferToArray } from '@dxos/util';
40
+ import { ComplexSet, bufferToArray, defaultMap, isNonNullable, range } from '@dxos/util';
41
41
 
42
- import { CollectionSynchronizer, diffCollectionState, type CollectionState } from './collection-synchronizer';
42
+ import { type CollectionState, CollectionSynchronizer, diffCollectionState } from './collection-synchronizer';
43
43
  import { type EchoDataMonitor } from './echo-data-monitor';
44
44
  import { EchoNetworkAdapter, isEchoPeerMetadata } from './echo-network-adapter';
45
- import { type EchoReplicator, type RemoteDocumentExistenceCheckParams } from './echo-replicator';
45
+ import { type AutomergeReplicator, type RemoteDocumentExistenceCheckProps } from './echo-replicator';
46
46
  import { HeadsStore } from './heads-store';
47
- import { LevelDBStorageAdapter, type BeforeSaveParams } from './leveldb-storage-adapter';
47
+ import { type BeforeSaveProps, LevelDBStorageAdapter } from './leveldb-storage-adapter';
48
48
 
49
49
  export type PeerIdProvider = () => string | undefined;
50
50
 
51
51
  export type RootDocumentSpaceKeyProvider = (documentId: string) => PublicKey | undefined;
52
52
 
53
- export type AutomergeHostParams = {
53
+ export type AutomergeHostProps = {
54
54
  db: LevelDB;
55
-
56
- indexMetadataStore: IndexMetadataStore;
57
55
  dataMonitor?: EchoDataMonitor;
58
56
 
59
57
  /**
@@ -65,6 +63,12 @@ export type AutomergeHostParams = {
65
63
 
66
64
  export type LoadDocOptions = {
67
65
  timeout?: number;
66
+
67
+ /**
68
+ * If true, the document will be fetched from the network if it is not found in the local storage.
69
+ * Setting this to false does not guarantee that the document will not be fetched from the network.
70
+ */
71
+ fetchFromNetwork?: boolean;
68
72
  };
69
73
 
70
74
  export type CreateDocOptions = {
@@ -72,19 +76,38 @@ export type CreateDocOptions = {
72
76
  * Import the document together with its history.
73
77
  */
74
78
  preserveHistory?: boolean;
79
+
80
+ documentId?: DocumentId;
75
81
  };
76
82
 
77
83
  export const FIND_PARAMS = {
78
84
  allowableStates: ['ready', 'requesting'] satisfies HandleState[],
79
85
  };
80
86
 
87
+ /**
88
+ * Maximum amount of documents to sync in a single bundle.
89
+ */
90
+ const BUNDLE_SIZE = 100;
91
+ /**
92
+ * Maximum amount of concurrent tasks to run when pushing or pulling bundles.
93
+ */
94
+ const BUNDLE_SYNC_CONCURRENCY = 2;
95
+ /**
96
+ * If the number of documents to sync is greater than this threshold, we will use bundles.
97
+ */
98
+ const BUNDLE_SYNC_THRESHOLD = 50;
99
+
100
+ /**
101
+ * Only announce documents that are known to require sync.
102
+ */
103
+ const OPTIMIZED_SHARE_POLICY = true;
104
+
81
105
  /**
82
106
  * Abstracts over the AutomergeRepo.
83
107
  */
84
108
  @trace.resource()
85
109
  export class AutomergeHost extends Resource {
86
110
  private readonly _db: LevelDB;
87
- private readonly _indexMetadataStore: IndexMetadataStore;
88
111
  private readonly _echoNetworkAdapter: EchoNetworkAdapter;
89
112
 
90
113
  private readonly _collectionSynchronizer = new CollectionSynchronizer({
@@ -97,6 +120,14 @@ export class AutomergeHost extends Resource {
97
120
  private _storage!: StorageAdapterInterface & Lifecycle;
98
121
  private readonly _headsStore: HeadsStore;
99
122
 
123
+ private _syncTask: DeferredTask | undefined = undefined;
124
+ /**
125
+ * Cache of collections that would be synced on next sync task run.
126
+ */
127
+ private readonly _collectionsToSync = new ComplexSet<{ collectionId: string; peerId: PeerId }>(
128
+ ({ collectionId, peerId }) => `${collectionId}|${peerId}`,
129
+ );
130
+
100
131
  @trace.info()
101
132
  private _peerId!: PeerId;
102
133
 
@@ -110,13 +141,32 @@ export class AutomergeHost extends Resource {
110
141
  */
111
142
  public readonly documentsSaved = new Event();
112
143
 
113
- constructor({
114
- db,
115
- indexMetadataStore,
116
- dataMonitor,
117
- peerIdProvider,
118
- getSpaceKeyByRootDocumentId,
119
- }: AutomergeHostParams) {
144
+ private readonly _headsUpdates = new Map<DocumentId, Heads>();
145
+ private _onHeadsChangedTask?: DeferredTask;
146
+
147
+ /**
148
+ * Documents created in this session.
149
+ */
150
+ private _createdDocuments = new Set<DocumentId>();
151
+
152
+ /**
153
+ * Documents that need to be synced based on the result of collection-sync.
154
+ */
155
+ private _documentsToSync = new Set<DocumentId>();
156
+
157
+ /**
158
+ * Documents that are not avaiale locally that should be requested.
159
+ */
160
+ private _documentsToRequest = new Set<DocumentId>();
161
+
162
+ /**
163
+ * Documents requested by remote peers.
164
+ */
165
+ private _documentsRequested = new Map<PeerId, Set<DocumentId>>();
166
+
167
+ private _sharePolicyChangedTask?: DeferredTask;
168
+
169
+ constructor({ db, dataMonitor, peerIdProvider, getSpaceKeyByRootDocumentId }: AutomergeHostProps) {
120
170
  super();
121
171
  this._db = db;
122
172
  this._storage = new LevelDBStorageAdapter({
@@ -134,8 +184,11 @@ export class AutomergeHost extends Resource {
134
184
  onCollectionStateReceived: this._onCollectionStateReceived.bind(this),
135
185
  monitor: dataMonitor,
136
186
  });
187
+ this._echoNetworkAdapter.documentRequested.on(({ peerId, documentId }) => {
188
+ defaultMap(this._documentsRequested, peerId, () => new Set()).add(documentId);
189
+ this._sharePolicyChangedTask!.schedule();
190
+ });
137
191
  this._headsStore = new HeadsStore({ db: db.sublevel('heads') });
138
- this._indexMetadataStore = indexMetadataStore;
139
192
  this._peerIdProvider = peerIdProvider;
140
193
  this._getSpaceKeyByRootDocumentId = getSpaceKeyByRootDocumentId;
141
194
  }
@@ -143,12 +196,18 @@ export class AutomergeHost extends Resource {
143
196
  protected override async _open(): Promise<void> {
144
197
  this._peerId = `host-${this._peerIdProvider?.() ?? PublicKey.random().toHex()}` as PeerId;
145
198
 
199
+ this._onHeadsChangedTask = new DeferredTask(this._ctx, async () => {
200
+ const docHeads = Array.from(this._headsUpdates.entries());
201
+ this._headsUpdates.clear();
202
+ this._onHeadsChanged(docHeads);
203
+ });
204
+
146
205
  await this._storage.open?.();
147
206
 
148
207
  // Construct the automerge repo.
149
208
  this._repo = new Repo({
150
209
  peerId: this._peerId as PeerId,
151
- sharePolicy: this._sharePolicy.bind(this),
210
+ shareConfig: this._shareConfig,
152
211
  storage: this._storage,
153
212
  network: [
154
213
  // Upstream swarm.
@@ -180,6 +239,27 @@ export class AutomergeHost extends Resource {
180
239
  }
181
240
  });
182
241
 
242
+ this._syncTask = new DeferredTask(this._ctx, async () => {
243
+ const collectionToSync = Array.from(this._collectionsToSync.values());
244
+ if (collectionToSync.length === 0) {
245
+ return;
246
+ }
247
+ await Promise.all(
248
+ collectionToSync.map(async ({ collectionId, peerId }) => {
249
+ try {
250
+ await this._handleCollectionSync(collectionId, peerId);
251
+ } catch (err) {
252
+ log.error('failed to sync collection', { collectionId, peerId, err });
253
+ }
254
+ }),
255
+ );
256
+ });
257
+
258
+ this._sharePolicyChangedTask = new DeferredTask(this._ctx, async () => {
259
+ log('share policy changed');
260
+ this._repo.shareConfigChanged();
261
+ });
262
+
183
263
  await this._echoNetworkAdapter.open();
184
264
  await this._collectionSynchronizer.open();
185
265
  await this._echoNetworkAdapter.open();
@@ -190,14 +270,9 @@ export class AutomergeHost extends Resource {
190
270
  await this._collectionSynchronizer.close();
191
271
  await this._storage.close?.();
192
272
  await this._echoNetworkAdapter.close();
193
- await this._ctx.dispose();
194
- }
195
-
196
- /**
197
- * @deprecated To be abstracted away.
198
- */
199
- get repo(): Repo {
200
- return this._repo;
273
+ this._syncTask = undefined;
274
+ this._onHeadsChangedTask = undefined;
275
+ this._sharePolicyChangedTask = undefined;
201
276
  }
202
277
 
203
278
  get peerId(): PeerId {
@@ -208,11 +283,17 @@ export class AutomergeHost extends Resource {
208
283
  return Object.keys(this._repo.handles).length;
209
284
  }
210
285
 
211
- async addReplicator(replicator: EchoReplicator): Promise<void> {
286
+ get handles(): Readonly<Record<DocumentId, DocHandle<any>>> {
287
+ return this._repo.handles;
288
+ }
289
+
290
+ async addReplicator(replicator: AutomergeReplicator): Promise<void> {
291
+ invariant(this.isOpen, 'AutomergeHost is not open');
212
292
  await this._echoNetworkAdapter.addReplicator(replicator);
213
293
  }
214
294
 
215
- async removeReplicator(replicator: EchoReplicator): Promise<void> {
295
+ async removeReplicator(replicator: AutomergeReplicator): Promise<void> {
296
+ invariant(this.isOpen, 'AutomergeHost is not open');
216
297
  await this._echoNetworkAdapter.removeReplicator(replicator);
217
298
  }
218
299
 
@@ -220,13 +301,29 @@ export class AutomergeHost extends Resource {
220
301
  * Loads the document handle from the repo and waits for it to be ready.
221
302
  */
222
303
  async loadDoc<T>(ctx: Context, documentId: AnyDocumentId, opts?: LoadDocOptions): Promise<DocHandle<T>> {
304
+ invariant(this.isOpen, 'AutomergeHost is not open');
223
305
  let handle: DocHandle<T> | undefined;
224
306
  if (typeof documentId === 'string') {
225
307
  // NOTE: documentId might also be a URL, in which case this lookup will fail.
226
308
  handle = this._repo.handles[documentId as DocumentId];
227
309
  }
228
310
  if (!handle) {
229
- handle = await this._repo.find(documentId as DocumentId, FIND_PARAMS);
311
+ if (!opts?.fetchFromNetwork) {
312
+ handle = await this._repo.find(documentId as DocumentId, FIND_PARAMS);
313
+ } else {
314
+ try {
315
+ handle = await this._repo.find(documentId as DocumentId, {
316
+ allowableStates: ['ready', 'requesting', 'unavailable'],
317
+ });
318
+ if (handle.state === 'requesting' || handle.state === 'unavailable') {
319
+ this._documentsToRequest.add(handle.documentId);
320
+ this._sharePolicyChangedTask!.schedule();
321
+ }
322
+ } catch (err) {
323
+ log.error('failed to load document', { documentId, err });
324
+ throw err;
325
+ }
326
+ }
230
327
  }
231
328
 
232
329
  // `whenReady` creates a timeout so we guard it with an if to skip it if the handle is already ready.
@@ -242,6 +339,7 @@ export class AutomergeHost extends Resource {
242
339
  }
243
340
 
244
341
  async exportDoc(ctx: Context, id: AnyDocumentId): Promise<Uint8Array> {
342
+ invariant(this.isOpen, 'AutomergeHost is not open');
245
343
  const documentId = interpretAsDocumentId(id);
246
344
 
247
345
  const chunks = await this._storage.loadRange([documentId]);
@@ -251,10 +349,11 @@ export class AutomergeHost extends Resource {
251
349
  /**
252
350
  * Create new persisted document.
253
351
  */
254
- createDoc<T>(initialValue?: T | Doc<T> | Uint8Array, opts?: CreateDocOptions): DocHandle<T> {
352
+ async createDoc<T>(initialValue?: T | Doc<T> | Uint8Array, opts?: CreateDocOptions): Promise<DocHandle<T>> {
353
+ invariant(this.isOpen, 'AutomergeHost is not open');
255
354
  if (opts?.preserveHistory) {
256
355
  if (initialValue instanceof Uint8Array) {
257
- return this._repo.import(initialValue);
356
+ return this._repo.import(initialValue, { docId: opts?.documentId });
258
357
  }
259
358
 
260
359
  if (!isAutomerge(initialValue)) {
@@ -262,17 +361,27 @@ export class AutomergeHost extends Resource {
262
361
  }
263
362
 
264
363
  // TODO(dmaretskyi): There's a more efficient way.
265
- return this._repo.import(save(initialValue as Doc<T>));
364
+ const handle = this._repo.import(save(initialValue as Doc<T>), { docId: opts?.documentId });
365
+ this._createdDocuments.add(handle.documentId);
366
+ this._sharePolicyChangedTask!.schedule();
367
+ return handle as DocHandle<T>;
266
368
  } else {
267
369
  if (initialValue instanceof Uint8Array) {
268
370
  throw new Error('Cannot create document from Uint8Array without preserving history');
269
371
  }
270
372
 
271
- return this._repo.create(initialValue);
373
+ if (opts?.documentId) {
374
+ throw new Error('Cannot prefil document id when not importing an existing doc');
375
+ }
376
+ const handle = await this._repo.create2<T>(initialValue);
377
+ this._createdDocuments.add(handle.documentId);
378
+ this._sharePolicyChangedTask!.schedule();
379
+ return handle;
272
380
  }
273
381
  }
274
382
 
275
383
  async waitUntilHeadsReplicated(heads: DocHeadsList): Promise<void> {
384
+ invariant(this.isOpen, 'AutomergeHost is not open');
276
385
  const entries = heads.entries;
277
386
  if (!entries?.length) {
278
387
  return;
@@ -289,7 +398,7 @@ export class AutomergeHost extends Resource {
289
398
  });
290
399
  if (headsToWait.length > 0) {
291
400
  await Promise.all(
292
- headsToWait.map(async (entry, index) => {
401
+ headsToWait.map(async (entry) => {
293
402
  const handle = await this.loadDoc<DatabaseDirectory>(Context.default(), entry.documentId as DocumentId);
294
403
  await waitForHeads(handle, entry.heads!);
295
404
  }),
@@ -303,6 +412,7 @@ export class AutomergeHost extends Resource {
303
412
  }
304
413
 
305
414
  async reIndexHeads(documentIds: DocumentId[]): Promise<void> {
415
+ invariant(this.isOpen, 'AutomergeHost is not open');
306
416
  for (const documentId of documentIds) {
307
417
  log('re-indexing heads for document', { documentId });
308
418
  const handle = await this._repo.find(documentId, FIND_PARAMS);
@@ -319,28 +429,42 @@ export class AutomergeHost extends Resource {
319
429
  log('done re-indexing heads');
320
430
  }
321
431
 
322
- // TODO(dmaretskyi): Share based on HALO permissions and space affinity.
323
- // Hosts, running in the worker, don't share documents unless requested by other peers.
324
- // NOTE: If both peers return sharePolicy=false the replication will not happen
325
- // https://github.com/automerge/automerge-repo/pull/292
326
- private async _sharePolicy(peerId: PeerId, documentId?: DocumentId): Promise<boolean> {
327
- if (peerId.startsWith('client-')) {
328
- return false; // Only send docs to clients if they are requested.
329
- }
432
+ private readonly _shareConfig = {
433
+ access: async (peerId: PeerId, documentId: DocumentId): Promise<boolean> => {
434
+ return true;
435
+ },
330
436
 
331
- if (!documentId) {
332
- return false;
333
- }
437
+ // TODO(dmaretskyi): Share based on HALO permissions and space affinity.
438
+ // Hosts, running in the worker, don't share documents unless requested by other peers.
439
+ // NOTE: If both peers return sharePolicy=false the replication will not happen
440
+ // https://github.com/automerge/automerge-repo/pull/292
441
+ // Called for all loaded documents so they could be advertised to the sync server.
442
+ announce: async (peerId: PeerId, documentId?: DocumentId): Promise<boolean> => {
443
+ if (!documentId) {
444
+ return false;
445
+ }
334
446
 
335
- const peerMetadata = this.repo.peerMetadataByPeerId[peerId];
336
- if (isEchoPeerMetadata(peerMetadata)) {
337
- return this._echoNetworkAdapter.shouldAdvertise(peerId, { documentId });
338
- }
447
+ if (OPTIMIZED_SHARE_POLICY) {
448
+ if (
449
+ !this._createdDocuments.has(documentId) &&
450
+ !this._documentsToSync.has(documentId) &&
451
+ !this._documentsToRequest.has(documentId)
452
+ ) {
453
+ // Skip advertising documents that don't need to be synced.
454
+ return false;
455
+ }
456
+ }
339
457
 
340
- return false;
341
- }
458
+ const peerMetadata = this._repo.peerMetadataByPeerId[peerId];
459
+ if (isEchoPeerMetadata(peerMetadata)) {
460
+ return this._echoNetworkAdapter.shouldAdvertise(peerId, { documentId });
461
+ }
462
+
463
+ return false;
464
+ },
465
+ };
342
466
 
343
- private async _beforeSave({ path, batch }: BeforeSaveParams): Promise<void> {
467
+ private async _beforeSave({ path, batch }: BeforeSaveProps): Promise<void> {
344
468
  const handle = this._repo.handles[path[0] as DocumentId];
345
469
  if (!handle || !handle.isReady()) {
346
470
  return;
@@ -352,14 +476,6 @@ export class AutomergeHost extends Resource {
352
476
 
353
477
  const heads = getHeads(doc);
354
478
  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
479
  }
364
480
 
365
481
  private _shouldSyncCollection(collectionId: string, peerId: PeerId): boolean {
@@ -375,14 +491,24 @@ export class AutomergeHost extends Resource {
375
491
  * Called by AutomergeStorageAdapter after levelDB batch commit.
376
492
  */
377
493
  private async _afterSave(path: StorageKey): Promise<void> {
378
- this._indexMetadataStore.notifyMarkedDirty();
494
+ if (!this.isOpen) {
495
+ return undefined;
496
+ }
379
497
 
380
498
  const documentId = path[0] as DocumentId;
381
- const document = this._repo.handles[documentId]?.doc();
382
- if (document) {
383
- const heads = getHeads(document);
384
- this._onHeadsChanged(documentId, heads);
499
+ const handle = this._repo.handles[documentId];
500
+ if (!handle || !handle.isReady()) {
501
+ return;
385
502
  }
503
+ const document = handle.doc();
504
+ if (!document) {
505
+ return;
506
+ }
507
+
508
+ const heads = getHeads(document);
509
+ this._headsUpdates.set(documentId, heads);
510
+ invariant(this._onHeadsChangedTask, 'onHeadsChangedTask is not initialized');
511
+ this._onHeadsChangedTask.schedule();
386
512
  this.documentsSaved.emit();
387
513
  }
388
514
 
@@ -391,7 +517,7 @@ export class AutomergeHost extends Resource {
391
517
  return this._repo.peers;
392
518
  }
393
519
 
394
- private async _isDocumentInRemoteCollection(params: RemoteDocumentExistenceCheckParams): Promise<boolean> {
520
+ private async _isDocumentInRemoteCollection(params: RemoteDocumentExistenceCheckProps): Promise<boolean> {
395
521
  for (const collectionId of this._collectionSynchronizer.getRegisteredCollectionIds()) {
396
522
  const remoteCollections = this._collectionSynchronizer.getRemoteCollectionStates(collectionId);
397
523
  const remotePeerDocs = remoteCollections.get(params.peerId as PeerId)?.documents;
@@ -433,11 +559,17 @@ export class AutomergeHost extends Resource {
433
559
  async flush({ documentIds }: FlushRequest = {}): Promise<void> {
434
560
  // Note: Sync protocol for client and services ensures that all handles should have all changes.
435
561
 
436
- const loadedDocuments = documentIds?.filter((documentId): documentId is DocumentId => {
437
- const handle = this._repo.handles[documentId as DocumentId];
438
- return handle && handle.isReady();
439
- });
562
+ const loadedDocuments = (documentIds ?? Object.keys(this._repo.handles)).filter(
563
+ (documentId): documentId is DocumentId => {
564
+ const handle = this._repo.handles[documentId as DocumentId];
565
+ return handle && handle.isReady();
566
+ },
567
+ );
440
568
  await this._repo.flush(loadedDocuments);
569
+
570
+ // Ensure that document verions have propagated accross the system.
571
+ // This is important for the case where we are doing flush and then waiting for sync to happen.
572
+ await this._onHeadsChangedTask?.runBlocking();
441
573
  }
442
574
 
443
575
  async getHeads(documentIds: DocumentId[]): Promise<(Heads | undefined)[]> {
@@ -463,6 +595,13 @@ export class AutomergeHost extends Resource {
463
595
  return result;
464
596
  }
465
597
 
598
+ /**
599
+ * Iterate over all document heads stored on disk.
600
+ */
601
+ listDocumentHeads(): AsyncGenerator<{ documentId: DocumentId; heads: Heads }> {
602
+ return this._headsStore.iterateAll();
603
+ }
604
+
466
605
  //
467
606
  // Collection sync.
468
607
  //
@@ -479,8 +618,8 @@ export class AutomergeHost extends Resource {
479
618
  this._collectionSynchronizer.refreshCollection(collectionId);
480
619
  }
481
620
 
482
- async getCollectionSyncState(collectionId: string): Promise<CollectionSyncState> {
483
- const result: CollectionSyncState = {
621
+ async getCollectionSyncState(collectionId: string): Promise<SpaceSyncState> {
622
+ const result: SpaceSyncState = {
484
623
  peers: [],
485
624
  };
486
625
 
@@ -493,13 +632,16 @@ export class AutomergeHost extends Resource {
493
632
 
494
633
  for (const [peerId, state] of remoteState) {
495
634
  const diff = diffCollectionState(localState, state);
496
- result.peers.push({
635
+ result.peers!.push({
497
636
  peerId,
498
637
  missingOnRemote: diff.missingOnRemote.length,
499
638
  missingOnLocal: diff.missingOnLocal.length,
500
639
  differentDocuments: diff.different.length,
501
- localDocumentCount: Object.keys(localState.documents).length,
502
- remoteDocumentCount: Object.keys(state.documents).length,
640
+ localDocumentCount: Object.entries(localState.documents).filter(([_, heads]) => heads.length > 0).length,
641
+ remoteDocumentCount: Object.entries(state.documents).filter(([_, heads]) => heads.length > 0).length,
642
+
643
+ totalDocumentCount: new Set([...Object.keys(localState.documents), ...Object.keys(state.documents)]).size,
644
+ unsyncedDocumentCount: new Set([...diff.missingOnLocal, ...diff.missingOnRemote, ...diff.different]).size,
503
645
  });
504
646
  }
505
647
 
@@ -515,6 +657,16 @@ export class AutomergeHost extends Resource {
515
657
  heads.map((heads, index) => [documentIds[index], heads ?? []]),
516
658
  );
517
659
  this._collectionSynchronizer.setLocalCollectionState(collectionId, { documents });
660
+
661
+ // Proactively push our updated local state to peers that are interested in this collection.
662
+ // This reduces reliance on the next periodic query and prevents replication stalls in fast paths
663
+ // where the remote queries before our local state is ready.
664
+ const interestedPeers = this._echoNetworkAdapter.getPeersInterestedInCollection(collectionId);
665
+ if (interestedPeers.length > 0) {
666
+ for (const peerId of interestedPeers) {
667
+ this._sendCollectionState(collectionId, peerId, { documents });
668
+ }
669
+ }
518
670
  }
519
671
 
520
672
  async clearLocalCollectionState(collectionId: string): Promise<void> {
@@ -546,6 +698,11 @@ export class AutomergeHost extends Resource {
546
698
  }
547
699
 
548
700
  private _onRemoteCollectionStateUpdated(collectionId: string, peerId: PeerId): void {
701
+ this._collectionsToSync.add({ collectionId, peerId });
702
+ this._syncTask?.schedule();
703
+ }
704
+
705
+ private async _handleCollectionSync(collectionId: string, peerId: PeerId) {
549
706
  const localState = this._collectionSynchronizer.getLocalCollectionState(collectionId);
550
707
  const remoteState = this._collectionSynchronizer.getRemoteCollectionStates(collectionId).get(peerId);
551
708
 
@@ -554,36 +711,184 @@ export class AutomergeHost extends Resource {
554
711
  }
555
712
 
556
713
  const { different, missingOnLocal, missingOnRemote } = diffCollectionState(localState, remoteState);
557
- const toReplicate = [...missingOnLocal, ...missingOnRemote, ...different];
558
714
 
559
- if (toReplicate.length === 0) {
715
+ if (different.length === 0 && missingOnLocal.length === 0 && missingOnRemote.length === 0) {
716
+ return;
717
+ }
718
+
719
+ const toReplicateWithoutBatching = [...different];
720
+ const bundleSyncEnabled = this._echoNetworkAdapter.bundleSyncEnabledForPeer(peerId);
721
+ if (bundleSyncEnabled && missingOnRemote.length >= BUNDLE_SYNC_THRESHOLD) {
722
+ log('pushing bundle', { amount: missingOnRemote.length });
723
+ const { syncInteractively } = await this._pushInBundles(peerId, missingOnRemote);
724
+ toReplicateWithoutBatching.push(...syncInteractively);
725
+ } else {
726
+ log.verbose('failed to push bundle, replicating interactively', {
727
+ collectionId,
728
+ peerId,
729
+ amount: missingOnRemote.length,
730
+ });
731
+ toReplicateWithoutBatching.push(...missingOnRemote);
732
+ }
733
+ if (bundleSyncEnabled && missingOnLocal.length >= BUNDLE_SYNC_THRESHOLD) {
734
+ log('pulling bundle', { amount: missingOnLocal.length });
735
+ const { syncInteractively } = await this._pullInBundles(peerId, missingOnLocal);
736
+ toReplicateWithoutBatching.push(...syncInteractively);
737
+ } else {
738
+ log.verbose('failed to pull bundle, replicating interactively', {
739
+ collectionId,
740
+ peerId,
741
+ amount: missingOnLocal.length,
742
+ });
743
+ toReplicateWithoutBatching.push(...missingOnLocal);
744
+ }
745
+
746
+ if (toReplicateWithoutBatching.length === 0) {
560
747
  return;
561
748
  }
562
749
 
563
750
  log('replicating documents after collection sync', {
564
751
  collectionId,
565
752
  peerId,
566
- toReplicate,
567
- count: toReplicate.length,
753
+ toReplicateWithoutBatching,
754
+ count: toReplicateWithoutBatching.length,
568
755
  });
569
756
 
570
757
  // Load the documents so they will start syncing.
571
- for (const documentId of toReplicate) {
758
+ for (const documentId of toReplicateWithoutBatching) {
759
+ // Unless we track the document in "to sync" list, it will not be advertised.
760
+ this._documentsToSync!.add(documentId);
572
761
  this._repo.findWithProgress(documentId);
573
762
  }
763
+ this._sharePolicyChangedTask!.schedule();
574
764
  }
575
765
 
576
- private _onHeadsChanged(documentId: DocumentId, heads: Heads): void {
766
+ // TODO(mykola): Add retries of batches https://gist.github.com/mykola-vrmchk/fde270259e9209fcbf1331e5abbf12cf
767
+ private async _pushInBundles(
768
+ peerId: PeerId,
769
+ documentIds: DocumentId[],
770
+ ): Promise<{ syncInteractively: DocumentId[] }> {
771
+ const documentsToPush = [...documentIds];
772
+ const syncInteractively: DocumentId[] = [];
773
+
774
+ // Push bundles in parallel with BUNDLE_SYNC_CONCURRENCY max concurrent tasks.
775
+ while (documentsToPush.length > 0) {
776
+ await Promise.all(
777
+ range(BUNDLE_SYNC_CONCURRENCY).map(async () => {
778
+ const bundle = documentsToPush.splice(0, BUNDLE_SIZE);
779
+ if (bundle.length === 0) {
780
+ return;
781
+ }
782
+ await this._pushBundle(peerId, bundle).catch((err) => {
783
+ log.warn('failed to push bundle, replicating interactively', { peerId, bundle, err });
784
+ syncInteractively.push(...bundle);
785
+ });
786
+ }),
787
+ );
788
+ }
789
+
790
+ return { syncInteractively };
791
+ }
792
+
793
+ private async _pushBundle(peerId: PeerId, documentIds: DocumentId[]): Promise<void> {
794
+ if (this._ctx.disposed) {
795
+ return;
796
+ }
797
+
798
+ const docs = documentIds.map((documentId) => {
799
+ const handle = this._repo.handles[documentId];
800
+ if (!handle || !handle.isReady()) {
801
+ log.warn('document not ready, skipping', { documentId });
802
+ return;
803
+ }
804
+ const doc = handle.doc();
805
+ if (!doc) {
806
+ log.warn('document not available, skipping', { documentId });
807
+ return;
808
+ }
809
+ return {
810
+ documentId,
811
+ data: save(doc),
812
+ heads: getHeads(doc),
813
+ };
814
+ });
815
+
816
+ await this._echoNetworkAdapter.pushBundle(peerId, docs.filter(isNonNullable));
817
+ }
818
+
819
+ private async _pullInBundles(
820
+ peerId: PeerId,
821
+ documentIds: DocumentId[],
822
+ ): Promise<{ syncInteractively: DocumentId[] }> {
823
+ const documentsToPull = [...documentIds];
824
+ const syncInteractively: DocumentId[] = [];
825
+ const docsToImport: Record<DocumentId, Uint8Array> = {};
826
+
827
+ // Pull bundles in parallel with BUNDLE_SYNC_CONCURRENCY max concurrent tasks.
828
+ while (documentsToPull.length > 0) {
829
+ await Promise.all(
830
+ range(BUNDLE_SYNC_CONCURRENCY).map(async () => {
831
+ const bundle = documentsToPull.splice(0, BUNDLE_SIZE);
832
+ if (bundle.length === 0) {
833
+ return;
834
+ }
835
+ const result = await this._pullBundle(peerId, bundle).catch((err) => {
836
+ log.warn('failed to pull bundle, replicating interactively', { peerId, bundle, err });
837
+ syncInteractively.push(...bundle);
838
+ });
839
+ if (result) {
840
+ Object.assign(docsToImport, result.docsToImport);
841
+ }
842
+ }),
843
+ );
844
+ }
845
+
846
+ for (const [documentId, data] of Object.entries(docsToImport)) {
847
+ this._repo.import(data, { docId: documentId as DocumentId });
848
+ }
849
+ await this._repo.flush(Object.keys(docsToImport) as DocumentId[]);
850
+
851
+ return { syncInteractively };
852
+ }
853
+
854
+ private async _pullBundle(
855
+ peerId: PeerId,
856
+ documentIds: DocumentId[],
857
+ ): Promise<{ docsToImport: Record<DocumentId, Uint8Array> } | undefined> {
858
+ if (this._ctx.disposed) {
859
+ return;
860
+ }
861
+ // NOTE: We are expecting that documents that are being pulled are not present locally, so we are pulling all changes.
862
+ const docHeads = Object.fromEntries(documentIds.map((documentId) => [documentId, []]));
863
+ const bundle = await this._echoNetworkAdapter.pullBundle(peerId, docHeads);
864
+ return { docsToImport: bundle };
865
+ }
866
+
867
+ private _onHeadsChanged(docHeads: [DocumentId, Heads][]): void {
577
868
  const collectionsChanged = new Set<CollectionId>();
869
+
578
870
  for (const collectionId of this._collectionSynchronizer.getRegisteredCollectionIds()) {
579
871
  const state = this._collectionSynchronizer.getLocalCollectionState(collectionId);
580
- if (state?.documents[documentId]) {
581
- const newState = structuredClone(state);
582
- newState.documents[documentId] = heads;
872
+ if (!state) {
873
+ continue;
874
+ }
875
+ let newState: CollectionState | undefined;
876
+
877
+ for (const [documentId, heads] of docHeads) {
878
+ if (documentId in state.documents) {
879
+ if (!newState) {
880
+ newState = structuredClone(state);
881
+ }
882
+ newState.documents[documentId] = heads;
883
+ }
884
+ }
885
+
886
+ if (newState) {
583
887
  this._collectionSynchronizer.setLocalCollectionState(collectionId, newState);
584
888
  collectionsChanged.add(collectionId as CollectionId);
585
889
  }
586
890
  }
891
+
587
892
  for (const collectionId of collectionsChanged) {
588
893
  this.collectionStateUpdated.emit({ collectionId });
589
894
  }
@@ -619,35 +924,3 @@ const decodeCollectionState = (state: unknown): CollectionState => {
619
924
  const encodeCollectionState = (state: CollectionState): unknown => {
620
925
  return state;
621
926
  };
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
- };