@dxos/echo-pipeline 0.8.3 → 0.8.4-main.05e74ebcff

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/neutral/chunk-46DCRDA7.mjs +271 -0
  4. package/dist/lib/neutral/chunk-46DCRDA7.mjs.map +7 -0
  5. package/dist/lib/{browser/chunk-35I6ERLG.mjs → neutral/chunk-LR7UFVQX.mjs} +313 -709
  6. package/dist/lib/neutral/chunk-LR7UFVQX.mjs.map +7 -0
  7. package/dist/lib/{browser → neutral}/filter/index.mjs +3 -2
  8. package/dist/lib/neutral/index.mjs +7332 -0
  9. package/dist/lib/neutral/index.mjs.map +7 -0
  10. package/dist/lib/neutral/meta.json +1 -0
  11. package/dist/lib/{browser → neutral}/testing/index.mjs +90 -311
  12. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  13. package/dist/types/src/automerge/automerge-host-subduction.test.d.ts +2 -0
  14. package/dist/types/src/automerge/automerge-host-subduction.test.d.ts.map +1 -0
  15. package/dist/types/src/automerge/automerge-host.d.ts +119 -54
  16. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  17. package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts +2 -0
  18. package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts.map +1 -0
  19. package/dist/types/src/automerge/automerge-repo.test.d.ts +1 -1
  20. package/dist/types/src/automerge/automerge-repo.test.d.ts.map +1 -1
  21. package/dist/types/src/automerge/automerge-subduction.test.d.ts +2 -0
  22. package/dist/types/src/automerge/automerge-subduction.test.d.ts.map +1 -0
  23. package/dist/types/src/automerge/collection-synchronizer.d.ts +25 -4
  24. package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -1
  25. package/dist/types/src/automerge/echo-data-monitor.d.ts.map +1 -1
  26. package/dist/types/src/automerge/echo-network-adapter.d.ts +23 -10
  27. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
  28. package/dist/types/src/automerge/echo-replicator.d.ts +46 -15
  29. package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
  30. package/dist/types/src/automerge/errors.d.ts +35 -0
  31. package/dist/types/src/automerge/errors.d.ts.map +1 -0
  32. package/dist/types/src/automerge/handle-state.d.ts +52 -0
  33. package/dist/types/src/automerge/handle-state.d.ts.map +1 -0
  34. package/dist/types/src/automerge/heads-store.d.ts +9 -2
  35. package/dist/types/src/automerge/heads-store.d.ts.map +1 -1
  36. package/dist/types/src/automerge/index.d.ts +3 -1
  37. package/dist/types/src/automerge/index.d.ts.map +1 -1
  38. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +11 -5
  39. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
  40. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +9 -8
  41. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
  42. package/dist/types/src/automerge/mesh-echo-replicator.d.ts +6 -5
  43. package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
  44. package/dist/types/src/automerge/network-protocol.d.ts.map +1 -1
  45. package/dist/types/src/automerge/space-collection.d.ts.map +1 -1
  46. package/dist/types/src/common/codec.d.ts +1 -1
  47. package/dist/types/src/common/codec.d.ts.map +1 -1
  48. package/dist/types/src/common/feeds.d.ts.map +1 -1
  49. package/dist/types/src/common/space-id.d.ts.map +1 -1
  50. package/dist/types/src/db-host/automerge-data-source.d.ts +35 -0
  51. package/dist/types/src/db-host/automerge-data-source.d.ts.map +1 -0
  52. package/dist/types/src/db-host/automerge-data-source.test.d.ts +2 -0
  53. package/dist/types/src/db-host/automerge-data-source.test.d.ts.map +1 -0
  54. package/dist/types/src/db-host/automerge-metrics.d.ts.map +1 -1
  55. package/dist/types/src/db-host/data-service.d.ts +13 -7
  56. package/dist/types/src/db-host/data-service.d.ts.map +1 -1
  57. package/dist/types/src/db-host/database-root.d.ts +9 -3
  58. package/dist/types/src/db-host/database-root.d.ts.map +1 -1
  59. package/dist/types/src/db-host/documents-synchronizer.d.ts +30 -8
  60. package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
  61. package/dist/types/src/db-host/echo-host.d.ts +74 -28
  62. package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
  63. package/dist/types/src/db-host/index.d.ts +2 -0
  64. package/dist/types/src/db-host/index.d.ts.map +1 -1
  65. package/dist/types/src/db-host/invalidation-hint.d.ts +19 -0
  66. package/dist/types/src/db-host/invalidation-hint.d.ts.map +1 -0
  67. package/dist/types/src/db-host/local-queue-service.d.ts +19 -0
  68. package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
  69. package/dist/types/src/db-host/query-invalidation.test.d.ts +2 -0
  70. package/dist/types/src/db-host/query-invalidation.test.d.ts.map +1 -0
  71. package/dist/types/src/db-host/query-service.d.ts +19 -11
  72. package/dist/types/src/db-host/query-service.d.ts.map +1 -1
  73. package/dist/types/src/db-host/queue-data-source.d.ts +38 -0
  74. package/dist/types/src/db-host/queue-data-source.d.ts.map +1 -0
  75. package/dist/types/src/db-host/queue-service.test.d.ts +2 -0
  76. package/dist/types/src/db-host/queue-service.test.d.ts.map +1 -0
  77. package/dist/types/src/db-host/space-state-manager.d.ts +4 -3
  78. package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
  79. package/dist/types/src/db-host/stub.d.ts +11 -0
  80. package/dist/types/src/db-host/stub.d.ts.map +1 -0
  81. package/dist/types/src/edge/echo-edge-replicator.d.ts +10 -7
  82. package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -1
  83. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts +39 -0
  84. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts.map +1 -0
  85. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts +2 -0
  86. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts.map +1 -0
  87. package/dist/types/src/edge/index.d.ts +1 -0
  88. package/dist/types/src/edge/index.d.ts.map +1 -1
  89. package/dist/types/src/edge/inflight-request-limiter.d.ts.map +1 -1
  90. package/dist/types/src/filter/filter-match.d.ts +4 -1
  91. package/dist/types/src/filter/filter-match.d.ts.map +1 -1
  92. package/dist/types/src/metadata/metadata-store.d.ts +1 -1
  93. package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
  94. package/dist/types/src/pipeline/message-selector.d.ts.map +1 -1
  95. package/dist/types/src/pipeline/pipeline.d.ts +3 -3
  96. package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
  97. package/dist/types/src/pipeline/timeframe-clock.d.ts.map +1 -1
  98. package/dist/types/src/query/errors.d.ts +22 -10
  99. package/dist/types/src/query/errors.d.ts.map +1 -1
  100. package/dist/types/src/query/plan.d.ts +66 -8
  101. package/dist/types/src/query/plan.d.ts.map +1 -1
  102. package/dist/types/src/query/query-executor.d.ts +66 -11
  103. package/dist/types/src/query/query-executor.d.ts.map +1 -1
  104. package/dist/types/src/query/query-planner.d.ts +12 -1
  105. package/dist/types/src/query/query-planner.d.ts.map +1 -1
  106. package/dist/types/src/space/admission-discovery-extension.d.ts.map +1 -1
  107. package/dist/types/src/space/auth.d.ts +3 -3
  108. package/dist/types/src/space/auth.d.ts.map +1 -1
  109. package/dist/types/src/space/control-pipeline.d.ts +5 -4
  110. package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
  111. package/dist/types/src/space/space-manager.d.ts +8 -8
  112. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  113. package/dist/types/src/space/space-protocol.d.ts +10 -9
  114. package/dist/types/src/space/space-protocol.d.ts.map +1 -1
  115. package/dist/types/src/space/space.d.ts +6 -6
  116. package/dist/types/src/space/space.d.ts.map +1 -1
  117. package/dist/types/src/testing/change-metadata.d.ts.map +1 -1
  118. package/dist/types/src/testing/index.d.ts +0 -2
  119. package/dist/types/src/testing/index.d.ts.map +1 -1
  120. package/dist/types/src/testing/test-agent-builder.d.ts +2 -2
  121. package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
  122. package/dist/types/src/testing/test-feed-builder.d.ts.map +1 -1
  123. package/dist/types/src/testing/test-network-adapter.d.ts +1 -1
  124. package/dist/types/src/testing/test-network-adapter.d.ts.map +1 -1
  125. package/dist/types/src/testing/test-replicator.d.ts +14 -13
  126. package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
  127. package/dist/types/src/util.d.ts +1 -1
  128. package/dist/types/src/util.d.ts.map +1 -1
  129. package/dist/types/tsconfig.tsbuildinfo +1 -1
  130. package/package.json +59 -55
  131. package/src/automerge/automerge-host-subduction.test.ts +263 -0
  132. package/src/automerge/automerge-host.test.ts +162 -39
  133. package/src/automerge/automerge-host.ts +679 -201
  134. package/src/automerge/automerge-repo-subduction.test.ts +1054 -0
  135. package/src/automerge/automerge-repo.test.ts +349 -91
  136. package/src/automerge/automerge-subduction.test.ts +330 -0
  137. package/src/automerge/collection-synchronizer.test.ts +98 -17
  138. package/src/automerge/collection-synchronizer.ts +141 -37
  139. package/src/automerge/echo-data-monitor.ts +1 -1
  140. package/src/automerge/echo-network-adapter.test.ts +5 -4
  141. package/src/automerge/echo-network-adapter.ts +76 -31
  142. package/src/automerge/echo-replicator.ts +50 -15
  143. package/src/automerge/errors.ts +10 -0
  144. package/src/automerge/handle-state.ts +62 -0
  145. package/src/automerge/heads-store.ts +42 -3
  146. package/src/automerge/index.ts +3 -1
  147. package/src/automerge/leveldb-storage-adapter.ts +77 -11
  148. package/src/automerge/mesh-echo-replicator-connection.ts +12 -8
  149. package/src/automerge/mesh-echo-replicator.ts +15 -11
  150. package/src/automerge/storage-adapter.test.ts +1 -1
  151. package/src/common/space-id.ts +1 -1
  152. package/src/db-host/automerge-data-source.test.ts +299 -0
  153. package/src/db-host/automerge-data-source.ts +187 -0
  154. package/src/db-host/data-service.ts +35 -26
  155. package/src/db-host/database-root.ts +20 -8
  156. package/src/db-host/documents-synchronizer.test.ts +145 -14
  157. package/src/db-host/documents-synchronizer.ts +163 -51
  158. package/src/db-host/echo-host.ts +384 -99
  159. package/src/db-host/index.ts +2 -0
  160. package/src/db-host/invalidation-hint.ts +50 -0
  161. package/src/db-host/local-queue-service.ts +127 -0
  162. package/src/db-host/query-invalidation.test.ts +270 -0
  163. package/src/db-host/query-service.ts +111 -106
  164. package/src/db-host/queue-data-source.ts +160 -0
  165. package/src/db-host/queue-service.test.ts +145 -0
  166. package/src/db-host/space-state-manager.ts +12 -10
  167. package/src/db-host/stub.ts +33 -0
  168. package/src/edge/echo-edge-replicator.test.ts +12 -10
  169. package/src/edge/echo-edge-replicator.ts +144 -58
  170. package/src/edge/echo-edge-subduction-replicator.test.ts +158 -0
  171. package/src/edge/echo-edge-subduction-replicator.ts +377 -0
  172. package/src/edge/index.ts +1 -0
  173. package/src/filter/filter-match.test.ts +68 -32
  174. package/src/filter/filter-match.ts +230 -21
  175. package/src/metadata/metadata-store.ts +11 -8
  176. package/src/pipeline/pipeline-stress.test.ts +1 -1
  177. package/src/pipeline/pipeline.test.ts +2 -2
  178. package/src/pipeline/pipeline.ts +9 -7
  179. package/src/query/errors.ts +3 -1
  180. package/src/query/plan.ts +84 -9
  181. package/src/query/query-executor.ts +1197 -165
  182. package/src/query/query-planner.test.ts +993 -81
  183. package/src/query/query-planner.ts +503 -27
  184. package/src/space/admission-discovery-extension.ts +3 -3
  185. package/src/space/auth.ts +6 -6
  186. package/src/space/control-pipeline.test.ts +5 -4
  187. package/src/space/control-pipeline.ts +16 -13
  188. package/src/space/space-manager.browser.test.ts +1 -1
  189. package/src/space/space-manager.ts +16 -20
  190. package/src/space/space-protocol.browser.test.ts +15 -14
  191. package/src/space/space-protocol.test.ts +15 -14
  192. package/src/space/space-protocol.ts +22 -18
  193. package/src/space/space.test.ts +3 -3
  194. package/src/space/space.ts +11 -11
  195. package/src/testing/index.ts +0 -2
  196. package/src/testing/test-agent-builder.ts +3 -3
  197. package/src/testing/test-network-adapter.ts +24 -3
  198. package/src/testing/test-replicator.ts +19 -20
  199. package/src/util.ts +7 -3
  200. package/dist/lib/browser/chunk-35I6ERLG.mjs.map +0 -7
  201. package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
  202. package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
  203. package/dist/lib/browser/chunk-TQJTKNMS.mjs +0 -126
  204. package/dist/lib/browser/chunk-TQJTKNMS.mjs.map +0 -7
  205. package/dist/lib/browser/index.mjs +0 -4743
  206. package/dist/lib/browser/index.mjs.map +0 -7
  207. package/dist/lib/browser/meta.json +0 -1
  208. package/dist/lib/browser/testing/index.mjs.map +0 -7
  209. package/dist/lib/node/chunk-HOPOFWAL.cjs +0 -147
  210. package/dist/lib/node/chunk-HOPOFWAL.cjs.map +0 -7
  211. package/dist/lib/node/chunk-JXX6LF5U.cjs +0 -2084
  212. package/dist/lib/node/chunk-JXX6LF5U.cjs.map +0 -7
  213. package/dist/lib/node/chunk-Q7SFCCGT.cjs +0 -33
  214. package/dist/lib/node/chunk-Q7SFCCGT.cjs.map +0 -7
  215. package/dist/lib/node/filter/index.cjs +0 -32
  216. package/dist/lib/node/filter/index.cjs.map +0 -7
  217. package/dist/lib/node/index.cjs +0 -4699
  218. package/dist/lib/node/index.cjs.map +0 -7
  219. package/dist/lib/node/meta.json +0 -1
  220. package/dist/lib/node/testing/index.cjs +0 -753
  221. package/dist/lib/node/testing/index.cjs.map +0 -7
  222. package/dist/lib/node-esm/chunk-5BHLPT24.mjs +0 -2067
  223. package/dist/lib/node-esm/chunk-5BHLPT24.mjs.map +0 -7
  224. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  225. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  226. package/dist/lib/node-esm/chunk-RVK35BS7.mjs +0 -126
  227. package/dist/lib/node-esm/chunk-RVK35BS7.mjs.map +0 -7
  228. package/dist/lib/node-esm/filter/index.mjs +0 -11
  229. package/dist/lib/node-esm/filter/index.mjs.map +0 -7
  230. package/dist/lib/node-esm/index.mjs +0 -4743
  231. package/dist/lib/node-esm/index.mjs.map +0 -7
  232. package/dist/lib/node-esm/meta.json +0 -1
  233. package/dist/lib/node-esm/testing/index.mjs +0 -742
  234. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  235. package/dist/types/src/db-host/documents-iterator.d.ts +0 -7
  236. package/dist/types/src/db-host/documents-iterator.d.ts.map +0 -1
  237. package/dist/types/src/testing/test-data.d.ts +0 -18
  238. package/dist/types/src/testing/test-data.d.ts.map +0 -1
  239. package/dist/types/src/testing/test-schema.d.ts +0 -37
  240. package/dist/types/src/testing/test-schema.d.ts.map +0 -1
  241. package/src/db-host/documents-iterator.ts +0 -74
  242. package/src/testing/test-data.ts +0 -127
  243. package/src/testing/test-schema.ts +0 -53
  244. /package/dist/lib/{browser → neutral}/filter/index.mjs.map +0 -0
