@dxos/echo-pipeline 0.8.3 → 0.8.4-main.1068cf700f
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/neutral/chunk-FJPXA75J.mjs +235 -0
- package/dist/lib/neutral/chunk-FJPXA75J.mjs.map +7 -0
- package/dist/lib/{browser/chunk-35I6ERLG.mjs → neutral/chunk-WIQUYO7H.mjs} +300 -213
- package/dist/lib/neutral/chunk-WIQUYO7H.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/filter/index.mjs +3 -2
- package/dist/lib/{browser → neutral}/index.mjs +2832 -946
- 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 +73 -224
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/automerge/automerge-host.d.ts +53 -42
- package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
- 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/collection-synchronizer.d.ts +6 -4
- package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-network-adapter.d.ts +22 -10
- package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-replicator.d.ts +37 -15
- package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
- 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 +1 -1
- package/dist/types/src/automerge/index.d.ts.map +1 -1
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +5 -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 +5 -5
- package/dist/types/src/automerge/mesh-echo-replicator.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/db-host/automerge-data-source.d.ts +34 -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/data-service.d.ts +5 -4
- package/dist/types/src/db-host/data-service.d.ts.map +1 -1
- package/dist/types/src/db-host/database-root.d.ts.map +1 -1
- package/dist/types/src/db-host/documents-synchronizer.d.ts +6 -5
- package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
- package/dist/types/src/db-host/echo-host.d.ts +44 -23
- package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
- package/dist/types/src/db-host/index.d.ts +1 -0
- package/dist/types/src/db-host/index.d.ts.map +1 -1
- package/dist/types/src/db-host/local-queue-service.d.ts +16 -0
- package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
- package/dist/types/src/db-host/query-service.d.ts +14 -9
- package/dist/types/src/db-host/query-service.d.ts.map +1 -1
- package/dist/types/src/db-host/queue-data-source.d.ts +28 -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 +2 -1
- package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
- package/dist/types/src/db-host/stub.d.ts +10 -0
- package/dist/types/src/db-host/stub.d.ts.map +1 -0
- package/dist/types/src/edge/echo-edge-replicator.d.ts +8 -6
- package/dist/types/src/edge/echo-edge-replicator.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/pipeline.d.ts +3 -3
- package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
- package/dist/types/src/query/errors.d.ts +21 -9
- package/dist/types/src/query/errors.d.ts.map +1 -1
- package/dist/types/src/query/plan.d.ts +57 -5
- package/dist/types/src/query/plan.d.ts.map +1 -1
- package/dist/types/src/query/query-executor.d.ts +49 -10
- package/dist/types/src/query/query-executor.d.ts.map +1 -1
- package/dist/types/src/query/query-planner.d.ts +9 -0
- 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 +3 -3
- package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
- package/dist/types/src/space/space-manager.d.ts +7 -7
- package/dist/types/src/space/space-manager.d.ts.map +1 -1
- package/dist/types/src/space/space-protocol.d.ts +6 -6
- package/dist/types/src/space/space-protocol.d.ts.map +1 -1
- package/dist/types/src/space/space.d.ts +4 -4
- package/dist/types/src/space/space.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-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 +53 -45
- package/src/automerge/automerge-host.test.ts +55 -24
- package/src/automerge/automerge-host.ts +399 -126
- package/src/automerge/automerge-repo.test.ts +214 -46
- package/src/automerge/collection-synchronizer.test.ts +22 -15
- package/src/automerge/collection-synchronizer.ts +57 -22
- package/src/automerge/echo-data-monitor.ts +1 -1
- package/src/automerge/echo-network-adapter.test.ts +3 -3
- package/src/automerge/echo-network-adapter.ts +73 -28
- package/src/automerge/echo-replicator.ts +40 -15
- package/src/automerge/heads-store.ts +42 -3
- package/src/automerge/index.ts +1 -1
- package/src/automerge/leveldb-storage-adapter.ts +11 -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 +292 -0
- package/src/db-host/automerge-data-source.ts +144 -0
- package/src/db-host/data-service.ts +19 -20
- package/src/db-host/database-root.ts +2 -2
- package/src/db-host/documents-synchronizer.test.ts +73 -13
- package/src/db-host/documents-synchronizer.ts +58 -44
- package/src/db-host/echo-host.ts +194 -84
- package/src/db-host/index.ts +1 -0
- package/src/db-host/local-queue-service.ts +147 -0
- package/src/db-host/query-service.ts +28 -95
- package/src/db-host/queue-data-source.ts +132 -0
- package/src/db-host/queue-service.test.ts +145 -0
- package/src/db-host/space-state-manager.ts +6 -2
- package/src/db-host/stub.ts +28 -0
- package/src/edge/echo-edge-replicator.test.ts +8 -6
- package/src/edge/echo-edge-replicator.ts +124 -42
- package/src/filter/filter-match.test.ts +58 -32
- package/src/filter/filter-match.ts +163 -15
- package/src/metadata/metadata-store.ts +8 -5
- package/src/pipeline/pipeline-stress.test.ts +4 -2
- package/src/pipeline/pipeline.test.ts +3 -2
- package/src/pipeline/pipeline.ts +10 -7
- package/src/query/errors.ts +3 -1
- package/src/query/plan.ts +75 -5
- package/src/query/query-executor.ts +812 -117
- package/src/query/query-planner.test.ts +652 -34
- package/src/query/query-planner.ts +276 -13
- package/src/space/admission-discovery-extension.ts +3 -3
- package/src/space/auth.ts +6 -6
- package/src/space/control-pipeline.test.ts +4 -3
- package/src/space/control-pipeline.ts +11 -8
- package/src/space/space-manager.browser.test.ts +1 -1
- package/src/space/space-manager.ts +11 -10
- package/src/space/space-protocol.browser.test.ts +2 -2
- package/src/space/space-protocol.test.ts +3 -2
- package/src/space/space-protocol.ts +17 -14
- package/src/space/space.test.ts +1 -1
- package/src/space/space.ts +6 -5
- package/src/testing/index.ts +0 -2
- package/src/testing/test-agent-builder.ts +4 -3
- package/src/testing/test-network-adapter.ts +16 -3
- package/src/testing/test-replicator.ts +23 -18
- 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.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,292 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { getHeads } from '@automerge/automerge';
|
|
6
|
+
import { describe, expect, onTestFinished, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { type DatabaseDirectory, ObjectStructure, SpaceDocVersion } from '@dxos/echo-protocol';
|
|
9
|
+
import { runAndForwardErrors } from '@dxos/effect';
|
|
10
|
+
import { type IndexCursor } from '@dxos/index-core';
|
|
11
|
+
import { DXN, SpaceId } from '@dxos/keys';
|
|
12
|
+
import { type LevelDB } from '@dxos/kv-store';
|
|
13
|
+
import { createTestLevel } from '@dxos/kv-store/testing';
|
|
14
|
+
import { openAndClose } from '@dxos/test-utils';
|
|
15
|
+
|
|
16
|
+
import { AutomergeHost } from '../automerge';
|
|
17
|
+
|
|
18
|
+
import { AutomergeDataSource, headsCodec } from './automerge-data-source';
|
|
19
|
+
|
|
20
|
+
const TEST_TYPE = DXN.parse('dxn:type:example.com/type/Test:0.1.0').toString();
|
|
21
|
+
const OTHER_TYPE = DXN.parse('dxn:type:example.com/type/Other:0.1.0').toString();
|
|
22
|
+
const PERSON_TYPE = DXN.parse('dxn:type:example.com/type/Person:0.1.0').toString();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Set up a real AutomergeHost with LevelDB storage.
|
|
26
|
+
*/
|
|
27
|
+
const setupAutomergeHost = async (level: LevelDB): Promise<AutomergeHost> => {
|
|
28
|
+
const host = new AutomergeHost({
|
|
29
|
+
db: level,
|
|
30
|
+
});
|
|
31
|
+
await host.open();
|
|
32
|
+
onTestFinished(async () => {
|
|
33
|
+
await host.close();
|
|
34
|
+
});
|
|
35
|
+
return host;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a DatabaseDirectory document with the given objects.
|
|
40
|
+
*/
|
|
41
|
+
const createDatabaseDirectory = async (
|
|
42
|
+
host: AutomergeHost,
|
|
43
|
+
spaceKey: string,
|
|
44
|
+
objects: Record<string, ObjectStructure>,
|
|
45
|
+
): Promise<Awaited<ReturnType<typeof host.createDoc<DatabaseDirectory>>>> => {
|
|
46
|
+
const handle = await host.createDoc<DatabaseDirectory>({
|
|
47
|
+
version: SpaceDocVersion.CURRENT,
|
|
48
|
+
access: { spaceKey },
|
|
49
|
+
objects: {},
|
|
50
|
+
links: {},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
handle.change((doc) => {
|
|
54
|
+
doc.objects = objects;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return handle;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe('AutomergeDataSource', () => {
|
|
61
|
+
test('headsCodec encodes and decodes heads correctly', () => {
|
|
62
|
+
const heads = ['abc123', 'def456'];
|
|
63
|
+
const encoded = headsCodec.encode(heads);
|
|
64
|
+
|
|
65
|
+
expect(encoded).toBe('abc123|def456');
|
|
66
|
+
expect(headsCodec.decode(encoded)).toEqual(['abc123', 'def456']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('headsCodec sorts heads for consistent encoding', () => {
|
|
70
|
+
const heads1 = ['def456', 'abc123'];
|
|
71
|
+
const heads2 = ['abc123', 'def456'];
|
|
72
|
+
|
|
73
|
+
expect(headsCodec.encode(heads1)).toBe(headsCodec.encode(heads2));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('returns empty when no documents exist', async () => {
|
|
77
|
+
const level = createTestLevel();
|
|
78
|
+
await openAndClose(level);
|
|
79
|
+
const host = await setupAutomergeHost(level);
|
|
80
|
+
|
|
81
|
+
const dataSource = new AutomergeDataSource(host);
|
|
82
|
+
const result = await runAndForwardErrors(dataSource.getChangedObjects([]));
|
|
83
|
+
|
|
84
|
+
expect(result.objects).toHaveLength(0);
|
|
85
|
+
expect(result.cursors).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('returns new documents that have no cursor', async () => {
|
|
89
|
+
const level = createTestLevel();
|
|
90
|
+
await openAndClose(level);
|
|
91
|
+
const host = await setupAutomergeHost(level);
|
|
92
|
+
const spaceKey = SpaceId.random();
|
|
93
|
+
|
|
94
|
+
const handle = await createDatabaseDirectory(host, spaceKey, {
|
|
95
|
+
'obj-1': ObjectStructure.makeObject({ type: TEST_TYPE as DXN.String, data: { title: 'Test Document' } }),
|
|
96
|
+
});
|
|
97
|
+
await host.flush();
|
|
98
|
+
|
|
99
|
+
const dataSource = new AutomergeDataSource(host);
|
|
100
|
+
const result = await runAndForwardErrors(dataSource.getChangedObjects([]));
|
|
101
|
+
|
|
102
|
+
expect(result.objects).toHaveLength(1);
|
|
103
|
+
expect(result.objects[0].documentId).toBe(handle.documentId);
|
|
104
|
+
expect(result.objects[0].data.title).toBe('Test Document');
|
|
105
|
+
|
|
106
|
+
expect(result.cursors).toHaveLength(1);
|
|
107
|
+
expect(result.cursors[0].resourceId).toBe(handle.documentId);
|
|
108
|
+
expect(result.cursors[0].cursor).toBe(headsCodec.encode(getHeads(handle.doc()!)));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('returns documents with changed heads', async () => {
|
|
112
|
+
const level = createTestLevel();
|
|
113
|
+
await openAndClose(level);
|
|
114
|
+
const host = await setupAutomergeHost(level);
|
|
115
|
+
const spaceKey = SpaceId.random();
|
|
116
|
+
|
|
117
|
+
const handle1 = await createDatabaseDirectory(host, spaceKey, {
|
|
118
|
+
'obj-1': ObjectStructure.makeObject({ type: TEST_TYPE as DXN.String, data: { title: 'Doc 1' } }),
|
|
119
|
+
});
|
|
120
|
+
const handle2 = await createDatabaseDirectory(host, spaceKey, {
|
|
121
|
+
'obj-2': ObjectStructure.makeObject({ type: TEST_TYPE as DXN.String, data: { title: 'Doc 2' } }),
|
|
122
|
+
});
|
|
123
|
+
await host.flush();
|
|
124
|
+
|
|
125
|
+
// Store the heads for doc2 (unchanged).
|
|
126
|
+
const doc2Heads = headsCodec.encode(getHeads(handle2.doc()!));
|
|
127
|
+
|
|
128
|
+
// Modify doc1 to have new heads.
|
|
129
|
+
handle1.change((doc) => {
|
|
130
|
+
doc.objects!['obj-1'].data.title = 'Doc 1 Updated';
|
|
131
|
+
});
|
|
132
|
+
await host.flush();
|
|
133
|
+
|
|
134
|
+
const dataSource = new AutomergeDataSource(host);
|
|
135
|
+
const cursors: IndexCursor[] = [
|
|
136
|
+
{ indexName: 'fts', spaceId: null, sourceName: 'automerge', resourceId: handle1.documentId, cursor: 'oldhead' },
|
|
137
|
+
{ indexName: 'fts', spaceId: null, sourceName: 'automerge', resourceId: handle2.documentId, cursor: doc2Heads },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const result = await runAndForwardErrors(dataSource.getChangedObjects(cursors));
|
|
141
|
+
|
|
142
|
+
// Only doc1 changed.
|
|
143
|
+
expect(result.objects).toHaveLength(1);
|
|
144
|
+
expect(result.objects[0].documentId).toBe(handle1.documentId);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('skips documents with unchanged heads', async () => {
|
|
148
|
+
const level = createTestLevel();
|
|
149
|
+
await openAndClose(level);
|
|
150
|
+
const host = await setupAutomergeHost(level);
|
|
151
|
+
const spaceKey = SpaceId.random();
|
|
152
|
+
|
|
153
|
+
const handle = await createDatabaseDirectory(host, spaceKey, {
|
|
154
|
+
'obj-1': ObjectStructure.makeObject({ type: TEST_TYPE as DXN.String, data: { title: 'Doc 1' } }),
|
|
155
|
+
});
|
|
156
|
+
await host.flush();
|
|
157
|
+
|
|
158
|
+
const currentHeads = headsCodec.encode(getHeads(handle.doc()!));
|
|
159
|
+
|
|
160
|
+
const dataSource = new AutomergeDataSource(host);
|
|
161
|
+
const cursors: IndexCursor[] = [
|
|
162
|
+
{
|
|
163
|
+
indexName: 'fts',
|
|
164
|
+
spaceId: null,
|
|
165
|
+
sourceName: 'automerge',
|
|
166
|
+
resourceId: handle.documentId,
|
|
167
|
+
cursor: currentHeads,
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const result = await runAndForwardErrors(dataSource.getChangedObjects(cursors));
|
|
172
|
+
|
|
173
|
+
expect(result.objects).toHaveLength(0);
|
|
174
|
+
expect(result.cursors).toHaveLength(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('respects limit option', async () => {
|
|
178
|
+
const level = createTestLevel();
|
|
179
|
+
await openAndClose(level);
|
|
180
|
+
const host = await setupAutomergeHost(level);
|
|
181
|
+
const spaceKey = SpaceId.random();
|
|
182
|
+
|
|
183
|
+
// Create 3 documents.
|
|
184
|
+
for (let i = 1; i <= 3; i++) {
|
|
185
|
+
await createDatabaseDirectory(host, spaceKey, {
|
|
186
|
+
[`obj-${i}`]: ObjectStructure.makeObject({ type: TEST_TYPE as DXN.String, data: { title: `Doc ${i}` } }),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
await host.flush();
|
|
190
|
+
|
|
191
|
+
const dataSource = new AutomergeDataSource(host);
|
|
192
|
+
const result = await runAndForwardErrors(dataSource.getChangedObjects([], { limit: 2 }));
|
|
193
|
+
|
|
194
|
+
expect(result.objects).toHaveLength(2);
|
|
195
|
+
expect(result.cursors).toHaveLength(2);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('extracts multiple objects from a document', async () => {
|
|
199
|
+
const level = createTestLevel();
|
|
200
|
+
await openAndClose(level);
|
|
201
|
+
const host = await setupAutomergeHost(level);
|
|
202
|
+
const spaceKey = SpaceId.random();
|
|
203
|
+
|
|
204
|
+
await createDatabaseDirectory(host, spaceKey, {
|
|
205
|
+
'obj-1': ObjectStructure.makeObject({ type: TEST_TYPE as DXN.String, data: { title: 'Object 1' } }),
|
|
206
|
+
'obj-2': ObjectStructure.makeObject({ type: OTHER_TYPE as DXN.String, data: { title: 'Object 2' } }),
|
|
207
|
+
});
|
|
208
|
+
await host.flush();
|
|
209
|
+
|
|
210
|
+
const dataSource = new AutomergeDataSource(host);
|
|
211
|
+
const result = await runAndForwardErrors(dataSource.getChangedObjects([]));
|
|
212
|
+
|
|
213
|
+
expect(result.objects).toHaveLength(2);
|
|
214
|
+
expect(result.objects.map((o) => o.data.id)).toContain('obj-1');
|
|
215
|
+
expect(result.objects.map((o) => o.data.id)).toContain('obj-2');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('skips outdated documents', async () => {
|
|
219
|
+
const level = createTestLevel();
|
|
220
|
+
await openAndClose(level);
|
|
221
|
+
const host = await setupAutomergeHost(level);
|
|
222
|
+
const spaceKey = SpaceId.random();
|
|
223
|
+
|
|
224
|
+
// Create a document with outdated version.
|
|
225
|
+
const handle = await host.createDoc<DatabaseDirectory>({
|
|
226
|
+
version: 0 as SpaceDocVersion, // Outdated version.
|
|
227
|
+
access: { spaceKey },
|
|
228
|
+
objects: {},
|
|
229
|
+
links: {},
|
|
230
|
+
});
|
|
231
|
+
handle.change((doc) => {
|
|
232
|
+
doc.objects = {
|
|
233
|
+
'obj-1': ObjectStructure.makeObject({ type: TEST_TYPE as DXN.String, data: { title: 'Test' } }),
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
await host.flush();
|
|
237
|
+
|
|
238
|
+
const dataSource = new AutomergeDataSource(host);
|
|
239
|
+
const result = await runAndForwardErrors(dataSource.getChangedObjects([]));
|
|
240
|
+
|
|
241
|
+
expect(result.objects).toHaveLength(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('extracts object attributes correctly', async () => {
|
|
245
|
+
const level = createTestLevel();
|
|
246
|
+
await openAndClose(level);
|
|
247
|
+
const host = await setupAutomergeHost(level);
|
|
248
|
+
const spaceKey = SpaceId.random();
|
|
249
|
+
|
|
250
|
+
await createDatabaseDirectory(host, spaceKey, {
|
|
251
|
+
'person-1': ObjectStructure.makeObject({
|
|
252
|
+
type: PERSON_TYPE as DXN.String,
|
|
253
|
+
data: { name: 'Alice', age: 30 },
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
await host.flush();
|
|
257
|
+
|
|
258
|
+
const dataSource = new AutomergeDataSource(host);
|
|
259
|
+
const result = await runAndForwardErrors(dataSource.getChangedObjects([]));
|
|
260
|
+
|
|
261
|
+
expect(result.objects).toHaveLength(1);
|
|
262
|
+
const obj = result.objects[0];
|
|
263
|
+
expect(obj.data.id).toBe('person-1');
|
|
264
|
+
expect(obj.data['@type']).toBe(PERSON_TYPE);
|
|
265
|
+
expect(obj.data.name).toBe('Alice');
|
|
266
|
+
expect(obj.data.age).toBe(30);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('skips documents without spaceKey', async () => {
|
|
270
|
+
const level = createTestLevel();
|
|
271
|
+
await openAndClose(level);
|
|
272
|
+
const host = await setupAutomergeHost(level);
|
|
273
|
+
|
|
274
|
+
// Create a document without access.spaceKey.
|
|
275
|
+
const handle = await host.createDoc<DatabaseDirectory>({
|
|
276
|
+
version: SpaceDocVersion.CURRENT,
|
|
277
|
+
objects: {},
|
|
278
|
+
links: {},
|
|
279
|
+
});
|
|
280
|
+
handle.change((doc) => {
|
|
281
|
+
doc.objects = {
|
|
282
|
+
'obj-1': ObjectStructure.makeObject({ type: TEST_TYPE as DXN.String, data: { title: 'No Space' } }),
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
await host.flush();
|
|
286
|
+
|
|
287
|
+
const dataSource = new AutomergeDataSource(host);
|
|
288
|
+
const result = await runAndForwardErrors(dataSource.getChangedObjects([]));
|
|
289
|
+
|
|
290
|
+
expect(result.objects).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Heads } from '@automerge/automerge';
|
|
6
|
+
import { type DocumentId } from '@automerge/automerge-repo';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
|
|
9
|
+
import { Context } from '@dxos/context';
|
|
10
|
+
import { objectStructureToJson } from '@dxos/echo/internal';
|
|
11
|
+
import { DatabaseDirectory, SpaceDocVersion } from '@dxos/echo-protocol';
|
|
12
|
+
import { type DataSourceCursor, type IndexDataSource, type IndexerObject } from '@dxos/index-core';
|
|
13
|
+
import { PublicKey } from '@dxos/keys';
|
|
14
|
+
import { log } from '@dxos/log';
|
|
15
|
+
|
|
16
|
+
import { type AutomergeHost } from '../automerge';
|
|
17
|
+
import { createIdFromSpaceKey } from '../common';
|
|
18
|
+
|
|
19
|
+
const HEADS_DELIMITER = '|';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Codec for serializing/deserializing Automerge heads to cursor strings.
|
|
23
|
+
*/
|
|
24
|
+
export const headsCodec = {
|
|
25
|
+
/**
|
|
26
|
+
* Serialize automerge heads to a cursor string.
|
|
27
|
+
* Heads are sorted to ensure consistent comparison.
|
|
28
|
+
*/
|
|
29
|
+
encode: (heads: Heads): string => [...heads].sort().join(HEADS_DELIMITER),
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Deserialize a cursor string back to heads array.
|
|
33
|
+
*/
|
|
34
|
+
decode: (cursor: string): string[] => (cursor ? cursor.split(HEADS_DELIMITER) : []),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if document has changed by comparing cursor with current heads.
|
|
39
|
+
*/
|
|
40
|
+
const hasChanged = (cursor: string | undefined, currentHeads: Heads): boolean => {
|
|
41
|
+
if (!cursor) {
|
|
42
|
+
return true; // New document.
|
|
43
|
+
}
|
|
44
|
+
return cursor !== headsCodec.encode(currentHeads);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Data source that fetches objects from AutomergeHost.
|
|
49
|
+
* Iterates all documents from HeadsStore and tracks document heads as cursors to detect changes.
|
|
50
|
+
*/
|
|
51
|
+
export class AutomergeDataSource implements IndexDataSource {
|
|
52
|
+
readonly sourceName = 'automerge';
|
|
53
|
+
|
|
54
|
+
readonly #automergeHost: AutomergeHost;
|
|
55
|
+
|
|
56
|
+
constructor(automergeHost: AutomergeHost) {
|
|
57
|
+
this.#automergeHost = automergeHost;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getChangedObjects(
|
|
61
|
+
cursors: DataSourceCursor[],
|
|
62
|
+
opts?: { limit?: number },
|
|
63
|
+
): Effect.Effect<{ objects: IndexerObject[]; cursors: DataSourceCursor[] }> {
|
|
64
|
+
return Effect.gen(this, function* () {
|
|
65
|
+
// Build a map of documentId -> cursor for quick lookup.
|
|
66
|
+
const cursorMap = new Map<string, string>();
|
|
67
|
+
for (const cursor of cursors) {
|
|
68
|
+
if (cursor.resourceId) {
|
|
69
|
+
cursorMap.set(cursor.resourceId, String(cursor.cursor));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Find changed documents by iterating all documents from HeadsStore.
|
|
74
|
+
const changedDocuments = yield* Effect.promise(async () => {
|
|
75
|
+
const result: { documentId: DocumentId; heads: Heads }[] = [];
|
|
76
|
+
const limit = opts?.limit ?? Infinity;
|
|
77
|
+
|
|
78
|
+
for await (const { documentId, heads } of this.#automergeHost.listDocumentHeads()) {
|
|
79
|
+
const existingCursor = cursorMap.get(documentId);
|
|
80
|
+
if (hasChanged(existingCursor, heads)) {
|
|
81
|
+
result.push({ documentId, heads });
|
|
82
|
+
if (result.length >= limit) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Load changed documents and extract objects.
|
|
91
|
+
const objects: IndexerObject[] = [];
|
|
92
|
+
const updatedCursors: DataSourceCursor[] = [];
|
|
93
|
+
|
|
94
|
+
for (const { documentId, heads: docHeads } of changedDocuments) {
|
|
95
|
+
try {
|
|
96
|
+
const handle = yield* Effect.promise(() =>
|
|
97
|
+
this.#automergeHost.loadDoc<DatabaseDirectory>(Context.default(), documentId),
|
|
98
|
+
);
|
|
99
|
+
const doc = handle.doc();
|
|
100
|
+
if (!doc) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Skip outdated docs.
|
|
105
|
+
if (doc.version !== SpaceDocVersion.CURRENT) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Extract spaceId from document.
|
|
110
|
+
const spaceKeyHex = DatabaseDirectory.getSpaceKey(doc);
|
|
111
|
+
if (!spaceKeyHex) {
|
|
112
|
+
// Skip documents without a space key.
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const spaceKey = PublicKey.fromHex(spaceKeyHex);
|
|
116
|
+
const spaceId = yield* Effect.promise(() => createIdFromSpaceKey(spaceKey));
|
|
117
|
+
|
|
118
|
+
// Extract objects from the document.
|
|
119
|
+
const docObjects = doc.objects ?? {};
|
|
120
|
+
for (const [objectId, structure] of Object.entries(docObjects)) {
|
|
121
|
+
objects.push({
|
|
122
|
+
spaceId,
|
|
123
|
+
documentId,
|
|
124
|
+
queueId: null,
|
|
125
|
+
recordId: null,
|
|
126
|
+
data: objectStructureToJson(objectId, structure),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Update cursor for this document.
|
|
131
|
+
updatedCursors.push({
|
|
132
|
+
spaceId,
|
|
133
|
+
resourceId: documentId,
|
|
134
|
+
cursor: headsCodec.encode(docHeads),
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
log.error('Error loading document for indexing', { documentId, error });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { objects, cursors: updatedCursors };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -11,25 +11,28 @@ import { invariant } from '@dxos/invariant';
|
|
|
11
11
|
import { SpaceId } from '@dxos/keys';
|
|
12
12
|
import { log } from '@dxos/log';
|
|
13
13
|
import {
|
|
14
|
+
type BatchedDocumentUpdates,
|
|
15
|
+
type CreateDocumentRequest,
|
|
16
|
+
type CreateDocumentResponse,
|
|
14
17
|
type DataService,
|
|
15
18
|
type FlushRequest,
|
|
16
|
-
type SubscribeRequest,
|
|
17
|
-
type BatchedDocumentUpdates,
|
|
18
|
-
type UpdateSubscriptionRequest,
|
|
19
19
|
type GetDocumentHeadsRequest,
|
|
20
20
|
type GetDocumentHeadsResponse,
|
|
21
|
-
type ReIndexHeadsRequest,
|
|
22
|
-
type WaitUntilHeadsReplicatedRequest,
|
|
23
|
-
type UpdateRequest,
|
|
24
21
|
type GetSpaceSyncStateRequest,
|
|
22
|
+
type ReIndexHeadsRequest,
|
|
25
23
|
type SpaceSyncState,
|
|
24
|
+
type SubscribeRequest,
|
|
25
|
+
type UpdateRequest,
|
|
26
|
+
type UpdateSubscriptionRequest,
|
|
27
|
+
type WaitUntilHeadsReplicatedRequest,
|
|
26
28
|
} from '@dxos/protocols/proto/dxos/echo/service';
|
|
27
29
|
|
|
30
|
+
import { type AutomergeHost, deriveCollectionIdFromSpaceId } from '../automerge';
|
|
31
|
+
|
|
28
32
|
import { DocumentsSynchronizer } from './documents-synchronizer';
|
|
29
33
|
import { type SpaceStateManager } from './space-state-manager';
|
|
30
|
-
import { deriveCollectionIdFromSpaceId, type AutomergeHost } from '../automerge';
|
|
31
34
|
|
|
32
|
-
export type
|
|
35
|
+
export type DataServiceProps = {
|
|
33
36
|
automergeHost: AutomergeHost;
|
|
34
37
|
spaceStateManager: SpaceStateManager;
|
|
35
38
|
updateIndexes: () => Promise<void>;
|
|
@@ -50,7 +53,7 @@ export class DataServiceImpl implements DataService {
|
|
|
50
53
|
private readonly _spaceStateManager: SpaceStateManager;
|
|
51
54
|
private readonly _updateIndexes: () => Promise<void>;
|
|
52
55
|
|
|
53
|
-
constructor(params:
|
|
56
|
+
constructor(params: DataServiceProps) {
|
|
54
57
|
this._automergeHost = params.automergeHost;
|
|
55
58
|
this._spaceStateManager = params.spaceStateManager;
|
|
56
59
|
this._updateIndexes = params.updateIndexes;
|
|
@@ -59,7 +62,7 @@ export class DataServiceImpl implements DataService {
|
|
|
59
62
|
subscribe(request: SubscribeRequest): Stream<BatchedDocumentUpdates> {
|
|
60
63
|
return new Stream<BatchedDocumentUpdates>(({ next, ready }) => {
|
|
61
64
|
const synchronizer = new DocumentsSynchronizer({
|
|
62
|
-
|
|
65
|
+
automergeHost: this._automergeHost,
|
|
63
66
|
sendUpdates: (updates) => next(updates),
|
|
64
67
|
});
|
|
65
68
|
synchronizer
|
|
@@ -85,6 +88,11 @@ export class DataServiceImpl implements DataService {
|
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
async createDocument(request: CreateDocumentRequest): Promise<CreateDocumentResponse> {
|
|
92
|
+
const handle = await this._automergeHost.createDoc(request.initialValue);
|
|
93
|
+
return { documentId: handle.documentId };
|
|
94
|
+
}
|
|
95
|
+
|
|
88
96
|
async update(request: UpdateRequest): Promise<void> {
|
|
89
97
|
if (!request.updates) {
|
|
90
98
|
return;
|
|
@@ -145,16 +153,7 @@ export class DataServiceImpl implements DataService {
|
|
|
145
153
|
const scheduler = new UpdateScheduler(ctx, async () => {
|
|
146
154
|
const state = collectionId ? await this._automergeHost.getCollectionSyncState(collectionId) : { peers: [] };
|
|
147
155
|
|
|
148
|
-
next({
|
|
149
|
-
peers: state.peers.map((peer) => ({
|
|
150
|
-
peerId: peer.peerId,
|
|
151
|
-
missingOnRemote: peer.missingOnRemote,
|
|
152
|
-
missingOnLocal: peer.missingOnLocal,
|
|
153
|
-
differentDocuments: peer.differentDocuments,
|
|
154
|
-
localDocumentCount: peer.localDocumentCount,
|
|
155
|
-
remoteDocumentCount: peer.remoteDocumentCount,
|
|
156
|
-
})),
|
|
157
|
-
});
|
|
156
|
+
next({ peers: state.peers });
|
|
158
157
|
});
|
|
159
158
|
|
|
160
159
|
this._automergeHost.collectionStateUpdated.on(ctx, (e) => {
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import type * as A from '@automerge/automerge';
|
|
6
|
-
import {
|
|
6
|
+
import { type AutomergeUrl, type DocHandle, type DocumentId, interpretAsDocumentId } from '@automerge/automerge-repo';
|
|
7
7
|
|
|
8
8
|
import { DatabaseDirectory, SpaceDocVersion } from '@dxos/echo-protocol';
|
|
9
9
|
import { invariant } from '@dxos/invariant';
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { type DocMetrics, measureDocMetrics } from './automerge-metrics';
|
|
12
12
|
|
|
13
13
|
export class DatabaseRoot {
|
|
14
14
|
static mapLinks(doc: DocHandle<DatabaseDirectory>, mapping: Record<DocumentId, DocumentId>): void {
|
|
@@ -2,39 +2,99 @@
|
|
|
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
8
|
import { sleep } from '@dxos/async';
|
|
9
|
+
import { Context } from '@dxos/context';
|
|
10
|
+
import { PublicKey } from '@dxos/keys';
|
|
11
|
+
import { createTestLevel } from '@dxos/kv-store/testing';
|
|
10
12
|
import { openAndClose } from '@dxos/test-utils';
|
|
11
13
|
|
|
14
|
+
import { AutomergeHost } from '../automerge';
|
|
15
|
+
|
|
12
16
|
import { DocumentsSynchronizer } from './documents-synchronizer';
|
|
13
17
|
|
|
14
18
|
describe('DocumentsSynchronizer', () => {
|
|
15
19
|
test('do not get init changes for client created docs', async () => {
|
|
16
20
|
let counter = 0;
|
|
17
|
-
const
|
|
21
|
+
const kv = createTestLevel();
|
|
22
|
+
const host = new AutomergeHost({
|
|
23
|
+
db: kv,
|
|
24
|
+
});
|
|
25
|
+
await openAndClose(host);
|
|
18
26
|
const synchronizer = new DocumentsSynchronizer({
|
|
19
|
-
|
|
27
|
+
automergeHost: host,
|
|
20
28
|
sendUpdates: () => {
|
|
21
29
|
counter++;
|
|
22
30
|
},
|
|
23
31
|
});
|
|
24
32
|
await openAndClose(synchronizer);
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
},
|
|
32
|
-
]);
|
|
34
|
+
// First create the document on the host (simulates DataService.createDocument).
|
|
35
|
+
const handle = await host.createDoc<{ text: string }>({ text: 'hello' });
|
|
36
|
+
|
|
37
|
+
// Add document to synchronizer (simulates updateSubscription with addIds).
|
|
38
|
+
await synchronizer.addDocuments([handle.documentId]);
|
|
33
39
|
|
|
34
40
|
// Wait for the changes to be processed.
|
|
35
41
|
await sleep(100);
|
|
36
42
|
|
|
37
|
-
//
|
|
38
|
-
|
|
43
|
+
// Updates will be sent for the initial sync (this is expected behavior).
|
|
44
|
+
// The key is that subsequent updates from the client should be properly synced.
|
|
45
|
+
expect(counter).to.be.greaterThanOrEqual(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('persistence', () => {
|
|
49
|
+
test('document created on host persists without explicit flush', async () => {
|
|
50
|
+
const path = `/tmp/dxos-${PublicKey.random().toHex()}`;
|
|
51
|
+
let documentId: DocumentId;
|
|
52
|
+
const text = 'Hello World!';
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
const kv = createTestLevel(path);
|
|
56
|
+
const host = new AutomergeHost({
|
|
57
|
+
db: kv,
|
|
58
|
+
});
|
|
59
|
+
await openAndClose(host);
|
|
60
|
+
const synchronizer = new DocumentsSynchronizer({
|
|
61
|
+
automergeHost: host,
|
|
62
|
+
sendUpdates: () => {},
|
|
63
|
+
});
|
|
64
|
+
await openAndClose(synchronizer);
|
|
65
|
+
|
|
66
|
+
// Create document on host (simulates DataService.createDocument).
|
|
67
|
+
const handle = await host.createDoc<{ text: string }>({ text });
|
|
68
|
+
documentId = handle.documentId;
|
|
69
|
+
|
|
70
|
+
// Add to synchronizer (simulates updateSubscription with addIds).
|
|
71
|
+
await synchronizer.addDocuments([documentId]);
|
|
72
|
+
|
|
73
|
+
// Wait for auto-save (no explicit flush).
|
|
74
|
+
await sleep(500);
|
|
75
|
+
|
|
76
|
+
// Close in same order as repo-proxy.test.ts: level first, then host.
|
|
77
|
+
await kv.close();
|
|
78
|
+
await host.close();
|
|
79
|
+
await synchronizer.close();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
// Reopen and verify persistence.
|
|
84
|
+
const kv = createTestLevel(path);
|
|
85
|
+
const host = new AutomergeHost({
|
|
86
|
+
db: kv,
|
|
87
|
+
});
|
|
88
|
+
await openAndClose(host);
|
|
89
|
+
|
|
90
|
+
const handle = await host.loadDoc<{ text: string }>(Context.default(), documentId);
|
|
91
|
+
await handle.whenReady();
|
|
92
|
+
|
|
93
|
+
expect(handle.doc()?.text).to.equal(text);
|
|
94
|
+
|
|
95
|
+
await kv.close();
|
|
96
|
+
await host.close();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
39
99
|
});
|
|
40
100
|
});
|