@dxos/echo-pipeline 0.8.4-main.ae835ea → 0.8.4-main.bc2380dfbc

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 (221) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/{browser/chunk-WJJXJTNS.mjs → neutral/chunk-46DCRDA7.mjs} +44 -13
  4. package/dist/lib/neutral/chunk-46DCRDA7.mjs.map +7 -0
  5. package/dist/lib/{browser/chunk-FR6GJ4S4.mjs → neutral/chunk-LR7UFVQX.mjs} +145 -618
  6. package/dist/lib/neutral/chunk-LR7UFVQX.mjs.map +7 -0
  7. package/dist/lib/{browser → neutral}/filter/index.mjs +1 -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 +49 -292
  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 +105 -27
  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 +22 -2
  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 +17 -11
  27. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
  28. package/dist/types/src/automerge/echo-replicator.d.ts +28 -16
  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 +2 -0
  37. package/dist/types/src/automerge/index.d.ts.map +1 -1
  38. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +10 -4
  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 +8 -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/feeds.d.ts.map +1 -1
  47. package/dist/types/src/common/space-id.d.ts.map +1 -1
  48. package/dist/types/src/db-host/automerge-data-source.d.ts +35 -0
  49. package/dist/types/src/db-host/automerge-data-source.d.ts.map +1 -0
  50. package/dist/types/src/db-host/automerge-data-source.test.d.ts +2 -0
  51. package/dist/types/src/db-host/automerge-data-source.test.d.ts.map +1 -0
  52. package/dist/types/src/db-host/automerge-metrics.d.ts.map +1 -1
  53. package/dist/types/src/db-host/data-service.d.ts +12 -6
  54. package/dist/types/src/db-host/data-service.d.ts.map +1 -1
  55. package/dist/types/src/db-host/database-root.d.ts +9 -3
  56. package/dist/types/src/db-host/database-root.d.ts.map +1 -1
  57. package/dist/types/src/db-host/documents-synchronizer.d.ts +30 -8
  58. package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
  59. package/dist/types/src/db-host/echo-host.d.ts +74 -28
  60. package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
  61. package/dist/types/src/db-host/index.d.ts +2 -0
  62. package/dist/types/src/db-host/index.d.ts.map +1 -1
  63. package/dist/types/src/db-host/invalidation-hint.d.ts +19 -0
  64. package/dist/types/src/db-host/invalidation-hint.d.ts.map +1 -0
  65. package/dist/types/src/db-host/local-queue-service.d.ts +19 -0
  66. package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
  67. package/dist/types/src/db-host/query-invalidation.test.d.ts +2 -0
  68. package/dist/types/src/db-host/query-invalidation.test.d.ts.map +1 -0
  69. package/dist/types/src/db-host/query-service.d.ts +18 -10
  70. package/dist/types/src/db-host/query-service.d.ts.map +1 -1
  71. package/dist/types/src/db-host/queue-data-source.d.ts +38 -0
  72. package/dist/types/src/db-host/queue-data-source.d.ts.map +1 -0
  73. package/dist/types/src/db-host/queue-service.test.d.ts +2 -0
  74. package/dist/types/src/db-host/queue-service.test.d.ts.map +1 -0
  75. package/dist/types/src/db-host/space-state-manager.d.ts +3 -2
  76. package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
  77. package/dist/types/src/db-host/stub.d.ts +11 -0
  78. package/dist/types/src/db-host/stub.d.ts.map +1 -0
  79. package/dist/types/src/edge/echo-edge-replicator.d.ts +7 -6
  80. package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -1
  81. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts +39 -0
  82. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts.map +1 -0
  83. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts +2 -0
  84. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts.map +1 -0
  85. package/dist/types/src/edge/index.d.ts +1 -0
  86. package/dist/types/src/edge/index.d.ts.map +1 -1
  87. package/dist/types/src/edge/inflight-request-limiter.d.ts.map +1 -1
  88. package/dist/types/src/filter/filter-match.d.ts +1 -1
  89. package/dist/types/src/filter/filter-match.d.ts.map +1 -1
  90. package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
  91. package/dist/types/src/pipeline/message-selector.d.ts.map +1 -1
  92. package/dist/types/src/pipeline/pipeline.d.ts +2 -2
  93. package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
  94. package/dist/types/src/pipeline/timeframe-clock.d.ts.map +1 -1
  95. package/dist/types/src/query/errors.d.ts +16 -20
  96. package/dist/types/src/query/errors.d.ts.map +1 -1
  97. package/dist/types/src/query/plan.d.ts +59 -8
  98. package/dist/types/src/query/plan.d.ts.map +1 -1
  99. package/dist/types/src/query/query-executor.d.ts +55 -12
  100. package/dist/types/src/query/query-executor.d.ts.map +1 -1
  101. package/dist/types/src/query/query-planner.d.ts +10 -1
  102. package/dist/types/src/query/query-planner.d.ts.map +1 -1
  103. package/dist/types/src/space/admission-discovery-extension.d.ts.map +1 -1
  104. package/dist/types/src/space/auth.d.ts +3 -3
  105. package/dist/types/src/space/auth.d.ts.map +1 -1
  106. package/dist/types/src/space/control-pipeline.d.ts +4 -3
  107. package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
  108. package/dist/types/src/space/space-manager.d.ts +7 -7
  109. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  110. package/dist/types/src/space/space-protocol.d.ts +9 -8
  111. package/dist/types/src/space/space-protocol.d.ts.map +1 -1
  112. package/dist/types/src/space/space.d.ts +5 -5
  113. package/dist/types/src/space/space.d.ts.map +1 -1
  114. package/dist/types/src/testing/change-metadata.d.ts.map +1 -1
  115. package/dist/types/src/testing/index.d.ts +0 -2
  116. package/dist/types/src/testing/index.d.ts.map +1 -1
  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 +13 -13
  122. package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
  123. package/dist/types/src/util.d.ts.map +1 -1
  124. package/dist/types/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +56 -54
  126. package/src/automerge/automerge-host-subduction.test.ts +263 -0
  127. package/src/automerge/automerge-host.test.ts +145 -32
  128. package/src/automerge/automerge-host.ts +442 -163
  129. package/src/automerge/automerge-repo-subduction.test.ts +1054 -0
  130. package/src/automerge/automerge-repo.test.ts +284 -77
  131. package/src/automerge/automerge-subduction.test.ts +330 -0
  132. package/src/automerge/collection-synchronizer.test.ts +96 -16
  133. package/src/automerge/collection-synchronizer.ts +125 -26
  134. package/src/automerge/echo-network-adapter.test.ts +2 -1
  135. package/src/automerge/echo-network-adapter.ts +44 -32
  136. package/src/automerge/echo-replicator.ts +30 -16
  137. package/src/automerge/errors.ts +10 -0
  138. package/src/automerge/handle-state.ts +62 -0
  139. package/src/automerge/heads-store.ts +42 -3
  140. package/src/automerge/index.ts +2 -0
  141. package/src/automerge/leveldb-storage-adapter.ts +70 -4
  142. package/src/automerge/mesh-echo-replicator-connection.ts +8 -8
  143. package/src/automerge/mesh-echo-replicator.ts +14 -11
  144. package/src/db-host/automerge-data-source.test.ts +299 -0
  145. package/src/db-host/automerge-data-source.ts +187 -0
  146. package/src/db-host/data-service.ts +27 -10
  147. package/src/db-host/database-root.ts +19 -7
  148. package/src/db-host/documents-synchronizer.test.ts +145 -14
  149. package/src/db-host/documents-synchronizer.ts +158 -59
  150. package/src/db-host/echo-host.ts +376 -90
  151. package/src/db-host/index.ts +2 -0
  152. package/src/db-host/invalidation-hint.ts +50 -0
  153. package/src/db-host/local-queue-service.ts +127 -0
  154. package/src/db-host/query-invalidation.test.ts +270 -0
  155. package/src/db-host/query-service.ts +108 -111
  156. package/src/db-host/queue-data-source.ts +160 -0
  157. package/src/db-host/queue-service.test.ts +145 -0
  158. package/src/db-host/space-state-manager.ts +11 -9
  159. package/src/db-host/stub.ts +33 -0
  160. package/src/edge/echo-edge-replicator.test.ts +9 -9
  161. package/src/edge/echo-edge-replicator.ts +50 -42
  162. package/src/edge/echo-edge-subduction-replicator.test.ts +158 -0
  163. package/src/edge/echo-edge-subduction-replicator.ts +377 -0
  164. package/src/edge/index.ts +1 -0
  165. package/src/filter/filter-match.test.ts +41 -25
  166. package/src/filter/filter-match.ts +75 -20
  167. package/src/metadata/metadata-store.ts +8 -5
  168. package/src/pipeline/pipeline-stress.test.ts +1 -3
  169. package/src/pipeline/pipeline.test.ts +0 -1
  170. package/src/pipeline/pipeline.ts +2 -3
  171. package/src/query/errors.ts +2 -2
  172. package/src/query/plan.ts +73 -9
  173. package/src/query/query-executor.ts +1064 -172
  174. package/src/query/query-planner.test.ts +854 -86
  175. package/src/query/query-planner.ts +444 -38
  176. package/src/space/admission-discovery-extension.ts +1 -1
  177. package/src/space/auth.ts +6 -6
  178. package/src/space/control-pipeline.test.ts +2 -2
  179. package/src/space/control-pipeline.ts +7 -7
  180. package/src/space/space-manager.ts +12 -17
  181. package/src/space/space-protocol.browser.test.ts +13 -12
  182. package/src/space/space-protocol.test.ts +13 -13
  183. package/src/space/space-protocol.ts +16 -15
  184. package/src/space/space.test.ts +2 -2
  185. package/src/space/space.ts +9 -10
  186. package/src/testing/index.ts +0 -2
  187. package/src/testing/test-agent-builder.ts +0 -1
  188. package/src/testing/test-network-adapter.ts +24 -3
  189. package/src/testing/test-replicator.ts +15 -20
  190. package/src/util.ts +7 -3
  191. package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
  192. package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
  193. package/dist/lib/browser/chunk-FR6GJ4S4.mjs.map +0 -7
  194. package/dist/lib/browser/chunk-WJJXJTNS.mjs.map +0 -7
  195. package/dist/lib/browser/index.mjs +0 -5492
  196. package/dist/lib/browser/index.mjs.map +0 -7
  197. package/dist/lib/browser/meta.json +0 -1
  198. package/dist/lib/browser/testing/index.mjs.map +0 -7
  199. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  200. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  201. package/dist/lib/node-esm/chunk-JQCCVDLT.mjs +0 -2144
  202. package/dist/lib/node-esm/chunk-JQCCVDLT.mjs.map +0 -7
  203. package/dist/lib/node-esm/chunk-LEQ77KAT.mjs +0 -240
  204. package/dist/lib/node-esm/chunk-LEQ77KAT.mjs.map +0 -7
  205. package/dist/lib/node-esm/filter/index.mjs +0 -13
  206. package/dist/lib/node-esm/filter/index.mjs.map +0 -7
  207. package/dist/lib/node-esm/index.mjs +0 -5492
  208. package/dist/lib/node-esm/index.mjs.map +0 -7
  209. package/dist/lib/node-esm/meta.json +0 -1
  210. package/dist/lib/node-esm/testing/index.mjs +0 -764
  211. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  212. package/dist/types/src/db-host/documents-iterator.d.ts +0 -7
  213. package/dist/types/src/db-host/documents-iterator.d.ts.map +0 -1
  214. package/dist/types/src/testing/test-data.d.ts +0 -18
  215. package/dist/types/src/testing/test-data.d.ts.map +0 -1
  216. package/dist/types/src/testing/test-schema.d.ts +0 -37
  217. package/dist/types/src/testing/test-schema.d.ts.map +0 -1
  218. package/src/db-host/documents-iterator.ts +0 -74
  219. package/src/testing/test-data.ts +0 -127
  220. package/src/testing/test-schema.ts +0 -53
  221. /package/dist/lib/{browser → neutral}/filter/index.mjs.map +0 -0
