@monque/core 1.2.0 → 1.4.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.
package/dist/index.mjs CHANGED
@@ -3,6 +3,40 @@ import { CronExpressionParser } from "cron-parser";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { EventEmitter } from "node:events";
5
5
 
6
+ //#region src/jobs/document-to-persisted-job.ts
7
+ /**
8
+ * Convert a raw MongoDB document to a strongly-typed {@link PersistedJob}.
9
+ *
10
+ * Maps required fields directly and conditionally includes optional fields
11
+ * only when they are present in the document (`!== undefined`).
12
+ *
13
+ * @internal Not part of the public API.
14
+ * @template T - The job data payload type
15
+ * @param doc - The raw MongoDB document with `_id`
16
+ * @returns A strongly-typed PersistedJob object with guaranteed `_id`
17
+ */
18
+ function documentToPersistedJob(doc) {
19
+ const job = {
20
+ _id: doc._id,
21
+ name: doc["name"],
22
+ data: doc["data"],
23
+ status: doc["status"],
24
+ nextRunAt: doc["nextRunAt"],
25
+ failCount: doc["failCount"],
26
+ createdAt: doc["createdAt"],
27
+ updatedAt: doc["updatedAt"]
28
+ };
29
+ if (doc["lockedAt"] !== void 0) job.lockedAt = doc["lockedAt"];
30
+ if (doc["claimedBy"] !== void 0) job.claimedBy = doc["claimedBy"];
31
+ if (doc["lastHeartbeat"] !== void 0) job.lastHeartbeat = doc["lastHeartbeat"];
32
+ if (doc["heartbeatInterval"] !== void 0) job.heartbeatInterval = doc["heartbeatInterval"];
33
+ if (doc["failReason"] !== void 0) job.failReason = doc["failReason"];
34
+ if (doc["repeatInterval"] !== void 0) job.repeatInterval = doc["repeatInterval"];
35
+ if (doc["uniqueKey"] !== void 0) job.uniqueKey = doc["uniqueKey"];
36
+ return job;
37
+ }
38
+
39
+ //#endregion
6
40
  //#region src/jobs/types.ts
7
41
  /**
8
42
  * Represents the lifecycle states of a job in the queue.
@@ -561,6 +595,39 @@ function handleCronParseError(expression, error) {
561
595
  throw new InvalidCronError(expression, `Invalid cron expression "${expression}": ${error instanceof Error ? error.message : "Unknown parsing error"}. Expected 5-field format: "minute hour day-of-month month day-of-week" or predefined expression (e.g. @daily). Example: "0 9 * * 1" (every Monday at 9am)`);
562
596
  }
563
597
 
598
+ //#endregion
599
+ //#region src/shared/utils/error.ts
600
+ /**
601
+ * Normalize an unknown caught value into a proper `Error` instance.
602
+ *
603
+ * In JavaScript, any value can be thrown — strings, numbers, objects, `undefined`, etc.
604
+ * This function ensures we always have a real `Error` with a proper stack trace and message.
605
+ *
606
+ * @param value - The caught value (typically from a `catch` block typed as `unknown`).
607
+ * @returns The original value if already an `Error`, otherwise a new `Error` wrapping `String(value)`.
608
+ *
609
+ * @example
610
+ * ```ts
611
+ * try {
612
+ * riskyOperation();
613
+ * } catch (error: unknown) {
614
+ * const normalized = toError(error);
615
+ * console.error(normalized.message);
616
+ * }
617
+ * ```
618
+ *
619
+ * @internal
620
+ */
621
+ function toError(value) {
622
+ if (value instanceof Error) return value;
623
+ try {
624
+ return new Error(String(value));
625
+ } catch (conversionError) {
626
+ const detail = conversionError instanceof Error ? conversionError.message : "unknown conversion failure";
627
+ return /* @__PURE__ */ new Error(`Unserializable value (${detail})`);
628
+ }
629
+ }
630
+
564
631
  //#endregion
