@remit/backend 0.0.16 → 0.0.18

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.
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, describe, it } from "node:test";
6
+ import Database from "better-sqlite3";
7
+ import { checkRelationalStore } from "./relational-health.js";
8
+
9
+ describe("checkRelationalStore", () => {
10
+ const ORIGINAL_DATA_BACKEND = process.env.DATA_BACKEND;
11
+ const ORIGINAL_SQLITE_DB_PATH = process.env.SQLITE_DB_PATH;
12
+ let dir: string | undefined;
13
+
14
+ afterEach(() => {
15
+ if (ORIGINAL_DATA_BACKEND === undefined) delete process.env.DATA_BACKEND;
16
+ else process.env.DATA_BACKEND = ORIGINAL_DATA_BACKEND;
17
+ if (ORIGINAL_SQLITE_DB_PATH === undefined) {
18
+ delete process.env.SQLITE_DB_PATH;
19
+ } else {
20
+ process.env.SQLITE_DB_PATH = ORIGINAL_SQLITE_DB_PATH;
21
+ }
22
+ if (dir) {
23
+ rmSync(dir, { recursive: true, force: true });
24
+ dir = undefined;
25
+ }
26
+ });
27
+
28
+ it("is true without touching disk on the AWS/DynamoDB path", async () => {
29
+ delete process.env.DATA_BACKEND;
30
+ delete process.env.SQLITE_DB_PATH;
31
+ assert.equal(await checkRelationalStore(), true);
32
+
33
+ process.env.DATA_BACKEND = "dynamodb";
34
+ assert.equal(await checkRelationalStore(), true);
35
+ });
36
+
37
+ it("is true when the SQLite file is reachable", async () => {
38
+ dir = mkdtempSync(join(tmpdir(), "remit-health-"));
39
+ const dbPath = join(dir, "remit.db");
40
+ new Database(dbPath).close();
41
+
42
+ process.env.DATA_BACKEND = "sqlite";
43
+ process.env.SQLITE_DB_PATH = dbPath;
44
+
45
+ assert.equal(await checkRelationalStore(), true);
46
+ });
47
+
48
+ it("is false when SQLITE_DB_PATH points at a directory that does not exist", async () => {
49
+ process.env.DATA_BACKEND = "sqlite";
50
+ process.env.SQLITE_DB_PATH = "/nonexistent/remit.db";
51
+
52
+ assert.equal(await checkRelationalStore(), false);
53
+ });
54
+
55
+ it("is false when the SQLite file exists but is unreadable", async () => {
56
+ dir = mkdtempSync(join(tmpdir(), "remit-health-"));
57
+ const dbPath = join(dir, "remit.db");
58
+ new Database(dbPath).close();
59
+ chmodSync(dbPath, 0o000);
60
+
61
+ process.env.DATA_BACKEND = "sqlite";
62
+ process.env.SQLITE_DB_PATH = dbPath;
63
+
64
+ assert.equal(await checkRelationalStore(), false);
65
+ });
66
+
67
+ it("is false when the file is readable but no longer writable", async () => {
68
+ dir = mkdtempSync(join(tmpdir(), "remit-health-"));
69
+ const dbPath = join(dir, "remit.db");
70
+ new Database(dbPath).close();
71
+
72
+ process.env.DATA_BACKEND = "sqlite";
73
+ process.env.SQLITE_DB_PATH = dbPath;
74
+ assert.equal(await checkRelationalStore(), true);
75
+
76
+ // Readable, not writable: the read-only SELECT ping still opens and
77
+ // succeeds, so without `access(W_OK)` this reports healthy on a store the
78
+ // backend can no longer write to. This is the case the writability check
79
+ // exists to catch — dropping `access()` from pingSqlite fails this test.
80
+ chmodSync(dbPath, 0o400);
81
+ assert.equal(await checkRelationalStore(), false);
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
+ });
@@ -0,0 +1,57 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import { isSelfHostSqlBackend } from "../src/data-backend.js";
3
+
4
+ // The SELECT ping opens a read-only connection, so it keeps succeeding on a
5
+ // file that can still be read but no longer written — permissions revoked to
6
+ // read-only, or the volume remounted read-only under the process. The backend
7
+ // must write (better-auth persists sessions to this same file, RFC 036 D3), so
8
+ // a readable-but-unwritable store has to report unhealthy. `access(R_OK|W_OK)`
9
+ // checks both bits in one fresh syscall; the read-only query never covers
10
+ // writability. The query after it still exercises the SQL engine itself.
11
+ const pingSqlite = async (): Promise<void> => {
12
+ const path = process.env.SQLITE_DB_PATH ?? "";
13
+ await access(path, constants.R_OK | constants.W_OK);
14
+
15
+ const { default: Database } = await import("better-sqlite3");
16
+ const db = new Database(path, { readonly: true });
17
+ try {
18
+ db.prepare("SELECT 1").get();
19
+ } finally {
20
+ db.close();
21
+ }
22
+ };
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
+ /**
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
41
+ * booted with an unusable database reports unhealthy instead of a bare 200. The
42
+ * AWS/DynamoDB path has no relational store and is never checked.
43
+ */
44
+ export const checkRelationalStore = async (): Promise<boolean> => {
45
+ if (!isSelfHostSqlBackend()) return true;
46
+
47
+ try {
48
+ if (process.env.DATA_BACKEND === "sqlite") {
49
+ await pingSqlite();
50
+ } else {
51
+ await pingPostgres();
52
+ }
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ };
@@ -16,6 +16,7 @@ import { serveContent } from "./content-handler.js";
16
16
  import { resolveContentPath } from "./content-path.js";
17
17
  import { parseAllowedOrigins, resolveAllowOrigin } from "./cors.js";
18
18
  import { createLambdaContext, createLambdaEvent } from "./lambda-helpers.js";
19
+ import { checkRelationalStore } from "./relational-health.js";
19
20
 
20
21
  const app = express();
21
22
 
@@ -123,7 +124,12 @@ app.use((req: Request, res: Response, next: NextFunction) => {
123
124
 
124
125
  app.get("/.well-known/appspecific/com.chrome.devtools.json", () => ({}));
125
126
 
126
- app.get("/health", (_req: Request, res: Response) => {
127
+ app.get("/health", async (_req: Request, res: Response) => {
128
+ const relationalStoreReachable = await checkRelationalStore();
129
+ if (!relationalStoreReachable) {
130
+ res.status(503).json({ status: "unhealthy" });
131
+ return;
132
+ }
127
133
  res.json({
128
134
  status: "ok",
129
135
  timestamp: new Date().toISOString(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -70,11 +70,15 @@
70
70
  "@remit/sqs-client": "*",
71
71
  "@aws-sdk/client-secrets-manager": "*",
72
72
  "@aws-sdk/client-sqs": "*",
73
+ "better-sqlite3": "^12.11.1",
73
74
  "drizzle-orm": "^0.45.2",
74
75
  "expect-env": "*",
75
76
  "openapi-backend": "*",
76
77
  "p-map": "*",
77
- "@types/aws-lambda": "*"
78
+ "pg": "^8.0.0",
79
+ "@types/aws-lambda": "*",
80
+ "@types/better-sqlite3": "^7.6.13",
81
+ "@types/pg": "^8.0.0"
78
82
  },
79
83
  "publishConfig": {
80
84
  "access": "public"
@@ -270,11 +270,12 @@ export const buildListThreadMessagesOptions = (query: {
270
270
  * A ThreadMessage row is keyed by (threadId, messageId), and a messageId is
271
271
  * derived from the account plus the RFC 5322 Message-ID, so the same mail
272
272
  * synced from two folders is one row — the mailbox a thread spans is not what
273
- * duplicates it. `MessageMoveService.copyMessage` is the exception: it mints a
274
- * random id for the copy, so a copied message becomes a second row in the same
275
- * thread carrying the same `messageIdHeader`. Reading the whole thread rather
276
- * than one mailbox of it (#46) is what makes both rows visible at once, and two
277
- * entries for one message is never what a conversation should show.
273
+ * duplicates it. `MessageMoveService.copyMessage` is the exception: a copy is a
274
+ * per-folder placement with its own derived id (`deriveCopyMessageId`), so a
275
+ * copied message is a second row in the same thread carrying the same
276
+ * `messageIdHeader`. Reading the whole thread rather than one mailbox of it
277
+ * (#46) is what makes both rows visible at once, and two entries for one message
278
+ * is never what a conversation should show.
278
279
  *
279
280
  * The oldest row wins, so the original outlives the copy, with `threadMessageId`
280
281
  * breaking ties. Both are independent of sort order, so `asc` and `desc` keep