@remit/backend 0.0.51 → 0.0.53

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 (43) 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 +18 -4
  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.ts +1 -1
  25. package/src/handlers/outbox.ts +1 -1
  26. package/src/handlers/search.ts +1 -1
  27. package/src/handlers/sync.ts +1 -1
  28. package/src/handlers/thread.ts +1 -1
  29. package/src/handlers/unified-threads.ts +1 -1
  30. package/src/index.ts +2 -2
  31. package/src/jwt-auth.test.ts +6 -6
  32. package/src/jwt-auth.ts +2 -2
  33. package/src/service/compose-sqlite.ts +8 -14
  34. package/src/service/create-remit-client.ts +6 -6
  35. package/src/service/data-client.test.ts +96 -0
  36. package/src/service/data-client.ts +58 -0
  37. package/src/service/organize.test.ts +1 -1
  38. package/src/service/organize.ts +1 -1
  39. package/src/service/semantic-capability.test.ts +10 -16
  40. package/src/service/semantic-capability.ts +1 -1
  41. package/src/service/compose-postgres.ts +0 -76
  42. package/src/service/dynamodb.test.ts +0 -98
  43. package/src/service/dynamodb.ts +0 -57
@@ -8,7 +8,7 @@ const PATH = "accounts/cfg-alice/acc-alice/messages/msg-1/parts/1.2";
8
8
  const now = () => Math.floor(Date.now() / 1000);
9
9
 