565
632
  //#region src/scheduler/helpers.ts
566
633
  /**
@@ -710,7 +777,7 @@ var ChangeStreamHandler = class {
710
777
  this.debounceTimer = setTimeout(() => {
711
778
  this.debounceTimer = null;
712
779
  this.onPoll().catch((error) => {
713
- this.ctx.emit("job:error", { error });
780
+ this.ctx.emit("job:error", { error: toError(error) });
714
781
  });
715
782
  }, 100);
716
783
  }
@@ -819,9 +886,8 @@ var JobManager = class {
819
886
  const _id = new ObjectId(jobId);
820
887
  const jobDoc = await this.ctx.collection.findOne({ _id });
821
888
  if (!jobDoc) return null;
822
- const currentJob = jobDoc;
823
- if (currentJob.status === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(currentJob);
824
- if (currentJob.status !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${currentJob.status}'`, jobId, currentJob.status, "cancel");
889
+ if (jobDoc["status"] === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(jobDoc);
890
+ if (jobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${jobDoc["status"]}'`, jobId, jobDoc["status"], "cancel");
825
891
  const result = await this.ctx.collection.findOneAndUpdate({
826
892
  _id,
827
893
  status: JobStatus.PENDING
@@ -906,8 +972,7 @@ var JobManager = class {
906
972
  const _id = new ObjectId(jobId);
907
973
  const currentJobDoc = await this.ctx.collection.findOne({ _id });
908
974
  if (!currentJobDoc) return null;
909
- const currentJob = currentJobDoc;
910
- if (currentJob.status !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJob.status}'`, jobId, currentJob.status, "reschedule");
975
+ if (currentJobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJobDoc["status"]}'`, jobId, currentJobDoc["status"], "reschedule");
911
976
  const result = await this.ctx.collection.findOneAndUpdate({
912
977
  _id,
913
978
  status: JobStatus.PENDING
@@ -969,21 +1034,20 @@ var JobManager = class {
969
1034
  const cancelledIds = [];
970
1035
  const cursor = this.ctx.collection.find(baseQuery);
971
1036
  for await (const doc of cursor) {
972
- const job = doc;
973
- const jobId = job._id.toString();
974
- if (job.status !== JobStatus.PENDING && job.status !== JobStatus.CANCELLED) {
1037
+ const jobId = doc._id.toString();
1038
+ if (doc["status"] !== JobStatus.PENDING && doc["status"] !== JobStatus.CANCELLED) {
975
1039
  errors.push({
976
1040
  jobId,
977
- error: `Cannot cancel job in status '${job.status}'`
1041
+ error: `Cannot cancel job in status '${doc["status"]}'`
978
1042
  });
979
1043
  continue;
980
1044
  }
981
- if (job.status === JobStatus.CANCELLED) {
1045
+ if (doc["status"] === JobStatus.CANCELLED) {
982
1046
  cancelledIds.push(jobId);
983
1047
  continue;
984
1048
  }
985
1049
  if (await this.ctx.collection.findOneAndUpdate({
986
- _id: job._id,
1050
+ _id: doc._id,
987
1051
  status: JobStatus.PENDING
988
1052
  }, { $set: {
989
1053
  status: JobStatus.CANCELLED,
@@ -1027,17 +1091,16 @@ var JobManager = class {
1027
1091
  const retriedIds = [];
1028
1092
  const cursor = this.ctx.collection.find(baseQuery);
1029
1093
  for await (const doc of cursor) {
1030
- const job = doc;
1031
- const jobId = job._id.toString();
1032
- if (job.status !== JobStatus.FAILED && job.status !== JobStatus.CANCELLED) {
1094
+ const jobId = doc._id.toString();
1095
+ if (doc["status"] !== JobStatus.FAILED && doc["status"] !== JobStatus.CANCELLED) {
1033
1096
  errors.push({
1034
1097
  jobId,
1035
- error: `Cannot retry job in status '${job.status}'`
1098
+ error: `Cannot retry job in status '${doc["status"]}'`
1036
1099
  });
1037
1100
  continue;
1038
1101
  }
1039
1102
  if (await this.ctx.collection.findOneAndUpdate({
1040
- _id: job._id,
1103
+ _id: doc._id,
1041
1104
  status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] }
1042
1105
  }, {
1043
1106
  $set: {
@@ -1110,6 +1173,8 @@ var JobManager = class {
1110
1173
  * @internal Not part of public API.
1111
1174
  */