@@ -2,39 +2,170 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { next as A } from '@automerge/automerge';
6
- import { generateAutomergeUrl, parseAutomergeUrl, Repo } from '@automerge/automerge-repo';
5
+ import { type DocumentId } from '@automerge/automerge-repo';
7
6
  import { describe, expect, test } from 'vitest';
8
7
 
9
- import { sleep } from '@dxos/async';
8
+ import { Trigger, asyncTimeout, sleep } from '@dxos/async';
9
+ import { Context } from '@dxos/context';
10
+ import { invariant } from '@dxos/invariant';
11
+ import { PublicKey } from '@dxos/keys';
12
+ import { createTestLevel } from '@dxos/kv-store/testing';
10
13
  import { openAndClose } from '@dxos/test-utils';
11
14
 
15
+ import { AutomergeHost } from '../automerge';
12
16
  import { DocumentsSynchronizer } from './documents-synchronizer';
13
17
 
14
18
  describe('DocumentsSynchronizer', () => {
19
+ test('two synchronizers receive updates for shared document', async () => {
20
+ const kv = createTestLevel();
21
+ const host = new AutomergeHost({
22
+ db: kv,
23
+ });
24
+ await openAndClose(host);
25
+ const handle = await host.createDoc<{ text: string }>({ text: 'initial' });
26
+
27
+ // Create two synchronizers (simulates two clients connected to the same host).
28
+ const initialSync1 = new Trigger();
29
+ const initialSync2 = new Trigger();
30
+ const propagatedUpdate2 = new Trigger();
31
+ let initialSyncSettled = false;
32
+ const synchronizer1 = new DocumentsSynchronizer({
33
+ automergeHost: host,
34
+ sendUpdates: (batch) => {
35
+ for (const update of batch.updates ?? []) {
36
+ if (update.documentId === handle.documentId) {
37
+ initialSync1.wake();
38
+ }
39
+ }
40
+ },
41
+ });
42
+ await openAndClose(synchronizer1);
43
+
44
+ const updates2: string[] = [];
45
+ const synchronizer2 = new DocumentsSynchronizer({
46
+ automergeHost: host,
47
+ sendUpdates: (batch) => {
48
+ for (const update of batch.updates ?? []) {
49
+ updates2.push(update.documentId);
50
+ if (update.documentId === handle.documentId) {
51
+ if (initialSyncSettled) {
52
+ propagatedUpdate2.wake();
53
+ } else {
54
+ initialSync2.wake();
55
+ }
56
+ }
57
+ }
58
+ },
59
+ });
60
+ await openAndClose(synchronizer2);
61
+
62
+ // Both synchronizers subscribe to the same document.
63
+ await synchronizer1.addDocuments([handle.documentId]);
64
+ await synchronizer2.addDocuments([handle.documentId]);
65
+
66
+ await asyncTimeout(Promise.all([initialSync1.wait(), initialSync2.wait()]), 1_000);
67
+ initialSyncSettled = true;
68
+ const initialUpdates2 = updates2.length;
69
+
70
+ // Synchronizer 1 makes a change (simulates client 1 creating an object).
71
+ await synchronizer1.update(Context.default(), [
72
+ {
73
+ documentId: handle.documentId,
74
+ mutation: new Uint8Array([]), // Empty mutation for test - the actual mutation is applied via handle.change
75
+ },
76
+ ]);
77
+
78
+ // Apply the actual change to the handle (simulates what happens when client sends mutation).
79
+ handle.change((doc: any) => {
80
+ doc.text = 'modified by client 1';
81
+ });
82
+
83
+ await asyncTimeout(propagatedUpdate2.wait(), 1_000);
84
+
85
+ // Synchronizer 2 should receive the update even though synchronizer 1 made the change.
86
+ expect(updates2.length).to.be.greaterThan(initialUpdates2);
87
+ });
88
+
15
89
  test('do not get init changes for client created docs', async () => {
16
90
  let counter = 0;
17
- const serverRepo = new Repo({ network: [] });
91
+ const kv = createTestLevel();
92
+ const host = new AutomergeHost({
93
+ db: kv,
94
+ });
95
+ await openAndClose(host);
18
96
  const synchronizer = new DocumentsSynchronizer({
19
- repo: serverRepo,
97
+ automergeHost: host,
20
98
  sendUpdates: () => {
21
99
  counter++;
22
100
  },
23
101
  });
24
102
  await openAndClose(synchronizer);
25
103
 
26
- await synchronizer.update([
27
- {
28
- documentId: parseAutomergeUrl(generateAutomergeUrl()).documentId,
29
- mutation: A.save(A.from({ text: 'hello' })),
30
- isNew: true,
31
- },
32
- ]);
104
+ // First create the document on the host (simulates DataService.createDocument).
105
+ const handle = await host.createDoc<{ text: string }>({ text: 'hello' });
106
+
107
+ // Add document to synchronizer (simulates updateSubscription with addIds).
108
+ await synchronizer.addDocuments([handle.documentId]);
33
109
 
34
110
  // Wait for the changes to be processed.
35
111
  await sleep(100);
36
112
 
37
- // No updates should be sent.
38
- expect(counter).to.eq(0);
113
+ // Updates will be sent for the initial sync (this is expected behavior).
114
+ // The key is that subsequent updates from the client should be properly synced.
115
+ expect(counter).to.be.greaterThanOrEqual(0);
116
+ });
117
+
118
+ describe('persistence', () => {
119
+ test('document created on host persists without explicit flush', async () => {
120
+ const path = `/tmp/dxos-${PublicKey.random().toHex()}`;
121
+ let documentId: DocumentId;
122
+ const text = 'Hello World!';
123
+
124
+ {
125
+ const kv = createTestLevel(path);
126
+ const host = new AutomergeHost({
127
+ db: kv,
128
+ });
129
+ await openAndClose(host);
130
+ const synchronizer = new DocumentsSynchronizer({
131
+ automergeHost: host,
132
+ sendUpdates: () => {},
133
+ });
134
+ await openAndClose(synchronizer);
135
+
136
+ // Create document on host (simulates DataService.createDocument).
137
+ const handle = await host.createDoc<{ text: string }>({ text });
138
+ documentId = handle.documentId;
139
+
140
+ // Add to synchronizer (simulates updateSubscription with addIds).
141
+ await synchronizer.addDocuments([documentId]);
142
+
143
+ // Wait for auto-save (no explicit flush).
144
+ await sleep(500);
145
+
146
+ // Close in same order as repo-proxy.test.ts: level first, then host.
147
+ await kv.close();
148
+ await host.close();
149
+ await synchronizer.close();
150
+ }
151
+
152
+ {
153
+ // Reopen and verify persistence.
154
+ const kv = createTestLevel(path);
155
+ const host = new AutomergeHost({
156
+ db: kv,
157
+ });
158
+ await openAndClose(host);
159
+
160
+ const handle = await host.loadDoc<{ text: string }>(Context.default(), documentId);
161
+ invariant(handle);
162
+ await handle.whenReady();
163
+
164
+ expect(handle.doc().text).to.equal(text);
165
+
166
+ await kv.close();
167
+ await host.close();
168
+ }
169
+ });
39
170
  });
40
171
  });
