@dxos/echo-pipeline 0.6.12 → 0.6.13-main.548ca8d
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-PESZVYAN.mjs +2050 -0
- package/dist/lib/browser/chunk-PESZVYAN.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +3463 -17
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +3 -4
- package/dist/lib/browser/testing/index.mjs.map +3 -3
- package/dist/lib/node/{chunk-7HHYCGUR.cjs → chunk-6EZVIJNE.cjs} +89 -47
- package/dist/lib/node/chunk-6EZVIJNE.cjs.map +7 -0
- package/dist/lib/node/index.cjs +3440 -35
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +11 -12
- package/dist/lib/node/testing/index.cjs.map +3 -3
- package/dist/lib/{browser/chunk-UKXIJW43.mjs → node-esm/chunk-4LW7MDPZ.mjs} +76 -36
- package/dist/lib/node-esm/chunk-4LW7MDPZ.mjs.map +7 -0
- package/dist/lib/{browser/chunk-MPWFDDQK.mjs → node-esm/index.mjs} +1702 -335
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/lib/node-esm/testing/index.mjs +551 -0
- package/dist/lib/node-esm/testing/index.mjs.map +7 -0
- package/dist/types/src/automerge/automerge-host.d.ts +24 -1
- package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
- package/dist/types/src/automerge/collection-synchronizer.d.ts +2 -0
- package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-replicator.d.ts +3 -3
- package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
- package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +3 -3
- package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
- package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
- package/dist/types/src/automerge/space-collection.d.ts +3 -2
- package/dist/types/src/automerge/space-collection.d.ts.map +1 -1
- package/dist/types/src/db-host/automerge-metrics.d.ts +11 -0
- package/dist/types/src/db-host/automerge-metrics.d.ts.map +1 -0
- package/dist/types/src/db-host/data-service.d.ts +3 -2
- package/dist/types/src/db-host/data-service.d.ts.map +1 -1
- package/dist/types/src/db-host/database-root.d.ts +20 -0
- package/dist/types/src/db-host/database-root.d.ts.map +1 -0
- package/dist/types/src/db-host/documents-iterator.d.ts +7 -0
- package/dist/types/src/db-host/documents-iterator.d.ts.map +1 -0
- package/dist/types/src/db-host/echo-host.d.ts +73 -0
- package/dist/types/src/db-host/echo-host.d.ts.map +1 -0
- package/dist/types/src/db-host/index.d.ts +5 -0
- package/dist/types/src/db-host/index.d.ts.map +1 -1
- package/dist/types/src/db-host/migration.d.ts +8 -0
- package/dist/types/src/db-host/migration.d.ts.map +1 -0
- package/dist/types/src/db-host/query-service.d.ts +25 -0
- package/dist/types/src/db-host/query-service.d.ts.map +1 -0
- package/dist/types/src/db-host/query-state.d.ts +41 -0
- package/dist/types/src/db-host/query-state.d.ts.map +1 -0
- package/dist/types/src/db-host/space-state-manager.d.ts +23 -0
- package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -0
- package/dist/types/src/edge/echo-edge-replicator.d.ts +23 -0
- package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -0
- package/dist/types/src/edge/echo-edge-replicator.test.d.ts +2 -0
- package/dist/types/src/edge/echo-edge-replicator.test.d.ts.map +1 -0
- package/dist/types/src/edge/index.d.ts +2 -0
- package/dist/types/src/edge/index.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/metadata/metadata-store.d.ts +4 -1
- package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
- package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
- package/dist/types/src/testing/test-replicator.d.ts +4 -4
- package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
- package/package.json +40 -50
- package/src/automerge/automerge-host.test.ts +8 -9
- package/src/automerge/automerge-host.ts +46 -7
- package/src/automerge/automerge-repo.test.ts +18 -16
- package/src/automerge/collection-synchronizer.test.ts +10 -5
- package/src/automerge/collection-synchronizer.ts +17 -6
- package/src/automerge/echo-data-monitor.test.ts +1 -3
- package/src/automerge/echo-network-adapter.test.ts +4 -3
- package/src/automerge/echo-network-adapter.ts +5 -4
- package/src/automerge/echo-replicator.ts +3 -3
- package/src/automerge/mesh-echo-replicator-connection.ts +10 -9
- package/src/automerge/mesh-echo-replicator.ts +2 -1
- package/src/automerge/space-collection.ts +3 -2
- package/src/automerge/storage-adapter.test.ts +2 -3
- package/src/db-host/automerge-metrics.ts +38 -0
- package/src/db-host/data-service.ts +29 -14
- package/src/db-host/database-root.ts +86 -0
- package/src/db-host/documents-iterator.ts +73 -0
- package/src/db-host/documents-synchronizer.test.ts +2 -2
- package/src/db-host/echo-host.ts +257 -0
- package/src/db-host/index.ts +6 -1
- package/src/db-host/migration.ts +57 -0
- package/src/db-host/query-service.ts +208 -0
- package/src/db-host/query-state.ts +200 -0
- package/src/db-host/space-state-manager.ts +90 -0
- package/src/edge/echo-edge-replicator.test.ts +96 -0
- package/src/edge/echo-edge-replicator.ts +337 -0
- package/src/edge/index.ts +5 -0
- package/src/index.ts +1 -0
- package/src/metadata/metadata-store.ts +20 -0
- package/src/pipeline/pipeline-stress.test.ts +44 -47
- package/src/pipeline/pipeline.test.ts +3 -4
- package/src/space/control-pipeline.test.ts +2 -3
- package/src/space/control-pipeline.ts +10 -1
- package/src/space/replication.browser.test.ts +2 -8
- package/src/space/space-manager.browser.test.ts +6 -5
- package/src/space/space-protocol.browser.test.ts +29 -34
- package/src/space/space-protocol.test.ts +29 -27
- package/src/space/space.test.ts +28 -11
- package/src/testing/test-agent-builder.ts +2 -2
- package/src/testing/test-replicator.ts +3 -3
- package/dist/lib/browser/chunk-MPWFDDQK.mjs.map +0 -7
- package/dist/lib/browser/chunk-UKXIJW43.mjs.map +0 -7
- package/dist/lib/browser/chunk-XPCF2V5U.mjs +0 -31
- package/dist/lib/browser/chunk-XPCF2V5U.mjs.map +0 -7
- package/dist/lib/browser/light.mjs +0 -32
- package/dist/lib/browser/light.mjs.map +0 -7
- package/dist/lib/node/chunk-5DH4KR2S.cjs +0 -2148
- package/dist/lib/node/chunk-5DH4KR2S.cjs.map +0 -7
- package/dist/lib/node/chunk-7HHYCGUR.cjs.map +0 -7
- package/dist/lib/node/chunk-DZVH7HDD.cjs +0 -43
- package/dist/lib/node/chunk-DZVH7HDD.cjs.map +0 -7
- package/dist/lib/node/light.cjs +0 -52
- package/dist/lib/node/light.cjs.map +0 -7
- package/dist/types/src/light.d.ts +0 -4
- package/dist/types/src/light.d.ts.map +0 -1
- package/src/light.ts +0 -7
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { DeferredTask } from '@dxos/async';
|
|
6
|
+
import { getHeads, type Doc } from '@dxos/automerge/automerge';
|
|
7
|
+
import { type DocHandle, type DocumentId } from '@dxos/automerge/automerge-repo';
|
|
8
|
+
import { Stream } from '@dxos/codec-protobuf';
|
|
9
|
+
import { Context, Resource } from '@dxos/context';
|
|
10
|
+
import { type SpaceDoc } from '@dxos/echo-protocol';
|
|
11
|
+
import { type ObjectSnapshot, type Indexer, type IdToHeads } from '@dxos/indexing';
|
|
12
|
+
import { log } from '@dxos/log';
|
|
13
|
+
import { objectPointerCodec } from '@dxos/protocols';
|
|
14
|
+
import { type IndexConfig } from '@dxos/protocols/proto/dxos/echo/indexing';
|
|
15
|
+
import {
|
|
16
|
+
type QueryRequest,
|
|
17
|
+
type QueryResponse,
|
|
18
|
+
type QueryService,
|
|
19
|
+
type QueryResult,
|
|
20
|
+
} from '@dxos/protocols/proto/dxos/echo/query';
|
|
21
|
+
import { trace } from '@dxos/tracing';
|
|
22
|
+
|
|
23
|
+
import { QueryState } from './query-state';
|
|
24
|
+
import { type AutomergeHost, getSpaceKeyFromDoc } from '../automerge';
|
|
25
|
+
|
|
26
|
+
export type QueryServiceParams = {
|
|
27
|
+
indexer: Indexer;
|
|
28
|
+
automergeHost: AutomergeHost;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Represents an active query (stream and query state connected to that stream).
|
|
33
|
+
*/
|
|
34
|
+
type ActiveQuery = {
|
|
35
|
+
state: QueryState;
|
|
36
|
+
sendResults: (results: QueryResult[]) => void;
|
|
37
|
+
close: () => Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export class QueryServiceImpl extends Resource implements QueryService {
|
|
41
|
+
private readonly _queries = new Set<ActiveQuery>();
|
|
42
|
+
|
|
43
|
+
private readonly _updateQueries = new DeferredTask(this._ctx, async () => {
|
|
44
|
+
await Promise.all(
|
|
45
|
+
Array.from(this._queries).map(async (query) => {
|
|
46
|
+
try {
|
|
47
|
+
const { changed } = await query.state.execQuery();
|
|
48
|
+
if (changed) {
|
|
49
|
+
query.sendResults(query.state.getResults());
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
log.catch(err);
|
|
53
|
+
}
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// TODO(burdon): OK for options, but not params. Pass separately and type readonly here.
|
|
59
|
+
constructor(private readonly _params: QueryServiceParams) {
|
|
60
|
+
super();
|
|
61
|
+
|
|
62
|
+
trace.diagnostic({
|
|
63
|
+
id: 'active-queries',
|
|
64
|
+
name: 'Active Queries',
|
|
65
|
+
fetch: () => {
|
|
66
|
+
return Array.from(this._queries).map((query) => {
|
|
67
|
+
return {
|
|
68
|
+
filter: JSON.stringify(query.state.filter),
|
|
69
|
+
metrics: query.state.metrics,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override async _open() {
|
|
77
|
+
this._params.indexer.updated.on(this._ctx, () => this._updateQueries.schedule());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
override async _close() {
|
|
81
|
+
await Promise.all(Array.from(this._queries).map((query) => query.close()));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async setConfig(config: IndexConfig): Promise<void> {
|
|
85
|
+
if (this._params.indexer.initialized) {
|
|
86
|
+
log.warn('Indexer already initialized.');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this._params.indexer.setConfig(config);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
execQuery(request: QueryRequest): Stream<QueryResponse> {
|
|
93
|
+
return new Stream<QueryResponse>(({ next, close, ctx }) => {
|
|
94
|
+
const query: ActiveQuery = {
|
|
95
|
+
state: new QueryState({
|
|
96
|
+
indexer: this._params.indexer,
|
|
97
|
+
automergeHost: this._params.automergeHost,
|
|
98
|
+
request,
|
|
99
|
+
}),
|
|
100
|
+
sendResults: (results) => {
|
|
101
|
+
if (ctx.disposed) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
next({ queryId: request.queryId, results });
|
|
105
|
+
},
|
|
106
|
+
close: async () => {
|
|
107
|
+
close();
|
|
108
|
+
await query.state.close();
|
|
109
|
+
this._queries.delete(query);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
this._queries.add(query);
|
|
113
|
+
|
|
114
|
+
queueMicrotask(async () => {
|
|
115
|
+
await query.state.open();
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const { changed } = await query.state.execQuery();
|
|
119
|
+
if (changed) {
|
|
120
|
+
query.sendResults(query.state.getResults());
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
log.catch(error);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return query.close;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Re-index all loaded documents.
|
|
133
|
+
*/
|
|
134
|
+
async reindex() {
|
|
135
|
+
log.info('Reindexing all documents...');
|
|
136
|
+
const iterator = createDocumentsIterator(this._params.automergeHost);
|
|
137
|
+
const ids: IdToHeads = new Map();
|
|
138
|
+
for await (const documents of iterator()) {
|
|
139
|
+
for (const { id, heads } of documents) {
|
|
140
|
+
ids.set(id, heads);
|
|
141
|
+
}
|
|
142
|
+
if (ids.size % 100 === 0) {
|
|
143
|
+
log.info('Collected documents...', { count: ids.size });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
log.info('Marking all documents as dirty...', { count: ids.size });
|
|
148
|
+
await this._params.indexer.reindex(ids);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Factory for `getAllDocuments` iterator.
|
|
154
|
+
*/
|
|
155
|
+
// TODO(dmaretskyi): Get roots from echo-host.
|
|
156
|
+
const createDocumentsIterator = (automergeHost: AutomergeHost) =>
|
|
157
|
+
/**
|
|
158
|
+
* Recursively get all object data blobs from loaded documents from Automerge Repo.
|
|
159
|
+
*/
|
|
160
|
+
// TODO(mykola): Unload automerge handles after usage.
|
|
161
|
+
async function* getAllDocuments(): AsyncGenerator<ObjectSnapshot[], void, void> {
|
|
162
|
+
/** visited automerge handles */
|
|
163
|
+
const visited = new Set<string>();
|
|
164
|
+
|
|
165
|
+
async function* getObjectsFromHandle(handle: DocHandle<any>): AsyncGenerator<ObjectSnapshot[]> {
|
|
166
|
+
if (visited.has(handle.documentId)) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const doc: Doc<SpaceDoc> = handle.docSync();
|
|
170
|
+
|
|
171
|
+
const spaceKey = getSpaceKeyFromDoc(doc) ?? undefined;
|
|
172
|
+
|
|
173
|
+
if (doc.objects) {
|
|
174
|
+
yield Object.entries(doc.objects as { [key: string]: any }).map(([objectId, object]) => {
|
|
175
|
+
return {
|
|
176
|
+
id: objectPointerCodec.encode({ documentId: handle.documentId, objectId, spaceKey }),
|
|
177
|
+
object,
|
|
178
|
+
heads: getHeads(doc),
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (doc.links) {
|
|
184
|
+
for (const id of Object.values(doc.links as { [echoId: string]: string })) {
|
|
185
|
+
if (visited.has(id)) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const linkHandle = await automergeHost.loadDoc(Context.default(), id as DocumentId);
|
|
189
|
+
for await (const result of getObjectsFromHandle(linkHandle)) {
|
|
190
|
+
yield result;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
visited.add(handle.documentId);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// TODO(mykola): Use list of roots instead of iterating over all handles.
|
|
199
|
+
for (const handle of Object.values(automergeHost.repo.handles)) {
|
|
200
|
+
if (visited.has(handle.documentId)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
for await (const result of getObjectsFromHandle(handle)) {
|
|
204
|
+
yield result;
|
|
205
|
+
}
|
|
206
|
+
visited.add(handle.documentId);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type DocumentId } from '@dxos/automerge/automerge-repo';
|
|
6
|
+
import { Context, LifecycleState, Resource } from '@dxos/context';
|
|
7
|
+
import { createIdFromSpaceKey } from '@dxos/echo-protocol';
|
|
8
|
+
import { type Indexer, type IndexQuery } from '@dxos/indexing';
|
|
9
|
+
import { invariant } from '@dxos/invariant';
|
|
10
|
+
import { PublicKey } from '@dxos/keys';
|
|
11
|
+
import { objectPointerCodec } from '@dxos/protocols';
|
|
12
|
+
import { type Filter as FilterProto } from '@dxos/protocols/proto/dxos/echo/filter';
|
|
13
|
+
import { type QueryRequest, type QueryResult } from '@dxos/protocols/proto/dxos/echo/query';
|
|
14
|
+
import { trace } from '@dxos/tracing';
|
|
15
|
+
import { nonNullable } from '@dxos/util';
|
|
16
|
+
|
|
17
|
+
import { type AutomergeHost, getSpaceKeyFromDoc } from '../automerge';
|
|
18
|
+
|
|
19
|
+
type QueryStateParams = {
|
|
20
|
+
indexer: Indexer;
|
|
21
|
+
automergeHost: AutomergeHost;
|
|
22
|
+
request: QueryRequest;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type QueryRunResult = {
|
|
26
|
+
changed: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type QueryMetrics = {
|
|
30
|
+
objectsReturned: number;
|
|
31
|
+
objectsReturnedFromIndex: number;
|
|
32
|
+
documentsLoaded: number;
|
|
33
|
+
executionTime: number;
|
|
34
|
+
indexQueryTime: number;
|
|
35
|
+
documentLoadTime: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Manages querying logic on service side.
|
|
40
|
+
*/
|
|
41
|
+
@trace.resource()
|
|
42
|
+
export class QueryState extends Resource {
|
|
43
|
+
private _results: QueryResult[] = [];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Metrics are only captured for the first run of the query since that is the most representative.
|
|
47
|
+
* We plan to change the query logic so that reactive updates do not require a full re-run of the query.
|
|
48
|
+
*/
|
|
49
|
+
private _firstRun = true;
|
|
50
|
+
|
|
51
|
+
@trace.info({ depth: null })
|
|
52
|
+
public readonly filter: FilterProto;
|
|
53
|
+
|
|
54
|
+
@trace.info()
|
|
55
|
+
public metrics: QueryMetrics = {
|
|
56
|
+
objectsReturned: 0,
|
|
57
|
+
objectsReturnedFromIndex: 0,
|
|
58
|
+
documentsLoaded: 0,
|
|
59
|
+
executionTime: 0,
|
|
60
|
+
indexQueryTime: 0,
|
|
61
|
+
documentLoadTime: 0,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
@trace.info()
|
|
65
|
+
get active() {
|
|
66
|
+
return this._lifecycleState === LifecycleState.OPEN;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
constructor(private readonly _params: QueryStateParams) {
|
|
70
|
+
super();
|
|
71
|
+
this.filter = _params.request.filter;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getResults() {
|
|
75
|
+
return this._results;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// https://github.com/open-telemetry/semantic-conventions/blob/main/docs/attributes-registry/db.md#generic-database-attributes
|
|
79
|
+
@trace.span({ showInBrowserTimeline: true, op: 'db.query', attributes: { 'db.system': 'echo' } })
|
|
80
|
+
async execQuery(): Promise<QueryRunResult> {
|
|
81
|
+
const filter = this._params.request.filter;
|
|
82
|
+
|
|
83
|
+
const beginQuery = performance.now();
|
|
84
|
+
|
|
85
|
+
// For object id filters, we return no results as those are handled by the SpaceQuerySource.
|
|
86
|
+
const hits =
|
|
87
|
+
filter.objectIds && filter.objectIds?.length > 0
|
|
88
|
+
? []
|
|
89
|
+
: await this._params.indexer.execQuery(filterToIndexQuery(filter));
|
|
90
|
+
if (this._firstRun) {
|
|
91
|
+
this.metrics.indexQueryTime = performance.now() - beginQuery;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const beginFilter = performance.now();
|
|
95
|
+
|
|
96
|
+
const results: QueryResult[] = (
|
|
97
|
+
await Promise.all(
|
|
98
|
+
hits.map(async (result) => {
|
|
99
|
+
if (this._firstRun) {
|
|
100
|
+
this.metrics.objectsReturnedFromIndex++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { objectId, documentId, spaceKey: spaceKeyInIndex } = objectPointerCodec.decode(result.id);
|
|
104
|
+
|
|
105
|
+
let spaceKey: string | null;
|
|
106
|
+
if (spaceKeyInIndex !== undefined) {
|
|
107
|
+
spaceKey = spaceKeyInIndex;
|
|
108
|
+
} else {
|
|
109
|
+
// Indexes created by older versions of the indexer do not have the spaceKey in the index.
|
|
110
|
+
// If the spaceKey is not in the index, we need to load the document to get it.
|
|
111
|
+
|
|
112
|
+
if (this._firstRun) {
|
|
113
|
+
this.metrics.documentsLoaded++;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const handle = await this._params.automergeHost.loadDoc(Context.default(), documentId as DocumentId);
|
|
117
|
+
|
|
118
|
+
// `whenReady` creates a timeout so we guard it with an if to skip it if the handle is already ready.
|
|
119
|
+
if (this._ctx.disposed) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
spaceKey = getSpaceKeyFromDoc(handle.docSync());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!spaceKey) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// TODO(mykola): Remove business logic from here.
|
|
129
|
+
if (
|
|
130
|
+
this._params.request.filter.options?.spaces?.length &&
|
|
131
|
+
!this._params.request.filter.options.spaces.some((key) => key.equals(spaceKey!))
|
|
132
|
+
) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (this._firstRun) {
|
|
137
|
+
this.metrics.objectsReturned++;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
id: objectId,
|
|
142
|
+
documentId,
|
|
143
|
+
spaceId: await createIdFromSpaceKey(PublicKey.from(spaceKey)),
|
|
144
|
+
spaceKey: PublicKey.from(spaceKey),
|
|
145
|
+
rank: result.rank,
|
|
146
|
+
} satisfies QueryResult;
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
).filter(nonNullable);
|
|
150
|
+
|
|
151
|
+
if (this._firstRun) {
|
|
152
|
+
this.metrics.documentLoadTime = performance.now() - beginFilter;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (this._ctx.disposed) {
|
|
156
|
+
return { changed: false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const areResultsUnchanged =
|
|
160
|
+
!this._firstRun &&
|
|
161
|
+
this._results.length === results.length &&
|
|
162
|
+
this._results.every((oldResult) => results.some((result) => result.id === oldResult.id)) &&
|
|
163
|
+
results.every((result) => this._results.some((oldResult) => oldResult.id === result.id));
|
|
164
|
+
|
|
165
|
+
if (this._firstRun) {
|
|
166
|
+
this.metrics.executionTime = performance.now() - beginQuery;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this._firstRun = false;
|
|
170
|
+
if (areResultsUnchanged) {
|
|
171
|
+
return { changed: false };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
this._results = results;
|
|
175
|
+
return { changed: true };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// TODO(burdon): Process Filter DSL.
|
|
180
|
+
const filterToIndexQuery = (filter: FilterProto): IndexQuery => {
|
|
181
|
+
invariant(!(filter.type && (filter.or ?? []).length > 0), 'Cannot mix type and or filters.');
|
|
182
|
+
invariant(
|
|
183
|
+
(filter.or ?? []).every((subFilter) => !(subFilter.type && (subFilter.or ?? []).length > 0)),
|
|
184
|
+
'Cannot mix type and or filters.',
|
|
185
|
+
);
|
|
186
|
+
if (
|
|
187
|
+
filter.type ||
|
|
188
|
+
((filter.or ?? []).length > 0 && (filter.or ?? []).every((subFilter) => !subFilter.not && subFilter.type))
|
|
189
|
+
) {
|
|
190
|
+
return {
|
|
191
|
+
typenames: filter.type?.objectId
|
|
192
|
+
? [filter.type.objectId]
|
|
193
|
+
: (filter.or ?? []).map((f) => f.type?.objectId).filter(nonNullable),
|
|
194
|
+
inverted: filter.not,
|
|
195
|
+
};
|
|
196
|
+
} else {
|
|
197
|
+
// Query all objects.
|
|
198
|
+
return { typenames: [] };
|
|
199
|
+
}
|
|
200
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import isEqual from 'lodash.isequal';
|
|
6
|
+
|
|
7
|
+
import { Event, UpdateScheduler } from '@dxos/async';
|
|
8
|
+
import { interpretAsDocumentId, type DocHandle, type DocumentId } from '@dxos/automerge/automerge-repo';
|
|
9
|
+
import { Resource, Context } from '@dxos/context';
|
|
10
|
+
import { type SpaceDoc } from '@dxos/echo-protocol';
|
|
11
|
+
import { type SpaceId } from '@dxos/keys';
|
|
12
|
+
|
|
13
|
+
import { DatabaseRoot } from './database-root';
|
|
14
|
+
|
|
15
|
+
export class SpaceStateManager extends Resource {
|
|
16
|
+
private readonly _roots = new Map<DocumentId, DatabaseRoot>();
|
|
17
|
+
private readonly _rootBySpace = new Map<SpaceId, DocumentId>();
|
|
18
|
+
private readonly _perRootContext = new Map<DocumentId, Context>();
|
|
19
|
+
private readonly _lastSpaceDocumentList = new Map<SpaceId, DocumentId[]>();
|
|
20
|
+
|
|
21
|
+
public readonly spaceDocumentListUpdated = new Event<SpaceDocumentListUpdatedEvent>();
|
|
22
|
+
|
|
23
|
+
protected override async _close(ctx: Context): Promise<void> {
|
|
24
|
+
for (const [_, rootCtx] of this._perRootContext) {
|
|
25
|
+
await rootCtx.dispose();
|
|
26
|
+
}
|
|
27
|
+
this._roots.clear();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get roots(): ReadonlyMap<DocumentId, DatabaseRoot> {
|
|
31
|
+
return this._roots;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getRootByDocumentId(documentId: DocumentId): DatabaseRoot | undefined {
|
|
35
|
+
return this._roots.get(documentId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async assignRootToSpace(spaceId: SpaceId, handle: DocHandle<SpaceDoc>): Promise<DatabaseRoot> {
|
|
39
|
+
let root: DatabaseRoot;
|
|
40
|
+
if (this._roots.has(handle.documentId)) {
|
|
41
|
+
root = this._roots.get(handle.documentId)!;
|
|
42
|
+
} else {
|
|
43
|
+
root = new DatabaseRoot(handle);
|
|
44
|
+
this._roots.set(handle.documentId, root);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (this._rootBySpace.get(spaceId) === root.handle.documentId) {
|
|
48
|
+
return root;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const prevRootId = this._rootBySpace.get(spaceId);
|
|
52
|
+
if (prevRootId) {
|
|
53
|
+
void this._perRootContext.get(prevRootId)?.dispose();
|
|
54
|
+
this._perRootContext.delete(prevRootId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this._rootBySpace.set(spaceId, root.handle.documentId);
|
|
58
|
+
const ctx = new Context();
|
|
59
|
+
|
|
60
|
+
this._perRootContext.set(root.handle.documentId, ctx);
|
|
61
|
+
|
|
62
|
+
await root.handle.whenReady();
|
|
63
|
+
|
|
64
|
+
const documentListCheckScheduler = new UpdateScheduler(
|
|
65
|
+
ctx,
|
|
66
|
+
async () => {
|
|
67
|
+
const documentIds = [root.documentId, ...root.getAllLinkedDocuments().map((url) => interpretAsDocumentId(url))];
|
|
68
|
+
if (!isEqual(documentIds, this._lastSpaceDocumentList.get(spaceId))) {
|
|
69
|
+
this._lastSpaceDocumentList.set(spaceId, documentIds);
|
|
70
|
+
this.spaceDocumentListUpdated.emit(new SpaceDocumentListUpdatedEvent(spaceId, documentIds));
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{ maxFrequency: 50 },
|
|
74
|
+
);
|
|
75
|
+
const triggerCheckOnChange = () => documentListCheckScheduler.trigger();
|
|
76
|
+
root.handle.addListener('change', triggerCheckOnChange);
|
|
77
|
+
ctx.onDispose(() => root.handle.removeListener('change', triggerCheckOnChange));
|
|
78
|
+
|
|
79
|
+
documentListCheckScheduler.trigger();
|
|
80
|
+
|
|
81
|
+
return root;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class SpaceDocumentListUpdatedEvent {
|
|
86
|
+
constructor(
|
|
87
|
+
public readonly spaceId: SpaceId,
|
|
88
|
+
public readonly documentIds: DocumentId[],
|
|
89
|
+
) {}
|
|
90
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { Event } from '@dxos/async';
|
|
8
|
+
import { cbor } from '@dxos/automerge/automerge-repo';
|
|
9
|
+
import { createEphemeralEdgeIdentity, EdgeClient, MessageSchema } from '@dxos/edge-client';
|
|
10
|
+
import { createTestEdgeWsServer } from '@dxos/edge-client/testing';
|
|
11
|
+
import { PublicKey, SpaceId } from '@dxos/keys';
|
|
12
|
+
import { EdgeService } from '@dxos/protocols';
|
|
13
|
+
import type { AutomergeProtocolMessage } from '@dxos/protocols';
|
|
14
|
+
import { createBuf } from '@dxos/protocols/buf';
|
|
15
|
+
import type { Peer } from '@dxos/protocols/proto/dxos/edge/messenger';
|
|
16
|
+
import { openAndClose } from '@dxos/test-utils';
|
|
17
|
+
|
|
18
|
+
import { EchoEdgeReplicator } from './echo-edge-replicator';
|
|
19
|
+
import type { EchoReplicatorContext } from '../automerge';
|
|
20
|
+
|
|
21
|
+
describe('EchoEdgeReplicator', () => {
|
|
22
|
+
test('reconnects', async ({ onTestFinished }) => {
|
|
23
|
+
const { endpoint, cleanup, sendMessage } = await createTestEdgeWsServer(8001);
|
|
24
|
+
onTestFinished(cleanup);
|
|
25
|
+
const client = new EdgeClient(await createEphemeralEdgeIdentity(), { socketEndpoint: endpoint });
|
|
26
|
+
await openAndClose(client);
|
|
27
|
+
|
|
28
|
+
const spaceId = SpaceId.random();
|
|
29
|
+
|
|
30
|
+
const replicator = new EchoEdgeReplicator({ edgeConnection: client });
|
|
31
|
+
const { context, connectionOpen } = createMockContext();
|
|
32
|
+
await replicator.connect(context);
|
|
33
|
+
await replicator.connectToSpace(spaceId);
|
|
34
|
+
|
|
35
|
+
client.setIdentity(await createEphemeralEdgeIdentity());
|
|
36
|
+
await connectionOpen.waitForCount(1);
|
|
37
|
+
|
|
38
|
+
sendMessage(
|
|
39
|
+
createForbiddenMessage(
|
|
40
|
+
{
|
|
41
|
+
identityKey: client.identityKey,
|
|
42
|
+
peerKey: client.peerKey,
|
|
43
|
+
},
|
|
44
|
+
spaceId,
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
await connectionOpen.waitForCount(1);
|
|
48
|
+
|
|
49
|
+
// Double restart to check for race conditions.
|
|
50
|
+
client.setIdentity(await createEphemeralEdgeIdentity());
|
|
51
|
+
sendMessage(
|
|
52
|
+
createForbiddenMessage(
|
|
53
|
+
{
|
|
54
|
+
identityKey: client.identityKey,
|
|
55
|
+
peerKey: client.peerKey,
|
|
56
|
+
},
|
|
57
|
+
spaceId,
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
await connectionOpen.waitForCount(1);
|
|
61
|
+
|
|
62
|
+
await replicator.disconnect();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const createMockContext = () => {
|
|
67
|
+
const connectionOpen = new Event();
|
|
68
|
+
return {
|
|
69
|
+
context: {
|
|
70
|
+
getContainingSpaceIdForDocument: async (documentId) => null,
|
|
71
|
+
getContainingSpaceForDocument: async (documentId) => null,
|
|
72
|
+
onConnectionAuthScopeChanged: (connection) => {},
|
|
73
|
+
isDocumentInRemoteCollection: async (params) => false,
|
|
74
|
+
onConnectionClosed: (connection) => {},
|
|
75
|
+
onConnectionOpen: (connection) => {
|
|
76
|
+
connectionOpen.emit();
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
peerId: PublicKey.random().toHex(),
|
|
80
|
+
} satisfies EchoReplicatorContext,
|
|
81
|
+
|
|
82
|
+
connectionOpen,
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const createForbiddenMessage = (target: Peer, spaceId: SpaceId) =>
|
|
87
|
+
createBuf(MessageSchema, {
|
|
88
|
+
target: [target],
|
|
89
|
+
serviceId: `${EdgeService.AUTOMERGE_REPLICATOR}:${spaceId}`,
|
|
90
|
+
payload: {
|
|
91
|
+
value: cbor.encode({
|
|
92
|
+
type: 'error',
|
|
93
|
+
message: 'Forbidden',
|
|
94
|
+
} satisfies AutomergeProtocolMessage),
|
|
95
|
+
},
|
|
96
|
+
});
|