@usequark/quark-core 2.5.4

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 (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. package/src/validation.js +26 -0
@@ -0,0 +1,284 @@
1
+ import assert from "node:assert";
2
+ import { mock, test } from "node:test";
3
+
4
+ // Module-level env control: restore after each test.
5
+ const ORIGINAL_ENV = { ...process.env };
6
+
7
+ test("db-instrumentation", async (t) => {
8
+ t.afterEach(() => {
9
+ process.env.DB_INSTRUMENTATION = ORIGINAL_ENV.DB_INSTRUMENTATION;
10
+ process.env.DB_SLOW_QUERY_THRESHOLD = ORIGINAL_ENV.DB_SLOW_QUERY_THRESHOLD;
11
+ });
12
+
13
+ await t.test("exports createDbInstrumentation", async () => {
14
+ const mod = await import("./db-instrumentation.js");
15
+ assert.equal(typeof mod.createDbInstrumentation, "function");
16
+ });
17
+
18
+ await t.test("exports maskSensitiveValues", async () => {
19
+ const mod = await import("./db-instrumentation.js");
20
+ assert.equal(typeof mod.maskSensitiveValues, "function");
21
+ });
22
+
23
+ await t.test("exports isSensitiveKey", async () => {
24
+ const mod = await import("./db-instrumentation.js");
25
+ assert.equal(typeof mod.isSensitiveKey, "function");
26
+ });
27
+
28
+ await t.test("exports summarizeArgs", async () => {
29
+ const mod = await import("./db-instrumentation.js");
30
+ assert.equal(typeof mod.summarizeArgs, "function");
31
+ });
32
+
33
+ await t.test("exports getSlowThreshold", async () => {
34
+ const mod = await import("./db-instrumentation.js");
35
+ assert.equal(typeof mod.getSlowThreshold, "function");
36
+ });
37
+
38
+ await t.test("exports REDACTED_PLACEHOLDER", async () => {
39
+ const mod = await import("./db-instrumentation.js");
40
+ assert.equal(mod.REDACTED_PLACEHOLDER, "[REDACTED]");
41
+ });
42
+
43
+ await t.test(
44
+ "should return null when DB_INSTRUMENTATION is disabled",
45
+ async () => {
46
+ process.env.DB_INSTRUMENTATION = "false";
47
+ const { createDbInstrumentation } = await import(
48
+ "./db-instrumentation.js"
49
+ );
50
+ const result = createDbInstrumentation();
51
+ assert.strictEqual(result, null);
52
+ },
53
+ );
54
+
55
+ await t.test(
56
+ "should return an extension object when instrumentation is enabled",
57
+ async () => {
58
+ process.env.DB_INSTRUMENTATION = "true";
59
+ // Re-import to pick up updated env (module body is cached, but
60
+ // isInstrumentationEnabled reads process.env each call).
61
+ const { createDbInstrumentation } = await import(
62
+ "./db-instrumentation.js"
63
+ );
64
+ const ext = createDbInstrumentation();
65
+ assert.ok(ext, "expected non-null extension");
66
+ assert.equal(ext.name, "quark-db-instrumentation");
67
+ assert.equal(typeof ext.query.$allOperations, "function");
68
+ },
69
+ );
70
+
71
+ await t.test(
72
+ "maskSensitiveValues — redacts known sensitive keys",
73
+ async () => {
74
+ const { maskSensitiveValues, REDACTED_PLACEHOLDER } = await import(
75
+ "./db-instrumentation.js"
76
+ );
77
+
78
+ const input = {
79
+ email: "user@example.com",
80
+ password: "supersecret",
81
+ name: "Alice",
82
+ token: "abc123",
83
+ metadata: {
84
+ apiKey: "sk-xxx",
85
+ role: "admin",
86
+ },
87
+ };
88
+
89
+ const result = maskSensitiveValues(input);
90
+
91
+ assert.strictEqual(result.email, "user@example.com");
92
+ assert.strictEqual(result.password, REDACTED_PLACEHOLDER);
93
+ assert.strictEqual(result.name, "Alice");
94
+ assert.strictEqual(result.token, REDACTED_PLACEHOLDER);
95
+ assert.strictEqual(result.metadata.apiKey, REDACTED_PLACEHOLDER);
96
+ assert.strictEqual(result.metadata.role, "admin");
97
+ },
98
+ );
99
+
100
+ await t.test(
101
+ "maskSensitiveValues — handles null/undefined/primitives",
102
+ async () => {
103
+ const { maskSensitiveValues } = await import("./db-instrumentation.js");
104
+
105
+ assert.strictEqual(maskSensitiveValues(null), null);
106
+ assert.strictEqual(maskSensitiveValues(undefined), undefined);
107
+ assert.strictEqual(maskSensitiveValues(42), 42);
108
+ assert.strictEqual(maskSensitiveValues("hello"), "hello");
109
+ },
110
+ );
111
+
112
+ await t.test("maskSensitiveValues — handles arrays", async () => {
113
+ const { maskSensitiveValues, REDACTED_PLACEHOLDER } = await import(
114
+ "./db-instrumentation.js"
115
+ );
116
+
117
+ const input = [
118
+ { password: "secret1", name: "A" },
119
+ { password: "secret2", name: "B" },
120
+ ];
121
+
122
+ const result = maskSensitiveValues(input);
123
+
124
+ assert.equal(result[0].password, REDACTED_PLACEHOLDER);
125
+ assert.equal(result[0].name, "A");
126
+ assert.equal(result[1].password, REDACTED_PLACEHOLDER);
127
+ assert.equal(result[1].name, "B");
128
+ });
129
+
130
+ await t.test("maskSensitiveValues — deeply nested objects", async () => {
131
+ const { maskSensitiveValues, REDACTED_PLACEHOLDER } = await import(
132
+ "./db-instrumentation.js"
133
+ );
134
+
135
+ const input = {
136
+ user: {
137
+ profile: {
138
+ hash: "abc",
139
+ displayName: "Bob",
140
+ },
141
+ },
142
+ };
143
+
144
+ const result = maskSensitiveValues(input);
145
+
146
+ assert.strictEqual(result.user.profile.hash, REDACTED_PLACEHOLDER);
147
+ assert.strictEqual(result.user.profile.displayName, "Bob");
148
+ });
149
+
150
+ await t.test("isSensitiveKey — matches known patterns", async () => {
151
+ const { isSensitiveKey } = await import("./db-instrumentation.js");
152
+
153
+ assert.strictEqual(isSensitiveKey("password"), true);
154
+ assert.strictEqual(isSensitiveKey("Password"), true);
155
+ assert.strictEqual(isSensitiveKey("PASSWORD"), true);
156
+ assert.strictEqual(isSensitiveKey("token"), true);
157
+ assert.strictEqual(isSensitiveKey("api_key"), true);
158
+ assert.strictEqual(isSensitiveKey("apiKey"), true);
159
+ assert.strictEqual(isSensitiveKey("secret"), true);
160
+ assert.strictEqual(isSensitiveKey("hash"), true);
161
+ assert.strictEqual(isSensitiveKey("pin"), true);
162
+ assert.strictEqual(isSensitiveKey("otp"), true);
163
+ assert.strictEqual(isSensitiveKey("nonce"), true);
164
+ assert.strictEqual(isSensitiveKey("signature"), true);
165
+ assert.strictEqual(isSensitiveKey("csrf_token"), true);
166
+ assert.strictEqual(isSensitiveKey("authorization"), true);
167
+ });
168
+
169
+ await t.test("isSensitiveKey — does not match safe keys", async () => {
170
+ const { isSensitiveKey } = await import("./db-instrumentation.js");
171
+
172
+ assert.strictEqual(isSensitiveKey("email"), false);
173
+ assert.strictEqual(isSensitiveKey("name"), false);
174
+ assert.strictEqual(isSensitiveKey("title"), false);
175
+ assert.strictEqual(isSensitiveKey("description"), false);
176
+ assert.strictEqual(isSensitiveKey("id"), false);
177
+ assert.strictEqual(isSensitiveKey("createdAt"), false);
178
+ assert.strictEqual(isSensitiveKey("updatedAt"), false);
179
+ assert.strictEqual(isSensitiveKey("role"), false);
180
+ });
181
+
182
+ await t.test("summarizeArgs — masks sensitive fields", async () => {
183
+ const { summarizeArgs, REDACTED_PLACEHOLDER } = await import(
184
+ "./db-instrumentation.js"
185
+ );
186
+
187
+ const args = {
188
+ where: { email: "test@test.com", password: "hunter2" },
189
+ data: { name: "Test", token: "xyz" },
190
+ };
191
+
192
+ const result = summarizeArgs(args);
193
+
194
+ assert.strictEqual(result.where.email, "test@test.com");
195
+ assert.strictEqual(result.where.password, REDACTED_PLACEHOLDER);
196
+ assert.strictEqual(result.data.name, "Test");
197
+ assert.strictEqual(result.data.token, REDACTED_PLACEHOLDER);
198
+ });
199
+
200
+ await t.test("summarizeArgs — truncates large objects", async () => {
201
+ const { summarizeArgs } = await import("./db-instrumentation.js");
202
+
203
+ const largeWhere = {};
204
+ for (let i = 0; i < 20; i++) {
205
+ largeWhere[`field_${i}`] = `value_${i}`;
206
+ }
207
+
208
+ const args = { where: largeWhere };
209
+ const result = summarizeArgs(args);
210
+
211
+ // Should have at most 5 keys (3 truncated + the "[+N more]" marker)
212
+ const keyCount = Object.keys(result.where).length;
213
+ assert.ok(keyCount <= 5, `Expected ≤5 keys, got ${keyCount}`);
214
+ assert.ok("[+17 more]" in result.where || "[+11 more]" in result.where);
215
+ });
216
+
217
+ await t.test(
218
+ "summarizeArgs — returns empty object for null/undefined",
219
+ async () => {
220
+ const { summarizeArgs } = await import("./db-instrumentation.js");
221
+
222
+ assert.deepStrictEqual(summarizeArgs(null), {});
223
+ assert.deepStrictEqual(summarizeArgs(undefined), {});
224
+ },
225
+ );
226
+
227
+ await t.test("getSlowThreshold — reads from env", async () => {
228
+ const { getSlowThreshold } = await import("./db-instrumentation.js");
229
+
230
+ process.env.DB_SLOW_QUERY_THRESHOLD = "2000";
231
+ assert.strictEqual(getSlowThreshold(), 2000);
232
+
233
+ process.env.DB_SLOW_QUERY_THRESHOLD = "-1";
234
+ assert.strictEqual(getSlowThreshold(), 500); // fallback to default
235
+
236
+ delete process.env.DB_SLOW_QUERY_THRESHOLD;
237
+ assert.strictEqual(getSlowThreshold(), 500); // fallback to default
238
+ });
239
+
240
+ await t.test("$allOperations — calls query and returns result", async () => {
241
+ process.env.DB_INSTRUMENTATION = "true";
242
+ const { createDbInstrumentation } = await import("./db-instrumentation.js");
243
+ const ext = createDbInstrumentation();
244
+
245
+ const expected = { id: 1, email: "test@test.com" };
246
+ const params = {
247
+ model: "User",
248
+ operation: "findUnique",
249
+ args: { where: { id: 1 } },
250
+ query: mock.fn(async () => expected),
251
+ };
252
+
253
+ const result = await ext.query.$allOperations(params);
254
+
255
+ assert.strictEqual(result, expected);
256
+ assert.equal(params.query.mock.callCount(), 1);
257
+ });
258
+
259
+ await t.test(
260
+ "$allOperations — still returns result on query error (finally block)",
261
+ async () => {
262
+ process.env.DB_INSTRUMENTATION = "true";
263
+ const { createDbInstrumentation } = await import(
264
+ "./db-instrumentation.js"
265
+ );
266
+ const ext = createDbInstrumentation();
267
+
268
+ const queryError = new Error("DB failure");
269
+ const params = {
270
+ model: "User",
271
+ operation: "create",
272
+ args: { data: { email: "test@test.com" } },
273
+ query: mock.fn(async () => {
274
+ throw queryError;
275
+ }),
276
+ };
277
+
278
+ await assert.rejects(
279
+ () => ext.query.$allOperations(params),
280
+ (error) => error === queryError,
281
+ );
282
+ },
283
+ );
284
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @techstream/quark-core — Database Metrics Registry
3
+ *
4
+ * Pre-registered Prometheus metrics for database query instrumentation.
5
+ * Imported lazily by db-instrumentation.js to avoid circular dependencies.
6
+ *
7
+ * These metrics are registered on the global `metrics` singleton so they
8
+ * appear alongside HTTP and queue metrics at the /api/metrics endpoint.
9
+ */
10
+
11
+ import { metrics } from "./metrics.js";
12
+
13
+ /** Total database queries (labels: model, operation) */
14
+ export const dbQueriesTotal = metrics.counter({
15
+ name: "db_queries_total",
16
+ help: "Total number of database queries executed",
17
+ labelNames: ["model", "operation"],
18
+ });
19
+
20
+ /** Database query duration in seconds (labels: model, operation) */
21
+ export const dbQueryDuration = metrics.histogram({
22
+ name: "db_query_duration_seconds",
23
+ help: "Database query duration in seconds",
24
+ labelNames: ["model", "operation"],
25
+ buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
26
+ });
package/src/db.js ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * @techstream/quark-core - Database Utilities
3
+ *
4
+ * Shared Prisma client singleton, connection-string builder, pool config,
5
+ * and a lightweight health-check ping. These replace the per-project
6
+ * re-implementations in Regina / Pawdora / Trenchmate.
7
+ *
8
+ * Usage:
9
+ * import { createPrismaClient, getConnectionString, pingDatabase } from "@techstream/quark-core/db";
10
+ */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Connection String
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Builds a PostgreSQL connection string from environment variables.
18
+ *
19
+ * Priority: DATABASE_URL → assembled from POSTGRES_* vars.
20
+ *
21
+ * @param {{ throwOnMissing?: boolean }} [options]
22
+ * @returns {string} PostgreSQL connection string
23
+ */
24
+ export function getConnectionString({ throwOnMissing = true } = {}) {
25
+ // 1. Prefer an explicit DATABASE_URL if set
26
+ if (process.env.DATABASE_URL) {
27
+ return process.env.DATABASE_URL;
28
+ }
29
+
30
+ // 2. Assemble from individual POSTGRES_* vars
31
+ const user = process.env.POSTGRES_USER;
32
+ const password = process.env.POSTGRES_PASSWORD;
33
+ const host = process.env.POSTGRES_HOST || "localhost";
34
+ const port = process.env.POSTGRES_PORT || "5432";
35
+ const db = process.env.POSTGRES_DB;
36
+
37
+ const hasCredentials = user && password && db;
38
+
39
+ if (!hasCredentials) {
40
+ if (throwOnMissing) {
41
+ const missing = [];
42
+ if (!user) missing.push("POSTGRES_USER");
43
+ if (!password) missing.push("POSTGRES_PASSWORD");
44
+ if (!db) missing.push("POSTGRES_DB");
45
+ throw new Error(
46
+ `Missing required database environment variables: ${missing.join(", ")}. ` +
47
+ "Set DATABASE_URL or the individual POSTGRES_* variables.",
48
+ );
49
+ }
50
+
51
+ // Placeholder for prisma generate / CI
52
+ return "postgresql://placeholder:placeholder@localhost:5432/placeholder?schema=public";
53
+ }
54
+
55
+ return `postgresql://${user}:${password}@${host}:${port}/${db}?schema=public`;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Pool Config
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Returns the connection pool configuration for the `pg` driver.
64
+ *
65
+ * Reads from environment variables with sensible defaults:
66
+ * DB_POOL_MAX – max connections (default: 10 prod / 5 dev)
67
+ * DB_POOL_IDLE_TIMEOUT – idle timeout ms (default: 30 000)
68
+ * DB_POOL_CONNECTION_TIMEOUT – connect timeout ms (default: 5 000)
69
+ *
70
+ * @returns {{ max: number, idleTimeoutMillis: number, connectionTimeoutMillis: number }}
71
+ */
72
+ export function getPoolConfig() {
73
+ const isProduction = process.env.NODE_ENV === "production";
74
+ return {
75
+ max: Number(process.env.DB_POOL_MAX) || (isProduction ? 10 : 5),
76
+ idleTimeoutMillis: Number(process.env.DB_POOL_IDLE_TIMEOUT) || 30_000,
77
+ connectionTimeoutMillis:
78
+ Number(process.env.DB_POOL_CONNECTION_TIMEOUT) || 5_000,
79
+ };
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Prisma Client Singleton
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * Creates a lazy Prisma client singleton that is safe to import at build
88
+ * time (no DB connection is made until the first property access).
89
+ *
90
+ * The singleton is stored on `globalThis` so it survives hot-reloads in
91
+ * development and is shared across lazy imports in a monorepo. The slot is
92
+ * namespaced per client (`__quark_prisma_<name>`) so two different Prisma
93
+ * clients in the same process do not silently share one instance.
94
+ *
95
+ * @template T - PrismaClient subclass
96
+ * @param {new (...args: any[]) => T} ClientClass - The PrismaClient constructor
97
+ * @param {Object} [options]
98
+ * @param {Array<(client: T) => T>} [options.$extends] - Extension functions applied to the client
99
+ * @param {Record<string, any>} [options.adapterOptions] - Extra options passed to the ClientClass constructor
100
+ * @param {string} [options.name] - Singleton namespace; defaults to the ClientClass name
101
+ * @param {string} [options.globalKey] - Full override for the `globalThis` key
102
+ * @returns {T} Proxy that defers initialization until first use
103
+ */
104
+ export function createPrismaClient(ClientClass, options = {}) {
105
+ const globalKey =
106
+ options.globalKey ||
107
+ `__quark_prisma_${options.name || ClientClass?.name || "default"}`;
108
+
109
+ /**
110
+ * Returns the real client, creating it on first access.
111
+ * @returns {T}
112
+ */
113
+ function getClient() {
114
+ if (globalThis[globalKey]) return globalThis[globalKey];
115
+
116
+ let client = new ClientClass(options.adapterOptions ?? {});
117
+
118
+ // Apply $extends chain if provided
119
+ if (options.$extends?.length) {
120
+ for (const ext of options.$extends) {
121
+ client = ext(client);
122
+ }
123
+ }
124
+
125
+ globalThis[globalKey] = client;
126
+ return client;
127
+ }
128
+
129
+ // Proxy-based deferred init — methods are bound on first access so the
130
+ // caller never notices the lazy instantiation.
131
+ return new Proxy(/** @type {T} */ ({}), {
132
+ get(_target, prop, receiver) {
133
+ const client = getClient();
134
+ const value = Reflect.get(client, prop, receiver);
135
+ return typeof value === "function" ? value.bind(client) : value;
136
+ },
137
+ has(_target, prop) {
138
+ return prop in getClient();
139
+ },
140
+ ownKeys() {
141
+ return Reflect.ownKeys(getClient());
142
+ },
143
+ getOwnPropertyDescriptor(_target, prop) {
144
+ return Reflect.getOwnPropertyDescriptor(getClient(), prop);
145
+ },
146
+ });
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Health Check
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * Pings PostgreSQL to verify connectivity.
155
+ * Uses a raw `pg.Client` directly so the check is immune to Prisma adapter
156
+ * quirks. `pg` is an optional peer dependency that is dynamically imported
157
+ * so importing this module (which the `.` and `./core` barrels re-export)
158
+ * does not require it to be installed. The import is marked `webpackIgnore`
159
+ * so bundlers keep it as a runtime import instead of failing the build when
160
+ * `pg` is absent.
161
+ *
162
+ * @param {object} [options]
163
+ * @param {number} [options.timeout=3000] - Connection/query timeout in ms.
164
+ * @returns {Promise<{ status: "ok", latencyMs: number } | { status: "error", message: string }>}
165
+ */
166
+ export async function pingDatabase({ timeout = 3000 } = {}) {
167
+ /** @type {import("pg").Client | null} */
168
+ let client = null;
169
+ let connectionString;
170
+
171
+ try {
172
+ const { default: pg } = await import(/* webpackIgnore: true */ "pg");
173
+ connectionString = getConnectionString();
174
+
175
+ client = new pg.Client({
176
+ connectionString,
177
+ connectionTimeoutMillis: timeout,
178
+ query_timeout: timeout,
179
+ });
180
+
181
+ await client.connect();
182
+
183
+ const start = performance.now();
184
+ await client.query("SELECT 1");
185
+ const latencyMs = Math.round(performance.now() - start);
186
+
187
+ return { status: "ok", latencyMs };
188
+ } catch (error) {
189
+ let message;
190
+ if (
191
+ error?.code === "MODULE_NOT_FOUND" ||
192
+ error?.code === "ERR_MODULE_NOT_FOUND"
193
+ ) {
194
+ message = "pg is not installed";
195
+ } else if (
196
+ error?.code === "ECONNREFUSED" ||
197
+ /ECONNREFUSED/i.test(error?.message ?? "")
198
+ ) {
199
+ try {
200
+ const { hostname, port } = new URL(connectionString);
201
+ message = `PostgreSQL unreachable at ${hostname}:${port || "5432"}`;
202
+ } catch {
203
+ message = "PostgreSQL unreachable";
204
+ }
205
+ } else if (error?.code === "ENOTFOUND") {
206
+ message = `PostgreSQL host not found: ${error.hostname ?? "unknown"}`;
207
+ } else {
208
+ message = error?.message ?? String(error);
209
+ }
210
+ return { status: "error", message };
211
+ } finally {
212
+ try {
213
+ await client?.end();
214
+ } catch {
215
+ // ignore disconnect errors
216
+ }
217
+ }
218
+ }