@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.
Files changed (244) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/neutral/chunk-46DCRDA7.mjs +271 -0
  4. package/dist/lib/neutral/chunk-46DCRDA7.mjs.map +7 -0
  5. package/dist/lib/{browser/chunk-35I6ERLG.mjs → neutral/chunk-LR7UFVQX.mjs} +313 -709
  6. package/dist/lib/neutral/chunk-LR7UFVQX.mjs.map +7 -0
  7. package/dist/lib/{browser → neutral}/filter/index.mjs +3 -2
  8. package/dist/lib/neutral/index.mjs +7332 -0
  9. package/dist/lib/neutral/index.mjs.map +7 -0
  10. package/dist/lib/neutral/meta.json +1 -0
  11. package/dist/lib/{browser → neutral}/testing/index.mjs +90 -311
  12. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  13. package/dist/types/src/automerge/automerge-host-subduction.test.d.ts +2 -0
  14. package/dist/types/src/automerge/automerge-host-subduction.test.d.ts.map +1 -0
  15. package/dist/types/src/automerge/automerge-host.d.ts +119 -54
  16. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  17. package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts +2 -0
  18. package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts.map +1 -0
  19. package/dist/types/src/automerge/automerge-repo.test.d.ts +1 -1
  20. package/dist/types/src/automerge/automerge-repo.test.d.ts.map +1 -1
  21. package/dist/types/src/automerge/automerge-subduction.test.d.ts +2 -0
  22. package/dist/types/src/automerge/automerge-subduction.test.d.ts.map +1 -0
  23. package/dist/types/src/automerge/collection-synchronizer.d.ts +25 -4
  24. package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -1
  25. package/dist/types/src/automerge/echo-data-monitor.d.ts.map +1 -1
  26. package/dist/types/src/automerge/echo-network-adapter.d.ts +23 -10
  27. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
  28. package/dist/types/src/automerge/echo-replicator.d.ts +46 -15
  29. package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
  30. package/dist/types/src/automerge/errors.d.ts +35 -0
  31. package/dist/types/src/automerge/errors.d.ts.map +1 -0
  32. package/dist/types/src/automerge/handle-state.d.ts +52 -0
  33. package/dist/types/src/automerge/handle-state.d.ts.map +1 -0
  34. package/dist/types/src/automerge/heads-store.d.ts +9 -2
  35. package/dist/types/src/automerge/heads-store.d.ts.map +1 -1
  36. package/dist/types/src/automerge/index.d.ts +3 -1
  37. package/dist/types/src/automerge/index.d.ts.map +1 -1
  38. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +11 -5
  39. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
  40. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +9 -8
  41. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
  42. package/dist/types/src/automerge/mesh-echo-replicator.d.ts +6 -5
  43. package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
  44. package/dist/types/src/automerge/network-protocol.d.ts.map +1 -1
  45. package/dist/types/src/automerge/space-collection.d.ts.map +1 -1
  46. package/dist/types/src/common/codec.d.ts +1 -1
  47. package/dist/types/src/common/codec.d.ts.map +1 -1
  48. package/dist/types/src/common/feeds.d.ts.map +1 -1
  49. package/dist/types/src/common/space-id.d.ts.map +1 -1
  50. package/dist/types/src/db-host/automerge-data-source.d.ts +35 -0
  51. package/dist/types/src/db-host/automerge-data-source.d.ts.map +1 -0
  52. package/dist/types/src/db-host/automerge-data-source.test.d.ts +2 -0
  53. package/dist/types/src/db-host/automerge-data-source.test.d.ts.map +1 -0
  54. package/dist/types/src/db-host/automerge-metrics.d.ts.map +1 -1
  55. package/dist/types/src/db-host/data-service.d.ts +13 -7
  56. package/dist/types/src/db-host/data-service.d.ts.map +1 -1
  57. package/dist/types/src/db-host/database-root.d.ts +9 -3
  58. package/dist/types/src/db-host/database-root.d.ts.map +1 -1
  59. package/dist/types/src/db-host/documents-synchronizer.d.ts +30 -8
  60. package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
  61. package/dist/types/src/db-host/echo-host.d.ts +74 -28
  62. package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
  63. package/dist/types/src/db-host/index.d.ts +2 -0
  64. package/dist/types/src/db-host/index.d.ts.map +1 -1
  65. package/dist/types/src/db-host/invalidation-hint.d.ts +19 -0
  66. package/dist/types/src/db-host/invalidation-hint.d.ts.map +1 -0
  67. package/dist/types/src/db-host/local-queue-service.d.ts +19 -0
  68. package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
  69. package/dist/types/src/db-host/query-invalidation.test.d.ts +2 -0
  70. package/dist/types/src/db-host/query-invalidation.test.d.ts.map +1 -0
  71. package/dist/types/src/db-host/query-service.d.ts +19 -11
  72. package/dist/types/src/db-host/query-service.d.ts.map +1 -1
  73. package/dist/types/src/db-host/queue-data-source.d.ts +38 -0
  74. package/dist/types/src/db-host/queue-data-source.d.ts.map +1 -0
  75. package/dist/types/src/db-host/queue-service.test.d.ts +2 -0
  76. package/dist/types/src/db-host/queue-service.test.d.ts.map +1 -0
  77. package/dist/types/src/db-host/space-state-manager.d.ts +4 -3
  78. package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
  79. package/dist/types/src/db-host/stub.d.ts +11 -0
  80. package/dist/types/src/db-host/stub.d.ts.map +1 -0
  81. package/dist/types/src/edge/echo-edge-replicator.d.ts +10 -7
  82. package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -1
  83. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts +39 -0
  84. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts.map +1 -0
  85. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts +2 -0
  86. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts.map +1 -0
  87. package/dist/types/src/edge/index.d.ts +1 -0
  88. package/dist/types/src/edge/index.d.ts.map +1 -1
  89. package/dist/types/src/edge/inflight-request-limiter.d.ts.map +1 -1
  90. package/dist/types/src/filter/filter-match.d.ts +4 -1
  91. package/dist/types/src/filter/filter-match.d.ts.map +1 -1
  92. package/dist/types/src/metadata/metadata-store.d.ts +1 -1
  93. package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
  94. package/dist/types/src/pipeline/message-selector.d.ts.map +1 -1
  95. package/dist/types/src/pipeline/pipeline.d.ts +3 -3
  96. package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
  97. package/dist/types/src/pipeline/timeframe-clock.d.ts.map +1 -1
  98. package/dist/types/src/query/errors.d.ts +22 -10
  99. package/dist/types/src/query/errors.d.ts.map +1 -1
  100. package/dist/types/src/query/plan.d.ts +66 -8
  101. package/dist/types/src/query/plan.d.ts.map +1 -1
  102. package/dist/types/src/query/query-executor.d.ts +66 -11
  103. package/dist/types/src/query/query-executor.d.ts.map +1 -1
  104. package/dist/types/src/query/query-planner.d.ts +12 -1
  105. package/dist/types/src/query/query-planner.d.ts.map +1 -1
  106. package/dist/types/src/space/admission-discovery-extension.d.ts.map +1 -1
  107. package/dist/types/src/space/auth.d.ts +3 -3
  108. package/dist/types/src/space/auth.d.ts.map +1 -1
  109. package/dist/types/src/space/control-pipeline.d.ts +5 -4
  110. package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
  111. package/dist/types/src/space/space-manager.d.ts +8 -8
  112. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  113. package/dist/types/src/space/space-protocol.d.ts +10 -9
  114. package/dist/types/src/space/space-protocol.d.ts.map +1 -1
  115. package/dist/types/src/space/space.d.ts +6 -6
  116. package/dist/types/src/space/space.d.ts.map +1 -1
  117. package/dist/types/src/testing/change-metadata.d.ts.map +1 -1
  118. package/dist/types/src/testing/index.d.ts +0 -2
  119. package/dist/types/src/testing/index.d.ts.map +1 -1
  120. package/dist/types/src/testing/test-agent-builder.d.ts +2 -2
  121. package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
  122. package/dist/types/src/testing/test-feed-builder.d.ts.map +1 -1
  123. package/dist/types/src/testing/test-network-adapter.d.ts +1 -1
  124. package/dist/types/src/testing/test-network-adapter.d.ts.map +1 -1
  125. package/dist/types/src/testing/test-replicator.d.ts +14 -13
  126. package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
  127. package/dist/types/src/util.d.ts +1 -1
  128. package/dist/types/src/util.d.ts.map +1 -1
  129. package/dist/types/tsconfig.tsbuildinfo +1 -1
  130. package/package.json +59 -55
  131. package/src/automerge/automerge-host-subduction.test.ts +263 -0
  132. package/src/automerge/automerge-host.test.ts +162 -39
  133. package/src/automerge/automerge-host.ts +679 -201
  134. package/src/automerge/automerge-repo-subduction.test.ts +1054 -0
  135. package/src/automerge/automerge-repo.test.ts +349 -91
  136. package/src/automerge/automerge-subduction.test.ts +330 -0
  137. package/src/automerge/collection-synchronizer.test.ts +98 -17
  138. package/src/automerge/collection-synchronizer.ts +141 -37
  139. package/src/automerge/echo-data-monitor.ts +1 -1
  140. package/src/automerge/echo-network-adapter.test.ts +5 -4
  141. package/src/automerge/echo-network-adapter.ts +76 -31
  142. package/src/automerge/echo-replicator.ts +50 -15
  143. package/src/automerge/errors.ts +10 -0
  144. package/src/automerge/handle-state.ts +62 -0
  145. package/src/automerge/heads-store.ts +42 -3
  146. package/src/automerge/index.ts +3 -1
  147. package/src/automerge/leveldb-storage-adapter.ts +77 -11
  148. package/src/automerge/mesh-echo-replicator-connection.ts +12 -8
  149. package/src/automerge/mesh-echo-replicator.ts +15 -11
  150. package/src/automerge/storage-adapter.test.ts +1 -1
  151. package/src/common/space-id.ts +1 -1
  152. package/src/db-host/automerge-data-source.test.ts +299 -0
  153. package/src/db-host/automerge-data-source.ts +187 -0
  154. package/src/db-host/data-service.ts +35 -26
  155. package/src/db-host/database-root.ts +20 -8
  156. package/src/db-host/documents-synchronizer.test.ts +145 -14
  157. package/src/db-host/documents-synchronizer.ts +163 -51
  158. package/src/db-host/echo-host.ts +384 -99
  159. package/src/db-host/index.ts +2 -0
  160. package/src/db-host/invalidation-hint.ts +50 -0
  161. package/src/db-host/local-queue-service.ts +127 -0
  162. package/src/db-host/query-invalidation.test.ts +270 -0
  163. package/src/db-host/query-service.ts +111 -106
  164. package/src/db-host/queue-data-source.ts +160 -0
  165. package/src/db-host/queue-service.test.ts +145 -0
  166. package/src/db-host/space-state-manager.ts +12 -10
  167. package/src/db-host/stub.ts +33 -0
  168. package/src/edge/echo-edge-replicator.test.ts +12 -10
  169. package/src/edge/echo-edge-replicator.ts +144 -58
  170. package/src/edge/echo-edge-subduction-replicator.test.ts +158 -0
  171. package/src/edge/echo-edge-subduction-replicator.ts +377 -0
  172. package/src/edge/index.ts +1 -0
  173. package/src/filter/filter-match.test.ts +68 -32
  174. package/src/filter/filter-match.ts +230 -21
  175. package/src/metadata/metadata-store.ts +11 -8
  176. package/src/pipeline/pipeline-stress.test.ts +1 -1
  177. package/src/pipeline/pipeline.test.ts +2 -2
  178. package/src/pipeline/pipeline.ts +9 -7
  179. package/src/query/errors.ts +3 -1
  180. package/src/query/plan.ts +84 -9
  181. package/src/query/query-executor.ts +1197 -165
  182. package/src/query/query-planner.test.ts +993 -81
  183. package/src/query/query-planner.ts +503 -27
  184. package/src/space/admission-discovery-extension.ts +3 -3
  185. package/src/space/auth.ts +6 -6
  186. package/src/space/control-pipeline.test.ts +5 -4
  187. package/src/space/control-pipeline.ts +16 -13
  188. package/src/space/space-manager.browser.test.ts +1 -1
  189. package/src/space/space-manager.ts +16 -20
  190. package/src/space/space-protocol.browser.test.ts +15 -14
  191. package/src/space/space-protocol.test.ts +15 -14
  192. package/src/space/space-protocol.ts +22 -18
  193. package/src/space/space.test.ts +3 -3
  194. package/src/space/space.ts +11 -11
  195. package/src/testing/index.ts +0 -2
  196. package/src/testing/test-agent-builder.ts +3 -3
  197. package/src/testing/test-network-adapter.ts +24 -3
  198. package/src/testing/test-replicator.ts +19 -20
  199. package/src/util.ts +7 -3
  200. package/dist/lib/browser/chunk-35I6ERLG.mjs.map +0 -7
  201. package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
  202. package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
  203. package/dist/lib/browser/chunk-TQJTKNMS.mjs +0 -126
  204. package/dist/lib/browser/chunk-TQJTKNMS.mjs.map +0 -7
  205. package/dist/lib/browser/index.mjs +0 -4743
  206. package/dist/lib/browser/index.mjs.map +0 -7
  207. package/dist/lib/browser/meta.json +0 -1
  208. package/dist/lib/browser/testing/index.mjs.map +0 -7
  209. package/dist/lib/node/chunk-HOPOFWAL.cjs +0 -147
  210. package/dist/lib/node/chunk-HOPOFWAL.cjs.map +0 -7
  211. package/dist/lib/node/chunk-JXX6LF5U.cjs +0 -2084
  212. package/dist/lib/node/chunk-JXX6LF5U.cjs.map +0 -7
  213. package/dist/lib/node/chunk-Q7SFCCGT.cjs +0 -33
  214. package/dist/lib/node/chunk-Q7SFCCGT.cjs.map +0 -7
  215. package/dist/lib/node/filter/index.cjs +0 -32
  216. package/dist/lib/node/filter/index.cjs.map +0 -7
  217. package/dist/lib/node/index.cjs +0 -4699
  218. package/dist/lib/node/index.cjs.map +0 -7
  219. package/dist/lib/node/meta.json +0 -1
  220. package/dist/lib/node/testing/index.cjs +0 -753
  221. package/dist/lib/node/testing/index.cjs.map +0 -7
  222. package/dist/lib/node-esm/chunk-5BHLPT24.mjs +0 -2067
  223. package/dist/lib/node-esm/chunk-5BHLPT24.mjs.map +0 -7
  224. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  225. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  226. package/dist/lib/node-esm/chunk-RVK35BS7.mjs +0 -126
  227. package/dist/lib/node-esm/chunk-RVK35BS7.mjs.map +0 -7
  228. package/dist/lib/node-esm/filter/index.mjs +0 -11
  229. package/dist/lib/node-esm/filter/index.mjs.map +0 -7
  230. package/dist/lib/node-esm/index.mjs +0 -4743
  231. package/dist/lib/node-esm/index.mjs.map +0 -7
  232. package/dist/lib/node-esm/meta.json +0 -1
  233. package/dist/lib/node-esm/testing/index.mjs +0 -742
  234. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  235. package/dist/types/src/db-host/documents-iterator.d.ts +0 -7
  236. package/dist/types/src/db-host/documents-iterator.d.ts.map +0 -1
  237. package/dist/types/src/testing/test-data.d.ts +0 -18
  238. package/dist/types/src/testing/test-data.d.ts.map +0 -1
  239. package/dist/types/src/testing/test-schema.d.ts +0 -37
  240. package/dist/types/src/testing/test-schema.d.ts.map +0 -1
  241. package/src/db-host/documents-iterator.ts +0 -74
  242. package/src/testing/test-data.ts +0 -127
  243. package/src/testing/test-schema.ts +0 -53
  244. /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