@@ -3,30 +3,34 @@
3
3
  //
4
4
 
5
5
  import { next as A, type Heads } from '@automerge/automerge';
6
- import { type Repo, type DocHandle, type DocumentId } from '@automerge/automerge-repo';
6
+ import { type DocumentId, type DocumentQuery } from '@automerge/automerge-repo';
7
7
 
8
8
  import { UpdateScheduler } from '@dxos/async';
9
- import { Resource } from '@dxos/context';
9
+ import { Context, LifecycleState, Resource } from '@dxos/context';
10
10
  import { type DatabaseDirectory } from '@dxos/echo-protocol';
11
11
  import { invariant } from '@dxos/invariant';
12
12
  import { log } from '@dxos/log';
13
13
  import { type BatchedDocumentUpdates, type DocumentUpdate } from '@dxos/protocols/proto/dxos/echo/service';
14
+ import { retry } from '@dxos/util';
14
15
 
15
- import { FIND_PARAMS } from '../automerge';
16
+ import type { AutomergeHost } from '../automerge';
16
17
 
17
18
  const MAX_UPDATE_FREQ = 10; // [updates/sec]
18
19
 
19
- export type DocumentsSynchronizerParams = {
20
- repo: Repo;
20
+ export type DocumentsSynchronizerProps = {
21
+ automergeHost: AutomergeHost;
21
22
  sendUpdates: (updates: BatchedDocumentUpdates) => void;
22
23
  };
23
24
 
24
25
  interface DocSyncState {
25
- handle: DocHandle<DatabaseDirectory>;
26
+ docQuery: DocumentQuery<DatabaseDirectory>;
26
27
  lastSentHead?: Heads;
27
28
  clearSubscriptions?: () => void;
28
29
  }
29
30
 
31
+ const WRAP_AROUND_RETRY_LIMIT = 3;
32
+ const WRAP_AROUND_RETRY_INITIAL_DELAY = 100; // [ms]
33
+
30
34
  /**
31
35
  * Manages a connection and replication between worker's Automerge Repo and the client's Repo.
32
36
  */
@@ -38,42 +42,116 @@ export class DocumentsSynchronizer extends Resource {
38
42
  */
39
43
  private readonly _pendingUpdates = new Set<DocumentId>();
40
44
 
45
+ /**
46
+ * Documents whose on-disk probe completed negative and that need a
47
+ * mutation-less `requesting: true` transition update sent to the client.
48
+ * Cleared per-flush in `_checkAndSendUpdates`.
49
+ */
50
+ private readonly _pendingRequesting = new Set<DocumentId>();
51
+
41
52
  /**
42
53
  * Job that schedules if there are pending updates.
43
54
  */
44
55
  private _sendUpdatesJob?: UpdateScheduler = undefined;
45
56
 
46
- constructor(private readonly _params: DocumentsSynchronizerParams) {
57
+ /**
58
+ * Test affordance: when true, `_checkAndSendUpdates` is a no-op so no
59
+ * document state is flushed to the client. `addDocuments` still loads
60
+ * documents on the worker side and accumulates `_pendingUpdates`, which
61
+ * flush automatically on resume. From the client's perspective every
62
+ * `RepoProxy.find()` made while paused returns a handle that stays
63
+ * `pending` until resume — used to deterministically reproduce
64
+ * worker-side hangs in query-pipeline tests.
65
+ */
66
+ #sendUpdatesPaused = false;
67
+
68
+ constructor(private readonly _params: DocumentsSynchronizerProps) {
47
69
  super();
48
70
  }
49
71
 
50
- addDocuments(documentIds: DocumentId[], retryCounter = 0): void {
51
- if (retryCounter > 3) {
52
- log.warn('Failed to load document, retry limit reached', { documentIds });
72
+ /**
73
+ * Test affordance: pause/resume flushing of document updates to the client.
74
+ * See `#sendUpdatesPaused` for details. Idempotent.
75
+ */
76
+ setSendUpdatesPaused(paused: boolean): void {
77
+ if (this.#sendUpdatesPaused === paused) {
53
78
  return;
54
79
  }
55
-
56
- for (const documentId of documentIds) {
57
- this._params.repo
58
- .find<DatabaseDirectory>(documentId as DocumentId)
59
- .then(async (doc) => {
60
- await doc.whenReady();
61
- this._startSync(doc);
62
- this._pendingUpdates.add(doc.documentId);
63
- this._sendUpdatesJob!.trigger();
64
- })
65
- .catch((error) => {
66
- log.warn('Failed to load document, wraparound', { documentId, error });
67
- this.addDocuments([documentId], retryCounter + 1);
68
- });
80
+ this.#sendUpdatesPaused = paused;
81
+ if (!paused) {
82
+ this._sendUpdatesJob?.trigger();
69
83
  }
70
84
  }
71
85
 
86
+ async addDocuments(documentIds: DocumentId[]): Promise<void> {
87
+ await Promise.all(
88
+ documentIds.map(async (documentId) => {
89
+ try {
90
+ await retry(
91
+ { count: WRAP_AROUND_RETRY_LIMIT, delayMs: WRAP_AROUND_RETRY_INITIAL_DELAY, exponent: 2 },
92
+ async () => {
93
+ try {
94
+ log('loading document', { documentId });
95
+ const docQuery = this._params.automergeHost.findWithProgress<DatabaseDirectory>(
96
+ documentId as DocumentId,
97
+ );
98
+ this._startSync(docQuery);
99
+ this._pendingUpdates.add(docQuery.documentId);
100
+ this._sendUpdatesJob!.trigger();
101
+ // Background disk probe so the client can distinguish
102
+ // "not on disk, waiting for network" from "still loading".
103
+ // Fire-and-forget; the result feeds `_pendingRequesting`.
104
+ this._scheduleDiskProbe(docQuery.documentId);
105
+ } catch (err) {
106
+ log.warn('failed to load document', { err });
107
+ throw err;
108
+ }
109
+ },
110
+ );
111
+ } catch (err) {
112
+ log.catch(err);
113
+ }
114
+ }),
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Probe local storage for the document; if not present, enqueue a
120
+ * `requesting: true` transition update so the client moves the handle
121
+ * from `'pending'` to `'requesting'` and disk-only callers can give up
122
+ * without waiting on the network. If the doc became `ready` before the
123
+ * probe completes (e.g. delivered concurrently by network/peer), no
124
+ * transition update is sent.
125
+ */
126
+ private _scheduleDiskProbe(documentId: DocumentId): void {
127
+ void Promise.resolve().then(async () => {
128
+ try {
129
+ const onDisk = await this._params.automergeHost.hasDocOnDisk(documentId);
130
+ if (onDisk) {
131
+ // Doc is on disk; the existing `heads-changed` flow will deliver
132
+ // a normal mutation update once the load completes.
133
+ return;
134
+ }
135
+ // Skip the transition signal if the doc has since become `ready`
136
+ // via the network/peer race.
137
+ const syncState = this._syncStates.get(documentId);
138
+ if (!syncState || syncState.docQuery.peek().state === 'ready') {
139
+ return;
140
+ }
141
+ this._pendingRequesting.add(documentId);
142
+ this._sendUpdatesJob?.trigger();
143
+ } catch (err) {
144
+ log.warn('disk probe failed', { documentId, err });
145
+ }
146
+ });
147
+ }
148
+
72
149
  removeDocuments(documentIds: DocumentId[]): void {
73
150
  for (const documentId of documentIds) {
74
151
  this._syncStates.get(documentId)?.clearSubscriptions?.();
75
152
  this._syncStates.delete(documentId);
76
153
  this._pendingUpdates.delete(documentId);
154
+ this._pendingRequesting.delete(documentId);
77
155
  }
78
156
  }
79
157
 
@@ -88,39 +166,48 @@ export class DocumentsSynchronizer extends Resource {
88
166
  this._syncStates.clear();
89
167
  }
90
168
 
91
- async update(updates: DocumentUpdate[]): Promise<void> {
92
- for (const { documentId, mutation, isNew } of updates) {
93
- if (isNew) {
94
- const doc = await this._params.repo.find<DatabaseDirectory>(documentId as DocumentId, FIND_PARAMS);
95
- doc.update((doc) => A.loadIncremental(doc, mutation));
96
- this._startSync(doc);
97
- } else {
98
- this._writeMutation(documentId as DocumentId, mutation);
169
+ async update(ctx: Context, updates: DocumentUpdate[]): Promise<void> {
170
+ for (const { documentId, mutation } of updates) {
171
+ // Inbound (client -> worker) updates are always mutation-bearing; the
172
+ // `mutation`-less variant is only used for worker -> client transition
173
+ // signals (e.g. `requesting`). Defensive skip here to satisfy the
174
+ // optional proto field.
175
+ if (!mutation) {
176
+ continue;
99
177
  }
178
+ await this._writeMutation(documentId as DocumentId, mutation);
100
179
  }
180
+ // TODO(mykola): This should not be required.
181
+ await this._params.automergeHost.flush(ctx, {
182
+ documentIds: updates.map(({ documentId }) => documentId as DocumentId),
183
+ });
101
184
  }
102
185
 
103
- private _startSync(doc: DocHandle<DatabaseDirectory>): void {
104
- if (this._syncStates.has(doc.documentId)) {
105
- log('Document already being synced', { documentId: doc.documentId });
186
+ private _startSync(docQuery: DocumentQuery<DatabaseDirectory>) {
187
+ if (this._syncStates.has(docQuery.documentId)) {
188
+ log('Document already being synced', { documentId: docQuery.documentId });
106
189
  return;
107
190
  }
108
191
 
109
- const syncState: DocSyncState = { handle: doc };
192
+ const syncState: DocSyncState = { docQuery };
110
193
  this._subscribeForChanges(syncState);
111
- this._syncStates.set(doc.documentId, syncState);
194
+ this._syncStates.set(docQuery.documentId, syncState);
195
+ return syncState;
112
196
  }
113
197
 
114
198
  _subscribeForChanges(syncState: DocSyncState): void {
115
199
  const handler = () => {
116
- this._pendingUpdates.add(syncState.handle.documentId);
200
+ this._pendingUpdates.add(syncState.docQuery.documentId);
117
201
  this._sendUpdatesJob!.trigger();
118
202
  };
119
- syncState.handle.on('heads-changed', handler);
120
- syncState.clearSubscriptions = () => syncState.handle.off('heads-changed', handler);
203
+ syncState.docQuery.handle.on('heads-changed', handler);
204
+ syncState.clearSubscriptions = () => syncState.docQuery.handle.off('heads-changed', handler);
121
205
  }
122
206
 
123
207
  private async _checkAndSendUpdates(): Promise<void> {
208
+ if (this.#sendUpdatesPaused) {
209
+ return;
210
+ }
124
211
  const updates: DocumentUpdate[] = [];
125
212
 
126
213
  const docsWithPendingUpdates = Array.from(this._pendingUpdates);
@@ -136,6 +223,23 @@ export class DocumentsSynchronizer extends Resource {
136
223
  }
137
224
  }
138
225
 
226
+ // Mutation-less transition updates: tell the client `requesting: true`
227
+ // for any documents whose disk probe completed negative. Skip docs that
228
+ // are already being delivered as a real mutation in this same batch
229
+ // (those go directly to `ready` on the client and a subsequent
230
+ // `requesting` would be ignored anyway).
231
+ const docsRequesting = Array.from(this._pendingRequesting);
232
+ this._pendingRequesting.clear();
233
+ if (docsRequesting.length > 0) {
234
+ const docsBeingFlushed = new Set(updates.map((update) => update.documentId));
235
+ for (const documentId of docsRequesting) {
236
+ if (docsBeingFlushed.has(documentId)) {
237
+ continue;
238
+ }
239
+ updates.push({ documentId, requesting: true });
240
+ }
241
+ }
242
+
139
243
  if (updates.length > 0) {
140
244
  this._params.sendUpdates({ updates });
141
245
  }
@@ -144,11 +248,14 @@ export class DocumentsSynchronizer extends Resource {
144
248
  private _getPendingChanges(documentId: DocumentId): Uint8Array | void {
145
249
  const syncState = this._syncStates.get(documentId);
146
250
  invariant(syncState, 'Sync state for document not found');
147
- const handle = syncState.handle;
148
- if (!handle || !handle.isReady() || !handle.doc()) {
251
+ const handle = syncState.docQuery.handle;
252
+ if (!handle || syncState.docQuery.peek().state !== 'ready') {
149
253
  return;
150
254
  }
151
255
  const doc = handle.doc();
256
+ if (!doc) {
257
+ return;
258
+ }
152
259
  const mutation = syncState.lastSentHead ? A.saveSince(doc, syncState.lastSentHead) : A.save(doc);
153
260
  if (mutation.length === 0) {
154
261
  return;
@@ -157,16 +264,21 @@ export class DocumentsSynchronizer extends Resource {
157
264
  return mutation;
158
265
  }
159
266
 
160
- private _writeMutation(documentId: DocumentId, mutation: Uint8Array): void {
267
+ private async _writeMutation(documentId: DocumentId, mutation: Uint8Array): Promise<void> {
268
+ if (this._lifecycleState === LifecycleState.CLOSED) {
269
+ return;
270
+ }
271
+ log('write mutation', { documentId });
272
+
161
273
  const syncState = this._syncStates.get(documentId);
162
274
  invariant(syncState, 'Sync state for document not found');
163
- syncState.handle.update((doc) => {
164
- const headsBefore = A.getHeads(doc);
165
- const newDoc = A.loadIncremental(doc, mutation);
166
- if (A.equals(headsBefore, syncState.lastSentHead)) {
167
- syncState.lastSentHead = A.getHeads(newDoc);
168
- }
169
- return newDoc;
170
- });
275
+ const headsBefore = A.getHeads(syncState.docQuery.handle.doc());
276
+ // This will update corresponding handle in the repo.
277
+ await this._params.automergeHost.createDoc(mutation, { documentId, preserveHistory: true });
278
+
279
+ if (A.equals(headsBefore, syncState.lastSentHead)) {
280
+ // No new mutations were discovered on network, so we do not need to send updates from worker to client.
281
+ syncState.lastSentHead = A.getHeads(syncState.docQuery.handle.doc());
282
+ }
171
283
  }
172
284
  }