@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,335 @@
1
+ import type { ObjectId } from 'mongodb';
2
+
3
+ /**
4
+ * Represents the lifecycle states of a job in the queue.
5
+ *
6
+ * Jobs transition through states as follows:
7
+ * - PENDING → PROCESSING (when picked up by a worker)
8
+ * - PROCESSING → COMPLETED (on success)
9
+ * - PROCESSING → PENDING (on failure, if retries remain)
10
+ * - PROCESSING → FAILED (on failure, after max retries exhausted)
11
+ * - PENDING → CANCELLED (on manual cancellation)
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * if (job.status === JobStatus.PENDING) {
16
+ * // job is waiting to be picked up
17
+ * }
18
+ * ```
19
+ */
20
+ export const JobStatus = {
21
+ /** Job is waiting to be picked up by a worker */
22
+ PENDING: 'pending',
23
+ /** Job is currently being executed by a worker */
24
+ PROCESSING: 'processing',
25
+ /** Job completed successfully */
26
+ COMPLETED: 'completed',
27
+ /** Job permanently failed after exhausting all retry attempts */
28
+ FAILED: 'failed',
29
+ /** Job was manually cancelled */
30
+ CANCELLED: 'cancelled',
31
+ } as const;
32
+
33
+ /**
34
+ * Union type of all possible job status values: `'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'`
35
+ */
36
+ export type JobStatusType = (typeof JobStatus)[keyof typeof JobStatus];
37
+
38
+ /**
39
+ * Represents a job in the Monque queue.
40
+ *
41
+ * @template T - The type of the job's data payload
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * interface EmailJobData {
46
+ * to: string;
47
+ * subject: string;
48
+ * template: string;
49
+ * }
50
+ *
51
+ * const job: Job<EmailJobData> = {
52
+ * name: 'send-email',
53
+ * data: { to: 'user@example.com', subject: 'Welcome!', template: 'welcome' },
54
+ * status: JobStatus.PENDING,
55
+ * nextRunAt: new Date(),
56
+ * failCount: 0,
57
+ * createdAt: new Date(),
58
+ * updatedAt: new Date(),
59
+ * };
60
+ * ```
61
+ */
62
+ export interface Job<T = unknown> {
63
+ /** MongoDB document identifier */
64
+ _id?: ObjectId;
65
+
66
+ /** Job type identifier, matches worker registration */
67
+ name: string;
68
+
69
+ /** Job payload - must be JSON-serializable */
70
+ data: T;
71
+
72
+ /** Current lifecycle state */
73
+ status: JobStatusType;
74
+
75
+ /** When the job should be processed */
76
+ nextRunAt: Date;
77
+
78
+ /** Timestamp when job was locked for processing */
79
+ lockedAt?: Date | null;
80
+
81
+ /**
82
+ * Unique identifier of the scheduler instance that claimed this job.
83
+ * Used for atomic claim pattern - ensures only one instance processes each job.
84
+ * Set when a job is claimed, cleared when job completes or fails.
85
+ */
86
+ claimedBy?: string | null;
87
+
88
+ /**
89
+ * Timestamp of the last heartbeat update for this job.
90
+ * Used to detect stale jobs when a scheduler instance crashes without releasing.
91
+ * Updated periodically while job is being processed.
92
+ */
93
+ lastHeartbeat?: Date | null;
94
+
95
+ /**
96
+ * Heartbeat interval in milliseconds for this job.
97
+ * Stored on the job to allow recovery logic to use the correct timeout.
98
+ */
99
+ heartbeatInterval?: number;
100
+
101
+ /** Number of failed attempts */
102
+ failCount: number;
103
+
104
+ /** Last failure error message */
105
+ failReason?: string;
106
+
107
+ /** Cron expression for recurring jobs */
108
+ repeatInterval?: string;
109
+
110
+ /** Deduplication key to prevent duplicate jobs */
111
+ uniqueKey?: string;
112
+
113
+ /** Job creation timestamp */
114
+ createdAt: Date;
115
+
116
+ /** Last modification timestamp */
117
+ updatedAt: Date;
118
+ }
119
+
120
+ /**
121
+ * A job that has been persisted to MongoDB and has a guaranteed `_id`.
122
+ * This is returned by `enqueue()`, `now()`, and `schedule()` methods.
123
+ *
124
+ * @template T - The type of the job's data payload
125
+ */
126
+ export type PersistedJob<T = unknown> = Job<T> & { _id: ObjectId };
127
+
128
+ /**
129
+ * Options for enqueueing a job.
130
+ *
131
+ * @example
132
+ * ```typescript
133
+ * await monque.enqueue('sync-user', { userId: '123' }, {
134
+ * uniqueKey: 'sync-user-123',
135
+ * runAt: new Date(Date.now() + 5000), // Run in 5 seconds
136
+ * });
137
+ * ```
138
+ */
139
+ export interface EnqueueOptions {
140
+ /**
141
+ * Deduplication key. If a job with this key is already pending or processing,
142
+ * the enqueue operation will not create a duplicate.
143
+ */
144
+ uniqueKey?: string;
145
+
146
+ /**
147
+ * When the job should be processed. Defaults to immediately (new Date()).
148
+ */
149
+ runAt?: Date;
150
+ }
151
+
152
+ /**
153
+ * Options for scheduling a recurring job.
154
+ *
155
+ * @example
156
+ * ```typescript
157
+ * await monque. schedule('0 * * * *', 'hourly-cleanup', { dir: '/tmp' }, {
158
+ * uniqueKey: 'hourly-cleanup-job',
159
+ * });
160
+ * ```
161
+ */
162
+ export interface ScheduleOptions {
163
+ /**
164
+ * Deduplication key. If a job with this key is already pending or processing,
165
+ * the schedule operation will not create a duplicate.
166
+ */
167
+ uniqueKey?: string;
168
+ }
169
+
170
+ /**
171
+ * Filter options for querying jobs.
172
+ *
173
+ * Use with `monque.getJobs()` to filter jobs by name, status, or limit results.
174
+ *
175
+ * @example
176
+ * ```typescript
177
+ * // Get all pending email jobs
178
+ * const pendingEmails = await monque.getJobs({
179
+ * name: 'send-email',
180
+ * status: JobStatus.PENDING,
181
+ * });
182
+ *
183
+ * // Get all failed or completed jobs (paginated)
184
+ * const finishedJobs = await monque.getJobs({
185
+ * status: [JobStatus.COMPLETED, JobStatus.FAILED],
186
+ * limit: 50,
187
+ * skip: 100,
188
+ * });
189
+ * ```
190
+ */
191
+ export interface GetJobsFilter {
192
+ /** Filter by job type name */
193
+ name?: string;
194
+
195
+ /** Filter by status (single or multiple) */
196
+ status?: JobStatusType | JobStatusType[];
197
+
198
+ /** Maximum number of jobs to return (default: 100) */
199
+ limit?: number;
200
+
201
+ /** Number of jobs to skip for pagination */
202
+ skip?: number;
203
+ }
204
+
205
+ /**
206
+ * Handler function signature for processing jobs.
207
+ *
208
+ * @template T - The type of the job's data payload
209
+ *
210
+ * @example
211
+ * ```typescript
212
+ * const emailHandler: JobHandler<EmailJobData> = async (job) => {
213
+ * await sendEmail(job.data.to, job.data.subject);
214
+ * };
215
+ * ```
216
+ */
217
+ export type JobHandler<T = unknown> = (job: Job<T>) => Promise<void> | void;
218
+
219
+ /**
220
+ * Valid cursor directions for pagination.
221
+ *
222
+ * @example
223
+ * ```typescript
224
+ * const direction = CursorDirection.FORWARD;
225
+ * ```
226
+ */
227
+ export const CursorDirection = {
228
+ FORWARD: 'forward',
229
+ BACKWARD: 'backward',
230
+ } as const;
231
+
232
+ export type CursorDirectionType = (typeof CursorDirection)[keyof typeof CursorDirection];
233
+
234
+ /**
235
+ * Selector options for bulk operations.
236
+ *
237
+ * Used to select multiple jobs for operations like cancellation or deletion.
238
+ *
239
+ * @example
240
+ * ```typescript
241
+ * // Select all failed jobs older than 7 days
242
+ * const selector: JobSelector = {
243
+ * status: JobStatus.FAILED,
244
+ * olderThan: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
245
+ * };
246
+ * ```
247
+ */
248
+ export interface JobSelector {
249
+ name?: string;
250
+ status?: JobStatusType | JobStatusType[];
251
+ olderThan?: Date;
252
+ newerThan?: Date;
253
+ }
254
+
255
+ /**
256
+ * Options for cursor-based pagination.
257
+ *
258
+ * @example
259
+ * ```typescript
260
+ * const options: CursorOptions = {
261
+ * limit: 50,
262
+ * direction: CursorDirection.FORWARD,
263
+ * filter: { status: JobStatus.PENDING },
264
+ * };
265
+ * ```
266
+ */
267
+ export interface CursorOptions {
268
+ cursor?: string;
269
+ limit?: number;
270
+ direction?: CursorDirectionType;
271
+ filter?: Pick<GetJobsFilter, 'name' | 'status'>;
272
+ }
273
+
274
+ /**
275
+ * Response structure for cursor-based pagination.
276
+ *
277
+ * @template T - The type of the job's data payload
278
+ *
279
+ * @example
280
+ * ```typescript
281
+ * const page = await monque.listJobs({ limit: 10 });
282
+ * console.log(`Got ${page.jobs.length} jobs`);
283
+ *
284
+ * if (page.hasNextPage) {
285
+ * console.log(`Next cursor: ${page.cursor}`);
286
+ * }
287
+ * ```
288
+ */
289
+ export interface CursorPage<T = unknown> {
290
+ jobs: PersistedJob<T>[];
291
+ cursor: string | null;
292
+ hasNextPage: boolean;
293
+ hasPreviousPage: boolean;
294
+ }
295
+
296
+ /**
297
+ * Aggregated statistics for the job queue.
298
+ *
299
+ * @example
300
+ * ```typescript
301
+ * const stats = await monque.getQueueStats();
302
+ * console.log(`Total jobs: ${stats.total}`);
303
+ * console.log(`Pending: ${stats.pending}`);
304
+ * console.log(`Processing: ${stats.processing}`);
305
+ * console.log(`Failed: ${stats.failed}`);
306
+ * console.log(`Start to finish avg: ${stats.avgProcessingDurationMs}ms`);
307
+ * ```
308
+ */
309
+ export interface QueueStats {
310
+ pending: number;
311
+ processing: number;
312
+ completed: number;
313
+ failed: number;
314
+ cancelled: number;
315
+ total: number;
316
+ avgProcessingDurationMs?: number;
317
+ }
318
+
319
+ /**
320
+ * Result of a bulk operation.
321
+ *
322
+ * @example
323
+ * ```typescript
324
+ * const result = await monque.cancelJobs(selector);
325
+ * console.log(`Cancelled ${result.count} jobs`);
326
+ *
327
+ * if (result.errors.length > 0) {
328
+ * console.warn('Some jobs could not be cancelled:', result.errors);
329
+ * }
330
+ * ```
331
+ */
332
+ export interface BulkOperationResult {
333
+ count: number;
334
+ errors: Array<{ jobId: string; error: string }>;
335
+ }
@@ -0,0 +1,107 @@
1
+ import { type Document, type Filter, ObjectId } from 'mongodb';
2
+
3
+ import { CursorDirection, type CursorDirectionType, type JobSelector } from '@/jobs';
4
+ import { InvalidCursorError } from '@/shared';
5
+
6
+ /**
7
+ * Build a MongoDB query filter from a JobSelector.
8
+ *
9
+ * Translates the high-level `JobSelector` interface into a MongoDB `Filter<Document>`.
10
+ * Handles array values for status (using `$in`) and date range filtering.
11
+ *
12
+ * @param filter - The user-provided job selector
13
+ * @returns A standard MongoDB filter object
14
+ */
15
+ export function buildSelectorQuery(filter: JobSelector): Filter<Document> {
16
+ const query: Filter<Document> = {};
17
+
18
+ if (filter.name) {
19
+ query['name'] = filter.name;
20
+ }
21
+
22
+ if (filter.status) {
23
+ if (Array.isArray(filter.status)) {
24
+ query['status'] = { $in: filter.status };
25
+ } else {
26
+ query['status'] = filter.status;
27
+ }
28
+ }
29
+
30
+ if (filter.olderThan || filter.newerThan) {
31
+ query['createdAt'] = {};
32
+ if (filter.olderThan) {
33
+ query['createdAt'].$lt = filter.olderThan;
34
+ }
35
+ if (filter.newerThan) {
36
+ query['createdAt'].$gt = filter.newerThan;
37
+ }
38
+ }
39
+
40
+ return query;
41
+ }
42
+
43
+ /**
44
+ * Encode an ObjectId and direction into an opaque cursor string.
45
+ *
46
+ * Format: `prefix` + `base64url(objectId)`
47
+ * Prefix: 'F' (forward) or 'B' (backward)
48
+ *
49
+ * @param id - The job ID to use as the cursor anchor (exclusive)
50
+ * @param direction - 'forward' or 'backward'
51
+ * @returns Base64url-encoded cursor string
52
+ */
53
+ export function encodeCursor(id: ObjectId, direction: CursorDirectionType): string {
54
+ const prefix = direction === 'forward' ? 'F' : 'B';
55
+ const buffer = Buffer.from(id.toHexString(), 'hex');
56
+
57
+ return prefix + buffer.toString('base64url');
58
+ }
59
+
60
+ /**
61
+ * Decode an opaque cursor string into an ObjectId and direction.
62
+ *
63
+ * Validates format and returns the components.
64
+ *
65
+ * @param cursor - The opaque cursor string
66
+ * @returns The decoded ID and direction
67
+ * @throws {InvalidCursorError} If the cursor format is invalid or ID is malformed
68
+ */
69
+ export function decodeCursor(cursor: string): {
70
+ id: ObjectId;
71
+ direction: CursorDirectionType;
72
+ } {
73
+ if (!cursor || cursor.length < 2) {
74
+ throw new InvalidCursorError('Cursor is empty or too short');
75
+ }
76
+
77
+ const prefix = cursor.charAt(0);
78
+ const payload = cursor.slice(1);
79
+
80
+ let direction: CursorDirectionType;
81
+
82
+ if (prefix === 'F') {
83
+ direction = CursorDirection.FORWARD;
84
+ } else if (prefix === 'B') {
85
+ direction = CursorDirection.BACKWARD;
86
+ } else {
87
+ throw new InvalidCursorError(`Invalid cursor prefix: ${prefix}`);
88
+ }
89
+
90
+ try {
91
+ const buffer = Buffer.from(payload, 'base64url');
92
+ const hex = buffer.toString('hex');
93
+ // standard ObjectID is 12 bytes = 24 hex chars
94
+ if (hex.length !== 24) {
95
+ throw new InvalidCursorError('Invalid length');
96
+ }
97
+
98
+ const id = new ObjectId(hex);
99
+
100
+ return { id, direction };
101
+ } catch (error) {
102
+ if (error instanceof InvalidCursorError) {
103
+ throw error;
104
+ }
105
+ throw new InvalidCursorError('Invalid cursor payload');
106
+ }
107
+ }
@@ -0,0 +1,5 @@
1
+ // helpers
2
+ export { buildSelectorQuery } from './helpers.js';
3
+ // Main class and options
4
+ export { Monque } from './monque.js';
5
+ export type { MonqueOptions } from './types.js';