@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/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@remit/search-index-worker",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "scripts": {
13
+ "test:typecheck": "tsgo --noEmit",
14
+ "test:run": "node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.test.ts'",
15
+ "test:integ": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.integ.test.ts'",
16
+ "test": "npm run test:typecheck && npm run test:run"
17
+ },
18
+ "dependencies": {
19
+ "@remit/outbox-relay": "*",
20
+ "p-map": "*",
21
+ "pg": "^8.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@aws-sdk/client-dynamodb": "*",
25
+ "@aws-sdk/client-sqs": "*",
26
+ "@aws-sdk/core": "*",
27
+ "@aws-sdk/lib-dynamodb": "*",
28
+ "@remit/data-ports": "*",
29
+ "@remit/drizzle-service": "*",
30
+ "better-sqlite3": "^12.11.1",
31
+ "@remit/logger-lambda": "*",
32
+ "@remit/search-service": "*",
33
+ "@remit/sqs-client": "*",
34
+ "@remit/storage-service": "*",
35
+ "@types/aws-lambda": "*",
36
+ "@types/pg": "^8.0.0",
37
+ "expect-env": "*",
38
+ "tsx": "*",
39
+ "zod": "*"
40
+ },
41
+ "license": "MIT",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/remit-mail/remit.git",
48
+ "directory": "packages/search-index-worker"
49
+ }
50
+ }
@@ -0,0 +1,163 @@
1
+ import {
2
+ DeleteMessageCommand,
3
+ ReceiveMessageCommand,
4
+ type ReceiveMessageCommandOutput,
5
+ SQSClient,
6
+ } from "@aws-sdk/client-sqs";
7
+ import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
8
+ import type { Logger } from "@remit/logger-lambda";
9
+ import { resolveSqsCredentials } from "@remit/sqs-client";
10
+ import type { SQSRecord } from "aws-lambda";
11
+ import { type IndexOutcome, processBatch } from "./handler.js";
12
+ import { createIndexWorkStats, type IndexWorkStats } from "./index-stats.js";
13
+ import { parseQueueMessage } from "./parse.js";
14
+ import type { Services } from "./services.js";
15
+ import type { RunningConsumer } from "./shutdown.js";
16
+
17
+ // `processBatch` takes a real `SQSRecord[]` (the Lambda event shape); the
18
+ // long-poll `ReceiveMessageCommand` result carries the same message id/body
19
+ // plus SDK-specific fields, so this fills in the rest with inert placeholders
20
+ // — `processBatch` only reads `record.body` and `record.messageId`.
21
+ const toSqsRecord = (
22
+ sqsMessage: NonNullable<ReceiveMessageCommandOutput["Messages"]>[number],
23
+ ): SQSRecord =>
24
+ ({
25
+ messageId: sqsMessage.MessageId ?? sqsMessage.ReceiptHandle ?? "",
26
+ body: sqsMessage.Body ?? "",
27
+ receiptHandle: sqsMessage.ReceiptHandle ?? "",
28
+ attributes: {} as SQSRecord["attributes"],
29
+ messageAttributes: {},
30
+ md5OfBody: sqsMessage.MD5OfBody ?? "",
31
+ eventSource: "aws:sqs",
32
+ eventSourceARN: "",
33
+ awsRegion: "",
34
+ }) satisfies SQSRecord;
35
+
36
+ const createSqsClient = (queueUrl: string): SQSClient => {
37
+ const isLocal = queueUrl.startsWith("http://localhost");
38
+ return new SQSClient({
39
+ endpoint: isLocal ? new URL(queueUrl).origin : undefined,
40
+ ...(isLocal
41
+ ? {
42
+ protocol: AwsQueryProtocol,
43
+ credentials: { accessKeyId: "local", secretAccessKey: "local" },
44
+ }
45
+ : { credentials: resolveSqsCredentials() }),
46
+ });
47
+ };
48
+
49
+ export interface SqsConsumerConfig {
50
+ /** Search-index queue URL; defaults to `SQS_QUEUE_URL_SEARCH_INDEX`. */
51
+ queueUrl?: string;
52
+ services: Services;
53
+ logger: Logger;
54
+ }
55
+
56
+ /**
57
+ * Long-polls the search-index SQS queue and processes one message at a time
58
+ * via `processBatch` (a batch of one) — the same per-message logic the AWS
59
+ * Lambda handler runs, reused so the two deployment shapes (Lambda event
60
+ * source mapping vs. a long-running container/pm2 process) share one
61
+ * indexing implementation. Used by the Postgres-parity stack, where the
62
+ * search-index queue has no Lambda event source; `remit-pg-index-worker`
63
+ * only relays committed outbox events onto this queue (the producer side —
64
+ * see its `worker.ts`), it does not consume them.
65
+ *
66
+ * A message is deleted only when `processBatch` reports no failure for it;
67
+ * a failure leaves it on the queue so its visibility timeout lapses and SQS
68
+ * redelivers (and eventually dead-letters) it — SQS owns the retry, same as
69
+ * the Lambda path's `batchItemFailures`.
70
+ *
71
+ * Every outcome is fed to `IndexWorkStats` (via `Services.onIndexOutcome`)
72
+ * and flushed on a lazy interval — the pg-only "index work summary" signal
73
+ * that surfaces over-triggering (#1082) CloudWatch alarms can't see.
74
+ */
75
+ export const startSqsConsumer = (
76
+ config: SqsConsumerConfig,
77
+ ): RunningConsumer => {
78
+ const { logger: log } = config;
79
+ const queueUrl = config.queueUrl ?? process.env.SQS_QUEUE_URL_SEARCH_INDEX;
80
+ if (!queueUrl) throw new Error("SQS_QUEUE_URL_SEARCH_INDEX is required");
81
+
82
+ const sqs = createSqsClient(queueUrl);
83
+
84
+ const stats: IndexWorkStats = createIndexWorkStats();
85
+ const flushStats = (): void => {
86
+ const summary = stats.drain();
87
+ if (summary) log.info("index work summary", { ...summary });
88
+ };
89
+ const SUMMARY_INTERVAL_MS = 60_000;
90
+ const summaryTimer = setInterval(flushStats, SUMMARY_INTERVAL_MS);
91
+ summaryTimer.unref();
92
+
93
+ const controller = new AbortController();
94
+ const consume = async (): Promise<void> => {
95
+ while (!controller.signal.aborted) {
96
+ let response: ReceiveMessageCommandOutput;
97
+ try {
98
+ response = await sqs.send(
99
+ new ReceiveMessageCommand({
100
+ QueueUrl: queueUrl,
101
+ MaxNumberOfMessages: 10,
102
+ WaitTimeSeconds: 20,
103
+ VisibilityTimeout: 300,
104
+ }),
105
+ { abortSignal: controller.signal },
106
+ );
107
+ } catch (error) {
108
+ if (controller.signal.aborted) return;
109
+ throw error;
110
+ }
111
+
112
+ for (const sqsMessage of response.Messages ?? []) {
113
+ if (!sqsMessage.Body || !sqsMessage.ReceiptHandle) continue;
114
+
115
+ const body = sqsMessage.Body;
116
+ const parsed = await Promise.resolve()
117
+ .then(() => parseQueueMessage(body))
118
+ .catch((error: unknown) => {
119
+ log.error("parse failed", { body, error: String(error) });
120
+ return null;
121
+ });
122
+ if (!parsed) continue;
123
+ const force = parsed.kind === "upsert" ? parsed.force : false;
124
+
125
+ let lastOutcome: IndexOutcome | undefined;
126
+ const services: Services = {
127
+ ...config.services,
128
+ onIndexOutcome: (outcome) => {
129
+ lastOutcome = outcome;
130
+ },
131
+ };
132
+
133
+ const { batchItemFailures } = await processBatch(
134
+ [toSqsRecord(sqsMessage)],
135
+ services,
136
+ log,
137
+ );
138
+
139
+ if (lastOutcome) stats.record(lastOutcome, force);
140
+
141
+ if (batchItemFailures.length === 0) {
142
+ await sqs.send(
143
+ new DeleteMessageCommand({
144
+ QueueUrl: queueUrl,
145
+ ReceiptHandle: sqsMessage.ReceiptHandle,
146
+ }),
147
+ );
148
+ }
149
+ }
150
+ }
151
+ };
152
+ const consumer = consume();
153
+
154
+ return {
155
+ stop: async () => {
156
+ clearInterval(summaryTimer);
157
+ flushStats();
158
+ controller.abort();
159
+ await consumer;
160
+ sqs.destroy();
161
+ },
162
+ };
163
+ };
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync } from "node:fs";
3
+ import { afterEach, test } from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import { buildDataPortsFromEnv } from "./data-ports.js";
6
+
7
+ // The DynamoDB composition module is stripped from the open-core tree; the
8
+ // DynamoDB-path cases skip there and run where the module ships.
9
+ const hasDynamoComposition = existsSync(
10
+ fileURLToPath(new URL("./compose-dynamodb.ts", import.meta.url)),
11
+ );
12
+
13
+ const withEnv = async (
14
+ overrides: Record<string, string | undefined>,
15
+ fn: () => Promise<void>,
16
+ ): Promise<void> => {
17
+ const saved: Record<string, string | undefined> = {};
18
+ for (const key of Object.keys(overrides)) saved[key] = process.env[key];
19
+ for (const [key, value] of Object.entries(overrides)) {
20
+ if (value === undefined) delete process.env[key];
21
+ else process.env[key] = value;
22
+ }
23
+ try {
24
+ await fn();
25
+ } finally {
26
+ for (const [key, value] of Object.entries(saved)) {
27
+ if (value === undefined) delete process.env[key];
28
+ else process.env[key] = value;
29
+ }
30
+ }
31
+ };
32
+
33
+ afterEach(() => {
34
+ delete process.env.DATA_BACKEND;
35
+ });
36
+
37
+ test(
38
+ "without DATA_BACKEND builds DynamoDB (electrodb) ports and no resolveAccountId hook",
39
+ { skip: !hasDynamoComposition },
40
+ async () => {
41
+ await withEnv(
42
+ { DATA_BACKEND: undefined, DYNAMODB_TABLE_NAME: "remit-test" },
43
+ async () => {
44
+ const ports = await buildDataPortsFromEnv();
45
+ assert.ok(ports.account, "account port must be defined");
46
+ assert.ok(ports.threadMessage, "threadMessage port must be defined");
47
+ assert.equal(
48
+ ports.resolveAccountId,
49
+ undefined,
50
+ "DynamoDB messages already carry a real accountId — no resolution hook",
51
+ );
52
+ },
53
+ );
54
+ },
55
+ );
56
+
57
+ test(
58
+ "without DATA_BACKEND and no DYNAMODB_TABLE_NAME throws",
59
+ { skip: !hasDynamoComposition },
60
+ async () => {
61
+ await withEnv(
62
+ { DATA_BACKEND: undefined, DYNAMODB_TABLE_NAME: undefined },
63
+ async () => {
64
+ await assert.rejects(() => buildDataPortsFromEnv());
65
+ },
66
+ );
67
+ },
68
+ );
69
+
70
+ test("DATA_BACKEND=postgres builds Drizzle ports with a resolveAccountId hook, without connecting", async () => {
71
+ await withEnv(
72
+ {
73
+ DATA_BACKEND: "postgres",
74
+ PG_CONNECTION_URL: "postgresql://remit:remit@localhost:5432/remit_test",
75
+ },
76
+ async () => {
77
+ const ports = await buildDataPortsFromEnv();
78
+ assert.ok(ports.account, "account port must be defined");
79
+ assert.ok(ports.threadMessage, "threadMessage port must be defined");
80
+ assert.equal(
81
+ typeof ports.resolveAccountId,
82
+ "function",
83
+ "the pg outbox relay carries no accountId, so the consumer must derive it",
84
+ );
85
+ },
86
+ );
87
+ });
88
+
89
+ test("DATA_BACKEND=postgres without PG_CONNECTION_URL throws", async () => {
90
+ await withEnv(
91
+ { DATA_BACKEND: "postgres", PG_CONNECTION_URL: undefined },
92
+ async () => {
93
+ await assert.rejects(() => buildDataPortsFromEnv());
94
+ },
95
+ );
96
+ });
@@ -0,0 +1,142 @@
1
+ import type {
2
+ IAccountRepository,
3
+ IThreadMessageRepository,
4
+ } from "@remit/data-ports";
5
+ // Type-only: erased at build, so it carries no runtime dependency on
6
+ // drizzle-orm. The value import lives inside `buildPostgresDataPorts` as a
7
+ // dynamic `import()` — see the comment there for why.
8
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
9
+
10
+ export interface SearchIndexDataPorts {
11
+ account: IAccountRepository;
12
+ threadMessage: IThreadMessageRepository;
13
+ /**
14
+ * Resolve the owning accountId for a message whose queue envelope carries no
15
+ * accountId of its own. The DynamoDB stream bridge resolves accountId at
16
+ * publish time, so every AWS search-index message already carries a real
17
+ * one — this hook is `undefined` there, and the handler uses the message's
18
+ * own `accountId` unchanged (see `prepareUpsert` in handler.ts).
19
+ *
20
+ * The Postgres outbox trigger fires from a plain `message_id`, so the pg
21
+ * relay (`remit-pg-index-worker`) has no accountId to attach; this hook
22
+ * derives it from the message's mailbox at consume time instead. Returns
23
+ * null when the mailbox can't be resolved (the message is skipped, not
24
+ * retried — see handler.ts).
25
+ */
26
+ resolveAccountId?: (messageId: string) => Promise<string | null>;
27
+ }
28
+
29
+ // `@remit/drizzle-service` and `drizzle-orm/node-postgres` are loaded
30
+ // lazily, inside this function, instead of as static top-level imports. This
31
+ // branch only ever runs when `DATA_BACKEND === "postgres"` — true for the
32
+ // local Postgres-parity dev stack, never on the deployed Lambda — but a static
33
+ // import is bundled (and evaluated at module load) regardless of whether the
34
+ // branch that uses it ever runs. `@remit/drizzle-service` and
35
+ // `drizzle-orm` are marked `external` for the Lambda esbuild build (see
36
+ // LAMBDA_ESBUILD_OPTIONS), so esbuild leaves this `import()` unresolved in the
37
+ // bundle; it is only ever reached — and only ever needs to resolve — on the
38
+ // Postgres path, which runs via `tsx` (no bundling, real module resolution)
39
+ // and always has both packages installed. Mirrors
40
+ // `packages/backend/src/service/dynamodb.ts`'s `buildPostgresClient`.
41
+ const buildPostgresDataPorts = async (): Promise<SearchIndexDataPorts> => {
42
+ const pgConnectionUrl = process.env.PG_CONNECTION_URL;
43
+ if (!pgConnectionUrl) throw new Error("PG_CONNECTION_URL is required");
44
+
45
+ const {
46
+ AccountRepo,
47
+ DrizzleMessageRepository,
48
+ DrizzleThreadMessageRepository,
49
+ MailboxRepo,
50
+ messageDataSchema,
51
+ } = await import("@remit/drizzle-service");
52
+ const { drizzle } = await import("drizzle-orm/node-postgres");
53
+
54
+ const db = drizzle(pgConnectionUrl, { schema: messageDataSchema });
55
+ const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
56
+ const messageDataDb = db as unknown as NodePgDatabase<
57
+ typeof messageDataSchema
58
+ >;
59
+
60
+ const message = new DrizzleMessageRepository(messageDataDb);
61
+ const mailbox = new MailboxRepo(genericDb);
62
+
63
+ return {
64
+ account: new AccountRepo(genericDb),
65
+ threadMessage: new DrizzleThreadMessageRepository(pgConnectionUrl),
66
+ resolveAccountId: async (messageId) => {
67
+ const row = await message.get(messageId);
68
+ return mailbox.resolveAccountId(row.mailboxId);
69
+ },
70
+ };
71
+ };
72
+
73
+ // The SQLite twin of `buildPostgresDataPorts` (RFC 036): the same Drizzle repos
74
+ // over the one shared SQLite file instead of a Postgres connection string.
75
+ // `createSqliteDatabase` (and better-sqlite3 behind it) is kept external from
76
+ // this worker's DynamoDB Lambda bundle by the same dynamic-import treatment.
77
+ const buildSqliteDataPorts = async (): Promise<SearchIndexDataPorts> => {
78
+ const sqliteDbPath = process.env.SQLITE_DB_PATH;
79
+ if (!sqliteDbPath) throw new Error("SQLITE_DB_PATH is required");
80
+
81
+ const {
82
+ AccountRepo,
83
+ createSqliteDatabase,
84
+ DrizzleMessageRepository,
85
+ DrizzleThreadMessageRepository,
86
+ MailboxRepo,
87
+ messageDataSchema,
88
+ } = await import("@remit/drizzle-service");
89
+
90
+ const { db } = await createSqliteDatabase(messageDataSchema, {
91
+ filename: sqliteDbPath,
92
+ });
93
+ const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
94
+ const messageDataDb = db as unknown as NodePgDatabase<
95
+ typeof messageDataSchema
96
+ >;
97
+
98
+ const message = new DrizzleMessageRepository(messageDataDb);
99
+ const mailbox = new MailboxRepo(genericDb);
100
+
101
+ return {
102
+ account: new AccountRepo(genericDb),
103
+ threadMessage: new DrizzleThreadMessageRepository(genericDb),
104
+ resolveAccountId: async (messageId) => {
105
+ const row = await message.get(messageId);
106
+ return mailbox.resolveAccountId(row.mailboxId);
107
+ },
108
+ };
109
+ };
110
+
111
+ /**
112
+ * Select the account + threadMessage data ports from the environment, mirroring
113
+ * `buildVectorStoreFromEnv`'s `DATA_BACKEND` selection so one handler serves
114
+ * the DynamoDB (AWS, production), Postgres (pg-parity), and SQLite
115
+ * (single-box, RFC 036) stacks:
116
+ *
117
+ * - `DATA_BACKEND=postgres` → Drizzle repos over `PG_CONNECTION_URL`.
118
+ * - `DATA_BACKEND=sqlite` → Drizzle repos over the shared `SQLITE_DB_PATH` file.
119
+ * - otherwise → ElectroDB services over `DYNAMODB_TABLE_NAME` (unchanged from
120
+ * the pre-convergence worker — this is the production path).
121
+ *
122
+ * Each branch loads its composition module through a dynamic `import()`, so the
123
+ * DynamoDB path — the sole importer of the closed `@remit/remit-electrodb-
124
+ * service` (`./compose-dynamodb.js`) — is never loaded on the relational
125
+ * (open-core) tree, where that module is not shipped. esbuild still bundles it
126
+ * into the Lambda because the specifier is a relative import, not an `external`.
127
+ */
128
+ export const buildDataPortsFromEnv =
129
+ async (): Promise<SearchIndexDataPorts> => {
130
+ if (process.env.DATA_BACKEND === "postgres")
131
+ return buildPostgresDataPorts();
132
+ if (process.env.DATA_BACKEND === "sqlite") return buildSqliteDataPorts();
133
+ // `as string` stops tsgo resolving the module in the open-core tree, which
134
+ // strips it; the named contract keeps the call site typed rather than `any`.
135
+ type ComposeDynamoDBModule = {
136
+ buildDynamoDBDataPorts: () => SearchIndexDataPorts;
137
+ };
138
+ const { buildDynamoDBDataPorts } = (await import(
139
+ "./compose-dynamodb.js" as string
140
+ )) as ComposeDynamoDBModule;
141
+ return buildDynamoDBDataPorts();
142
+ };