@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.
- package/README.md +210 -0
- package/package.json +56 -0
- package/src/dependabot-config.test.js +128 -0
- package/src/deploy/adapters/iac.js +203 -0
- package/src/deploy/adapters/index.js +27 -0
- package/src/deploy/adapters/railway.js +508 -0
- package/src/deploy/adapters/railway.test.js +318 -0
- package/src/deploy/contract.js +59 -0
- package/src/deploy/deploy.integration.test.js +183 -0
- package/src/deploy/deploy.js +430 -0
- package/src/deploy/deploy.test.js +105 -0
- package/src/deploy/discovery.js +115 -0
- package/src/deploy/discovery.test.js +141 -0
- package/src/deploy/index.js +38 -0
- package/src/deploy/inspect.js +130 -0
- package/src/index.js +2920 -0
- package/src/mobile-template.test.js +232 -0
- package/src/prepare-hooks.test.js +535 -0
- package/src/scaffold-guards.test.js +140 -0
- package/src/scaffold-output.test.js +228 -0
- package/src/template-scripts.test.js +160 -0
- package/src/utils.js +77 -0
- package/src/utils.test.js +95 -0
- package/src/worker-dockerfile.test.js +85 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.dockerignore +14 -0
- package/templates/base-project/.env.railway.example +87 -0
- package/templates/base-project/.github/copilot-instructions.md +65 -0
- package/templates/base-project/.github/dependabot.yml +31 -0
- package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
- package/templates/base-project/.github/workflows/ci.yml +116 -0
- package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
- package/templates/base-project/.github/workflows/install-windows.yml +27 -0
- package/templates/base-project/.github/workflows/release.yml +67 -0
- package/templates/base-project/.railway/railway.ts +40 -0
- package/templates/base-project/CLAUDE.md +163 -0
- package/templates/base-project/MAIN.md +21 -0
- package/templates/base-project/README.md +164 -0
- package/templates/base-project/apps/web/.env.example +99 -0
- package/templates/base-project/apps/web/Dockerfile +38 -0
- package/templates/base-project/apps/web/biome.json +8 -0
- package/templates/base-project/apps/web/jsconfig.json +9 -0
- package/templates/base-project/apps/web/next.config.js +154 -0
- package/templates/base-project/apps/web/package.json +42 -0
- package/templates/base-project/apps/web/postcss.config.mjs +7 -0
- package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
- package/templates/base-project/apps/web/public/quark.svg +47 -0
- package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
- package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
- package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
- package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
- package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
- package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
- package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
- package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
- package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
- package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
- package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
- package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
- package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
- package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
- package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
- package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +58 -0
- package/templates/base-project/apps/web/src/app/globals.css +1166 -0
- package/templates/base-project/apps/web/src/app/icon.svg +47 -0
- package/templates/base-project/apps/web/src/app/layout.js +57 -0
- package/templates/base-project/apps/web/src/app/manifest.js +12 -0
- package/templates/base-project/apps/web/src/app/not-found.js +39 -0
- package/templates/base-project/apps/web/src/app/page.js +262 -0
- package/templates/base-project/apps/web/src/app/robots.js +23 -0
- package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
- package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
- package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +191 -0
- package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
- package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
- package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
- package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
- package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
- package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
- package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
- package/templates/base-project/apps/web/src/preflight.js +55 -0
- package/templates/base-project/apps/web/src/proxy.js +328 -0
- package/templates/base-project/biome.json +52 -0
- package/templates/base-project/docker-compose.override.yml +17 -0
- package/templates/base-project/docker-compose.yml +37 -0
- package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
- package/templates/base-project/docs/START_HERE.md +70 -0
- package/templates/base-project/docs/adr/README.md +42 -0
- package/templates/base-project/package.json +58 -0
- package/templates/base-project/packages/db/package.json +38 -0
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
- package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
- package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
- package/templates/base-project/packages/db/prisma/seed.js +152 -0
- package/templates/base-project/packages/db/prisma.config.js +27 -0
- package/templates/base-project/packages/db/src/client.js +75 -0
- package/templates/base-project/packages/db/src/connection.js +44 -0
- package/templates/base-project/packages/db/src/context.js +275 -0
- package/templates/base-project/packages/db/src/index.js +5 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/packages/db/src/queries.js +292 -0
- package/templates/base-project/packages/db/src/schemas.js +30 -0
- package/templates/base-project/packages/db/src/umami.js +299 -0
- package/templates/base-project/pnpm-workspace.yaml +20 -0
- package/templates/base-project/scripts/check-loading.mjs +49 -0
- package/templates/base-project/scripts/check-standards.mjs +186 -0
- package/templates/base-project/scripts/clean-workspace.mjs +479 -0
- package/templates/base-project/scripts/dev-preflight.mjs +48 -0
- package/templates/base-project/scripts/doctor.js +408 -0
- package/templates/base-project/scripts/prepare.js +490 -0
- package/templates/base-project/scripts/run-tests.mjs +71 -0
- package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
- package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
- package/templates/base-project/skills/add-model/SKILL.md +28 -0
- package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
- package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
- package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
- package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
- package/templates/base-project/skills/i18n/SKILL.md +211 -0
- package/templates/base-project/skills/payment/SKILL.md +280 -0
- package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
- package/templates/base-project/turbo.json +143 -0
- package/templates/config/package.json +20 -0
- package/templates/config/src/app-url.js +134 -0
- package/templates/config/src/environment.js +284 -0
- package/templates/config/src/index.js +16 -0
- package/templates/config/src/load-config.js +157 -0
- package/templates/config/src/rate-limit.js +136 -0
- package/templates/config/src/redis.js +43 -0
- package/templates/config/src/validate-env.js +532 -0
- package/templates/jobs/README.md +52 -0
- package/templates/jobs/package.json +9 -0
- package/templates/jobs/src/definitions.js +13 -0
- package/templates/jobs/src/index.js +1 -0
- package/templates/mobile/.env.example +16 -0
- package/templates/mobile/CHECKLIST.md +40 -0
- package/templates/mobile/README.md +130 -0
- package/templates/mobile/app/(app)/_layout.tsx +83 -0
- package/templates/mobile/app/(app)/index.tsx +147 -0
- package/templates/mobile/app/(app)/profile.tsx +204 -0
- package/templates/mobile/app/(app)/settings.tsx +80 -0
- package/templates/mobile/app/(auth)/_layout.tsx +10 -0
- package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
- package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
- package/templates/mobile/app/+not-found.tsx +32 -0
- package/templates/mobile/app/_layout.tsx +18 -0
- package/templates/mobile/app.json +47 -0
- package/templates/mobile/assets/adaptive-icon.png +0 -0
- package/templates/mobile/assets/icon.png +0 -0
- package/templates/mobile/assets/notification-icon.png +0 -0
- package/templates/mobile/assets/splash.png +0 -0
- package/templates/mobile/biome.json +27 -0
- package/templates/mobile/components/AuthGate.tsx +19 -0
- package/templates/mobile/components/ThemeProvider.tsx +23 -0
- package/templates/mobile/eas.json +29 -0
- package/templates/mobile/hooks/use-auth.ts +127 -0
- package/templates/mobile/lib/api-client.ts +138 -0
- package/templates/mobile/lib/api.ts +83 -0
- package/templates/mobile/lib/auth-providers-google.ts +74 -0
- package/templates/mobile/lib/auth-providers.ts +74 -0
- package/templates/mobile/lib/auth.ts +100 -0
- package/templates/mobile/lib/config.ts +14 -0
- package/templates/mobile/lib/errors.ts +46 -0
- package/templates/mobile/lib/notifications.ts +77 -0
- package/templates/mobile/lib/storage.ts +25 -0
- package/templates/mobile/lib/theme-tokens.ts +16 -0
- package/templates/mobile/package.json +37 -0
- package/templates/mobile/tsconfig.json +10 -0
- package/templates/pwa/app/_components/PWARegister.js +15 -0
- package/templates/pwa/app/manifest.js +32 -0
- package/templates/pwa/public/sw.js +72 -0
- package/templates/ui/README.md +44 -0
- package/templates/ui/package.json +21 -0
- package/templates/ui/src/badge.js +23 -0
- package/templates/ui/src/button.js +111 -0
- package/templates/ui/src/card.js +128 -0
- package/templates/ui/src/checkbox.js +20 -0
- package/templates/ui/src/container.js +13 -0
- package/templates/ui/src/dialog.js +137 -0
- package/templates/ui/src/error-banner.js +15 -0
- package/templates/ui/src/footer.js +239 -0
- package/templates/ui/src/form-field.integration-test.js +142 -0
- package/templates/ui/src/form-field.js +50 -0
- package/templates/ui/src/index.js +24 -0
- package/templates/ui/src/input.js +11 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/lightbox.integration-test.js +167 -0
- package/templates/ui/src/lightbox.js +243 -0
- package/templates/ui/src/logo.js +52 -0
- package/templates/ui/src/navbar.js +602 -0
- package/templates/ui/src/password-input.js +31 -0
- package/templates/ui/src/rich-text.js +514 -0
- package/templates/ui/src/select.js +317 -0
- package/templates/ui/src/skeleton.js +10 -0
- package/templates/ui/src/spinner.js +31 -0
- package/templates/ui/src/table.js +169 -0
- package/templates/ui/src/textarea.js +11 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +196 -0
- package/templates/ui/src/toast.js +206 -0
- package/templates/worker/Dockerfile +64 -0
- package/templates/worker/README.md +56 -0
- package/templates/worker/package.json +36 -0
- package/templates/worker/src/config/pricing.js +46 -0
- package/templates/worker/src/handlers/email-job-validation.js +36 -0
- package/templates/worker/src/handlers/email.js +98 -0
- package/templates/worker/src/handlers/files.js +59 -0
- package/templates/worker/src/handlers/index.js +20 -0
- package/templates/worker/src/handlers/push-notification.js +234 -0
- package/templates/worker/src/index.js +505 -0
- package/templates/worker/src/lib/railway.js +438 -0
- 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
|
+
}
|