@quave/agenda-mongodb 1.0.0

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.
Files changed (52) hide show
  1. package/LICENSE.md +25 -0
  2. package/README.md +273 -0
  3. package/dist/MongoBackend.d.ts +58 -0
  4. package/dist/MongoBackend.js +82 -0
  5. package/dist/MongoBackend.js.map +1 -0
  6. package/dist/MongoChangeStreamNotificationChannel.d.ts +96 -0
  7. package/dist/MongoChangeStreamNotificationChannel.js +248 -0
  8. package/dist/MongoChangeStreamNotificationChannel.js.map +1 -0
  9. package/dist/MongoJobLogger.d.ts +46 -0
  10. package/dist/MongoJobLogger.js +142 -0
  11. package/dist/MongoJobLogger.js.map +1 -0
  12. package/dist/MongoJobRepository.d.ts +84 -0
  13. package/dist/MongoJobRepository.js +713 -0
  14. package/dist/MongoJobRepository.js.map +1 -0
  15. package/dist/cjs/MongoBackend.d.ts +58 -0
  16. package/dist/cjs/MongoBackend.js +86 -0
  17. package/dist/cjs/MongoBackend.js.map +1 -0
  18. package/dist/cjs/MongoChangeStreamNotificationChannel.d.ts +96 -0
  19. package/dist/cjs/MongoChangeStreamNotificationChannel.js +255 -0
  20. package/dist/cjs/MongoChangeStreamNotificationChannel.js.map +1 -0
  21. package/dist/cjs/MongoJobLogger.d.ts +46 -0
  22. package/dist/cjs/MongoJobLogger.js +149 -0
  23. package/dist/cjs/MongoJobLogger.js.map +1 -0
  24. package/dist/cjs/MongoJobRepository.d.ts +84 -0
  25. package/dist/cjs/MongoJobRepository.js +720 -0
  26. package/dist/cjs/MongoJobRepository.js.map +1 -0
  27. package/dist/cjs/hasMongoProtocol.d.ts +1 -0
  28. package/dist/cjs/hasMongoProtocol.js +6 -0
  29. package/dist/cjs/hasMongoProtocol.js.map +1 -0
  30. package/dist/cjs/index.d.ts +39 -0
  31. package/dist/cjs/index.js +46 -0
  32. package/dist/cjs/index.js.map +1 -0
  33. package/dist/cjs/package.json +1 -0
  34. package/dist/cjs/testing.d.ts +25 -0
  35. package/dist/cjs/testing.js +55 -0
  36. package/dist/cjs/testing.js.map +1 -0
  37. package/dist/cjs/types.d.ts +52 -0
  38. package/dist/cjs/types.js +3 -0
  39. package/dist/cjs/types.js.map +1 -0
  40. package/dist/hasMongoProtocol.d.ts +1 -0
  41. package/dist/hasMongoProtocol.js +2 -0
  42. package/dist/hasMongoProtocol.js.map +1 -0
  43. package/dist/index.d.ts +39 -0
  44. package/dist/index.js +37 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/testing.d.ts +25 -0
  47. package/dist/testing.js +49 -0
  48. package/dist/testing.js.map +1 -0
  49. package/dist/types.d.ts +52 -0
  50. package/dist/types.js +2 -0
  51. package/dist/types.js.map +1 -0
  52. package/package.json +84 -0
