@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.
package/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ MIT License
2
+ Copyright (c) 2022 DXOS
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
+
6
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @dxos/index-core
2
+
3
+ Indexing core.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm i @dxos/index-core
9
+ ```
10
+
11
+ ## DXOS Resources
12
+
13
+ - [Website](https://dxos.org)
14
+ - [Developer Documentation](https://docs.dxos.org)
15
+ - Talk to us on [Discord](https://dxos.org/discord)
16
+
17
+ ## Contributions
18
+
19
+ Your ideas, issues, and code are most welcome. Please take a look at our [community code of conduct](https://github.com/dxos/dxos/blob/main/CODE_OF_CONDUCT.md), the [issue guide](https://github.com/dxos/dxos/blob/main/CONTRIBUTING.md#submitting-issues), and the [PR contribution guide](https://github.com/dxos/dxos/blob/main/CONTRIBUTING.md#submitting-prs).
20
+
21
+ License: [MIT](./LICENSE) Copyright 2022 © DXOS
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@dxos/index-core",
3
+ "version": "0.0.0",
4
+ "description": "Indexing core.",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "license": "MIT",
8
+ "author": "info@dxos.org",
9
+ "sideEffects": false,
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "source": "./src/index.ts",
14
+ "types": "./dist/types/src/index.d.ts",
15
+ "browser": "./dist/lib/browser/index.mjs",
16
+ "node": "./dist/lib/node-esm/index.mjs"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src"
22
+ ],
23
+ "dependencies": {
24
+ "@dxos/async": "",
25
+ "@dxos/context": "",
26
+ "@dxos/debug": "",
27
+ "@dxos/echo": "",
28
+ "@dxos/echo-protocol": "",
29
+ "@dxos/effect": "",
30
+ "@dxos/invariant": "",
31
+ "@dxos/keys": "",
32
+ "@dxos/log": "",
33
+ "@effect/cli": "0.72.1",
34
+ "@effect/experimental": "0.57.11",
35
+ "@effect/platform": "0.93.6",
36
+ "@effect/sql": "0.48.6",
37
+ "effect": "3.19.11"
38
+ },
39
+ "devDependencies": {
40
+ "@effect/sql-sqlite-node": "0.49.1"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "beast": {}
46
+ }
@@ -0,0 +1,242 @@
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 { invariant } from '@dxos/invariant';
13
+ import { DXN, ObjectId, SpaceId } from '@dxos/keys';
14
+
15
+ import { type DataSourceCursor, type IndexDataSource, IndexEngine } from './index-engine';
16
+ import { type IndexCursor, IndexTracker } from './index-tracker';
17
+ import { FtsIndex, type IndexerObject, ObjectMetaIndex, ReverseRefIndex } from './indexes';
18
+
19
+ const TYPE_DEFAULT = DXN.parse('dxn:type:test.com/type/Type:0.1.0').toString();
20
+ const TYPE_A = DXN.parse('dxn:type:test.com/type/TypeA:0.1.0').toString();
21
+ const TYPE_B = DXN.parse('dxn:type:test.com/type/TypeB:0.1.0').toString();
22
+
23
+ const TestLayer = Layer.merge(
24
+ SqliteClient.layer({
25
+ filename: ':memory:',
26
+ }),
27
+ Reactivity.layer,
28
+ );
29
+
30
+ class MockIndexDataSource implements IndexDataSource {
31
+ readonly sourceName = 'mock-source';
32
+
33
+ // Composite Key -> { object, hash, timestamp }
34
+ // Key: `${spaceId}:${documentId}`
35
+ private _state = new Map<string, { object: IndexerObject; hash: string }>();
36
+
37
+ push(objects: IndexerObject[]) {
38
+ for (const obj of objects) {
39
+ invariant(obj.documentId, 'documentId is required');
40
+
41
+ this._state.set(`${obj.spaceId}:${obj.documentId}`, {
42
+ object: obj,
43
+ hash: Math.random().toString(36).substring(7),
44
+ });
45
+ }
46
+ }
47
+
48
+ getChangedObjects(
49
+ cursors: IndexCursor[],
50
+ opts?: { limit?: number },
51
+ ): Effect.Effect<{ objects: IndexerObject[]; cursors: DataSourceCursor[] }> {
52
+ return Effect.sync(() => {
53
+ const results: { object: IndexerObject; hash: string }[] = [];
54
+
55
+ for (const [, entry] of this._state.entries()) {
56
+ const { object, hash } = entry;
57
+
58
+ // Find cursor for this object.
59
+ // Multi-space indexing: match by resourceId (documentId) only.
60
+ const cursor = cursors.find((c) => c.resourceId === object.documentId);
61
+
62
+ let include = false;
63
+ if (!cursor) {
64
+ include = true;
65
+ } else {
66
+ include = cursor.cursor !== hash;
67
+ }
68
+
69
+ if (include) {
70
+ results.push({ object, hash });
71
+ }
72
+ }
73
+
74
+ // Apply limit if needed (simplistic logic).
75
+ const limitedResults = opts?.limit ? results.slice(0, opts.limit) : results;
76
+
77
+ const objects = limitedResults.map((r) => r.object);
78
+ const newCursors: DataSourceCursor[] = limitedResults.map((r) => ({
79
+ spaceId: r.object.spaceId,
80
+ resourceId: r.object.documentId,
81
+ cursor: r.hash,
82
+ }));
83
+
84
+ return { objects, cursors: newCursors };
85
+ });
86
+ }
87
+ }
88
+
89
+ describe('IndexEngine', () => {
90
+ const setup = Effect.gen(function* () {
91
+ const tracker = new IndexTracker();
92
+ yield* tracker.migrate();
93
+ const metaIndex = new ObjectMetaIndex();
94
+ yield* metaIndex.migrate();
95
+ const ftsIndex = new FtsIndex();
96
+ yield* ftsIndex.migrate();
97
+ const reverseRefIndex = new ReverseRefIndex();
98
+ yield* reverseRefIndex.migrate();
99
+ const indexEngine = new IndexEngine({ tracker, ftsIndex, objectMetaIndex: metaIndex, reverseRefIndex });
100
+ return { indexEngine, tracker, metaIndex, ftsIndex, reverseRefIndex };
101
+ });
102
+
103
+ it.effect(
104
+ 'should index and update objects',
105
+ Effect.fnUntraced(function* () {
106
+ const { tracker, metaIndex, ftsIndex, reverseRefIndex } = yield* setup;
107
+
108
+ // Inject dependencies.
109
+ const engine = new IndexEngine({ tracker, ftsIndex, objectMetaIndex: metaIndex, reverseRefIndex });
110
+ const dataSource = new MockIndexDataSource();
111
+ const spaceId = SpaceId.random();
112
+
113
+ const obj1: IndexerObject = {
114
+ spaceId,
115
+ documentId: 'doc-1',
116
+ queueId: null,
117
+ recordId: null,
118
+ data: {
119
+ id: ObjectId.random(),
120
+ [ATTR_TYPE]: TYPE_DEFAULT,
121
+ title: 'Hello',
122
+ },
123
+ };
124
+
125
+ dataSource.push([obj1]);
126
+
127
+ // First update.
128
+ const { updated } = yield* engine.update(dataSource, { spaceId: null });
129
+ // Updates objectMeta, FTS, and reverseRef indexes.
130
+ expect(updated).toBe(2);
131
+
132
+ // Verify using the SAME index instance.
133
+ const results1 = yield* metaIndex.query({ spaceId: spaceId.toString(), typeDxn: TYPE_DEFAULT });
134
+ expect(results1).toHaveLength(1);
135
+ expect(results1[0].objectId).toBe(obj1.data.id);
136
+ expect(results1[0].version).toBeGreaterThan(0);
137
+
138
+ // Verify FTS index gets updated.
139
+ const ftsResults1 = yield* ftsIndex.query({
140
+ query: 'Hello',
141
+ spaceId: null,
142
+ includeAllQueues: false,
143
+ queueIds: null,
144
+ });
145
+ expect(ftsResults1.length).toBeGreaterThan(0);
146
+ expect(ftsResults1.some((row) => row.objectId === obj1.data.id)).toBe(true);
147
+
148
+ // Update object.
149
+ const obj1Updated: IndexerObject = {
150
+ spaceId,
151
+ documentId: obj1.documentId,
152
+ queueId: null,
153
+ recordId: null,
154
+ data: { id: obj1.data.id, [ATTR_TYPE]: obj1.data[ATTR_TYPE], title: 'Hello World' },
155
+ };
156
+ dataSource.push([obj1Updated]);
157
+
158
+ // Second update.
159
+ const { updated: updated2 } = yield* engine.update(dataSource, { spaceId: null });
160
+ expect(updated2).toBe(2);
161
+
162
+ // Verify update.
163
+ const results2 = yield* metaIndex.query({ spaceId: spaceId.toString(), typeDxn: TYPE_DEFAULT });
164
+ expect(results2).toHaveLength(1);
165
+ expect(results2[0].objectId).toBe(obj1Updated.data.id);
166
+ expect(results2[0].version).toBeGreaterThan(results1[0].version);
167
+
168
+ const ftsResults2 = yield* ftsIndex.query({
169
+ query: 'World',
170
+ spaceId: null,
171
+ includeAllQueues: false,
172
+ queueIds: null,
173
+ });
174
+ expect(ftsResults2.length).toBeGreaterThan(0);
175
+ }, Effect.provide(TestLayer)),
176
+ );
177
+
178
+ it.effect(
179
+ 'should handle multiple objects',
180
+ Effect.fnUntraced(function* () {
181
+ const { tracker, metaIndex, ftsIndex, reverseRefIndex } = yield* setup;
182
+
183
+ const engine = new IndexEngine({ tracker, objectMetaIndex: metaIndex, ftsIndex, reverseRefIndex });
184
+ const dataSource = new MockIndexDataSource();
185
+ const spaceId = SpaceId.random();
186
+
187
+ const objects: IndexerObject[] = [
188
+ {
189
+ spaceId,
190
+ queueId: null,
191
+ documentId: 'd1',
192
+ recordId: null,
193
+ data: {
194
+ id: ObjectId.random(),
195
+ [ATTR_TYPE]: TYPE_A,
196
+ val: 1,
197
+ },
198
+ },
199
+ {
200
+ spaceId,
201
+ queueId: null,
202
+ documentId: 'd2',
203
+ recordId: null,
204
+ data: {
205
+ id: ObjectId.random(),
206
+ [ATTR_TYPE]: TYPE_A,
207
+ val: 2,
208
+ },
209
+ },
210
+ {
211
+ spaceId,
212
+ queueId: null,
213
+ documentId: 'd3',
214
+ recordId: null,
215
+ data: {
216
+ id: ObjectId.random(),
217
+ [ATTR_TYPE]: TYPE_B,
218
+ val: 3,
219
+ },
220
+ },
221
+ ];
222
+
223
+ dataSource.push(objects);
224
+
225
+ yield* engine.update(dataSource, { spaceId: null });
226
+
227
+ const resultsA = yield* metaIndex.query({ spaceId: spaceId.toString(), typeDxn: TYPE_A });
228
+ expect(resultsA).toHaveLength(2);
229
+
230
+ const resultsB = yield* metaIndex.query({ spaceId: spaceId.toString(), typeDxn: TYPE_B });
231
+ expect(resultsB).toHaveLength(1);
232
+
233
+ const ftsResults = yield* ftsIndex.query({
234
+ query: 'TypeA',
235
+ spaceId: null,
236
+ includeAllQueues: false,
237
+ queueIds: null,
238
+ });
239
+ expect(ftsResults).toHaveLength(2);
240
+ }, Effect.provide(TestLayer)),
241
+ );
242
+ });
@@ -0,0 +1,187 @@
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
+
9
+ import type { SpaceId } from '@dxos/keys';
10
+
11
+ import { type IndexCursor, IndexTracker } from './index-tracker';
12
+ import {
13
+ FtsIndex,
14
+ type FtsQuery,
15
+ type Index,
16
+ type IndexerObject,
17
+ type ObjectMeta,
18
+ ObjectMetaIndex,
19
+ ReverseRefIndex,
20
+ type ReverseRefQuery,
21
+ } from './indexes';
22
+
23
+ /**
24
+ * Cursor into indexable data-source.
25
+ */
26
+ export interface DataSourceCursor {
27
+ spaceId: SpaceId | null;
28
+
29
+ /**
30
+ * documentId or queueId.
31
+ */
32
+ resourceId: string | null;
33
+
34
+ /**
35
+ * heads or queue position.
36
+ */
37
+ cursor: number | string;
38
+ }
39
+
40
+ export interface IndexDataSource {
41
+ readonly sourceName: string; // e.g. queue, automerge, etc.
42
+
43
+ getChangedObjects(
44
+ cursors: DataSourceCursor[],
45
+ opts?: { limit?: number },
46
+ ): Effect.Effect<{ objects: IndexerObject[]; cursors: DataSourceCursor[] }>;
47
+ }
48
+
49
+ export interface IndexEngineParams {
50
+ tracker: IndexTracker;
51
+ objectMetaIndex: ObjectMetaIndex;
52
+ ftsIndex: FtsIndex;
53
+ reverseRefIndex: ReverseRefIndex;
54
+ }
55
+
56
+ export class IndexEngine {
57
+ readonly #tracker: IndexTracker;
58
+ readonly #objectMetaIndex: ObjectMetaIndex;
59
+ readonly #ftsIndex: FtsIndex;
60
+ readonly #reverseRefIndex: ReverseRefIndex;
61
+
62
+ constructor(params?: IndexEngineParams) {
63
+ this.#tracker = params?.tracker ?? new IndexTracker();
64
+ this.#objectMetaIndex = params?.objectMetaIndex ?? new ObjectMetaIndex();
65
+ this.#ftsIndex = params?.ftsIndex ?? new FtsIndex();
66
+ this.#reverseRefIndex = params?.reverseRefIndex ?? new ReverseRefIndex();
67
+ }
68
+
69
+ migrate() {
70
+ return Effect.gen(this, function* () {
71
+ yield* this.#tracker.migrate();
72
+ yield* this.#objectMetaIndex.migrate();
73
+ yield* this.#ftsIndex.migrate();
74
+ yield* this.#reverseRefIndex.migrate();
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Query text index and return full object metadata.
80
+ */
81
+ queryText(query: FtsQuery): Effect.Effect<readonly ObjectMeta[], SqlError.SqlError, SqlClient.SqlClient> {
82
+ return Effect.gen(this, function* () {
83
+ return yield* this.#ftsIndex.query(query);
84
+ });
85
+ }
86
+
87
+ queryReverseRef(query: ReverseRefQuery) {
88
+ // TODO(mykola): Join with metadata table here.
89
+ return this.#reverseRefIndex.query(query);
90
+ }
91
+
92
+ /**
93
+ * Query snapshots by recordIds.
94
+ * Used to load queue objects from indexed snapshots.
95
+ */
96
+ querySnapshotsJSON(recordIds: number[]) {
97
+ return this.#ftsIndex.querySnapshotsJSON(recordIds);
98
+ }
99
+
100
+ queryType(
101
+ query: Pick<ObjectMeta, 'spaceId' | 'typeDxn'>,
102
+ ): Effect.Effect<readonly ObjectMeta[], SqlError.SqlError, SqlClient.SqlClient> {
103
+ return this.#objectMetaIndex.query(query);
104
+ }
105
+
106
+ update(
107
+ dataSource: IndexDataSource,
108
+ opts: { spaceId: SpaceId | null; limit?: number },
109
+ ): Effect.Effect<{ updated: number; done: boolean }, SqlError.SqlError, SqlClient.SqlClient> {
110
+ return Effect.gen(this, function* () {
111
+ let updated = 0;
112
+
113
+ const { updated: updatedFtsIndex, done: doneFtsIndex } = yield* this.#update(this.#ftsIndex, dataSource, {
114
+ indexName: 'fts',
115
+ spaceId: opts.spaceId,
116
+ limit: opts.limit,
117
+ });
118
+ updated += updatedFtsIndex;
119
+
120
+ const { updated: updatedReverseRefIndex, done: doneReverseRefIndex } = yield* this.#update(
121
+ this.#reverseRefIndex,
122
+ dataSource,
123
+ {
124
+ indexName: 'reverseRef',
125
+ spaceId: opts.spaceId,
126
+ limit: opts.limit,
127
+ },
128
+ );
129
+ updated += updatedReverseRefIndex;
130
+
131
+ return { updated, done: doneFtsIndex && doneReverseRefIndex };
132
+ }).pipe(Effect.withSpan('IndexEngine.update'));
133
+ }
134
+
135
+ /**
136
+ * Update a dependent index that requires recordId enrichment.
137
+ * This method:
138
+ * 1. Gets changed objects from the source.
139
+ * 2. Ensures those objects exist in ObjectMetaIndex.
140
+ * 3. Looks up recordIds for those objects.
141
+ * 4. Enriches objects with recordIds.
142
+ * 5. Updates the dependent index.
143
+ */
144
+ #update(
145
+ index: Index,
146
+ source: IndexDataSource,
147
+ opts: { indexName: string; spaceId: SpaceId | null; limit?: number },
148
+ ): Effect.Effect<{ updated: number; done: boolean }, SqlError.SqlError, SqlClient.SqlClient> {
149
+ return Effect.gen(this, function* () {
150
+ const sql = yield* SqlClient.SqlClient;
151
+ return yield* sql.withTransaction(
152
+ Effect.gen(this, function* () {
153
+ const cursors = yield* this.#tracker.queryCursors({
154
+ indexName: opts.indexName,
155
+ sourceName: source.sourceName,
156
+ // Pass undefined to get all cursors when spaceId is null.
157
+ spaceId: opts.spaceId ?? undefined,
158
+ });
159
+ const { objects, cursors: updatedCursors } = yield* source.getChangedObjects(cursors, { limit: opts.limit });
160
+ if (objects.length === 0) {
161
+ return { updated: 0, done: true };
162
+ }
163
+
164
+ // Ensure objects exist in ObjectMetaIndex.
165
+ yield* this.#objectMetaIndex.update(objects);
166
+
167
+ // Look up recordIds for the objects.
168
+ yield* this.#objectMetaIndex.lookupRecordIds(objects);
169
+
170
+ yield* index.update(objects);
171
+ yield* this.#tracker.updateCursors(
172
+ updatedCursors.map(
173
+ (_): IndexCursor => ({
174
+ indexName: opts.indexName,
175
+ spaceId: _.spaceId,
176
+ sourceName: source.sourceName,
177
+ resourceId: _.resourceId,
178
+ cursor: _.cursor,
179
+ }),
180
+ ),
181
+ );
182
+ return { updated: objects.length, done: false };
183
+ }),
184
+ );
185
+ }).pipe(Effect.withSpan('IndexEngine.#updateDependentIndex'));
186
+ }
187
+ }
@@ -0,0 +1,72 @@
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 { SpaceId } from '@dxos/keys';
12
+
13
+ import { type IndexCursor, IndexTracker } from './index-tracker';
14
+
15
+ const TestLayer = Layer.merge(
16
+ SqliteClient.layer({
17
+ filename: ':memory:',
18
+ }),
19
+ Reactivity.layer,
20
+ );
21
+
22
+ describe('IndexTracker', () => {
23
+ it.effect(
24
+ 'should store and retrieve index cursors',
25
+ Effect.fnUntraced(function* () {
26
+ const tracker = new IndexTracker();
27
+ yield* tracker.migrate();
28
+
29
+ const cursor1: IndexCursor = {
30
+ indexName: 'test-index',
31
+ spaceId: SpaceId.random(),
32
+ sourceName: 'automerge',
33
+ resourceId: 'doc-1',
34
+ cursor: 'heads-1',
35
+ };
36
+
37
+ const cursor2: IndexCursor = {
38
+ indexName: 'test-index',
39
+ spaceId: null,
40
+ sourceName: 'queue',
41
+ resourceId: 'queue-1',
42
+ cursor: 123,
43
+ };
44
+
45
+ // Test Insert
46
+ yield* tracker.updateCursors([cursor1, cursor2]);
47
+
48
+ // Test Query All for index
49
+ const results = yield* tracker.queryCursors({ indexName: 'test-index' });
50
+ expect(results.length).toBe(2);
51
+ expect(results).toContainEqual(cursor1);
52
+ expect(results).toContainEqual(cursor2);
53
+
54
+ // Test Query with Filter
55
+ const filtered = yield* tracker.queryCursors({ indexName: 'test-index', sourceName: 'automerge' });
56
+ expect(filtered.length).toBe(1);
57
+ expect(filtered[0]).toEqual(cursor1);
58
+
59
+ // Test Update
60
+ const updatedCursor1 = { ...cursor1, cursor: 'heads-2' };
61
+ yield* tracker.updateCursors([updatedCursor1]);
62
+
63
+ const resultsAfterUpdate = yield* tracker.queryCursors({ indexName: 'test-index', resourceId: 'doc-1' });
64
+ expect(resultsAfterUpdate[0].cursor).toBe('heads-2');
65
+
66
+ // Test null handling (empty string in DB)
67
+ const resultsNullSpace = yield* tracker.queryCursors({ indexName: 'test-index', spaceId: null });
68
+ expect(resultsNullSpace.length).toBe(1);
69
+ expect(resultsNullSpace[0]).toEqual(cursor2);
70
+ }, Effect.provide(TestLayer)),
71
+ );
72
+ });
@@ -0,0 +1,104 @@
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 { SpaceId } from '@dxos/keys';
11
+
12
+ export const IndexCursor = Schema.Struct({
13
+ /**
14
+ * Name of the index owning this cursor.
15
+ */
16
+ indexName: Schema.String,
17
+ /**
18
+ * Space id.
19
+ */
20
+ spaceId: Schema.NullOr(SpaceId),
21
+ /**
22
+ * Source name.
23
+ * 'automerge' / 'queue' / 'index' (for secondary indexes)
24
+ */
25
+ sourceName: Schema.String,
26
+ /**
27
+ * Document id or queue id.
28
+ * doc_id, queue_id, '' <empty string> (if indexing entire namespace)
29
+ */
30
+ resourceId: Schema.NullOr(Schema.String),
31
+ /**
32
+ * Heads, queue position, version.
33
+ */
34
+ cursor: Schema.Union(Schema.Number, Schema.String),
35
+ });
36
+ export interface IndexCursor extends Schema.Schema.Type<typeof IndexCursor> {}
37
+
38
+ export class IndexTracker {
39
+ migrate = Effect.fn('IndexTracker.migrate')(function* () {
40
+ const sql = yield* SqlClient.SqlClient;
41
+
42
+ // For automerge: last-indexed heads of the document
43
+ // For queue: the position of the item that was indexed last
44
+ yield* sql`CREATE TABLE IF NOT EXISTS indexCursor (
45
+ indexName TEXT NOT NULL,
46
+ spaceId TEXT NOT NULL DEFAULT '',
47
+ sourceName TEXT NOT NULL,
48
+ resourceId TEXT NOT NULL DEFAULT '',
49
+ cursor,
50
+ PRIMARY KEY (indexName, spaceId, sourceName, resourceId)
51
+ )`;
52
+ });
53
+
54
+ queryCursors = Effect.fn('IndexTracker.queryCursors')(
55
+ (
56
+ query: Pick<IndexCursor, 'indexName'> & Partial<Pick<IndexCursor, 'sourceName' | 'resourceId' | 'spaceId'>>,
57
+ ): Effect.Effect<IndexCursor[], SqlError.SqlError, SqlClient.SqlClient> =>
58
+ Effect.gen(function* () {
59
+ const sql = yield* SqlClient.SqlClient;
60
+
61
+ const spaceIdParam = query.spaceId === undefined ? null : (query.spaceId ?? '');
62
+ const sourceNameParam = query.sourceName === undefined ? null : query.sourceName;
63
+ const resourceIdParam = query.resourceId === undefined ? null : (query.resourceId ?? '');
64
+
65
+ const rows = yield* sql<IndexCursor>`
66
+ SELECT * FROM indexCursor
67
+ WHERE indexName = ${query.indexName}
68
+ AND (${spaceIdParam} IS NULL OR spaceId = ${spaceIdParam})
69
+ AND (${sourceNameParam} IS NULL OR sourceName = ${sourceNameParam})
70
+ AND (${resourceIdParam} IS NULL OR resourceId = ${resourceIdParam})
71
+ `;
72
+
73
+ return rows.map(
74
+ (row): IndexCursor => ({
75
+ indexName: row.indexName,
76
+ spaceId: row.spaceId === '' ? null : Schema.decodeSync(SpaceId)(row.spaceId!),
77
+ sourceName: row.sourceName,
78
+ resourceId: row.resourceId === '' ? null : row.resourceId,
79
+ cursor: row.cursor,
80
+ }),
81
+ );
82
+ }),
83
+ );
84
+
85
+ updateCursors = Effect.fn('IndexTracker.updateCursors')(
86
+ (cursors: IndexCursor[]): Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient> =>
87
+ Effect.gen(function* () {
88
+ const sql = yield* SqlClient.SqlClient;
89
+ yield* Effect.forEach(
90
+ cursors,
91
+ (cursor) => {
92
+ const spaceId = cursor.spaceId ?? '';
93
+ const resourceId = cursor.resourceId ?? '';
94
+ return sql`
95
+ INSERT INTO indexCursor (indexName, spaceId, sourceName, resourceId, cursor)
96
+ VALUES (${cursor.indexName}, ${spaceId}, ${cursor.sourceName}, ${resourceId}, ${cursor.cursor})
97
+ ON CONFLICT(indexName, spaceId, sourceName, resourceId) DO UPDATE SET cursor = excluded.cursor
98
+ `;
99
+ },
100
+ { discard: true },
101
+ );
102
+ }),
103
+ );
104
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export { IndexEngine, type IndexDataSource, type DataSourceCursor, type IndexEngineParams } from './index-engine';
6
+ export { IndexTracker, type IndexCursor } from './index-tracker';
7
+ export { type IndexerObject, type Index } from './indexes/interface';
8
+ export { FtsIndex } from './indexes/fts-index';
9
+ export { ObjectMetaIndex, type ObjectMeta } from './indexes/object-meta-index';
10
+ export { ReverseRefIndex, type ReverseRef } from './indexes/reverse-ref-index';