@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.
- package/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- 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
|
+
}
|