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