@usequark/quark-create-app 1.23.11

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 (237) hide show
  1. package/README.md +210 -0
  2. package/package.json +56 -0
  3. package/src/dependabot-config.test.js +128 -0
  4. package/src/deploy/adapters/iac.js +203 -0
  5. package/src/deploy/adapters/index.js +27 -0
  6. package/src/deploy/adapters/railway.js +508 -0
  7. package/src/deploy/adapters/railway.test.js +318 -0
  8. package/src/deploy/contract.js +59 -0
  9. package/src/deploy/deploy.integration.test.js +183 -0
  10. package/src/deploy/deploy.js +430 -0
  11. package/src/deploy/deploy.test.js +105 -0
  12. package/src/deploy/discovery.js +115 -0
  13. package/src/deploy/discovery.test.js +141 -0
  14. package/src/deploy/index.js +38 -0
  15. package/src/deploy/inspect.js +130 -0
  16. package/src/index.js +2920 -0
  17. package/src/mobile-template.test.js +232 -0
  18. package/src/prepare-hooks.test.js +535 -0
  19. package/src/scaffold-guards.test.js +140 -0
  20. package/src/scaffold-output.test.js +228 -0
  21. package/src/template-scripts.test.js +160 -0
  22. package/src/utils.js +77 -0
  23. package/src/utils.test.js +95 -0
  24. package/src/worker-dockerfile.test.js +85 -0
  25. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  26. package/templates/base-project/.dockerignore +14 -0
  27. package/templates/base-project/.env.railway.example +87 -0
  28. package/templates/base-project/.github/copilot-instructions.md +65 -0
  29. package/templates/base-project/.github/dependabot.yml +31 -0
  30. package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
  31. package/templates/base-project/.github/workflows/ci.yml +116 -0
  32. package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
  33. package/templates/base-project/.github/workflows/install-windows.yml +27 -0
  34. package/templates/base-project/.github/workflows/release.yml +67 -0
  35. package/templates/base-project/.railway/railway.ts +40 -0
  36. package/templates/base-project/CLAUDE.md +163 -0
  37. package/templates/base-project/MAIN.md +21 -0
  38. package/templates/base-project/README.md +164 -0
  39. package/templates/base-project/apps/web/.env.example +99 -0
  40. package/templates/base-project/apps/web/Dockerfile +38 -0
  41. package/templates/base-project/apps/web/biome.json +8 -0
  42. package/templates/base-project/apps/web/jsconfig.json +9 -0
  43. package/templates/base-project/apps/web/next.config.js +154 -0
  44. package/templates/base-project/apps/web/package.json +42 -0
  45. package/templates/base-project/apps/web/postcss.config.mjs +7 -0
  46. package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
  47. package/templates/base-project/apps/web/public/quark.svg +47 -0
  48. package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
  49. package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
  50. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  51. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  52. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  53. package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
  54. package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
  55. package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
  56. package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
  57. package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
  58. package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
  59. package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
  60. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
  61. package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
  62. package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
  63. package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
  64. package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
  65. package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
  66. package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
  67. package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
  68. package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
  69. package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
  70. package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
  71. package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
  72. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
  73. package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
  74. package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
  75. package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
  76. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
  77. package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
  78. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  79. package/templates/base-project/apps/web/src/app/global-error.js +58 -0
  80. package/templates/base-project/apps/web/src/app/globals.css +1166 -0
  81. package/templates/base-project/apps/web/src/app/icon.svg +47 -0
  82. package/templates/base-project/apps/web/src/app/layout.js +57 -0
  83. package/templates/base-project/apps/web/src/app/manifest.js +12 -0
  84. package/templates/base-project/apps/web/src/app/not-found.js +39 -0
  85. package/templates/base-project/apps/web/src/app/page.js +262 -0
  86. package/templates/base-project/apps/web/src/app/robots.js +23 -0
  87. package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
  88. package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
  89. package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
  90. package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
  91. package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
  92. package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
  93. package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
  94. package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
  95. package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
  96. package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
  97. package/templates/base-project/apps/web/src/lib/auth.js +191 -0
  98. package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
  99. package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
  100. package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
  101. package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
  102. package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
  103. package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
  104. package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
  105. package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
  106. package/templates/base-project/apps/web/src/preflight.js +55 -0
  107. package/templates/base-project/apps/web/src/proxy.js +328 -0
  108. package/templates/base-project/biome.json +52 -0
  109. package/templates/base-project/docker-compose.override.yml +17 -0
  110. package/templates/base-project/docker-compose.yml +37 -0
  111. package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
  112. package/templates/base-project/docs/START_HERE.md +70 -0
  113. package/templates/base-project/docs/adr/README.md +42 -0
  114. package/templates/base-project/package.json +58 -0
  115. package/templates/base-project/packages/db/package.json +38 -0
  116. package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
  117. package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
  118. package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
  119. package/templates/base-project/packages/db/prisma/seed.js +152 -0
  120. package/templates/base-project/packages/db/prisma.config.js +27 -0
  121. package/templates/base-project/packages/db/src/client.js +75 -0
  122. package/templates/base-project/packages/db/src/connection.js +44 -0
  123. package/templates/base-project/packages/db/src/context.js +275 -0
  124. package/templates/base-project/packages/db/src/index.js +5 -0
  125. package/templates/base-project/packages/db/src/ping.js +66 -0
  126. package/templates/base-project/packages/db/src/queries.js +292 -0
  127. package/templates/base-project/packages/db/src/schemas.js +30 -0
  128. package/templates/base-project/packages/db/src/umami.js +299 -0
  129. package/templates/base-project/pnpm-workspace.yaml +20 -0
  130. package/templates/base-project/scripts/check-loading.mjs +49 -0
  131. package/templates/base-project/scripts/check-standards.mjs +186 -0
  132. package/templates/base-project/scripts/clean-workspace.mjs +479 -0
  133. package/templates/base-project/scripts/dev-preflight.mjs +48 -0
  134. package/templates/base-project/scripts/doctor.js +408 -0
  135. package/templates/base-project/scripts/prepare.js +490 -0
  136. package/templates/base-project/scripts/run-tests.mjs +71 -0
  137. package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
  138. package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
  139. package/templates/base-project/skills/add-model/SKILL.md +28 -0
  140. package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
  141. package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
  142. package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
  143. package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
  144. package/templates/base-project/skills/i18n/SKILL.md +211 -0
  145. package/templates/base-project/skills/payment/SKILL.md +280 -0
  146. package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
  147. package/templates/base-project/turbo.json +143 -0
  148. package/templates/config/package.json +20 -0
  149. package/templates/config/src/app-url.js +134 -0
  150. package/templates/config/src/environment.js +284 -0
  151. package/templates/config/src/index.js +16 -0
  152. package/templates/config/src/load-config.js +157 -0
  153. package/templates/config/src/rate-limit.js +136 -0
  154. package/templates/config/src/redis.js +43 -0
  155. package/templates/config/src/validate-env.js +532 -0
  156. package/templates/jobs/README.md +52 -0
  157. package/templates/jobs/package.json +9 -0
  158. package/templates/jobs/src/definitions.js +13 -0
  159. package/templates/jobs/src/index.js +1 -0
  160. package/templates/mobile/.env.example +16 -0
  161. package/templates/mobile/CHECKLIST.md +40 -0
  162. package/templates/mobile/README.md +130 -0
  163. package/templates/mobile/app/(app)/_layout.tsx +83 -0
  164. package/templates/mobile/app/(app)/index.tsx +147 -0
  165. package/templates/mobile/app/(app)/profile.tsx +204 -0
  166. package/templates/mobile/app/(app)/settings.tsx +80 -0
  167. package/templates/mobile/app/(auth)/_layout.tsx +10 -0
  168. package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
  169. package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
  170. package/templates/mobile/app/+not-found.tsx +32 -0
  171. package/templates/mobile/app/_layout.tsx +18 -0
  172. package/templates/mobile/app.json +47 -0
  173. package/templates/mobile/assets/adaptive-icon.png +0 -0
  174. package/templates/mobile/assets/icon.png +0 -0
  175. package/templates/mobile/assets/notification-icon.png +0 -0
  176. package/templates/mobile/assets/splash.png +0 -0
  177. package/templates/mobile/biome.json +27 -0
  178. package/templates/mobile/components/AuthGate.tsx +19 -0
  179. package/templates/mobile/components/ThemeProvider.tsx +23 -0
  180. package/templates/mobile/eas.json +29 -0
  181. package/templates/mobile/hooks/use-auth.ts +127 -0
  182. package/templates/mobile/lib/api-client.ts +138 -0
  183. package/templates/mobile/lib/api.ts +83 -0
  184. package/templates/mobile/lib/auth-providers-google.ts +74 -0
  185. package/templates/mobile/lib/auth-providers.ts +74 -0
  186. package/templates/mobile/lib/auth.ts +100 -0
  187. package/templates/mobile/lib/config.ts +14 -0
  188. package/templates/mobile/lib/errors.ts +46 -0
  189. package/templates/mobile/lib/notifications.ts +77 -0
  190. package/templates/mobile/lib/storage.ts +25 -0
  191. package/templates/mobile/lib/theme-tokens.ts +16 -0
  192. package/templates/mobile/package.json +37 -0
  193. package/templates/mobile/tsconfig.json +10 -0
  194. package/templates/pwa/app/_components/PWARegister.js +15 -0
  195. package/templates/pwa/app/manifest.js +32 -0
  196. package/templates/pwa/public/sw.js +72 -0
  197. package/templates/ui/README.md +44 -0
  198. package/templates/ui/package.json +21 -0
  199. package/templates/ui/src/badge.js +23 -0
  200. package/templates/ui/src/button.js +111 -0
  201. package/templates/ui/src/card.js +128 -0
  202. package/templates/ui/src/checkbox.js +20 -0
  203. package/templates/ui/src/container.js +13 -0
  204. package/templates/ui/src/dialog.js +137 -0
  205. package/templates/ui/src/error-banner.js +15 -0
  206. package/templates/ui/src/footer.js +239 -0
  207. package/templates/ui/src/form-field.integration-test.js +142 -0
  208. package/templates/ui/src/form-field.js +50 -0
  209. package/templates/ui/src/index.js +24 -0
  210. package/templates/ui/src/input.js +11 -0
  211. package/templates/ui/src/label.js +14 -0
  212. package/templates/ui/src/lightbox.integration-test.js +167 -0
  213. package/templates/ui/src/lightbox.js +243 -0
  214. package/templates/ui/src/logo.js +52 -0
  215. package/templates/ui/src/navbar.js +602 -0
  216. package/templates/ui/src/password-input.js +31 -0
  217. package/templates/ui/src/rich-text.js +514 -0
  218. package/templates/ui/src/select.js +317 -0
  219. package/templates/ui/src/skeleton.js +10 -0
  220. package/templates/ui/src/spinner.js +31 -0
  221. package/templates/ui/src/table.js +169 -0
  222. package/templates/ui/src/textarea.js +11 -0
  223. package/templates/ui/src/theme-constants.js +24 -0
  224. package/templates/ui/src/theme.js +196 -0
  225. package/templates/ui/src/toast.js +206 -0
  226. package/templates/worker/Dockerfile +64 -0
  227. package/templates/worker/README.md +56 -0
  228. package/templates/worker/package.json +36 -0
  229. package/templates/worker/src/config/pricing.js +46 -0
  230. package/templates/worker/src/handlers/email-job-validation.js +36 -0
  231. package/templates/worker/src/handlers/email.js +98 -0
  232. package/templates/worker/src/handlers/files.js +59 -0
  233. package/templates/worker/src/handlers/index.js +20 -0
  234. package/templates/worker/src/handlers/push-notification.js +234 -0
  235. package/templates/worker/src/index.js +505 -0
  236. package/templates/worker/src/lib/railway.js +438 -0
  237. package/templates/worker/src/lib/tokens.js +81 -0
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Worker Service
3
+ * Processes background jobs using BullMQ and Redis
4
+ * Handles job execution, retries, and error tracking
5
+ */
6
+
7
+ import {
8
+ checkQueueHealth,
9
+ createLogger,
10
+ createQueue,
11
+ createWorker,
12
+ getRedisUrl,
13
+ updateQueueDepths,
14
+ } from "@techstream/quark-core";
15
+ import { AppError } from "@techstream/quark-core/errors";
16
+ import { job, prisma } from "@techstream/quark-db";
17
+ import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
18
+ import { jobHandlers } from "./handlers/index.js";
19
+
20
+ const logger = createLogger("worker");
21
+ const isDevMode =
22
+ process.env.NODE_ENV !== "production" &&
23
+ process.env.npm_lifecycle_event === "dev";
24
+
25
+ // Store workers for graceful shutdown
26
+ const workers = [];
27
+ let devDisabledKeepAlive = null;
28
+ let depthTimer = null;
29
+ let isShuttingDown = false;
30
+
31
+ // ============================================================================
32
+ // RESILIENCE UTILITIES
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Detects if error is a connection/network error
37
+ * @param {Error} error
38
+ * @returns {boolean}
39
+ */
40
+ export function isConnectionError(error) {
41
+ if (!error) return false;
42
+ const message = (error.message || "") + (error.code || "");
43
+ const connectionErrors = [
44
+ "ECONNREFUSED", // Connection refused
45
+ "ECONNRESET", // Connection reset
46
+ "ENOTFOUND", // DNS lookup failure
47
+ "ETIMEDOUT", // Connection timeout
48
+ "EHOSTUNREACH", // Host unreachable
49
+ "ENETUNREACH", // Network unreachable
50
+ "Error: Redis connection failed", // Generic redis failure
51
+ "Redis unavailable at", // Final wrapped startup error
52
+ "Ready status is false", // BullMQ readiness
53
+ ];
54
+ return connectionErrors.some((err) => message.includes(err));
55
+ }
56
+
57
+ /**
58
+ * Creates a throttled error logger
59
+ * Suppresses duplicate errors within a time window
60
+ * @param {Object} logger
61
+ * @param {number} windowMs - Throttle window in milliseconds
62
+ * @returns {Function} throttle function
63
+ */
64
+ export function throttledError(logger, windowMs = 5000) {
65
+ let lastErrorTime = 0;
66
+ let lastErrorMsg = "";
67
+
68
+ return (error) => {
69
+ const now = Date.now();
70
+ const msg = error.message || String(error);
71
+
72
+ // Log if new error type or window expired
73
+ if (msg !== lastErrorMsg || now - lastErrorTime > windowMs) {
74
+ logger.warn("Waiting for Redis", {
75
+ reason: msg,
76
+ });
77
+ lastErrorTime = now;
78
+ lastErrorMsg = msg;
79
+ }
80
+ };
81
+ }
82
+
83
+ export function getJobHandlerOrThrow(jobName) {
84
+ const handler = jobHandlers[jobName];
85
+
86
+ if (!handler) {
87
+ throw new AppError(
88
+ `No handler registered for job: ${jobName}`,
89
+ 500,
90
+ "JOB_HANDLER_NOT_REGISTERED",
91
+ );
92
+ }
93
+
94
+ return handler;
95
+ }
96
+
97
+ function disableWorkerInDev() {
98
+ if (!devDisabledKeepAlive) {
99
+ // Keep the process alive so the dev session stays healthy even when the
100
+ // worker is intentionally disabled due to missing Redis.
101
+ devDisabledKeepAlive = setInterval(() => {}, 60_000);
102
+ }
103
+ }
104
+
105
+ export async function waitForWorkerReady(
106
+ queueWorker,
107
+ queueName,
108
+ loggerInstance,
109
+ ) {
110
+ await queueWorker.waitUntilReady();
111
+ loggerInstance.info(
112
+ `Queue "${queueName}" worker started (concurrency: ${queueWorker.opts.concurrency})`,
113
+ );
114
+ return queueWorker;
115
+ }
116
+
117
+ /**
118
+ * Waits for Redis to be ready with retries
119
+ * @param {Function} healthCheck - Async function that returns boolean or throws
120
+ * @param {Object} config
121
+ * @param {number} config.maxRetries - Maximum retry attempts
122
+ * @param {number} config.intervalMs - Delay between retries
123
+ * @returns {Promise<boolean>}
124
+ */
125
+ export async function waitForRedis(
126
+ healthCheck = checkQueueHealth,
127
+ config = {},
128
+ ) {
129
+ const {
130
+ maxRetries = parseInt(process.env.WORKER_HEALTH_RETRIES || "10", 10),
131
+ intervalMs = parseInt(process.env.WORKER_HEALTH_INTERVAL_MS || "1000", 10),
132
+ } = config;
133
+
134
+ const reportThrottledError = throttledError(logger, 3000);
135
+
136
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
137
+ try {
138
+ const isReady = await healthCheck();
139
+ if (isReady) {
140
+ logger.info(
141
+ `Redis health check passed (attempt ${attempt}/${maxRetries})`,
142
+ );
143
+ return true;
144
+ }
145
+ } catch (error) {
146
+ if (isConnectionError(error)) {
147
+ reportThrottledError(error);
148
+ if (attempt < maxRetries) {
149
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
150
+ }
151
+ } else {
152
+ throw new AppError(
153
+ `Redis health check failed: ${error.message}`,
154
+ 503,
155
+ "REDIS_HEALTH_CHECK_FAILED",
156
+ );
157
+ }
158
+ }
159
+ }
160
+
161
+ // All retries exhausted
162
+ throw new AppError(
163
+ `Redis unavailable at ${getRedisUrl()} after ${maxRetries} attempts. Start Redis or check REDIS_URL/REDIS_HOST/REDIS_PORT.`,
164
+ 503,
165
+ "REDIS_UNAVAILABLE",
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Waits for the database schema to be ready (migrations applied).
171
+ * Queries a known table to verify the schema exists, retrying on failure.
172
+ * This prevents the "Failed to persist job event" error when the worker
173
+ * starts before web service migrations finish.
174
+ *
175
+ * @param {Function} healthCheck - Async function that performs a DB query. Throws on failure.
176
+ * @param {Object} config
177
+ * @param {number} config.maxRetries - Maximum retry attempts (default: 30)
178
+ * @param {number} config.intervalMs - Delay between retries in ms (default: 2000)
179
+ * @returns {Promise<boolean>}
180
+ */
181
+ export async function waitForDatabase(
182
+ healthCheck = async () => {
183
+ await prisma.$queryRaw`SELECT 1`;
184
+ return true;
185
+ },
186
+ config = {},
187
+ ) {
188
+ const {
189
+ maxRetries = parseInt(process.env.WORKER_DB_RETRIES || "30", 10),
190
+ intervalMs = parseInt(process.env.WORKER_DB_INTERVAL_MS || "2000", 10),
191
+ } = config;
192
+
193
+ const reportThrottledError = throttledError(logger, 5000);
194
+
195
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
196
+ try {
197
+ const isReady = await healthCheck();
198
+ if (isReady) {
199
+ logger.info(`Database schema ready (attempt ${attempt}/${maxRetries})`);
200
+ return true;
201
+ }
202
+ } catch (error) {
203
+ const message = (error.message || "") + (error.code || "");
204
+ const isSchemaError =
205
+ message.includes("relation") ||
206
+ message.includes("table") ||
207
+ message.includes("does not exist") ||
208
+ message.includes("P2021") ||
209
+ message.includes("P1000") ||
210
+ message.includes("Can't reach database");
211
+
212
+ if (isSchemaError || isConnectionError(error)) {
213
+ reportThrottledError(error);
214
+ if (attempt < maxRetries) {
215
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
216
+ }
217
+ } else {
218
+ // Non-schema error (e.g. query syntax) — fail immediately
219
+ throw new AppError(
220
+ `Database health check failed: ${error.message}`,
221
+ 503,
222
+ "DATABASE_HEALTH_CHECK_FAILED",
223
+ );
224
+ }
225
+ }
226
+ }
227
+
228
+ throw new AppError(
229
+ `Database schema not ready after ${maxRetries} attempts. Ensure migrations have run.`,
230
+ 503,
231
+ "DATABASE_SCHEMA_NOT_READY",
232
+ );
233
+ }
234
+
235
+ // ============================================================================
236
+ // PREFLIGHT MODE
237
+ // ============================================================================
238
+
239
+ /**
240
+ * Runs pre-flight health checks and exits
241
+ * Used for deployment readiness probes
242
+ */
243
+ async function preflight() {
244
+ // Load and validate environment variables
245
+ const { loadEnv } = await import("@techstream/quark-config");
246
+ loadEnv("worker");
247
+
248
+ logger.info("Running preflight health checks");
249
+
250
+ try {
251
+ // Check Redis
252
+ logger.info("Checking Redis connectivity...");
253
+ await checkQueueHealth();
254
+ logger.info("✓ Redis connected");
255
+
256
+ // Check Database
257
+ logger.info("Checking database connectivity...");
258
+ await prisma.$queryRaw`SELECT 1`;
259
+ logger.info("✓ Database connected");
260
+
261
+ // Validate handlers are registered for every known job name.
262
+ // No queues are created here - handler presence is a registry check,
263
+ // and createQueue() instances would only need closing again.
264
+ logger.info("Checking job handler registration...");
265
+ const handlerCount = Object.values(JOB_NAMES).filter(
266
+ (jobName) => jobHandlers[jobName],
267
+ ).length;
268
+ logger.info(`✓ ${handlerCount} job handlers registered`);
269
+
270
+ logger.info("✓ All preflight checks passed");
271
+ process.exit(0);
272
+ } catch (error) {
273
+ logger.error("Preflight check failed", {
274
+ error: error.message,
275
+ stack: error.stack,
276
+ });
277
+ process.exit(1);
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Generic queue processor - dispatches jobs to registered handlers
283
+ * @param {string} queueName
284
+ */
285
+ async function createQueueWorker(queueName) {
286
+ const queueWorker = createWorker(
287
+ queueName,
288
+ async (bullJob) => {
289
+ const handler = getJobHandlerOrThrow(bullJob.name);
290
+
291
+ return handler(bullJob, logger);
292
+ },
293
+ {
294
+ concurrency: parseInt(process.env.WORKER_CONCURRENCY || "5", 10),
295
+ },
296
+ );
297
+
298
+ workers.push(queueWorker);
299
+
300
+ async function persistJob(bullJob, status, extra = {}) {
301
+ try {
302
+ await job.upsert(bullJob.id, {
303
+ queue: queueName,
304
+ name: bullJob.name,
305
+ data: bullJob.data,
306
+ attempts: bullJob.attemptsMade,
307
+ status,
308
+ ...extra,
309
+ });
310
+ } catch (error) {
311
+ logger.error("Failed to persist job event", {
312
+ error: error.message,
313
+ jobId: bullJob.id,
314
+ status,
315
+ });
316
+ }
317
+ }
318
+
319
+ queueWorker.on("active", (bullJob) => {
320
+ persistJob(bullJob, "IN_PROGRESS", { startedAt: new Date() });
321
+ });
322
+
323
+ queueWorker.on("completed", (bullJob, result) => {
324
+ logger.info(`Job ${bullJob.id} (${bullJob.name}) completed`, { result });
325
+ persistJob(bullJob, "COMPLETED", { completedAt: new Date() });
326
+ });
327
+
328
+ queueWorker.on("failed", (bullJob, error) => {
329
+ logger.error(
330
+ `Job ${bullJob.id} (${bullJob.name}) failed after ${bullJob.attemptsMade} attempts`,
331
+ {
332
+ error: error.message,
333
+ jobName: bullJob.name,
334
+ attemptsMade: bullJob.attemptsMade,
335
+ },
336
+ );
337
+ persistJob(bullJob, "FAILED", {
338
+ error: error.message,
339
+ completedAt: new Date(),
340
+ });
341
+ });
342
+
343
+ queueWorker.on("stalled", (jobId) => {
344
+ logger.warn(`Job ${jobId} in queue "${queueName}" has stalled`, {
345
+ queueName,
346
+ jobId,
347
+ });
348
+ });
349
+
350
+ queueWorker.on("error", (error) => {
351
+ logger.error(`Worker error in queue "${queueName}"`, {
352
+ error: error.message,
353
+ queueName,
354
+ });
355
+ });
356
+
357
+ return waitForWorkerReady(queueWorker, queueName, logger);
358
+ }
359
+
360
+ /**
361
+ * Start the worker service
362
+ */
363
+ async function startWorker() {
364
+ // Load and validate environment variables
365
+ const { loadEnv } = await import("@techstream/quark-config");
366
+ loadEnv("worker");
367
+
368
+ logger.info("Starting Quark Worker Service");
369
+
370
+ try {
371
+ // Pre-flight: Wait for Redis with health checks and retries
372
+ logger.info("Performing health checks...");
373
+ await waitForRedis(
374
+ checkQueueHealth,
375
+ isDevMode ? { maxRetries: 3, intervalMs: 500 } : {},
376
+ );
377
+
378
+ logger.info("Redis connected", { address: getRedisUrl() });
379
+
380
+ // Wait for database schema to be ready (migrations may still be running)
381
+ logger.info("Checking database schema readiness...");
382
+ await waitForDatabase(isDevMode ? { maxRetries: 5, intervalMs: 1000 } : {});
383
+ logger.info("Database schema ready");
384
+
385
+ // Register a worker for each queue
386
+ await Promise.all(
387
+ Object.values(JOB_QUEUES).map((queueName) =>
388
+ createQueueWorker(queueName),
389
+ ),
390
+ );
391
+
392
+ // Schedule repeating cleanup job (runs every 24 hours)
393
+ // Uses upsertJobScheduler for atomic registration - prevents duplicate schedulers on worker restart
394
+ const filesQueue = createQueue(JOB_QUEUES.FILES);
395
+ await filesQueue.upsertJobScheduler(
396
+ "cleanup-orphaned-files",
397
+ { every: 24 * 60 * 60 * 1000 },
398
+ { name: JOB_NAMES.CLEANUP_ORPHANED_FILES, data: { retentionHours: 24 } },
399
+ );
400
+
401
+ // Keep job_queue_depth gauge current for Prometheus scraping.
402
+ // Seed it immediately so the gauge isn't empty on the first scrape.
403
+ updateQueueDepths().catch((error) => {
404
+ logger.warn("Initial queue depth update failed", {
405
+ error: error.message,
406
+ });
407
+ });
408
+ depthTimer = setInterval(() => {
409
+ updateQueueDepths().catch((error) => {
410
+ logger.warn("Queue depth update failed", { error: error.message });
411
+ });
412
+ }, 30_000);
413
+
414
+ logger.info("Worker service ready");
415
+ } catch (error) {
416
+ if (isDevMode && isConnectionError(error)) {
417
+ logger.warn("Redis unavailable - worker disabled in dev", {
418
+ action:
419
+ "Start Redis and restart the worker when background jobs are needed.",
420
+ });
421
+ disableWorkerInDev();
422
+ return;
423
+ }
424
+
425
+ logger.error("Failed to start worker service", {
426
+ error: error.message,
427
+ });
428
+ process.exit(1);
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Graceful shutdown handler
434
+ */
435
+ async function shutdown(signal = "unknown") {
436
+ if (isShuttingDown) {
437
+ logger.warn("Shutdown already in progress", { signal });
438
+ return;
439
+ }
440
+
441
+ isShuttingDown = true;
442
+ logger.info("Shutting down worker service", { signal });
443
+
444
+ try {
445
+ if (depthTimer) {
446
+ clearInterval(depthTimer);
447
+ depthTimer = null;
448
+ }
449
+
450
+ if (devDisabledKeepAlive) {
451
+ clearInterval(devDisabledKeepAlive);
452
+ devDisabledKeepAlive = null;
453
+ }
454
+
455
+ for (const worker of workers) {
456
+ await worker.close();
457
+ }
458
+
459
+ try {
460
+ await prisma.$disconnect();
461
+ } catch (error) {
462
+ if (error.message?.includes("environment variable is required")) {
463
+ logger.warn("Skipping Prisma disconnect due missing database env", {
464
+ error: error.message,
465
+ });
466
+ } else {
467
+ throw error;
468
+ }
469
+ }
470
+
471
+ logger.info("All workers closed");
472
+ process.exit(0);
473
+ } catch (error) {
474
+ logger.error("Error during shutdown", {
475
+ error: error.message,
476
+ stack: error.stack,
477
+ });
478
+ process.exit(1);
479
+ }
480
+ }
481
+
482
+ process.on("SIGTERM", () => {
483
+ void shutdown("SIGTERM");
484
+ });
485
+ process.on("SIGINT", () => {
486
+ void shutdown("SIGINT");
487
+ });
488
+
489
+ // ============================================================================
490
+ // ENTRY POINT
491
+ // ============================================================================
492
+
493
+ // Only start the worker if this file is being run directly
494
+ // Convert file:// URL to path for comparison
495
+ const currentFile = new URL(import.meta.url).pathname;
496
+ const mainModule = process.argv[1];
497
+ const isMainModule = currentFile === mainModule;
498
+
499
+ if (isMainModule) {
500
+ if (process.argv.includes("--preflight")) {
501
+ void preflight();
502
+ } else {
503
+ void startWorker();
504
+ }
505
+ }