@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.
@@ -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
- startWorker();
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.0"
24
+ "prisma": "^7.4.2"
28
25
  },
29
26
  "dependencies": {
30
- "@prisma/adapter-pg": "^7.4.0",
31
- "@prisma/client": "^7.4.0",
32
- "pg": "^8.18.0",
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
+ });
@@ -16,6 +16,7 @@ export default defineConfig({
16
16
  schema: "prisma/schema.prisma",
17
17
  migrations: {
18
18
  path: "prisma/migrations",
19
+ seed: "tsx prisma/seed.js",
19
20
  },
20
21
  datasource: {
21
22
  url: databaseUrl,