@@ -0,0 +1,713 @@
1
+ // Unprefixed builtin import: Meteor 2.x's module resolver predates the
2
+ // node: scheme, and require('node:...') needs Node 14.18+.
3
+ import assert from 'assert';
4
+ import debug from 'debug';
5
+ import { MongoClient, ObjectId } from 'mongodb';
6
+ import { toJobId, computeJobState } from '@quave/agenda';
7
+ import { hasMongoProtocol } from './hasMongoProtocol.js';
8
+ const log = debug('agenda:mongo:repository');
9
+ /**
10
+ * Escape special regex characters in a string to treat it as literal text
11
+ */
12
+ function escapeRegex(str) {
13
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
+ }
15
+ /**
16
+ * Check if a value is a plain object (not an array, date, or other non-plain type)
17
+ */
18
+ function isPlainObject(value) {
19
+ if (value === null || typeof value !== 'object') {
20
+ return false;
21
+ }
22
+ const prototype = Object.getPrototypeOf(value);
23
+ return prototype === Object.prototype || prototype === null;
24
+ }
25
+ /**
26
+ * Flatten a data filter object into MongoDB dot-notation keys.
27
+ * This enables partial matching on the data subdocument.
28
+ *
29
+ * Example:
30
+ * { searchField: "value", nested: { key: "v" } }
31
+ * becomes:
32
+ * { "data.searchField": "value", "data.nested.key": "v" }
33
+ */
34
+ function flattenDataFilter(data, prefix = 'data') {
35
+ const result = {};
36
+ for (const [key, value] of Object.entries(data)) {
37
+ const fullKey = `${prefix}.${key}`;
38
+ if (isPlainObject(value)) {
39
+ Object.assign(result, flattenDataFilter(value, fullKey));
40
+ }
41
+ else {
42
+ result[fullKey] = value;
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+ function computeJobObj(job) {
48
+ return {
49
+ _id: toJobId(job._id.toHexString()),
50
+ name: job.name,
51
+ priority: job.priority,
52
+ nextRunAt: job.nextRunAt,
53
+ type: job.type,
54
+ data: job.data,
55
+ lockedAt: job.lockedAt,
56
+ lastFinishedAt: job.lastFinishedAt,
57
+ failedAt: job.failedAt || undefined,
58
+ failCount: job.failCount || undefined,
59
+ failReason: job.failReason || undefined,
60
+ repeatTimezone: job.repeatTimezone,
61
+ lastRunAt: job.lastRunAt,
62
+ repeatInterval: job.repeatInterval,
63
+ repeatAt: job.repeatAt,
64
+ disabled: job.disabled,
65
+ progress: job.progress,
66
+ unique: job.unique,
67
+ uniqueOpts: job.uniqueOpts,
68
+ lastModifiedBy: job.lastModifiedBy,
69
+ fork: job.fork,
70
+ debounceStartedAt: job.debounceStartedAt
71
+ };
72
+ }
73
+ /**
74
+ * @class
75
+ * MongoDB implementation of JobRepository
76
+ */
77
+ export class MongoJobRepository {
78
+ constructor(connectOptions) {
79
+ this.connectOptions = connectOptions;
80
+ this.ownsConnection = false;
81
+ this.connectOptions.sort = this.connectOptions.sort || { nextRunAt: 'asc', priority: 'desc' };
82
+ // Track if we own the connection (i.e., we created it from a connection string)
83
+ this.ownsConnection = !('mongo' in connectOptions);
84
+ }
85
+ async createConnection() {
86
+ const { connectOptions } = this;
87
+ if ('mongo' in connectOptions) {
88
+ log('using passed in mongo connection');
89
+ return connectOptions.mongo;
90
+ }
91
+ if ('db' in connectOptions && connectOptions.db) {
92
+ log('using database config', connectOptions);
93
+ return this.database(connectOptions.db.address, connectOptions.db.options);
94
+ }
95
+ throw new Error('invalid db config, or db config not found');
96
+ }
97
+ async getJobById(id) {
98
+ const doc = await this.collection.findOne({ _id: new ObjectId(id) });
99
+ if (!doc)
100
+ return null;
101
+ // Convert MongoDB ObjectId to JobId
102
+ return computeJobObj(doc);
103
+ }
104
+ /**
105
+ * Convert a SortDirection value to MongoDB's numeric sort direction
106
+ */
107
+ toMongoSortDirection(dir) {
108
+ return dir === 'asc' ? 1 : -1;
109
+ }
110
+ /**
111
+ * findOneAndUpdate that tolerates both driver result shapes: driver 6+
112
+ * returns the document (or null) directly, while drivers 4/5 — e.g. the
113
+ * mongodb driver bundled with Meteor 2.x — return a ModifyResult wrapper
114
+ * with the document under `value`.
115
+ */
116
+ async findOneAndUpdateDoc(filter, update, options) {
117
+ const result = options
118
+ ? await this.collection.findOneAndUpdate(filter, update, options)
119
+ : await this.collection.findOneAndUpdate(filter, update);
120
+ if (result && typeof result === 'object' && 'lastErrorObject' in result) {
121
+ const modifyResult = result;
122
+ return modifyResult.value ?? null;
123
+ }
124
+ return result;
125
+ }
126
+ /**
127
+ * Convert generic sort options to MongoDB sort
128
+ */
129
+ toMongoSort(sort) {
130
+ if (!sort) {
131
+ return { nextRunAt: -1, lastRunAt: -1 };
132
+ }
133
+ const mongoSort = {};
134
+ if (sort.nextRunAt !== undefined)
135
+ mongoSort.nextRunAt = this.toMongoSortDirection(sort.nextRunAt);
136
+ if (sort.lastRunAt !== undefined)
137
+ mongoSort.lastRunAt = this.toMongoSortDirection(sort.lastRunAt);
138
+ if (sort.lastFinishedAt !== undefined)
139
+ mongoSort.lastFinishedAt = this.toMongoSortDirection(sort.lastFinishedAt);
140
+ if (sort.priority !== undefined)
141
+ mongoSort.priority = this.toMongoSortDirection(sort.priority);
142
+ if (sort.name !== undefined)
143
+ mongoSort.name = this.toMongoSortDirection(sort.name);
144
+ if (sort.data !== undefined)
145
+ mongoSort.data = this.toMongoSortDirection(sort.data);
146
+ return Object.keys(mongoSort).length > 0 ? mongoSort : { nextRunAt: -1, lastRunAt: -1 };
147
+ }
148
+ /**
149
+ * Query jobs with database-agnostic options.
150
+ * Handles state computation and filtering internally.
151
+ */
152
+ async queryJobs(options = {}) {
153
+ const { name, names, state, id, ids, search, data, includeDisabled = true, sort, skip = 0, limit = 0 } = options;
154
+ const now = new Date();
155
+ // Build MongoDB query from options
156
+ const query = {};
157
+ // Validate name is a string to prevent query operator injection
158
+ if (name && typeof name === 'string') {
159
+ query.name = name;
160
+ }
161
+ else if (names && Array.isArray(names) && names.length > 0) {
162
+ // Filter to only valid strings
163
+ const validNames = names.filter((n) => typeof n === 'string');
164
+ if (validNames.length > 0) {
165
+ query.name = { $in: validNames };
166
+ }
167
+ }
168
+ if (id && typeof id === 'string') {
169
+ try {
170
+ query._id = new ObjectId(id);
171
+ }
172
+ catch {
173
+ return { jobs: [], total: 0 };
174
+ }
175
+ }
176
+ else if (ids && Array.isArray(ids) && ids.length > 0) {
177
+ try {
178
+ const validIds = ids.filter((i) => typeof i === 'string');
179
+ if (validIds.length > 0) {
180
+ query._id = { $in: validIds.map(i => new ObjectId(i)) };
181
+ }
182
+ }
183
+ catch {
184
+ return { jobs: [], total: 0 };
185
+ }
186
+ }
187
+ // Validate search is a string and escape regex metacharacters
188
+ if (search && typeof search === 'string' && search.length > 0) {
189
+ query.name = { $regex: escapeRegex(search), $options: 'i' };
190
+ }
191
+ if (data !== undefined) {
192
+ if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
193
+ Object.assign(query, flattenDataFilter(data));
194
+ }
195
+ else {
196
+ query.data = data;
197
+ }
198
+ }
199
+ if (!includeDisabled) {
200
+ query.disabled = { $ne: true };
201
+ }
202
+ // Fetch jobs
203
+ const allJobs = await this.collection.find(query).sort(this.toMongoSort(sort)).toArray();
204
+ // Compute states and filter by state if specified
205
+ // Special handling for 'paused' state which is based on disabled field
206
+ let jobsWithState = allJobs
207
+ .map(job => {
208
+ const jobOb = computeJobObj(job);
209
+ return {
210
+ ...jobOb,
211
+ state: computeJobState(jobOb, now)
212
+ };
213
+ })
214
+ .filter(job => {
215
+ if (!state)
216
+ return true;
217
+ if (state === 'paused')
218
+ return job.disabled === true;
219
+ return job.state === state;
220
+ });
221
+ // Apply pagination after state filtering
222
+ const total = jobsWithState.length;
223
+ if (limit > 0) {
224
+ jobsWithState = jobsWithState.slice(skip, skip + limit);
225
+ }
226
+ else if (skip > 0) {
227
+ jobsWithState = jobsWithState.slice(skip);
228
+ }
229
+ return { jobs: jobsWithState, total };
230
+ }
231
+ /**
232
+ * Get overview statistics for jobs grouped by name.
233
+ * Returns counts of jobs in each state for each job name.
234
+ */
235
+ async getJobsOverview() {
236
+ const now = new Date();
237
+ const names = await this.getDistinctJobNames();
238
+ const overviews = await Promise.all(names.map(async (name) => {
239
+ const jobs = await this.collection.find({ name }).toArray();
240
+ const overview = {
241
+ name,
242
+ total: jobs.length,
243
+ running: 0,
244
+ scheduled: 0,
245
+ queued: 0,
246
+ completed: 0,
247
+ failed: 0,
248
+ repeating: 0,
249
+ paused: 0
250
+ };
251
+ for (const job of jobs) {
252
+ const state = computeJobState(job, now);
253
+ overview[state]++;
254
+ if (job.disabled === true) {
255
+ overview.paused++;
256
+ }
257
+ }
258
+ return overview;
259
+ }));
260
+ return overviews;
261
+ }
262
+ /**
263
+ * Get all distinct job names
264
+ */
265
+ async getDistinctJobNames() {
266
+ return this.collection.distinct('name');
267
+ }
268
+ async getQueueSize() {
269
+ return this.collection.countDocuments({ nextRunAt: { $lt: new Date() } });
270
+ }
271
+ async removeJobs(options) {
272
+ const query = {};
273
+ if (options.id) {
274
+ const idStr = String(options.id);
275
+ try {
276
+ query._id = new ObjectId(idStr);
277
+ }
278
+ catch {
279
+ return 0;
280
+ }
281
+ }
282
+ else if (options.ids && Array.isArray(options.ids) && options.ids.length > 0) {
283
+ try {
284
+ query._id = { $in: options.ids.map(id => new ObjectId(String(id))) };
285
+ }
286
+ catch {
287
+ return 0;
288
+ }
289
+ }
290
+ // Validate name is a string to prevent query operator injection
291
+ if (options.name && typeof options.name === 'string') {
292
+ query.name = options.name;
293
+ }
294
+ else if (options.names && Array.isArray(options.names) && options.names.length > 0) {
295
+ const validNames = options.names.filter((n) => typeof n === 'string');
296
+ if (validNames.length > 0) {
297
+ query.name = { $in: validNames };
298
+ }
299
+ }
300
+ else if (options.notNames && Array.isArray(options.notNames) && options.notNames.length > 0) {
301
+ const validNotNames = options.notNames.filter((n) => typeof n === 'string');
302
+ if (validNotNames.length > 0) {
303
+ query.name = { $nin: validNotNames };
304
+ }
305
+ }
306
+ if (options.data !== undefined) {
307
+ if (options.data !== null && typeof options.data === 'object' && !Array.isArray(options.data)) {
308
+ Object.assign(query, flattenDataFilter(options.data));
309
+ }
310
+ else {
311
+ query.data = options.data;
312
+ }
313
+ }
314
+ // If no criteria provided, don't delete anything
315
+ if (Object.keys(query).length === 0) {
316
+ log('removeJobs: skipping deleteMany without query', query);
317
+ return 0;
318
+ }
319
+ const result = await this.collection.deleteMany(query);
320
+ return result.deletedCount;
321
+ }
322
+ /**
323
+ * Build a MongoDB filter from RemoveJobsOptions
324
+ */
325
+ buildFilterFromOptions(options) {
326
+ const query = {};
327
+ if (options.id) {
328
+ const idStr = String(options.id);
329
+ try {
330
+ query._id = new ObjectId(idStr);
331
+ }
332
+ catch {
333
+ return null;
334
+ }
335
+ }
336
+ else if (options.ids && Array.isArray(options.ids) && options.ids.length > 0) {
337
+ try {
338
+ query._id = { $in: options.ids.map(id => new ObjectId(String(id))) };
339
+ }
340
+ catch {
341
+ return null;
342
+ }
343
+ }
344
+ // Validate name is a string to prevent query operator injection
345
+ if (options.name && typeof options.name === 'string') {
346
+ query.name = options.name;
347
+ }
348
+ else if (options.names && Array.isArray(options.names) && options.names.length > 0) {
349
+ const validNames = options.names.filter((n) => typeof n === 'string');
350
+ if (validNames.length > 0) {
351
+ query.name = { $in: validNames };
352
+ }
353
+ }
354
+ else if (options.notNames && Array.isArray(options.notNames) && options.notNames.length > 0) {
355
+ const validNotNames = options.notNames.filter((n) => typeof n === 'string');
356
+ if (validNotNames.length > 0) {
357
+ query.name = { $nin: validNotNames };
358
+ }
359
+ }
360
+ if (options.data !== undefined) {
361
+ if (options.data !== null && typeof options.data === 'object' && !Array.isArray(options.data)) {
362
+ Object.assign(query, flattenDataFilter(options.data));
363
+ }
364
+ else {
365
+ query.data = options.data;
366
+ }
367
+ }
368
+ // If no criteria provided, return null to indicate no operation
369
+ if (Object.keys(query).length === 0) {
370
+ return null;
371
+ }
372
+ return query;
373
+ }
374
+ async disableJobs(options) {
375
+ const query = this.buildFilterFromOptions(options);
376
+ if (!query) {
377
+ return 0;
378
+ }
379
+ const result = await this.collection.updateMany(query, { $set: { disabled: true } });
380
+ return result.modifiedCount;
381
+ }
382
+ async enableJobs(options) {
383
+ const query = this.buildFilterFromOptions(options);
384
+ if (!query) {
385
+ return 0;
386
+ }
387
+ const result = await this.collection.updateMany(query, { $set: { disabled: false } });
388
+ return result.modifiedCount;
389
+ }
390
+ async purgeAllJobs() {
391
+ const result = await this.collection.deleteMany({});
392
+ return result.deletedCount;
393
+ }
394
+ async unlockJob(job) {
395
+ if (!job._id)
396
+ return;
397
+ // only unlock jobs which are not currently processed (nextRunAt is not null)
398
+ await this.collection.updateOne({ _id: new ObjectId(job._id.toString()), nextRunAt: { $ne: null } }, { $unset: { lockedAt: true } });
399
+ }
400
+ /**
401
+ * Internal method to unlock jobs so that they can be re-run
402
+ */
403
+ async unlockJobs(jobIds) {
404
+ if (jobIds.length === 0)
405
+ return;
406
+ const objectIds = jobIds.map(id => new ObjectId(id.toString()));
407
+ // Unlock jobs by ID regardless of nextRunAt status.
408
+ // When a one-time job starts running, nextRunAt becomes null,
409
+ // but we still want to unlock it when stop() is called.
410
+ await this.collection.updateMany({ _id: { $in: objectIds } }, { $unset: { lockedAt: true } });
411
+ }
412
+ async lockJob(job, options) {
413
+ if (!job._id)
414
+ return undefined;
415
+ // Query to run against collection to see if we need to lock it
416
+ const criteria = {
417
+ _id: new ObjectId(job._id.toString()),
418
+ name: job.name,
419
+ lockedAt: null,
420
+ nextRunAt: job.nextRunAt,
421
+ disabled: { $ne: true }
422
+ };
423
+ // Update / options for the MongoDB query
424
+ const update = {
425
+ $set: { lockedAt: new Date(), lastModifiedBy: options?.lastModifiedBy }
426
+ };
427
+ const findOptions = {
428
+ returnDocument: 'after',
429
+ sort: this.connectOptions.sort
430
+ };
431
+ // Lock the job in MongoDB!
432
+ const result = await this.findOneAndUpdateDoc(criteria, update, findOptions);
433
+ if (!result)
434
+ return undefined;
435
+ // Convert MongoDB ObjectId to JobId
436
+ return computeJobObj(result);
437
+ }
438
+ async getNextJobToRun(jobName, nextScanAt, lockDeadline, now, options) {
439
+ const lockTime = now ?? new Date();
440
+ /**
441
+ * Query used to find job to run
442
+ */
443
+ const JOB_PROCESS_WHERE_QUERY = {
444
+ name: jobName,
445
+ disabled: { $ne: true },
446
+ $or: [
447
+ {
448
+ lockedAt: { $eq: null },
449
+ nextRunAt: { $lte: nextScanAt }
450
+ },
451
+ {
452
+ lockedAt: { $lte: lockDeadline }
453
+ }
454
+ ]
455
+ };
456
+ /**
457
+ * Query used to set a job as locked
458
+ */
459
+ const JOB_PROCESS_SET_QUERY = {
460
+ $set: { lockedAt: lockTime, lastModifiedBy: options?.lastModifiedBy }
461
+ };
462
+ /**
463
+ * Query used to affect what gets returned
464
+ */
465
+ const JOB_RETURN_QUERY = {
466
+ returnDocument: 'after',
467
+ sort: this.connectOptions.sort
468
+ };
469
+ // Find ONE and ONLY ONE job and set the 'lockedAt' time so that job begins to be processed
470
+ const result = await this.findOneAndUpdateDoc(JOB_PROCESS_WHERE_QUERY, JOB_PROCESS_SET_QUERY, JOB_RETURN_QUERY);
471
+ if (!result)
472
+ return undefined;
473
+ // Convert MongoDB ObjectId to JobId
474
+ return computeJobObj(result);
475
+ }
476
+ /**
477
+ * Get the underlying MongoDB Db instance (for use by job logger).
478
+ */
479
+ getDb() {
480
+ if (!this._db) {
481
+ throw new Error('Not connected');
482
+ }
483
+ return this._db;
484
+ }
485
+ async connect() {
486
+ const db = await this.createConnection();
487
+ this._db = db;
488
+ log('successful connection to MongoDB', db.options);
489
+ const collection = this.connectOptions.db?.collection || 'agendaJobs';
490
+ this.collection = db.collection(collection);
491
+ if (log.enabled) {
492
+ log(`connected with collection: ${collection}, collection size: ${typeof this.collection.estimatedDocumentCount === 'function'
493
+ ? await this.collection.estimatedDocumentCount()
494
+ : '?'}`);
495
+ }
496
+ if (this.connectOptions.ensureIndex ?? true) {
497
+ log('attempting index creation');
498
+ try {
499
+ // Index directions must be numeric; connectOptions.sort uses
500
+ // 'asc'/'desc' (valid for queries, rejected by createIndex).
501
+ const sortIndexSpec = {};
502
+ for (const [key, dir] of Object.entries(this.connectOptions.sort ?? {})) {
503
+ sortIndexSpec[key] = this.toMongoSortDirection(dir);
504
+ }
505
+ const result = await this.collection.createIndex({
506
+ name: 1,
507
+ ...sortIndexSpec,
508
+ priority: -1,
509
+ lockedAt: 1,
510
+ nextRunAt: 1,
511
+ disabled: 1
512
+ }, { name: 'findAndLockNextJobIndex' });
513
+ log('index succesfully created', result);
514
+ }
515
+ catch (error) {
516
+ log('db index creation failed', error);
517
+ throw error;
518
+ }
519
+ }
520
+ }
521
+ async database(url, options) {
522
+ let connectionString = url;
523
+ if (!hasMongoProtocol(connectionString)) {
524
+ connectionString = `mongodb://${connectionString}`;
525
+ }
526
+ this.mongoClient = await MongoClient.connect(connectionString, {
527
+ ...options
528
+ });
529
+ return this.mongoClient.db();
530
+ }
531
+ async disconnect() {
532
+ if (this.ownsConnection && this.mongoClient) {
533
+ log('closing owned MongoDB connection');
534
+ await this.mongoClient.close();
535
+ this.mongoClient = undefined;
536
+ }
537
+ }
538
+ async saveJobState(job, options) {
539
+ if (!job._id) {
540
+ throw new Error('Cannot save job state without job ID');
541
+ }
542
+ const id = new ObjectId(job._id.toString());
543
+ const $set = {
544
+ lockedAt: (job.lockedAt && new Date(job.lockedAt)) || undefined,
545
+ nextRunAt: (job.nextRunAt && new Date(job.nextRunAt)) || undefined,
546
+ lastRunAt: (job.lastRunAt && new Date(job.lastRunAt)) || undefined,
547
+ progress: job.progress,
548
+ failReason: job.failReason,
549
+ failCount: job.failCount,
550
+ failedAt: job.failedAt && new Date(job.failedAt),
551
+ lastFinishedAt: (job.lastFinishedAt && new Date(job.lastFinishedAt)) || undefined,
552
+ lastModifiedBy: options?.lastModifiedBy
553
+ };
554
+ log('[job %s] save job state: \n%O', job._id, $set);
555
+ const result = await this.collection.updateOne({ _id: id, name: job.name }, {
556
+ $set
557
+ });
558
+ if (!result.acknowledged || result.matchedCount !== 1) {
559
+ throw new Error(`job ${job._id} (name: ${job.name}) cannot be updated in the database, maybe it does not exist anymore?`);
560
+ }
561
+ }
562
+ /**
563
+ * Save a job to the database (insert or update)
564
+ * @param job Job parameters to save
565
+ * @returns The saved job parameters with ID
566
+ */
567
+ async saveJob(job, options) {
568
+ try {
569
+ log('attempting to save a job');
570
+ // Extract the ID and unique fields (not persisted directly)
571
+ const { _id, unique, uniqueOpts, ...props } = job;
572
+ // Add lastModifiedBy
573
+ const propsWithModifier = {
574
+ ...props,
575
+ lastModifiedBy: options?.lastModifiedBy
576
+ };
577
+ log('[job %s] set job props: \n%O', _id, propsWithModifier);
578
+ // Grab current time and set default query options for MongoDB
579
+ const now = new Date();
580
+ const protect = {};
581
+ let update = { $set: propsWithModifier };
582
+ log('current time stored as %s', now.toISOString());
583
+ // If the job already had an ID, then update the properties of the job
584
+ if (_id) {
585
+ log('job already has _id, calling findOneAndUpdate() using _id as query');
586
+ const result = await this.findOneAndUpdateDoc({ _id: new ObjectId(_id.toString()), name: props.name }, update, { returnDocument: 'after' });
587
+ if (!result) {
588
+ // Job was removed, return original data unchanged
589
+ log('job %s was not found for update, returning original data', _id);
590
+ return job;
591
+ }
592
+ return computeJobObj(result);
593
+ }
594
+ if (props.type === 'single') {
595
+ // Job type set to 'single' so...
596
+ log('job with type of "single" found');
597
+ // If the nextRunAt time is older than the current time, "protect" that property
598
+ if (props.nextRunAt && props.nextRunAt <= now) {
599
+ log('job has a scheduled nextRunAt time, protecting that field from upsert');
600
+ protect.nextRunAt = props.nextRunAt;
601
+ delete propsWithModifier.nextRunAt;
602
+ }
603
+ // If we have things to protect, set them in MongoDB using $setOnInsert
604
+ if (Object.keys(protect).length > 0) {
605
+ update.$setOnInsert = protect;
606
+ }
607
+ // Try an upsert - ensures only one job of this name exists
608
+ log(`calling findOneAndUpdate(${props.name}) with job name and type of "single" as query`);
609
+ const result = await this.findOneAndUpdateDoc({
610
+ name: props.name,
611
+ type: 'single'
612
+ }, update, {
613
+ upsert: true,
614
+ returnDocument: 'after'
615
+ });
616
+ log(`findOneAndUpdate(${props.name}) with type "single" completed`);
617
+ assert(result, 'findOneAndUpdate with upsert should always return a document');
618
+ return computeJobObj(result);
619
+ }
620
+ if (unique) {
621
+ // Upsert based on the 'unique' query object
622
+ const query = { ...unique };
623
+ query.name = props.name;
624
+ const debounce = uniqueOpts?.debounce;
625
+ if (uniqueOpts?.insertOnly && !debounce) {
626
+ update = { $setOnInsert: propsWithModifier };
627
+ }
628
+ // Handle debounce logic
629
+ if (debounce) {
630
+ log('handling debounce with delay: %dms, strategy: %s', debounce.delay, debounce.strategy || 'trailing');
631
+ // First, check if a matching job already exists
632
+ const existingJob = await this.collection.findOne(query);
633
+ if (existingJob) {
634
+ // Job exists - apply debounce logic
635
+ const debounceStartedAt = existingJob.debounceStartedAt ?? now;
636
+ const timeSinceStart = now.getTime() - debounceStartedAt.getTime();
637
+ if (debounce.strategy === 'leading') {
638
+ // Leading: keep original nextRunAt, just update data
639
+ // Don't change nextRunAt - the job runs on its original schedule
640
+ log('leading debounce: keeping original nextRunAt, updating data only');
641
+ const leadingUpdate = {
642
+ $set: {
643
+ ...propsWithModifier,
644
+ nextRunAt: existingJob.nextRunAt, // Preserve original
645
+ debounceStartedAt: debounceStartedAt
646
+ }
647
+ };
648
+ const result = await this.findOneAndUpdateDoc(query, leadingUpdate, {
649
+ returnDocument: 'after'
650
+ });
651
+ assert(result, 'findOneAndUpdate should return a document');
652
+ return computeJobObj(result);
653
+ }
654
+ // Trailing (default): push nextRunAt forward
655
+ let newNextRunAt;
656
+ // Check maxWait - force execution if exceeded
657
+ if (debounce.maxWait && timeSinceStart >= debounce.maxWait) {
658
+ log('maxWait exceeded (%dms >= %dms), forcing immediate execution', timeSinceStart, debounce.maxWait);
659
+ newNextRunAt = now;
660
+ // Reset debounceStartedAt for the next cycle
661
+ propsWithModifier.debounceStartedAt = undefined;
662
+ }
663
+ else {
664
+ // Normal debounce: schedule for delay ms from now
665
+ newNextRunAt = new Date(now.getTime() + debounce.delay);
666
+ propsWithModifier.debounceStartedAt = debounceStartedAt;
667
+ log('trailing debounce: rescheduling to %s', newNextRunAt.toISOString());
668
+ }
669
+ propsWithModifier.nextRunAt = newNextRunAt;
670
+ update = { $set: propsWithModifier };
671
+ const result = await this.findOneAndUpdateDoc(query, update, {
672
+ returnDocument: 'after'
673
+ });
674
+ assert(result, 'findOneAndUpdate should return a document');
675
+ return computeJobObj(result);
676
+ }
677
+ // No existing job - this is a new job
678
+ if (debounce.strategy === 'leading') {
679
+ // Leading: execute immediately (keep nextRunAt as-is)
680
+ log('leading debounce: new job, executing immediately');
681
+ propsWithModifier.debounceStartedAt = now;
682
+ }
683
+ else {
684
+ // Trailing: schedule after delay
685
+ const newNextRunAt = new Date(now.getTime() + debounce.delay);
686
+ propsWithModifier.nextRunAt = newNextRunAt;
687
+ propsWithModifier.debounceStartedAt = now;
688
+ log('trailing debounce: new job, scheduling for %s', newNextRunAt.toISOString());
689
+ }
690
+ }
691
+ log('calling findOneAndUpdate() with unique object as query: \n%O', query);
692
+ const result = await this.findOneAndUpdateDoc(query, update, {
693
+ upsert: true,
694
+ returnDocument: 'after'
695
+ });
696
+ assert(result, 'findOneAndUpdate with upsert should always return a document');
697
+ return computeJobObj(result);
698
+ }
699
+ // Insert new job
700
+ log('using default behavior, inserting new job via insertOne() with props that were set: \n%O', propsWithModifier);
701
+ const result = await this.collection.insertOne(propsWithModifier);
702
+ return computeJobObj({
703
+ _id: result.insertedId,
704
+ ...propsWithModifier
705
+ });
706
+ }
707
+ catch (error) {
708
+ log('saveJob() received an error, job was not updated/created');
709
+ throw error;
710
+ }
711
+ }
712
+ }
713
+ //# sourceMappingURL=MongoJobRepository.js.map