@remit/backend 0.0.50 → 0.0.52

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.
Files changed (44) hide show
  1. package/dev-server/content-auth.test.ts +9 -9
  2. package/dev-server/content-auth.ts +2 -2
  3. package/dev-server/relational-health.test.ts +0 -8
  4. package/dev-server/relational-health.ts +3 -21
  5. package/dev-server/server.ts +7 -9
  6. package/package.json +4 -7
  7. package/scripts/backfill-list-id.ts +1 -1
  8. package/src/data-backend.test.ts +1 -3
  9. package/src/data-backend.ts +8 -14
  10. package/src/derive/contentSignature.test.ts +1 -19
  11. package/src/derive/contentSignature.ts +10 -10
  12. package/src/derive/contentUrl.ts +1 -1
  13. package/src/handlers/account-oauth.ts +1 -1
  14. package/src/handlers/account.ts +1 -1
  15. package/src/handlers/address.ts +1 -1
  16. package/src/handlers/config.ts +1 -1
  17. package/src/handlers/filter.ts +1 -1
  18. package/src/handlers/folder-role.ts +1 -1
  19. package/src/handlers/label.ts +1 -1
  20. package/src/handlers/mailbox.ts +3 -3
  21. package/src/handlers/me.test.ts +1 -1
  22. package/src/handlers/me.ts +1 -1
  23. package/src/handlers/message.ts +1 -1
  24. package/src/handlers/organize.test.ts +20 -0
  25. package/src/handlers/organize.ts +1 -1
  26. package/src/handlers/outbox.ts +1 -1
  27. package/src/handlers/search.ts +1 -1
  28. package/src/handlers/sync.ts +1 -1
  29. package/src/handlers/thread.ts +1 -1
  30. package/src/handlers/unified-threads.ts +1 -1
  31. package/src/index.ts +2 -2
  32. package/src/jwt-auth.test.ts +6 -6
  33. package/src/jwt-auth.ts +2 -2
  34. package/src/service/compose-sqlite.ts +8 -14
  35. package/src/service/create-remit-client.ts +6 -6
  36. package/src/service/data-client.test.ts +96 -0
  37. package/src/service/data-client.ts +58 -0
  38. package/src/service/organize.test.ts +9 -4
  39. package/src/service/organize.ts +5 -7
  40. package/src/service/semantic-capability.test.ts +10 -16
  41. package/src/service/semantic-capability.ts +1 -1
  42. package/src/service/compose-postgres.ts +0 -76
  43. package/src/service/dynamodb.test.ts +0 -98
  44. package/src/service/dynamodb.ts +0 -57
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, test } from "node:test";
3
3
  import type { APIGatewayProxyEvent } from "aws-lambda";
4
4
  import {
5
5
  _setVerifierForTest,
6
- authenticatePostgresRequest,
6
+ authenticateSelfHostRequest,
7
7
  } from "./jwt-auth.js";
8
8
 
