@mars-stack/cli 0.2.0 → 1.0.2

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 (175) hide show
  1. package/dist/index.js +137 -12
  2. package/dist/index.js.map +1 -1
  3. package/package.json +4 -3
  4. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  5. package/template/.cursor/rules/data-access.mdc +29 -0
  6. package/template/.cursor/rules/project-structure.mdc +34 -0
  7. package/template/.cursor/rules/security.mdc +25 -0
  8. package/template/.cursor/rules/testing.mdc +24 -0
  9. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  10. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  11. package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
  12. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  13. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  14. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  15. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  16. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  17. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  18. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  19. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  20. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  21. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  22. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  23. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  24. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  25. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  26. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  27. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  28. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  29. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  30. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  31. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  32. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  33. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  34. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  35. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  36. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  37. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  38. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  39. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  40. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  41. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  42. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  43. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  44. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  45. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  46. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  47. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  48. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  49. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  50. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  51. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  52. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  53. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  54. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  55. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  56. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  57. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  58. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  59. package/template/AGENTS.md +104 -0
  60. package/template/ARCHITECTURE.md +102 -0
  61. package/template/docs/QUALITY_SCORE.md +20 -0
  62. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  63. package/template/docs/design-docs/core-beliefs.md +43 -0
  64. package/template/docs/design-docs/index.md +8 -0
  65. package/template/docs/exec-plans/active/.gitkeep +0 -0
  66. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  67. package/template/docs/exec-plans/tech-debt.md +7 -0
  68. package/template/docs/generated/.gitkeep +0 -0
  69. package/template/docs/product-specs/index.md +7 -0
  70. package/template/docs/references/index.md +18 -0
  71. package/template/e2e/api.spec.ts +20 -0
  72. package/template/e2e/auth.spec.ts +24 -0
  73. package/template/e2e/public.spec.ts +25 -0
  74. package/template/eslint.config.mjs +24 -0
  75. package/template/next-env.d.ts +6 -0
  76. package/template/next.config.ts +45 -0
  77. package/template/package.json +80 -0
  78. package/template/playwright.config.ts +31 -0
  79. package/template/postcss.config.mjs +8 -0
  80. package/template/prisma/generated/prisma/browser.ts +49 -0
  81. package/template/prisma/generated/prisma/client.ts +73 -0
  82. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  83. package/template/prisma/generated/prisma/enums.ts +15 -0
  84. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  85. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  86. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  87. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  88. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  89. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  90. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  91. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  92. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  93. package/template/prisma/generated/prisma/models.ts +17 -0
  94. package/template/prisma/schema/auth.prisma +69 -0
  95. package/template/prisma/schema/base.prisma +8 -0
  96. package/template/prisma/schema/file.prisma +15 -0
  97. package/template/prisma/schema/subscription.prisma +17 -0
  98. package/template/prisma.config.ts +13 -0
  99. package/template/scripts/check-architecture.ts +221 -0
  100. package/template/scripts/check-doc-freshness.ts +242 -0
  101. package/template/scripts/ensure-db.mjs +291 -0
  102. package/template/scripts/generate-docs.ts +143 -0
  103. package/template/scripts/generate-env-example.ts +89 -0
  104. package/template/scripts/seed.ts +56 -0
  105. package/template/scripts/update-quality-score.ts +263 -0
  106. package/template/src/__tests__/architecture.test.ts +114 -0
  107. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  108. package/template/src/app/(auth)/layout.tsx +11 -0
  109. package/template/src/app/(auth)/register/page.tsx +162 -0
  110. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  111. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  112. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  113. package/template/src/app/(auth)/verify/page.tsx +56 -0
  114. package/template/src/app/(protected)/admin/page.tsx +108 -0
  115. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  116. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  117. package/template/src/app/(protected)/layout.tsx +262 -0
  118. package/template/src/app/(protected)/settings/page.tsx +370 -0
  119. package/template/src/app/api/auth/forgot/route.ts +63 -0
  120. package/template/src/app/api/auth/login/route.ts +121 -0
  121. package/template/src/app/api/auth/logout/route.ts +19 -0
  122. package/template/src/app/api/auth/me/route.ts +30 -0
  123. package/template/src/app/api/auth/reset/route.ts +45 -0
  124. package/template/src/app/api/auth/signup/route.ts +85 -0
  125. package/template/src/app/api/auth/verify/route.ts +46 -0
  126. package/template/src/app/api/csrf/route.ts +12 -0
  127. package/template/src/app/api/health/route.ts +10 -0
  128. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  129. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  130. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  131. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  132. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  133. package/template/src/app/api/protected/user/password/route.ts +63 -0
  134. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  135. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  136. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  137. package/template/src/app/api/readiness/route.ts +15 -0
  138. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  139. package/template/src/app/error.tsx +33 -0
  140. package/template/src/app/layout.tsx +29 -0
  141. package/template/src/app/not-found.tsx +20 -0
  142. package/template/src/app/page.tsx +136 -0
  143. package/template/src/app/privacy/page.tsx +178 -0
  144. package/template/src/app/providers.tsx +8 -0
  145. package/template/src/app/terms/page.tsx +139 -0
  146. package/template/src/config/app.config.ts +70 -0
  147. package/template/src/config/routes.ts +17 -0
  148. package/template/src/features/admin/index.ts +11 -0
  149. package/template/src/features/admin/permissions.ts +64 -0
  150. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  151. package/template/src/features/auth/context/index.ts +2 -0
  152. package/template/src/features/auth/index.ts +3 -0
  153. package/template/src/features/auth/server/consent.ts +66 -0
  154. package/template/src/features/auth/server/session-revocation.ts +20 -0
  155. package/template/src/features/auth/server/sessions.ts +66 -0
  156. package/template/src/features/auth/server/user.ts +166 -0
  157. package/template/src/features/auth/types.ts +19 -0
  158. package/template/src/features/auth/validators.ts +29 -0
  159. package/template/src/features/billing/server/index.ts +66 -0
  160. package/template/src/features/billing/types.ts +43 -0
  161. package/template/src/features/uploads/server/index.ts +49 -0
  162. package/template/src/features/uploads/types.ts +26 -0
  163. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  164. package/template/src/lib/core/email/templates/index.ts +4 -0
  165. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  166. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  167. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  168. package/template/src/lib/mars.ts +56 -0
  169. package/template/src/lib/prisma.ts +19 -0
  170. package/template/src/proxy.ts +92 -0
  171. package/template/src/styles/brand.css +15 -0
  172. package/template/src/styles/globals.css +7 -0
  173. package/template/tsconfig.json +59 -0
  174. package/template/vitest.config.ts +41 -0
  175. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,500 @@