1112
1175
  var JobProcessor = class {
1176
+ /** Guard flag to prevent concurrent poll() execution */
1177
+ _isPolling = false;
1113
1178
  constructor(ctx) {
1114
1179
  this.ctx = ctx;
1115
1180
  }
@@ -1144,7 +1209,18 @@ var JobProcessor = class {
1144
1209
  * the instance-level `instanceConcurrency` limit is reached.
1145
1210
  */
1146
1211
  async poll() {
1147
- if (!this.ctx.isRunning()) return;
1212
+ if (!this.ctx.isRunning() || this._isPolling) return;
1213
+ this._isPolling = true;
1214
+ try {
1215
+ await this._doPoll();
1216
+ } finally {
1217
+ this._isPolling = false;
1218
+ }
1219
+ }
1220
+ /**
1221
+ * Internal poll implementation.
1222
+ */
1223
+ async _doPoll() {
1148
1224
  const { instanceConcurrency } = this.ctx.options;
1149
1225
  if (instanceConcurrency !== void 0 && this.getTotalActiveJobs() >= instanceConcurrency) return;
1150
1226
  for (const [name, worker] of this.ctx.workers) {
@@ -1160,7 +1236,7 @@ var JobProcessor = class {
1160
1236
  worker.activeJobs.set(job._id.toString(), job);
1161
1237
  this.processJob(job, worker).catch((error) => {
1162
1238
  this.ctx.emit("job:error", {
1163
- error,
1239
+ error: toError(error),
1164
1240
  job
1165
1241
  });
1166
1242
  });
@@ -1212,6 +1288,10 @@ var JobProcessor = class {
1212
1288
  * both success and failure cases. On success, calls `completeJob()`. On failure,
1213
1289
  * calls `failJob()` which implements exponential backoff retry logic.
1214
1290
  *
1291
+ * Events are only emitted when the underlying atomic status transition succeeds,
1292
+ * ensuring event consumers receive reliable, consistent data backed by the actual
1293
+ * database state.
1294
+ *
1215
1295
  * @param job - The job to process
1216
1296
  * @param worker - The worker registration containing the handler and active job tracking
1217
1297
  */
@@ -1222,38 +1302,50 @@ var JobProcessor = class {
1222
1302
  try {
1223
1303
  await worker.handler(job);
1224
1304
  const duration = Date.now() - startTime;
1225
- await this.completeJob(job);
1226
- this.ctx.emit("job:complete", {
1227
- job,
1305
+ const updatedJob = await this.completeJob(job);
1306
+ if (updatedJob) this.ctx.emit("job:complete", {
1307
+ job: updatedJob,
1228
1308
  duration
1229
1309
  });
1230
1310
  } catch (error) {
1231
1311
  const err = error instanceof Error ? error : new Error(String(error));
1232
- await this.failJob(job, err);
1233
- const willRetry = job.failCount + 1 < this.ctx.options.maxRetries;
1234
- this.ctx.emit("job:fail", {
1235
- job,
1236
- error: err,
1237
- willRetry
1238
- });
1312
+ const updatedJob = await this.failJob(job, err);
1313
+ if (updatedJob) {
1314
+ const willRetry = updatedJob.status === JobStatus.PENDING;
1315
+ this.ctx.emit("job:fail", {
1316
+ job: updatedJob,
1317
+ error: err,
1318
+ willRetry
1319
+ });
1320
+ }
1239
1321
  } finally {
1240
1322
  worker.activeJobs.delete(jobId);
1241
1323
  }
1242
1324
  }
1243
1325
  /**
1244
- * Mark a job as completed successfully.
1326
+ * Mark a job as completed successfully using an atomic status transition.
1327
+ *
1328
+ * Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
1329
+ * preconditions to ensure the transition only occurs if the job is still owned by this
1330
+ * scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
1331
+ * by another instance after stale recovery).
1245
1332
  *
1246
1333
  * For recurring jobs (with `repeatInterval`), schedules the next run based on the cron
1247
1334
  * expression and resets `failCount` to 0. For one-time jobs, sets status to `completed`.
1248
1335
  * Clears `lockedAt` and `failReason` fields in both cases.
1249
1336
  *
1250
1337
  * @param job - The job that completed successfully
1338
+ * @returns The updated job document, or `null` if the transition could not be applied
1251
1339
  */
1252
1340
  async completeJob(job) {
1253
- if (!isPersistedJob(job)) return;
1341
+ if (!isPersistedJob(job)) return null;
1254
1342
  if (job.repeatInterval) {
1255
1343
  const nextRunAt = getNextCronDate(job.repeatInterval);
1256
- await this.ctx.collection.updateOne({ _id: job._id }, {
1344
+ const result = await this.ctx.collection.findOneAndUpdate({
1345
+ _id: job._id,
1346
+ status: JobStatus.PROCESSING,
1347
+ claimedBy: this.ctx.instanceId
1348
+ }, {
1257
1349
  $set: {
1258
1350
  status: JobStatus.PENDING,
1259
1351
  nextRunAt,
@@ -1267,61 +1359,59 @@ var JobProcessor = class {
1267
1359
  heartbeatInterval: "",
1268
1360
  failReason: ""
1269
1361
  }
1270
- });
1271
- } else {
1272
- await this.ctx.collection.updateOne({ _id: job._id }, {
1273
- $set: {
1274
- status: JobStatus.COMPLETED,
1275
- updatedAt: /* @__PURE__ */ new Date()
1276
- },
1277
- $unset: {
1278
- lockedAt: "",
1279
- claimedBy: "",
1280
- lastHeartbeat: "",
1281
- heartbeatInterval: "",
1282
- failReason: ""
1283
- }
1284
- });
1285
- job.status = JobStatus.COMPLETED;
1362
+ }, { returnDocument: "after" });
1363
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1286
1364
  }
1365
+ const result = await this.ctx.collection.findOneAndUpdate({
1366
+ _id: job._id,
1367
+ status: JobStatus.PROCESSING,
1368
+ claimedBy: this.ctx.instanceId
1369
+ }, {
1370
+ $set: {
1371
+ status: JobStatus.COMPLETED,
1372
+ updatedAt: /* @__PURE__ */ new Date()
1373
+ },
1374
+ $unset: {
1375
+ lockedAt: "",
1376
+ claimedBy: "",
1377
+ lastHeartbeat: "",
1378
+ heartbeatInterval: "",
1379
+ failReason: ""
1380
+ }
1381
+ }, { returnDocument: "after" });
1382
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1287
1383
  }
1288
1384
  /**
1289
- * Handle job failure with exponential backoff retry logic.
1385
+ * Handle job failure with exponential backoff retry logic using an atomic status transition.
1386
+ *
1387
+ * Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
1388
+ * preconditions to ensure the transition only occurs if the job is still owned by this
1389
+ * scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
1390
+ * by another instance after stale recovery).
1290
1391
  *
1291
1392
  * Increments `failCount` and calculates next retry time using exponential backoff:
1292
- * `nextRunAt = 2^failCount × baseRetryInterval` (capped by optional `maxBackoffDelay`).
1393
+ * `nextRunAt = 2^failCount * baseRetryInterval` (capped by optional `maxBackoffDelay`).
1293
1394
  *
1294
1395
  * If `failCount >= maxRetries`, marks job as permanently `failed`. Otherwise, resets
1295
1396
  * to `pending` status for retry. Stores error message in `failReason` field.
1296
1397
  *
1297
1398
  * @param job - The job that failed
1298
1399
  * @param error - The error that caused the failure
1400
+ * @returns The updated job document, or `null` if the transition could not be applied
1299
1401
  */
1300
1402
  async failJob(job, error) {
1301
- if (!isPersistedJob(job)) return;
1403
+ if (!isPersistedJob(job)) return null;
1302
1404
  const newFailCount = job.failCount + 1;
1303
- if (newFailCount >= this.ctx.options.maxRetries) await this.ctx.collection.updateOne({ _id: job._id }, {
1304
- $set: {
1305
- status: JobStatus.FAILED,
1306
- failCount: newFailCount,
1307
- failReason: error.message,
1308
- updatedAt: /* @__PURE__ */ new Date()
1309
- },
1310
- $unset: {
1311
- lockedAt: "",
1312
- claimedBy: "",
1313
- lastHeartbeat: "",
1314
- heartbeatInterval: ""
1315
- }
1316
- });
1317
- else {
1318
- const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
1319
- await this.ctx.collection.updateOne({ _id: job._id }, {
1405
+ if (newFailCount >= this.ctx.options.maxRetries) {
1406
+ const result = await this.ctx.collection.findOneAndUpdate({
1407
+ _id: job._id,
1408
+ status: JobStatus.PROCESSING,
1409
+ claimedBy: this.ctx.instanceId
1410
+ }, {
1320
1411
  $set: {
1321
- status: JobStatus.PENDING,
1412
+ status: JobStatus.FAILED,
1322
1413
  failCount: newFailCount,
1323
1414
  failReason: error.message,
1324
- nextRunAt,
1325
1415
  updatedAt: /* @__PURE__ */ new Date()
1326
1416
  },
1327
1417
  $unset: {
@@ -1330,8 +1420,30 @@ var JobProcessor = class {
1330
1420
  lastHeartbeat: "",
1331
1421
  heartbeatInterval: ""
1332
1422
  }
1333
- });
1423
+ }, { returnDocument: "after" });
1424
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1334
1425
  }
1426
+ const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
1427
+ const result = await this.ctx.collection.findOneAndUpdate({
1428
+ _id: job._id,
1429
+ status: JobStatus.PROCESSING,
1430
+ claimedBy: this.ctx.instanceId
1431
+ }, {
1432
+ $set: {
1433
+ status: JobStatus.PENDING,
1434
+ failCount: newFailCount,
1435
+ failReason: error.message,
1436
+ nextRunAt,
1437
+ updatedAt: /* @__PURE__ */ new Date()
1438
+ },
1439
+ $unset: {
1440
+ lockedAt: "",
1441
+ claimedBy: "",
1442
+ lastHeartbeat: "",
1443
+ heartbeatInterval: ""
1444
+ }
1445
+ }, { returnDocument: "after" });
1446
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1335
1447
  }
1336
1448
  /**
1337
1449
  * Update heartbeats for all jobs claimed by this scheduler instance.
@@ -1947,7 +2059,8 @@ var Monque = class extends EventEmitter {
1947
2059
  instanceConcurrency: options.instanceConcurrency ?? options.maxConcurrency,
1948
2060
  schedulerInstanceId: options.schedulerInstanceId ?? randomUUID(),
1949
2061
  heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
1950
- jobRetention: options.jobRetention
2062
+ jobRetention: options.jobRetention,
2063
+ skipIndexCreation: options.skipIndexCreation ?? false
1951
2064
  };
1952
2065
  }
1953
2066
  /**
@@ -1960,7 +2073,7 @@ var Monque = class extends EventEmitter {
1960
2073
  if (this.isInitialized) return;
1961
2074
  try {
1962
2075
  this.collection = this.db.collection(this.options.collectionName);
1963
- await this.createIndexes();
2076
+ if (!this.options.skipIndexCreation) await this.createIndexes();
1964
2077
  if (this.options.recoverStaleJobs) await this.recoverStaleJobs();
1965
2078
  const ctx = this.buildContext();
1966
2079
  this._scheduler = new JobScheduler(ctx);
@@ -2010,7 +2123,7 @@ var Monque = class extends EventEmitter {
2010
2123
  workers: this.workers,
2011
2124
  isRunning: () => this.isRunning,
2012
2125
  emit: (event, payload) => this.emit(event, payload),
2013
- documentToPersistedJob: (doc) => this.documentToPersistedJob(doc)
2126
+ documentToPersistedJob: (doc) => documentToPersistedJob(doc)
2014
2127
  };
2015
2128
  }
2016
2129
  /**
@@ -2027,43 +2140,64 @@ var Monque = class extends EventEmitter {
2027
2140
  */
2028
2141
  async createIndexes() {
2029
2142
  if (!this.collection) throw new ConnectionError("Collection not initialized");
2030
- await this.collection.createIndex({
2031
- status: 1,
2032
- nextRunAt: 1
2033
- }, { background: true });
2034
- await this.collection.createIndex({
2035
- name: 1,
2036
- uniqueKey: 1
2037
- }, {
2038
- unique: true,
2039
- partialFilterExpression: {
2040
- uniqueKey: { $exists: true },
2041
- status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
2143
+ await this.collection.createIndexes([
2144
+ {
2145
+ key: {
2146
+ status: 1,
2147
+ nextRunAt: 1
2148
+ },
2149
+ background: true
2042
2150
  },
2043
- background: true
2044
- });
2045
- await this.collection.createIndex({
2046
- name: 1,
2047
- status: 1
2048
- }, { background: true });
2049
- await this.collection.createIndex({
2050
- claimedBy: 1,
2051
- status: 1
2052
- }, { background: true });
2053
- await this.collection.createIndex({
2054
- lastHeartbeat: 1,
2055
- status: 1
2056
- }, { background: true });
2057
- await this.collection.createIndex({
2058
- status: 1,
2059
- nextRunAt: 1,
2060
- claimedBy: 1
2061
- }, { background: true });
2062
- await this.collection.createIndex({
2063
- status: 1,
2064
- lockedAt: 1,
2065
- lastHeartbeat: 1
2066
- }, { background: true });
2151
+ {
2152
+ key: {
2153
+ name: 1,
2154
+ uniqueKey: 1
2155
+ },
2156
+ unique: true,
2157
+ partialFilterExpression: {
2158
+ uniqueKey: { $exists: true },
2159
+ status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
2160
+ },
2161
+ background: true
2162
+ },
2163
+ {
2164
+ key: {
2165
+ name: 1,
2166
+ status: 1
2167
+ },
2168
+ background: true
2169
+ },
2170
+ {
2171
+ key: {
2172
+ claimedBy: 1,
2173
+ status: 1
2174
+ },
2175
+ background: true
2176
+ },
2177
+ {
2178
+ key: {
2179
+ lastHeartbeat: 1,
2180
+ status: 1
2181
+ },
2182
+ background: true
2183
+ },
2184
+ {
2185
+ key: {
2186
+ status: 1,
2187
+ nextRunAt: 1,
2188
+ claimedBy: 1
2189
+ },
2190
+ background: true
2191
+ },
2192
+ {
2193
+ key: {
2194
+ status: 1,
2195
+ lockedAt: 1,
2196
+ lastHeartbeat: 1
2197
+ },
2198
+ background: true
2199
+ }
2200
+ ]);
2067
2201
  }
2068
2202
  /**
2069
2203
  * Recover stale jobs that were left in 'processing' status.
@@ -2665,27 +2799,27 @@ var Monque = class extends EventEmitter {
2665
2799
  this.changeStreamHandler.setup();
2666
2800
  this.pollIntervalId = setInterval(() => {
2667
2801
  this.processor.poll().catch((error) => {
2668
- this.emit("job:error", { error });
2802
+ this.emit("job:error", { error: toError(error) });
2669
2803
  });
2670
2804
  }, this.options.pollInterval);
2671
2805
  this.heartbeatIntervalId = setInterval(() => {
2672
2806
  this.processor.updateHeartbeats().catch((error) => {
2673
- this.emit("job:error", { error });
2807
+ this.emit("job:error", { error: toError(error) });
2674
2808
  });
2675
2809
  }, this.options.heartbeatInterval);
2676
2810
  if (this.options.jobRetention) {
2677
2811
  const interval = this.options.jobRetention.interval ?? DEFAULTS.retentionInterval;
2678
2812
  this.cleanupJobs().catch((error) => {
2679
- this.emit("job:error", { error });
2813
+ this.emit("job:error", { error: toError(error) });
2680
2814
  });
2681
2815
  this.cleanupIntervalId = setInterval(() => {
2682
2816
  this.cleanupJobs().catch((error) => {
2683
- this.emit("job:error", { error });
2817
+ this.emit("job:error", { error: toError(error) });
2684
2818
  });
2685
2819
  }, interval);
2686
2820
  }
2687
2821
  this.processor.poll().catch((error) => {
2688
- this.emit("job:error", { error });
2822
+ this.emit("job:error", { error: toError(error) });
2689
2823
  });
2690
2824
  }
2691
2825
  /**
@@ -2843,37 +2977,6 @@ var Monque = class extends EventEmitter {
2843
2977
  return activeJobs;
2844
2978
  }
2845
2979
  /**
2846
- * Convert a MongoDB document to a typed PersistedJob object.
2847
- *
2848
- * Maps raw MongoDB document fields to the strongly-typed `PersistedJob<T>` interface,
2849
- * ensuring type safety and handling optional fields (`lockedAt`, `failReason`, etc.).
2850
- *
2851
- * @private
2852
- * @template T - The job data payload type
2853
- * @param doc - The raw MongoDB document with `_id`
2854
- * @returns A strongly-typed PersistedJob object with guaranteed `_id`
2855
- */
2856
- documentToPersistedJob(doc) {
2857
- const job = {
2858
- _id: doc._id,
2859
- name: doc["name"],
2860
- data: doc["data"],
2861
- status: doc["status"],
2862
- nextRunAt: doc["nextRunAt"],
2863
- failCount: doc["failCount"],
2864
- createdAt: doc["createdAt"],
2865
- updatedAt: doc["updatedAt"]
2866
- };
2867
- if (doc["lockedAt"] !== void 0) job.lockedAt = doc["lockedAt"];
2868
- if (doc["claimedBy"] !== void 0) job.claimedBy = doc["claimedBy"];
2869
- if (doc["lastHeartbeat"] !== void 0) job.lastHeartbeat = doc["lastHeartbeat"];
2870
- if (doc["heartbeatInterval"] !== void 0) job.heartbeatInterval = doc["heartbeatInterval"];
2871
- if (doc["failReason"] !== void 0) job.failReason = doc["failReason"];
2872
- if (doc["repeatInterval"] !== void 0) job.repeatInterval = doc["repeatInterval"];
2873
- if (doc["uniqueKey"] !== void 0) job.uniqueKey = doc["uniqueKey"];
2874
- return job;
2875
- }
2876
- /**
2877
2980
  * Type-safe event emitter methods
2878
2981
  */
2879
2982
  emit(event, payload) {