@donkeylabs/server 0.1.1 → 0.1.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/docs/jobs.md ADDED
@@ -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
+ ```