@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,193 @@
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 type * as Statement from '@effect/sql/Statement';
8
+ import * as Effect from 'effect/Effect';
9
+
10
+ import type { Obj } from '@dxos/echo';
11
+ import type { ObjectId, SpaceId } from '@dxos/keys';
12
+
13
+ import type { Index, IndexerObject } from './interface';
14
+ import type { ObjectMeta } from './object-meta-index';
15
+
16
+ /**
17
+ * The space and queue constrains are combined together using a logical OR.
18
+ */
19
+ export interface FtsQuery {
20
+ /**
21
+ * Text to search.
22
+ */
23
+ query: string;
24
+
25
+ /**
26
+ * Space ID to search within.
27
+ */
28
+ spaceId: readonly SpaceId[] | null;
29
+
30
+ /**
31
+ * If true, include all queues in the spaces specified by `spaceId`.
32
+ */
33
+ includeAllQueues: boolean;
34
+
35
+ /**
36
+ * Queue IDs to search within.
37
+ */
38
+ queueIds: readonly ObjectId[] | null;
39
+ }
40
+
41
+ /**
42
+ * Result of FTS query including the indexed snapshot data.
43
+ */
44
+ export interface FtsResult extends ObjectMeta {
45
+ /**
46
+ * The indexed snapshot data (JSON string).
47
+ * Used to load queue objects without going through document loading.
48
+ */
49
+ snapshot: string;
50
+ }
51
+
52
+ /**
53
+ * Escapes user input for safe FTS5 queries.
54
+ *
55
+ * FTS5 has special syntax characters that can cause errors or unexpected behavior:
56
+ * - `*` suffix for prefix matching (e.g., `prog*` matches "program", "programming")
57
+ * - `"..."` for phrase queries
58
+ * - `.` for column specification
59
+ * - `AND`, `OR`, `NOT` boolean operators
60
+ * - `+`, `-` for required/excluded terms
61
+ *
62
+ * This function wraps each whitespace-separated term in double quotes, treating all
63
+ * characters as literals. Double quotes within terms are escaped by doubling (`""`).
64
+ *
65
+ * Example: `prog* AND test.` becomes `"prog*" "AND" "test."`.
66
+ */
67
+ const escapeFts5Query = (text: string): string => {
68
+ return text
69
+ .split(/\s+/)
70
+ .filter(Boolean)
71
+ .map((term) => `"${term.replace(/"/g, '""')}"`)
72
+ .join(' ');
73
+ };
74
+
75
+ export class FtsIndex implements Index {
76
+ migrate = Effect.fn('FtsIndex.migrate')(function* () {
77
+ const sql = yield* SqlClient.SqlClient;
78
+
79
+ // https://sqlite.org/fts5.html#the_trigram_tokenizer
80
+ // FTS5 tables are created as virtual tables; they implicitly have a `rowid`.
81
+ // Trigram tokenizer enables substring matching (e.g., "rog" matches "programming").
82
+ //
83
+ // Data structure: inverted index mapping trigrams to document IDs.
84
+ // "hello" → trigrams ["hel", "ell", "llo"] → B-tree entries: "hel"→[1], "ell"→[1], "llo"→[1].
85
+ // Query "ell" → O(log n) B-tree lookup → returns [1].
86
+ // Posting lists are compressed, so index size scales well with document count.
87
+ yield* sql`CREATE VIRTUAL TABLE IF NOT EXISTS ftsIndex USING fts5(snapshot, tokenize='trigram')`;
88
+ });
89
+
90
+ query({
91
+ query,
92
+ spaceId,
93
+ includeAllQueues,
94
+ queueIds,
95
+ }: FtsQuery): Effect.Effect<readonly ObjectMeta[], SqlError.SqlError, SqlClient.SqlClient> {
96
+ return Effect.gen(function* () {
97
+ const trimmed = query.trim();
98
+ if (trimmed.length === 0) {
99
+ return [];
100
+ }
101
+
102
+ const sql = yield* SqlClient.SqlClient;
103
+
104
+ // Trigram tokenizer requires at least 3 characters per term.
105
+ // Check if ALL terms are at least 3 chars; otherwise use LIKE fallback.
106
+ const terms = trimmed.split(/\s+/).filter(Boolean);
107
+ const minTermLength = Math.min(...terms.map((t) => t.length));
108
+
109
+ const conditions =
110
+ minTermLength < 3
111
+ ? // LIKE fallback - scan the entire table, AND all terms.
112
+ terms.map((term) => sql`f.snapshot LIKE ${'%' + term + '%'}`)
113
+ : // MATCH - fast index lookup.
114
+ [sql`f.snapshot MATCH ${escapeFts5Query(trimmed)}`];
115
+
116
+ // Space and queue constraints are combined with OR.
117
+ const sourceConditions: Statement.Statement<{}>[] = [];
118
+
119
+ if (spaceId && spaceId.length > 0) {
120
+ if (includeAllQueues) {
121
+ // All items from these spaces (both space objects and queue objects).
122
+ sourceConditions.push(sql`m.spaceId IN ${sql.in(spaceId)}`);
123
+ } else {
124
+ // Only space objects (not queue objects) from these spaces.
125
+ sourceConditions.push(sql`(m.spaceId IN ${sql.in(spaceId)} AND m.queueId = '')`);
126
+ }
127
+ }
128
+
129
+ if (queueIds && queueIds.length > 0) {
130
+ // Items from specific queues.
131
+ sourceConditions.push(sql`m.queueId IN ${sql.in(queueIds)}`);
132
+ }
133
+
134
+ if (sourceConditions.length > 0) {
135
+ conditions.push(sql`(${sql.or(sourceConditions)})`);
136
+ }
137
+
138
+ return yield* sql<ObjectMeta>`SELECT m.* FROM ftsIndex AS f JOIN objectMeta AS m ON f.rowid = m.recordId WHERE ${sql.and(conditions)}`;
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Query snapshots by recordIds.
144
+ * Returns the parsed JSON snapshots for queue objects.
145
+ */
146
+ querySnapshotsJSON(
147
+ recordIds: number[],
148
+ ): Effect.Effect<readonly { recordId: number; snapshot: Obj.JSON }[], SqlError.SqlError, SqlClient.SqlClient> {
149
+ return Effect.gen(function* () {
150
+ if (recordIds.length === 0) {
151
+ return [];
152
+ }
153
+ const sql = yield* SqlClient.SqlClient;
154
+ const results = yield* sql<{
155
+ rowid: number;
156
+ snapshot: string;
157
+ }>`SELECT rowid, snapshot FROM ftsIndex WHERE rowid IN ${sql.in(recordIds)}`;
158
+ return results.map((r) => ({
159
+ recordId: r.rowid,
160
+ snapshot: JSON.parse(r.snapshot),
161
+ }));
162
+ });
163
+ }
164
+
165
+ update = Effect.fn('FtsIndex.update')(
166
+ (objects: IndexerObject[]): Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient> =>
167
+ Effect.gen(function* () {
168
+ const sql = yield* SqlClient.SqlClient;
169
+
170
+ yield* Effect.forEach(
171
+ objects,
172
+ (object) =>
173
+ Effect.gen(function* () {
174
+ const { recordId, data } = object;
175
+ if (recordId === null) {
176
+ return yield* Effect.die(new Error('FtsIndex.update requires recordId to be set'));
177
+ }
178
+
179
+ const snapshot = JSON.stringify(data);
180
+
181
+ // FTS5 doesn't support UPDATE, need DELETE + INSERT for upsert.
182
+ const existing = yield* sql<{ rowid: number }>`SELECT rowid FROM ftsIndex WHERE rowid = ${recordId}`;
183
+ if (existing.length > 0) {
184
+ yield* sql`DELETE FROM ftsIndex WHERE rowid = ${recordId}`;
185
+ }
186
+
187
+ yield* sql`INSERT INTO ftsIndex (rowid, snapshot) VALUES (${recordId}, ${snapshot})`;
188
+ }),
189
+ { discard: true },
190
+ );
191
+ }),
192
+ );
193
+ }
@@ -0,0 +1,27 @@
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 * as Effect from 'effect/Effect';
8
+ import { describe, expect, it } from 'vitest';
9
+
10
+ describe('FTS5', () => {
11
+ it('should create an FTS5 table and search it', () =>
12
+ Effect.gen(function* () {
13
+ const sql = yield* SqliteClient.make({
14
+ filename: ':memory:',
15
+ });
16
+
17
+ yield* sql`CREATE VIRTUAL TABLE emails USING fts5(sender, title, body);`;
18
+
19
+ yield* sql`INSERT INTO emails (sender, title, body) VALUES ('bob@example.com', 'Hello', 'This is a message about Effect');`;
20
+ yield* sql`INSERT INTO emails (sender, title, body) VALUES ('alice@example.com', 'Meeting', 'Let us discuss SQL');`;
21
+
22
+ const result = yield* sql`SELECT * FROM emails WHERE emails MATCH 'Effect'`;
23
+
24
+ expect(result.length).toEqual(1);
25
+ expect(result[0].sender).toEqual('bob@example.com');
26
+ }).pipe(Effect.provide(Reactivity.layer), Effect.scoped, Effect.runPromise));
27
+ });
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './fts-index';
6
+ export * from './object-meta-index';
7
+ export * from './reverse-ref-index';
8
+ export * from './interface';
@@ -0,0 +1,56 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type * as SqlClient from '@effect/sql/SqlClient';
6
+ import type * as SqlError from '@effect/sql/SqlError';
7
+ import type * as Effect from 'effect/Effect';
8
+
9
+ import type { Obj } from '@dxos/echo';
10
+ import type { ObjectId, SpaceId } from '@dxos/keys';
11
+
12
+ /**
13
+ * Data describing objects returned from sources to the indexer.
14
+ */
15
+ export interface IndexerObject {
16
+ spaceId: SpaceId;
17
+ /**
18
+ * Queue id if object is from the queue.
19
+ * If null, `documentId` must be set.
20
+ */
21
+ queueId: ObjectId | null;
22
+ /**
23
+ * Document id if object is from the automerge document.
24
+ * If null, `queueId` must be set.
25
+ */
26
+ documentId: string | null;
27
+
28
+ /**
29
+ * Record id from the objectMeta index.
30
+ * `Null` before the object is stored in the ObjectMetaIndex.
31
+ * Enriched by the IndexEngine after the object is stored in the ObjectMetaIndex.
32
+ */
33
+ recordId: number | null;
34
+
35
+ /**
36
+ * JSON data of the object.
37
+ */
38
+ data: Obj.JSON;
39
+ }
40
+
41
+ /**
42
+ * SQLite-based index for storing and querying object data.
43
+ */
44
+ export interface Index {
45
+ /**
46
+ * Runs necessary migrations to the index before it is usable.
47
+ * Idempotent.
48
+ */
49
+ migrate: () => Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient>;
50
+
51
+ /**
52
+ * Updates the index with the given objects.
53
+ * Idempotent.
54
+ */
55
+ update: (objects: IndexerObject[]) => Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient>;
56
+ }
@@ -0,0 +1,119 @@
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_DELETED, ATTR_RELATION_SOURCE, ATTR_RELATION_TARGET, ATTR_TYPE } from '@dxos/echo/internal';
12
+ import { DXN, ObjectId, SpaceId } from '@dxos/keys';
13
+
14
+ import type { IndexerObject } from './interface';
15
+ import { ObjectMetaIndex } from './object-meta-index';
16
+
17
+ const TYPE_PERSON = DXN.parse('dxn:type:example.com/type/Person:0.1.0').toString();
18
+ const TYPE_RELATION = DXN.parse('dxn:type:example.com/type/Relation:0.1.0').toString();
19
+ const TYPE_RELATION_UPDATED = DXN.parse('dxn:type:example.com/type/RelationUpdated:0.1.0').toString();
20
+
21
+ const TestLayer = Layer.merge(
22
+ SqliteClient.layer({
23
+ filename: ':memory:',
24
+ }),
25
+ Reactivity.layer,
26
+ );
27
+
28
+ describe('ObjectMetaIndex', () => {
29
+ it.effect('should store and update object metadata', () =>
30
+ Effect.gen(function* () {
31
+ const index = new ObjectMetaIndex();
32
+ yield* index.migrate();
33
+
34
+ const spaceId = SpaceId.random();
35
+ const objectId1 = ObjectId.random();
36
+ const objectId2 = ObjectId.random();
37
+
38
+ const item1: IndexerObject = {
39
+ spaceId,
40
+ queueId: ObjectId.random(),
41
+ documentId: null,
42
+ recordId: null,
43
+ data: {
44
+ id: objectId1,
45
+ [ATTR_TYPE]: TYPE_PERSON,
46
+ [ATTR_DELETED]: false,
47
+ },
48
+ };
49
+
50
+ const item2: IndexerObject = {
51
+ spaceId,
52
+ queueId: null,
53
+ documentId: 'doc-123',
54
+ recordId: null,
55
+ data: {
56
+ id: objectId2,
57
+ [ATTR_TYPE]: TYPE_RELATION,
58
+ [ATTR_RELATION_SOURCE]: DXN.parse(`dxn:echo:${spaceId}:${ObjectId.random()}}`).toString(),
59
+ [ATTR_RELATION_TARGET]: DXN.parse(`dxn:echo:${spaceId}:${ObjectId.random()}}`).toString(),
60
+ [ATTR_DELETED]: false,
61
+ },
62
+ };
63
+
64
+ // 1. Initial Insert
65
+ yield* index.update([item1, item2]);
66
+
67
+ // Verify Query.
68
+ const results = yield* index.query({ spaceId, typeDxn: TYPE_PERSON });
69
+ expect(results.length).toBe(1);
70
+ expect(results[0].objectId).toBe(objectId1);
71
+ expect(results[0].version).toBe(1);
72
+
73
+ const relationResults = yield* index.query({ spaceId, typeDxn: TYPE_RELATION });
74
+ expect(relationResults.length).toBe(1);
75
+ expect(relationResults[0].objectId).toBe(objectId2);
76
+ expect(relationResults[0].entityKind).toBe('relation');
77
+ expect(relationResults[0].source).toBe(item2.data[ATTR_RELATION_SOURCE]);
78
+ expect(relationResults[0].target).toBe(item2.data[ATTR_RELATION_TARGET]);
79
+ expect(relationResults[0].version).toBe(2);
80
+
81
+ // 2. Update existing object (item1 matches by queueId)
82
+ const item1Update: IndexerObject = {
83
+ ...item1,
84
+ data: {
85
+ ...item1.data,
86
+ [ATTR_DELETED]: true,
87
+ },
88
+ };
89
+
90
+ yield* index.update([item1Update]);
91
+
92
+ const updatedResults = yield* index.query({ spaceId, typeDxn: TYPE_PERSON });
93
+ // Depending on implementation, query might filter deleted or not.
94
+ // Current implementation is SELECT * without deleted filter for queryType
95
+ expect(updatedResults.length).toBe(1);
96
+ expect(updatedResults[0].deleted).toBe(true);
97
+ expect(updatedResults[0].version).toBe(3); // Incremented globally
98
+
99
+ // 3. Update existing object by documentId (item2)
100
+ const item2Update: IndexerObject = {
101
+ ...item2,
102
+ data: {
103
+ ...item2.data,
104
+ [ATTR_TYPE]: TYPE_RELATION_UPDATED,
105
+ },
106
+ };
107
+
108
+ yield* index.update([item2Update]);
109
+
110
+ const newTypeResults = yield* index.query({ spaceId, typeDxn: TYPE_RELATION_UPDATED });
111
+ expect(newTypeResults.length).toBe(1);
112
+ expect(newTypeResults[0].version).toBe(4);
113
+ expect(newTypeResults[0].objectId).toBe(objectId2);
114
+
115
+ const oldTypeResults = yield* index.query({ spaceId, typeDxn: TYPE_RELATION });
116
+ expect(oldTypeResults.length).toBe(0);
117
+ }).pipe(Effect.provide(TestLayer)),
118
+ );
119
+ });
@@ -0,0 +1,204 @@
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 { ATTR_DELETED, ATTR_RELATION_SOURCE, ATTR_RELATION_TARGET, ATTR_TYPE } from '@dxos/echo/internal';
11
+
12
+ import type { IndexerObject } from './interface';
13
+ import type { Index } from './interface';
14
+
15
+ export const ObjectMeta = Schema.Struct({
16
+ recordId: Schema.Number,
17
+ objectId: Schema.String,
18
+ queueId: Schema.String,
19
+ spaceId: Schema.String,
20
+ documentId: Schema.String,
21
+ entityKind: Schema.String,
22
+ typeDxn: Schema.String,
23
+ deleted: Schema.Boolean,
24
+ source: Schema.NullOr(Schema.String),
25
+ target: Schema.NullOr(Schema.String),
26
+ /** Monotonically increasing sequence number assigned on insert/update for tracking indexing order. */
27
+ version: Schema.Number,
28
+ });
29
+ export interface ObjectMeta extends Schema.Schema.Type<typeof ObjectMeta> {}
30
+
31
+ export class ObjectMetaIndex implements Index {
32
+ migrate = Effect.fn('ObjectMetaIndex.runMigrations')(function* () {
33
+ const sql = yield* SqlClient.SqlClient;
34
+
35
+ yield* sql`CREATE TABLE IF NOT EXISTS objectMeta (
36
+ recordId INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ objectId TEXT NOT NULL,
38
+ queueId TEXT NOT NULL DEFAULT '',
39
+ spaceId TEXT NOT NULL,
40
+ documentId TEXT NOT NULL DEFAULT '',
41
+ entityKind TEXT NOT NULL,
42
+ typeDxn TEXT NOT NULL,
43
+ deleted INTEGER NOT NULL,
44
+ source TEXT,
45
+ target TEXT,
46
+ version INTEGER NOT NULL
47
+ )`;
48
+
49
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_object_index_objectId ON objectMeta(spaceId, objectId)`;
50
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_object_index_typeDxn ON objectMeta(spaceId, typeDxn)`;
51
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_object_index_version ON objectMeta(version)`;
52
+ });
53
+
54
+ query = Effect.fn('ObjectMetaIndex.queryType')(
55
+ (
56
+ query: Pick<ObjectMeta, 'spaceId' | 'typeDxn'>,
57
+ ): Effect.Effect<readonly ObjectMeta[], SqlError.SqlError, SqlClient.SqlClient> =>
58
+ Effect.gen(function* () {
59
+ const sql = yield* SqlClient.SqlClient;
60
+ // SQLite stores booleans as integers, so we need to specify the raw row type.
61
+ const rows =
62
+ yield* sql<ObjectMeta>`SELECT * FROM objectMeta WHERE spaceId = ${query.spaceId} AND typeDxn = ${query.typeDxn}`;
63
+ return rows.map((row) => ({
64
+ ...row,
65
+ deleted: !!row.deleted,
66
+ }));
67
+ }),
68
+ );
69
+
70
+ // TODO(dmaretskyi): Update recordId on objects so that we don't need to look it up separately.
71
+ update = Effect.fn('ObjectMetaIndex.update')(
72
+ (objects: IndexerObject[]): Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient> =>
73
+ Effect.gen(function* () {
74
+ const sql = yield* SqlClient.SqlClient;
75
+
76
+ yield* Effect.forEach(
77
+ objects,
78
+ (object) =>
79
+ Effect.gen(function* () {
80
+ const { spaceId, queueId, documentId, data } = object;
81
+
82
+ // Extract metadata (Logic emulating Echo APIs as strict imports are unavailable).
83
+ // TODO(agent): Verify property access matches Obj.JSON structure.
84
+ const castData = data;
85
+ const objectId = castData.id;
86
+
87
+ // Check for existing record by (spaceId, queueId) or (spaceId, documentId).
88
+ let existing: readonly { recordId: number }[];
89
+ if (documentId) {
90
+ existing = yield* sql<{
91
+ recordId: number;
92
+ }>`SELECT recordId FROM objectMeta WHERE spaceId = ${spaceId} AND documentId = ${documentId} AND objectId = ${objectId} LIMIT 1`;
93
+ } else if (queueId) {
94
+ existing = yield* sql<{
95
+ recordId: number;
96
+ }>`SELECT recordId FROM objectMeta WHERE spaceId = ${spaceId} AND queueId = ${queueId} AND objectId = ${objectId} LIMIT 1`;
97
+ } else {
98
+ // Should not happen based on IndexerObject definition (one must be present ideally), but handle gracefully.
99
+ existing = [];
100
+ }
101
+
102
+ // Get max version + 1.
103
+ const result = yield* sql<{ v: number | null }>`SELECT MAX(version) as v FROM objectMeta`;
104
+ const [{ v }] = result;
105
+ const version = (v ?? 0) + 1;
106
+
107
+ // Extract metadata.
108
+ const entityKind = castData[ATTR_RELATION_SOURCE] ? 'relation' : 'object';
109
+ const typeDxn = castData[ATTR_TYPE] ? String(castData[ATTR_TYPE]) : 'type';
110
+ const deleted = castData[ATTR_DELETED] ? 1 : 0;
111
+ // Relations.
112
+ const source = entityKind === 'relation' ? (castData[ATTR_RELATION_SOURCE] ?? null) : null;
113
+ const target = entityKind === 'relation' ? (castData[ATTR_RELATION_TARGET] ?? null) : null;
114
+
115
+ if (existing.length > 0) {
116
+ yield* sql`
117
+ UPDATE objectMeta SET
118
+ version = ${version},
119
+ entityKind = ${entityKind},
120
+ typeDxn = ${typeDxn},
121
+ deleted = ${deleted},
122
+ source = ${source},
123
+ target = ${target}
124
+ WHERE recordId = ${existing[0].recordId}
125
+ `;
126
+ } else {
127
+ yield* sql`
128
+ INSERT INTO objectMeta (
129
+ objectId, queueId, spaceId, documentId,
130
+ entityKind, typeDxn, deleted, source, target, version
131
+ ) VALUES (
132
+ ${objectId}, ${queueId ?? ''}, ${spaceId}, ${documentId ?? ''},
133
+ ${entityKind}, ${typeDxn}, ${deleted},
134
+ ${source}, ${target}, ${version}
135
+ )
136
+ `;
137
+ }
138
+ }),
139
+ { discard: true },
140
+ );
141
+ }),
142
+ );
143
+
144
+ /**
145
+ * Look up `recordIds` for objects that are already stored in the ObjectMetaIndex.
146
+ * Mutates the objects in place.
147
+ */
148
+ lookupRecordIds = Effect.fn('ObjectMetaIndex.lookupRecordIds')(
149
+ (objects: IndexerObject[]): Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient> =>
150
+ Effect.gen(function* () {
151
+ const sql = yield* SqlClient.SqlClient;
152
+
153
+ for (const object of objects) {
154
+ const { spaceId, queueId, documentId, data } = object;
155
+ const objectId = data.id;
156
+
157
+ let result: readonly { recordId: number }[];
158
+ if (documentId) {
159
+ result = yield* sql<{
160
+ recordId: number;
161
+ }>`SELECT recordId FROM objectMeta WHERE spaceId = ${spaceId} AND documentId = ${documentId} AND objectId = ${objectId} LIMIT 1`;
162
+ } else if (queueId) {
163
+ result = yield* sql<{
164
+ recordId: number;
165
+ }>`SELECT recordId FROM objectMeta WHERE spaceId = ${spaceId} AND queueId = ${queueId} AND objectId = ${objectId} LIMIT 1`;
166
+ } else {
167
+ result = [];
168
+ }
169
+
170
+ if (result.length === 0) {
171
+ // TODO(mykola): Handle this case gracefully.
172
+ yield* Effect.die(
173
+ new Error(`Object not found in ObjectMetaIndex: ${spaceId}/${documentId ?? queueId}/${objectId}`),
174
+ );
175
+ }
176
+ object.recordId = result[0].recordId;
177
+ }
178
+ }),
179
+ );
180
+
181
+ /**
182
+ * Look up object metadata by recordIds.
183
+ */
184
+ lookupByRecordIds = Effect.fn('ObjectMetaIndex.lookupByRecordIds')(
185
+ (recordIds: number[]): Effect.Effect<readonly ObjectMeta[], SqlError.SqlError, SqlClient.SqlClient> =>
186
+ Effect.gen(function* () {
187
+ if (recordIds.length === 0) {
188
+ return [];
189
+ }
190
+
191
+ const sql = yield* SqlClient.SqlClient;
192
+ const placeholders = recordIds.map(() => '?').join(', ');
193
+ const rows = yield* sql.unsafe<ObjectMeta>(
194
+ `SELECT * FROM objectMeta WHERE recordId IN (${placeholders})`,
195
+ recordIds,
196
+ );
197
+
198
+ return rows.map((row) => ({
199
+ ...row,
200
+ deleted: !!row.deleted,
201
+ }));
202
+ }),
203
+ );
204
+ }