@dxos/echo-pipeline 0.8.4-main.ae835ea → 0.8.4-main.bc2380dfbc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +102 -5
- package/README.md +1 -1
- package/dist/lib/{browser/chunk-WJJXJTNS.mjs → neutral/chunk-46DCRDA7.mjs} +44 -13
- package/dist/lib/neutral/chunk-46DCRDA7.mjs.map +7 -0
- package/dist/lib/{browser/chunk-FR6GJ4S4.mjs → neutral/chunk-LR7UFVQX.mjs} +145 -618
- package/dist/lib/neutral/chunk-LR7UFVQX.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/filter/index.mjs +1 -2
- package/dist/lib/neutral/index.mjs +7332 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/{browser → neutral}/testing/index.mjs +49 -292
- 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 +105 -27
- 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 +22 -2
- 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 +17 -11
- package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
- package/dist/types/src/automerge/echo-replicator.d.ts +28 -16
- package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
- package/dist/types/src/automerge/errors.d.ts +35 -0
- package/dist/types/src/automerge/errors.d.ts.map +1 -0
- package/dist/types/src/automerge/handle-state.d.ts +52 -0
- package/dist/types/src/automerge/handle-state.d.ts.map +1 -0
- package/dist/types/src/automerge/heads-store.d.ts +9 -2
- package/dist/types/src/automerge/heads-store.d.ts.map +1 -1
- package/dist/types/src/automerge/index.d.ts +2 -0
- package/dist/types/src/automerge/index.d.ts.map +1 -1
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +10 -4
- package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
- package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +8 -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/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 +12 -6
- package/dist/types/src/db-host/data-service.d.ts.map +1 -1
- package/dist/types/src/db-host/database-root.d.ts +9 -3
- package/dist/types/src/db-host/database-root.d.ts.map +1 -1
- package/dist/types/src/db-host/documents-synchronizer.d.ts +30 -8
- package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
- package/dist/types/src/db-host/echo-host.d.ts +74 -28
- package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
- package/dist/types/src/db-host/index.d.ts +2 -0
- package/dist/types/src/db-host/index.d.ts.map +1 -1
- package/dist/types/src/db-host/invalidation-hint.d.ts +19 -0
- package/dist/types/src/db-host/invalidation-hint.d.ts.map +1 -0
- package/dist/types/src/db-host/local-queue-service.d.ts +19 -0
- package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
- package/dist/types/src/db-host/query-invalidation.test.d.ts +2 -0
- package/dist/types/src/db-host/query-invalidation.test.d.ts.map +1 -0
- package/dist/types/src/db-host/query-service.d.ts +18 -10
- 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 +3 -2
- 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 +7 -6
- 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 +1 -1
- package/dist/types/src/filter/filter-match.d.ts.map +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 +2 -2
- 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 +16 -20
- package/dist/types/src/query/errors.d.ts.map +1 -1
- package/dist/types/src/query/plan.d.ts +59 -8
- package/dist/types/src/query/plan.d.ts.map +1 -1
- package/dist/types/src/query/query-executor.d.ts +55 -12
- package/dist/types/src/query/query-executor.d.ts.map +1 -1
- package/dist/types/src/query/query-planner.d.ts +10 -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 +4 -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 +9 -8
- package/dist/types/src/space/space-protocol.d.ts.map +1 -1
- package/dist/types/src/space/space.d.ts +5 -5
- 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.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 +13 -13
- package/dist/types/src/testing/test-replicator.d.ts.map +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 +263 -0
- package/src/automerge/automerge-host.test.ts +145 -32
- package/src/automerge/automerge-host.ts +442 -163
- package/src/automerge/automerge-repo-subduction.test.ts +1054 -0
- package/src/automerge/automerge-repo.test.ts +284 -77
- package/src/automerge/automerge-subduction.test.ts +330 -0
- package/src/automerge/collection-synchronizer.test.ts +96 -16
- package/src/automerge/collection-synchronizer.ts +125 -26
- package/src/automerge/echo-network-adapter.test.ts +2 -1
- package/src/automerge/echo-network-adapter.ts +44 -32
- package/src/automerge/echo-replicator.ts +30 -16
- package/src/automerge/errors.ts +10 -0
- package/src/automerge/handle-state.ts +62 -0
- package/src/automerge/heads-store.ts +42 -3
- package/src/automerge/index.ts +2 -0
- package/src/automerge/leveldb-storage-adapter.ts +70 -4
- package/src/automerge/mesh-echo-replicator-connection.ts +8 -8
- package/src/automerge/mesh-echo-replicator.ts +14 -11
- 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 +27 -10
- package/src/db-host/database-root.ts +19 -7
- package/src/db-host/documents-synchronizer.test.ts +145 -14
- package/src/db-host/documents-synchronizer.ts +158 -59
- package/src/db-host/echo-host.ts +376 -90
- 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 +108 -111
- 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 +11 -9
- package/src/db-host/stub.ts +33 -0
- package/src/edge/echo-edge-replicator.test.ts +9 -9
- package/src/edge/echo-edge-replicator.ts +50 -42
- 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 +41 -25
- package/src/filter/filter-match.ts +75 -20
- package/src/metadata/metadata-store.ts +8 -5
- package/src/pipeline/pipeline-stress.test.ts +1 -3
- package/src/pipeline/pipeline.test.ts +0 -1
- package/src/pipeline/pipeline.ts +2 -3
- package/src/query/errors.ts +2 -2
- package/src/query/plan.ts +73 -9
- package/src/query/query-executor.ts +1064 -172
- package/src/query/query-planner.test.ts +854 -86
- package/src/query/query-planner.ts +444 -38
- package/src/space/admission-discovery-extension.ts +1 -1
- package/src/space/auth.ts +6 -6
- package/src/space/control-pipeline.test.ts +2 -2
- package/src/space/control-pipeline.ts +7 -7
- package/src/space/space-manager.ts +12 -17
- package/src/space/space-protocol.browser.test.ts +13 -12
- package/src/space/space-protocol.test.ts +13 -13
- package/src/space/space-protocol.ts +16 -15
- package/src/space/space.test.ts +2 -2
- package/src/space/space.ts +9 -10
- package/src/testing/index.ts +0 -2
- package/src/testing/test-agent-builder.ts +0 -1
- package/src/testing/test-network-adapter.ts +24 -3
- package/src/testing/test-replicator.ts +15 -20
- package/src/util.ts +7 -3
- package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
- package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
- package/dist/lib/browser/chunk-FR6GJ4S4.mjs.map +0 -7
- package/dist/lib/browser/chunk-WJJXJTNS.mjs.map +0 -7
- package/dist/lib/browser/index.mjs +0 -5492
- 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-esm/chunk-HSLMI22Q.mjs +0 -11
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-JQCCVDLT.mjs +0 -2144
- package/dist/lib/node-esm/chunk-JQCCVDLT.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-LEQ77KAT.mjs +0 -240
- package/dist/lib/node-esm/chunk-LEQ77KAT.mjs.map +0 -7
- package/dist/lib/node-esm/filter/index.mjs +0 -13
- package/dist/lib/node-esm/filter/index.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -5492
- 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 -764
- 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,187 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as A from '@automerge/automerge';
|
|
6
|
+
import { type DocumentId } from '@automerge/automerge-repo';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
|
|
9
|
+
import { type Context } from '@dxos/context';
|
|
10
|
+
import { DatabaseDirectory, SpaceDocVersion } from '@dxos/echo-protocol';
|
|
11
|
+
import { objectStructureToJson } from '@dxos/echo/internal';
|
|
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: A.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: A.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
|
+
ctx: Context,
|
|
62
|
+
cursors: DataSourceCursor[],
|
|
63
|
+
opts?: { limit?: number },
|
|
64
|
+
): Effect.Effect<{ objects: IndexerObject[]; cursors: DataSourceCursor[] }> {
|
|
65
|
+
return Effect.gen(this, function* () {
|
|
66
|
+
// Build a map of documentId -> cursor for quick lookup.
|
|
67
|
+
const cursorMap = new Map<string, string>();
|
|
68
|
+
for (const cursor of cursors) {
|
|
69
|
+
if (cursor.resourceId) {
|
|
70
|
+
cursorMap.set(cursor.resourceId, String(cursor.cursor));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Find changed documents by iterating all documents from HeadsStore.
|
|
75
|
+
const changedDocuments = yield* Effect.promise(async () => {
|
|
76
|
+
const result: { documentId: DocumentId; heads: A.Heads }[] = [];
|
|
77
|
+
const limit = opts?.limit ?? Infinity;
|
|
78
|
+
|
|
79
|
+
for await (const { documentId, heads } of this.#automergeHost.listDocumentHeads()) {
|
|
80
|
+
const existingCursor = cursorMap.get(documentId);
|
|
81
|
+
if (hasChanged(existingCursor, heads)) {
|
|
82
|
+
result.push({ documentId, heads });
|
|
83
|
+
if (result.length >= limit) {
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Load changed documents and extract objects.
|
|
92
|
+
const objects: IndexerObject[] = [];
|
|
93
|
+
const updatedCursors: DataSourceCursor[] = [];
|
|
94
|
+
|
|
95
|
+
for (const { documentId, heads: docHeads } of changedDocuments) {
|
|
96
|
+
try {
|
|
97
|
+
const handle = yield* Effect.promise(() => this.#automergeHost.loadDoc<DatabaseDirectory>(ctx, documentId));
|
|
98
|
+
if (!handle) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const doc = handle.doc();
|
|
102
|
+
|
|
103
|
+
// Skip outdated docs.
|
|
104
|
+
if (doc.version !== SpaceDocVersion.CURRENT) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Extract spaceId from document.
|
|
109
|
+
const spaceKeyHex = DatabaseDirectory.getSpaceKey(doc);
|
|
110
|
+
if (!spaceKeyHex) {
|
|
111
|
+
// Skip documents without a space key.
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const spaceKey = PublicKey.fromHex(spaceKeyHex);
|
|
115
|
+
const spaceId = yield* Effect.promise(() => createIdFromSpaceKey(spaceKey));
|
|
116
|
+
|
|
117
|
+
const existingCursor = cursorMap.get(documentId);
|
|
118
|
+
const { changedObjectIds, updatedAt } = inspectDocChanges(doc, existingCursor);
|
|
119
|
+
|
|
120
|
+
const docObjects = doc.objects ?? {};
|
|
121
|
+
for (const [objectId, structure] of Object.entries(docObjects)) {
|
|
122
|
+
if (changedObjectIds && !changedObjectIds.has(objectId)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
objects.push({
|
|
126
|
+
spaceId,
|
|
127
|
+
documentId,
|
|
128
|
+
queueId: null,
|
|
129
|
+
queueNamespace: null,
|
|
130
|
+
recordId: null,
|
|
131
|
+
data: objectStructureToJson(objectId, structure),
|
|
132
|
+
updatedAt,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Update cursor for this document.
|
|
137
|
+
updatedCursors.push({
|
|
138
|
+
spaceId,
|
|
139
|
+
resourceId: documentId,
|
|
140
|
+
cursor: headsCodec.encode(docHeads),
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
log.error('Error loading document for indexing', { documentId, error });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { objects, cursors: updatedCursors };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Determines which ECHO objects changed and the document-level max change timestamp.
|
|
154
|
+
*
|
|
155
|
+
* Uses `A.diff` to extract changed objectIds from patch paths (`["objects", objectId, ...]`),
|
|
156
|
+
* and `A.getChangesMetaSince` for the max timestamp (second-level precision).
|
|
157
|
+
* Returns `changedObjectIds: null` when all objects should be indexed (new document).
|
|
158
|
+
*/
|
|
159
|
+
const inspectDocChanges = (
|
|
160
|
+
doc: DatabaseDirectory,
|
|
161
|
+
existingCursor: string | undefined,
|
|
162
|
+
): { changedObjectIds: Set<string> | null; updatedAt: number } => {
|
|
163
|
+
if (!existingCursor) {
|
|
164
|
+
return { changedObjectIds: null, updatedAt: Date.now() };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const oldHeads = headsCodec.decode(existingCursor);
|
|
168
|
+
|
|
169
|
+
const patches = A.diff(doc, oldHeads, A.getHeads(doc));
|
|
170
|
+
const changedObjectIds = new Set<string>();
|
|
171
|
+
for (const patch of patches) {
|
|
172
|
+
if (patch.path.length >= 2 && patch.path[0] === 'objects') {
|
|
173
|
+
changedObjectIds.add(String(patch.path[1]));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const changes = A.getChangesMetaSince(doc, oldHeads);
|
|
178
|
+
let maxTime = 0;
|
|
179
|
+
for (const change of changes) {
|
|
180
|
+
if (change.time > maxTime) {
|
|
181
|
+
maxTime = change.time;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const updatedAt = maxTime > 0 ? maxTime * 1000 : Date.now();
|
|
185
|
+
|
|
186
|
+
return { changedObjectIds, updatedAt };
|
|
187
|
+
};
|
|
@@ -7,11 +7,14 @@ import { type DocumentId } from '@automerge/automerge-repo';
|
|
|
7
7
|
import { UpdateScheduler } from '@dxos/async';
|
|
8
8
|
import { type RequestOptions } from '@dxos/codec-protobuf';
|
|
9
9
|
import { Stream } from '@dxos/codec-protobuf/stream';
|
|
10
|
+
import { Context } from '@dxos/context';
|
|
10
11
|
import { invariant } from '@dxos/invariant';
|
|
11
12
|
import { SpaceId } from '@dxos/keys';
|
|
12
13
|
import { log } from '@dxos/log';
|
|
13
14
|
import {
|
|
14
15
|
type BatchedDocumentUpdates,
|
|
16
|
+
type CreateDocumentRequest,
|
|
17
|
+
type CreateDocumentResponse,
|
|
15
18
|
type DataService,
|
|
16
19
|
type FlushRequest,
|
|
17
20
|
type GetDocumentHeadsRequest,
|
|
@@ -26,11 +29,10 @@ import {
|
|
|
26
29
|
} from '@dxos/protocols/proto/dxos/echo/service';
|
|
27
30
|
|
|
28
31
|
import { type AutomergeHost, deriveCollectionIdFromSpaceId } from '../automerge';
|
|
29
|
-
|
|
30
32
|
import { DocumentsSynchronizer } from './documents-synchronizer';
|
|
31
33
|
import { type SpaceStateManager } from './space-state-manager';
|
|
32
34
|
|
|
33
|
-
export type
|
|
35
|
+
export type DataServiceProps = {
|
|
34
36
|
automergeHost: AutomergeHost;
|
|
35
37
|
spaceStateManager: SpaceStateManager;
|
|
36
38
|
updateIndexes: () => Promise<void>;
|
|
@@ -51,7 +53,7 @@ export class DataServiceImpl implements DataService {
|
|
|
51
53
|
private readonly _spaceStateManager: SpaceStateManager;
|
|
52
54
|
private readonly _updateIndexes: () => Promise<void>;
|
|
53
55
|
|
|
54
|
-
constructor(params:
|
|
56
|
+
constructor(params: DataServiceProps) {
|
|
55
57
|
this._automergeHost = params.automergeHost;
|
|
56
58
|
this._spaceStateManager = params.spaceStateManager;
|
|
57
59
|
this._updateIndexes = params.updateIndexes;
|
|
@@ -60,7 +62,7 @@ export class DataServiceImpl implements DataService {
|
|
|
60
62
|
subscribe(request: SubscribeRequest): Stream<BatchedDocumentUpdates> {
|
|
61
63
|
return new Stream<BatchedDocumentUpdates>(({ next, ready }) => {
|
|
62
64
|
const synchronizer = new DocumentsSynchronizer({
|
|
63
|
-
|
|
65
|
+
automergeHost: this._automergeHost,
|
|
64
66
|
sendUpdates: (updates) => next(updates),
|
|
65
67
|
});
|
|
66
68
|
synchronizer
|
|
@@ -86,18 +88,23 @@ export class DataServiceImpl implements DataService {
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
async
|
|
91
|
+
async createDocument(request: CreateDocumentRequest): Promise<CreateDocumentResponse> {
|
|
92
|
+
const handle = await this._automergeHost.createDoc(request.initialValue);
|
|
93
|
+
return { documentId: handle.documentId };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async update(request: UpdateRequest, options?: RequestOptions): Promise<void> {
|
|
90
97
|
if (!request.updates) {
|
|
91
98
|
return;
|
|
92
99
|
}
|
|
93
100
|
const synchronizer = this._subscriptions.get(request.subscriptionId);
|
|
94
101
|
invariant(synchronizer, 'Subscription not found');
|
|
95
102
|
|
|
96
|
-
await synchronizer.update(request.updates);
|
|
103
|
+
await synchronizer.update(options?.ctx ?? Context.default(), request.updates);
|
|
97
104
|
}
|
|
98
105
|
|
|
99
|
-
async flush(request: FlushRequest): Promise<void> {
|
|
100
|
-
await this._automergeHost.flush(request);
|
|
106
|
+
async flush(request: FlushRequest, options?: RequestOptions): Promise<void> {
|
|
107
|
+
await this._automergeHost.flush(options?.ctx ?? Context.default(), request);
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
async getDocumentHeads(request: GetDocumentHeadsRequest): Promise<GetDocumentHeadsResponse> {
|
|
@@ -117,10 +124,10 @@ export class DataServiceImpl implements DataService {
|
|
|
117
124
|
request: WaitUntilHeadsReplicatedRequest,
|
|
118
125
|
options?: RequestOptions | undefined,
|
|
119
126
|
): Promise<void> {
|
|
120
|
-
await this._automergeHost.waitUntilHeadsReplicated(request.heads);
|
|
127
|
+
await this._automergeHost.waitUntilHeadsReplicated(options?.ctx ?? Context.default(), request.heads);
|
|
121
128
|
}
|
|
122
129
|
|
|
123
|
-
async reIndexHeads(request: ReIndexHeadsRequest,
|
|
130
|
+
async reIndexHeads(request: ReIndexHeadsRequest, _options?: RequestOptions): Promise<void> {
|
|
124
131
|
await this._automergeHost.reIndexHeads((request.documentIds ?? []) as DocumentId[]);
|
|
125
132
|
}
|
|
126
133
|
|
|
@@ -128,6 +135,16 @@ export class DataServiceImpl implements DataService {
|
|
|
128
135
|
await this._updateIndexes();
|
|
129
136
|
}
|
|
130
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Test affordance: pause/resume flushing of document updates on every
|
|
140
|
+
* active subscription. See `DocumentsSynchronizer.setSendUpdatesPaused`.
|
|
141
|
+
*/
|
|
142
|
+
setAllSubscriptionsSendUpdatesPaused(paused: boolean): void {
|
|
143
|
+
for (const synchronizer of this._subscriptions.values()) {
|
|
144
|
+
synchronizer.setSendUpdatesPaused(paused);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
subscribeSpaceSyncState(request: GetSpaceSyncStateRequest): Stream<SpaceSyncState> {
|
|
132
149
|
return new Stream<SpaceSyncState>(({ ctx, next, ready }) => {
|
|
133
150
|
const spaceId = request.spaceId;
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import type * as A from '@automerge/automerge';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type AutomergeUrl,
|
|
8
|
+
type DocHandle,
|
|
9
|
+
type DocumentId,
|
|
10
|
+
type DocumentQuery,
|
|
11
|
+
interpretAsDocumentId,
|
|
12
|
+
} from '@automerge/automerge-repo';
|
|
7
13
|
|
|
8
14
|
import { DatabaseDirectory, SpaceDocVersion } from '@dxos/echo-protocol';
|
|
9
15
|
import { invariant } from '@dxos/invariant';
|
|
@@ -25,26 +31,32 @@ export class DatabaseRoot {
|
|
|
25
31
|
});
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
/**
|
|
35
|
+
* @param _query - Live `DocumentQuery` for the root doc. Carries both the
|
|
36
|
+
* `DocHandle` (synchronously created with the query) and the actual
|
|
37
|
+
* readiness state. See `getHandleState` in `@dxos/echo-pipeline` for why
|
|
38
|
+
* we read liveness off the query rather than `DocHandle.*` predicates.
|
|
39
|
+
*/
|
|
40
|
+
constructor(private readonly _query: DocumentQuery<DatabaseDirectory>) {}
|
|
29
41
|
|
|
30
42
|
get documentId(): DocumentId {
|
|
31
|
-
return this.
|
|
43
|
+
return this._query.documentId;
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
get url() {
|
|
35
|
-
return this.
|
|
47
|
+
return this._query.handle.url;
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
get isLoaded(): boolean {
|
|
39
|
-
return this.
|
|
51
|
+
return this._query.peek().state === 'ready';
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
get handle(): DocHandle<DatabaseDirectory> {
|
|
43
|
-
return this.
|
|
55
|
+
return this._query.handle;
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
doc(): A.Doc<DatabaseDirectory> | null {
|
|
47
|
-
return this.
|
|
59
|
+
return this.isLoaded ? this._query.handle.doc() : null;
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
getVersion(): SpaceDocVersion | null {
|
|
@@ -2,39 +2,170 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { Repo, generateAutomergeUrl, parseAutomergeUrl } from '@automerge/automerge-repo';
|
|
5
|
+
import { type DocumentId } from '@automerge/automerge-repo';
|
|
7
6
|
import { describe, expect, test } from 'vitest';
|
|
8
7
|
|
|
9
|
-
import { sleep } from '@dxos/async';
|
|
8
|
+
import { Trigger, asyncTimeout, sleep } from '@dxos/async';
|
|
9
|
+
import { Context } from '@dxos/context';
|
|
10
|
+
import { invariant } from '@dxos/invariant';
|
|
11
|
+
import { PublicKey } from '@dxos/keys';
|
|
12
|
+
import { createTestLevel } from '@dxos/kv-store/testing';
|
|
10
13
|
import { openAndClose } from '@dxos/test-utils';
|
|
11
14
|
|
|
15
|
+
import { AutomergeHost } from '../automerge';
|
|
12
16
|
import { DocumentsSynchronizer } from './documents-synchronizer';
|
|
13
17
|
|
|
14
18
|
describe('DocumentsSynchronizer', () => {
|
|
19
|
+
test('two synchronizers receive updates for shared document', async () => {
|
|
20
|
+
const kv = createTestLevel();
|
|
21
|
+
const host = new AutomergeHost({
|
|
22
|
+
db: kv,
|
|
23
|
+
});
|
|
24
|
+
await openAndClose(host);
|
|
25
|
+
const handle = await host.createDoc<{ text: string }>({ text: 'initial' });
|
|
26
|
+
|
|
27
|
+
// Create two synchronizers (simulates two clients connected to the same host).
|
|
28
|
+
const initialSync1 = new Trigger();
|
|
29
|
+
const initialSync2 = new Trigger();
|
|
30
|
+
const propagatedUpdate2 = new Trigger();
|
|
31
|
+
let initialSyncSettled = false;
|
|
32
|
+
const synchronizer1 = new DocumentsSynchronizer({
|
|
33
|
+
automergeHost: host,
|
|
34
|
+
sendUpdates: (batch) => {
|
|
35
|
+
for (const update of batch.updates ?? []) {
|
|
36
|
+
if (update.documentId === handle.documentId) {
|
|
37
|
+
initialSync1.wake();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
await openAndClose(synchronizer1);
|
|
43
|
+
|
|
44
|
+
const updates2: string[] = [];
|
|
45
|
+
const synchronizer2 = new DocumentsSynchronizer({
|
|
46
|
+
automergeHost: host,
|
|
47
|
+
sendUpdates: (batch) => {
|
|
48
|
+
for (const update of batch.updates ?? []) {
|
|
49
|
+
updates2.push(update.documentId);
|
|
50
|
+
if (update.documentId === handle.documentId) {
|
|
51
|
+
if (initialSyncSettled) {
|
|
52
|
+
propagatedUpdate2.wake();
|
|
53
|
+
} else {
|
|
54
|
+
initialSync2.wake();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
await openAndClose(synchronizer2);
|
|
61
|
+
|
|
62
|
+
// Both synchronizers subscribe to the same document.
|
|
63
|
+
await synchronizer1.addDocuments([handle.documentId]);
|
|
64
|
+
await synchronizer2.addDocuments([handle.documentId]);
|
|
65
|
+
|
|
66
|
+
await asyncTimeout(Promise.all([initialSync1.wait(), initialSync2.wait()]), 1_000);
|
|
67
|
+
initialSyncSettled = true;
|
|
68
|
+
const initialUpdates2 = updates2.length;
|
|
69
|
+
|
|
70
|
+
// Synchronizer 1 makes a change (simulates client 1 creating an object).
|
|
71
|
+
await synchronizer1.update(Context.default(), [
|
|
72
|
+
{
|
|
73
|
+
documentId: handle.documentId,
|
|
74
|
+
mutation: new Uint8Array([]), // Empty mutation for test - the actual mutation is applied via handle.change
|
|
75
|
+
},
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// Apply the actual change to the handle (simulates what happens when client sends mutation).
|
|
79
|
+
handle.change((doc: any) => {
|
|
80
|
+
doc.text = 'modified by client 1';
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await asyncTimeout(propagatedUpdate2.wait(), 1_000);
|
|
84
|
+
|
|
85
|
+
// Synchronizer 2 should receive the update even though synchronizer 1 made the change.
|
|
86
|
+
expect(updates2.length).to.be.greaterThan(initialUpdates2);
|
|
87
|
+
});
|
|
88
|
+
|
|
15
89
|
test('do not get init changes for client created docs', async () => {
|
|
16
90
|
let counter = 0;
|
|
17
|
-
const
|
|
91
|
+
const kv = createTestLevel();
|
|
92
|
+
const host = new AutomergeHost({
|
|
93
|
+
db: kv,
|
|
94
|
+
});
|
|
95
|
+
await openAndClose(host);
|
|
18
96
|
const synchronizer = new DocumentsSynchronizer({
|
|
19
|
-
|
|
97
|
+
automergeHost: host,
|
|
20
98
|
sendUpdates: () => {
|
|
21
99
|
counter++;
|
|
22
100
|
},
|
|
23
101
|
});
|
|
24
102
|
await openAndClose(synchronizer);
|
|
25
103
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
},
|
|
32
|
-
]);
|
|
104
|
+
// First create the document on the host (simulates DataService.createDocument).
|
|
105
|
+
const handle = await host.createDoc<{ text: string }>({ text: 'hello' });
|
|
106
|
+
|
|
107
|
+
// Add document to synchronizer (simulates updateSubscription with addIds).
|
|
108
|
+
await synchronizer.addDocuments([handle.documentId]);
|
|
33
109
|
|
|
34
110
|
// Wait for the changes to be processed.
|
|
35
111
|
await sleep(100);
|
|
36
112
|
|
|
37
|
-
//
|
|
38
|
-
|
|
113
|
+
// Updates will be sent for the initial sync (this is expected behavior).
|
|
114
|
+
// The key is that subsequent updates from the client should be properly synced.
|
|
115
|
+
expect(counter).to.be.greaterThanOrEqual(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('persistence', () => {
|
|
119
|
+
test('document created on host persists without explicit flush', async () => {
|
|
120
|
+
const path = `/tmp/dxos-${PublicKey.random().toHex()}`;
|
|
121
|
+
let documentId: DocumentId;
|
|
122
|
+
const text = 'Hello World!';
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
const kv = createTestLevel(path);
|
|
126
|
+
const host = new AutomergeHost({
|
|
127
|
+
db: kv,
|
|
128
|
+
});
|
|
129
|
+
await openAndClose(host);
|
|
130
|
+
const synchronizer = new DocumentsSynchronizer({
|
|
131
|
+
automergeHost: host,
|
|
132
|
+
sendUpdates: () => {},
|
|
133
|
+
});
|
|
134
|
+
await openAndClose(synchronizer);
|
|
135
|
+
|
|
136
|
+
// Create document on host (simulates DataService.createDocument).
|
|
137
|
+
const handle = await host.createDoc<{ text: string }>({ text });
|
|
138
|
+
documentId = handle.documentId;
|
|
139
|
+
|
|
140
|
+
// Add to synchronizer (simulates updateSubscription with addIds).
|
|
141
|
+
await synchronizer.addDocuments([documentId]);
|
|
142
|
+
|
|
143
|
+
// Wait for auto-save (no explicit flush).
|
|
144
|
+
await sleep(500);
|
|
145
|
+
|
|
146
|
+
// Close in same order as repo-proxy.test.ts: level first, then host.
|
|
147
|
+
await kv.close();
|
|
148
|
+
await host.close();
|
|
149
|
+
await synchronizer.close();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
{
|
|
153
|
+
// Reopen and verify persistence.
|
|
154
|
+
const kv = createTestLevel(path);
|
|
155
|
+
const host = new AutomergeHost({
|
|
156
|
+
db: kv,
|
|
157
|
+
});
|
|
158
|
+
await openAndClose(host);
|
|
159
|
+
|
|
160
|
+
const handle = await host.loadDoc<{ text: string }>(Context.default(), documentId);
|
|
161
|
+
invariant(handle);
|
|
162
|
+
await handle.whenReady();
|
|
163
|
+
|
|
164
|
+
expect(handle.doc().text).to.equal(text);
|
|
165
|
+
|
|
166
|
+
await kv.close();
|
|
167
|
+
await host.close();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
39
170
|
});
|
|
40
171
|
});
|