@techstream/quark-create-app 1.7.0 → 1.9.0
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 +61 -0
- package/package.json +7 -4
- package/src/index.js +130 -44
- package/src/utils.js +36 -0
- package/src/utils.test.js +63 -0
- package/templates/base-project/.github/workflows/release.yml +37 -8
- package/templates/base-project/apps/web/package.json +7 -5
- package/templates/base-project/apps/web/railway.json +1 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +29 -1
- package/templates/base-project/apps/worker/README.md +690 -0
- package/templates/base-project/apps/worker/package.json +3 -2
- package/templates/base-project/apps/worker/src/index.js +190 -5
- package/templates/base-project/apps/worker/src/index.test.js +278 -0
- package/templates/base-project/package.json +14 -1
- package/templates/base-project/packages/db/package.json +4 -7
- package/templates/base-project/packages/db/prisma/seed.js +119 -0
- package/templates/base-project/packages/db/prisma.config.ts +1 -0
- package/templates/config/src/index.js +2 -4
- package/templates/config/src/validate-env.js +79 -3
- package/templates/jobs/package.json +1 -1
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Handles job execution, retries, and error tracking
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { loadEnv } from "@techstream/quark-config";
|
|
8
7
|
import {
|
|
8
|
+
checkQueueHealth,
|
|
9
9
|
createLogger,
|
|
10
10
|
createQueue,
|
|
11
11
|
createWorker,
|
|
@@ -14,15 +14,176 @@ import { prisma } from "@techstream/quark-db";
|
|
|
14
14
|
import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
|
|
15
15
|
import { jobHandlers } from "./handlers/index.js";
|
|
16
16
|
|
|
17
|
-
// Validate environment variables (worker-scoped — skips web-only checks)
|
|
18
|
-
loadEnv("worker");
|
|
19
|
-
|
|
20
17
|
const logger = createLogger("worker");
|
|
21
18
|
|
|
22
19
|
// Store workers for graceful shutdown
|
|
23
20
|
const workers = [];
|
|
24
21
|
let isShuttingDown = false;
|
|
25
22
|
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// RESILIENCE UTILITIES
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detects if error is a connection/network error
|
|
29
|
+
* @param {Error} error
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function isConnectionError(error) {
|
|
33
|
+
if (!error) return false;
|
|
34
|
+
const message = (error.message || "") + (error.code || "");
|
|
35
|
+
const connectionErrors = [
|
|
36
|
+
"ECONNREFUSED", // Connection refused
|
|
37
|
+
"ECONNRESET", // Connection reset
|
|
38
|
+
"ENOTFOUND", // DNS lookup failure
|
|
39
|
+
"ETIMEDOUT", // Connection timeout
|
|
40
|
+
"EHOSTUNREACH", // Host unreachable
|
|
41
|
+
"ENETUNREACH", // Network unreachable
|
|
42
|
+
"Error: Redis connection failed", // Generic redis failure
|
|
43
|
+
"Ready status is false", // BullMQ readiness
|
|
44
|
+
];
|
|
45
|
+
return connectionErrors.some((err) => message.includes(err));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a throttled error logger
|
|
50
|
+
* Suppresses duplicate errors within a time window
|
|
51
|
+
* @param {Object} logger
|
|
52
|
+
* @param {number} windowMs - Throttle window in milliseconds
|
|
53
|
+
* @returns {Function} throttle function
|
|
54
|
+
*/
|
|
55
|
+
export function throttledError(logger, windowMs = 5000) {
|
|
56
|
+
let lastErrorTime = 0;
|
|
57
|
+
let lastErrorMsg = "";
|
|
58
|
+
|
|
59
|
+
return (error) => {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const msg = error.message || String(error);
|
|
62
|
+
|
|
63
|
+
// Log if new error type or window expired
|
|
64
|
+
if (msg !== lastErrorMsg || now - lastErrorTime > windowMs) {
|
|
65
|
+
logger.error(`Connection error (will retry)`, {
|
|
66
|
+
error: msg,
|
|
67
|
+
code: error.code,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
});
|
|
70
|
+
lastErrorTime = now;
|
|
71
|
+
lastErrorMsg = msg;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Waits for Redis to be ready with retries
|
|
78
|
+
* @param {Function} healthCheck - Async function that returns boolean or throws
|
|
79
|
+
* @param {Object} config
|
|
80
|
+
* @param {number} config.maxRetries - Maximum retry attempts
|
|
81
|
+
* @param {number} config.intervalMs - Delay between retries
|
|
82
|
+
* @returns {Promise<boolean>}
|
|
83
|
+
*/
|
|
84
|
+
export async function waitForRedis(
|
|
85
|
+
healthCheck = checkQueueHealth,
|
|
86
|
+
config = {},
|
|
87
|
+
) {
|
|
88
|
+
const {
|
|
89
|
+
maxRetries = parseInt(process.env.WORKER_HEALTH_RETRIES || "10", 10),
|
|
90
|
+
intervalMs = parseInt(process.env.WORKER_HEALTH_INTERVAL_MS || "1000", 10),
|
|
91
|
+
} = config;
|
|
92
|
+
|
|
93
|
+
const reportThrottledError = throttledError(logger, 3000);
|
|
94
|
+
let lastError;
|
|
95
|
+
|
|
96
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
97
|
+
try {
|
|
98
|
+
const isReady = await healthCheck();
|
|
99
|
+
if (isReady) {
|
|
100
|
+
logger.info(
|
|
101
|
+
`Redis health check passed (attempt ${attempt}/${maxRetries})`,
|
|
102
|
+
);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
lastError = error;
|
|
107
|
+
if (isConnectionError(error)) {
|
|
108
|
+
reportThrottledError(error);
|
|
109
|
+
if (attempt < maxRetries) {
|
|
110
|
+
// Wait before retrying
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// Non-connection error; don't retry
|
|
115
|
+
logger.error("Health check failed with non-network error", {
|
|
116
|
+
error: error.message,
|
|
117
|
+
});
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// All retries exhausted
|
|
124
|
+
logger.error("Redis health check failed after all retries", {
|
|
125
|
+
attempts: maxRetries,
|
|
126
|
+
lastError: lastError?.message,
|
|
127
|
+
});
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Failed to connect to Redis after ${maxRetries} attempts: ${lastError?.message}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// PREFLIGHT MODE
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Runs pre-flight health checks and exits
|
|
139
|
+
* Used for deployment readiness probes
|
|
140
|
+
*/
|
|
141
|
+
async function preflight() {
|
|
142
|
+
// Load and validate environment variables
|
|
143
|
+
const { loadEnv } = await import("@techstream/quark-config");
|
|
144
|
+
loadEnv("worker");
|
|
145
|
+
|
|
146
|
+
logger.info("Running preflight health checks");
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
// Check Redis
|
|
150
|
+
logger.info("Checking Redis connectivity...");
|
|
151
|
+
const redisReady = await checkQueueHealth();
|
|
152
|
+
if (!redisReady) {
|
|
153
|
+
throw new Error("Redis health check returned false");
|
|
154
|
+
}
|
|
155
|
+
logger.info("✓ Redis connected");
|
|
156
|
+
|
|
157
|
+
// Check Database
|
|
158
|
+
logger.info("Checking database connectivity...");
|
|
159
|
+
await prisma.$queryRaw`SELECT 1`;
|
|
160
|
+
logger.info("✓ Database connected");
|
|
161
|
+
|
|
162
|
+
// Validate handlers are registered
|
|
163
|
+
logger.info("Checking job handler registration...");
|
|
164
|
+
let handlerCount = 0;
|
|
165
|
+
for (const queueName of Object.values(JOB_QUEUES)) {
|
|
166
|
+
const queue = createQueue(queueName);
|
|
167
|
+
for (const jobName of Object.values(JOB_NAMES)) {
|
|
168
|
+
if (jobHandlers[jobName]) {
|
|
169
|
+
handlerCount++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await queue.close();
|
|
173
|
+
}
|
|
174
|
+
logger.info(`✓ ${handlerCount} job handlers registered`);
|
|
175
|
+
|
|
176
|
+
logger.info("✓ All preflight checks passed");
|
|
177
|
+
process.exit(0);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
logger.error("Preflight check failed", {
|
|
180
|
+
error: error.message,
|
|
181
|
+
stack: error.stack,
|
|
182
|
+
});
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
26
187
|
/**
|
|
27
188
|
* Generic queue processor — dispatches jobs to registered handlers
|
|
28
189
|
* @param {string} queueName
|
|
@@ -86,9 +247,17 @@ function createQueueWorker(queueName) {
|
|
|
86
247
|
* Start the worker service
|
|
87
248
|
*/
|
|
88
249
|
async function startWorker() {
|
|
250
|
+
// Load and validate environment variables
|
|
251
|
+
const { loadEnv } = await import("@techstream/quark-config");
|
|
252
|
+
loadEnv("worker");
|
|
253
|
+
|
|
89
254
|
logger.info("Starting Quark Worker Service");
|
|
90
255
|
|
|
91
256
|
try {
|
|
257
|
+
// Pre-flight: Wait for Redis with health checks and retries
|
|
258
|
+
logger.info("Performing health checks...");
|
|
259
|
+
await waitForRedis();
|
|
260
|
+
|
|
92
261
|
// Register a worker for each queue
|
|
93
262
|
for (const queueName of Object.values(JOB_QUEUES)) {
|
|
94
263
|
createQueueWorker(queueName);
|
|
@@ -162,4 +331,20 @@ process.on("SIGINT", () => {
|
|
|
162
331
|
void shutdown("SIGINT");
|
|
163
332
|
});
|
|
164
333
|
|
|
165
|
-
|
|
334
|
+
// ============================================================================
|
|
335
|
+
// ENTRY POINT
|
|
336
|
+
// ============================================================================
|
|
337
|
+
|
|
338
|
+
// Only start the worker if this file is being run directly
|
|
339
|
+
// Convert file:// URL to path for comparison
|
|
340
|
+
const currentFile = new URL(import.meta.url).pathname;
|
|
341
|
+
const mainModule = process.argv[1];
|
|
342
|
+
const isMainModule = currentFile === mainModule;
|
|
343
|
+
|
|
344
|
+
if (isMainModule) {
|
|
345
|
+
if (process.argv.includes("--preflight")) {
|
|
346
|
+
void preflight();
|
|
347
|
+
} else {
|
|
348
|
+
void startWorker();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, mock, test } from "node:test";
|
|
3
|
+
import { isConnectionError, throttledError, waitForRedis } from "./index.js";
|
|
3
4
|
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Helpers: lightweight fakes for Prisma, emailService, and storage
|
|
@@ -298,3 +299,280 @@ describe("jobHandlers registry", () => {
|
|
|
298
299
|
});
|
|
299
300
|
});
|
|
300
301
|
});
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Resilience Utilities: isConnectionError
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
describe("isConnectionError", () => {
|
|
308
|
+
test("detects ECONNREFUSED (connection refused)", () => {
|
|
309
|
+
const error = new Error("connect ECONNREFUSED 127.0.0.1:6379");
|
|
310
|
+
assert.strictEqual(isConnectionError(error), true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("detects ECONNRESET (connection reset)", () => {
|
|
314
|
+
const error = new Error("read ECONNRESET");
|
|
315
|
+
assert.strictEqual(isConnectionError(error), true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("detects ENOTFOUND (DNS lookup failure)", () => {
|
|
319
|
+
const error = new Error("getaddrinfo ENOTFOUND redis.example.com");
|
|
320
|
+
assert.strictEqual(isConnectionError(error), true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("detects ETIMEDOUT (connection timeout)", () => {
|
|
324
|
+
const error = new Error("connect ETIMEDOUT");
|
|
325
|
+
assert.strictEqual(isConnectionError(error), true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("detects EHOSTUNREACH (host unreachable)", () => {
|
|
329
|
+
const error = new Error("EHOSTUNREACH 10.0.0.1");
|
|
330
|
+
assert.strictEqual(isConnectionError(error), true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("detects ENETUNREACH (network unreachable)", () => {
|
|
334
|
+
const error = new Error("ENETUNREACH 10.0.0.1");
|
|
335
|
+
assert.strictEqual(isConnectionError(error), true);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("returns false for non-connection errors", () => {
|
|
339
|
+
const error = new Error("Invalid queue configuration");
|
|
340
|
+
assert.strictEqual(isConnectionError(error), false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("returns false for null or undefined", () => {
|
|
344
|
+
assert.strictEqual(isConnectionError(null), false);
|
|
345
|
+
assert.strictEqual(isConnectionError(undefined), false);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("handles error objects with code property", () => {
|
|
349
|
+
const error = { code: "ECONNREFUSED", message: "refused" };
|
|
350
|
+
assert.strictEqual(isConnectionError(error), true);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Resilience Utilities: throttledError
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
describe("throttledError", () => {
|
|
359
|
+
test("logs first error immediately", () => {
|
|
360
|
+
const logger = createMockLogger();
|
|
361
|
+
const throttle = throttledError(logger, 100);
|
|
362
|
+
|
|
363
|
+
throttle(new Error("Redis unavailable"));
|
|
364
|
+
|
|
365
|
+
assert.strictEqual(logger.error.mock.callCount(), 1);
|
|
366
|
+
const call = logger.error.mock.calls[0];
|
|
367
|
+
assert.ok(call.arguments[0].includes("Connection error"));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("suppresses duplicate errors within window", () => {
|
|
371
|
+
const logger = createMockLogger();
|
|
372
|
+
const throttle = throttledError(logger, 100);
|
|
373
|
+
|
|
374
|
+
throttle(new Error("Redis unavailable"));
|
|
375
|
+
assert.strictEqual(logger.error.mock.callCount(), 1);
|
|
376
|
+
|
|
377
|
+
// Same error within window — should be suppressed
|
|
378
|
+
throttle(new Error("Redis unavailable"));
|
|
379
|
+
assert.strictEqual(logger.error.mock.callCount(), 1);
|
|
380
|
+
|
|
381
|
+
// Different error within window — should log
|
|
382
|
+
throttle(new Error("Redis timeout"));
|
|
383
|
+
assert.strictEqual(logger.error.mock.callCount(), 2);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("logs error again after window expires", async () => {
|
|
387
|
+
const logger = createMockLogger();
|
|
388
|
+
const throttle = throttledError(logger, 50); // 50ms window
|
|
389
|
+
|
|
390
|
+
throttle(new Error("Redis unavailable"));
|
|
391
|
+
assert.strictEqual(logger.error.mock.callCount(), 1);
|
|
392
|
+
|
|
393
|
+
// Same error within window — suppressed
|
|
394
|
+
throttle(new Error("Redis unavailable"));
|
|
395
|
+
assert.strictEqual(logger.error.mock.callCount(), 1);
|
|
396
|
+
|
|
397
|
+
// Wait for window to expire
|
|
398
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
399
|
+
|
|
400
|
+
// Same error after window — logged again
|
|
401
|
+
throttle(new Error("Redis unavailable"));
|
|
402
|
+
assert.strictEqual(logger.error.mock.callCount(), 2);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("includes error details in log", () => {
|
|
406
|
+
const logger = createMockLogger();
|
|
407
|
+
const throttle = throttledError(logger, 100);
|
|
408
|
+
|
|
409
|
+
const error = new Error("ECONNREFUSED");
|
|
410
|
+
error.code = "ECONNREFUSED";
|
|
411
|
+
throttle(error);
|
|
412
|
+
|
|
413
|
+
const call = logger.error.mock.calls[0];
|
|
414
|
+
const args = call.arguments;
|
|
415
|
+
assert.strictEqual(args[0], "Connection error (will retry)");
|
|
416
|
+
assert.ok(args[1].error.includes("ECONNREFUSED"));
|
|
417
|
+
assert.ok(args[1].timestamp); // Should have timestamp
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("uses default 5 second window if not specified", async () => {
|
|
421
|
+
const logger = createMockLogger();
|
|
422
|
+
const throttle = throttledError(logger); // No window specified
|
|
423
|
+
|
|
424
|
+
throttle(new Error("Test"));
|
|
425
|
+
assert.strictEqual(logger.error.mock.callCount(), 1);
|
|
426
|
+
|
|
427
|
+
throttle(new Error("Test"));
|
|
428
|
+
assert.strictEqual(logger.error.mock.callCount(), 1); // Suppressed
|
|
429
|
+
|
|
430
|
+
// 5 second default window hasn't expired
|
|
431
|
+
throttle(new Error("Test"));
|
|
432
|
+
assert.strictEqual(logger.error.mock.callCount(), 1); // Still suppressed
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Resilience Utilities: waitForRedis
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
describe("waitForRedis", () => {
|
|
441
|
+
test("returns true when health check succeeds immediately", async () => {
|
|
442
|
+
const healthCheck = mock.fn(async () => true);
|
|
443
|
+
const result = await waitForRedis(healthCheck, {
|
|
444
|
+
maxRetries: 3,
|
|
445
|
+
intervalMs: 10,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
assert.strictEqual(result, true);
|
|
449
|
+
assert.strictEqual(healthCheck.mock.callCount(), 1);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("retries and succeeds on second attempt", async () => {
|
|
453
|
+
let attempts = 0;
|
|
454
|
+
const healthCheck = mock.fn(async () => {
|
|
455
|
+
attempts++;
|
|
456
|
+
if (attempts < 2) {
|
|
457
|
+
throw new Error("ECONNREFUSED 127.0.0.1:6379");
|
|
458
|
+
}
|
|
459
|
+
return true;
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const result = await waitForRedis(healthCheck, {
|
|
463
|
+
maxRetries: 3,
|
|
464
|
+
intervalMs: 10,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
assert.strictEqual(result, true);
|
|
468
|
+
assert.strictEqual(healthCheck.mock.callCount(), 2);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("fails after max retries exhausted", async () => {
|
|
472
|
+
const healthCheck = mock.fn(async () => {
|
|
473
|
+
throw new Error("ECONNREFUSED");
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
await assert.rejects(
|
|
477
|
+
() =>
|
|
478
|
+
waitForRedis(healthCheck, {
|
|
479
|
+
maxRetries: 2,
|
|
480
|
+
intervalMs: 10,
|
|
481
|
+
}),
|
|
482
|
+
{ message: /Failed to connect to Redis after 2 attempts/ },
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
assert.strictEqual(healthCheck.mock.callCount(), 2);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("stops retrying on non-connection errors", async () => {
|
|
489
|
+
const healthCheck = mock.fn(async () => {
|
|
490
|
+
throw new Error("Invalid configuration");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
await assert.rejects(
|
|
494
|
+
() =>
|
|
495
|
+
waitForRedis(healthCheck, {
|
|
496
|
+
maxRetries: 5,
|
|
497
|
+
intervalMs: 10,
|
|
498
|
+
}),
|
|
499
|
+
{ message: "Invalid configuration" },
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
// Should fail immediately, not retry 5 times
|
|
503
|
+
assert.strictEqual(healthCheck.mock.callCount(), 1);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("respects maxRetries from config", async () => {
|
|
507
|
+
const healthCheck = mock.fn(async () => {
|
|
508
|
+
throw new Error("ETIMEDOUT");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await assert.rejects(
|
|
512
|
+
() =>
|
|
513
|
+
waitForRedis(healthCheck, {
|
|
514
|
+
maxRetries: 4,
|
|
515
|
+
intervalMs: 10,
|
|
516
|
+
}),
|
|
517
|
+
/Failed to connect to Redis after 4 attempts/,
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
assert.strictEqual(healthCheck.mock.callCount(), 4);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("respects intervalMs delay between retries", async () => {
|
|
524
|
+
let attempts = 0;
|
|
525
|
+
const startTime = Date.now();
|
|
526
|
+
const healthCheck = mock.fn(async () => {
|
|
527
|
+
attempts++;
|
|
528
|
+
if (attempts < 3) {
|
|
529
|
+
throw new Error("ECONNREFUSED");
|
|
530
|
+
}
|
|
531
|
+
return true;
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const result = await waitForRedis(healthCheck, {
|
|
535
|
+
maxRetries: 3,
|
|
536
|
+
intervalMs: 30,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const duration = Date.now() - startTime;
|
|
540
|
+
|
|
541
|
+
assert.strictEqual(result, true);
|
|
542
|
+
// Should have ~60ms delay (2 retries × 30ms)
|
|
543
|
+
// Allow some variance for test execution
|
|
544
|
+
assert.ok(
|
|
545
|
+
duration >= 50,
|
|
546
|
+
`Expected at least 50ms delay, got ${duration}ms`,
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("reads environment variables for config defaults", async () => {
|
|
551
|
+
// Save original env
|
|
552
|
+
const originalRetries = process.env.WORKER_HEALTH_RETRIES;
|
|
553
|
+
const originalInterval = process.env.WORKER_HEALTH_INTERVAL_MS;
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
process.env.WORKER_HEALTH_RETRIES = "2";
|
|
557
|
+
process.env.WORKER_HEALTH_INTERVAL_MS = "20";
|
|
558
|
+
|
|
559
|
+
let _attempts = 0;
|
|
560
|
+
const healthCheck = mock.fn(async () => {
|
|
561
|
+
_attempts++;
|
|
562
|
+
throw new Error("ECONNREFUSED");
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// Call without explicit config — should use env defaults
|
|
566
|
+
await assert.rejects(
|
|
567
|
+
() => waitForRedis(healthCheck),
|
|
568
|
+
/Failed to connect to Redis after 2 attempts/,
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
assert.strictEqual(healthCheck.mock.callCount(), 2);
|
|
572
|
+
} finally {
|
|
573
|
+
// Restore env
|
|
574
|
+
process.env.WORKER_HEALTH_RETRIES = originalRetries;
|
|
575
|
+
process.env.WORKER_HEALTH_INTERVAL_MS = originalInterval;
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
});
|
|
@@ -15,7 +15,18 @@
|
|
|
15
15
|
"db:migrate": "turbo run db:migrate",
|
|
16
16
|
"db:push": "turbo run db:push",
|
|
17
17
|
"db:seed": "turbo run db:seed",
|
|
18
|
-
"db:studio": "turbo run db:studio"
|
|
18
|
+
"db:studio": "turbo run db:studio",
|
|
19
|
+
"prepare": "simple-git-hooks"
|
|
20
|
+
},
|
|
21
|
+
"simple-git-hooks": {
|
|
22
|
+
"pre-commit": "pnpm nano-staged",
|
|
23
|
+
"pre-push": "pnpm test"
|
|
24
|
+
},
|
|
25
|
+
"nano-staged": {
|
|
26
|
+
"**/*.{js,mjs,ts,tsx,json,css}": [
|
|
27
|
+
"biome format --write",
|
|
28
|
+
"biome check --write"
|
|
29
|
+
]
|
|
19
30
|
},
|
|
20
31
|
"keywords": [],
|
|
21
32
|
"author": "",
|
|
@@ -39,6 +50,8 @@
|
|
|
39
50
|
"@biomejs/biome": "^2.4.0",
|
|
40
51
|
"@types/node": "^24.10.9",
|
|
41
52
|
"dotenv-cli": "^11.0.0",
|
|
53
|
+
"nano-staged": "^0.9.0",
|
|
54
|
+
"simple-git-hooks": "^2.13.1",
|
|
42
55
|
"tsx": "^4.21.0",
|
|
43
56
|
"turbo": "^2.8.1"
|
|
44
57
|
}
|
|
@@ -13,9 +13,6 @@
|
|
|
13
13
|
"db:seed": "prisma db seed",
|
|
14
14
|
"db:studio": "prisma studio"
|
|
15
15
|
},
|
|
16
|
-
"prisma": {
|
|
17
|
-
"seed": "node scripts/seed.js"
|
|
18
|
-
},
|
|
19
16
|
"keywords": [],
|
|
20
17
|
"author": "",
|
|
21
18
|
"license": "ISC",
|
|
@@ -24,12 +21,12 @@
|
|
|
24
21
|
"@faker-js/faker": "^10.3.0",
|
|
25
22
|
"@techstream/quark-config": "workspace:*",
|
|
26
23
|
"bcryptjs": "^3.0.3",
|
|
27
|
-
"prisma": "^7.4.
|
|
24
|
+
"prisma": "^7.4.2"
|
|
28
25
|
},
|
|
29
26
|
"dependencies": {
|
|
30
|
-
"@prisma/adapter-pg": "^7.4.
|
|
31
|
-
"@prisma/client": "^7.4.
|
|
32
|
-
"pg": "^8.
|
|
27
|
+
"@prisma/adapter-pg": "^7.4.2",
|
|
28
|
+
"@prisma/client": "^7.4.2",
|
|
29
|
+
"pg": "^8.20.0",
|
|
33
30
|
"zod": "^4.3.6"
|
|
34
31
|
}
|
|
35
32
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
3
|
+
import { getConnectionString } from "../src/connection.js";
|
|
4
|
+
import { PrismaClient } from "../src/generated/prisma/client.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Deterministic hash for dev seed users only.
|
|
8
|
+
* The app uses bcrypt for real user passwords — do not use this elsewhere.
|
|
9
|
+
*/
|
|
10
|
+
function devHash(password) {
|
|
11
|
+
return crypto.createHash("sha256").update(`${password}salt`).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal seed: creates a single admin user.
|
|
16
|
+
* Used standalone in production initial setup (SEED_PROFILE=minimal).
|
|
17
|
+
* Also called by seedDev to avoid duplication.
|
|
18
|
+
*/
|
|
19
|
+
async function seedMinimal(prisma) {
|
|
20
|
+
const existing = await prisma.user.findUnique({
|
|
21
|
+
where: { email: "admin@example.com" },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (existing) {
|
|
25
|
+
console.log("✓ Admin user already exists");
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const admin = await prisma.user.create({
|
|
30
|
+
data: {
|
|
31
|
+
email: "admin@example.com",
|
|
32
|
+
name: "Admin User",
|
|
33
|
+
role: "admin",
|
|
34
|
+
password: devHash("admin123"),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
console.log(`✓ Created admin user: ${admin.email}`);
|
|
39
|
+
return admin;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Development seed: admin user + representative sample data for local testing.
|
|
44
|
+
* Runs by default (SEED_PROFILE=dev).
|
|
45
|
+
*/
|
|
46
|
+
async function seedDev(prisma) {
|
|
47
|
+
const admin = await seedMinimal(prisma);
|
|
48
|
+
|
|
49
|
+
// If admin already existed, skip the rest to remain idempotent
|
|
50
|
+
if (!admin) return;
|
|
51
|
+
|
|
52
|
+
const sampleUser = await prisma.user.create({
|
|
53
|
+
data: {
|
|
54
|
+
email: "user@example.com",
|
|
55
|
+
name: "Sample User",
|
|
56
|
+
role: "viewer",
|
|
57
|
+
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=user@example.com",
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
console.log(`✓ Created sample user: ${sampleUser.email}`);
|
|
62
|
+
|
|
63
|
+
await prisma.auditLog.create({
|
|
64
|
+
data: {
|
|
65
|
+
userId: admin.id,
|
|
66
|
+
action: "CREATE",
|
|
67
|
+
entity: "User",
|
|
68
|
+
entityId: sampleUser.id,
|
|
69
|
+
metadata: { email: sampleUser.email, role: sampleUser.role },
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
console.log("✓ Created sample audit log");
|
|
74
|
+
|
|
75
|
+
await prisma.job.create({
|
|
76
|
+
data: {
|
|
77
|
+
queue: "default",
|
|
78
|
+
name: "example-job",
|
|
79
|
+
status: "COMPLETED",
|
|
80
|
+
data: { message: "This is a sample job" },
|
|
81
|
+
completedAt: new Date(),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
console.log("✓ Created sample job");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function main() {
|
|
89
|
+
const prisma = new PrismaClient({
|
|
90
|
+
adapter: new PrismaPg({ connectionString: getConnectionString() }),
|
|
91
|
+
errorFormat: "pretty",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
// SEED_PROFILE is intentionally separate from NODE_ENV.
|
|
96
|
+
// Railway sets NODE_ENV=production on ALL deployed services (including staging)
|
|
97
|
+
// for build/performance reasons, so NODE_ENV cannot reliably distinguish staging
|
|
98
|
+
// from production at seed time. Set SEED_PROFILE explicitly in your deploy command:
|
|
99
|
+
// production: SEED_PROFILE=minimal pnpm db:seed
|
|
100
|
+
// staging: pnpm db:seed (defaults to "dev")
|
|
101
|
+
const seedProfile = process.env.SEED_PROFILE || "dev";
|
|
102
|
+
console.log(`🌱 Seeding database with profile: "${seedProfile}"`);
|
|
103
|
+
|
|
104
|
+
if (seedProfile === "minimal") {
|
|
105
|
+
await seedMinimal(prisma);
|
|
106
|
+
} else {
|
|
107
|
+
await seedDev(prisma);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log("✅ Database seeding completed");
|
|
111
|
+
} finally {
|
|
112
|
+
await prisma.$disconnect();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
main().catch((error) => {
|
|
117
|
+
console.error("❌ Seed failed:", error.message);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
});
|