@dxos/index-core 0.0.0

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.
@@ -0,0 +1,251 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Reactivity from '@effect/experimental/Reactivity';
6
+ import * as SqliteClient from '@effect/sql-sqlite-node/SqliteClient';
7
+ import { describe, expect, it } from '@effect/vitest';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Layer from 'effect/Layer';
10
+
11
+ import { ATTR_TYPE } from '@dxos/echo/internal';
12
+ import { DXN, ObjectId, SpaceId } from '@dxos/keys';
13
+
14
+ import type { IndexerObject } from './interface';
15
+ import { ReverseRefIndex } from './reverse-ref-index';
16
+
17
+ const TYPE_PERSON = DXN.parse('dxn:type:example.com/type/Person:0.1.0').toString();
18
+ const TYPE_EXAMPLE = DXN.parse('dxn:type:example.com/type/Example:0.1.0').toString();
19
+
20
+ const TestLayer = Layer.merge(
21
+ SqliteClient.layer({
22
+ filename: ':memory:',
23
+ }),
24
+ Reactivity.layer,
25
+ );
26
+
27
+ describe('ReverseRefIndex', () => {
28
+ it.effect('should store and query reverse references', () =>
29
+ Effect.gen(function* () {
30
+ const reverseRefIndex = new ReverseRefIndex();
31
+ yield* reverseRefIndex.migrate();
32
+
33
+ const spaceId = SpaceId.random();
34
+ const sourceObjectId = ObjectId.random();
35
+ const targetObjectId = ObjectId.random();
36
+ const targetDxn = `dxn:echo:@:${targetObjectId}`;
37
+
38
+ const sourceObject: IndexerObject = {
39
+ spaceId,
40
+ queueId: ObjectId.random(),
41
+ documentId: null,
42
+ recordId: 1,
43
+ data: {
44
+ id: sourceObjectId,
45
+ [ATTR_TYPE]: TYPE_PERSON,
46
+ contact: { '/': targetDxn },
47
+ },
48
+ };
49
+
50
+ yield* reverseRefIndex.update([sourceObject]);
51
+
52
+ const results = yield* reverseRefIndex.query({ targetDxn });
53
+ expect(results.length).toBe(1);
54
+ expect(results[0].targetDxn).toBe(targetDxn);
55
+ expect(results[0].propPath).toBe('contact');
56
+ }).pipe(Effect.provide(TestLayer)),
57
+ );
58
+
59
+ it.effect('should handle nested references', () =>
60
+ Effect.gen(function* () {
61
+ const reverseRefIndex = new ReverseRefIndex();
62
+ yield* reverseRefIndex.migrate();
63
+
64
+ const spaceId = SpaceId.random();
65
+ const sourceObjectId = ObjectId.random();
66
+ const targetObjectId1 = ObjectId.random();
67
+ const targetObjectId2 = ObjectId.random();
68
+ const targetDxn1 = `dxn:echo:@:${targetObjectId1}`;
69
+ const targetDxn2 = `dxn:echo:@:${targetObjectId2}`;
70
+
71
+ const sourceObject: IndexerObject = {
72
+ spaceId,
73
+ queueId: ObjectId.random(),
74
+ documentId: null,
75
+ recordId: 1,
76
+ data: {
77
+ id: sourceObjectId,
78
+ [ATTR_TYPE]: TYPE_EXAMPLE,
79
+ nested: {
80
+ deep: {
81
+ ref: { '/': targetDxn1 },
82
+ },
83
+ },
84
+ simple: { '/': targetDxn2 },
85
+ },
86
+ };
87
+
88
+ yield* reverseRefIndex.update([sourceObject]);
89
+
90
+ const results1 = yield* reverseRefIndex.query({ targetDxn: targetDxn1 });
91
+ expect(results1.length).toBe(1);
92
+ expect(results1[0].propPath).toBe('nested.deep.ref');
93
+
94
+ const results2 = yield* reverseRefIndex.query({ targetDxn: targetDxn2 });
95
+ expect(results2.length).toBe(1);
96
+ expect(results2[0].propPath).toBe('simple');
97
+ }).pipe(Effect.provide(TestLayer)),
98
+ );
99
+
100
+ it.effect('should handle array references', () =>
101
+ Effect.gen(function* () {
102
+ const reverseRefIndex = new ReverseRefIndex();
103
+ yield* reverseRefIndex.migrate();
104
+
105
+ const spaceId = SpaceId.random();
106
+ const sourceObjectId = ObjectId.random();
107
+ const targetObjectId1 = ObjectId.random();
108
+ const targetObjectId2 = ObjectId.random();
109
+ const targetDxn1 = `dxn:echo:@:${targetObjectId1}`;
110
+ const targetDxn2 = `dxn:echo:@:${targetObjectId2}`;
111
+
112
+ const sourceObject: IndexerObject = {
113
+ spaceId,
114
+ queueId: ObjectId.random(),
115
+ documentId: null,
116
+ recordId: 1,
117
+ data: {
118
+ id: sourceObjectId,
119
+ [ATTR_TYPE]: TYPE_EXAMPLE,
120
+ items: [{ '/': targetDxn1 }, { '/': targetDxn2 }],
121
+ },
122
+ };
123
+
124
+ yield* reverseRefIndex.update([sourceObject]);
125
+
126
+ const results1 = yield* reverseRefIndex.query({ targetDxn: targetDxn1 });
127
+ expect(results1.length).toBe(1);
128
+ expect(results1[0].propPath).toBe('items.0');
129
+
130
+ const results2 = yield* reverseRefIndex.query({ targetDxn: targetDxn2 });
131
+ expect(results2.length).toBe(1);
132
+ expect(results2[0].propPath).toBe('items.1');
133
+ }).pipe(Effect.provide(TestLayer)),
134
+ );
135
+
136
+ it.effect('should update references on object change', () =>
137
+ Effect.gen(function* () {
138
+ const reverseRefIndex = new ReverseRefIndex();
139
+ yield* reverseRefIndex.migrate();
140
+
141
+ const spaceId = SpaceId.random();
142
+ const queueId = ObjectId.random();
143
+ const sourceObjectId = ObjectId.random();
144
+ const targetObjectId1 = ObjectId.random();
145
+ const targetObjectId2 = ObjectId.random();
146
+ const targetDxn1 = `dxn:echo:@:${targetObjectId1}`;
147
+ const targetDxn2 = `dxn:echo:@:${targetObjectId2}`;
148
+ const recordId = 1;
149
+
150
+ // Initial object with reference to target1.
151
+ const sourceObject: IndexerObject = {
152
+ spaceId,
153
+ queueId,
154
+ documentId: null,
155
+ recordId,
156
+ data: {
157
+ id: sourceObjectId,
158
+ [ATTR_TYPE]: TYPE_EXAMPLE,
159
+ contact: { '/': targetDxn1 },
160
+ },
161
+ };
162
+
163
+ yield* reverseRefIndex.update([sourceObject]);
164
+
165
+ let results1 = yield* reverseRefIndex.query({ targetDxn: targetDxn1 });
166
+ expect(results1.length).toBe(1);
167
+
168
+ // Update object to reference target2 instead (same recordId).
169
+ const updatedObject: IndexerObject = {
170
+ spaceId,
171
+ queueId,
172
+ documentId: null,
173
+ recordId,
174
+ data: {
175
+ id: sourceObjectId,
176
+ [ATTR_TYPE]: TYPE_EXAMPLE,
177
+ contact: { '/': targetDxn2 },
178
+ },
179
+ };
180
+
181
+ yield* reverseRefIndex.update([updatedObject]);
182
+
183
+ // Old reference should be gone.
184
+ results1 = yield* reverseRefIndex.query({ targetDxn: targetDxn1 });
185
+ expect(results1.length).toBe(0);
186
+
187
+ // New reference should exist.
188
+ const results2 = yield* reverseRefIndex.query({ targetDxn: targetDxn2 });
189
+ expect(results2.length).toBe(1);
190
+ }).pipe(Effect.provide(TestLayer)),
191
+ );
192
+
193
+ it.effect('should handle objects without references', () =>
194
+ Effect.gen(function* () {
195
+ const reverseRefIndex = new ReverseRefIndex();
196
+ yield* reverseRefIndex.migrate();
197
+
198
+ const spaceId = SpaceId.random();
199
+ const sourceObjectId = ObjectId.random();
200
+
201
+ const sourceObject: IndexerObject = {
202
+ spaceId,
203
+ queueId: ObjectId.random(),
204
+ documentId: null,
205
+ recordId: 1,
206
+ data: {
207
+ id: sourceObjectId,
208
+ [ATTR_TYPE]: TYPE_EXAMPLE,
209
+ name: 'Test Object',
210
+ count: 42,
211
+ },
212
+ };
213
+
214
+ yield* reverseRefIndex.update([sourceObject]);
215
+
216
+ // Should not throw and no results for random DXN.
217
+ const results = yield* reverseRefIndex.query({ targetDxn: 'dxn:echo:@:nonexistent' });
218
+ expect(results.length).toBe(0);
219
+ }).pipe(Effect.provide(TestLayer)),
220
+ );
221
+
222
+ it.effect('should work with documentId instead of queueId', () =>
223
+ Effect.gen(function* () {
224
+ const reverseRefIndex = new ReverseRefIndex();
225
+ yield* reverseRefIndex.migrate();
226
+
227
+ const spaceId = SpaceId.random();
228
+ const sourceObjectId = ObjectId.random();
229
+ const targetObjectId = ObjectId.random();
230
+ const targetDxn = `dxn:echo:@:${targetObjectId}`;
231
+
232
+ const sourceObject: IndexerObject = {
233
+ spaceId,
234
+ queueId: null,
235
+ documentId: 'doc-123',
236
+ recordId: 1,
237
+ data: {
238
+ id: sourceObjectId,
239
+ [ATTR_TYPE]: TYPE_EXAMPLE,
240
+ ref: { '/': targetDxn },
241
+ },
242
+ };
243
+
244
+ yield* reverseRefIndex.update([sourceObject]);
245
+
246
+ const results = yield* reverseRefIndex.query({ targetDxn });
247
+ expect(results.length).toBe(1);
248
+ expect(results[0].propPath).toBe('ref');
249
+ }).pipe(Effect.provide(TestLayer)),
250
+ );
251
+ });
@@ -0,0 +1,127 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as SqlClient from '@effect/sql/SqlClient';
6
+ import type * as SqlError from '@effect/sql/SqlError';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Schema from 'effect/Schema';
9
+
10
+ import { EncodedReference, isEncodedReference } from '@dxos/echo-protocol';
11
+
12
+ import { EscapedPropPath } from '../utils';
13
+
14
+ import type { Index, IndexerObject } from './interface';
15
+
16
+ /**
17
+ * Extracts all outgoing references from an object's data.
18
+ */
19
+ const extractReferences = (data: Record<string, unknown>): { path: string[]; targetDxn: string }[] => {
20
+ const refs: { path: string[]; targetDxn: string }[] = [];
21
+ const visit = (path: string[], value: unknown) => {
22
+ if (isEncodedReference(value)) {
23
+ const dxn = EncodedReference.toDXN(value);
24
+ const echoId = dxn.asEchoDXN()?.echoId;
25
+ if (!echoId) {
26
+ return; // Skip non-echo references.
27
+ }
28
+ refs.push({ path, targetDxn: dxn.toString() });
29
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
30
+ for (const [key, v] of Object.entries(value)) {
31
+ visit([...path, key], v);
32
+ }
33
+ } else if (Array.isArray(value)) {
34
+ for (let i = 0; i < value.length; i++) {
35
+ visit([...path, String(i)], value[i]);
36
+ }
37
+ }
38
+ };
39
+ visit([], data);
40
+ return refs;
41
+ };
42
+
43
+ export const ReverseRef = Schema.Struct({
44
+ recordId: Schema.Number,
45
+ targetDxn: Schema.String,
46
+ /**
47
+ * Escaped property path within an object.
48
+ *
49
+ * Escaping rules:
50
+ *
51
+ * - '.' -> '\.'
52
+ * - '\' -> '\\'
53
+ * - contact with .
54
+ */
55
+ propPath: Schema.String,
56
+ });
57
+ export interface ReverseRef extends Schema.Schema.Type<typeof ReverseRef> {}
58
+
59
+ export interface ReverseRefQuery {
60
+ targetDxn: string;
61
+ // TODO: Add prop filter
62
+ }
63
+
64
+ /**
65
+ * Indexes reverse references - tracks which objects reference which targets.
66
+ * Only indexes references, not relations.
67
+ */
68
+ export class ReverseRefIndex implements Index {
69
+ migrate = Effect.fn('ReverseRefIndex.migrate')(function* () {
70
+ const sql = yield* SqlClient.SqlClient;
71
+
72
+ yield* sql`CREATE TABLE IF NOT EXISTS reverseRef (
73
+ recordId INTEGER NOT NULL,
74
+ targetDxn TEXT NOT NULL,
75
+ propPath TEXT NOT NULL,
76
+ PRIMARY KEY (recordId, targetDxn, propPath)
77
+ )`;
78
+
79
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_reverse_ref_target ON reverseRef(targetDxn)`;
80
+ });
81
+
82
+ /**
83
+ * Query all references pointing to a target DXN.
84
+ */
85
+ query = Effect.fn('ReverseRefIndex.query')(
86
+ ({ targetDxn }: ReverseRefQuery): Effect.Effect<readonly ReverseRef[], SqlError.SqlError, SqlClient.SqlClient> =>
87
+ Effect.gen(function* () {
88
+ const sql = yield* SqlClient.SqlClient;
89
+ // TODO(mykola): Join objectMeta table here.
90
+ const rows = yield* sql`SELECT * FROM reverseRef WHERE targetDxn = ${targetDxn}`;
91
+ return rows as ReverseRef[];
92
+ }),
93
+ );
94
+
95
+ update = Effect.fn('ReverseRefIndex.update')(
96
+ (objects: IndexerObject[]): Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient> =>
97
+ Effect.gen(function* () {
98
+ const sql = yield* SqlClient.SqlClient;
99
+
100
+ yield* Effect.forEach(
101
+ objects,
102
+ (object) =>
103
+ Effect.gen(function* () {
104
+ const { recordId, data } = object;
105
+ if (recordId === null) {
106
+ yield* Effect.die(new Error('ReverseRefIndex.update requires recordId to be set'));
107
+ }
108
+
109
+ // Delete existing references for this record.
110
+ yield* sql`DELETE FROM reverseRef WHERE recordId = ${recordId}`;
111
+
112
+ // Extract references from data.
113
+ const refs = extractReferences(data as unknown as Record<string, unknown>);
114
+
115
+ // Insert new references.
116
+ yield* Effect.forEach(
117
+ refs,
118
+ (ref) =>
119
+ sql`INSERT INTO reverseRef (recordId, targetDxn, propPath) VALUES (${recordId}, ${ref.targetDxn}, ${EscapedPropPath.escape(ref.path)})`,
120
+ { discard: true },
121
+ );
122
+ }),
123
+ { discard: true },
124
+ );
125
+ }),
126
+ );
127
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,49 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+
7
+ import { invariant } from '@dxos/invariant';
8
+
9
+ export type ObjectPropPath = (string | number)[];
10
+
11
+ /**
12
+ * Escaped property path within an object.
13
+ *
14
+ * Escaping rules:
15
+ *
16
+ * - '.' -> '\.'
17
+ * - '\' -> '\\'
18
+ * - contact with .
19
+ */
20
+ export const EscapedPropPath: Schema.SchemaClass<string, string> & {
21
+ escape: (path: ObjectPropPath) => EscapedPropPath;
22
+ unescape: (path: EscapedPropPath) => ObjectPropPath;
23
+ } = class extends Schema.String.annotations({ title: 'EscapedPropPath' }) {
24
+ static escape(path: ObjectPropPath): EscapedPropPath {
25
+ return path.map((p) => p.toString().replaceAll('\\', '\\\\').replaceAll('.', '\\.')).join('.');
26
+ }
27
+
28
+ static unescape(path: EscapedPropPath): ObjectPropPath {
29
+ const parts: string[] = [];
30
+ let current = '';
31
+
32
+ for (let i = 0; i < path.length; i++) {
33
+ if (path[i] === '\\') {
34
+ invariant(i + 1 < path.length && (path[i + 1] === '.' || path[i + 1] === '\\'), 'Malformed escaping.');
35
+ current = current + path[i + 1];
36
+ i++;
37
+ } else if (path[i] === '.') {
38
+ parts.push(current);
39
+ current = '';
40
+ } else {
41
+ current += path[i];
42
+ }
43
+ }
44
+ parts.push(current);
45
+
46
+ return parts;
47
+ }
48
+ };
49
+ export type EscapedPropPath = Schema.Schema.Type<typeof EscapedPropPath>;