@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 +50 -0
- package/src/consumer.ts +163 -0
- package/src/data-ports.test.ts +96 -0
- package/src/data-ports.ts +142 -0
- package/src/handler.ts +285 -0
- package/src/index-stats.test.ts +56 -0
- package/src/index-stats.ts +62 -0
- package/src/index.ts +2 -0
- package/src/parse.ts +19 -0
- package/src/poller.ts +31 -0
- package/src/reindex.ts +63 -0
- package/src/run-reindex.ts +22 -0
- package/src/run-worker.ts +26 -0
- package/src/search-index-message.ts +1 -0
- package/src/services.ts +81 -0
- package/src/shutdown.test.ts +54 -0
- package/src/shutdown.ts +39 -0
- package/src/sqlite-outbox-drain.test.ts +90 -0
- package/src/sqlite-outbox-drain.ts +161 -0
- package/tsconfig.json +8 -0
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
|
+
}
|
package/src/consumer.ts
ADDED
|
@@ -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
|
+
};
|