@remit/search-index-worker 0.0.1

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/src/handler.ts ADDED
@@ -0,0 +1,285 @@
1
+ import { inspect } from "node:util";
2
+ import { NotFoundError } from "@remit/data-ports/errors";
3
+ import {
4
+ createLogger,
5
+ type Logger,
6
+ MetricUnit,
7
+ metrics,
8
+ withTelemetry,
9
+ } from "@remit/logger-lambda";
10
+ import type { VectorRecord } from "@remit/search-service";
11
+ import type { SQSBatchResponse, SQSEvent, SQSRecord } from "aws-lambda";
12
+ import { type ParsedQueueMessage, parseQueueMessage } from "./parse.js";
13
+ import { getServices, type Services } from "./services.js";
14
+
15
+ const log = createLogger();
16
+
17
+ export const handler = withTelemetry(
18
+ async (event: SQSEvent): Promise<SQSBatchResponse> => {
19
+ const services = await getServices();
20
+ return processBatch(event.Records, services, log);
21
+ },
22
+ );
23
+
24
+ /**
25
+ * A single message's indexing outcome — the pg-only work-summary signal
26
+ * (`remit-search-index-worker/consumer.ts`'s periodic noop/deferred/dropped
27
+ * log) hooks in via `Services.onIndexOutcome`, which is `undefined` on the
28
+ * Lambda path and so never fires there. Purely observational: nothing here
29
+ * feeds back into what gets logged, metriced, or returned to SQS.
30
+ */
31
+ export type IndexOutcome =
32
+ | { status: "indexed"; upserted: number; skipped: number }
33
+ | { status: "skipped"; reason: string; retryable: boolean };
34
+
35
+ export const processBatch = async (
36
+ records: SQSRecord[],
37
+ services: Services,
38
+ log: Logger,
39
+ ): Promise<SQSBatchResponse> => {
40
+ const batchItemFailures: { itemIdentifier: string }[] = [];
41
+ const processingStart = Date.now();
42
+
43
+ for (const record of records) {
44
+ const message = parseQueueMessage(record.body);
45
+
46
+ const failed =
47
+ message.kind === "delete"
48
+ ? await deleteMessage(message, services, log)
49
+ : await upsertMessage(message, services, log);
50
+
51
+ if (failed) {
52
+ batchItemFailures.push({ itemIdentifier: record.messageId });
53
+ }
54
+ }
55
+
56
+ metrics.addMetric(
57
+ "searchIndexProcessingDuration",
58
+ MetricUnit.Milliseconds,
59
+ Date.now() - processingStart,
60
+ );
61
+
62
+ return { batchItemFailures };
63
+ };
64
+
65
+ const deleteMessage = async (
66
+ message: Extract<ParsedQueueMessage, { kind: "delete" }>,
67
+ services: Services,
68
+ log: Logger,
69
+ ): Promise<boolean> =>
70
+ services.searchService
71
+ .delete(message.messageId)
72
+ .then(() => {
73
+ log.info("Deleted search vectors", { messageId: message.messageId });
74
+ metrics.addMetric("searchIndexProcessed", MetricUnit.Count, 1);
75
+ return false;
76
+ })
77
+ .catch((error) => {
78
+ log.error("Delete failed", {
79
+ error: inspect(error),
80
+ messageId: message.messageId,
81
+ });
82
+ metrics.addMetric("searchIndexFailures", MetricUnit.Count, 1);
83
+ return true;
84
+ });
85
+
86
+ // One SQS message = one email's chunks = one upsert. Isolating the
87
+ // upsert per message means a record S3 Vectors rejects (e.g. a metadata
88
+ // ValidationException) dead-letters on its own — its siblings in the
89
+ // batch still index instead of retrying forever behind a poison record.
90
+ // Exported so the long-running Postgres consumer (`consumer.ts`) and the
91
+ // bulk reindex script (`reindex.ts`) can process one message at a time
92
+ // outside the Lambda batch shape, reusing this exact logic.
93
+ export const upsertMessage = async (
94
+ message: Extract<ParsedQueueMessage, { kind: "upsert" }>,
95
+ services: Services,
96
+ log: Logger,
97
+ ): Promise<boolean> =>
98
+ indexMessage(message, services, log)
99
+ .then(() => false)
100
+ .catch((error) => {
101
+ // Name the messageId so the dead-letter is diagnosable per message
102
+ // (#910); a transient/whole-call error retries this message alone.
103
+ log.error("Upsert failed", {
104
+ error: inspect(error),
105
+ accountId: message.accountId,
106
+ messageId: message.messageId,
107
+ });
108
+ metrics.addMetric("searchIndexFailures", MetricUnit.Count, 1);
109
+ return true;
110
+ });
111
+
112
+ const indexMessage = async (
113
+ message: Extract<ParsedQueueMessage, { kind: "upsert" }>,
114
+ services: Services,
115
+ log: Logger,
116
+ ): Promise<void> => {
117
+ const vectorRecords = await prepareUpsert(
118
+ message.accountId,
119
+ message.messageId,
120
+ services,
121
+ log,
122
+ );
123
+ if (vectorRecords === null) return;
124
+ if (vectorRecords.length === 0) {
125
+ log.info("No indexable content, skipping", {
126
+ messageId: message.messageId,
127
+ });
128
+ services.onIndexOutcome?.({
129
+ status: "skipped",
130
+ reason: "no-indexable-content",
131
+ retryable: false,
132
+ });
133
+ return;
134
+ }
135
+
136
+ // Dedup by chunkId within this single message. The same email can
137
+ // emit a repeated chunkId (deterministic keys); duplicate keys in one
138
+ // PutVectors call are rejected by S3 Vectors.
139
+ const deduped = new Map<string, VectorRecord>();
140
+ for (const vectorRecord of vectorRecords) {
141
+ deduped.set(vectorRecord.chunkId, vectorRecord);
142
+ }
143
+ const upsertRecords = [...deduped.values()];
144
+
145
+ const { upserted, skipped } = await services.searchService.upsertVectors(
146
+ upsertRecords,
147
+ { force: message.force },
148
+ );
149
+ log.info("Upsert complete", {
150
+ accountId: message.accountId,
151
+ messageId: message.messageId,
152
+ upserted,
153
+ skipped,
154
+ });
155
+ metrics.addMetric("searchIndexProcessed", MetricUnit.Count, upserted);
156
+ metrics.addMetric("searchIndexSkipped", MetricUnit.Count, skipped);
157
+ services.onIndexOutcome?.({ status: "indexed", upserted, skipped });
158
+ };
159
+
160
+ const prepareUpsert = async (
161
+ accountIdFromMessage: string,
162
+ messageId: string,
163
+ services: Services,
164
+ log: Logger,
165
+ ): Promise<VectorRecord[] | null> => {
166
+ const {
167
+ accountService,
168
+ threadMessageService,
169
+ storageService,
170
+ searchService,
171
+ resolveAccountId,
172
+ } = services;
173
+
174
+ // On DynamoDB `resolveAccountId` is undefined (the stream bridge already
175
+ // resolved and attached a real accountId to the queue message), so this is
176
+ // exactly `accountIdFromMessage` — unchanged from before this hook existed.
177
+ // On Postgres the queue message carries no real accountId (the outbox
178
+ // trigger fires from a bare message id), so this derives it from the
179
+ // message's mailbox instead (see `data-ports.ts`).
180
+ const accountId = resolveAccountId
181
+ ? await resolveAccountId(messageId)
182
+ : accountIdFromMessage;
183
+ if (!accountId) {
184
+ log.info("Account not found for message, skipping", { messageId });
185
+ services.onIndexOutcome?.({
186
+ status: "skipped",
187
+ reason: "account-not-found",
188
+ retryable: false,
189
+ });
190
+ return null;
191
+ }
192
+
193
+ let accountConfigId: string;
194
+ try {
195
+ const account = await accountService.get(accountId);
196
+ if (account.deletedAt) {
197
+ log.info("Account deleted, skipping", {
198
+ accountId,
199
+ messageId,
200
+ deletedAt: account.deletedAt,
201
+ });
202
+ services.onIndexOutcome?.({
203
+ status: "skipped",
204
+ reason: "account-deleted",
205
+ retryable: false,
206
+ });
207
+ return null;
208
+ }
209
+ accountConfigId = account.accountConfigId;
210
+ } catch (error) {
211
+ // Only a genuine missing account is skippable. AccessDenied, throttling,
212
+ // and network errors must surface so the record retries (and reaches the
213
+ // DLQ) instead of being silently dropped as if it were a 404.
214
+ if (error instanceof NotFoundError) {
215
+ log.info("Account not found, skipping", { accountId, messageId });
216
+ services.onIndexOutcome?.({
217
+ status: "skipped",
218
+ reason: "account-not-found",
219
+ retryable: false,
220
+ });
221
+ return null;
222
+ }
223
+ throw error;
224
+ }
225
+
226
+ const threadMessage = await threadMessageService.findByMessageId(
227
+ accountConfigId,
228
+ messageId,
229
+ );
230
+ if (!threadMessage) {
231
+ log.info("ThreadMessage not found, skipping", { messageId });
232
+ services.onIndexOutcome?.({
233
+ status: "skipped",
234
+ reason: "thread-message-not-found",
235
+ retryable: true,
236
+ });
237
+ return null;
238
+ }
239
+
240
+ const parsedBody = await storageService.retrieveParsedBody(
241
+ threadMessage.accountConfigId,
242
+ accountId,
243
+ messageId,
244
+ );
245
+ if (!parsedBody) {
246
+ log.info("Parsed body not found in S3, skipping", { messageId });
247
+ services.onIndexOutcome?.({
248
+ status: "skipped",
249
+ reason: "parsed-body-not-found",
250
+ retryable: true,
251
+ });
252
+ return null;
253
+ }
254
+
255
+ return searchService.prepareVectors({
256
+ envelope: {
257
+ from: {
258
+ name: threadMessage.fromName ?? null,
259
+ email: threadMessage.fromEmail ?? "",
260
+ },
261
+ to: [],
262
+ cc: [],
263
+ bcc: [],
264
+ subject: threadMessage.subject ?? "",
265
+ attachments: [],
266
+ },
267
+ parsedBody: {
268
+ text: parsedBody.text,
269
+ html: parsedBody.html,
270
+ },
271
+ metadata: {
272
+ messageId,
273
+ threadId: threadMessage.threadId,
274
+ accountConfigId: threadMessage.accountConfigId,
275
+ mailboxIds: [threadMessage.mailboxId],
276
+ sentDate: threadMessage.sentDate,
277
+ isRead: threadMessage.isRead,
278
+ hasAttachment: threadMessage.hasAttachment,
279
+ hasStars: threadMessage.hasStars,
280
+ fromName: threadMessage.fromName ?? null,
281
+ subject: threadMessage.subject ?? "",
282
+ category: threadMessage.category,
283
+ },
284
+ });
285
+ };
@@ -0,0 +1,56 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { createIndexWorkStats } from "./index-stats.js";
4
+
5
+ describe("createIndexWorkStats", () => {
6
+ test("counts an upserted:0 indexed outcome as noop (the over-trigger signal)", () => {
7
+ const stats = createIndexWorkStats();
8
+ stats.record({ status: "indexed", upserted: 0, skipped: 4 }, false);
9
+ stats.record({ status: "indexed", upserted: 2, skipped: 1 }, false);
10
+ assert.deepStrictEqual(stats.drain(), {
11
+ processed: 2,
12
+ embedded: 1,
13
+ noop: 1,
14
+ deferred: 0,
15
+ dropped: 0,
16
+ forced: 0,
17
+ });
18
+ });
19
+
20
+ test("separates transient (deferred) from terminal (dropped) skips", () => {
21
+ const stats = createIndexWorkStats();
22
+ stats.record(
23
+ { status: "skipped", reason: "parsed-body-not-found", retryable: true },
24
+ false,
25
+ );
26
+ stats.record(
27
+ { status: "skipped", reason: "no-indexable-content", retryable: false },
28
+ false,
29
+ );
30
+ assert.deepStrictEqual(stats.drain(), {
31
+ processed: 2,
32
+ embedded: 0,
33
+ noop: 0,
34
+ deferred: 1,
35
+ dropped: 1,
36
+ forced: 0,
37
+ });
38
+ });
39
+
40
+ test("counts force re-indexes (moves) separately", () => {
41
+ const stats = createIndexWorkStats();
42
+ stats.record({ status: "indexed", upserted: 3, skipped: 0 }, true);
43
+ stats.record({ status: "indexed", upserted: 1, skipped: 0 }, false);
44
+ const summary = stats.drain();
45
+ assert.equal(summary?.forced, 1);
46
+ assert.equal(summary?.processed, 2);
47
+ });
48
+
49
+ test("drain resets the window and returns null when empty", () => {
50
+ const stats = createIndexWorkStats();
51
+ assert.equal(stats.drain(), null, "nothing recorded yet");
52
+ stats.record({ status: "indexed", upserted: 1, skipped: 0 }, false);
53
+ assert.ok(stats.drain());
54
+ assert.equal(stats.drain(), null, "drained window is empty again");
55
+ });
56
+ });
@@ -0,0 +1,62 @@
1
+ import type { IndexOutcome } from "./handler.js";
2
+
3
+ /**
4
+ * A window of index outcomes. `noop` is the proxy metric for over-triggering:
5
+ * an indexed message that embedded nothing (`upserted: 0`) means an event fired
6
+ * for content already in the store. A high noop rate is the signal that error
7
+ * and DLQ alarms can't see — "too much successful work" — and is what let the
8
+ * AWS search-index cost blowup ramp unnoticed (#1082). Pg-only: the Lambda path
9
+ * never wires `Services.onIndexOutcome`, so this only accumulates in the
10
+ * long-running Postgres consumer (`consumer.ts`).
11
+ */
12
+ export interface IndexWorkSummary {
13
+ processed: number;
14
+ /** Indexed and wrote vectors — real work. */
15
+ embedded: number;
16
+ /** Indexed but wrote nothing (`upserted: 0`) — an event for unchanged content. */
17
+ noop: number;
18
+ /** Transient skip (thread/body not visible yet); left undrained for retry. */
19
+ deferred: number;
20
+ /** Terminal skip; drained, will never index. */
21
+ dropped: number;
22
+ /** Of the above, how many were force re-indexes (moves) — always re-embedded. */
23
+ forced: number;
24
+ }
25
+
26
+ export interface IndexWorkStats {
27
+ record(outcome: IndexOutcome, force: boolean): void;
28
+ /** Return the accumulated window and reset it; null if nothing was recorded. */
29
+ drain(): IndexWorkSummary | null;
30
+ }
31
+
32
+ const empty = (): IndexWorkSummary => ({
33
+ processed: 0,
34
+ embedded: 0,
35
+ noop: 0,
36
+ deferred: 0,
37
+ dropped: 0,
38
+ forced: 0,
39
+ });
40
+
41
+ export const createIndexWorkStats = (): IndexWorkStats => {
42
+ let window = empty();
43
+ return {
44
+ record: (outcome, force) => {
45
+ window.processed += 1;
46
+ if (force) window.forced += 1;
47
+ if (outcome.status === "indexed") {
48
+ if (outcome.upserted > 0) window.embedded += 1;
49
+ else window.noop += 1;
50
+ return;
51
+ }
52
+ if (outcome.retryable) window.deferred += 1;
53
+ else window.dropped += 1;
54
+ },
55
+ drain: () => {
56
+ if (window.processed === 0) return null;
57
+ const summary = window;
58
+ window = empty();
59
+ return summary;
60
+ },
61
+ };
62
+ };
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { handler } from "./handler.js";
2
+ export type { SearchIndexMessage } from "./search-index-message.js";
package/src/parse.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { searchIndexMessageSchema } from "@remit/search-service";
2
+
3
+ export type ParsedQueueMessage =
4
+ | { kind: "upsert"; accountId: string; messageId: string; force: boolean }
5
+ | { kind: "delete"; messageId: string };
6
+
7
+ export const parseQueueMessage = (body: string): ParsedQueueMessage => {
8
+ const parsed = searchIndexMessageSchema.parse(JSON.parse(body));
9
+
10
+ if (parsed.eventName === "REMOVE") {
11
+ return { kind: "delete", messageId: parsed.messageId };
12
+ }
13
+ return {
14
+ kind: "upsert",
15
+ accountId: parsed.accountId,
16
+ messageId: parsed.messageId,
17
+ force: parsed.force ?? false,
18
+ };
19
+ };
package/src/poller.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { createLogger } from "@remit/logger-lambda";
2
+ import { runQueuePoller } from "@remit/sqs-client/poller";
3
+ import { env } from "expect-env";
4
+ import { handler } from "./index.js";
5
+ import { maybeStartSqliteOutboxDrain } from "./sqlite-outbox-drain.js";
6
+
7
+ /** Production queue poller — no e2e shim exists for this queue today (the
8
+ * e2e/CI stack piggybacks it onto the imap-worker shim); this is the
9
+ * standalone production entrypoint for the dedicated image. */
10
+ const log = createLogger();
11
+
12
+ // On the SQLite backend (RFC 036 D2) this container also owns the outbox wake:
13
+ // a 2-second poll relaying committed rows onto the search-index queue this same
14
+ // process consumes. A no-op on every other backend. Started before the poll
15
+ // loop, which blocks until shutdown, then stopped after it returns.
16
+ const drain = await maybeStartSqliteOutboxDrain(log);
17
+
18
+ try {
19
+ await runQueuePoller({
20
+ log,
21
+ targets: [
22
+ {
23
+ queueUrl: env.SQS_QUEUE_URL_SEARCH_INDEX,
24
+ handler,
25
+ functionName: "search-index-worker",
26
+ },
27
+ ],
28
+ });
29
+ } finally {
30
+ await drain?.stop();
31
+ }
package/src/reindex.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { Logger } from "@remit/logger-lambda";
2
+ import pMap from "p-map";
3
+ import type { Pool } from "pg";
4
+ import { type IndexOutcome, upsertMessage } from "./handler.js";
5
+ import type { Services } from "./services.js";
6
+
7
+ export interface ReindexResult {
8
+ total: number;
9
+ indexed: number;
10
+ skipped: number;
11
+ }
12
+
13
+ const NOT_APPLICABLE_ACCOUNT_ID = "reindex-all";
14
+
15
+ /**
16
+ * Re-embed every body-synced Postgres message. Concurrent (pMap) and
17
+ * force-upserting so a model change or a repair repopulates the whole store;
18
+ * keys-only scan keeps the hot path off `describe()`. Postgres-only — the
19
+ * `accountId` on the synthetic upsert message is a placeholder: `services`
20
+ * must carry `resolveAccountId` (true whenever `DATA_BACKEND=postgres`; see
21
+ * `data-ports.ts`), which derives the real one from each message's mailbox.
22
+ */
23
+ export const reindexAll = async (
24
+ pool: Pool,
25
+ services: Services,
26
+ log: Logger,
27
+ options?: { concurrency?: number },
28
+ ): Promise<ReindexResult> => {
29
+ const rows = await pool.query<{ message_id: string }>(
30
+ "SELECT message_id FROM message WHERE body_storage_key IS NOT NULL",
31
+ );
32
+ const messageIds = rows.rows.map((row) => row.message_id);
33
+
34
+ let indexed = 0;
35
+ let skipped = 0;
36
+ await pMap(
37
+ messageIds,
38
+ async (messageId) => {
39
+ let outcome: IndexOutcome | undefined;
40
+ const taskServices: Services = {
41
+ ...services,
42
+ onIndexOutcome: (o) => {
43
+ outcome = o;
44
+ },
45
+ };
46
+ await upsertMessage(
47
+ {
48
+ kind: "upsert",
49
+ accountId: NOT_APPLICABLE_ACCOUNT_ID,
50
+ messageId,
51
+ force: true,
52
+ },
53
+ taskServices,
54
+ log,
55
+ );
56
+ if (outcome?.status === "indexed") indexed += 1;
57
+ else skipped += 1;
58
+ },
59
+ { concurrency: options?.concurrency ?? 8 },
60
+ );
61
+
62
+ return { total: messageIds.length, indexed, skipped };
63
+ };
@@ -0,0 +1,22 @@
1
+ import { createLogger } from "@remit/logger-lambda";
2
+ import pg from "pg";
3
+ import { reindexAll } from "./reindex.js";
4
+ import { getServices } from "./services.js";
5
+
6
+ const log = createLogger();
7
+
8
+ const main = async (): Promise<void> => {
9
+ const connectionString = process.env.PG_CONNECTION_URL;
10
+ if (!connectionString) throw new Error("PG_CONNECTION_URL is required");
11
+
12
+ const pool = new pg.Pool({ connectionString });
13
+ const services = await getServices();
14
+
15
+ const result = await reindexAll(pool, services, log);
16
+ log.info("reindex complete", { ...result });
17
+
18
+ await pool.end();
19
+ process.exit(0);
20
+ };
21
+
22
+ await main();
@@ -0,0 +1,26 @@
1
+ import { createLogger } from "@remit/logger-lambda";
2
+ import { startSqsConsumer } from "./consumer.js";
3
+ import { getServices } from "./services.js";
4
+ import { runShutdown } from "./shutdown.js";
5
+
6
+ const SHUTDOWN_TIMEOUT_MS = 10_000;
7
+ const log = createLogger();
8
+
9
+ const main = async (): Promise<void> => {
10
+ const services = await getServices();
11
+ const consumer = startSqsConsumer({ services, logger: log });
12
+
13
+ const shutdown = (): void =>
14
+ runShutdown(consumer, {
15
+ timeoutMs: SHUTDOWN_TIMEOUT_MS,
16
+ exit: (code) => process.exit(code),
17
+ onError: (error) =>
18
+ log.error("shutdown failed", { error: String(error) }),
19
+ });
20
+ process.on("SIGINT", shutdown);
21
+ process.on("SIGTERM", shutdown);
22
+
23
+ log.info("search-index-worker consumer started");
24
+ };
25
+
26
+ await main();
@@ -0,0 +1 @@
1
+ export type { SearchIndexMessage } from "@remit/search-service";
@@ -0,0 +1,81 @@
1
+ import { createSearchService, type SearchService } from "@remit/search-service";
2
+ import {
3
+ buildEmbeddingServiceFromEnv,
4
+ buildVectorStoreFromEnv,
5
+ } from "@remit/search-service/from-env";
6
+ import type { StorageService } from "@remit/storage-service";
7
+ import { createStorageService } from "@remit/storage-service/s3";
8
+ import {
9
+ buildDataPortsFromEnv,
10
+ type SearchIndexDataPorts,
11
+ } from "./data-ports.js";
12
+ import type { IndexOutcome } from "./handler.js";
13
+
14
+ export interface Services {
15
+ accountService: SearchIndexDataPorts["account"];
16
+ threadMessageService: SearchIndexDataPorts["threadMessage"];
17
+ storageService: StorageService;
18
+ searchService: SearchService;
19
+ resolveAccountId?: SearchIndexDataPorts["resolveAccountId"];
20
+ /**
21
+ * Fired once per upsert outcome — the pg-only work-summary signal
22
+ * (`consumer.ts` wires this to `IndexWorkStats`). `undefined` on the Lambda
23
+ * path, where it never fires and so never affects behavior.
24
+ */
25
+ onIndexOutcome?: (outcome: IndexOutcome) => void;
26
+ }
27
+
28
+ let cached: Services | undefined;
29
+
30
+ export const getServices = async (): Promise<Services> => {
31
+ if (cached) return cached;
32
+
33
+ const dataPorts = await buildDataPortsFromEnv();
34
+
35
+ const storageService = createStorageService();
36
+
37
+ // The worker must have a durable vector store — a typo'd or missing S3 (or,
38
+ // on Postgres, PG_CONNECTION_URL) env var must not silently succeed by
39
+ // falling back to the throwaway in-memory store (which emits success
40
+ // metrics but drops every vector).
41
+ const isPostgres = process.env.DATA_BACKEND === "postgres";
42
+ const pgConnectionUrl = process.env.PG_CONNECTION_URL;
43
+ const localPath = process.env.LOCAL_VECTORDB_PATH;
44
+ const bucket = process.env.S3_VECTORS_BUCKET_NAME;
45
+ const indexName = process.env.S3_VECTORS_INDEX_NAME;
46
+ if (
47
+ !(isPostgres && pgConnectionUrl) &&
48
+ !localPath &&
49
+ !(bucket && indexName)
50
+ ) {
51
+ throw new Error(
52
+ "Vector store is not configured: set PG_CONNECTION_URL for the Postgres " +
53
+ "backend, LOCAL_VECTORDB_PATH for local dev, or both " +
54
+ "S3_VECTORS_BUCKET_NAME and S3_VECTORS_INDEX_NAME for production.",
55
+ );
56
+ }
57
+
58
+ // Build the embedder first so we can pass its dimension count to the
59
+ // sqlite-vec store — the vec0 table dimension must match the embedder.
60
+ const embedder = buildEmbeddingServiceFromEnv();
61
+ const searchService = createSearchService({
62
+ store: buildVectorStoreFromEnv(embedder.dimensions),
63
+ embedder,
64
+ });
65
+
66
+ cached = {
67
+ accountService: dataPorts.account,
68
+ threadMessageService: dataPorts.threadMessage,
69
+ resolveAccountId: dataPorts.resolveAccountId,
70
+ storageService,
71
+ searchService,
72
+ };
73
+ return cached;
74
+ };
75
+
76
+ export const createServices = (overrides: Services): Services => overrides;
77
+
78
+ /** Reset the singleton — test use only. */
79
+ export const _resetForTest = (): void => {
80
+ cached = undefined;
81
+ };