@donkeylabs/server 0.1.3 → 0.1.4
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/examples/starter/node_modules/@donkeylabs/server/README.md +15 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/commands/generate.ts +461 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/commands/init.ts +476 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/commands/interactive.ts +223 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/commands/plugin.ts +192 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/donkeylabs +106 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/index.ts +100 -0
- package/examples/starter/node_modules/@donkeylabs/server/context.d.ts +17 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/api-client.md +520 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/cache.md +437 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/cli.md +353 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/core-services.md +338 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/cron.md +465 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/errors.md +303 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/events.md +460 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/handlers.md +549 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/jobs.md +556 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/logger.md +316 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/middleware.md +682 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/plugins.md +524 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/project-structure.md +493 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/rate-limiter.md +525 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/router.md +566 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/sse.md +542 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/svelte-frontend.md +324 -0
- package/examples/starter/node_modules/@donkeylabs/server/mcp/donkeylabs-mcp +3238 -0
- package/examples/starter/node_modules/@donkeylabs/server/mcp/server.ts +3238 -0
- package/examples/starter/node_modules/@donkeylabs/server/package.json +77 -0
- package/examples/starter/node_modules/@donkeylabs/server/registry.d.ts +11 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/client/base.ts +481 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/client/index.ts +150 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/cache.ts +183 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/cron.ts +255 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/errors.ts +320 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/events.ts +163 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/index.ts +94 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/jobs.ts +334 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/logger.ts +131 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/rate-limiter.ts +193 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/sse.ts +210 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core.ts +428 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/handlers.ts +87 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/harness.ts +70 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/index.ts +38 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/middleware.ts +34 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/registry.ts +13 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/router.ts +155 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/server.ts +234 -0
- package/examples/starter/node_modules/@donkeylabs/server/templates/init/donkeylabs.config.ts.template +14 -0
- package/examples/starter/node_modules/@donkeylabs/server/templates/init/index.ts.template +41 -0
- package/examples/starter/node_modules/@donkeylabs/server/templates/plugin/index.ts.template +25 -0
- package/examples/starter/src/routes/health/ping/models/model.ts +11 -7
- package/package.json +3 -3
- package/examples/starter/node_modules/.svelte2tsx-language-server-files/svelte-native-jsx.d.ts +0 -32
- package/examples/starter/node_modules/.svelte2tsx-language-server-files/svelte-shims-v4.d.ts +0 -290
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
# Jobs Service
|
|
2
|
+
|
|
3
|
+
Background job queue for processing tasks asynchronously with automatic retries, scheduling, and event integration.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// Register a job handler
|
|
9
|
+
ctx.core.jobs.register("sendEmail", async (data) => {
|
|
10
|
+
await emailService.send(data.to, data.subject, data.body);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Enqueue a job for immediate processing
|
|
14
|
+
await ctx.core.jobs.enqueue("sendEmail", {
|
|
15
|
+
to: "user@example.com",
|
|
16
|
+
subject: "Welcome!",
|
|
17
|
+
body: "Thanks for signing up.",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Schedule a job for later
|
|
21
|
+
await ctx.core.jobs.schedule("sendEmail", data, new Date(Date.now() + 3600000));
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## API Reference
|
|
27
|
+
|
|
28
|
+
### Interface
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
interface Jobs {
|
|
32
|
+
register<T = any, R = any>(name: string, handler: JobHandler<T, R>): void;
|
|
33
|
+
enqueue<T = any>(name: string, data: T, options?: { maxAttempts?: number }): Promise<string>;
|
|
34
|
+
schedule<T = any>(name: string, data: T, runAt: Date, options?: { maxAttempts?: number }): Promise<string>;
|
|
35
|
+
get(jobId: string): Promise<Job | null>;
|
|
36
|
+
cancel(jobId: string): Promise<boolean>;
|
|
37
|
+
getByName(name: string, status?: JobStatus): Promise<Job[]>;
|
|
38
|
+
start(): void;
|
|
39
|
+
stop(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type JobStatus = "pending" | "running" | "completed" | "failed" | "scheduled";
|
|
43
|
+
|
|
44
|
+
interface Job {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
data: any;
|
|
48
|
+
status: JobStatus;
|
|
49
|
+
createdAt: Date;
|
|
50
|
+
runAt?: Date;
|
|
51
|
+
startedAt?: Date;
|
|
52
|
+
completedAt?: Date;
|
|
53
|
+
result?: any;
|
|
54
|
+
error?: string;
|
|
55
|
+
attempts: number;
|
|
56
|
+
maxAttempts: number;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Methods
|
|
61
|
+
|
|
62
|
+
| Method | Description |
|
|
63
|
+
|--------|-------------|
|
|
64
|
+
| `register(name, handler)` | Register a job handler |
|
|
65
|
+
| `enqueue(name, data, opts?)` | Queue job for immediate processing |
|
|
66
|
+
| `schedule(name, data, runAt, opts?)` | Queue job for future execution |
|
|
67
|
+
| `get(jobId)` | Get job by ID |
|
|
68
|
+
| `cancel(jobId)` | Cancel pending/scheduled job |
|
|
69
|
+
| `getByName(name, status?)` | Find jobs by name and optional status |
|
|
70
|
+
| `start()` | Start processing jobs |
|
|
71
|
+
| `stop()` | Stop processing (waits for active jobs) |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const server = new AppServer({
|
|
79
|
+
db,
|
|
80
|
+
jobs: {
|
|
81
|
+
concurrency: 5, // Max parallel jobs (default: 5)
|
|
82
|
+
pollInterval: 1000, // Check interval in ms (default: 1000)
|
|
83
|
+
maxAttempts: 3, // Default retry attempts (default: 3)
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Usage Examples
|
|
91
|
+
|
|
92
|
+
### Registering Handlers
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// plugins/email/index.ts
|
|
96
|
+
service: async (ctx) => {
|
|
97
|
+
// Register job handler during plugin init
|
|
98
|
+
ctx.core.jobs.register("sendEmail", async (data: {
|
|
99
|
+
to: string;
|
|
100
|
+
subject: string;
|
|
101
|
+
body: string;
|
|
102
|
+
template?: string;
|
|
103
|
+
}) => {
|
|
104
|
+
const html = data.template
|
|
105
|
+
? await renderTemplate(data.template, data)
|
|
106
|
+
: data.body;
|
|
107
|
+
|
|
108
|
+
const result = await emailProvider.send({
|
|
109
|
+
to: data.to,
|
|
110
|
+
subject: data.subject,
|
|
111
|
+
html,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return { messageId: result.id };
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
async sendWelcome(email: string, name: string) {
|
|
119
|
+
return ctx.core.jobs.enqueue("sendEmail", {
|
|
120
|
+
to: email,
|
|
121
|
+
subject: "Welcome!",
|
|
122
|
+
template: "welcome",
|
|
123
|
+
name,
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Enqueuing Jobs
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// From route handlers
|
|
134
|
+
router.route("register").typed({
|
|
135
|
+
handle: async (input, ctx) => {
|
|
136
|
+
const user = await ctx.db.insertInto("users").values(input).execute();
|
|
137
|
+
|
|
138
|
+
// Queue welcome email
|
|
139
|
+
await ctx.core.jobs.enqueue("sendEmail", {
|
|
140
|
+
to: input.email,
|
|
141
|
+
subject: "Welcome!",
|
|
142
|
+
body: `Hi ${input.name}, thanks for signing up!`,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Queue with custom retry settings
|
|
146
|
+
await ctx.core.jobs.enqueue("syncToMailchimp", {
|
|
147
|
+
email: input.email,
|
|
148
|
+
name: input.name,
|
|
149
|
+
}, { maxAttempts: 5 });
|
|
150
|
+
|
|
151
|
+
return user;
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Scheduling Jobs
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// Schedule for specific time
|
|
160
|
+
const reminderTime = new Date();
|
|
161
|
+
reminderTime.setHours(reminderTime.getHours() + 24);
|
|
162
|
+
|
|
163
|
+
await ctx.core.jobs.schedule("sendReminder", {
|
|
164
|
+
userId: user.id,
|
|
165
|
+
message: "Don't forget to complete your profile!",
|
|
166
|
+
}, reminderTime);
|
|
167
|
+
|
|
168
|
+
// Schedule relative to now
|
|
169
|
+
await ctx.core.jobs.schedule("expireSession", {
|
|
170
|
+
sessionId: session.id,
|
|
171
|
+
}, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)); // 7 days
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Job Status Tracking
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// Get specific job
|
|
178
|
+
const jobId = await ctx.core.jobs.enqueue("processFile", { fileId: 123 });
|
|
179
|
+
const job = await ctx.core.jobs.get(jobId);
|
|
180
|
+
|
|
181
|
+
console.log(job.status); // "pending" | "running" | "completed" | "failed"
|
|
182
|
+
console.log(job.attempts); // Number of attempts so far
|
|
183
|
+
console.log(job.result); // Result if completed
|
|
184
|
+
console.log(job.error); // Error message if failed
|
|
185
|
+
|
|
186
|
+
// Find all pending jobs of a type
|
|
187
|
+
const pendingEmails = await ctx.core.jobs.getByName("sendEmail", "pending");
|
|
188
|
+
|
|
189
|
+
// Cancel a job
|
|
190
|
+
const cancelled = await ctx.core.jobs.cancel(jobId);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Event Integration
|
|
196
|
+
|
|
197
|
+
Jobs automatically emit events on completion and failure:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
// Listen for job completions
|
|
201
|
+
ctx.core.events.on("job.completed", async (data) => {
|
|
202
|
+
console.log(`Job ${data.jobId} (${data.name}) completed with result:`, data.result);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Listen for specific job type
|
|
206
|
+
ctx.core.events.on("job.sendEmail.completed", async (data) => {
|
|
207
|
+
await updateEmailStatus(data.result.messageId, "sent");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Listen for failures
|
|
211
|
+
ctx.core.events.on("job.failed", async (data) => {
|
|
212
|
+
ctx.core.logger.error("Job failed", {
|
|
213
|
+
jobId: data.jobId,
|
|
214
|
+
name: data.name,
|
|
215
|
+
error: data.error,
|
|
216
|
+
attempts: data.attempts,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Alert on critical failures
|
|
220
|
+
if (data.name === "processPayment") {
|
|
221
|
+
await alertOps(`Payment job failed: ${data.error}`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Real-World Examples
|
|
229
|
+
|
|
230
|
+
### Image Processing Pipeline
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// Register handlers for each step
|
|
234
|
+
ctx.core.jobs.register("processImage", async (data) => {
|
|
235
|
+
const { imageId, operations } = data;
|
|
236
|
+
const image = await loadImage(imageId);
|
|
237
|
+
|
|
238
|
+
for (const op of operations) {
|
|
239
|
+
switch (op) {
|
|
240
|
+
case "resize":
|
|
241
|
+
await resizeImage(image, { width: 800 });
|
|
242
|
+
break;
|
|
243
|
+
case "optimize":
|
|
244
|
+
await optimizeImage(image);
|
|
245
|
+
break;
|
|
246
|
+
case "thumbnail":
|
|
247
|
+
await createThumbnail(image, { width: 200 });
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await saveImage(image);
|
|
253
|
+
return { processed: true, operations };
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// In route handler
|
|
257
|
+
router.route("upload").typed({
|
|
258
|
+
handle: async (input, ctx) => {
|
|
259
|
+
const image = await saveUploadedImage(input.file);
|
|
260
|
+
|
|
261
|
+
const jobId = await ctx.core.jobs.enqueue("processImage", {
|
|
262
|
+
imageId: image.id,
|
|
263
|
+
operations: ["resize", "optimize", "thumbnail"],
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return { imageId: image.id, processingJob: jobId };
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Order Processing
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
// Complex order workflow
|
|
275
|
+
ctx.core.jobs.register("processOrder", async (data) => {
|
|
276
|
+
const { orderId } = data;
|
|
277
|
+
const order = await getOrder(orderId);
|
|
278
|
+
|
|
279
|
+
// Step 1: Validate inventory
|
|
280
|
+
for (const item of order.items) {
|
|
281
|
+
const available = await checkInventory(item.productId, item.quantity);
|
|
282
|
+
if (!available) {
|
|
283
|
+
throw new Error(`Insufficient inventory for ${item.productId}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Step 2: Process payment
|
|
288
|
+
const payment = await processPayment(order);
|
|
289
|
+
if (!payment.success) {
|
|
290
|
+
throw new Error(`Payment failed: ${payment.error}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Step 3: Reserve inventory
|
|
294
|
+
await reserveInventory(order.items);
|
|
295
|
+
|
|
296
|
+
// Step 4: Queue fulfillment
|
|
297
|
+
await ctx.core.jobs.enqueue("fulfillOrder", { orderId });
|
|
298
|
+
|
|
299
|
+
return { paymentId: payment.id };
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
ctx.core.jobs.register("fulfillOrder", async (data) => {
|
|
303
|
+
const { orderId } = data;
|
|
304
|
+
|
|
305
|
+
// Generate shipping label
|
|
306
|
+
const label = await createShippingLabel(orderId);
|
|
307
|
+
|
|
308
|
+
// Notify warehouse
|
|
309
|
+
await notifyWarehouse(orderId, label);
|
|
310
|
+
|
|
311
|
+
// Send confirmation
|
|
312
|
+
await ctx.core.jobs.enqueue("sendEmail", {
|
|
313
|
+
to: order.customerEmail,
|
|
314
|
+
template: "order-confirmation",
|
|
315
|
+
orderId,
|
|
316
|
+
trackingNumber: label.trackingNumber,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return { trackingNumber: label.trackingNumber };
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Report Generation
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
ctx.core.jobs.register("generateReport", async (data) => {
|
|
327
|
+
const { reportType, dateRange, userId } = data;
|
|
328
|
+
|
|
329
|
+
ctx.core.logger.info("Generating report", { reportType, dateRange });
|
|
330
|
+
|
|
331
|
+
let report;
|
|
332
|
+
switch (reportType) {
|
|
333
|
+
case "sales":
|
|
334
|
+
report = await generateSalesReport(dateRange);
|
|
335
|
+
break;
|
|
336
|
+
case "inventory":
|
|
337
|
+
report = await generateInventoryReport(dateRange);
|
|
338
|
+
break;
|
|
339
|
+
case "users":
|
|
340
|
+
report = await generateUsersReport(dateRange);
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Save report
|
|
345
|
+
const reportId = await saveReport(report);
|
|
346
|
+
|
|
347
|
+
// Notify user
|
|
348
|
+
await ctx.core.jobs.enqueue("sendEmail", {
|
|
349
|
+
to: await getUserEmail(userId),
|
|
350
|
+
subject: `Your ${reportType} report is ready`,
|
|
351
|
+
body: `Download your report: /reports/${reportId}`,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
return { reportId };
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Queue from cron
|
|
358
|
+
ctx.core.cron.schedule("0 6 * * 1", async () => {
|
|
359
|
+
// Weekly sales report every Monday at 6am
|
|
360
|
+
await ctx.core.jobs.enqueue("generateReport", {
|
|
361
|
+
reportType: "sales",
|
|
362
|
+
dateRange: { start: lastWeek(), end: today() },
|
|
363
|
+
userId: adminUserId,
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## Retry Behavior
|
|
371
|
+
|
|
372
|
+
Jobs automatically retry on failure:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
ctx.core.jobs.register("flakyTask", async (data) => {
|
|
376
|
+
// This might fail sometimes
|
|
377
|
+
if (Math.random() < 0.5) {
|
|
378
|
+
throw new Error("Random failure");
|
|
379
|
+
}
|
|
380
|
+
return { success: true };
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Enqueue with custom retry count
|
|
384
|
+
await ctx.core.jobs.enqueue("flakyTask", {}, { maxAttempts: 5 });
|
|
385
|
+
|
|
386
|
+
// Job will retry up to 5 times before being marked as "failed"
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Custom Retry Logic
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
ctx.core.jobs.register("apiCall", async (data) => {
|
|
393
|
+
const { url, payload, attempt = 1 } = data;
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const response = await fetch(url, {
|
|
397
|
+
method: "POST",
|
|
398
|
+
body: JSON.stringify(payload),
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
if (!response.ok && response.status >= 500) {
|
|
402
|
+
// Server error - let job system retry
|
|
403
|
+
throw new Error(`Server error: ${response.status}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!response.ok) {
|
|
407
|
+
// Client error - don't retry
|
|
408
|
+
return { success: false, status: response.status };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return { success: true, data: await response.json() };
|
|
412
|
+
} catch (error) {
|
|
413
|
+
if (error.message.includes("fetch failed")) {
|
|
414
|
+
// Network error - retry with backoff
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
// Other errors - don't retry
|
|
418
|
+
return { success: false, error: error.message };
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Custom Adapters
|
|
426
|
+
|
|
427
|
+
Implement `JobAdapter` for persistent storage:
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
interface JobAdapter {
|
|
431
|
+
create(job: Omit<Job, "id">): Promise<Job>;
|
|
432
|
+
get(jobId: string): Promise<Job | null>;
|
|
433
|
+
update(jobId: string, updates: Partial<Job>): Promise<void>;
|
|
434
|
+
delete(jobId: string): Promise<boolean>;
|
|
435
|
+
getPending(limit?: number): Promise<Job[]>;
|
|
436
|
+
getScheduledReady(now: Date): Promise<Job[]>;
|
|
437
|
+
getByName(name: string, status?: JobStatus): Promise<Job[]>;
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### SQLite Adapter Example
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
class SQLiteJobAdapter implements JobAdapter {
|
|
445
|
+
constructor(private db: Kysely<any>) {}
|
|
446
|
+
|
|
447
|
+
async create(job: Omit<Job, "id">): Promise<Job> {
|
|
448
|
+
const result = await this.db
|
|
449
|
+
.insertInto("jobs")
|
|
450
|
+
.values({
|
|
451
|
+
...job,
|
|
452
|
+
data: JSON.stringify(job.data),
|
|
453
|
+
createdAt: job.createdAt.toISOString(),
|
|
454
|
+
runAt: job.runAt?.toISOString(),
|
|
455
|
+
})
|
|
456
|
+
.returning("id")
|
|
457
|
+
.executeTakeFirstOrThrow();
|
|
458
|
+
|
|
459
|
+
return { ...job, id: result.id };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async getPending(limit: number = 100): Promise<Job[]> {
|
|
463
|
+
return this.db
|
|
464
|
+
.selectFrom("jobs")
|
|
465
|
+
.selectAll()
|
|
466
|
+
.where("status", "=", "pending")
|
|
467
|
+
.limit(limit)
|
|
468
|
+
.execute();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ... implement other methods
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## Best Practices
|
|
478
|
+
|
|
479
|
+
### 1. Keep Jobs Idempotent
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
// Good - can be safely retried
|
|
483
|
+
ctx.core.jobs.register("updateStatus", async (data) => {
|
|
484
|
+
await db.updateTable("orders")
|
|
485
|
+
.set({ status: "shipped" })
|
|
486
|
+
.where("id", "=", data.orderId)
|
|
487
|
+
.where("status", "=", "processing") // Only if still processing
|
|
488
|
+
.execute();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Bad - not idempotent
|
|
492
|
+
ctx.core.jobs.register("incrementCounter", async (data) => {
|
|
493
|
+
await db.raw(`UPDATE counters SET value = value + 1 WHERE id = ?`, [data.id]);
|
|
494
|
+
// Re-running increments again!
|
|
495
|
+
});
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### 2. Include Enough Context
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
// Good - all data needed to process
|
|
502
|
+
await ctx.core.jobs.enqueue("processOrder", {
|
|
503
|
+
orderId: "order-123",
|
|
504
|
+
customerId: "cust-456",
|
|
505
|
+
items: order.items,
|
|
506
|
+
total: order.total,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Bad - requires database lookup that might change
|
|
510
|
+
await ctx.core.jobs.enqueue("processOrder", {
|
|
511
|
+
orderId: "order-123",
|
|
512
|
+
// Handler has to look up order, which might have changed
|
|
513
|
+
});
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### 3. Log Job Progress
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
ctx.core.jobs.register("longRunningTask", async (data) => {
|
|
520
|
+
ctx.core.logger.info("Job started", { jobData: data });
|
|
521
|
+
|
|
522
|
+
for (let i = 0; i < data.items.length; i++) {
|
|
523
|
+
ctx.core.logger.debug("Processing item", { index: i, total: data.items.length });
|
|
524
|
+
await processItem(data.items[i]);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
ctx.core.logger.info("Job completed", { itemsProcessed: data.items.length });
|
|
528
|
+
return { processed: data.items.length };
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### 4. Handle Failures Gracefully
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
ctx.core.jobs.register("criticalTask", async (data) => {
|
|
536
|
+
try {
|
|
537
|
+
return await performTask(data);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
// Log with context
|
|
540
|
+
ctx.core.logger.error("Critical task failed", {
|
|
541
|
+
error: error.message,
|
|
542
|
+
data,
|
|
543
|
+
stack: error.stack,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Emit for monitoring
|
|
547
|
+
await ctx.core.events.emit("job.criticalTask.error", {
|
|
548
|
+
error: error.message,
|
|
549
|
+
data,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Re-throw to trigger retry
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
```
|