9
9
  const buildEvent = (
@@ -34,7 +34,7 @@ test("valid token injects verified sub into authorizer claims", async () => {
34
34
  headers: { Authorization: "Bearer good.token.here" },
35
35
  });
36
36
 
37
- const result = await authenticatePostgresRequest(event);
37
+ const result = await authenticateSelfHostRequest(event);
38
38
 
39
39
  assert.equal(result, null);
40
40
  assert.equal(event.requestContext.authorizer?.claims?.sub, "user-abc");
@@ -49,7 +49,7 @@ test("invalid token returns 401 and does not inject claims", async () => {
49
49
  headers: { authorization: "Bearer bad.token" },
50
50
  });
51
51
 
52
- const result = await authenticatePostgresRequest(event);
52
+ const result = await authenticateSelfHostRequest(event);
53
53
 
54
54
  assert.equal(result?.statusCode, 401);
55
55
  assert.equal(event.requestContext.authorizer, undefined);
@@ -59,7 +59,7 @@ test("no token with a local bypass configured is allowed", async () => {
59
59
  process.env.LOCAL_ACCOUNT_CONFIG_ID = "some-config-id";
60
60
  const event = buildEvent();
61
61
 
62
- const result = await authenticatePostgresRequest(event);
62
+ const result = await authenticateSelfHostRequest(event);
63
63
 
64
64
  assert.equal(result, null);
65
65
  });
@@ -67,7 +67,7 @@ test("no token with a local bypass configured is allowed", async () => {
67
67
  test("no token and no bypass returns 401", async () => {
68
68
  const event = buildEvent();
69
69
 
70
- const result = await authenticatePostgresRequest(event);
70
+ const result = await authenticateSelfHostRequest(event);
71
71
 
72
72
  assert.equal(result?.statusCode, 401);
73
73
  });
@@ -82,7 +82,7 @@ test("pre-injected claims (edge tier) short-circuit verification", async () => {
82
82
  } as unknown as APIGatewayProxyEvent["requestContext"],
83
83
  });
84
84
 
85
- const result = await authenticatePostgresRequest(event);
85
+ const result = await authenticateSelfHostRequest(event);
86
86
 
87
87
  assert.equal(result, null);
88
88
  assert.equal(event.requestContext.authorizer?.claims?.sub, "edge-user");
package/src/jwt-auth.ts CHANGED
@@ -56,7 +56,7 @@ const hasLocalBypass = (): boolean =>
56
56
  Boolean(process.env.LOCAL_ACCOUNT_CONFIG_ID);
57
57
 
58
58
  /**
59
- * Authenticate a Postgres-mode request from a better-auth RS256 JWT.
59
+ * Authenticate a self-host request from a better-auth RS256 JWT.
60
60
  *
61
61
  * On a valid token the verified `sub` is injected into the event's authorizer
62
62
  * claims, exactly where the Cognito authorizer puts it, so every downstream
@@ -68,7 +68,7 @@ const hasLocalBypass = (): boolean =>
68
68
  * When the edge tier (APISIX) has already verified and injected claims, this is
69
69
  * a no-op — the backend trusts pre-populated claims and re-verifies otherwise.
70
70
  */
71
- export const authenticatePostgresRequest = async (
71
+ export const authenticateSelfHostRequest = async (
72
72
  event: APIGatewayProxyEvent,
73
73
  ): Promise<APIGatewayProxyResult | null> => {
74
74
  const existingSub = event.requestContext?.authorizer?.claims?.sub;
@@ -25,7 +25,6 @@ import {
25
25
  OutboxMessageRepo,
26
26
  QuarantineRepo,
27
27
  } from "@remit/drizzle-service";
28
- import type { NodePgDatabase } from "drizzle-orm/node-postgres";
29
28
  import { env } from "expect-env";
30
29
  import {
31
30
  buildSharedDeps,
@@ -34,25 +33,20 @@ import {
34
33
  type RemitClientRepositories,
35
34
  } from "./create-remit-client.js";
36
35
 
37
- // The SQLite backend (RFC 036). Mirrors the Postgres composition the same
38
- // dialect-neutral Drizzle repos, wired to one shared SQLite file instead of a
39
- // Postgres server with two differences the file topology forces (D3): every
40
- // repo runs on the single serialized connection `createSqliteDatabase` opens
41
- // (writes cannot bypass serialization, so a plain repo write never joins an
42
- // open transaction's savepoint), and `threadMessage` takes that shared handle
43
- // rather than its own connection string, so its writes enlist in the same
44
- // unit-of-work transaction and the same write queue as everything else.
36
+ // The SQLite adapter composition (RFC 036). Every repo runs on the single
37
+ // serialized connection `createSqliteDatabase` opens (D3), so a plain repo
38
+ // write cannot bypass serialization and join an open transaction's savepoint,
39
+ // and `threadMessage` takes that same shared handle, so its writes enlist in
40
+ // the same unit-of-work transaction and the same write queue as everything
41
+ // else.
45
42
  export const buildSqliteClient = async (): Promise<RemitClient> => {
46
43
  const sqliteDbPath = env.SQLITE_DB_PATH;
47
44
 
48
45
  const { db } = await createSqliteDatabase(messageDataSchema, {
49
46
  filename: sqliteDbPath,
50
47
  });
51
- const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
52
-
53
- const messageDataDb = db as unknown as NodePgDatabase<
54
- typeof messageDataSchema
55
- >;
48
+ const genericDb = db;
49
+ const messageDataDb = db;
56
50
 
57
51
  const repositories: RemitClientRepositories = {
58
52
  accountConfig: new AccountConfigRepo(genericDb),
@@ -85,8 +85,8 @@ export interface RemitClient {
85
85
 
86
86
  // Smart Organize (RFC 034, epic #1280). Present on both backends
87
87
  // (`FilterService`/`LabelService`/… on DynamoDB, `FilterRepo`/`LabelRepo`/…
88
- // on Postgres) so the matching pipeline and filter CRUD run unchanged on
89
- // either. The Postgres side has no TTL reaper for expired Temporary filters;
88
+ // relationally) so the matching pipeline and filter CRUD run unchanged on
89
+ // either. The relational side has no TTL reaper for expired Temporary filters;
90
90
  // match-time correctness gates on `expiresAt` (RFC 034 Decision 1.1), never
91
91
  // on the row still existing, so the missing reaper is housekeeping only.
92
92
  filter: IFilterRepository;
@@ -99,7 +99,7 @@ export interface RemitClient {
99
99
  label: ILabelRepository;
100
100
  messageLabel: IMessageLabelRepository;
101
101
 
102
- // Atomic write set for a message save. Present on Postgres (real
102
+ // Atomic write set for a message save. Present on the relational backend (real
103
103
  // transaction); absent on DynamoDB, where callers fall back to per-repo
104
104
  // writes with that backend's own (non-transactional) guarantees.
105
105
  unitOfWork?: IUnitOfWork;
@@ -123,16 +123,16 @@ export interface RemitClient {
123
123
 
124
124
  // Pending placement-move markers (issue #1271). Present on both backends
125
125
  // (`MessagePlacementMoveService` on DynamoDB, `MessagePlacementMoveRepo` on
126
- // Postgres) so a caller never needs to guess which backend is active. Used
126
+ // relationally) so a caller never needs to guess which backend is active. Used
127
127
  // for read-time count prediction (epic #1281 invariant 4) and by the
128
128
  // account-worker cascade delete. Written by the imap-worker's bulk
129
129
  // body-sync path through this `placementMove`, so the placement producer
130
- // runs on whatever backend is active — DynamoDB, Postgres, or SQLite.
130
+ // runs on whatever backend is active — DynamoDB or SQLite.
131
131
  placementMove: IMessagePlacementMoveRepository;
132
132
 
133
133
  // Pending flag-push markers (issue #1273). Present on both backends
134
134
  // (`MessageFlagPushService` on DynamoDB, `MessageFlagPushRepo` on
135
- // Postgres). Used for read-time unseenCount prediction (epic #1281
135
+ // relationally). Used for read-time unseenCount prediction (epic #1281
136
136
  // invariant 4), by the account-worker cascade delete, and by the periodic
137
137
  // per-mailbox sync tick to re-arm a marker stuck `pending`. Unlike
138
138
  // `placementMove`, this one IS written on both backends — `flagQueue`
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, test } from "node:test";
6
+ import {
7
+ _resetForTest,
8
+ getClient,
9
+ type RemitClient,
10
+ setClient,
11
+ } from "./data-client.js";
12
+
13
+ const REQUIRED_KEYS: ReadonlyArray<keyof RemitClient> = [
14
+ "accountConfig",
15
+ "account",
16
+ "accountSetting",
17
+ "address",
18
+ "mailbox",
19
+ "mailboxSpecialUse",
20
+ "message",
21
+ "messageFlag",
22
+ "outboxMessage",
23
+ "threadMessage",
24
+ "envelope",
25
+ "accountExportRequest",
26
+ "quarantine",
27
+ "storage",
28
+ "search",
29
+ "secrets",
30
+ "bodySync",
31
+ "flagQueue",
32
+ "mailboxQueue",
33
+ "messageMove",
34
+ "outboxQueue",
35
+ "createConnectionScope",
36
+ ] as const;
37
+
38
+ afterEach(() => {
39
+ _resetForTest();
40
+ });
41
+
42
+ const withSqliteDbPath = async (run: () => Promise<void>): Promise<void> => {
43
+ const saved = process.env.SQLITE_DB_PATH;
44
+ const dir = mkdtempSync(join(tmpdir(), "remit-data-client-"));
45
+ process.env.SQLITE_DB_PATH = join(dir, "remit.db");
46
+ try {
47
+ await run();
48
+ } finally {
49
+ _resetForTest();
50
+ if (saved === undefined) delete process.env.SQLITE_DB_PATH;
51
+ else process.env.SQLITE_DB_PATH = saved;
52
+ rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ };
55
+
56
+ test("getClient() falls back to the in-package composition and constructs every service", async () => {
57
+ await withSqliteDbPath(async () => {
58
+ const client = await getClient();
59
+
60
+ for (const key of REQUIRED_KEYS) {
61
+ assert.ok(
62
+ client[key] !== undefined && client[key] !== null,
63
+ `RemitClient.${String(key)} must be defined on the fallback path`,
64
+ );
65
+ }
66
+
67
+ assert.equal(typeof client.createConnectionScope, "function");
68
+ });
69
+ });
70
+
71
+ test("getClient() names setClient when nothing is registered and there is no database to open", async () => {
72
+ const saved = process.env.SQLITE_DB_PATH;
73
+ delete process.env.SQLITE_DB_PATH;
74
+
75
+ try {
76
+ await assert.rejects(getClient(), /register one with setClient\(\)/);
77
+ } finally {
78
+ if (saved !== undefined) process.env.SQLITE_DB_PATH = saved;
79
+ }
80
+ });
81
+
82
+ test("getClient() returns the injected client without reading the environment", async () => {
83
+ const saved = process.env.SQLITE_DB_PATH;
84
+ delete process.env.SQLITE_DB_PATH;
85
+
86
+ const injected = { account: {} } as unknown as RemitClient;
87
+ setClient(injected);
88
+
89
+ try {
90
+ const client = await getClient();
91
+ assert.equal(client, injected);
92
+ } finally {
93
+ _resetForTest();
94
+ if (saved !== undefined) process.env.SQLITE_DB_PATH = saved;
95
+ }
96
+ });
@@ -0,0 +1,58 @@
1
+ import type { RemitClient } from "./create-remit-client.js";
2
+
3
+ export type {
4
+ ConnectionScope,
5
+ RemitClient,
6
+ } from "./create-remit-client.js";
7
+
8
+ let clientPromise: Promise<RemitClient> | null = null;
9
+ let injected: RemitClient | null = null;
10
+
11
+ /**
12
+ * Register the client from the composition root. An adapter that lives outside
13
+ * this shared, open-core module — DynamoDB, or a Postgres adapter in the
14
+ * repository that deploys it — is never imported here; its composition root
15
+ * calls this before handling a request.
16
+ */
17
+ export const setClient = (client: RemitClient): void => {
18
+ injected = client;
19
+ clientPromise = null;
20
+ };
21
+
22
+ /**
23
+ * The SQLite composition this build contains, reached only by a process that
24
+ * registered no client of its own. The `import()` is dynamic so a bundler
25
+ * following the entry graph of a process that injects its client never reaches
26
+ * `@remit/drizzle-service` or drizzle-orm (both `external` for the Lambda
27
+ * esbuild build).
28
+ *
29
+ * `SQLITE_DB_PATH` is the precondition the composition needs, checked ahead of
30
+ * the import so its absence names `setClient` instead of failing on module
31
+ * resolution inside a bundle, or — worse — opening an empty database and
32
+ * serving an empty mailbox. It is a precondition, not a backend selection.
33
+ */
34
+ const buildRelationalClient = async (): Promise<RemitClient> => {
35
+ if (!process.env.SQLITE_DB_PATH) {
36
+ throw new Error(
37
+ "no client registered — register one with setClient() from your composition root",
38
+ );
39
+ }
40
+ const composition = await import("./compose-sqlite.js");
41
+ return composition.buildSqliteClient();
42
+ };
43
+
44
+ export const getClient = (): Promise<RemitClient> => {
45
+ if (!clientPromise) {
46
+ clientPromise = injected
47
+ ? Promise.resolve(injected)
48
+ : buildRelationalClient();
49
+ }
50
+
51
+ return clientPromise;
52
+ };
53
+
54
+ /** Reset the singleton and any injected client — test use only. */
55
+ export const _resetForTest = (): void => {
56
+ clientPromise = null;
57
+ injected = null;
58
+ };
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { afterEach, beforeEach, describe, it } from "node:test";
3
3
  import type { FilterAnchorItem, FilterItem } from "@remit/data-ports";
4
- import { NotFoundError } from "@remit/data-ports/errors";
4
+ import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
5
5
  import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
6
6
  import type {
7
7
  AnchorPayload,
@@ -9,7 +9,7 @@ import type {
9
9
  VectorRecord,
10
10
  } from "@remit/search-service";
11
11
  import { createMemoryVectorStore } from "@remit/search-service";
12
- import type { RemitClient } from "./dynamodb.js";
12
+ import type { RemitClient } from "./data-client.js";
13
13
  import {
14
14
  applyOrganize,
15
15
  matchOrganize,
@@ -487,7 +487,7 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
487
487
  );
488
488
  });
489
489
 
490
- it("fails loud on a body-content (HasWords) clause rather than matching it against a preview", async () => {
490
+ it("rejects a body-content (HasWords) clause as a 400 rather than matching it against a preview", async () => {
491
491
  const deps = vectorlessDeps([
492
492
  candidate("msg-1", { subject: "Dinner reservation" }),
493
493
  ]);
@@ -499,7 +499,12 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
499
499
  anchorMessageId: "None",
500
500
  literalClauses: [{ field: "HasWords", value: "invoice" }],
501
501
  }),
502
- /HasWords/,
502
+ (error: unknown) => {
503
+ assert.ok(error instanceof BadRequestError);
504
+ assert.equal(error.statusCode, 400);
505
+ assert.match(error.message, /HasWords/);
506
+ return true;
507
+ },
503
508
  "the vector-free literal path must not silently narrow a body match to a preview",
504
509
  );
505
510
  assert.equal(deps.semanticUsed(), false);
@@ -3,7 +3,7 @@ import type {
3
3
  IFilterAnchorRepository,
4
4
  OrganizeJobRequestItem,
5
5
  } from "@remit/data-ports";
6
- import { NotFoundError } from "@remit/data-ports/errors";
6
+ import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
7
7
  import { FilterClauseField, FilterState } from "@remit/domain-enums";
8
8
  import {
9
9
  buildMatchText,
@@ -25,7 +25,7 @@ import {
25
25
  buildEmbeddingServiceFromEnv,
26
26
  buildVectorStoreFromEnv,
27
27
  } from "@remit/search-service/from-env";
28
- import type { RemitClient } from "./dynamodb.js";
28
+ import type { RemitClient } from "./data-client.js";
29
29
  import { noteSemanticCapabilityAbsence } from "./semantic-capability.js";
30
30
 
31
31
  /**
@@ -280,16 +280,14 @@ const matchSemantic = async (
280
280
  * happened to be indexed. Body-content matching therefore requires the widen
281
281
  * (vector) path — {@link matchSemantic} reconstructs body text from chunk
282
282
  * previews there. This guard keeps the two matchers from diverging silently: a
283
- * body-content clause reaching the vector-free path fails loud instead of
284
- * returning a wrong set. It is unreachable today — no product surface emits a
285
- * `HasWords` clause (the organize UI sends empty `literalClauses`, the filter
286
- * builder emits none) — and stays a fail-fast for any future surface that does.
283
+ * body-content clause reaching the vector-free path is rejected instead of
284
+ * returning a wrong set.
287
285
  */
288
286
  const assertNoBodyContentClause = (
289
287
  clauses: OrganizePredicate["literalClauses"],
290
288
  ): void => {
291
289
  if (clauses.some((clause) => clause.field === FilterClauseField.HasWords)) {
292
- throw new Error(
290
+ throw new BadRequestError(
293
291
  "Organize literal matching cannot evaluate a body-content (HasWords) clause without the vector pipeline — it requires the semantic widen path",
294
292
  );
295
293
  }
@@ -33,14 +33,11 @@ describe("noteSemanticCapabilityAbsence", () => {
33
33
  _resetSemanticCapabilityForTest();
34
34
  });
35
35
 
36
- it("absorbs a missing-module failure on the self-host SQL backends and remembers it", () => {
37
- for (const backend of ["sqlite", "postgres"]) {
38
- _resetSemanticCapabilityForTest();
39
- process.env.DATA_BACKEND = backend;
40
- assert.equal(isSemanticSearchUnavailable(), false);
41
- assert.equal(noteSemanticCapabilityAbsence(moduleNotFound()), true);
42
- assert.equal(isSemanticSearchUnavailable(), true);
43
- }
36
+ it("absorbs a missing-module failure on the self-host SQL backend and remembers it", () => {
37
+ process.env.DATA_BACKEND = "sqlite";
38
+ assert.equal(isSemanticSearchUnavailable(), false);
39
+ assert.equal(noteSemanticCapabilityAbsence(moduleNotFound()), true);
40
+ assert.equal(isSemanticSearchUnavailable(), true);
44
41
  });
45
42
 
46
43
  it("absorbs a dlopen failure (musl loading a glibc extension)", () => {
@@ -53,16 +50,13 @@ describe("noteSemanticCapabilityAbsence", () => {
53
50
  });
54
51
 
55
52
  it("absorbs an embedding-model load failure (e2e-dev from source, HuggingFace fetch failed) and remembers it", () => {
56
- for (const backend of ["sqlite", "postgres"]) {
57
- _resetSemanticCapabilityForTest();
58
- process.env.DATA_BACKEND = backend;
59
- assert.equal(isSemanticSearchUnavailable(), false);
60
- assert.equal(noteSemanticCapabilityAbsence(modelUnavailable()), true);
61
- assert.equal(isSemanticSearchUnavailable(), true);
62
- }
53
+ process.env.DATA_BACKEND = "sqlite";
54
+ assert.equal(isSemanticSearchUnavailable(), false);
55
+ assert.equal(noteSemanticCapabilityAbsence(modelUnavailable()), true);
56
+ assert.equal(isSemanticSearchUnavailable(), true);
63
57
  });
64
58
 
65
- it("rethrows genuine query errors on the self-host SQL backends", () => {
59
+ it("rethrows genuine query errors on the self-host SQL backend", () => {
66
60
  process.env.DATA_BACKEND = "sqlite";
67
61
  assert.equal(
68
62
  noteSemanticCapabilityAbsence(new Error("SQLITE_BUSY")),
@@ -35,7 +35,7 @@ import { isSelfHostSqlBackend } from "../data-backend.js";
35
35
  * the FTS/literal engine is the primary search surface on these profiles and is
36
36
  * unaffected. The absence is remembered so subsequent requests short-circuit.
37
37
  *
38
- * Scoped to the self-host SQL backends: on AWS the pipeline (Bedrock +
38
+ * Scoped to the self-host SQL backend: on AWS the pipeline (Bedrock +
39
39
  * S3 Vectors) is bundled, so a module-resolution failure there is a broken
40
40
  * deploy and must keep failing loud. Falling back to the FTS engine here
41
41
  * instead was considered and rejected — it would fabricate relevance scores and
@@ -1,76 +0,0 @@
1
- import {
2
- AccountConfigRepo,
3
- AccountExportRequestRepo,
4
- AccountRepo,
5
- AccountSettingRepo,
6
- AddressRepo,
7
- DrizzleEnvelopeRepository,
8
- DrizzleFilterAnchorTransaction,
9
- DrizzleMessageFlagRepository,
10
- DrizzleMessageRepository,
11
- DrizzleThreadMessageRepository,
12
- DrizzleUnitOfWork,
13
- FilterAnchorRepo,
14
- FilterRepo,
15
- LabelRepo,
16
- MailboxLockRepo,
17
- MailboxRepo,
18
- MailboxSpecialUseRepo,
19
- MessageFlagPushRepo,
20
- MessageLabelRepo,
21
- MessagePlacementMoveRepo,
22
- messageDataSchema,
23
- OrganizeJobRequestRepo,
24
- OutboxMessageRepo,
25
- QuarantineRepo,
26
- } from "@remit/drizzle-service";
27
- import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
28
- import { env } from "expect-env";
29
- import {
30
- buildSharedDeps,
31
- createRemitClient,
32
- type RemitClient,
33
- type RemitClientRepositories,
34
- } from "./create-remit-client.js";
35
-
36
- export const buildPostgresClient = (): RemitClient => {
37
- const pgConnectionUrl = env.PG_CONNECTION_URL;
38
-
39
- // One drizzle db instance shared across repos.
40
- // The schema is registered for message-data tables; i4 repos use the same
41
- // underlying connection and only need the builder API (no relational queries).
42
- const db = drizzle(pgConnectionUrl, { schema: messageDataSchema });
43
- const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
44
-
45
- const messageDataDb = db as unknown as NodePgDatabase<
46
- typeof messageDataSchema
47
- >;
48
-
49
- const repositories: RemitClientRepositories = {
50
- accountConfig: new AccountConfigRepo(genericDb),
51
- account: new AccountRepo(genericDb),
52
- accountSetting: new AccountSettingRepo(genericDb),
53
- address: new AddressRepo(genericDb),
54
- mailbox: new MailboxRepo(genericDb),
55
- mailboxSpecialUse: new MailboxSpecialUseRepo(genericDb),
56
- mailboxLock: new MailboxLockRepo(genericDb),
57
- message: new DrizzleMessageRepository(messageDataDb),
58
- messageFlag: new DrizzleMessageFlagRepository(messageDataDb),
59
- outboxMessage: new OutboxMessageRepo(genericDb),
60
- threadMessage: new DrizzleThreadMessageRepository(pgConnectionUrl),
61
- envelope: new DrizzleEnvelopeRepository(messageDataDb),
62
- accountExportRequest: new AccountExportRequestRepo(genericDb),
63
- quarantine: new QuarantineRepo(genericDb),
64
- organizeJobRequest: new OrganizeJobRequestRepo(genericDb),
65
- placementMove: new MessagePlacementMoveRepo(genericDb),
66
- flagPush: new MessageFlagPushRepo(genericDb),
67
- filter: new FilterRepo(genericDb),
68
- filterAnchor: new FilterAnchorRepo(genericDb),
69
- filterAnchorTransaction: new DrizzleFilterAnchorTransaction(genericDb),
70
- label: new LabelRepo(genericDb),
71
- messageLabel: new MessageLabelRepo(genericDb),
72
- unitOfWork: new DrizzleUnitOfWork(messageDataDb),
73
- };
74
-
75
- return createRemitClient({ repositories, ...buildSharedDeps() });
76
- };
@@ -1,98 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, test } from "node:test";
3
- import {
4
- _resetForTest,
5
- getClient,
6
- type RemitClient,
7
- setClient,
8
- } from "./dynamodb.js";
9
-
10
- const REQUIRED_KEYS: ReadonlyArray<keyof RemitClient> = [
11
- "accountConfig",
12
- "account",
13
- "accountSetting",
14
- "address",
15
- "mailbox",
16
- "mailboxSpecialUse",
17
- "message",
18
- "messageFlag",
19
- "outboxMessage",
20
- "threadMessage",
21
- "envelope",
22
- "accountExportRequest",
23
- "quarantine",
24
- "storage",
25
- "search",
26
- "secrets",
27
- "bodySync",
28
- "flagQueue",
29
- "mailboxQueue",
30
- "messageMove",
31
- "outboxQueue",
32
- "createConnectionScope",
33
- ] as const;
34
-
35
- afterEach(() => {
36
- _resetForTest();
37
- });
38
-
39
- test("getClient() with DATA_BACKEND=postgres constructs all services without throwing", async () => {
40
- const savedBackend = process.env.DATA_BACKEND;
41
- const savedPgUrl = process.env.PG_CONNECTION_URL;
42
-
43
- process.env.DATA_BACKEND = "postgres";
44
- process.env.PG_CONNECTION_URL =
45
- "postgresql://remit:remit@localhost:5432/remit_test";
46
-
47
- try {
48
- const client = await getClient();
49
-
50
- for (const key of REQUIRED_KEYS) {
51
- assert.ok(
52
- client[key] !== undefined && client[key] !== null,
53
- `RemitClient.${key} must be defined on the postgres path`,
54
- );
55
- }
56
-
57
- assert.equal(typeof client.createConnectionScope, "function");
58
- } finally {
59
- _resetForTest();
60
- if (savedBackend === undefined) {
61
- delete process.env.DATA_BACKEND;
62
- } else {
63
- process.env.DATA_BACKEND = savedBackend;
64
- }
65
- if (savedPgUrl === undefined) {
66
- delete process.env.PG_CONNECTION_URL;
67
- } else {
68
- process.env.PG_CONNECTION_URL = savedPgUrl;
69
- }
70
- }
71
- });
72
-
73
- test("getClient() without DATA_BACKEND throws until a client is registered", () => {
74
- const savedBackend = process.env.DATA_BACKEND;
75
- delete process.env.DATA_BACKEND;
76
-
77
- try {
78
- assert.throws(() => getClient(), /no DynamoDB client registered/);
79
- } finally {
80
- if (savedBackend !== undefined) process.env.DATA_BACKEND = savedBackend;
81
- }
82
- });
83
-
84
- test("getClient() without DATA_BACKEND returns the injected client", async () => {
85
- const savedBackend = process.env.DATA_BACKEND;
86
- delete process.env.DATA_BACKEND;
87
-
88
- const injected = { account: {} } as unknown as RemitClient;
89
- setClient(injected);
90
-
91
- try {
92
- const client = await getClient();
93
- assert.equal(client, injected);
94
- } finally {
95
- _resetForTest();
96
- if (savedBackend !== undefined) process.env.DATA_BACKEND = savedBackend;
97
- }
98
- });
@@ -1,57 +0,0 @@
1
- import type { RemitClient } from "./create-remit-client.js";
2
-
3
- export type {
4
- ConnectionScope,
5
- RemitClient,
6
- } from "./create-remit-client.js";
7
-
8
- let clientPromise: Promise<RemitClient> | null = null;
9
- let injected: RemitClient | null = null;
10
-
11
- /**
12
- * Register the DynamoDB-backed client from the composition root. The DynamoDB
13
- * composition lives outside this shared, open-core module and is never imported
14
- * here. Every DynamoDB entry point calls this before handling a request. The
15
- * relational backends compose in-package below and never touch this seam.
16
- */
17
- export const setClient = (client: RemitClient): void => {
18
- injected = client;
19
- clientPromise = null;
20
- };
21
-
22
- // The API process and the imap-worker share this composition root. The
23
- // relational backends are loaded through a lazy `import()` of their own
24
- // in-package composition module, so the module a given deploy never runs never
25
- // enters its graph: `@remit/drizzle-service`/`drizzle-orm` stay out of a
26
- // Lambda bundle (both `external` for the Lambda esbuild build;
27
- // `remit-lambda-bundles.test.ts` enforces this). The DynamoDB backend is
28
- // injected by the composition root through `setClient`, so this module carries
29
- // no import of the DynamoDB composition.
30
- export const getClient = (): Promise<RemitClient> => {
31
- if (!clientPromise) {
32
- if (process.env.DATA_BACKEND === "postgres") {
33
- clientPromise = import("./compose-postgres.js").then((m) =>
34
- m.buildPostgresClient(),
35
- );
36
- } else if (process.env.DATA_BACKEND === "sqlite") {
37
- clientPromise = import("./compose-sqlite.js").then((m) =>
38
- m.buildSqliteClient(),
39
- );
40
- } else {
41
- if (!injected) {
42
- throw new Error(
43
- "no DynamoDB client registered — register one with setClient() from your composition root",
44
- );
45
- }
46
- clientPromise = Promise.resolve(injected);
47
- }
48
- }
49
-
50
- return clientPromise;
51
- };
52
-
53
- /** Reset the singleton and any injected client — test use only. */
54
- export const _resetForTest = (): void => {
55
- clientPromise = null;
56
- injected = null;
57
- };