@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.
- package/LICENSE +102 -5
- package/README.md +1 -1
- package/dist/lib/neutral/chunk-46DCRDA7.mjs +271 -0
- package/dist/lib/neutral/chunk-46DCRDA7.mjs.map +7 -0
- package/dist/lib/{browser/chunk-35I6ERLG.mjs → neutral/chunk-LR7UFVQX.mjs} +313 -709
- package/dist/lib/neutral/chunk-LR7UFVQX.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/filter/index.mjs +3 -2
- package/dist/lib/neutral/index.mjs +7332 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/{browser → neutral}/testing/index.mjs +90 -311
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/automerge/automerge-host-subduction.test.d.ts +2 -0
- package/dist/types/src/automerge/automerge-host-subduction.test.d.ts.map +1 -0
- package/dist/types/src/automerge/automerge-host.d.ts +119 -54
- package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
- package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts +2 -0
- package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts.map +1 -0
- package/dist/types/src/automerge/automerge-repo.test.d.ts +1 -1
- package/dist/types/src/automerge/automerge-repo.test.d.ts.map +1 -1
- package/dist/types/src/automerge/automerge-subduction.test.d.ts +2 -0
- package/dist/types/src/automerge/automerge-subduction.test.d.ts.map +1 -0
- package/dist/types/src/automerge/collection-synchronizer.d.ts +25 -4
- package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-data-monitor.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-network-adapter.d.ts +23 -10
- package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-replicator.d.ts +46 -15
- package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
- package/dist/types/src/automerge/errors.d.ts +35 -0
- package/dist/types/src/automerge/errors.d.ts.map +1 -0
- package/dist/types/src/automerge/handle-state.d.ts +52 -0
- package/dist/types/src/automerge/handle-state.d.ts.map +1 -0
- package/dist/types/src/automerge/heads-store.d.ts +9 -2
- package/dist/types/src/automerge/heads-store.d.ts.map +1 -1
- package/dist/types/src/automerge/index.d.ts +3 -1
- package/dist/types/src/automerge/index.d.ts.map +1 -1
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +11 -5
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
- package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +9 -8
- package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
- package/dist/types/src/automerge/mesh-echo-replicator.d.ts +6 -5
- package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
- package/dist/types/src/automerge/network-protocol.d.ts.map +1 -1
- package/dist/types/src/automerge/space-collection.d.ts.map +1 -1
- package/dist/types/src/common/codec.d.ts +1 -1
- package/dist/types/src/common/codec.d.ts.map +1 -1
- package/dist/types/src/common/feeds.d.ts.map +1 -1
- package/dist/types/src/common/space-id.d.ts.map +1 -1
- package/dist/types/src/db-host/automerge-data-source.d.ts +35 -0
- package/dist/types/src/db-host/automerge-data-source.d.ts.map +1 -0
- package/dist/types/src/db-host/automerge-data-source.test.d.ts +2 -0
- package/dist/types/src/db-host/automerge-data-source.test.d.ts.map +1 -0
- package/dist/types/src/db-host/automerge-metrics.d.ts.map +1 -1
- package/dist/types/src/db-host/data-service.d.ts +13 -7
- package/dist/types/src/db-host/data-service.d.ts.map +1 -1
- package/dist/types/src/db-host/database-root.d.ts +9 -3
- package/dist/types/src/db-host/database-root.d.ts.map +1 -1
- package/dist/types/src/db-host/documents-synchronizer.d.ts +30 -8
- package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
- package/dist/types/src/db-host/echo-host.d.ts +74 -28
- package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
- package/dist/types/src/db-host/index.d.ts +2 -0
- package/dist/types/src/db-host/index.d.ts.map +1 -1
- package/dist/types/src/db-host/invalidation-hint.d.ts +19 -0
- package/dist/types/src/db-host/invalidation-hint.d.ts.map +1 -0
- package/dist/types/src/db-host/local-queue-service.d.ts +19 -0
- package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
- package/dist/types/src/db-host/query-invalidation.test.d.ts +2 -0
- package/dist/types/src/db-host/query-invalidation.test.d.ts.map +1 -0
- package/dist/types/src/db-host/query-service.d.ts +19 -11
- package/dist/types/src/db-host/query-service.d.ts.map +1 -1
- package/dist/types/src/db-host/queue-data-source.d.ts +38 -0
- package/dist/types/src/db-host/queue-data-source.d.ts.map +1 -0
- package/dist/types/src/db-host/queue-service.test.d.ts +2 -0
- package/dist/types/src/db-host/queue-service.test.d.ts.map +1 -0
- package/dist/types/src/db-host/space-state-manager.d.ts +4 -3
- package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
- package/dist/types/src/db-host/stub.d.ts +11 -0
- package/dist/types/src/db-host/stub.d.ts.map +1 -0
- package/dist/types/src/edge/echo-edge-replicator.d.ts +10 -7
- package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -1
- package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts +39 -0
- package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts.map +1 -0
- package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts +2 -0
- package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts.map +1 -0
- package/dist/types/src/edge/index.d.ts +1 -0
- package/dist/types/src/edge/index.d.ts.map +1 -1
- package/dist/types/src/edge/inflight-request-limiter.d.ts.map +1 -1
- package/dist/types/src/filter/filter-match.d.ts +4 -1
- package/dist/types/src/filter/filter-match.d.ts.map +1 -1
- package/dist/types/src/metadata/metadata-store.d.ts +1 -1
- package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
- package/dist/types/src/pipeline/message-selector.d.ts.map +1 -1
- package/dist/types/src/pipeline/pipeline.d.ts +3 -3
- package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
- package/dist/types/src/pipeline/timeframe-clock.d.ts.map +1 -1
- package/dist/types/src/query/errors.d.ts +22 -10
- package/dist/types/src/query/errors.d.ts.map +1 -1
- package/dist/types/src/query/plan.d.ts +66 -8
- package/dist/types/src/query/plan.d.ts.map +1 -1
- package/dist/types/src/query/query-executor.d.ts +66 -11
- package/dist/types/src/query/query-executor.d.ts.map +1 -1
- package/dist/types/src/query/query-planner.d.ts +12 -1
- package/dist/types/src/query/query-planner.d.ts.map +1 -1
- package/dist/types/src/space/admission-discovery-extension.d.ts.map +1 -1
- package/dist/types/src/space/auth.d.ts +3 -3
- package/dist/types/src/space/auth.d.ts.map +1 -1
- package/dist/types/src/space/control-pipeline.d.ts +5 -4
- package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
- package/dist/types/src/space/space-manager.d.ts +8 -8
- package/dist/types/src/space/space-manager.d.ts.map +1 -1
- package/dist/types/src/space/space-protocol.d.ts +10 -9
- package/dist/types/src/space/space-protocol.d.ts.map +1 -1
- package/dist/types/src/space/space.d.ts +6 -6
- package/dist/types/src/space/space.d.ts.map +1 -1
- package/dist/types/src/testing/change-metadata.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +0 -2
- package/dist/types/src/testing/index.d.ts.map +1 -1
- package/dist/types/src/testing/test-agent-builder.d.ts +2 -2
- package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
- package/dist/types/src/testing/test-feed-builder.d.ts.map +1 -1
- package/dist/types/src/testing/test-network-adapter.d.ts +1 -1
- package/dist/types/src/testing/test-network-adapter.d.ts.map +1 -1
- package/dist/types/src/testing/test-replicator.d.ts +14 -13
- package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +1 -1
- package/dist/types/src/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +59 -55
- package/src/automerge/automerge-host-subduction.test.ts +263 -0
- package/src/automerge/automerge-host.test.ts +162 -39
- package/src/automerge/automerge-host.ts +679 -201
- package/src/automerge/automerge-repo-subduction.test.ts +1054 -0
- package/src/automerge/automerge-repo.test.ts +349 -91
- package/src/automerge/automerge-subduction.test.ts +330 -0
- package/src/automerge/collection-synchronizer.test.ts +98 -17
- package/src/automerge/collection-synchronizer.ts +141 -37
- package/src/automerge/echo-data-monitor.ts +1 -1
- package/src/automerge/echo-network-adapter.test.ts +5 -4
- package/src/automerge/echo-network-adapter.ts +76 -31
- package/src/automerge/echo-replicator.ts +50 -15
- package/src/automerge/errors.ts +10 -0
- package/src/automerge/handle-state.ts +62 -0
- package/src/automerge/heads-store.ts +42 -3
- package/src/automerge/index.ts +3 -1
- package/src/automerge/leveldb-storage-adapter.ts +77 -11
- package/src/automerge/mesh-echo-replicator-connection.ts +12 -8
- package/src/automerge/mesh-echo-replicator.ts +15 -11
- package/src/automerge/storage-adapter.test.ts +1 -1
- package/src/common/space-id.ts +1 -1
- package/src/db-host/automerge-data-source.test.ts +299 -0
- package/src/db-host/automerge-data-source.ts +187 -0
- package/src/db-host/data-service.ts +35 -26
- package/src/db-host/database-root.ts +20 -8
- package/src/db-host/documents-synchronizer.test.ts +145 -14
- package/src/db-host/documents-synchronizer.ts +163 -51
- package/src/db-host/echo-host.ts +384 -99
- package/src/db-host/index.ts +2 -0
- package/src/db-host/invalidation-hint.ts +50 -0
- package/src/db-host/local-queue-service.ts +127 -0
- package/src/db-host/query-invalidation.test.ts +270 -0
- package/src/db-host/query-service.ts +111 -106
- package/src/db-host/queue-data-source.ts +160 -0
- package/src/db-host/queue-service.test.ts +145 -0
- package/src/db-host/space-state-manager.ts +12 -10
- package/src/db-host/stub.ts +33 -0
- package/src/edge/echo-edge-replicator.test.ts +12 -10
- package/src/edge/echo-edge-replicator.ts +144 -58
- package/src/edge/echo-edge-subduction-replicator.test.ts +158 -0
- package/src/edge/echo-edge-subduction-replicator.ts +377 -0
- package/src/edge/index.ts +1 -0
- package/src/filter/filter-match.test.ts +68 -32
- package/src/filter/filter-match.ts +230 -21
- package/src/metadata/metadata-store.ts +11 -8
- package/src/pipeline/pipeline-stress.test.ts +1 -1
- package/src/pipeline/pipeline.test.ts +2 -2
- package/src/pipeline/pipeline.ts +9 -7
- package/src/query/errors.ts +3 -1
- package/src/query/plan.ts +84 -9
- package/src/query/query-executor.ts +1197 -165
- package/src/query/query-planner.test.ts +993 -81
- package/src/query/query-planner.ts +503 -27
- package/src/space/admission-discovery-extension.ts +3 -3
- package/src/space/auth.ts +6 -6
- package/src/space/control-pipeline.test.ts +5 -4
- package/src/space/control-pipeline.ts +16 -13
- package/src/space/space-manager.browser.test.ts +1 -1
- package/src/space/space-manager.ts +16 -20
- package/src/space/space-protocol.browser.test.ts +15 -14
- package/src/space/space-protocol.test.ts +15 -14
- package/src/space/space-protocol.ts +22 -18
- package/src/space/space.test.ts +3 -3
- package/src/space/space.ts +11 -11
- package/src/testing/index.ts +0 -2
- package/src/testing/test-agent-builder.ts +3 -3
- package/src/testing/test-network-adapter.ts +24 -3
- package/src/testing/test-replicator.ts +19 -20
- package/src/util.ts +7 -3
- package/dist/lib/browser/chunk-35I6ERLG.mjs.map +0 -7
- package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
- package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
- package/dist/lib/browser/chunk-TQJTKNMS.mjs +0 -126
- package/dist/lib/browser/chunk-TQJTKNMS.mjs.map +0 -7
- package/dist/lib/browser/index.mjs +0 -4743
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/testing/index.mjs.map +0 -7
- package/dist/lib/node/chunk-HOPOFWAL.cjs +0 -147
- package/dist/lib/node/chunk-HOPOFWAL.cjs.map +0 -7
- package/dist/lib/node/chunk-JXX6LF5U.cjs +0 -2084
- package/dist/lib/node/chunk-JXX6LF5U.cjs.map +0 -7
- package/dist/lib/node/chunk-Q7SFCCGT.cjs +0 -33
- package/dist/lib/node/chunk-Q7SFCCGT.cjs.map +0 -7
- package/dist/lib/node/filter/index.cjs +0 -32
- package/dist/lib/node/filter/index.cjs.map +0 -7
- package/dist/lib/node/index.cjs +0 -4699
- package/dist/lib/node/index.cjs.map +0 -7
- package/dist/lib/node/meta.json +0 -1
- package/dist/lib/node/testing/index.cjs +0 -753
- package/dist/lib/node/testing/index.cjs.map +0 -7
- package/dist/lib/node-esm/chunk-5BHLPT24.mjs +0 -2067
- package/dist/lib/node-esm/chunk-5BHLPT24.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-RVK35BS7.mjs +0 -126
- package/dist/lib/node-esm/chunk-RVK35BS7.mjs.map +0 -7
- package/dist/lib/node-esm/filter/index.mjs +0 -11
- package/dist/lib/node-esm/filter/index.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -4743
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/testing/index.mjs +0 -742
- package/dist/lib/node-esm/testing/index.mjs.map +0 -7
- package/dist/types/src/db-host/documents-iterator.d.ts +0 -7
- package/dist/types/src/db-host/documents-iterator.d.ts.map +0 -1
- package/dist/types/src/testing/test-data.d.ts +0 -18
- package/dist/types/src/testing/test-data.d.ts.map +0 -1
- package/dist/types/src/testing/test-schema.d.ts +0 -37
- package/dist/types/src/testing/test-schema.d.ts.map +0 -1
- package/src/db-host/documents-iterator.ts +0 -74
- package/src/testing/test-data.ts +0 -127
- package/src/testing/test-schema.ts +0 -53
- /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 {
|
|
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
|
|
91
|
+
const kv = createTestLevel();
|
|
92
|
+
const host = new AutomergeHost({
|
|
93
|
+
db: kv,
|
|
94
|
+
});
|
|
95
|
+
await openAndClose(host);
|
|
18
96
|
const synchronizer = new DocumentsSynchronizer({
|
|
19
|
-
|
|
97
|
+
automergeHost: host,
|
|
20
98
|
sendUpdates: () => {
|
|
21
99
|
counter++;
|
|
22
100
|
},
|
|
23
101
|
});
|
|
24
102
|
await openAndClose(synchronizer);
|
|
25
103
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
//
|
|
38
|
-
|
|
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
|
|
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 {
|
|
16
|
+
import type { AutomergeHost } from '../automerge';
|
|
16
17
|
|
|
17
18
|
const MAX_UPDATE_FREQ = 10; // [updates/sec]
|
|
18
19
|
|
|
19
|
-
export type
|
|
20
|
-
|
|
20
|
+
export type DocumentsSynchronizerProps = {
|
|
21
|
+
automergeHost: AutomergeHost;
|
|
21
22
|
sendUpdates: (updates: BatchedDocumentUpdates) => void;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
interface DocSyncState {
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
this.
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
104
|
-
if (this._syncStates.has(
|
|
105
|
-
log('Document already being synced', { 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 = {
|
|
192
|
+
const syncState: DocSyncState = { docQuery };
|
|
110
193
|
this._subscribeForChanges(syncState);
|
|
111
|
-
this._syncStates.set(
|
|
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.
|
|
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 ||
|
|
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.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
}
|