- type HandleState,
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, type Lifecycle } from '@dxos/context';
31
- import { DatabaseDirectory, type CollectionId } from '@dxos/echo-protocol';
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 { objectPointerCodec } from '@dxos/protocols';
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, type CollectionState } from './collection-synchronizer';
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 EchoReplicator, type RemoteDocumentExistenceCheckParams } from './echo-replicator';
47
+ import { type AutomergeReplicator, type RemoteDocumentExistenceCheckProps } from './echo-replicator';
48
+ import { getHandleState } from './handle-state';
46
49
  import { HeadsStore } from './heads-store';
47
- import { LevelDBStorageAdapter, type BeforeSaveParams } from './leveldb-storage-adapter';
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
- export type AutomergeHostParams = {
54
- db: LevelDB;
56
+ const SUBDUCTION_SERVICE_NAME = 'dxos-subduction';
55
57
 
56
- indexMetadataStore: IndexMetadataStore;
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
- export const FIND_PARAMS = {
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
- }: AutomergeHostParams) {
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
- // Construct the automerge repo.
149
- this._repo = new Repo({
150
- peerId: this._peerId as PeerId,
151
- sharePolicy: this._sharePolicy.bind(this),
152
- storage: this._storage,
153
- network: [
154
- // Upstream swarm.
155
- this._echoNetworkAdapter,
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
- // We use collection lookups during share policy check, so we might need to update share policy for the new doc
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
- await this._collectionSynchronizer.close();
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
- await this._ctx.dispose();
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
- async addReplicator(replicator: EchoReplicator): Promise<void> {
212
- await this._echoNetworkAdapter.addReplicator(replicator);
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: EchoReplicator): Promise<void> {
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
- * Loads the document handle from the repo and waits for it to be ready.
221
- */
222
- async loadDoc<T>(ctx: Context, documentId: AnyDocumentId, opts?: LoadDocOptions): Promise<DocHandle<T>> {
223
- let handle: DocHandle<T> | undefined;
224
- if (typeof documentId === 'string') {
225
- // NOTE: documentId might also be a URL, in which case this lookup will fail.
226
- handle = this._repo.handles[documentId as DocumentId];
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
- if (!handle) {
229
- handle = await this._repo.find(documentId as DocumentId, FIND_PARAMS);
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
- // `whenReady` creates a timeout so we guard it with an if to skip it if the handle is already ready.
233
- if (!handle.isReady()) {
234
- if (!opts?.timeout) {
235
- await cancelWithContext(ctx, handle.whenReady());
236
- } else {
237
- await cancelWithContext(ctx, asyncTimeout(handle.whenReady(), opts.timeout));
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
- return handle;
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(ctx: Context, id: AnyDocumentId): Promise<Uint8Array> {
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
- return this._repo.import(initialValue);
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
- return this._repo.import(save(initialValue as Doc<T>));
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
- return this._repo.create(initialValue);
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, index) => {
293
- const handle = await this.loadDoc<DatabaseDirectory>(Context.default(), entry.documentId as DocumentId);
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
- const handle = await this._repo.find(documentId, FIND_PARAMS);
309
- if (!handle.isReady()) {
310
- log.warn('document is not available locally, skipping', { documentId });
311
- continue; // Handle not available locally.
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
- // 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
- }
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
- if (!documentId) {
332
- return false;
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
- const peerMetadata = this.repo.peerMetadataByPeerId[peerId];
336
- if (isEchoPeerMetadata(peerMetadata)) {
337
- return this._echoNetworkAdapter.shouldAdvertise(peerId, { documentId });
338
- }
603
+ const peerMetadata = this._repo.peerMetadataByPeerId[peerId];
604
+ if (isEchoPeerMetadata(peerMetadata)) {
605
+ return this._echoNetworkAdapter.shouldAdvertise(peerId, { documentId });
606
+ }
339
607
 
340
- return false;
341
- }
608
+ return false;
609
+ },
610
+ };
342
611
 
343
- private async _beforeSave({ path, batch }: BeforeSaveParams): Promise<void> {
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 || !handle.isReady()) {
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
- const peerMetadata = this._repo.peerMetadataByPeerId[peerId];
367
- if (isEchoPeerMetadata(peerMetadata)) {
368
- return this._echoNetworkAdapter.shouldSyncCollection(peerId, { collectionId });
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._indexMetadataStore.notifyMarkedDirty();
647
+ if (!this.isOpen) {
648
+ return undefined;
649
+ }
379
650
 
380
651
  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);
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
- @trace.info({ depth: null })
390
- private _automergePeers(): PeerId[] {
391
- return this._repo.peers;
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: RemoteDocumentExistenceCheckParams): Promise<boolean> {
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.state === 'loading') {
408
- await handle.whenReady();
409
- }
410
- if (handle && handle.isReady() && handle.doc()) {
411
- const spaceKeyHex = DatabaseDirectory.getSpaceKey(handle.doc());
412
- if (spaceKeyHex) {
413
- return PublicKey.from(spaceKeyHex);
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
- * Edge case on the initial space setup.
418
- * A peer is maybe trying to share space root document with us after a successful invitation.
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
- // Note: Sync protocol for client and services ensures that all handles should have all changes.
435
-
436
- const loadedDocuments = documentIds?.filter((documentId): documentId is DocumentId => {
437
- const handle = this._repo.handles[documentId as DocumentId];
438
- return handle && handle.isReady();
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 && handle.isReady() && handle.doc()) {
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<CollectionSyncState> {
483
- const result: CollectionSyncState = {
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.push({
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.keys(localState.documents).length,
502
- remoteDocumentCount: Object.keys(state.documents).length,
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 (toReplicate.length === 0) {
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
- toReplicate,
567
- count: toReplicate.length,
920
+ toReplicateWithoutBatching,
921
+ count: toReplicateWithoutBatching.length,
568
922
  });
569
923
 
570
- // Load the documents so they will start syncing.
571
- for (const documentId of toReplicate) {
572
- this._repo.findWithProgress(documentId);
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 _onHeadsChanged(documentId: DocumentId, heads: Heads): void {
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?.documents[documentId]) {
581
- const newState = structuredClone(state);
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
- await handle.whenReady();
597
- await Event.wrap<DocHandleChangePayload<DatabaseDirectory>>(handle, 'change').waitForCondition(() => {
598
- // Check if unavailable heads became available.
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(handle.doc()!, changeHash)) {
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
- 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;
1129
+ const isDocumentUnavailableError = (error: Error): boolean => {
1130
+ return error.message.includes('unavailable');
653
1131
  };