1
+ # Skill: Configure Background Jobs
2
+
3
+ Set up background job processing in a MARS application using Inngest, Trigger.dev, or a zero-dependency DB queue.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add background jobs, async processing, task queues, scheduled tasks, cron jobs, or deferred work (e.g., "add email digests", "process webhooks in background", "schedule a report").
8
+
9
+ ## Prerequisites
10
+
11
+ - `appConfig.services.jobs.provider` set to `'inngest'`, `'trigger'`, or `'db-queue'`
12
+ - For Inngest/Trigger.dev: an account with the respective provider
13
+ - For DB Queue: Prisma and PostgreSQL already configured
14
+
15
+ ## Provider Interface
16
+
17
+ All providers implement a common interface so you can swap later:
18
+
19
+ ```typescript
20
+ // src/features/jobs/types.ts
21
+ export interface EnqueueOptions {
22
+ runAt?: Date;
23
+ maxAttempts?: number;
24
+ idempotencyKey?: string;
25
+ }
26
+
27
+ export interface JobProvider {
28
+ enqueue(type: string, payload: Record<string, unknown>, options?: EnqueueOptions): Promise<string>;
29
+ process(type: string, handler: (payload: Record<string, unknown>) => Promise<void>): void;
30
+ }
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Option A: Inngest
36
+
37
+ ### Step 1: Install Inngest
38
+
39
+ ```bash
40
+ yarn add inngest
41
+ ```
42
+
43
+ ### Step 2: Environment Variables
44
+
45
+ ```bash
46
+ INNGEST_EVENT_KEY="your-event-key"
47
+ INNGEST_SIGNING_KEY="your-signing-key"
48
+ ```
49
+
50
+ ### Step 3: Create the Inngest Client
51
+
52
+ ```typescript
53
+ // src/lib/core/inngest/client.ts
54
+ import 'server-only';
55
+
56
+ import { Inngest } from 'inngest';
57
+
58
+ export const inngest = new Inngest({
59
+ id: 'mars-app',
60
+ eventKey: process.env.INNGEST_EVENT_KEY,
61
+ });
62
+ ```
63
+
64
+ ### Step 4: Define a Function
65
+
66
+ ```typescript
67
+ // src/features/jobs/server/functions/send-email-digest.ts
68
+ import 'server-only';
69
+
70
+ import { inngest } from '@/lib/core/inngest/client';
71
+ import { prisma } from '@/lib/prisma';
72
+
73
+ export const sendEmailDigest = inngest.createFunction(
74
+ {
75
+ id: 'send-email-digest',
76
+ retries: 3,
77
+ },
78
+ { event: 'jobs/email-digest.requested' },
79
+ async ({ event, step }) => {
80
+ const { userId } = event.data;
81
+
82
+ const user = await step.run('fetch-user', async () => {
83
+ return prisma.user.findUniqueOrThrow({ where: { id: userId } });
84
+ });
85
+
86
+ const activities = await step.run('fetch-activities', async () => {
87
+ return prisma.activity.findMany({
88
+ where: { userId, createdAt: { gte: new Date(Date.now() - 7 * 86400000) } },
89
+ orderBy: { createdAt: 'desc' },
90
+ });
91
+ });
92
+
93
+ await step.run('send-email', async () => {
94
+ // Use your email service to send the digest
95
+ });
96
+
97
+ return { sent: true, activityCount: activities.length };
98
+ },
99
+ );
100
+ ```
101
+
102
+ ### Step 5: Serve via API Route
103
+
104
+ ```typescript
105
+ // src/app/api/inngest/route.ts
106
+ import { serve } from 'inngest/next';
107
+ import { inngest } from '@/lib/core/inngest/client';
108
+ import { sendEmailDigest } from '@/features/jobs/server/functions/send-email-digest';
109
+
110
+ export const { GET, POST, PUT } = serve({
111
+ client: inngest,
112
+ functions: [sendEmailDigest],
113
+ });
114
+ ```
115
+
116
+ ### Step 6: Trigger a Job
117
+
118
+ ```typescript
119
+ import { inngest } from '@/lib/core/inngest/client';
120
+
121
+ await inngest.send({
122
+ name: 'jobs/email-digest.requested',
123
+ data: { userId: 'user_123' },
124
+ });
125
+ ```
126
+
127
+ ### Step 7: Scheduled / Cron Functions
128
+
129
+ ```typescript
130
+ export const weeklyReport = inngest.createFunction(
131
+ { id: 'weekly-report' },
132
+ { cron: '0 9 * * 1' }, // Every Monday at 9am
133
+ async ({ step }) => {
134
+ // Generate and send weekly report
135
+ },
136
+ );
137
+ ```
138
+
139
+ ### Local Development
140
+
141
+ Run the Inngest dev server alongside your app:
142
+
143
+ ```bash
144
+ npx inngest-cli@latest dev
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Option B: Trigger.dev
150
+
151
+ ### Step 1: Install Trigger.dev
152
+
153
+ ```bash
154
+ yarn add @trigger.dev/sdk
155
+ ```
156
+
157
+ ### Step 2: Environment Variables
158
+
159
+ ```bash
160
+ TRIGGER_SECRET_KEY="tr_dev_..."
161
+ TRIGGER_API_URL="https://api.trigger.dev"
162
+ ```
163
+
164
+ ### Step 3: Create the Trigger Client
165
+
166
+ ```typescript
167
+ // src/lib/core/trigger/client.ts
168
+ import 'server-only';
169
+
170
+ import { TriggerClient } from '@trigger.dev/sdk';
171
+
172
+ export const triggerClient = new TriggerClient({
173
+ id: 'mars-app',
174
+ apiKey: process.env.TRIGGER_SECRET_KEY,
175
+ apiUrl: process.env.TRIGGER_API_URL,
176
+ });
177
+ ```
178
+
179
+ ### Step 4: Define a Task
180
+
181
+ ```typescript
182
+ // src/features/jobs/server/tasks/export-data.ts
183
+ import 'server-only';
184
+
185
+ import { task } from '@trigger.dev/sdk/v3';
186
+ import { prisma } from '@/lib/prisma';
187
+
188
+ export const exportDataTask = task({
189
+ id: 'export-data',
190
+ retry: { maxAttempts: 3 },
191
+ run: async (payload: { userId: string; format: 'csv' | 'json' }) => {
192
+ const records = await prisma.record.findMany({
193
+ where: { userId: payload.userId },
194
+ });
195
+
196
+ // Transform and upload to storage
197
+ return { recordCount: records.length };
198
+ },
199
+ });
200
+ ```
201
+
202
+ ### Step 5: Trigger a Task
203
+
204
+ ```typescript
205
+ import { tasks } from '@trigger.dev/sdk/v3';
206
+
207
+ await tasks.trigger('export-data', {
208
+ userId: 'user_123',
209
+ format: 'csv',
210
+ });
211
+ ```
212
+
213
+ ### Step 6: Scheduled Tasks
214
+
215
+ ```typescript
216
+ import { schedules } from '@trigger.dev/sdk/v3';
217
+
218
+ export const dailyCleanup = schedules.task({
219
+ id: 'daily-cleanup',
220
+ cron: '0 2 * * *', // 2am daily
221
+ run: async () => {
222
+ // Cleanup expired records
223
+ },
224
+ });
225
+ ```
226
+
227
+ ---
228
+
229
+ ## Option C: DB Queue (Zero Dependencies)
230
+
231
+ A serverless-compatible job queue backed by Prisma and PostgreSQL. No external services required.
232
+
233
+ ### Step 1: Prisma Schema
234
+
235
+ ```prisma
236
+ // prisma/schema/job.prisma
237
+ model Job {
238
+ id String @id @default(cuid())
239
+ type String
240
+ payload Json
241
+ status String @default("pending") // pending | processing | completed | failed
242
+ attempts Int @default(0)
243
+ maxAttempts Int @default(3)
244
+ runAt DateTime @default(now())
245
+ startedAt DateTime?
246
+ completedAt DateTime?
247
+ error String?
248
+ idempotencyKey String? @unique
249
+ createdAt DateTime @default(now())
250
+ updatedAt DateTime @updatedAt
251
+
252
+ @@index([status, runAt])
253
+ @@index([type])
254
+ }
255
+ ```
256
+
257
+ Run `yarn db:push` to sync the schema.
258
+
259
+ ### Step 2: Job Service
260
+
261
+ ```typescript
262
+ // src/features/jobs/server/index.ts
263
+ import 'server-only';
264
+
265
+ import { prisma } from '@/lib/prisma';
266
+ import type { EnqueueOptions } from '@/features/jobs/types';
267
+
268
+ export async function enqueueJob(
269
+ type: string,
270
+ payload: Record<string, unknown>,
271
+ options?: EnqueueOptions,
272
+ ): Promise<string> {
273
+ const job = await prisma.job.create({
274
+ data: {
275
+ type,
276
+ payload,
277
+ runAt: options?.runAt ?? new Date(),
278
+ maxAttempts: options?.maxAttempts ?? 3,
279
+ idempotencyKey: options?.idempotencyKey,
280
+ },
281
+ });
282
+ return job.id;
283
+ }
284
+
285
+ export async function claimNextJob(type: string) {
286
+ // Atomic claim using updateMany to prevent double-processing
287
+ const now = new Date();
288
+
289
+ const jobs = await prisma.$queryRaw<Array<{ id: string }>>`
290
+ UPDATE "Job"
291
+ SET status = 'processing', "startedAt" = ${now}, attempts = attempts + 1
292
+ WHERE id = (
293
+ SELECT id FROM "Job"
294
+ WHERE type = ${type}
295
+ AND status = 'pending'
296
+ AND "runAt" <= ${now}
297
+ AND attempts < "maxAttempts"
298
+ ORDER BY "runAt" ASC
299
+ LIMIT 1
300
+ FOR UPDATE SKIP LOCKED
301
+ )
302
+ RETURNING id
303
+ `;
304
+
305
+ if (jobs.length === 0) return null;
306
+
307
+ return prisma.job.findUnique({ where: { id: jobs[0].id } });
308
+ }
309
+
310
+ export async function completeJob(jobId: string) {
311
+ await prisma.job.update({
312
+ where: { id: jobId },
313
+ data: { status: 'completed', completedAt: new Date() },
314
+ });
315
+ }
316
+
317
+ export async function failJob(jobId: string, error: string) {
318
+ const job = await prisma.job.findUnique({ where: { id: jobId } });
319
+ if (!job) return;
320
+
321
+ const newStatus = job.attempts >= job.maxAttempts ? 'failed' : 'pending';
322
+
323
+ await prisma.job.update({
324
+ where: { id: jobId },
325
+ data: { status: newStatus, error, startedAt: null },
326
+ });
327
+ }
328
+
329
+ export async function cleanupCompletedJobs(olderThanDays: number = 30) {
330
+ const cutoff = new Date(Date.now() - olderThanDays * 86400000);
331
+
332
+ const { count } = await prisma.job.deleteMany({
333
+ where: {
334
+ status: { in: ['completed', 'failed'] },
335
+ updatedAt: { lt: cutoff },
336
+ },
337
+ });
338
+
339
+ return count;
340
+ }
341
+ ```
342
+
343
+ ### Step 3: Worker API Route
344
+
345
+ This route is called by a cron trigger (Vercel Cron, external cron service, etc.) to process pending jobs. No `setInterval` -- fully serverless compatible.
346
+
347
+ ```typescript
348
+ // src/app/api/protected/jobs/process/route.ts
349
+ import { handleApiError, withRole } from '@/lib/mars';
350
+ import { claimNextJob, completeJob, failJob } from '@/features/jobs/server';
351
+ import { jobHandlers } from '@/features/jobs/server/handlers';
352
+ import { NextResponse } from 'next/server';
353
+
354
+ export const maxDuration = 60;
355
+
356
+ export const POST = withRole(['admin'], async (request) => {
357
+ try {
358
+ const { type } = await request.json();
359
+ const handler = jobHandlers[type];
360
+
361
+ if (!handler) {
362
+ return NextResponse.json({ error: `No handler for job type: ${type}` }, { status: 400 });
363
+ }
364
+
365
+ let processed = 0;
366
+ const maxBatch = 10;
367
+
368
+ for (let i = 0; i < maxBatch; i++) {
369
+ const job = await claimNextJob(type);
370
+ if (!job) break;
371
+
372
+ try {
373
+ await handler(job.payload as Record<string, unknown>);
374
+ await completeJob(job.id);
375
+ processed++;
376
+ } catch (error) {
377
+ await failJob(job.id, error instanceof Error ? error.message : 'Unknown error');
378
+ }
379
+ }
380
+
381
+ return NextResponse.json({ processed });
382
+ } catch (error) {
383
+ return handleApiError(error, { endpoint: '/api/protected/jobs/process' });
384
+ }
385
+ });
386
+ ```
387
+
388
+ ### Step 4: Job Handlers Registry
389
+
390
+ ```typescript
391
+ // src/features/jobs/server/handlers.ts
392
+ import 'server-only';
393
+
394
+ type JobHandler = (payload: Record<string, unknown>) => Promise<void>;
395
+
396
+ export const jobHandlers: Record<string, JobHandler> = {};
397
+
398
+ export function registerJobHandler(type: string, handler: JobHandler) {
399
+ jobHandlers[type] = handler;
400
+ }
401
+ ```
402
+
403
+ Register handlers at module load:
404
+
405
+ ```typescript
406
+ // src/features/jobs/server/handlers/email-digest.ts
407
+ import 'server-only';
408
+
409
+ import { registerJobHandler } from '@/features/jobs/server/handlers';
410
+
411
+ registerJobHandler('email-digest', async (payload) => {
412
+ const { userId } = payload as { userId: string };
413
+ // Build and send the email digest
414
+ });
415
+ ```
416
+
417
+ ### Step 5: Cron Configuration
418
+
419
+ For Vercel, add to `vercel.json`:
420
+
421
+ ```json
422
+ {
423
+ "crons": [
424
+ {
425
+ "path": "/api/protected/jobs/process",
426
+ "schedule": "*/5 * * * *"
427
+ }
428
+ ]
429
+ }
430
+ ```
431
+
432
+ For cleanup, add a separate cron or call `cleanupCompletedJobs()` as part of the processing route.
433
+
434
+ ---
435
+
436
+ ## Common Use Cases
437
+
438
+ | Use Case | Job Type | Recommended Provider |
439
+ |---|---|---|
440
+ | Email digests | `email-digest` | Any |
441
+ | Data export (CSV/JSON) | `data-export` | Inngest / Trigger.dev |
442
+ | Webhook delivery with retry | `webhook-delivery` | Any |
443
+ | Scheduled reports | `scheduled-report` | Inngest (cron) / DB Queue (Vercel cron) |
444
+ | Image processing | `image-process` | Trigger.dev (long-running) |
445
+ | Cleanup expired data | `data-cleanup` | DB Queue (Vercel cron) |
446
+
447
+ ## Config Integration
448
+
449
+ Add to `src/config/app.config.ts`:
450
+
451
+ ```typescript
452
+ export const appConfig = {
453
+ // ... existing config
454
+ services: {
455
+ // ... existing services
456
+ jobs: {
457
+ provider: 'inngest' as const, // 'inngest' | 'trigger' | 'db-queue'
458
+ },
459
+ },
460
+ };
461
+ ```
462
+
463
+ ## Testing
464
+
465
+ Mock the job provider in tests:
466
+
467
+ ```typescript
468
+ import { describe, it, expect, vi } from 'vitest';
469
+
470
+ vi.mock('@/features/jobs/server', () => ({
471
+ enqueueJob: vi.fn().mockResolvedValue('job_123'),
472
+ claimNextJob: vi.fn(),
473
+ completeJob: vi.fn(),
474
+ failJob: vi.fn(),
475
+ }));
476
+ ```
477
+
478
+ For Inngest, use their test helper:
479
+
480
+ ```typescript
481
+ import { inngest } from '@/lib/core/inngest/client';
482
+
483
+ // In tests, assert events were sent
484
+ const events = inngest.createFunction.mock.calls;
485
+ ```
486
+
487
+ ## Checklist
488
+
489
+ - [ ] Provider chosen and configured in `app.config.ts`
490
+ - [ ] Environment variables set for chosen provider
491
+ - [ ] Client/service module created with `import 'server-only'`
492
+ - [ ] Job functions/tasks defined
493
+ - [ ] API route for serving (Inngest) or processing (DB Queue) created
494
+ - [ ] Job handler registry set up (DB Queue)
495
+ - [ ] Cron schedule configured (if using scheduled jobs)
496
+ - [ ] Idempotency keys used where needed to prevent duplicate processing
497
+ - [ ] Retry logic configured with sensible `maxAttempts`
498
+ - [ ] Cleanup strategy for completed/failed jobs
499
+ - [ ] No `setInterval` or `setTimeout` -- serverless compatible
500
+ - [ ] Tests written for job handlers