@dxos/echo-pipeline 0.8.3 → 0.8.4-main.1c7ec43d41
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/dist/lib/{browser/chunk-35I6ERLG.mjs → neutral/chunk-VIHYDCCC.mjs} +313 -709
- package/dist/lib/neutral/chunk-VIHYDCCC.mjs.map +7 -0
- package/dist/lib/neutral/chunk-YBC6ONBZ.mjs +247 -0
- package/dist/lib/neutral/chunk-YBC6ONBZ.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/filter/index.mjs +3 -2
- package/dist/lib/neutral/index.mjs +7065 -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 +99 -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 +38 -15
- package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
- 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 +2 -1
- package/dist/types/src/automerge/index.d.ts.map +1 -1
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +9 -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 +8 -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 +9 -8
- package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
- package/dist/types/src/db-host/echo-host.d.ts +62 -27
- 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 +56 -54
- package/src/automerge/automerge-host-subduction.test.ts +247 -0
- package/src/automerge/automerge-host.test.ts +65 -39
- package/src/automerge/automerge-host.ts +589 -206
- package/src/automerge/automerge-repo-subduction.test.ts +1049 -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 +53 -17
- package/src/automerge/collection-synchronizer.ts +119 -36
- 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 +74 -30
- package/src/automerge/echo-replicator.ts +41 -15
- package/src/automerge/handle-state.ts +62 -0
- package/src/automerge/heads-store.ts +42 -3
- package/src/automerge/index.ts +2 -1
- package/src/automerge/leveldb-storage-adapter.ts +40 -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 +25 -26
- package/src/db-host/database-root.ts +20 -8
- package/src/db-host/documents-synchronizer.test.ts +143 -14
- package/src/db-host/documents-synchronizer.ts +72 -55
- package/src/db-host/echo-host.ts +348 -96
- 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 +184 -20
- 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 +82 -8
- package/src/query/query-executor.ts +1199 -164
- package/src/query/query-planner.test.ts +993 -81
- package/src/query/query-planner.ts +467 -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
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
next as A,
|
|
7
|
+
type Heads,
|
|
8
|
+
change,
|
|
9
|
+
clone,
|
|
10
|
+
equals,
|
|
11
|
+
from,
|
|
12
|
+
getBackend,
|
|
13
|
+
getHeads,
|
|
14
|
+
save,
|
|
15
|
+
saveSince,
|
|
16
|
+
} from '@automerge/automerge';
|
|
17
|
+
import {
|
|
18
|
+
type AutomergeUrl,
|
|
19
|
+
type DocHandle,
|
|
20
|
+
type DocumentId,
|
|
21
|
+
type DocumentProgress,
|
|
22
|
+
type Message,
|
|
23
|
+
type PeerId,
|
|
24
|
+
type QueryState,
|
|
25
|
+
Repo,
|
|
26
|
+
type StorageAdapterInterface,
|
|
27
|
+
type SubductionPolicy,
|
|
28
|
+
generateAutomergeUrl,
|
|
29
|
+
initSubduction,
|
|
30
|
+
parseAutomergeUrl,
|
|
31
|
+
} from '@automerge/automerge-repo';
|
|
32
|
+
import { beforeAll, describe, expect, onTestFinished, test } from 'vitest';
|
|
33
|
+
|
|
34
|
+
import { Trigger, sleep } from '@dxos/async';
|
|
35
|
+
import { randomBytes } from '@dxos/crypto';
|
|
36
|
+
import { PublicKey } from '@dxos/keys';
|
|
37
|
+
import { createTestLevel } from '@dxos/kv-store/testing';
|
|
38
|
+
import { openAndClose } from '@dxos/test-utils';
|
|
39
|
+
import { isNonNullable } from '@dxos/util';
|
|
40
|
+
|
|
41
|
+
import { TestAdapter, type TestConnectionStateProvider } from '../testing';
|
|
42
|
+
import { LevelDBStorageAdapter } from './leveldb-storage-adapter';
|
|
43
|
+
|
|
44
|
+
const HOST_AND_CLIENT: [string, string] = ['host', 'client'];
|
|
45
|
+
const SUBDUCTION_SERVICE_NAME = 'test-subduction-service';
|
|
46
|
+
const FIND_STATES: readonly QueryStateName[] = ['ready', 'loading'];
|
|
47
|
+
|
|
48
|
+
// How long to wait before asserting "this should NOT have happened by now".
|
|
49
|
+
// Tuned so that:
|
|
50
|
+
// - The happy path (basic subduction sync) consistently completes faster:
|
|
51
|
+
// observed end-to-end ~200-300 ms locally.
|
|
52
|
+
// - The window is short enough to keep the suite fast.
|
|
53
|
+
// - It's long enough that GC pauses or scheduling jitter on a busy CI box
|
|
54
|
+
// don't sneak a successful sync past the assertion.
|
|
55
|
+
// If you find yourself raising this, write down WHY in the test that needs
|
|
56
|
+
// more time rather than bumping the global.
|
|
57
|
+
const NEGATIVE_ASSERTION_DELAY_MS = 500;
|
|
58
|
+
|
|
59
|
+
// Subduction control-plane message type, sent by `NetworkAdapterTransport` from
|
|
60
|
+
// `@automerge/automerge-repo/dist/subduction/network.js`. Not exported from the
|
|
61
|
+
// package root, so we inline the string literal. If you change this, also update
|
|
62
|
+
// `node_modules/.../subduction/network.d.ts:SUBDUCTION_MESSAGE_TYPE`.
|
|
63
|
+
const SUBDUCTION_MESSAGE_TYPE = 'subduction-connection';
|
|
64
|
+
|
|
65
|
+
type QueryStateName = QueryState<unknown>['state'];
|
|
66
|
+
|
|
67
|
+
const waitForQueryState = async (
|
|
68
|
+
progress: DocumentProgress<unknown>,
|
|
69
|
+
awaitStates: readonly QueryStateName[],
|
|
70
|
+
{ timeout }: { timeout?: number } = {},
|
|
71
|
+
): Promise<void> => {
|
|
72
|
+
if (awaitStates.includes(progress.peek().state)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const trigger = new Trigger();
|
|
76
|
+
const unsubscribe = progress.subscribe((state) => {
|
|
77
|
+
if (awaitStates.includes(state.state)) {
|
|
78
|
+
trigger.wake();
|
|
79
|
+
} else if (state.state === 'failed' && !awaitStates.includes('failed')) {
|
|
80
|
+
trigger.throw(state.error);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
await trigger.wait({ timeout });
|
|
85
|
+
} finally {
|
|
86
|
+
unsubscribe();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const findInStates = async <T>(
|
|
91
|
+
repo: Repo,
|
|
92
|
+
url: AutomergeUrl,
|
|
93
|
+
awaitStates: readonly QueryStateName[] = ['ready'],
|
|
94
|
+
): Promise<DocHandle<T>> => {
|
|
95
|
+
const { documentId } = parseAutomergeUrl(url);
|
|
96
|
+
const progress = repo.findWithProgress<T>(url);
|
|
97
|
+
await waitForQueryState(progress, awaitStates);
|
|
98
|
+
return repo.handles[documentId] as DocHandle<T>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
describe('AutomergeRepo with Subduction', () => {
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
await initSubduction();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('change events', () => {
|
|
107
|
+
const repo = createRepo({ network: [] });
|
|
108
|
+
const handle = repo.create<{ field?: string }>();
|
|
109
|
+
|
|
110
|
+
let valueDuringChange: string | undefined;
|
|
111
|
+
handle.addListener('change', () => {
|
|
112
|
+
valueDuringChange = handle.doc()!.field;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
handle.change((doc: any) => {
|
|
116
|
+
doc.field = 'value';
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(valueDuringChange).to.eq('value');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('flush', async () => {
|
|
123
|
+
const storage = await createLevelAdapter();
|
|
124
|
+
const repo = createRepo({ network: [], storage });
|
|
125
|
+
const handle = repo.create<{ field?: string }>();
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < 10; i++) {
|
|
128
|
+
const p = repo.flush([handle.documentId]);
|
|
129
|
+
handle.change((doc: any) => {
|
|
130
|
+
doc.field = (doc.field ?? '') + randomBytes(1024).toString('hex');
|
|
131
|
+
});
|
|
132
|
+
await p;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('getChangeByHash', async () => {
|
|
137
|
+
const doc = from({ foo: 'bar' });
|
|
138
|
+
const copy = clone(doc);
|
|
139
|
+
const newDoc = change(copy, 'change', (doc: any) => {
|
|
140
|
+
doc.foo = 'baz';
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
const heads = getHeads(newDoc);
|
|
145
|
+
const changes = heads.map((hash) => getBackend(newDoc).getChangeByHash(hash));
|
|
146
|
+
expect(changes.length).to.equal(1);
|
|
147
|
+
expect(changes[0]).to.not.be.null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
const heads = getHeads(newDoc);
|
|
152
|
+
const changes = heads.map((hash) => getBackend(doc).getChangeByHash(hash));
|
|
153
|
+
expect(changes.length).to.equal(1);
|
|
154
|
+
expect(changes[0]).to.be.null;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('heads equality', () => {
|
|
159
|
+
const a: Heads = getHeads(from({ foo: 'bar' }));
|
|
160
|
+
const b: Heads = getHeads(from({ foo: 'bar' }));
|
|
161
|
+
const c: Heads = [...a];
|
|
162
|
+
|
|
163
|
+
expect(equals(a, b)).to.be.false;
|
|
164
|
+
expect(equals(a, c)).to.be.true;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('documents missing from local storage go to loading state', async () => {
|
|
168
|
+
const { repos, adapters } = await createHostClientRepoTopology();
|
|
169
|
+
const [host] = repos;
|
|
170
|
+
await connectAdapters(adapters);
|
|
171
|
+
const url = 'automerge:3JN8F3Z4dUWEEKKFN7WE9gEGvVUT' as AutomergeUrl;
|
|
172
|
+
|
|
173
|
+
const progress = host.findWithProgress(url);
|
|
174
|
+
expect(progress.peek().state).to.equal('loading');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('documents on disk go to ready state', async () => {
|
|
178
|
+
const storage = await createLevelAdapter();
|
|
179
|
+
let url: AutomergeUrl | undefined;
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
const repo = createRepo({ network: [], storage });
|
|
183
|
+
const handle = repo.create<{ field?: string }>({ field: 'value' });
|
|
184
|
+
url = handle.url;
|
|
185
|
+
await repo.flush();
|
|
186
|
+
await repo.shutdown();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
const repo = createRepo({ network: [], storage });
|
|
191
|
+
const handle = await repo.find<{ field?: string }>(url as AutomergeUrl);
|
|
192
|
+
await handle.whenReady(['ready']);
|
|
193
|
+
expect(handle.doc()?.field).to.equal('value');
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('network', () => {
|
|
198
|
+
test('basic networking', async () => {
|
|
199
|
+
const { repos, adapters } = await createHostClientRepoTopology();
|
|
200
|
+
const [host, client] = repos;
|
|
201
|
+
await connectAdapters(adapters);
|
|
202
|
+
|
|
203
|
+
const handle = host.create<{ text?: string }>();
|
|
204
|
+
const text = 'Hello world';
|
|
205
|
+
handle.change((doc: any) => {
|
|
206
|
+
doc.text = text;
|
|
207
|
+
});
|
|
208
|
+
await waitForSubductionSave();
|
|
209
|
+
|
|
210
|
+
await expect
|
|
211
|
+
.poll(async () => (await client.find<{ text?: string }>(handle.url)).doc()?.text, { timeout: 5_000 })
|
|
212
|
+
.toEqual(text);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('share config does not gate subduction replication', async () => {
|
|
216
|
+
const { repos, adapters } = await createHostClientRepoTopology({
|
|
217
|
+
shareConfig: {
|
|
218
|
+
access: async () => false,
|
|
219
|
+
announce: async () => false,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
const [host, client] = repos;
|
|
223
|
+
await connectAdapters(adapters);
|
|
224
|
+
|
|
225
|
+
const handle = host.create<{ text?: string }>();
|
|
226
|
+
handle.change((doc: any) => {
|
|
227
|
+
doc.text = 'Hello world';
|
|
228
|
+
});
|
|
229
|
+
await waitForSubductionSave();
|
|
230
|
+
|
|
231
|
+
await expect
|
|
232
|
+
.poll(async () => (await client.find<{ text?: string }>(handle.url)).doc()?.text, { timeout: 5_000 })
|
|
233
|
+
.toEqual('Hello world');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('replication through a 4 peer chain', async () => {
|
|
237
|
+
const { repos, adapters } = await createRepoTopology({
|
|
238
|
+
peers: ['A', 'B', 'C', 'D'],
|
|
239
|
+
connections: [
|
|
240
|
+
['A', 'B'],
|
|
241
|
+
['B', 'C'],
|
|
242
|
+
['C', 'D'],
|
|
243
|
+
],
|
|
244
|
+
});
|
|
245
|
+
const [repoA, repoB, repoC, repoD] = repos;
|
|
246
|
+
await connectAdapters(adapters);
|
|
247
|
+
|
|
248
|
+
const docA = repoA.create<{ text?: string }>();
|
|
249
|
+
docA.change((doc: any) => {
|
|
250
|
+
doc.text = 'Hello world';
|
|
251
|
+
});
|
|
252
|
+
await waitForSubductionSave();
|
|
253
|
+
|
|
254
|
+
const [docB, docC, docD] = await Promise.all([
|
|
255
|
+
findInStates<{ text?: string }>(repoB, docA.url, FIND_STATES),
|
|
256
|
+
findInStates<{ text?: string }>(repoC, docA.url, FIND_STATES),
|
|
257
|
+
findInStates<{ text?: string }>(repoD, docA.url, FIND_STATES),
|
|
258
|
+
]);
|
|
259
|
+
await expect
|
|
260
|
+
.poll(() => [docB.doc()?.text, docC.doc()?.text, docD.doc()?.text], { timeout: 5_000 })
|
|
261
|
+
.toEqual(['Hello world', 'Hello world', 'Hello world']);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('replication through a 3 peer chain', { timeout: 30_000 }, async () => {
|
|
265
|
+
const { repos, adapters } = await createRepoTopology({
|
|
266
|
+
peers: ['A', 'B', 'C'],
|
|
267
|
+
connections: [
|
|
268
|
+
['A', 'B'],
|
|
269
|
+
['B', 'C'],
|
|
270
|
+
],
|
|
271
|
+
});
|
|
272
|
+
const [repoA, repoB, repoC] = repos;
|
|
273
|
+
await connectAdapters(adapters);
|
|
274
|
+
|
|
275
|
+
const docA = repoA.create<{ text?: string }>();
|
|
276
|
+
docA.change((doc: any) => {
|
|
277
|
+
doc.text = 'Hello world';
|
|
278
|
+
});
|
|
279
|
+
await waitForSubductionSave();
|
|
280
|
+
|
|
281
|
+
const [docB, docC] = await Promise.all([
|
|
282
|
+
findInStates<{ text?: string }>(repoB, docA.url, FIND_STATES),
|
|
283
|
+
findInStates<{ text?: string }>(repoC, docA.url, FIND_STATES),
|
|
284
|
+
]);
|
|
285
|
+
await expect
|
|
286
|
+
.poll(() => [docB.doc()?.text, docC.doc()?.text], { timeout: 10_000 })
|
|
287
|
+
.toEqual(['Hello world', 'Hello world']);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('documents loaded from disk get replicated', async () => {
|
|
291
|
+
const storage = await createLevelAdapter();
|
|
292
|
+
let url: AutomergeUrl | undefined;
|
|
293
|
+
|
|
294
|
+
{
|
|
295
|
+
const peer1 = createRepo({ network: [], storage }, { registerCleanup: false });
|
|
296
|
+
const handle = peer1.create({ text: 'foo' });
|
|
297
|
+
await peer1.flush();
|
|
298
|
+
await waitForSubductionSave();
|
|
299
|
+
url = handle.url;
|
|
300
|
+
await shutdownRepo(peer1);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const { repos, adapters } = await createHostClientRepoTopology({ storages: [storage] });
|
|
304
|
+
const [peer1, peer2] = repos;
|
|
305
|
+
await connectAdapters(adapters);
|
|
306
|
+
|
|
307
|
+
const hostHandle = await peer1.find<any>(url as AutomergeUrl);
|
|
308
|
+
await hostHandle.whenReady();
|
|
309
|
+
await expect.poll(() => hostHandle.doc()?.text, { timeout: 5_000 }).toEqual('foo');
|
|
310
|
+
const peer2Handle = await findInStates<any>(peer2, hostHandle.url, FIND_STATES);
|
|
311
|
+
await expect.poll(() => peer2Handle.doc(), { timeout: 5_000 }).toEqual(hostHandle.doc());
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('client cold-starts and syncs doc from a Repo', async () => {
|
|
315
|
+
const repo = createRepo({ network: [] });
|
|
316
|
+
const serverHandle = repo.create<{ field?: string }>();
|
|
317
|
+
|
|
318
|
+
let clientDoc: A.Doc<{ field?: string }> | undefined;
|
|
319
|
+
const receiveByClient = (blob: Uint8Array) => {
|
|
320
|
+
clientDoc = clientDoc === undefined ? A.load(blob) : A.loadIncremental(clientDoc, blob);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
let syncedHeads = getHeads(serverHandle.doc()!);
|
|
324
|
+
receiveByClient(save(serverHandle.doc()!));
|
|
325
|
+
|
|
326
|
+
serverHandle.on('change', ({ doc }) => {
|
|
327
|
+
const blob = saveSince(doc, syncedHeads);
|
|
328
|
+
syncedHeads = getHeads(doc);
|
|
329
|
+
receiveByClient(blob);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const value = 'text to test if sync works';
|
|
333
|
+
serverHandle.change((doc: any) => {
|
|
334
|
+
doc.field = value;
|
|
335
|
+
});
|
|
336
|
+
expect(clientDoc?.field).to.deep.equal(value);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('client creates doc and syncs with a Repo', async () => {
|
|
340
|
+
const repo = createRepo({ network: [] });
|
|
341
|
+
const receiveByServer = (blob: Uint8Array, docId: DocumentId) => repo.import<any>(blob, { docId });
|
|
342
|
+
let clientDoc = A.from<{ field?: string }>({});
|
|
343
|
+
const { documentId } = parseAutomergeUrl(generateAutomergeUrl());
|
|
344
|
+
let sentHeads: Heads = [];
|
|
345
|
+
|
|
346
|
+
const sendDoc = async (doc: A.Doc<any>) => {
|
|
347
|
+
receiveByServer(saveSince(doc, sentHeads), documentId);
|
|
348
|
+
sentHeads = getHeads(doc);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const value = 'text to test if sync works';
|
|
352
|
+
clientDoc = A.change(clientDoc, (doc: any) => {
|
|
353
|
+
doc.field = value;
|
|
354
|
+
});
|
|
355
|
+
await sendDoc(clientDoc);
|
|
356
|
+
|
|
357
|
+
const serverHandle = await repo.find<any>(documentId);
|
|
358
|
+
expect(serverHandle.doc()!.field).to.deep.equal(value);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('client creates doc and Repo persists it to disk', async () => {
|
|
362
|
+
const storage = await createLevelAdapter();
|
|
363
|
+
const repo = createRepo({ network: [], storage });
|
|
364
|
+
const receiveByServer = async (blob: Uint8Array, docId: DocumentId) => {
|
|
365
|
+
repo.import<any>(blob, { docId });
|
|
366
|
+
await repo.flush([docId]);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
let clientDoc = A.from<{ field?: string }>({ field: 'foo' });
|
|
370
|
+
const { documentId } = parseAutomergeUrl(generateAutomergeUrl());
|
|
371
|
+
let sentHeads: Heads = [];
|
|
372
|
+
|
|
373
|
+
const sendDoc = async (doc: A.Doc<any>) => {
|
|
374
|
+
await receiveByServer(saveSince(doc, sentHeads), documentId);
|
|
375
|
+
sentHeads = getHeads(doc);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const value = 'text to test if sync works';
|
|
379
|
+
clientDoc = A.change(clientDoc, (doc: any) => {
|
|
380
|
+
doc.field = value;
|
|
381
|
+
});
|
|
382
|
+
await sendDoc(clientDoc);
|
|
383
|
+
|
|
384
|
+
const serverHandle = await repo.find<any>(documentId);
|
|
385
|
+
expect(serverHandle.doc()!.field).to.deep.equal(value);
|
|
386
|
+
await waitForSubductionSave();
|
|
387
|
+
await repo.shutdown();
|
|
388
|
+
|
|
389
|
+
const repo2 = createRepo({ network: [], storage });
|
|
390
|
+
const reloaded = await repo2.find<any>(documentId);
|
|
391
|
+
expect(reloaded.doc()!.field).to.deep.equal(value);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('two repo sync docs on `update` call', async () => {
|
|
395
|
+
const { repos, adapters } = await createHostClientRepoTopology();
|
|
396
|
+
const [repoA, repoB] = repos;
|
|
397
|
+
await connectAdapters(adapters);
|
|
398
|
+
|
|
399
|
+
const handleA = repoA.create<any>();
|
|
400
|
+
const handleB = await findInStates<any>(repoB, handleA.url, FIND_STATES);
|
|
401
|
+
|
|
402
|
+
const text = 'Hello world';
|
|
403
|
+
handleA.update((doc: any) => {
|
|
404
|
+
return A.change(doc, (doc: any) => {
|
|
405
|
+
doc.text = text;
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
await waitForSubductionSave();
|
|
409
|
+
|
|
410
|
+
expect(handleA.doc()!.text).to.equal(text);
|
|
411
|
+
await expect.poll(() => handleB.doc()?.text, { timeout: 5_000 }).toEqual(text);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// TODO(mykola): Mirrored from `automerge-repo.test.ts:'recovering from a
|
|
415
|
+
// lost connection'`. This test exposes a real gap in the subduction fork's
|
|
416
|
+
// post-disconnect recovery, characterized empirically as follows.
|
|
417
|
+
//
|
|
418
|
+
// Setup: peers connect, an initial change syncs. Then the test flips
|
|
419
|
+
// `TestAdapter`'s connection-state gate to `'off'`, makes an offline
|
|
420
|
+
// change, flips back to `'on'`, and calls `reconnectAdapters`
|
|
421
|
+
// (peer-disconnected + peer-candidate cycle).
|
|
422
|
+
//
|
|
423
|
+
// Observed:
|
|
424
|
+
// - The offline `#save` triggers `doSync`, which fails (dead transport)
|
|
425
|
+
// and sets `entry.lastSyncResult = "all-failed"`. The heal scheduler
|
|
426
|
+
// schedules a retry.
|
|
427
|
+
// - `reconnectAdapters` tears down the old `NetworkAdapterTransport` and
|
|
428
|
+
// `AdapterConnections.peer-candidate` listener spins up a fresh one,
|
|
429
|
+
// bumping the connection generation.
|
|
430
|
+
// - `SubductionSource.#recomputeEntry` in the `running` branch only
|
|
431
|
+
// re-syncs when `lastSyncResult === null` OR (lastSyncResult ===
|
|
432
|
+
// `"no-peers"` AND generation changed). It does NOT re-sync on
|
|
433
|
+
// `"all-failed"` even when the connection generation changed — only
|
|
434
|
+
// the heal scheduler retries `"all-failed"`, and its backoff
|
|
435
|
+
// (100 → 200 → 400 → 800 → 1600 → 3200 → 6400 ms ...) means
|
|
436
|
+
// recovery takes ~10+ s under the test's 15 s wall clock.
|
|
437
|
+
// - Even when heal rounds DO fire on the new transport (visible in
|
|
438
|
+
// `subduction:source` debug logs), they exchange heads-protocol frames
|
|
439
|
+
// (~126-byte messages tagged `S U M`) without delivering the offline
|
|
440
|
+
// commit. Suspected cause: the subduction-core peer state from the
|
|
441
|
+
// old transport is stale in heal scheduler bookkeeping and the new
|
|
442
|
+
// transport's peer is not yet treated as having the missing commits.
|
|
443
|
+
//
|
|
444
|
+
// Suggested fork fix: in `SubductionSource.#recomputeEntry`, treat
|
|
445
|
+
// `lastSyncResult === "all-failed"` the same as `"no-peers"` for the
|
|
446
|
+
// `lastSyncGeneration !== this.#connectionGeneration()` re-sync check —
|
|
447
|
+
// a connection-generation change means the prior failure was on a
|
|
448
|
+
// now-dead peer set and is worth retrying immediately.
|
|
449
|
+
//
|
|
450
|
+
// Marked `.todo` until the fork driver retries on connection change for
|
|
451
|
+
// `"all-failed"` results. Enabling this test as `test()` reliably times
|
|
452
|
+
// out at 15 s.
|
|
453
|
+
test.todo('recovering from a lost connection');
|
|
454
|
+
|
|
455
|
+
// Mirrored from `automerge-repo.test.ts:'replicate document after request'`,
|
|
456
|
+
// adapted for subduction:
|
|
457
|
+
// - Classical version asserts the query reaches `'unavailable'` before
|
|
458
|
+
// peer candidates are emitted. Subduction does NOT drive this query
|
|
459
|
+
// to `'unavailable'` while a dormant subduction source is attached —
|
|
460
|
+
// the SKILL doc and inline TODOs in the classical file call this out.
|
|
461
|
+
// We assert "stays in `'loading'`" instead.
|
|
462
|
+
// - After emitting peer candidates AND cycling adapters via
|
|
463
|
+
// `reconnectAdapters`, the query reaches `'ready'`. The reconnect is
|
|
464
|
+
// necessary because the first peer-candidate emit is consumed by
|
|
465
|
+
// `AdapterConnections` to start a transport, but the dormant
|
|
466
|
+
// subduction source's `lastSyncResult` may already be in
|
|
467
|
+
// `'no-peers'` from prior recompute cycles; `reconnectAdapters`
|
|
468
|
+
// bumps the connection generation and forces a re-sync.
|
|
469
|
+
test('replicate document after request', { timeout: 15_000 }, async () => {
|
|
470
|
+
const { repos, adapters } = await createHostClientRepoTopology();
|
|
471
|
+
const [host, client] = repos;
|
|
472
|
+
await connectAdapters(adapters, { noEmitPeerCandidate: true });
|
|
473
|
+
|
|
474
|
+
const docA = host.create<{ text?: string }>();
|
|
475
|
+
docA.change((doc: any) => {
|
|
476
|
+
doc.text = 'Hello world';
|
|
477
|
+
});
|
|
478
|
+
await waitForSubductionSave();
|
|
479
|
+
|
|
480
|
+
const progress = client.findWithProgress<{ text?: string }>(docA.url);
|
|
481
|
+
// TODO(mykola): Subduction does not drive this query to `'unavailable'` while
|
|
482
|
+
// a dormant subduction source is attached; assert "stays in loading" instead.
|
|
483
|
+
// Switch to a positive `'unavailable'` assertion once the fork drives source
|
|
484
|
+
// state correctly. See `.agents/skills/effect/subduction/SKILL.md`.
|
|
485
|
+
await expect.poll(() => progress.peek().state, { timeout: 500, interval: 50 }).toEqual('loading');
|
|
486
|
+
|
|
487
|
+
// Bring the peers online. `reconnectAdapters` does
|
|
488
|
+
// peer-disconnected + peer-candidate, which causes `AdapterConnections`
|
|
489
|
+
// to start a fresh transport and bumps the generation so the dormant
|
|
490
|
+
// source re-syncs. We must reconnect on BOTH sides; with only one side
|
|
491
|
+
// emitting `peer-candidate`, only one transport is initiated.
|
|
492
|
+
await reconnectAdapters(adapters);
|
|
493
|
+
await waitForQueryState(progress, ['ready'], { timeout: 10_000 });
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// The contract block below tests subduction-specific behavior described in
|
|
498
|
+
// `.agents/skills/effect/subduction/SKILL.md`. None of these can be expressed
|
|
499
|
+
// against the classical-network transport, which is why they live here and
|
|
500
|
+
// not in `automerge-repo.test.ts`.
|
|
501
|
+
describe('subduction contract', () => {
|
|
502
|
+
describe('role matrix', () => {
|
|
503
|
+
test('connect/accept syncs', async () => {
|
|
504
|
+
const { repos, adapters } = await createHostClientRepoTopology({
|
|
505
|
+
roles: { host: 'connect', client: 'accept' },
|
|
506
|
+
});
|
|
507
|
+
const [host, client] = repos;
|
|
508
|
+
await connectAdapters(adapters);
|
|
509
|
+
|
|
510
|
+
const handle = host.create<{ text?: string }>();
|
|
511
|
+
handle.change((doc: any) => {
|
|
512
|
+
doc.text = 'connect/accept';
|
|
513
|
+
});
|
|
514
|
+
await waitForSubductionSave();
|
|
515
|
+
|
|
516
|
+
await expect
|
|
517
|
+
.poll(async () => (await client.find<{ text?: string }>(handle.url)).doc()?.text, { timeout: 5_000 })
|
|
518
|
+
.toEqual('connect/accept');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('accept/connect syncs', async () => {
|
|
522
|
+
const { repos, adapters } = await createHostClientRepoTopology({
|
|
523
|
+
roles: { host: 'accept', client: 'connect' },
|
|
524
|
+
});
|
|
525
|
+
const [host, client] = repos;
|
|
526
|
+
await connectAdapters(adapters);
|
|
527
|
+
|
|
528
|
+
const handle = host.create<{ text?: string }>();
|
|
529
|
+
handle.change((doc: any) => {
|
|
530
|
+
doc.text = 'accept/connect';
|
|
531
|
+
});
|
|
532
|
+
await waitForSubductionSave();
|
|
533
|
+
|
|
534
|
+
await expect
|
|
535
|
+
.poll(async () => (await client.find<{ text?: string }>(handle.url)).doc()?.text, { timeout: 5_000 })
|
|
536
|
+
.toEqual('accept/connect');
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// SKILL doc: two `accept` peers are both responders, neither initiates the
|
|
540
|
+
// handshake, so the query never reaches `'ready'`. We bound the negative
|
|
541
|
+
// assertion at 1.5 s; if the fork ever heals via retry this test will catch
|
|
542
|
+
// the change.
|
|
543
|
+
test('accept/accept does not sync', async () => {
|
|
544
|
+
const { repos, adapters } = await createHostClientRepoTopology({
|
|
545
|
+
roles: { host: 'accept', client: 'accept' },
|
|
546
|
+
});
|
|
547
|
+
const [host, client] = repos;
|
|
548
|
+
await connectAdapters(adapters);
|
|
549
|
+
|
|
550
|
+
const handle = host.create<{ text?: string }>();
|
|
551
|
+
handle.change((doc: any) => {
|
|
552
|
+
doc.text = 'accept/accept';
|
|
553
|
+
});
|
|
554
|
+
await waitForSubductionSave();
|
|
555
|
+
|
|
556
|
+
const progress = client.findWithProgress<{ text?: string }>(handle.url);
|
|
557
|
+
// Stays in `'loading'` (or worst case never reaches `'ready'`) within the
|
|
558
|
+
// window. We can't assert `'loading'` strictly because the dormant
|
|
559
|
+
// subduction source could transition to `'unavailable'`; what we CAN
|
|
560
|
+
// assert is that the query never reaches `'ready'`.
|
|
561
|
+
await sleep(NEGATIVE_ASSERTION_DELAY_MS);
|
|
562
|
+
expect(progress.peek().state).to.not.equal('ready');
|
|
563
|
+
const docHandle = client.handles[parseAutomergeUrl(handle.url).documentId] as
|
|
564
|
+
| DocHandle<{ text?: string }>
|
|
565
|
+
| undefined;
|
|
566
|
+
expect(docHandle?.doc()?.text).to.be.undefined;
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
describe('subductionPolicy gates', () => {
|
|
571
|
+
// TODO(mykola): These tests probe behavior the SKILL doc claims is gated by
|
|
572
|
+
// `subductionPolicy`. If a denial path doesn't actually surface as a stuck
|
|
573
|
+
// `'loading'` query (for example, the fork swallows the rejection and
|
|
574
|
+
// surfaces `'unavailable'` or `'failed'` instead), we update the assertion
|
|
575
|
+
// and leave a TODO pointing at the SKILL doc claim. We do NOT force-pass.
|
|
576
|
+
// `authorizeFetch` is consulted on the SERVER side of a fetch — i.e. when
|
|
577
|
+
// a peer requests a sedimentree from us, our `authorizeFetch` decides
|
|
578
|
+
// whether we serve it. The SKILL doc summary "gates reads of a specific
|
|
579
|
+
// sedimentree" is server-centric, not fetcher-centric.
|
|
580
|
+
//
|
|
581
|
+
// To exercise this we need a scenario where replication relies on the
|
|
582
|
+
// client EXPLICITLY pulling from the host (rather than the host pushing
|
|
583
|
+
// proactively when the client connects). We do this by:
|
|
584
|
+
// 1. Creating the doc on the host BEFORE the client subduction adapter
|
|
585
|
+
// connects (so there's no peer to push to at create time).
|
|
586
|
+
// 2. Restarting the host repo from the same storage AFTER the offline
|
|
587
|
+
// `host.flush()` (so subduction-core in the new host repo has no
|
|
588
|
+
// memory of having advertised this commit).
|
|
589
|
+
// 3. Connecting the client and having it call `find(url)`, which
|
|
590
|
+
// triggers an explicit fetch from the client to the host.
|
|
591
|
+
//
|
|
592
|
+
// With host's `authorizeFetch` denying, the host should refuse the
|
|
593
|
+
// client's fetch request and the client's query should not reach
|
|
594
|
+
// `'ready'` within the timeout. With the host permissive, the same
|
|
595
|
+
// setup DOES sync (verified by the parallel control test below).
|
|
596
|
+
test('authorizeFetch denial on server blocks fetcher', async () => {
|
|
597
|
+
const denyingPolicy: SubductionPolicy = {
|
|
598
|
+
authorizeConnect: async () => {},
|
|
599
|
+
authorizeFetch: async () => {
|
|
600
|
+
throw new Error('denied');
|
|
601
|
+
},
|
|
602
|
+
authorizePut: async () => {},
|
|
603
|
+
filterAuthorizedFetch: async (_peerId, ids) => ids,
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const { repos, adapters } = await createHostClientRepoTopology({
|
|
607
|
+
subductionPolicies: { host: denyingPolicy },
|
|
608
|
+
});
|
|
609
|
+
const [host, client] = repos;
|
|
610
|
+
await connectAdapters(adapters);
|
|
611
|
+
|
|
612
|
+
const handle = host.create<{ text?: string }>({ text: 'should-not-fetch' });
|
|
613
|
+
await waitForSubductionSave();
|
|
614
|
+
|
|
615
|
+
// Client explicitly requests the doc by URL. Host's `authorizeFetch`
|
|
616
|
+
// denial should prevent the response. We rely on `findWithProgress`
|
|
617
|
+
// staying out of `'ready'` and the doc body remaining empty.
|
|
618
|
+
const progress = client.findWithProgress<{ text?: string }>(handle.url);
|
|
619
|
+
await sleep(NEGATIVE_ASSERTION_DELAY_MS);
|
|
620
|
+
expect(progress.peek().state).to.not.equal('ready');
|
|
621
|
+
const docHandle = client.handles[parseAutomergeUrl(handle.url).documentId] as
|
|
622
|
+
| DocHandle<{ text?: string }>
|
|
623
|
+
| undefined;
|
|
624
|
+
expect(docHandle?.doc()?.text).to.be.undefined;
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Control for the test above: same setup, host policy permissive →
|
|
628
|
+
// doc DOES reach `'ready'` on the client. If this control failed
|
|
629
|
+
// alongside the `authorizeFetch` denial test, the negative result
|
|
630
|
+
// wouldn't actually prove anything — we'd just have a flaky
|
|
631
|
+
// replication setup. Keeping this control here pins the contract
|
|
632
|
+
// ("with permissive host, client gets the doc") and makes the
|
|
633
|
+
// `authorizeFetch` denial result meaningful.
|
|
634
|
+
test('authorizeFetch permissive on server allows fetcher (control)', async () => {
|
|
635
|
+
const { repos, adapters } = await createHostClientRepoTopology();
|
|
636
|
+
const [host, client] = repos;
|
|
637
|
+
await connectAdapters(adapters);
|
|
638
|
+
|
|
639
|
+
const handle = host.create<{ text?: string }>({ text: 'should-fetch' });
|
|
640
|
+
await waitForSubductionSave();
|
|
641
|
+
|
|
642
|
+
await expect
|
|
643
|
+
.poll(async () => (await client.find<{ text?: string }>(handle.url)).doc()?.text, {
|
|
644
|
+
timeout: 5_000,
|
|
645
|
+
})
|
|
646
|
+
.toEqual('should-fetch');
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test('authorizeConnect denial blocks handshake', async () => {
|
|
650
|
+
const denyingPolicy: SubductionPolicy = {
|
|
651
|
+
authorizeConnect: async () => {
|
|
652
|
+
throw new Error('denied');
|
|
653
|
+
},
|
|
654
|
+
authorizeFetch: async () => {},
|
|
655
|
+
authorizePut: async () => {},
|
|
656
|
+
filterAuthorizedFetch: async (_peerId, ids) => ids,
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const { repos, adapters } = await createHostClientRepoTopology({
|
|
660
|
+
subductionPolicies: { client: denyingPolicy },
|
|
661
|
+
});
|
|
662
|
+
const [host, client] = repos;
|
|
663
|
+
await connectAdapters(adapters);
|
|
664
|
+
|
|
665
|
+
const handle = host.create<{ text?: string }>();
|
|
666
|
+
handle.change((doc: any) => {
|
|
667
|
+
doc.text = 'should-not-arrive';
|
|
668
|
+
});
|
|
669
|
+
await waitForSubductionSave();
|
|
670
|
+
|
|
671
|
+
const progress = client.findWithProgress<{ text?: string }>(handle.url);
|
|
672
|
+
await sleep(NEGATIVE_ASSERTION_DELAY_MS);
|
|
673
|
+
expect(progress.peek().state).to.not.equal('ready');
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('authorizePut denial blocks writes from peer', async () => {
|
|
677
|
+
const denyingPolicy: SubductionPolicy = {
|
|
678
|
+
authorizeConnect: async () => {},
|
|
679
|
+
authorizeFetch: async () => {},
|
|
680
|
+
authorizePut: async () => {
|
|
681
|
+
throw new Error('denied');
|
|
682
|
+
},
|
|
683
|
+
filterAuthorizedFetch: async (_peerId, ids) => ids,
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// Receiver creates the doc and seeds initial content; sender then attempts
|
|
687
|
+
// to push a change. Receiver's policy denies puts → receiver doc must
|
|
688
|
+
// remain at the original heads.
|
|
689
|
+
const { repos, adapters } = await createHostClientRepoTopology({
|
|
690
|
+
subductionPolicies: { host: denyingPolicy },
|
|
691
|
+
});
|
|
692
|
+
const [host, client] = repos;
|
|
693
|
+
await connectAdapters(adapters);
|
|
694
|
+
|
|
695
|
+
const hostHandle = host.create<{ text?: string }>({ text: 'initial' });
|
|
696
|
+
await waitForSubductionSave();
|
|
697
|
+
|
|
698
|
+
const clientHandle = await findInStates<{ text?: string }>(client, hostHandle.url, FIND_STATES);
|
|
699
|
+
const initialHostHeads = getHeads(hostHandle.doc()!);
|
|
700
|
+
|
|
701
|
+
clientHandle.change((doc: any) => {
|
|
702
|
+
doc.text = 'should-be-rejected';
|
|
703
|
+
});
|
|
704
|
+
await sleep(NEGATIVE_ASSERTION_DELAY_MS);
|
|
705
|
+
|
|
706
|
+
// Host should not have accepted client's change.
|
|
707
|
+
expect(getHeads(hostHandle.doc()!)).to.deep.equal(initialHostHeads);
|
|
708
|
+
expect(hostHandle.doc()?.text).to.equal('initial');
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// `filterAuthorizedFetch` narrows which sedimentrees a fetcher is allowed
|
|
712
|
+
// to RECEIVE in response to a fetch RPC. Under the current
|
|
713
|
+
// automerge-repo subduction bridge wiring, however, replication is
|
|
714
|
+
// primarily driven by proactive PUSH at connection time and on every
|
|
715
|
+
// `#save`: the host announces its sedimentrees to connected peers via
|
|
716
|
+
// the same channel that delivers commits. That push path does NOT
|
|
717
|
+
// appear to consult `filterAuthorizedFetch` (which is a fetch-response
|
|
718
|
+
// filter, not a push-advertise filter — recall the SKILL doc note that
|
|
719
|
+
// there is no `authorizeAdvertise` hook).
|
|
720
|
+
//
|
|
721
|
+
// Empirically: with a host-side `filterAuthorizedFetch` returning only
|
|
722
|
+
// the allowed doc id, BOTH the allowed and blocked docs reach
|
|
723
|
+
// `'ready'` on the client because the host pushes both. The filter is
|
|
724
|
+
// essentially dead in this bridge.
|
|
725
|
+
//
|
|
726
|
+
// We assert the empirical behavior here and TODO the gap. To actually
|
|
727
|
+
// gate replication you must combine `authorizePut` on the receiver and
|
|
728
|
+
// `authorizeFetch` on the server, OR add an advertise-side hook to
|
|
729
|
+
// the bridge upstream.
|
|
730
|
+
//
|
|
731
|
+
// TODO(mykola): If the fork later adds an advertise hook OR
|
|
732
|
+
// `filterAuthorizedFetch` starts gating push, flip this assertion to
|
|
733
|
+
// `not.equal('ready')` and `expect(blocked).to.be.undefined`.
|
|
734
|
+
test('filterAuthorizedFetch on server is bypassed by proactive push', async () => {
|
|
735
|
+
let allowedDoc: DocumentId | undefined;
|
|
736
|
+
const filteringPolicy: SubductionPolicy = {
|
|
737
|
+
authorizeConnect: async () => {},
|
|
738
|
+
authorizeFetch: async () => {},
|
|
739
|
+
authorizePut: async () => {},
|
|
740
|
+
filterAuthorizedFetch: async (_peerId, ids) => {
|
|
741
|
+
if (allowedDoc === undefined) {
|
|
742
|
+
return ids;
|
|
743
|
+
}
|
|
744
|
+
return ids.filter((id) => id.toString() === (allowedDoc as unknown as string));
|
|
745
|
+
},
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const { repos, adapters } = await createHostClientRepoTopology({
|
|
749
|
+
subductionPolicies: { host: filteringPolicy },
|
|
750
|
+
});
|
|
751
|
+
const [host, client] = repos;
|
|
752
|
+
await connectAdapters(adapters);
|
|
753
|
+
|
|
754
|
+
const allowedHandle = host.create<{ text?: string }>({ text: 'allowed' });
|
|
755
|
+
const blockedHandle = host.create<{ text?: string }>({ text: 'blocked' });
|
|
756
|
+
allowedDoc = allowedHandle.documentId;
|
|
757
|
+
await waitForSubductionSave();
|
|
758
|
+
|
|
759
|
+
// Allowed doc replicates as expected.
|
|
760
|
+
await expect
|
|
761
|
+
.poll(async () => (await client.find<{ text?: string }>(allowedHandle.url)).doc()?.text, {
|
|
762
|
+
timeout: 5_000,
|
|
763
|
+
})
|
|
764
|
+
.toEqual('allowed');
|
|
765
|
+
|
|
766
|
+
// Documented gap: the supposedly-blocked doc ALSO reaches the client
|
|
767
|
+
// via proactive push, despite the filter.
|
|
768
|
+
await expect
|
|
769
|
+
.poll(async () => (await client.find<{ text?: string }>(blockedHandle.url)).doc()?.text, {
|
|
770
|
+
timeout: 5_000,
|
|
771
|
+
})
|
|
772
|
+
.toEqual('blocked');
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
// Mutable-policy recovery. We hold a closure-captured `allowFetch` flag
|
|
776
|
+
// and pass a policy whose `authorizeFetch` consults it on each call. The
|
|
777
|
+
// policy reference itself never changes (subduction doesn't rebind it
|
|
778
|
+
// after construction), but its observable behavior does.
|
|
779
|
+
//
|
|
780
|
+
// Sequence:
|
|
781
|
+
// 1. allowFetch = false. Connect, request the doc on the client. Host's
|
|
782
|
+
// `authorizeFetch` denies → fetch RPC fails → entry settles in
|
|
783
|
+
// `lastSyncResult === "all-failed"`.
|
|
784
|
+
// 2. Flip allowFetch = true.
|
|
785
|
+
// 3. Call `client.shareConfigChanged()`. This is the documented escape
|
|
786
|
+
// hatch (see SKILL doc): `SubductionSource.shareConfigChanged()`
|
|
787
|
+
// resets `"all-failed"` entries to `null`, clears the heal backoff,
|
|
788
|
+
// and re-syncs immediately. On the next attempt, host's
|
|
789
|
+
// `authorizeFetch` returns `true` and the doc replicates.
|
|
790
|
+
//
|
|
791
|
+
// This is the only graceful policy-flip path: without
|
|
792
|
+
// `shareConfigChanged()`, the entry sits in `"all-failed"` and recovery
|
|
793
|
+
// depends entirely on the heal scheduler's exponential backoff.
|
|
794
|
+
test('shareConfigChanged() retries after subductionPolicy denial flips to allow', async () => {
|
|
795
|
+
let allowFetch = false;
|
|
796
|
+
const mutablePolicy: SubductionPolicy = {
|
|
797
|
+
authorizeConnect: async () => {},
|
|
798
|
+
authorizeFetch: async () => {
|
|
799
|
+
if (!allowFetch) {
|
|
800
|
+
throw new Error('denied');
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
authorizePut: async () => {},
|
|
804
|
+
filterAuthorizedFetch: async (_peerId, ids) => ids,
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
const { repos, adapters } = await createHostClientRepoTopology({
|
|
808
|
+
subductionPolicies: { host: mutablePolicy },
|
|
809
|
+
});
|
|
810
|
+
const [host, client] = repos;
|
|
811
|
+
await connectAdapters(adapters);
|
|
812
|
+
|
|
813
|
+
const handle = host.create<{ text?: string }>({ text: 'gated' });
|
|
814
|
+
await waitForSubductionSave();
|
|
815
|
+
|
|
816
|
+
// (1) Initial denial: query does not reach `'ready'`, doc stays empty.
|
|
817
|
+
const progress = client.findWithProgress<{ text?: string }>(handle.url);
|
|
818
|
+
await sleep(NEGATIVE_ASSERTION_DELAY_MS);
|
|
819
|
+
expect(progress.peek().state).to.not.equal('ready');
|
|
820
|
+
const docHandle = client.handles[parseAutomergeUrl(handle.url).documentId] as
|
|
821
|
+
| DocHandle<{ text?: string }>
|
|
822
|
+
| undefined;
|
|
823
|
+
expect(docHandle?.doc()?.text).to.be.undefined;
|
|
824
|
+
|
|
825
|
+
// (2) Flip the policy and (3) kick the source. Without the kick,
|
|
826
|
+
// recovery is left to heal-retry exponential backoff (slow + flaky).
|
|
827
|
+
allowFetch = true;
|
|
828
|
+
client.shareConfigChanged();
|
|
829
|
+
|
|
830
|
+
// Sync now succeeds: doc reaches the client.
|
|
831
|
+
await expect.poll(() => docHandle?.doc()?.text, { timeout: 5_000 }).toEqual('gated');
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Negative control: with no subduction adapters and no classical network,
|
|
836
|
+
// documents must NOT replicate. If this passes while the positive tests above
|
|
837
|
+
// also pass, we know the positive tests are exercising the subduction
|
|
838
|
+
// transport rather than some unintended back-channel.
|
|
839
|
+
test('negative control: without subductionAdapters and network=[], peers do not sync', async () => {
|
|
840
|
+
const peer1 = createRepo({ network: [] });
|
|
841
|
+
const peer2 = createRepo({ network: [] });
|
|
842
|
+
|
|
843
|
+
const handle = peer1.create<{ text?: string }>();
|
|
844
|
+
handle.change((doc: any) => {
|
|
845
|
+
doc.text = 'should-stay-local';
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const progress = peer2.findWithProgress<{ text?: string }>(handle.url);
|
|
849
|
+
await sleep(NEGATIVE_ASSERTION_DELAY_MS);
|
|
850
|
+
expect(progress.peek().state).to.not.equal('ready');
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
// Confirms B is actually relaying via subduction in a 3-peer chain, by
|
|
854
|
+
// counting messages of type SUBDUCTION_MESSAGE_TYPE that traverse the B↔C
|
|
855
|
+
// adapter pair while a doc created on A propagates to C.
|
|
856
|
+
test('B relays via subduction in 3-peer chain', async () => {
|
|
857
|
+
let bcSubductionMessages = 0;
|
|
858
|
+
const onMessage = (message: Message) => {
|
|
859
|
+
if (message.type === SUBDUCTION_MESSAGE_TYPE) {
|
|
860
|
+
// We only count messages on the B↔C pair. `createRepoTopology` invokes
|
|
861
|
+
// this hook for every adapter pair, but since this test uses a
|
|
862
|
+
// fresh topology and only A↔B and B↔C exist, a positive count on the
|
|
863
|
+
// B↔C pair (filtered below by `targetId`) is sufficient.
|
|
864
|
+
bcSubductionMessages++;
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const { repos, adapters } = await createRepoTopology({
|
|
869
|
+
peers: ['A', 'B', 'C'],
|
|
870
|
+
connections: [
|
|
871
|
+
['A', 'B'],
|
|
872
|
+
['B', 'C'],
|
|
873
|
+
],
|
|
874
|
+
// Hook installed only on the B↔C pair (second connection).
|
|
875
|
+
onMessageByConnection: { 1: onMessage },
|
|
876
|
+
});
|
|
877
|
+
const [repoA, repoB, repoC] = repos;
|
|
878
|
+
await connectAdapters(adapters);
|
|
879
|
+
|
|
880
|
+
const docA = repoA.create<{ text?: string }>();
|
|
881
|
+
docA.change((doc: any) => {
|
|
882
|
+
doc.text = 'relayed';
|
|
883
|
+
});
|
|
884
|
+
await waitForSubductionSave();
|
|
885
|
+
|
|
886
|
+
const docC = await findInStates<{ text?: string }>(repoC, docA.url, FIND_STATES);
|
|
887
|
+
await expect.poll(() => docC.doc()?.text, { timeout: 10_000 }).toEqual('relayed');
|
|
888
|
+
|
|
889
|
+
// If C reached `'ready'` AND we observed subduction-typed messages on the
|
|
890
|
+
// B↔C pair, B is relaying via subduction (not via some classical bypass).
|
|
891
|
+
expect(bcSubductionMessages).to.be.greaterThan(0);
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
const createLevelAdapter = async (level = createTestLevel()) => {
|
|
896
|
+
const storage = new LevelDBStorageAdapter({ db: level.sublevel('automerge') });
|
|
897
|
+
await openAndClose(level, storage);
|
|
898
|
+
return storage;
|
|
899
|
+
};
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
type ShareConfig = Exclude<ConstructorParameters<typeof Repo>[0], undefined>['shareConfig'];
|
|
903
|
+
|
|
904
|
+
type ConnectedRepoOptions = {
|
|
905
|
+
storages?: StorageAdapterInterface[];
|
|
906
|
+
connectionStateProvider?: TestConnectionStateProvider;
|
|
907
|
+
shareConfig?: ShareConfig;
|
|
908
|
+
/**
|
|
909
|
+
* Per-peer subduction adapter role override. Defaults to `'connect'` for every
|
|
910
|
+
* peer when omitted (matches the production wiring in `AutomergeHost`).
|
|
911
|
+
*/
|
|
912
|
+
roles?: Record<string, 'connect' | 'accept'>;
|
|
913
|
+
/**
|
|
914
|
+
* Per-peer `subductionPolicy` override. Defaults to all-permissive for any
|
|
915
|
+
* peer not listed here. Matches the production wiring in `AutomergeHost`.
|
|
916
|
+
*/
|
|
917
|
+
subductionPolicies?: Record<string, SubductionPolicy>;
|
|
918
|
+
/**
|
|
919
|
+
* Optional per-connection `onMessage` hooks, keyed by index into `connections`.
|
|
920
|
+
* Each hook is invoked for every message that traverses the indexed
|
|
921
|
+
* `TestAdapter.createPair` pair, in either direction.
|
|
922
|
+
*/
|
|
923
|
+
onMessageByConnection?: Record<number, (message: Message) => void>;
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
const createRepoTopology = async <Peers extends string[], Peer extends string = Peers[number]>(args: {
|
|
927
|
+
peers: Peers;
|
|
928
|
+
connections: [Peer, Peer][];
|
|
929
|
+
options?: ConnectedRepoOptions;
|
|
930
|
+
onMessage?: (message: Message) => void;
|
|
931
|
+
onMessageByConnection?: Record<number, (message: Message) => void>;
|
|
932
|
+
}) => {
|
|
933
|
+
const onMessageByConnection = args.onMessageByConnection ?? args.options?.onMessageByConnection ?? {};
|
|
934
|
+
const adapters = args.connections.map((_, idx) => {
|
|
935
|
+
const perConnectionHook = onMessageByConnection[idx];
|
|
936
|
+
const handler = (message: Message) => {
|
|
937
|
+
args.onMessage?.(message);
|
|
938
|
+
perConnectionHook?.(message);
|
|
939
|
+
};
|
|
940
|
+
return TestAdapter.createPair(args.options?.connectionStateProvider, handler) as [TestAdapter, TestAdapter];
|
|
941
|
+
});
|
|
942
|
+
const repos = args.peers.map((peerId, peerIndex) => {
|
|
943
|
+
const network = adapters
|
|
944
|
+
.map((pair, idx) => {
|
|
945
|
+
return args.connections[idx].includes(peerId as Peer)
|
|
946
|
+
? peerId === args.connections[idx][0]
|
|
947
|
+
? pair[0]
|
|
948
|
+
: pair[1]
|
|
949
|
+
: null;
|
|
950
|
+
})
|
|
951
|
+
.filter(isNonNullable);
|
|
952
|
+
|
|
953
|
+
const role = args.options?.roles?.[peerId as string] ?? 'connect';
|
|
954
|
+
const subductionPolicy = args.options?.subductionPolicies?.[peerId as string];
|
|
955
|
+
|
|
956
|
+
return createRepo(
|
|
957
|
+
{
|
|
958
|
+
peerId: peerId as PeerId,
|
|
959
|
+
storage: args.options?.storages?.[peerIndex],
|
|
960
|
+
network: [],
|
|
961
|
+
shareConfig: args.options?.shareConfig,
|
|
962
|
+
...(subductionPolicy ? { subductionPolicy } : {}),
|
|
963
|
+
subductionAdapters: network.map((adapter) => ({
|
|
964
|
+
adapter,
|
|
965
|
+
serviceName: SUBDUCTION_SERVICE_NAME,
|
|
966
|
+
role,
|
|
967
|
+
})),
|
|
968
|
+
},
|
|
969
|
+
{ registerCleanup: false },
|
|
970
|
+
);
|
|
971
|
+
});
|
|
972
|
+
onTestFinished(async () => {
|
|
973
|
+
await Promise.all(repos.map((repo) => repo.flush().catch(() => {})));
|
|
974
|
+
await Promise.all(repos.map((repo) => shutdownRepo(repo)));
|
|
975
|
+
disconnectAdapters(adapters);
|
|
976
|
+
});
|
|
977
|
+
return { repos, adapters };
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
const createHostClientRepoTopology = (options?: ConnectedRepoOptions) =>
|
|
981
|
+
createRepoTopology({
|
|
982
|
+
peers: HOST_AND_CLIENT,
|
|
983
|
+
connections: [HOST_AND_CLIENT],
|
|
984
|
+
options,
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
const connectAdapters = async (pairs: [TestAdapter, TestAdapter][], options?: { noEmitPeerCandidate?: boolean }) => {
|
|
988
|
+
for (const pair of pairs) {
|
|
989
|
+
await pair[0].onConnect.wait();
|
|
990
|
+
await pair[1].onConnect.wait();
|
|
991
|
+
if (!options?.noEmitPeerCandidate) {
|
|
992
|
+
pair[0].peerCandidate(pair[1].peerId!);
|
|
993
|
+
pair[1].peerCandidate(pair[0].peerId!);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
const disconnectAdapters = (pairs: [TestAdapter, TestAdapter][]) => {
|
|
999
|
+
for (const [left, right] of pairs) {
|
|
1000
|
+
if (left.peerId && right.peerId) {
|
|
1001
|
+
left.peerDisconnected(right.peerId);
|
|
1002
|
+
right.peerDisconnected(left.peerId);
|
|
1003
|
+
}
|
|
1004
|
+
left.disconnect();
|
|
1005
|
+
right.disconnect();
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
const reconnectAdapters = async (pairs: [TestAdapter, TestAdapter][]) => {
|
|
1010
|
+
for (const pair of pairs) {
|
|
1011
|
+
pair[0].peerDisconnected(pair[1].peerId!);
|
|
1012
|
+
pair[1].peerDisconnected(pair[0].peerId!);
|
|
1013
|
+
pair[0].peerCandidate(pair[1].peerId!);
|
|
1014
|
+
pair[1].peerCandidate(pair[0].peerId!);
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
const shutdownRepo = async (repo: Repo) => {
|
|
1019
|
+
await repo.shutdown().catch(() => {});
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const createRepo = (
|
|
1023
|
+
options?: ConstructorParameters<typeof Repo>[0],
|
|
1024
|
+
cleanupOptions: { registerCleanup?: boolean } = {},
|
|
1025
|
+
) => {
|
|
1026
|
+
const repo = new Repo({
|
|
1027
|
+
// Reduce sync timeout so inflight syncWithAllPeers fails fast when a relay
|
|
1028
|
+
// peer does not yet have the document; self-healing retries kick in quickly.
|
|
1029
|
+
subductionTimeouts: {
|
|
1030
|
+
syncMs: 2_000,
|
|
1031
|
+
healInitialDelayMs: 100,
|
|
1032
|
+
},
|
|
1033
|
+
...options,
|
|
1034
|
+
});
|
|
1035
|
+
if (cleanupOptions.registerCleanup ?? true) {
|
|
1036
|
+
onTestFinished(async () => {
|
|
1037
|
+
await shutdownRepo(repo);
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
return repo;
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
const waitForSubductionSave = async () => {
|
|
1044
|
+
await sleep(150);
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
export const createTmpPath = (): string => {
|
|
1048
|
+
return `/tmp/dxos-${PublicKey.random().toHex()}`;
|
|
1049
|
+
};
|