@@ -13,11 +13,13 @@ import { log } from '@dxos/log';
13
13
  import { trace } from '@dxos/tracing';
14
14
  import { defaultMap } from '@dxos/util';
15
15
 
16
+ import { PeerNotFoundError } from './errors';
17
+
16
18
  const MIN_QUERY_INTERVAL = 5_000;
17
19
 
18
- const POLL_INTERVAL = 30_000;
20
+ const POLL_INTERVAL = 10_000;
19
21
 
20
- export type CollectionSynchronizerParams = {
22
+ export type CollectionSynchronizerProps = {
21
23
  sendCollectionState: (collectionId: string, peerId: PeerId, state: CollectionState) => void;
22
24
  queryCollectionState: (collectionId: string, peerId: PeerId) => void;
23
25
  shouldSyncCollection: (collectionId: string, peerId: PeerId) => boolean;
@@ -26,11 +28,11 @@ export type CollectionSynchronizerParams = {
26
28
  /**
27
29
  * Implements collection sync protocol.
28
30
  */
29
- @trace.resource()
31
+ @trace.resource({ lifecycle: true })
30
32
  export class CollectionSynchronizer extends Resource {
31
- private readonly _sendCollectionState: CollectionSynchronizerParams['sendCollectionState'];
32
- private readonly _queryCollectionState: CollectionSynchronizerParams['queryCollectionState'];
33
- private readonly _shouldSyncCollection: CollectionSynchronizerParams['shouldSyncCollection'];
33
+ private readonly _sendCollectionState: CollectionSynchronizerProps['sendCollectionState'];
34
+ private readonly _queryCollectionState: CollectionSynchronizerProps['queryCollectionState'];
35
+ private readonly _shouldSyncCollection: CollectionSynchronizerProps['shouldSyncCollection'];
34
36
 
35
37
  /**
36
38
  * CollectionId -> State.
@@ -42,7 +44,7 @@ export class CollectionSynchronizer extends Resource {
42
44
 
43
45
  public readonly remoteStateUpdated = new Event<{ collectionId: string; peerId: PeerId; newDocsAppeared: boolean }>();
44
46
 
45
- constructor(params: CollectionSynchronizerParams) {
47
+ constructor(params: CollectionSynchronizerProps) {
46
48
  super();
47
49
  this._sendCollectionState = params.sendCollectionState;
48
50
  this._queryCollectionState = params.queryCollectionState;
@@ -76,13 +78,29 @@ export class CollectionSynchronizer extends Resource {
76
78
  this._activeCollections.add(collectionId);
77
79
 
78
80
  log('setLocalCollectionState', { collectionId, state });
79
- this._getOrCreatePerCollectionState(collectionId).localState = state;
81
+ const perCollectionState = this._getOrCreatePerCollectionState(collectionId);
82
+ perCollectionState.localState = state;
80
83
 
81
- queueMicrotask(async () => {
82
- if (!this._ctx.disposed && this._activeCollections.has(collectionId)) {
83
- this._refreshInterestedPeers(collectionId);
84
- this.refreshCollection(collectionId);
85
- }
84
+ for (const peerId of this._connectedPeers) {
85
+ this._diffCollectionState(collectionId, peerId);
86
+ }
87
+
88
+ this._scheduleBroadcast(collectionId);
89
+ }
90
+
91
+ /**
92
+ * Coalesce bursts of `setLocalCollectionState` (e.g. from `_onHeadsChanged` during an
93
+ * import) into one broadcast microtask per collection. If another call arrives while
94
+ * the microtask is pending, we just flag the collection dirty — the in-flight run
95
+ * already reads the latest `localState`. If it arrives during the run itself, we
96
+ * schedule exactly one follow-up.
97
+ */
98
+ private _scheduleBroadcast(collectionId: string): void {
99
+ const perCollectionState = this._getOrCreatePerCollectionState(collectionId);
100
+ queueMicrotask(() => {
101
+ this._refreshInterestedPeers(collectionId);
102
+ this._broadcastLocalState(collectionId);
103
+ this.refreshCollection(collectionId);
86
104
  });
87
105
  }
88
106
 
@@ -119,10 +137,14 @@ export class CollectionSynchronizer extends Resource {
119
137
  * Callback when a connection to a peer is established.
120
138
  */
121
139
  onConnectionOpen(peerId: PeerId): void {
122
- const spanId = getSpanName(peerId);
123
- trace.spanStart({
140
+ log('onConnectionOpen', { peerId });
141
+ const spanId = getSpanId(peerId);
142
+ // Browser-timeline-only span; the derived ctx is intentionally discarded because
143
+ // the downstream `_queryCollectionState` hop is user-supplied callback land with
144
+ // no ctx plumbing.
145
+ void trace.spanStart({
124
146
  id: spanId,
125
- methodName: spanId,
147
+ methodName: SYNC_SPAN_METHOD,
126
148
  instance: this,
127
149
  parentCtx: this._ctx,
128
150
  showInBrowserTimeline: true,
@@ -148,21 +170,37 @@ export class CollectionSynchronizer extends Resource {
148
170
  * Callback when a connection to a peer is closed.
149
171
  */
150
172
  onConnectionClosed(peerId: PeerId): void {
173
+ log('onConnectionClosed', { peerId });
174
+
151
175
  this._connectedPeers.delete(peerId);
152
176
 
153
177
  for (const perCollectionState of this._perCollectionStates.values()) {
154
178
  perCollectionState.remoteStates.delete(peerId);
179
+ perCollectionState.interestedPeers.delete(peerId);
180
+ perCollectionState.lastQueried.delete(peerId);
181
+ perCollectionState.lastBroadcast.delete(peerId);
155
182
  }
156
183
  }
157
184
 
158
185
  /**
159
186
  * Callback when a peer queries the state of a collection.
187
+ *
188
+ * If we have no local state yet we silently drop the query; the peer will receive our
189
+ * state via {@link _broadcastLocalState} once `setLocalCollectionState` is called.
160
190
  */
161
191
  onCollectionStateQueried(collectionId: string, peerId: PeerId): void {
162
192
  const perCollectionState = this._getOrCreatePerCollectionState(collectionId);
163
193
 
164
194
  if (perCollectionState.localState) {
165
- this._sendCollectionState(collectionId, peerId, perCollectionState.localState);
195
+ try {
196
+ this._sendCollectionState(collectionId, peerId, perCollectionState.localState);
197
+ } catch (error) {
198
+ if (PeerNotFoundError.is(error)) {
199
+ log('peer not found when sending collection state callback', { error });
200
+ return;
201
+ }
202
+ throw error;
203
+ }
166
204
  }
167
205
  }
168
206
 
@@ -173,24 +211,48 @@ export class CollectionSynchronizer extends Resource {
173
211
  log('onRemoteStateReceived', { collectionId, peerId, state });
174
212
  validateCollectionState(state);
175
213
  const perCollectionState = this._getOrCreatePerCollectionState(collectionId);
176
- const existingState = perCollectionState.remoteStates.get(peerId) ?? { documents: {} };
177
- const diff = diffCollectionState(existingState, state);
178
- const spanId = getSpanName(peerId);
214
+ perCollectionState.remoteStates.set(peerId, state);
215
+ this._diffCollectionState(collectionId, peerId);
216
+ }
217
+
218
+ private _diffCollectionState(collectionId: string, peerId: PeerId) {
219
+ const perCollectionState = this._getOrCreatePerCollectionState(collectionId);
220
+ const remoteState = perCollectionState.remoteStates.get(peerId);
221
+ if (!remoteState) {
222
+ return;
223
+ }
224
+
225
+ log('diffCollectionState', { collectionId, peerId });
226
+ const localState = perCollectionState.localState ?? { documents: {} };
227
+ const diff = diffCollectionState(localState, remoteState);
228
+ const spanId = getSpanId(peerId);
179
229
  if (diff.different.length === 0) {
180
230
  trace.spanEnd(spanId);
181
231
  } else {
182
- trace.spanStart({
232
+ // Browser-timeline-only span; see note in onConnectionOpen.
233
+ void trace.spanStart({
183
234
  id: spanId,
184
- methodName: spanId,
235
+ methodName: SYNC_SPAN_METHOD,
185
236
  instance: this,
186
237
  parentCtx: this._ctx,
187
238
  showInBrowserTimeline: true,
188
239
  attributes: { peerId },
189
240
  });
190
241
  }
191
- if (diff.missingOnLocal.length > 0 || diff.different.length > 0) {
192
- perCollectionState.remoteStates.set(peerId, state);
193
- this.remoteStateUpdated.emit({ peerId, collectionId, newDocsAppeared: diff.missingOnLocal.length > 0 });
242
+ log('diff', {
243
+ localState: localState.documents,
244
+ remoteState: remoteState.documents,
245
+ missingOnLocal: diff.missingOnLocal,
246
+ missingOnRemote: diff.missingOnRemote,
247
+ different: diff.different,
248
+ });
249
+ if (diff.missingOnLocal.length > 0 || diff.different.length > 0 || diff.missingOnRemote.length > 0) {
250
+ log('emit remote state update');
251
+ this.remoteStateUpdated.emit({
252
+ peerId,
253
+ collectionId,
254
+ newDocsAppeared: diff.missingOnLocal.length > 0,
255
+ });
194
256
  }
195
257
  }
196
258
 
@@ -200,9 +262,43 @@ export class CollectionSynchronizer extends Resource {
200
262
  remoteStates: new Map(),
201
263
  interestedPeers: new Set(),
202
264
  lastQueried: new Map(),
265
+ lastBroadcast: new Map(),
203
266
  }));
204
267
  }
205
268
 
269
+ /**
270
+ * Push local state to interested peers whose last-known remote state differs from local
271
+ * (or is unknown), then pull from peers via {@link refreshCollection}.
272
+ *
273
+ * Diff-gating avoids spamming peers that are already in sync; this matters because
274
+ * {@link setLocalCollectionState} is called on every local heads change.
275
+ */
276
+ private _broadcastLocalState(collectionId: string): void {
277
+ if (this._ctx.disposed || !this._activeCollections.has(collectionId)) {
278
+ return;
279
+ }
280
+ const perCollectionState = this._getOrCreatePerCollectionState(collectionId);
281
+ const localState = perCollectionState.localState;
282
+ if (!localState) {
283
+ return;
284
+ }
285
+ for (const peerId of perCollectionState.interestedPeers) {
286
+ const lastBroadcast = perCollectionState.lastBroadcast.get(peerId) ?? 0;
287
+ if (Date.now() - lastBroadcast > MIN_QUERY_INTERVAL) {
288
+ perCollectionState.lastBroadcast.set(peerId, Date.now());
289
+ try {
290
+ this._sendCollectionState(collectionId, peerId, localState);
291
+ } catch (error) {
292
+ if (PeerNotFoundError.is(error)) {
293
+ log('peer not found when broadcasting collection state', { error });
294
+ return;
295
+ }
296
+ throw error;
297
+ }
298
+ }
299
+ }
300
+ }
301
+
206
302
  private _refreshInterestedPeers(collectionId: string): void {
207
303
  for (const peerId of this._connectedPeers) {
208
304
  if (this._shouldSyncCollection(collectionId, peerId)) {
@@ -219,6 +315,7 @@ type PerCollectionState = {
219
315
  remoteStates: Map<PeerId, CollectionState>;
220
316
  interestedPeers: Set<PeerId>;
221
317
  lastQueried: Map<PeerId, number>;
318
+ lastBroadcast: Map<PeerId, number>;
222
319
  };
223
320
 
224
321
  export type CollectionState = {
@@ -275,6 +372,8 @@ const isValidDocumentId = (documentId: DocumentId) => {
275
372
  return typeof documentId === 'string' && !documentId.includes(':');
276
373
  };
277
374
 
278
- const getSpanName = (peerId: PeerId) => {
375
+ const SYNC_SPAN_METHOD = 'syncPeer';
376
+
377
+ const getSpanId = (peerId: PeerId) => {
279
378
  return `collection-sync-${peerId}`;
280
379
  };
@@ -6,6 +6,7 @@ import { type PeerId, cbor } from '@automerge/automerge-repo';
6
6
  import { describe, expect, onTestFinished, test } from 'vitest';
7
7
 
8
8
  import { Trigger, sleep, waitForCondition } from '@dxos/async';
9
+ import { Context } from '@dxos/context';
9
10
  import { invariant } from '@dxos/invariant';
10
11
  import { PublicKey } from '@dxos/keys';
11
12
  import { type SyncMessage } from '@dxos/protocols/proto/dxos/mesh/teleport/automerge';
@@ -113,7 +114,7 @@ describe('EchoNetworkAdapter', () => {
113
114
  onTestFinished(async () => {
114
115
  await adapter.close();
115
116
  });
116
- await adapter.addReplicator(replicator);
117
+ await adapter.addReplicator(Context.default(), replicator);
117
118
  return adapter;
118
119
  };
119
120
 
@@ -11,8 +11,8 @@ import {
11
11
  type PeerMetadata,
12
12
  } from '@automerge/automerge-repo';
13
13
 
14
- import { Trigger, synchronized } from '@dxos/async';
15
- import { LifecycleState } from '@dxos/context';
14
+ import { Event, Trigger, synchronized } from '@dxos/async';
15
+ import { type Context, LifecycleState } from '@dxos/context';
16
16
  import { invariant } from '@dxos/invariant';
17
17
  import { type PublicKey } from '@dxos/keys';
18
18
  import { log } from '@dxos/log';
@@ -20,14 +20,14 @@ import type { AutomergeProtocolMessage } from '@dxos/protocols';
20
20
  import { isNonNullable } from '@dxos/util';
21
21
 
22
22
  import { createIdFromSpaceKey } from '../common/space-id';
23
-
24
23
  import {
25
- type EchoReplicator,
26
- type RemoteDocumentExistenceCheckParams,
27
- type ReplicatorConnection,
28
- type ShouldAdvertiseParams,
29
- type ShouldSyncCollectionParams,
24
+ type AutomergeReplicator,
25
+ type AutomergeReplicatorConnection,
26
+ type RemoteDocumentExistenceCheckProps,
27
+ type ShouldAdvertiseProps,
28
+ type ShouldSyncCollectionProps,
30
29
  } from './echo-replicator';
30
+ import { PeerNotFoundError } from './errors';
31
31
  import {
32
32
  type CollectionQueryMessage,
33
33
  type CollectionStateMessage,
@@ -43,9 +43,9 @@ export interface NetworkDataMonitor {
43
43
  recordMessageSendingFailed(message: Message): void;
44
44
  }
45
45
 
46
- export type EchoNetworkAdapterParams = {
46
+ export type EchoNetworkAdapterProps = {
47
47
  getContainingSpaceForDocument: (documentId: string) => Promise<PublicKey | null>;
48
- isDocumentInRemoteCollection: (params: RemoteDocumentExistenceCheckParams) => Promise<boolean>;
48
+ isDocumentInRemoteCollection: (params: RemoteDocumentExistenceCheckProps) => Promise<boolean>;
49
49
  onCollectionStateQueried: (collectionId: string, peerId: PeerId) => void;
50
50
  onCollectionStateReceived: (collectionId: string, peerId: PeerId, state: unknown) => void;
51
51
  monitor?: NetworkDataMonitor;
@@ -53,16 +53,17 @@ export type EchoNetworkAdapterParams = {
53
53
 
54
54
  type ConnectionEntry = {
55
55
  isOpen: boolean;
56
- connection: ReplicatorConnection;
56
+ connection: AutomergeReplicatorConnection;
57
57
  reader: ReadableStreamDefaultReader<AutomergeProtocolMessage>;
58
58
  writer: WritableStreamDefaultWriter<AutomergeProtocolMessage>;
59
+ requestedDocuments: Set<DocumentId>;
59
60
  };
60
61
 
61
62
  /**
62
- * Manages a set of {@link EchoReplicator} instances.
63
+ * Manages a set of {@link AutomergeReplicator} instances.
63
64
  */
64
65
  export class EchoNetworkAdapter extends NetworkAdapter {
65
- private readonly _replicators = new Set<EchoReplicator>();
66
+ private readonly _replicators = new Set<AutomergeReplicator>();
66
67
  /**
67
68
  * Remote peer id -> connection.
68
69
  */
@@ -71,7 +72,9 @@ export class EchoNetworkAdapter extends NetworkAdapter {
71
72
  private readonly _connected = new Trigger();
72
73
  private readonly _ready = new Trigger();
73
74
 
74
- constructor(private readonly _params: EchoNetworkAdapterParams) {
75
+ public readonly documentRequested = new Event<{ documentId: DocumentId; peerId: PeerId }>();
76
+
77
+ constructor(private readonly _params: EchoNetworkAdapterProps) {
75
78
  super();
76
79
  }
77
80
 
@@ -133,13 +136,13 @@ export class EchoNetworkAdapter extends NetworkAdapter {
133
136
  }
134
137
 
135
138
  @synchronized
136
- async addReplicator(replicator: EchoReplicator): Promise<void> {
139
+ async addReplicator(ctx: Context, replicator: AutomergeReplicator): Promise<void> {
137
140
  invariant(this._lifecycleState === LifecycleState.OPEN);
138
141
  invariant(this.peerId);
139
142
  invariant(!this._replicators.has(replicator));
140
143
 
141
144
  this._replicators.add(replicator);
142
- await replicator.connect({
145
+ await replicator.connect(ctx, {
143
146
  peerId: this.peerId,
144
147
  onConnectionOpen: this._onConnectionOpen.bind(this),
145
148
  onConnectionClosed: this._onConnectionClosed.bind(this),
@@ -154,14 +157,14 @@ export class EchoNetworkAdapter extends NetworkAdapter {
154
157
  }
155
158
 
156
159
  @synchronized
157
- async removeReplicator(replicator: EchoReplicator): Promise<void> {
160
+ async removeReplicator(replicator: AutomergeReplicator): Promise<void> {
158
161
  invariant(this._lifecycleState === LifecycleState.OPEN);
159
162
  invariant(this._replicators.has(replicator));
160
163
  await replicator.disconnect();
161
164
  this._replicators.delete(replicator);
162
165
  }
163
166
 
164
- async shouldAdvertise(peerId: PeerId, params: ShouldAdvertiseParams): Promise<boolean> {
167
+ async shouldAdvertise(peerId: PeerId, params: ShouldAdvertiseProps): Promise<boolean> {
165
168
  const connection = this._connections.get(peerId);
166
169
  if (!connection) {
167
170
  return false;
@@ -170,7 +173,7 @@ export class EchoNetworkAdapter extends NetworkAdapter {
170
173
  return connection.connection.shouldAdvertise(params);
171
174
  }
172
175
 
173
- shouldSyncCollection(peerId: PeerId, params: ShouldSyncCollectionParams): boolean {
176
+ shouldSyncCollection(peerId: PeerId, params: ShouldSyncCollectionProps): boolean {
174
177
  const connection = this._connections.get(peerId);
175
178
  if (!connection) {
176
179
  return false;
@@ -219,26 +222,26 @@ export class EchoNetworkAdapter extends NetworkAdapter {
219
222
  return connection.connection.bundleSyncEnabled;
220
223
  }
221
224
 
222
- async pushBundle(peerId: PeerId, bundle: { documentId: DocumentId; data: Uint8Array; heads: Heads }[]) {
225
+ async pushBundle(ctx: Context, peerId: PeerId, bundle: { documentId: DocumentId; data: Uint8Array; heads: Heads }[]) {
223
226
  const connection = this._connections.get(peerId);
224
227
  if (!connection) {
225
- throw new Error('Connection not found.');
228
+ throw new PeerNotFoundError({ context: { peerId } });
226
229
  }
227
- return connection.connection.pushBundle!(bundle);
230
+ return connection.connection.pushBundle!(ctx, bundle);
228
231
  }
229
232
 
230
- async pullBundle(peerId: PeerId, docHeads: Record<DocumentId, Heads>) {
233
+ async pullBundle(ctx: Context, peerId: PeerId, docHeads: Record<DocumentId, Heads>) {
231
234
  const connection = this._connections.get(peerId);
232
235
  if (!connection) {
233
- throw new Error('Connection not found.');
236
+ throw new PeerNotFoundError({ context: { peerId } });
234
237
  }
235
- return connection.connection.pullBundle!(docHeads);
238
+ return connection.connection.pullBundle!(ctx, docHeads);
236
239
  }
237
240
 
238
241
  private _send(message: Message): void {
239
242
  const connectionEntry = this._connections.get(message.targetId);
240
243
  if (!connectionEntry) {
241
- throw new Error('Connection not found.');
244
+ throw new PeerNotFoundError({ context: { peerId: message.targetId } });
242
245
  }
243
246
 
244
247
  // TODO(dmaretskyi): Find a way to enforce backpressure on AM-repo.
@@ -257,7 +260,7 @@ export class EchoNetworkAdapter extends NetworkAdapter {
257
260
  });
258
261
  }
259
262
 
260
- private _onConnectionOpen(connection: ReplicatorConnection): void {
263
+ private _onConnectionOpen(connection: AutomergeReplicatorConnection): void {
261
264
  log('connection opened', { peerId: connection.peerId });
262
265
  invariant(!this._connections.has(connection.peerId as PeerId));
263
266
  const connectionEntry: ConnectionEntry = {
@@ -265,6 +268,7 @@ export class EchoNetworkAdapter extends NetworkAdapter {
265
268
  connection,
266
269
  reader: connection.readable.getReader(),
267
270
  writer: connection.writable.getWriter(),
271
+ requestedDocuments: new Set(),
268
272
  };
269
273
 
270
274
  this._connections.set(connection.peerId as PeerId, connectionEntry);
@@ -279,7 +283,7 @@ export class EchoNetworkAdapter extends NetworkAdapter {
279
283
  break;
280
284
  }
281
285
 
282
- this._onMessage(value as Message);
286
+ this._onMessage(connectionEntry, value as Message);
283
287
  }
284
288
  } catch (err) {
285
289
  if (connectionEntry.isOpen) {
@@ -293,7 +297,15 @@ export class EchoNetworkAdapter extends NetworkAdapter {
293
297
  this._params.monitor?.recordPeerConnected(connection.peerId);
294
298
  }
295
299
 
296
- private _onMessage(message: Message): void {
300
+ private _onMessage(connectionEntry: ConnectionEntry, message: Message): void {
301
+ const amMessage = message as AutomergeProtocolMessage;
302
+ if (amMessage.type === 'request') {
303
+ this.documentRequested.emit({
304
+ documentId: amMessage.documentId as DocumentId,
305
+ peerId: connectionEntry.connection.peerId as PeerId,
306
+ });
307
+ }
308
+
297
309
  if (isCollectionQueryMessage(message)) {
298
310
  this._params.onCollectionStateQueried(message.collectionId, message.senderId);
299
311
  } else if (isCollectionStateMessage(message)) {
@@ -304,7 +316,7 @@ export class EchoNetworkAdapter extends NetworkAdapter {
304
316
  this._params.monitor?.recordMessageReceived(message);
305
317
  }
306
318
 
307
- private _onConnectionClosed(connection: ReplicatorConnection): void {
319
+ private _onConnectionClosed(connection: AutomergeReplicatorConnection): void {
308
320
  log('connection closed', { peerId: connection.peerId });
309
321
  const entry = this._connections.get(connection.peerId as PeerId);
310
322
  invariant(entry);
@@ -323,7 +335,7 @@ export class EchoNetworkAdapter extends NetworkAdapter {
323
335
  * Trigger doc-synchronizer shared documents set recalculation. Happens on peer-candidate.
324
336
  * TODO(y): replace with a proper API call when sharePolicy update becomes supported by automerge-repo
325
337
  */
326
- private _onConnectionAuthScopeChanged(connection: ReplicatorConnection): void {
338
+ private _onConnectionAuthScopeChanged(connection: AutomergeReplicatorConnection): void {
327
339
  log('Connection auth scope changed', { peerId: connection.peerId });
328
340
  const entry = this._connections.get(connection.peerId as PeerId);
329
341
  invariant(entry);
@@ -331,7 +343,7 @@ export class EchoNetworkAdapter extends NetworkAdapter {
331
343
  this._emitPeerCandidate(connection);
332
344
  }
333
345
 
334
- private _emitPeerCandidate(connection: ReplicatorConnection): void {
346
+ private _emitPeerCandidate(connection: AutomergeReplicatorConnection): void {
335
347
  this.emit('peer-candidate', {
336
348
  peerId: connection.peerId as PeerId,
337
349
  peerMetadata: createEchoPeerMetadata(),
@@ -5,15 +5,16 @@
5
5
  import { type Heads } from '@automerge/automerge';
6
6
  import { type DocumentId } from '@automerge/automerge-repo';
7
7
 
8
+ import { type Context } from '@dxos/context';
8
9
  import { type PublicKey, type SpaceId } from '@dxos/keys';
9
10
  import { type AutomergeProtocolMessage } from '@dxos/protocols';
10
11
 
11
12
  // TODO(burdon): Rename AutomergeReplicator?
12
- export interface EchoReplicator {
13
+ export interface AutomergeReplicator {
13
14
  /**
14
15
  * Called on when replicator is added to EchoHost.
15
16
  */
16
- connect(context: EchoReplicatorContext): Promise<void>;
17
+ connect(ctx: Context, context: AutomergeReplicatorContext): Promise<void>;
17
18
 
18
19
  /**
19
20
  * Called on when replicator is removed from EchoHost.
@@ -21,7 +22,16 @@ export interface EchoReplicator {
21
22
  disconnect(): Promise<void>;
22
23
  }
23
24
 
24
- export interface EchoReplicatorContext {
25
+ /**
26
+ * Replicator with explicit per-space wiring (used by edge replicators that maintain one logical
27
+ * connection per `SpaceId`). `DataSpaceManager` drives these lifecycle hooks on space open/close.
28
+ */
29
+ export interface EdgeAutomergeReplicator extends AutomergeReplicator {
30
+ connectToSpace(ctx: Context, spaceId: SpaceId): Promise<void>;
31
+ disconnectFromSpace(spaceId: SpaceId): Promise<void>;
32
+ }
33
+
34
+ export interface AutomergeReplicatorContext {
25
35
  /**
26
36
  * Our own peer id.
27
37
  */
@@ -32,14 +42,18 @@ export interface EchoReplicatorContext {
32
42
  */
33
43
  getContainingSpaceForDocument(documentId: string): Promise<PublicKey | null>;
34
44
  getContainingSpaceIdForDocument(documentId: string): Promise<SpaceId | null>;
35
- isDocumentInRemoteCollection(params: RemoteDocumentExistenceCheckParams): Promise<boolean>;
36
45
 
37
- onConnectionOpen(connection: ReplicatorConnection): void;
38
- onConnectionClosed(connection: ReplicatorConnection): void;
39
- onConnectionAuthScopeChanged(connection: ReplicatorConnection): void;
46
+ /**
47
+ * Returns false if collection sync hasn't happened yet.
48
+ */
49
+ isDocumentInRemoteCollection(params: RemoteDocumentExistenceCheckProps): Promise<boolean>;
50
+
51
+ onConnectionOpen(connection: AutomergeReplicatorConnection): void;
52
+ onConnectionClosed(connection: AutomergeReplicatorConnection): void;
53
+ onConnectionAuthScopeChanged(connection: AutomergeReplicatorConnection): void;
40
54
  }
41
55
 
42
- export interface ReplicatorConnection {
56
+ export interface AutomergeReplicatorConnection {
43
57
  /**
44
58
  * Remote peer id.
45
59
  */
@@ -59,15 +73,15 @@ export interface ReplicatorConnection {
59
73
  * @returns true if the document should be advertised to this peer.
60
74
  * The remote peer can still request the document by its id bypassing this check.
61
75
  */
62
- shouldAdvertise(params: ShouldAdvertiseParams): Promise<boolean>;
76
+ shouldAdvertise(params: ShouldAdvertiseProps): Promise<boolean>;
63
77
 
64
78
  /**
65
79
  * @returns true if the collection should be synced to this peer.
66
80
  */
67
- shouldSyncCollection(params: ShouldSyncCollectionParams): boolean;
81
+ shouldSyncCollection(params: ShouldSyncCollectionProps): boolean;
68
82
 
69
83
  /**
70
- * Batch syncing considered enabled if ReplicatorConnection implements `pushBatch` and `pullBatch` methods.
84
+ * Batch syncing considered enabled if AutomergeReplicatorConnection implements `pushBatch` and `pullBatch` methods.
71
85
  * @returns true if the batch syncing is enabled.
72
86
  */
73
87
  get bundleSyncEnabled(): boolean;
@@ -75,24 +89,24 @@ export interface ReplicatorConnection {
75
89
  /**
76
90
  * Pushes the batch of documents to the remote peer.
77
91
  */
78
- pushBundle?(bundle: { documentId: DocumentId; data: Uint8Array; heads: Heads }[]): Promise<void>;
92
+ pushBundle?(ctx: Context, bundle: { documentId: DocumentId; data: Uint8Array; heads: Heads }[]): Promise<void>;
79
93
 
80
94
  /**
81
95
  * Pulls the batch of documents from the remote peer.
82
96
  */
83
97
  // TODO(mykola): Use automerge-repo-bundles Bundle type here.
84
- pullBundle?(docHeads: Record<DocumentId, Heads>): Promise<Record<DocumentId, Uint8Array>>;
98
+ pullBundle?(ctx: Context, docHeads: Record<DocumentId, Heads>): Promise<Record<DocumentId, Uint8Array>>;
85
99
  }
86
100
 
87
- export type ShouldAdvertiseParams = {
101
+ export type ShouldAdvertiseProps = {
88
102
  documentId: string;
89
103
  };
90
104
 
91
- export type ShouldSyncCollectionParams = {
105
+ export type ShouldSyncCollectionProps = {
92
106
  collectionId: string;
93
107
  };
94
108
 
95
- export type RemoteDocumentExistenceCheckParams = {
109
+ export type RemoteDocumentExistenceCheckProps = {
96
110
  peerId: string;
97
111
  documentId: string;
98
112
  };
@@ -0,0 +1,10 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { BaseError } from '@dxos/errors';
6
+
7
+ /**
8
+ * Thrown when attempting to send a message to a peer that is not connected.
9
+ */
10
+ export class PeerNotFoundError extends BaseError.extend('PeerNotFoundError', 'Peer not found') {}
@@ -0,0 +1,62 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type DocumentId, type QueryState, type Repo } from '@automerge/automerge-repo';
6
+
7
+ /**
8
+ * Variants reported by {@link QueryState}: `'loading' | 'ready' | 'unavailable' | 'failed'`.
9
+ */
10
+ export type HandleQueryState = QueryState<unknown>['state'];
11
+
12
+ /**
13
+ * Returns the {@link QueryState} variant for a document already known to the
14
+ * repo (i.e. one for which a handle has been registered via `Repo.find` /
15
+ * `Repo.findWithProgress` / `Repo.create`), or `undefined` if no handle/query
16
+ * exists for this document yet.
17
+ *
18
+ * Use this anywhere you need to ask "is this document loaded?" / "did sync
19
+ * give up?" — read the result against the {@link HandleQueryState} variants
20
+ * (e.g. `=== 'ready'`).
21
+ *
22
+ * # Why we can't just call `DocHandle.isReady()` / read `DocHandle.state`
23
+ *
24
+ * The current subduction fork of `@automerge/automerge-repo`
25
+ * (`2.6.0-subduction.10`) **gutted** the per-handle state machine. Inside the
26
+ * fork, every `DocHandle` state predicate is a constant placeholder:
27
+ *
28
+ * ```
29
+ * isReady = () => true
30
+ * isUnloaded = () => false
31
+ * isDeleted = () => false
32
+ * isUnavailable = () => false
33
+ * inState = (states) => states.includes('ready')
34
+ * get state = () => 'ready'
35
+ * whenReady = async () => {} // resolves immediately, ignores argument
36
+ * ```
37
+ *
38
+ * That means `handle.isReady()` always lies "yes", `handle.state` always reads
39
+ * `'ready'`, and `await handle.whenReady()` never blocks — even for handles
40
+ * whose document has never been loaded from storage or synced from a peer.
41
+ * Reading `handle.doc()` on such a "ready" handle returns the empty initial
42
+ * doc (`A.init()` or `A.emptyChange(A.init())`) rather than throwing or
43
+ * returning undefined, which can silently corrupt downstream logic that
44
+ * trusted the handle's `isReady()` signal.
45
+ *
46
+ * Real liveness moved to the {@link DocumentQuery} returned by
47
+ * `Repo.findWithProgress`, whose state goes through
48
+ * `'loading' → 'ready' | 'unavailable' | 'failed'`. Production code that
49
+ * needs to know whether a document is actually loaded must consult the query
50
+ * state through this helper instead of any `DocHandle.*` predicate.
51
+ *
52
+ * TODO(mykola): Remove this helper once the fork either (a) restores the
53
+ * `DocHandle` state machine, or (b) every caller is migrated to query
54
+ * `DocumentProgress` directly with a richer API surface (await on state
55
+ * transitions, etc.).
56
+ */
57
+ export const getHandleState = (repo: Repo, documentId: DocumentId): HandleQueryState | undefined => {
58
+ if (!repo.handles[documentId]) {
59
+ return undefined;
60
+ }
61
+ return repo.findWithProgress(documentId).peek().state;
62
+ };