@mars-stack/cli 0.2.0 → 0.2.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.
- package/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- 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
|