@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/search-index-worker",
3
- "version": "0.0.15",
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 Postgres-parity stack, where the
54
- * search-index queue has no Lambda event source; `remit-pg-index-worker`
55
- * only relays committed outbox events onto this queue (the producer side
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
@@ -1,5 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
- import { afterEach, test } from "node:test";
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
- afterEach(() => {
30
- delete process.env.DATA_BACKEND;
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 DynamoDB search-index data ports registered/,
36
+ /no search-index data ports registered/,
38
37
  );
39
38
  });
40
39
  });
41
40
 
42
- test("without DATA_BACKEND returns the injected DynamoDB ports", async () => {
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({ DATA_BACKEND: undefined }, async () => {
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 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).
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` 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.
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(messageDataDb);
99
- const mailbox = new MailboxRepo(genericDb);
48
+ const message = new DrizzleMessageRepository(db);
49
+ const mailbox = new MailboxRepo(db);
100
50
 
101
51
  return {
102
- account: new AccountRepo(genericDb),
103
- threadMessage: new DrizzleThreadMessageRepository(genericDb),
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 backends compose in-package above and never touch this
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 (process.env.DATA_BACKEND === "postgres")
139
- return buildPostgresDataPorts();
140
- if (process.env.DATA_BACKEND === "sqlite") return buildSqliteDataPorts();
141
- if (!injectedDataPorts) {
142
- throw new Error(
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 Postgres consumer (`consumer.ts`) can
90
- // process one message at a time outside the Lambda batch shape, reusing
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 pg-only work-summary signal
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 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;
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 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.",
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