10
10
  describe("authorizeContentRequest", () => {
11
- it("is a no-op outside Postgres mode (AWS serves unsigned URLs)", () => {
11
+ it("is a no-op on the AWS path (unsigned URLs)", () => {
12
12
  const result = authorizeContentRequest({
13
13
  dataBackend: undefined,
14
14
  secret: undefined,
@@ -20,10 +20,10 @@ describe("authorizeContentRequest", () => {
20
20
  assert.deepEqual(result, { authorized: true });
21
21
  });
22
22
 
23
- it("authorizes a validly signed request in Postgres mode", () => {
23
+ it("authorizes a validly signed request on the self-host stack", () => {
24
24
  const { exp, sig } = createContentSigner(SECRET)(PATH);
25
25
  const result = authorizeContentRequest({
26
- dataBackend: "postgres",
26
+ dataBackend: "sqlite",
27
27
  secret: SECRET,
28
28
  relativePath: PATH,
29
29
  exp: String(exp),
@@ -35,7 +35,7 @@ describe("authorizeContentRequest", () => {
35
35
 
36
36
  it("returns 401 when the signature is absent", () => {
37
37
  const result = authorizeContentRequest({
38
- dataBackend: "postgres",
38
+ dataBackend: "sqlite",
39
39
  secret: SECRET,
40
40
  relativePath: PATH,
41
41
  exp: undefined,
@@ -49,7 +49,7 @@ describe("authorizeContentRequest", () => {
49
49
  it("returns 403 for a signature minted for another account's path", () => {
50
50
  const { exp, sig } = createContentSigner(SECRET)(PATH);
51
51
  const result = authorizeContentRequest({
52
- dataBackend: "postgres",
52
+ dataBackend: "sqlite",
53
53
  secret: SECRET,
54
54
  relativePath: "accounts/cfg-bob/acc-bob/messages/msg-9/parts/1.2",
55
55
  exp: String(exp),
@@ -63,7 +63,7 @@ describe("authorizeContentRequest", () => {
63
63
  it("returns 403 for an expired signature", () => {
64
64
  const { exp, sig } = createContentSigner(SECRET, -20)(PATH);
65
65
  const result = authorizeContentRequest({
66
- dataBackend: "postgres",
66
+ dataBackend: "sqlite",
67
67
  secret: SECRET,
68
68
  relativePath: PATH,
69
69
  exp: String(exp),
@@ -74,9 +74,9 @@ describe("authorizeContentRequest", () => {
74
74
  assert.equal(result.authorized === false && result.status, 403);
75
75
  });
76
76
 
77
- it("fails closed with 500 in Postgres mode when no signing secret is configured", () => {
77
+ it("fails closed with 500 when no signing secret is configured", () => {
78
78
  const result = authorizeContentRequest({
79
- dataBackend: "postgres",
79
+ dataBackend: "sqlite",
80
80
  secret: undefined,
81
81
  relativePath: PATH,
82
82
  exp: "123",
@@ -87,7 +87,7 @@ describe("authorizeContentRequest", () => {
87
87
  assert.equal(result.authorized === false && result.status, 500);
88
88
  });
89
89
 
90
- it("enforces signatures in SQLite mode the same as Postgres", () => {
90
+ it("enforces signatures on every self-host request", () => {
91
91
  const { exp, sig } = createContentSigner(SECRET)(PATH);
92
92
  const valid = authorizeContentRequest({
93
93
  dataBackend: "sqlite",
@@ -2,7 +2,7 @@ import { verifyContentSignature } from "../src/derive/contentSignature.js";
2
2
 
3
3
  /**
4
4
  * Decide whether a `/content` request is authorized. Enforcement applies on the
5
- * self-host SQL backends (postgres and sqlite), where this server is the
5
+ * self-host SQL backend (sqlite), where this server is the
6
6
  * deployed backend container and content URLs are signed. On AWS-local dev
7
7
  * (`DATA_BACKEND` unset) `/content` is served straight from the filesystem
8
8
  * stand-in for CloudFront and URLs are unsigned, so the check is a no-op.
@@ -25,7 +25,7 @@ export const authorizeContentRequest = (
25
25
  ):
26
26
  | { authorized: true }
27
27
  | { authorized: false; status: number; reason: string } => {
28
- if (input.dataBackend !== "postgres" && input.dataBackend !== "sqlite") {
28
+ if (input.dataBackend !== "sqlite") {
29
29
  return { authorized: true };
30
30
  }
31
31
 
@@ -80,12 +80,4 @@ describe("checkRelationalStore", () => {
80
80
  chmodSync(dbPath, 0o400);
81
81
  assert.equal(await checkRelationalStore(), false);
82
82
  });
83
-
84
- it("is false when Postgres connection fails", async () => {
85
- process.env.DATA_BACKEND = "postgres";
86
- process.env.PG_CONNECTION_URL =
87
- "postgresql://remit:remit@127.0.0.1:1/nonexistent";
88
-
89
- assert.equal(await checkRelationalStore(), false);
90
- });
91
83
  });
@@ -21,23 +21,9 @@ const pingSqlite = async (): Promise<void> => {
21
21
  }
22
22
  };
23
23
 
24
- const pingPostgres = async (): Promise<void> => {
25
- const { Client } = await import("pg");
26
- const client = new Client({
27
- connectionString: process.env.PG_CONNECTION_URL,
28
- connectionTimeoutMillis: 3000,
29
- });
30
- await client.connect();
31
- try {
32
- await client.query("SELECT 1");
33
- } finally {
34
- await client.end();
35
- }
36
- };
37
-
38
24
  /**
39
- * A trivial read against whichever relational store DATA_BACKEND selects the
40
- * dependency `/health` must actually exercise (RFC 037 D5, R9) so a backend that
25
+ * A trivial read against the relational store the dependency `/health` must
26
+ * actually exercise (RFC 037 D5, R9) so a backend that
41
27
  * booted with an unusable database reports unhealthy instead of a bare 200. The
42
28
  * AWS/DynamoDB path has no relational store and is never checked.
43
29
  */
@@ -45,11 +31,7 @@ export const checkRelationalStore = async (): Promise<boolean> => {
45
31
  if (!isSelfHostSqlBackend()) return true;
46
32
 
47
33
  try {
48
- if (process.env.DATA_BACKEND === "sqlite") {
49
- await pingSqlite();
50
- } else {
51
- await pingPostgres();
52
- }
34
+ await pingSqlite();
53
35
  return true;
54
36
  } catch {
55
37
  return false;
@@ -17,7 +17,7 @@ import express, {
17
17
  } from "express";
18
18
  import { handler, OpenAPISpec } from "../src/index.js";
19
19
  import { safeJsonParse } from "../src/json.js";
20
- import { getClient } from "../src/service/dynamodb.js";
20
+ import { getClient } from "../src/service/data-client.js";
21
21
  import { authorizeContentRequest } from "./content-auth.js";
22
22
  import { serveContent } from "./content-handler.js";
23
23
  import { resolveContentPath } from "./content-path.js";
@@ -28,15 +28,13 @@ import { collectAccountSyncAges } from "./sync-age.js";
28
28
 
29
29
  const app = express();
30
30
 
31
- // The self-host relational backends Postgres and, from RFC 036, SQLite — both
32
- // run better-auth and the APISIX edge; the AWS-local (DynamoDB) dev path runs
33
- // neither. Everything gated on "not the AWS-local path" keys off this.
34
- const isSelfHostBackend =
35
- process.env.DATA_BACKEND === "postgres" ||
36
- process.env.DATA_BACKEND === "sqlite";
31
+ // The self-host relational backend runs better-auth and the APISIX edge; the
32
+ // AWS-local (DynamoDB) dev path runs neither. Everything gated on "not the
33
+ // AWS-local path" keys off this.
34
+ const isSelfHostBackend = process.env.DATA_BACKEND === "sqlite";
37
35
 
38
36
  // CORS is driven by CORS_ALLOWED_ORIGINS (comma-separated, or `*`). On the
39
- // self-host backends it is required config — refuse to start if unset, so the
37
+ // self-host backend it is required config — refuse to start if unset, so the
40
38
  // deployed edge is never accidentally wide open by omission. On the AWS-local
41
39
  // dev path it defaults to `*` to keep the existing local flow working.
42
40
  const configuredCorsOrigins = parseAllowedOrigins(
@@ -218,7 +216,7 @@ const STORAGE_BASE = resolve(
218
216
  app.get(/^\/content\/.+$/, async (req: Request, res: Response) => {
219
217
  const storageKey = req.path.replace(/^\/content\//, "");
220
218
 
221
- // On the Postgres stack this route is the deployed content-delivery surface;
219
+ // On the self-host stack this route is the deployed content-delivery surface;
222
220
  // require a valid signed URL (HMAC + expiry, scoped to the owning account)
223
221
  // before touching storage. Bearer auth can't ride on an `<img src>` content
224
222
  // load, so the signature carried in the query string is the authorization.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.51",
3
+ "version": "0.0.53",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -12,8 +12,8 @@
12
12
  "default": "./src/index.ts"
13
13
  },
14
14
  "./client": {
15
- "types": "./src/service/dynamodb.ts",
16
- "default": "./src/service/dynamodb.ts"
15
+ "types": "./src/service/data-client.ts",
16
+ "default": "./src/service/data-client.ts"
17
17
  },
18
18
  "./create-remit-client": {
19
19
  "types": "./src/service/create-remit-client.ts",
@@ -73,14 +73,11 @@
73
73
  "@aws-sdk/client-secrets-manager": "*",
74
74
  "@aws-sdk/client-sqs": "*",
75
75
  "better-sqlite3": "^12.11.1",
76
- "drizzle-orm": "^0.45.2",
77
76
  "expect-env": "*",
78
77
  "openapi-backend": "*",
79
78
  "p-map": "*",
80
- "pg": "^8.0.0",
81
79
  "@types/aws-lambda": "*",
82
- "@types/better-sqlite3": "^7.6.13",
83
- "@types/pg": "^8.0.0"
80
+ "@types/better-sqlite3": "^7.6.13"
84
81
  },
85
82
  "publishConfig": {
86
83
  "access": "public"
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
4
  import { logger } from "@remit/logger-lambda";
5
5
  import {
@@ -7,7 +7,7 @@ import {
7
7
  type ListIdBackfillCheckpoint,
8
8
  type ListIdBackfillCheckpointStore,
9
9
  } from "@remit/mailbox-service";
10
- import { getClient } from "../src/service/dynamodb.js";
10
+ import { getClient } from "../src/service/data-client.js";
11
11
 
12
12
  /**
13
13
  * One-time, full-corpus `ThreadMessage.listId` backfill (issue #263). Ships as
@@ -29,15 +29,29 @@ const CHECKPOINT_PATH =
29
29
  process.env.LIST_ID_BACKFILL_CHECKPOINT_PATH ??
30
30
  "/data/sqlite/list-id-backfill-checkpoint.json";
31
31
 
32
+ const isCheckpoint = (value: unknown): value is ListIdBackfillCheckpoint => {
33
+ if (typeof value !== "object" || value === null) return false;
34
+ if (!("accountConfigId" in value)) return false;
35
+ const candidate = value as Record<string, unknown>;
36
+ return (
37
+ typeof candidate.accountConfigId === "string" &&
38
+ (candidate.continuationToken === undefined ||
39
+ typeof candidate.continuationToken === "string")
40
+ );
41
+ };
42
+
32
43
  const fileCheckpointStore: ListIdBackfillCheckpointStore = {
33
44
  load: async () => {
34
45
  if (!existsSync(CHECKPOINT_PATH)) return undefined;
35
46
  const raw = await readFile(CHECKPOINT_PATH, "utf8");
36
- return JSON.parse(raw) as ListIdBackfillCheckpoint;
47
+ const parsed: unknown = JSON.parse(raw);
48
+ return isCheckpoint(parsed) ? parsed : undefined;
37
49
  },
38
50
  save: async (checkpoint) => {
39
51
  await mkdir(dirname(CHECKPOINT_PATH), { recursive: true });
40
- await writeFile(CHECKPOINT_PATH, JSON.stringify(checkpoint));
52
+ const staging = `${CHECKPOINT_PATH}.writing`;
53
+ await writeFile(staging, JSON.stringify(checkpoint));
54
+ await rename(staging, CHECKPOINT_PATH);
41
55
  },
42
56
  clear: async () => {
43
57
  await rm(CHECKPOINT_PATH, { force: true });
@@ -9,9 +9,7 @@ describe("usesBetterAuthJwt", () => {
9
9
  else process.env.DATA_BACKEND = ORIGINAL;
10
10
  });
11
11
 
12
- it("is true for the self-host SQL backends", () => {
13
- process.env.DATA_BACKEND = "postgres";
14
- assert.equal(usesBetterAuthJwt(), true);
12
+ it("is true for the self-host SQL backend", () => {
15
13
  process.env.DATA_BACKEND = "sqlite";
16
14
  assert.equal(usesBetterAuthJwt(), true);
17
15
  });
@@ -1,20 +1,14 @@
1
1
  /**
2
- * The two self-host SQL backends (RFC 034/035/036), as opposed to the AWS
3
- * DynamoDB path.
2
+ * The self-host SQL backend (RFC 034/035/036), as opposed to the AWS DynamoDB
3
+ * path.
4
4
  */
5
- export const isSelfHostSqlBackend = (): boolean => {
6
- const backend = process.env.DATA_BACKEND;
7
- return backend === "postgres" || backend === "sqlite";
8
- };
5
+ export const isSelfHostSqlBackend = (): boolean =>
6
+ process.env.DATA_BACKEND === "sqlite";
9
7
 
10
8
  /**
11
- * The self-host SQL backends authenticate requests and sign content URLs the
12
- * same way: a better-auth RS256 JWT verified at the edge and re-verified
13
- * in-process, no Cognito authorizer. DynamoDB is the AWS path (Cognito claims,
14
- * Lambda@Edge content guard) and is deliberately excluded.
15
- *
16
- * Guarding these paths on `=== "postgres"` alone left the sqlite deployment
17
- * with no claim injection (every identity-bound request 500s) and unsigned
18
- * `/content/*` URLs; both branch on this predicate instead.
9
+ * The self-host SQL backend authenticates requests and signs content URLs with
10
+ * a better-auth RS256 JWT verified at the edge and re-verified in-process, no
11
+ * Cognito authorizer. DynamoDB is the AWS path (Cognito claims, Lambda@Edge
12
+ * content guard) and is deliberately excluded.
19
13
  */
20
14
  export const usesBetterAuthJwt = (): boolean => isSelfHostSqlBackend();
@@ -110,29 +110,11 @@ describe("getContentSigner", () => {
110
110
  else process.env.BETTER_AUTH_SECRET = ORIGINAL_SECRET;
111
111
  });
112
112
 
113
- it("returns undefined outside Postgres mode (AWS keeps unsigned URLs)", () => {
113
+ it("returns undefined on the AWS path (unsigned URLs)", () => {
114
114
  delete process.env.DATA_BACKEND;
115
115
  assert.equal(getContentSigner(), undefined);
116
116
  });
117
117
 
118
- it("returns a working signer in Postgres mode", () => {
119
- process.env.DATA_BACKEND = "postgres";
120
- process.env.BETTER_AUTH_SECRET = SECRET;
121
- const signer = getContentSigner();
122
- assert.ok(signer);
123
- const { exp, sig } = signer(PATH_A);
124
- assert.deepEqual(
125
- verifyContentSignature(PATH_A, String(exp), sig, SECRET, nowSeconds()),
126
- { valid: true },
127
- );
128
- });
129
-
130
- it("throws in Postgres mode when the master secret is missing (fail loud)", () => {
131
- process.env.DATA_BACKEND = "postgres";
132
- delete process.env.BETTER_AUTH_SECRET;
133
- assert.throws(() => getContentSigner(), /BETTER_AUTH_SECRET/);
134
- });
135
-
136
118
  it("returns a working signer in SQLite mode", () => {
137
119
  process.env.DATA_BACKEND = "sqlite";
138
120
  process.env.BETTER_AUTH_SECRET = SECRET;
@@ -2,10 +2,10 @@ import { createHmac, timingSafeEqual } from "node:crypto";
2
2
  import { usesBetterAuthJwt } from "../data-backend.js";
3
3
 
4
4
  /**
5
- * Signed-URL scheme for `/content/*` on the Postgres stack.
5
+ * Signed-URL scheme for `/content/*` on the self-host stack.
6
6
  *
7
7
  * On AWS the same bytes are guarded by CloudFront + a Lambda@Edge JWT verifier.
8
- * The Postgres stack serves `/content/*` straight from the backend container,
8
+ * The self-host stack serves `/content/*` straight from the backend container,
9
9
  * and a bearer token cannot ride along on an `<img src>` / `<a href>` content
10
10
  * load rendered inside email HTML. So the backend signs each content URL when
11
11
  * it hands the SPA a `BodyPartResponse.contentUrl`, and the `/content` route
@@ -37,7 +37,7 @@ export const CONTENT_URL_TTL_SECONDS = 3600;
37
37
  * separation via a fixed label keeps the content-signing key distinct from the
38
38
  * raw better-auth secret, so a compromise of one signing space does not reveal
39
39
  * the other. Reusing `BETTER_AUTH_SECRET` as the master means no new secret has
40
- * to be provisioned on the Postgres stack — it is already required there.
40
+ * to be provisioned on the self-host stack — it is already required there.
41
41
  */
42
42
  const deriveSigningKey = (masterSecret: string): Buffer =>
43
43
  createHmac("sha256", masterSecret).update(KEY_DERIVATION_LABEL).digest();
@@ -78,19 +78,19 @@ export const createContentSigner = (
78
78
 
79
79
  /**
80
80
  * Build the content-URL signer for the current environment, or `undefined` when
81
- * signing does not apply. Signing is enforced on the self-host SQL backends
82
- * (postgres and sqlite), which serve `/content/*` straight from the backend
83
- * container; on AWS the Lambda@Edge JWT verifier guards `/content/*` and the URL
84
- * stays unsigned so CloudFront/S3 behaviour is unchanged. Throws on those
85
- * backends when the master secret is missing, so a misconfigured deploy fails
86
- * loud rather than shipping unsigned (unauthenticated) content URLs.
81
+ * signing does not apply. Signing is enforced on the self-host SQL backend,
82
+ * which serves `/content/*` straight from the backend container; on AWS the
83
+ * Lambda@Edge JWT verifier guards `/content/*` and the URL stays unsigned so
84
+ * CloudFront/S3 behaviour is unchanged. Throws on the self-host backend when the
85
+ * master secret is missing, so a misconfigured deploy fails loud rather than
86
+ * shipping unsigned (unauthenticated) content URLs.
87
87
  */
88
88
  export const getContentSigner = (): ContentSigner | undefined => {
89
89
  if (!usesBetterAuthJwt()) return undefined;
90
90
  const secret = process.env.BETTER_AUTH_SECRET;
91
91
  if (!secret || secret.length === 0) {
92
92
  throw new Error(
93
- "a self-host SQL backend (postgres/sqlite) requires BETTER_AUTH_SECRET to sign content URLs",
93
+ "the self-host SQL backend requires BETTER_AUTH_SECRET to sign content URLs",
94
94
  );
95
95
  }
96
96
  return createContentSigner(secret);
@@ -19,7 +19,7 @@ export interface BuildContentUrlInput {
19
19
  messageId: string;
20
20
  partPath: string;
21
21
  /**
22
- * When present (Postgres stack), append an `exp`/`sig` signature scoped to
22
+ * When present (self-host stack), append an `exp`/`sig` signature scoped to
23
23
  * this account's storage path. Absent on AWS, where Lambda@Edge guards the
24
24
  * URL and the path stays unsigned. See `contentSignature.ts`.
25
25
  */
@@ -16,7 +16,7 @@ import type { Context } from "openapi-backend";
16
16
  import { getAccountConfigIdFromEvent } from "../auth.js";
17
17
  import { getMsOAuthConfig } from "../config/msoauth.js";
18
18
  import { safeJsonParse } from "../json.js";
19
- import { getClient } from "../service/dynamodb.js";
19
+ import { getClient } from "../service/data-client.js";
20
20
  import type { MicrosoftOAuthOperationIds, OperationHandler } from "../types.js";
21
21
  import { triggerAccountSyncSafe } from "./account.js";
22
22
  import { findActiveDuplicateMailbox } from "./account-guards.js";
@@ -29,7 +29,7 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
29
29
  import { env } from "expect-env";
30
30
  import type { Context } from "openapi-backend";
31
31
  import { getAccountConfigIdFromEvent } from "../auth.js";
32
- import { getClient } from "../service/dynamodb.js";
32
+ import { getClient } from "../service/data-client.js";
33
33
  import { fireAndForget } from "../service/fire-and-forget.js";
34
34
  import { sqsClient } from "../service/sqs.js";
35
35
  import { triggerAccountSync } from "../service/trigger-sync.js";
@@ -7,7 +7,7 @@ import { ForbiddenError } from "@remit/data-ports/errors";
7
7
  import type { APIGatewayProxyEvent } from "aws-lambda";
8
8
  import type { Context } from "openapi-backend";
9
9
  import { getAccountConfigIdFromEvent } from "../auth.js";
10
- import { getClient } from "../service/dynamodb.js";
10
+ import { getClient } from "../service/data-client.js";
11
11
  import type {
12
12
  AddressDetailOperationIds,
13
13
  AddressOperationIds,
@@ -10,7 +10,7 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
10
10
  import { env } from "expect-env";
11
11
  import type { Context } from "openapi-backend";
12
12
  import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
13
- import { getClient } from "../service/dynamodb.js";
13
+ import { getClient } from "../service/data-client.js";
14
14
  import { fireAndForget } from "../service/fire-and-forget.js";
15
15
  import { sqsClient } from "../service/sqs.js";
16
16
  import { triggerAccountSync } from "../service/trigger-sync.js";
@@ -13,7 +13,7 @@ import { FilterScope, FilterState } from "@remit/domain-enums";
13
13
  import type { AnchorPayload } from "@remit/search-service";
14
14
  import type { APIGatewayProxyEvent } from "aws-lambda";
15
15
  import { getAccountConfigIdFromEvent } from "../auth.js";
16
- import { getClient } from "../service/dynamodb.js";
16
+ import { getClient } from "../service/data-client.js";
17
17
  import { buildFilterAnchor } from "../service/filter.js";
18
18
  import type {
19
19
  FilterDetailOperationIds,
@@ -4,7 +4,7 @@ import type {
4
4
  } from "@remit/api-openapi-types";
5
5
  import type { APIGatewayProxyEvent } from "aws-lambda";
6
6
  import { getAccountConfigIdFromEvent } from "../auth.js";
7
- import { getClient } from "../service/dynamodb.js";
7
+ import { getClient } from "../service/data-client.js";
8
8
  import type { FolderRoleOperationIds, OperationHandler } from "../types.js";
9
9
  import { toAccountResponse } from "./account-guards.js";
10
10
  import { loadAccountOverrides } from "./account-overrides.js";
@@ -11,7 +11,7 @@ import type {
11
11
  } from "@remit/data-ports";
12
12
  import type { APIGatewayProxyEvent } from "aws-lambda";
13
13
  import { getAccountConfigIdFromEvent } from "../auth.js";
14
- import { getClient } from "../service/dynamodb.js";
14
+ import { getClient } from "../service/data-client.js";
15
15
  import type {
16
16
  LabelDetailOperationIds,
17
17
  LabelOperationIds,
@@ -11,7 +11,7 @@ import {
11
11
  applyPendingMoveCountPrediction,
12
12
  type PendingUnseenFlagPush,
13
13
  } from "../derive/pendingMoveCounts.js";
14
- import { getClient } from "../service/dynamodb.js";
14
+ import { getClient } from "../service/data-client.js";
15
15
  import type {
16
16
  MailboxDetailOperationIds,
17
17
  MailboxOperationIds,
@@ -139,7 +139,7 @@ export const assertMailboxInAccount = (
139
139
  * `applyPendingMoveCountPrediction`'s read-time adjustment only, never mutates
140
140
  * stored counts (epic #1281 invariant 4). Markers are written by the
141
141
  * imap-worker bulk sync path through `RemitClient.placementMove` on every
142
- * backend, so this is a real signal on Postgres too.
142
+ * backend, so this is a real signal on the self-host stack too.
143
143
  */
144
144
  const loadPendingMoves = (
145
145
  client: Awaited<ReturnType<typeof getClient>>,
@@ -150,7 +150,7 @@ const loadPendingMoves = (
150
150
  * Every pending `\Seen` flag-push marker (issue #1273) for an account —
151
151
  * `\Flagged` (star) markers are excluded, since only read/unread state feeds
152
152
  * `unseenCount`'s prediction. `RemitClient.flagPush` is present and WRITTEN
153
- * on both backends, so this is a real signal on Postgres too.
153
+ * on both backends, so this is a real signal on the self-host stack too.
154
154
  */
155
155
  const loadPendingUnseenFlagPushes = async (
156
156
  client: Awaited<ReturnType<typeof getClient>>,
@@ -8,7 +8,7 @@ import {
8
8
  _resetForTest,
9
9
  type RemitClient,
10
10
  setClient,
11
- } from "../service/dynamodb.js";
11
+ } from "../service/data-client.js";
12
12
  import { MeOperations } from "./me.js";
13
13
 
14
14
  const listQuarantine = MeOperations.MeOperations_listQuarantine as unknown as (
@@ -13,7 +13,7 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
13
13
  import { env } from "expect-env";
14
14
  import type { Context } from "openapi-backend";
15
15
  import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
16
- import { getClient } from "../service/dynamodb.js";
16
+ import { getClient } from "../service/data-client.js";
17
17
  import { sqsClient } from "../service/sqs.js";
18
18
  import type { MeOperationIds, OperationHandler } from "../types.js";
19
19
  import { toVipSuggestionEntry } from "./vip-suggestions.js";
@@ -41,7 +41,7 @@ import {
41
41
  getContentDeliveryDomain,
42
42
  } from "../derive/contentUrl.js";
43
43
  import { deriveSenderTrust } from "../derive/senderTrust.js";
44
- import { getClient } from "../service/dynamodb.js";
44
+ import { getClient } from "../service/data-client.js";
45
45
  import type {
46
46
  MessageBulkOperationIds,
47
47
  MessageOperationIds,
@@ -11,7 +11,7 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
11
11
  import { env } from "expect-env";
12
12
  import type { Context } from "openapi-backend";
13
13
  import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
14
- import { getClient, type RemitClient } from "../service/dynamodb.js";
14
+ import { getClient, type RemitClient } from "../service/data-client.js";
15
15
  import {
16
16
  buildOrganizeMatchDeps,
17
17
  matchOrganize,
@@ -8,7 +8,7 @@ import { ForbiddenError } from "@remit/data-ports/errors";
8
8
  import type { APIGatewayProxyEvent } from "aws-lambda";
9
9
  import type { Context } from "openapi-backend";
10
10
  import { getAccountConfigIdFromEvent } from "../auth.js";
11
- import { getClient } from "../service/dynamodb.js";
11
+ import { getClient } from "../service/data-client.js";
12
12
  import type {
13
13
  OperationHandler,
14
14
  OutboxDetailOperationIds,
@@ -6,7 +6,7 @@ import type { SearchResult } from "@remit/search-service";
6
6
  import type { APIGatewayProxyEvent } from "aws-lambda";
7
7
  import type { Context } from "openapi-backend";
8
8
  import { getAccountConfigIdFromEvent } from "../auth.js";
9
- import { getClient } from "../service/dynamodb.js";
9
+ import { getClient } from "../service/data-client.js";
10
10
  import {
11
11
  isSemanticSearchUnavailable,
12
12
  noteSemanticCapabilityAbsence,
@@ -3,7 +3,7 @@ import { logger } from "@remit/logger-lambda";
3
3
  import type { APIGatewayProxyEvent } from "aws-lambda";
4
4
  import { env } from "expect-env";
5
5
  import { getAccountConfigIdFromEvent } from "../auth.js";
6
- import { getClient } from "../service/dynamodb.js";
6
+ import { getClient } from "../service/data-client.js";
7
7
  import {
8
8
  type FireAndForgetLogger,
9
9
  fireAndForget,
@@ -17,7 +17,7 @@ import {
17
17
  filterByOffRowCriteria,
18
18
  hasOffRowCriteria,
19
19
  } from "../derive/filterThreadCriteria.js";
20
- import { getClient } from "../service/dynamodb.js";
20
+ import { getClient } from "../service/data-client.js";
21
21
  import type {
22
22
  OperationHandler,
23
23
  ThreadDetailOperationIds,
@@ -9,7 +9,7 @@ import type { Context } from "openapi-backend";
9
9
  import pMap from "p-map";
10
10
  import { getAccountConfigIdFromEvent } from "../auth.js";
11
11
  import { enrichThreadRows } from "../derive/enrichThreadRows.js";
12
- import { getClient } from "../service/dynamodb.js";
12
+ import { getClient } from "../service/data-client.js";
13
13
  import type { OperationHandler, UnifiedThreadOperationIds } from "../types.js";
14
14
  import {
15
15
  groupAccountOverrides,
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ import { assertLocalBypassNotInDeployedEnv } from "./auth.js";
14
14
  import { usesBetterAuthJwt } from "./data-backend.js";
15
15
  import { handleError } from "./error.js";
16
16
  import { handlers } from "./handlers/index.js";
17
- import { authenticatePostgresRequest } from "./jwt-auth.js";
17
+ import { authenticateSelfHostRequest } from "./jwt-auth.js";
18
18
  import { normalizeRequest } from "./request.js";
19
19
  import { runWithRequestContext } from "./request-context.js";
20
20
  import { formatResponse, postResponseHandler } from "./response.js";
@@ -161,7 +161,7 @@ const rawHandler = async (event: APIGatewayProxyEvent, context: Context) =>
161
161
  const origin = readOriginHeader(event.headers);
162
162
 
163
163
  if (usesBetterAuthJwt()) {
164
- const denied = await authenticatePostgresRequest(event);
164
+ const denied = await authenticateSelfHostRequest(event);
165
165
  if (denied) return denied;
166
166
  }
167
167
 
@@ -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
+ };
@@ -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,
@@ -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
  /**
@@ -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
- };