@monque/core 1.1.0 → 1.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.
@@ -0,0 +1,239 @@
1
+ import type { ChangeStream, ChangeStreamDocument, Document } from 'mongodb';
2
+
3
+ import { JobStatus } from '@/jobs';
4
+
5
+ import type { SchedulerContext } from './types.js';
6
+
7
+ /**
8
+ * Internal service for MongoDB Change Stream lifecycle.
9
+ *
10
+ * Provides real-time job notifications when available, with automatic
11
+ * reconnection and graceful fallback to polling-only mode.
12
+ *
13
+ * @internal Not part of public API.
14
+ */
15
+ export class ChangeStreamHandler {
16
+ /** MongoDB Change Stream for real-time job notifications */
17
+ private changeStream: ChangeStream | null = null;
18
+
19
+ /** Number of consecutive reconnection attempts */
20
+ private reconnectAttempts = 0;
21
+
22
+ /** Maximum reconnection attempts before falling back to polling-only mode */
23
+ private readonly maxReconnectAttempts = 3;
24
+
25
+ /** Debounce timer for change stream event processing */
26
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
27
+
28
+ /** Timer ID for reconnection with exponential backoff */
29
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
30
+
31
+ /** Whether the scheduler is currently using change streams */
32
+ private usingChangeStreams = false;
33
+
34
+ constructor(
35
+ private readonly ctx: SchedulerContext,
36
+ private readonly onPoll: () => Promise<void>,
37
+ ) {}
38
+
39
+ /**
40
+ * Set up MongoDB Change Stream for real-time job notifications.
41
+ *
42
+ * Change streams provide instant notifications when jobs are inserted or when
43
+ * job status changes to pending (e.g., after a retry). This eliminates the
44
+ * polling delay for reactive job processing.
45
+ *
46
+ * The change stream watches for:
47
+ * - Insert operations (new jobs)
48
+ * - Update operations where status field changes
49
+ *
50
+ * If change streams are unavailable (e.g., standalone MongoDB), the system
51
+ * gracefully falls back to polling-only mode.
52
+ */
53
+ setup(): void {
54
+ if (!this.ctx.isRunning()) {
55
+ return;
56
+ }
57
+
58
+ try {
59
+ // Create change stream with pipeline to filter relevant events
60
+ const pipeline = [
61
+ {
62
+ $match: {
63
+ $or: [
64
+ { operationType: 'insert' },
65
+ {
66
+ operationType: 'update',
67
+ 'updateDescription.updatedFields.status': { $exists: true },
68
+ },
69
+ ],
70
+ },
71
+ },
72
+ ];
73
+
74
+ this.changeStream = this.ctx.collection.watch(pipeline, {
75
+ fullDocument: 'updateLookup',
76
+ });
77
+
78
+ // Handle change events
79
+ this.changeStream.on('change', (change) => {
80
+ this.handleEvent(change);
81
+ });
82
+
83
+ // Handle errors with reconnection
84
+ this.changeStream.on('error', (error: Error) => {
85
+ this.ctx.emit('changestream:error', { error });
86
+ this.handleError(error);
87
+ });
88
+
89
+ // Mark as connected
90
+ this.usingChangeStreams = true;
91
+ this.reconnectAttempts = 0;
92
+ this.ctx.emit('changestream:connected', undefined);
93
+ } catch (error) {
94
+ // Change streams not available (e.g., standalone MongoDB)
95
+ this.usingChangeStreams = false;
96
+ const reason = error instanceof Error ? error.message : 'Unknown error';
97
+ this.ctx.emit('changestream:fallback', { reason });
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Handle a change stream event by triggering a debounced poll.
103
+ *
104
+ * Events are debounced to prevent "claim storms" when multiple changes arrive
105
+ * in rapid succession (e.g., bulk job inserts). A 100ms debounce window
106
+ * collects multiple events and triggers a single poll.
107
+ *
108
+ * @param change - The change stream event document
109
+ */
110
+ handleEvent(change: ChangeStreamDocument<Document>): void {
111
+ if (!this.ctx.isRunning()) {
112
+ return;
113
+ }
114
+
115
+ // Trigger poll on insert (new job) or update where status changes
116
+ const isInsert = change.operationType === 'insert';
117
+ const isUpdate = change.operationType === 'update';
118
+
119
+ // Get fullDocument if available (for insert or with updateLookup option)
120
+ const fullDocument = 'fullDocument' in change ? change.fullDocument : undefined;
121
+ const isPendingStatus = fullDocument?.['status'] === JobStatus.PENDING;
122
+
123
+ // For inserts: always trigger since new pending jobs need processing
124
+ // For updates: trigger if status changed to pending (retry/release scenario)
125
+ const shouldTrigger = isInsert || (isUpdate && isPendingStatus);
126
+
127
+ if (shouldTrigger) {
128
+ // Debounce poll triggers to avoid claim storms
129
+ if (this.debounceTimer) {
130
+ clearTimeout(this.debounceTimer);
131
+ }
132
+
133
+ this.debounceTimer = setTimeout(() => {
134
+ this.debounceTimer = null;
135
+ this.onPoll().catch((error: unknown) => {
136
+ this.ctx.emit('job:error', { error: error as Error });
137
+ });
138
+ }, 100);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Handle change stream errors with exponential backoff reconnection.
144
+ *
145
+ * Attempts to reconnect up to `maxReconnectAttempts` times with
146
+ * exponential backoff (base 1000ms). After exhausting retries, falls back to
147
+ * polling-only mode.
148
+ *
149
+ * @param error - The error that caused the change stream failure
150
+ */
151
+ handleError(error: Error): void {
152
+ if (!this.ctx.isRunning()) {
153
+ return;
154
+ }
155
+
156
+ this.reconnectAttempts++;
157
+
158
+ if (this.reconnectAttempts > this.maxReconnectAttempts) {
159
+ // Fall back to polling-only mode
160
+ this.usingChangeStreams = false;
161
+
162
+ if (this.reconnectTimer) {
163
+ clearTimeout(this.reconnectTimer);
164
+ this.reconnectTimer = null;
165
+ }
166
+
167
+ if (this.changeStream) {
168
+ this.changeStream.close().catch(() => {});
169
+ this.changeStream = null;
170
+ }
171
+
172
+ this.ctx.emit('changestream:fallback', {
173
+ reason: `Exhausted ${this.maxReconnectAttempts} reconnection attempts: ${error.message}`,
174
+ });
175
+
176
+ return;
177
+ }
178
+
179
+ // Exponential backoff: 1s, 2s, 4s
180
+ const delay = 2 ** (this.reconnectAttempts - 1) * 1000;
181
+
182
+ // Clear any existing reconnect timer before scheduling a new one
183
+ if (this.reconnectTimer) {
184
+ clearTimeout(this.reconnectTimer);
185
+ }
186
+
187
+ this.reconnectTimer = setTimeout(() => {
188
+ this.reconnectTimer = null;
189
+ if (this.ctx.isRunning()) {
190
+ // Close existing change stream before reconnecting
191
+ if (this.changeStream) {
192
+ this.changeStream.close().catch(() => {});
193
+ this.changeStream = null;
194
+ }
195
+ this.setup();
196
+ }
197
+ }, delay);
198
+ }
199
+
200
+ /**
201
+ * Close the change stream cursor and emit closed event.
202
+ */
203
+ async close(): Promise<void> {
204
+ // Clear debounce timer
205
+ if (this.debounceTimer) {
206
+ clearTimeout(this.debounceTimer);
207
+ this.debounceTimer = null;
208
+ }
209
+
210
+ // Clear reconnection timer
211
+ if (this.reconnectTimer) {
212
+ clearTimeout(this.reconnectTimer);
213
+ this.reconnectTimer = null;
214
+ }
215
+
216
+ if (this.changeStream) {
217
+ try {
218
+ await this.changeStream.close();
219
+ } catch {
220
+ // Ignore close errors during shutdown
221
+ }
222
+ this.changeStream = null;
223
+
224
+ if (this.usingChangeStreams) {
225
+ this.ctx.emit('changestream:closed', undefined);
226
+ }
227
+ }
228
+
229
+ this.usingChangeStreams = false;
230
+ this.reconnectAttempts = 0;
231
+ }
232
+
233
+ /**
234
+ * Check if change streams are currently active.
235
+ */
236
+ isActive(): boolean {
237
+ return this.usingChangeStreams;
238
+ }
239
+ }
@@ -0,0 +1,8 @@
1
+ // Services
2
+ export { ChangeStreamHandler } from './change-stream-handler.js';
3
+ export { JobManager } from './job-manager.js';
4
+ export { JobProcessor } from './job-processor.js';
5
+ export { JobQueryService } from './job-query.js';
6
+ export { JobScheduler } from './job-scheduler.js';
7
+ // Types
8
+ export type { ResolvedMonqueOptions, SchedulerContext } from './types.js';
@@ -0,0 +1,455 @@
1
+ import { ObjectId, type WithId } from 'mongodb';
2
+
3
+ import {
4
+ type BulkOperationResult,
5
+ type Job,
6
+ type JobSelector,
7
+ JobStatus,
8
+ type PersistedJob,
9
+ } from '@/jobs';
10
+ import { buildSelectorQuery } from '@/scheduler';
11
+ import { JobStateError } from '@/shared';
12
+
13
+ import type { SchedulerContext } from './types.js';
14
+
15
+ /**
16
+ * Internal service for job lifecycle management operations.
17
+ *
18
+ * Provides atomic state transitions (cancel, retry, reschedule) and deletion.
19
+ * Emits appropriate events on each operation.
20
+ *
21
+ * @internal Not part of public API - use Monque class methods instead.
22
+ */
23
+ export class JobManager {
24
+ constructor(private readonly ctx: SchedulerContext) {}
25
+
26
+ /**
27
+ * Cancel a pending or scheduled job.
28
+ *
29
+ * Sets the job status to 'cancelled' and emits a 'job:cancelled' event.
30
+ * If the job is already cancelled, this is a no-op and returns the job.
31
+ * Cannot cancel jobs that are currently 'processing', 'completed', or 'failed'.
32
+ *
33
+ * @param jobId - The ID of the job to cancel
34
+ * @returns The cancelled job, or null if not found
35
+ * @throws {JobStateError} If job is in an invalid state for cancellation
36
+ *
37
+ * @example Cancel a pending job
38
+ * ```typescript
39
+ * const job = await monque.enqueue('report', { type: 'daily' });
40
+ * await monque.cancelJob(job._id.toString());
41
+ * ```
42
+ */
43
+ async cancelJob(jobId: string): Promise<PersistedJob<unknown> | null> {
44
+ if (!ObjectId.isValid(jobId)) return null;
45
+
46
+ const _id = new ObjectId(jobId);
47
+
48
+ // Fetch job first to allow emitting the full job object in the event
49
+ const jobDoc = await this.ctx.collection.findOne({ _id });
50
+ if (!jobDoc) return null;
51
+
52
+ const currentJob = jobDoc as unknown as WithId<Job>;
53
+
54
+ if (currentJob.status === JobStatus.CANCELLED) {
55
+ return this.ctx.documentToPersistedJob(currentJob);
56
+ }
57
+
58
+ if (currentJob.status !== JobStatus.PENDING) {
59
+ throw new JobStateError(
60
+ `Cannot cancel job in status '${currentJob.status}'`,
61
+ jobId,
62
+ currentJob.status,
63
+ 'cancel',
64
+ );
65
+ }
66
+
67
+ const result = await this.ctx.collection.findOneAndUpdate(
68
+ { _id, status: JobStatus.PENDING },
69
+ {
70
+ $set: {
71
+ status: JobStatus.CANCELLED,
72
+ updatedAt: new Date(),
73
+ },
74
+ },
75
+ { returnDocument: 'after' },
76
+ );
77
+
78
+ if (!result) {
79
+ // Race condition: job changed state between check and update
80
+ throw new JobStateError(
81
+ 'Job status changed during cancellation attempt',
82
+ jobId,
83
+ 'unknown',
84
+ 'cancel',
85
+ );
86
+ }
87
+
88
+ const job = this.ctx.documentToPersistedJob(result);
89
+ this.ctx.emit('job:cancelled', { job });
90
+ return job;
91
+ }
92
+
93
+ /**
94
+ * Retry a failed or cancelled job.
95
+ *
96
+ * Resets the job to 'pending' status, clears failure count/reason, and sets
97
+ * nextRunAt to now (immediate retry). Emits a 'job:retried' event.
98
+ *
99
+ * @param jobId - The ID of the job to retry
100
+ * @returns The updated job, or null if not found
101
+ * @throws {JobStateError} If job is in an invalid state for retry (must be failed or cancelled)
102
+ *
103
+ * @example Retry a failed job
104
+ * ```typescript
105
+ * monque.on('job:fail', async ({ job }) => {
106
+ * console.log(`Job ${job._id} failed, retrying manually...`);
107
+ * await monque.retryJob(job._id.toString());
108
+ * });
109
+ * ```
110
+ */
111
+ async retryJob(jobId: string): Promise<PersistedJob<unknown> | null> {
112
+ if (!ObjectId.isValid(jobId)) return null;
113
+
114
+ const _id = new ObjectId(jobId);
115
+ const currentJob = await this.ctx.collection.findOne({ _id });
116
+
117
+ if (!currentJob) return null;
118
+
119
+ if (currentJob['status'] !== JobStatus.FAILED && currentJob['status'] !== JobStatus.CANCELLED) {
120
+ throw new JobStateError(
121
+ `Cannot retry job in status '${currentJob['status']}'`,
122
+ jobId,
123
+ currentJob['status'],
124
+ 'retry',
125
+ );
126
+ }
127
+
128
+ const previousStatus = currentJob['status'] as 'failed' | 'cancelled';
129
+
130
+ const result = await this.ctx.collection.findOneAndUpdate(
131
+ {
132
+ _id,
133
+ status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] },
134
+ },
135
+ {
136
+ $set: {
137
+ status: JobStatus.PENDING,
138
+ failCount: 0,
139
+ nextRunAt: new Date(),
140
+ updatedAt: new Date(),
141
+ },
142
+ $unset: {
143
+ failReason: '',
144
+ lockedAt: '',
145
+ claimedBy: '',
146
+ lastHeartbeat: '',
147
+ heartbeatInterval: '',
148
+ },
149
+ },
150
+ { returnDocument: 'after' },
151
+ );
152
+
153
+ if (!result) {
154
+ throw new JobStateError('Job status changed during retry attempt', jobId, 'unknown', 'retry');
155
+ }
156
+
157
+ const job = this.ctx.documentToPersistedJob(result);
158
+ this.ctx.emit('job:retried', { job, previousStatus });
159
+ return job;
160
+ }
161
+
162
+ /**
163
+ * Reschedule a pending job to run at a different time.
164
+ *
165
+ * Only works for jobs in 'pending' status.
166
+ *
167
+ * @param jobId - The ID of the job to reschedule
168
+ * @param runAt - The new Date when the job should run
169
+ * @returns The updated job, or null if not found
170
+ * @throws {JobStateError} If job is not in pending state
171
+ *
172
+ * @example Delay a job by 1 hour
173
+ * ```typescript
174
+ * const nextHour = new Date(Date.now() + 60 * 60 * 1000);
175
+ * await monque.rescheduleJob(jobId, nextHour);
176
+ * ```
177
+ */
178
+ async rescheduleJob(jobId: string, runAt: Date): Promise<PersistedJob<unknown> | null> {
179
+ if (!ObjectId.isValid(jobId)) return null;
180
+
181
+ const _id = new ObjectId(jobId);
182
+ const currentJobDoc = await this.ctx.collection.findOne({ _id });
183
+
184
+ if (!currentJobDoc) return null;
185
+
186
+ const currentJob = currentJobDoc as unknown as WithId<Job>;
187
+
188
+ if (currentJob.status !== JobStatus.PENDING) {
189
+ throw new JobStateError(
190
+ `Cannot reschedule job in status '${currentJob.status}'`,
191
+ jobId,
192
+ currentJob.status,
193
+ 'reschedule',
194
+ );
195
+ }
196
+
197
+ const result = await this.ctx.collection.findOneAndUpdate(
198
+ { _id, status: JobStatus.PENDING },
199
+ {
200
+ $set: {
201
+ nextRunAt: runAt,
202
+ updatedAt: new Date(),
203
+ },
204
+ },
205
+ { returnDocument: 'after' },
206
+ );
207
+
208
+ if (!result) {
209
+ throw new JobStateError(
210
+ 'Job status changed during reschedule attempt',
211
+ jobId,
212
+ 'unknown',
213
+ 'reschedule',
214
+ );
215
+ }
216
+
217
+ return this.ctx.documentToPersistedJob(result);
218
+ }
219
+
220
+ /**
221
+ * Permanently delete a job.
222
+ *
223
+ * This action is irreversible. Emits a 'job:deleted' event upon success.
224
+ * Can delete a job in any state.
225
+ *
226
+ * @param jobId - The ID of the job to delete
227
+ * @returns true if deleted, false if job not found
228
+ *
229
+ * @example Delete a cleanup job
230
+ * ```typescript
231
+ * const deleted = await monque.deleteJob(jobId);
232
+ * if (deleted) {
233
+ * console.log('Job permanently removed');
234
+ * }
235
+ * ```
236
+ */
237
+ async deleteJob(jobId: string): Promise<boolean> {
238
+ if (!ObjectId.isValid(jobId)) return false;
239
+
240
+ const _id = new ObjectId(jobId);
241
+
242
+ const result = await this.ctx.collection.deleteOne({ _id });
243
+
244
+ if (result.deletedCount > 0) {
245
+ this.ctx.emit('job:deleted', { jobId });
246
+ return true;
247
+ }
248
+
249
+ return false;
250
+ }
251
+
252
+ // ─────────────────────────────────────────────────────────────────────────────
253
+ // Bulk Operations
254
+ // ─────────────────────────────────────────────────────────────────────────────
255
+
256
+ /**
257
+ * Cancel multiple jobs matching the given filter.
258
+ *
259
+ * Only cancels jobs in 'pending' status. Jobs in other states are collected
260
+ * as errors in the result. Emits a 'jobs:cancelled' event with the IDs of
261
+ * successfully cancelled jobs.
262
+ *
263
+ * @param filter - Selector for which jobs to cancel (name, status, date range)
264
+ * @returns Result with count of cancelled jobs and any errors encountered
265
+ *
266
+ * @example Cancel all pending jobs for a queue
267
+ * ```typescript
268
+ * const result = await monque.cancelJobs({
269
+ * name: 'email-queue',
270
+ * status: 'pending'
271
+ * });
272
+ * console.log(`Cancelled ${result.count} jobs`);
273
+ * ```
274
+ */
275
+ async cancelJobs(filter: JobSelector): Promise<BulkOperationResult> {
276
+ const baseQuery = buildSelectorQuery(filter);
277
+ const errors: Array<{ jobId: string; error: string }> = [];
278
+ const cancelledIds: string[] = [];
279
+
280
+ // Find all matching jobs and stream them to avoid memory pressure
281
+ const cursor = this.ctx.collection.find(baseQuery);
282
+
283
+ for await (const doc of cursor) {
284
+ const job = doc as unknown as WithId<Job>;
285
+ const jobId = job._id.toString();
286
+
287
+ if (job.status !== JobStatus.PENDING && job.status !== JobStatus.CANCELLED) {
288
+ errors.push({
289
+ jobId,
290
+ error: `Cannot cancel job in status '${job.status}'`,
291
+ });
292
+ continue;
293
+ }
294
+
295
+ // Skip already cancelled jobs (idempotent)
296
+ if (job.status === JobStatus.CANCELLED) {
297
+ cancelledIds.push(jobId);
298
+ continue;
299
+ }
300
+
301
+ // Atomically update to cancelled
302
+ const result = await this.ctx.collection.findOneAndUpdate(
303
+ { _id: job._id, status: JobStatus.PENDING },
304
+ {
305
+ $set: {
306
+ status: JobStatus.CANCELLED,
307
+ updatedAt: new Date(),
308
+ },
309
+ },
310
+ { returnDocument: 'after' },
311
+ );
312
+
313
+ if (result) {
314
+ cancelledIds.push(jobId);
315
+ } else {
316
+ // Race condition: status changed
317
+ errors.push({
318
+ jobId,
319
+ error: 'Job status changed during cancellation',
320
+ });
321
+ }
322
+ }
323
+
324
+ if (cancelledIds.length > 0) {
325
+ this.ctx.emit('jobs:cancelled', {
326
+ jobIds: cancelledIds,
327
+ count: cancelledIds.length,
328
+ });
329
+ }
330
+
331
+ return {
332
+ count: cancelledIds.length,
333
+ errors,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Retry multiple jobs matching the given filter.
339
+ *
340
+ * Only retries jobs in 'failed' or 'cancelled' status. Jobs in other states
341
+ * are collected as errors in the result. Emits a 'jobs:retried' event with
342
+ * the IDs of successfully retried jobs.
343
+ *
344
+ * @param filter - Selector for which jobs to retry (name, status, date range)
345
+ * @returns Result with count of retried jobs and any errors encountered
346
+ *
347
+ * @example Retry all failed jobs
348
+ * ```typescript
349
+ * const result = await monque.retryJobs({
350
+ * status: 'failed'
351
+ * });
352
+ * console.log(`Retried ${result.count} jobs`);
353
+ * ```
354
+ */
355
+ async retryJobs(filter: JobSelector): Promise<BulkOperationResult> {
356
+ const baseQuery = buildSelectorQuery(filter);
357
+ const errors: Array<{ jobId: string; error: string }> = [];
358
+ const retriedIds: string[] = [];
359
+
360
+ const cursor = this.ctx.collection.find(baseQuery);
361
+
362
+ for await (const doc of cursor) {
363
+ const job = doc as unknown as WithId<Job>;
364
+ const jobId = job._id.toString();
365
+
366
+ if (job.status !== JobStatus.FAILED && job.status !== JobStatus.CANCELLED) {
367
+ errors.push({
368
+ jobId,
369
+ error: `Cannot retry job in status '${job.status}'`,
370
+ });
371
+ continue;
372
+ }
373
+
374
+ const result = await this.ctx.collection.findOneAndUpdate(
375
+ {
376
+ _id: job._id,
377
+ status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] },
378
+ },
379
+ {
380
+ $set: {
381
+ status: JobStatus.PENDING,
382
+ failCount: 0,
383
+ nextRunAt: new Date(),
384
+ updatedAt: new Date(),
385
+ },
386
+ $unset: {
387
+ failReason: '',
388
+ lockedAt: '',
389
+ claimedBy: '',
390
+ lastHeartbeat: '',
391
+ heartbeatInterval: '',
392
+ },
393
+ },
394
+ { returnDocument: 'after' },
395
+ );
396
+
397
+ if (result) {
398
+ retriedIds.push(jobId);
399
+ } else {
400
+ errors.push({
401
+ jobId,
402
+ error: 'Job status changed during retry attempt',
403
+ });
404
+ }
405
+ }
406
+
407
+ if (retriedIds.length > 0) {
408
+ this.ctx.emit('jobs:retried', {
409
+ jobIds: retriedIds,
410
+ count: retriedIds.length,
411
+ });
412
+ }
413
+
414
+ return {
415
+ count: retriedIds.length,
416
+ errors,
417
+ };
418
+ }
419
+
420
+ /**
421
+ * Delete multiple jobs matching the given filter.
422
+ *
423
+ * Deletes jobs in any status. Uses a batch delete for efficiency.
424
+ * Emits a 'jobs:deleted' event with the count of deleted jobs.
425
+ * Does not emit individual 'job:deleted' events to avoid noise.
426
+ *
427
+ * @param filter - Selector for which jobs to delete (name, status, date range)
428
+ * @returns Result with count of deleted jobs (errors array always empty for delete)
429
+ *
430
+ * @example Delete old completed jobs
431
+ * ```typescript
432
+ * const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
433
+ * const result = await monque.deleteJobs({
434
+ * status: 'completed',
435
+ * olderThan: weekAgo
436
+ * });
437
+ * console.log(`Deleted ${result.count} jobs`);
438
+ * ```
439
+ */
440
+ async deleteJobs(filter: JobSelector): Promise<BulkOperationResult> {
441
+ const query = buildSelectorQuery(filter);
442
+
443
+ // Use deleteMany for efficiency
444
+ const result = await this.ctx.collection.deleteMany(query);
445
+
446
+ if (result.deletedCount > 0) {
447
+ this.ctx.emit('jobs:deleted', { count: result.deletedCount });
448
+ }
449
+
450
+ return {
451
+ count: result.deletedCount,
452
+ errors: [],
453
+ };
454
+ }
455
+ }