@dxos/echo-pipeline 0.8.3 → 0.8.4-main.03d5cd7b56

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