@remit/search-index-worker 0.0.15 → 0.0.17
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 +1 -4
- package/src/consumer.ts +3 -4
- package/src/data-ports.test.ts +27 -38
- package/src/data-ports.ts +27 -89
- package/src/handler.ts +2 -3
- package/src/services.ts +8 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/search-index-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@remit/outbox-relay": "*",
|
|
23
23
|
"p-map": "*",
|
|
24
|
-
"pg": "^8.0.0",
|
|
25
24
|
"@remit/logger-lambda": "*",
|
|
26
25
|
"@remit/sqs-client": "*",
|
|
27
26
|
"@remit/data-ports": "*",
|
|
@@ -30,10 +29,8 @@
|
|
|
30
29
|
"@remit/storage-service": "*",
|
|
31
30
|
"@aws-sdk/client-sqs": "*",
|
|
32
31
|
"better-sqlite3": "^12.11.1",
|
|
33
|
-
"drizzle-orm": "^0.45.2",
|
|
34
32
|
"expect-env": "*",
|
|
35
33
|
"@types/aws-lambda": "*",
|
|
36
|
-
"@types/pg": "^8.0.0",
|
|
37
34
|
"prom-client": "^15.1.3"
|
|
38
35
|
},
|
|
39
36
|
"devDependencies": {
|
package/src/consumer.ts
CHANGED
|
@@ -50,10 +50,9 @@ export interface SqsConsumerConfig {
|
|
|
50
50
|
* via `processBatch` (a batch of one) — the same per-message logic the AWS
|
|
51
51
|
* Lambda handler runs, reused so the two deployment shapes (Lambda event
|
|
52
52
|
* source mapping vs. a long-running container/pm2 process) share one
|
|
53
|
-
* indexing implementation. Used by the
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* see its `worker.ts`), it does not consume them.
|
|
53
|
+
* indexing implementation. Used by the self-host stack, where the search-index
|
|
54
|
+
* queue has no Lambda event source: the outbox drain relays committed events
|
|
55
|
+
* onto this queue (the producer side) and this consumer takes them off it.
|
|
57
56
|
*
|
|
58
57
|
* A message is deleted only when `processBatch` reports no failure for it;
|
|
59
58
|
* a failure leaves it on the queue so its visibility timeout lapses and SQS
|
package/src/data-ports.test.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
3
6
|
import {
|
|
4
7
|
buildDataPortsFromEnv,
|
|
5
8
|
type SearchIndexDataPorts,
|
|
@@ -26,55 +29,41 @@ const withEnv = async (
|
|
|
26
29
|
}
|
|
27
30
|
};
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("without DATA_BACKEND and no registered ports, throws (DynamoDB path is injected)", async () => {
|
|
34
|
-
await withEnv({ DATA_BACKEND: undefined }, async () => {
|
|
32
|
+
test("with neither a registration nor a database, the error names setSearchIndexDataPorts", async () => {
|
|
33
|
+
await withEnv({ SQLITE_DB_PATH: undefined }, async () => {
|
|
35
34
|
await assert.rejects(
|
|
36
35
|
() => buildDataPortsFromEnv(),
|
|
37
|
-
/no
|
|
36
|
+
/no search-index data ports registered/,
|
|
38
37
|
);
|
|
39
38
|
});
|
|
40
39
|
});
|
|
41
40
|
|
|
42
|
-
test("
|
|
41
|
+
test("SQLITE_DB_PATH builds Drizzle ports with a resolveAccountId hook", async () => {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "remit-search-ports-"));
|
|
43
|
+
await withEnv({ SQLITE_DB_PATH: join(dir, "remit.db") }, 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
|
+
typeof ports.resolveAccountId,
|
|
49
|
+
"function",
|
|
50
|
+
"the outbox relay carries no accountId, so the consumer must derive it",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Last: the registration is process-wide and takes precedence over everything
|
|
57
|
+
// below it, so a test registering ports cannot run before the ones that must
|
|
58
|
+
// reach the fallback.
|
|
59
|
+
test("a registered set of ports is returned without reading the environment", async () => {
|
|
43
60
|
const injected = {
|
|
44
61
|
account: {},
|
|
45
62
|
threadMessage: {},
|
|
46
63
|
} as unknown as SearchIndexDataPorts;
|
|
47
64
|
setSearchIndexDataPorts(injected);
|
|
48
|
-
await withEnv({
|
|
65
|
+
await withEnv({ SQLITE_DB_PATH: undefined }, async () => {
|
|
49
66
|
const ports = await buildDataPortsFromEnv();
|
|
50
67
|
assert.equal(ports, injected);
|
|
51
68
|
});
|
|
52
69
|
});
|
|
53
|
-
|
|
54
|
-
test("DATA_BACKEND=postgres builds Drizzle ports with a resolveAccountId hook, without connecting", async () => {
|
|
55
|
-
await withEnv(
|
|
56
|
-
{
|
|
57
|
-
DATA_BACKEND: "postgres",
|
|
58
|
-
PG_CONNECTION_URL: "postgresql://remit:remit@localhost:5432/remit_test",
|
|
59
|
-
},
|
|
60
|
-
async () => {
|
|
61
|
-
const ports = await buildDataPortsFromEnv();
|
|
62
|
-
assert.ok(ports.account, "account port must be defined");
|
|
63
|
-
assert.ok(ports.threadMessage, "threadMessage port must be defined");
|
|
64
|
-
assert.equal(
|
|
65
|
-
typeof ports.resolveAccountId,
|
|
66
|
-
"function",
|
|
67
|
-
"the pg outbox relay carries no accountId, so the consumer must derive it",
|
|
68
|
-
);
|
|
69
|
-
},
|
|
70
|
-
);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("DATA_BACKEND=postgres without PG_CONNECTION_URL throws", async () => {
|
|
74
|
-
await withEnv(
|
|
75
|
-
{ DATA_BACKEND: "postgres", PG_CONNECTION_URL: undefined },
|
|
76
|
-
async () => {
|
|
77
|
-
await assert.rejects(() => buildDataPortsFromEnv());
|
|
78
|
-
},
|
|
79
|
-
);
|
|
80
|
-
});
|
package/src/data-ports.ts
CHANGED
|
@@ -2,10 +2,6 @@ import type {
|
|
|
2
2
|
IAccountRepository,
|
|
3
3
|
IThreadMessageRepository,
|
|
4
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
5
|
|
|
10
6
|
export interface SearchIndexDataPorts {
|
|
11
7
|
account: IAccountRepository;
|
|
@@ -17,63 +13,21 @@ export interface SearchIndexDataPorts {
|
|
|
17
13
|
* one — this hook is `undefined` there, and the handler uses the message's
|
|
18
14
|
* own `accountId` unchanged (see `prepareUpsert` in handler.ts).
|
|
19
15
|
*
|
|
20
|
-
* The
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* retried — see handler.ts).
|
|
16
|
+
* The outbox carries a plain `message_id`, so the relational drain has no
|
|
17
|
+
* accountId to attach; this hook derives it from the message's mailbox at
|
|
18
|
+
* consume time instead. Returns null when the mailbox can't be resolved (the
|
|
19
|
+
* message is skipped, not retried — see handler.ts).
|
|
25
20
|
*/
|
|
26
21
|
resolveAccountId?: (messageId: string) => Promise<string | null>;
|
|
27
22
|
}
|
|
28
23
|
|
|
29
|
-
// `@remit/drizzle-service`
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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.
|
|
24
|
+
// `@remit/drizzle-service` is loaded lazily, inside this function, instead of as
|
|
25
|
+
// a static top-level import. A static import is bundled (and evaluated at module
|
|
26
|
+
// load) regardless of whether the branch that uses it ever runs; the package is
|
|
27
|
+
// marked `external` for the Lambda esbuild build (see LAMBDA_ESBUILD_OPTIONS),
|
|
28
|
+
// so esbuild leaves this `import()` unresolved in the bundle. It resolves only
|
|
29
|
+
// where the relational composition actually runs, which has the package
|
|
30
|
+
// installed. Mirrors `packages/backend/src/service/data-client.ts`.
|
|
77
31
|
const buildSqliteDataPorts = async (): Promise<SearchIndexDataPorts> => {
|
|
78
32
|
const sqliteDbPath = process.env.SQLITE_DB_PATH;
|
|
79
33
|
if (!sqliteDbPath) throw new Error("SQLITE_DB_PATH is required");
|
|
@@ -90,17 +44,13 @@ const buildSqliteDataPorts = async (): Promise<SearchIndexDataPorts> => {
|
|
|
90
44
|
const { db } = await createSqliteDatabase(messageDataSchema, {
|
|
91
45
|
filename: sqliteDbPath,
|
|
92
46
|
});
|
|
93
|
-
const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
|
|
94
|
-
const messageDataDb = db as unknown as NodePgDatabase<
|
|
95
|
-
typeof messageDataSchema
|
|
96
|
-
>;
|
|
97
47
|
|
|
98
|
-
const message = new DrizzleMessageRepository(
|
|
99
|
-
const mailbox = new MailboxRepo(
|
|
48
|
+
const message = new DrizzleMessageRepository(db);
|
|
49
|
+
const mailbox = new MailboxRepo(db);
|
|
100
50
|
|
|
101
51
|
return {
|
|
102
|
-
account: new AccountRepo(
|
|
103
|
-
threadMessage: new DrizzleThreadMessageRepository(
|
|
52
|
+
account: new AccountRepo(db),
|
|
53
|
+
threadMessage: new DrizzleThreadMessageRepository(db),
|
|
104
54
|
resolveAccountId: async (messageId) => {
|
|
105
55
|
const row = await message.get(messageId);
|
|
106
56
|
return mailbox.resolveAccountId(row.mailboxId);
|
|
@@ -108,40 +58,28 @@ const buildSqliteDataPorts = async (): Promise<SearchIndexDataPorts> => {
|
|
|
108
58
|
};
|
|
109
59
|
};
|
|
110
60
|
|
|
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
|
-
* The DynamoDB ports are injected by the composition root, which lives outside
|
|
123
|
-
* this shared, open-core module and is never imported here.
|
|
124
|
-
*/
|
|
125
61
|
let injectedDataPorts: SearchIndexDataPorts | null = null;
|
|
126
62
|
|
|
127
63
|
/**
|
|
128
64
|
* Register the DynamoDB-backed search-index data ports from the composition
|
|
129
|
-
* root. The relational
|
|
65
|
+
* root. The relational backend composes in-package above and never touches this
|
|
130
66
|
* seam.
|
|
131
67
|
*/
|
|
132
68
|
export const setSearchIndexDataPorts = (ports: SearchIndexDataPorts): void => {
|
|
133
69
|
injectedDataPorts = ports;
|
|
134
70
|
};
|
|
135
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The search-index data ports for this process: the ones a composition root
|
|
74
|
+
* registered, or — for a process that registered none — the SQLite composition
|
|
75
|
+
* this build contains. `SQLITE_DB_PATH` is that composition's precondition, not
|
|
76
|
+
* a backend selection.
|
|
77
|
+
*/
|
|
136
78
|
export const buildDataPortsFromEnv =
|
|
137
79
|
async (): Promise<SearchIndexDataPorts> => {
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
"no DynamoDB search-index data ports registered — register them with setSearchIndexDataPorts() from your composition root",
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
return injectedDataPorts;
|
|
80
|
+
if (injectedDataPorts) return injectedDataPorts;
|
|
81
|
+
if (process.env.SQLITE_DB_PATH) return buildSqliteDataPorts();
|
|
82
|
+
throw new Error(
|
|
83
|
+
"no search-index data ports registered — register them with setSearchIndexDataPorts() from your composition root",
|
|
84
|
+
);
|
|
147
85
|
};
|
package/src/handler.ts
CHANGED
|
@@ -86,9 +86,8 @@ const deleteMessage = async (
|
|
|
86
86
|
// upsert per message means a record S3 Vectors rejects (e.g. a metadata
|
|
87
87
|
// ValidationException) dead-letters on its own — its siblings in the
|
|
88
88
|
// batch still index instead of retrying forever behind a poison record.
|
|
89
|
-
// Exported so the long-running
|
|
90
|
-
//
|
|
91
|
-
// this exact logic.
|
|
89
|
+
// Exported so the long-running consumer (`consumer.ts`) can process one
|
|
90
|
+
// message at a time outside the Lambda batch shape, reusing this exact logic.
|
|
92
91
|
export const upsertMessage = async (
|
|
93
92
|
message: Extract<ParsedQueueMessage, { kind: "upsert" }>,
|
|
94
93
|
services: Services,
|
package/src/services.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface Services {
|
|
|
18
18
|
searchService: SearchService;
|
|
19
19
|
resolveAccountId?: SearchIndexDataPorts["resolveAccountId"];
|
|
20
20
|
/**
|
|
21
|
-
* Fired once per upsert outcome — the
|
|
21
|
+
* Fired once per upsert outcome — the relational work-summary signal
|
|
22
22
|
* (`consumer.ts` wires this to `IndexWorkStats`). `undefined` on the Lambda
|
|
23
23
|
* path, where it never fires and so never affects behavior.
|
|
24
24
|
*/
|
|
@@ -34,24 +34,17 @@ export const getServices = async (): Promise<Services> => {
|
|
|
34
34
|
|
|
35
35
|
const storageService = createStorageService();
|
|
36
36
|
|
|
37
|
-
// The worker must have a durable vector store — a typo'd or missing
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// metrics but drops every vector).
|
|
41
|
-
const isPostgres = process.env.DATA_BACKEND === "postgres";
|
|
42
|
-
const pgConnectionUrl = process.env.PG_CONNECTION_URL;
|
|
37
|
+
// The worker must have a durable vector store — a typo'd or missing env var
|
|
38
|
+
// must not silently succeed by falling back to the throwaway in-memory store
|
|
39
|
+
// (which emits success metrics but drops every vector).
|
|
43
40
|
const localPath = process.env.LOCAL_VECTORDB_PATH;
|
|
44
41
|
const bucket = process.env.S3_VECTORS_BUCKET_NAME;
|
|
45
42
|
const indexName = process.env.S3_VECTORS_INDEX_NAME;
|
|
46
|
-
if (
|
|
47
|
-
!(isPostgres && pgConnectionUrl) &&
|
|
48
|
-
!localPath &&
|
|
49
|
-
!(bucket && indexName)
|
|
50
|
-
) {
|
|
43
|
+
if (!localPath && !(bucket && indexName)) {
|
|
51
44
|
throw new Error(
|
|
52
|
-
"Vector store is not configured: set
|
|
53
|
-
"
|
|
54
|
-
"
|
|
45
|
+
"Vector store is not configured: set LOCAL_VECTORDB_PATH for local dev, " +
|
|
46
|
+
"or both S3_VECTORS_BUCKET_NAME and S3_VECTORS_INDEX_NAME for " +
|
|
47
|
+
"production.",
|
|
55
48
|
);
|
|
56
49
|
}
|
|
57
50
|
|