@monque/core 1.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @monque/core
2
2
 
3
+ ## 1.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#188](https://github.com/ueberBrot/monque/pull/188) [`9ce0ade`](https://github.com/ueberBrot/monque/commit/9ce0adebda2d47841a4420e94a440b62d10396a0) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Optimize MongoDB index creation by batching 7 sequential `createIndex()` calls into a single `createIndexes()` call. This reduces index creation from 7 round-trips to 1, significantly speeding up `initialize()` on first run.
8
+
9
+ Also adds `skipIndexCreation` option to `MonqueOptions` for production deployments where indexes are managed externally (e.g., via migrations or DBA tooling).
10
+
11
+ ### Patch Changes
12
+
13
+ - [#187](https://github.com/ueberBrot/monque/pull/187) [`67c9d9a`](https://github.com/ueberBrot/monque/commit/67c9d9a2a5df6c493d5f51c15a33cd38db49cbe0) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Use atomic `findOneAndUpdate` with status preconditions in `completeJob` and `failJob` to prevent phantom events when the DB write is a no-op. Events are now only emitted when the transition actually occurred, and `willRetry` is derived from the actual DB document state.
14
+
15
+ - [#173](https://github.com/ueberBrot/monque/pull/173) [`df0630a`](https://github.com/ueberBrot/monque/commit/df0630a5e5c84e3216f5b65a999ce618502f89ab) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Replace unsafe `as unknown as WithId<Job>` type casts in `job-manager` with bracket notation, consistent with the existing pattern in `retryJob`.
16
+
17
+ - [#186](https://github.com/ueberBrot/monque/pull/186) [`5697370`](https://github.com/ueberBrot/monque/commit/5697370cf278302ffa6dcaaf0a4fb34ed9c3bc00) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Replace 7 unsafe `error as Error` casts with a new `toError()` utility that safely normalizes unknown caught values into proper `Error` instances, preventing silent type lies when non-Error values are thrown.
18
+
19
+ - [#189](https://github.com/ueberBrot/monque/pull/189) [`1e32439`](https://github.com/ueberBrot/monque/commit/1e324395caf25309dfb7c40bc35edac60b8d80ad) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Extract `documentToPersistedJob` mapper into a standalone function for testability and add round-trip unit tests to guard against silent field-dropping when new Job fields are added.
20
+
3
21
  ## 1.3.0
4
22
 
5
23
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -4,6 +4,40 @@ let cron_parser = require("cron-parser");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_events = require("node:events");
6
6
 
7
+ //#region src/jobs/document-to-persisted-job.ts
8
+ /**
9
+ * Convert a raw MongoDB document to a strongly-typed {@link PersistedJob}.
10
+ *
11
+ * Maps required fields directly and conditionally includes optional fields
12
+ * only when they are present in the document (`!== undefined`).
13
+ *
14
+ * @internal Not part of the public API.
15
+ * @template T - The job data payload type
16
+ * @param doc - The raw MongoDB document with `_id`
17
+ * @returns A strongly-typed PersistedJob object with guaranteed `_id`
18
+ */
19
+ function documentToPersistedJob(doc) {
20
+ const job = {
21
+ _id: doc._id,
22
+ name: doc["name"],
23
+ data: doc["data"],
24
+ status: doc["status"],
25
+ nextRunAt: doc["nextRunAt"],
26
+ failCount: doc["failCount"],
27
+ createdAt: doc["createdAt"],
28
+ updatedAt: doc["updatedAt"]
29
+ };
30
+ if (doc["lockedAt"] !== void 0) job.lockedAt = doc["lockedAt"];
31
+ if (doc["claimedBy"] !== void 0) job.claimedBy = doc["claimedBy"];
32
+ if (doc["lastHeartbeat"] !== void 0) job.lastHeartbeat = doc["lastHeartbeat"];
33
+ if (doc["heartbeatInterval"] !== void 0) job.heartbeatInterval = doc["heartbeatInterval"];
34
+ if (doc["failReason"] !== void 0) job.failReason = doc["failReason"];
35
+ if (doc["repeatInterval"] !== void 0) job.repeatInterval = doc["repeatInterval"];
36
+ if (doc["uniqueKey"] !== void 0) job.uniqueKey = doc["uniqueKey"];
37
+ return job;
38
+ }
39
+
40
+ //#endregion
7
41
  //#region src/jobs/types.ts
8
42
  /**
9
43
  * Represents the lifecycle states of a job in the queue.
@@ -562,6 +596,39 @@ function handleCronParseError(expression, error) {
562
596
  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)`);
563
597
  }
564
598
 
599
+ //#endregion
600
+ //#region src/shared/utils/error.ts
601
+ /**
602
+ * Normalize an unknown caught value into a proper `Error` instance.
603
+ *
604
+ * In JavaScript, any value can be thrown — strings, numbers, objects, `undefined`, etc.
605
+ * This function ensures we always have a real `Error` with a proper stack trace and message.
606
+ *
607
+ * @param value - The caught value (typically from a `catch` block typed as `unknown`).
608
+ * @returns The original value if already an `Error`, otherwise a new `Error` wrapping `String(value)`.
609
+ *
610
+ * @example
611
+ * ```ts
612
+ * try {
613
+ * riskyOperation();
614
+ * } catch (error: unknown) {
615
+ * const normalized = toError(error);
616
+ * console.error(normalized.message);
617
+ * }
618
+ * ```
619
+ *
620
+ * @internal
621
+ */
622
+ function toError(value) {
623
+ if (value instanceof Error) return value;
624
+ try {
625
+ return new Error(String(value));
626
+ } catch (conversionError) {
627
+ const detail = conversionError instanceof Error ? conversionError.message : "unknown conversion failure";
628
+ return /* @__PURE__ */ new Error(`Unserializable value (${detail})`);
629
+ }
630
+ }
631
+
565
632
  //#endregion
566
633
  //#region src/scheduler/helpers.ts
567
634
  /**
@@ -711,7 +778,7 @@ var ChangeStreamHandler = class {
711
778
  this.debounceTimer = setTimeout(() => {
712
779
  this.debounceTimer = null;
713
780
  this.onPoll().catch((error) => {
714
- this.ctx.emit("job:error", { error });
781
+ this.ctx.emit("job:error", { error: toError(error) });
715
782
  });
716
783
  }, 100);
717
784
  }
@@ -820,9 +887,8 @@ var JobManager = class {
820
887
  const _id = new mongodb.ObjectId(jobId);
821
888
  const jobDoc = await this.ctx.collection.findOne({ _id });
822
889
  if (!jobDoc) return null;
823
- const currentJob = jobDoc;
824
- if (currentJob.status === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(currentJob);
825
- if (currentJob.status !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${currentJob.status}'`, jobId, currentJob.status, "cancel");
890
+ if (jobDoc["status"] === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(jobDoc);
891
+ if (jobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${jobDoc["status"]}'`, jobId, jobDoc["status"], "cancel");
826
892
  const result = await this.ctx.collection.findOneAndUpdate({
827
893
  _id,
828
894
  status: JobStatus.PENDING
@@ -907,8 +973,7 @@ var JobManager = class {
907
973
  const _id = new mongodb.ObjectId(jobId);
908
974
  const currentJobDoc = await this.ctx.collection.findOne({ _id });
909
975
  if (!currentJobDoc) return null;
910
- const currentJob = currentJobDoc;
911
- if (currentJob.status !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJob.status}'`, jobId, currentJob.status, "reschedule");
976
+ if (currentJobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJobDoc["status"]}'`, jobId, currentJobDoc["status"], "reschedule");
912
977
  const result = await this.ctx.collection.findOneAndUpdate({
913
978
  _id,
914
979
  status: JobStatus.PENDING
@@ -970,21 +1035,20 @@ var JobManager = class {
970
1035
  const cancelledIds = [];
971
1036
  const cursor = this.ctx.collection.find(baseQuery);
972
1037
  for await (const doc of cursor) {
973
- const job = doc;
974
- const jobId = job._id.toString();
975
- if (job.status !== JobStatus.PENDING && job.status !== JobStatus.CANCELLED) {
1038
+ const jobId = doc._id.toString();
1039
+ if (doc["status"] !== JobStatus.PENDING && doc["status"] !== JobStatus.CANCELLED) {
976
1040
  errors.push({
977
1041
  jobId,
978
- error: `Cannot cancel job in status '${job.status}'`
1042
+ error: `Cannot cancel job in status '${doc["status"]}'`
979
1043
  });
980
1044
  continue;
981
1045
  }
982
- if (job.status === JobStatus.CANCELLED) {
1046
+ if (doc["status"] === JobStatus.CANCELLED) {
983
1047
  cancelledIds.push(jobId);
984
1048
  continue;
985
1049
  }
986
1050
  if (await this.ctx.collection.findOneAndUpdate({
987
- _id: job._id,
1051
+ _id: doc._id,
988
1052
  status: JobStatus.PENDING
989
1053
  }, { $set: {
990
1054
  status: JobStatus.CANCELLED,
@@ -1028,17 +1092,16 @@ var JobManager = class {
1028
1092
  const retriedIds = [];
1029
1093
  const cursor = this.ctx.collection.find(baseQuery);
1030
1094
  for await (const doc of cursor) {
1031
- const job = doc;
1032
- const jobId = job._id.toString();
1033
- if (job.status !== JobStatus.FAILED && job.status !== JobStatus.CANCELLED) {
1095
+ const jobId = doc._id.toString();
1096
+ if (doc["status"] !== JobStatus.FAILED && doc["status"] !== JobStatus.CANCELLED) {
1034
1097
  errors.push({
1035
1098
  jobId,
1036
- error: `Cannot retry job in status '${job.status}'`
1099
+ error: `Cannot retry job in status '${doc["status"]}'`
1037
1100
  });
1038
1101
  continue;
1039
1102
  }
1040
1103
  if (await this.ctx.collection.findOneAndUpdate({
1041
- _id: job._id,
1104
+ _id: doc._id,
1042
1105
  status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] }
1043
1106
  }, {
1044
1107
  $set: {
@@ -1174,7 +1237,7 @@ var JobProcessor = class {
1174
1237
  worker.activeJobs.set(job._id.toString(), job);
1175
1238
  this.processJob(job, worker).catch((error) => {
1176
1239
  this.ctx.emit("job:error", {
1177
- error,
1240
+ error: toError(error),
1178
1241
  job
1179
1242
  });
1180
1243
  });
@@ -1226,6 +1289,10 @@ var JobProcessor = class {
1226
1289
  * both success and failure cases. On success, calls `completeJob()`. On failure,
1227
1290
  * calls `failJob()` which implements exponential backoff retry logic.
1228
1291
  *
1292
+ * Events are only emitted when the underlying atomic status transition succeeds,
1293
+ * ensuring event consumers receive reliable, consistent data backed by the actual
1294
+ * database state.
1295
+ *
1229
1296
  * @param job - The job to process
1230
1297
  * @param worker - The worker registration containing the handler and active job tracking
1231
1298
  */
@@ -1236,38 +1303,50 @@ var JobProcessor = class {
1236
1303
  try {
1237
1304
  await worker.handler(job);
1238
1305
  const duration = Date.now() - startTime;
1239
- await this.completeJob(job);
1240
- this.ctx.emit("job:complete", {
1241
- job,
1306
+ const updatedJob = await this.completeJob(job);
1307
+ if (updatedJob) this.ctx.emit("job:complete", {
1308
+ job: updatedJob,
1242
1309
  duration
1243
1310
  });
1244
1311
  } catch (error) {
1245
1312
  const err = error instanceof Error ? error : new Error(String(error));
1246
- await this.failJob(job, err);
1247
- const willRetry = job.failCount + 1 < this.ctx.options.maxRetries;
1248
- this.ctx.emit("job:fail", {
1249
- job,
1250
- error: err,
1251
- willRetry
1252
- });
1313
+ const updatedJob = await this.failJob(job, err);
1314
+ if (updatedJob) {
1315
+ const willRetry = updatedJob.status === JobStatus.PENDING;
1316
+ this.ctx.emit("job:fail", {
1317
+ job: updatedJob,
1318
+ error: err,
1319
+ willRetry
1320
+ });
1321
+ }
1253
1322
  } finally {
1254
1323
  worker.activeJobs.delete(jobId);
1255
1324
  }
1256
1325
  }
1257
1326
  /**
1258
- * Mark a job as completed successfully.
1327
+ * Mark a job as completed successfully using an atomic status transition.
1328
+ *
1329
+ * Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
1330
+ * preconditions to ensure the transition only occurs if the job is still owned by this
1331
+ * scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
1332
+ * by another instance after stale recovery).
1259
1333
  *
1260
1334
  * For recurring jobs (with `repeatInterval`), schedules the next run based on the cron
1261
1335
  * expression and resets `failCount` to 0. For one-time jobs, sets status to `completed`.
1262
1336
  * Clears `lockedAt` and `failReason` fields in both cases.
1263
1337
  *
1264
1338
  * @param job - The job that completed successfully
1339
+ * @returns The updated job document, or `null` if the transition could not be applied
1265
1340
  */
1266
1341
  async completeJob(job) {
1267
- if (!isPersistedJob(job)) return;
1342
+ if (!isPersistedJob(job)) return null;
1268
1343
  if (job.repeatInterval) {
1269
1344
  const nextRunAt = getNextCronDate(job.repeatInterval);
1270
- await this.ctx.collection.updateOne({ _id: job._id }, {
1345
+ const result = await this.ctx.collection.findOneAndUpdate({
1346
+ _id: job._id,
1347
+ status: JobStatus.PROCESSING,
1348
+ claimedBy: this.ctx.instanceId
1349
+ }, {
1271
1350
  $set: {
1272
1351
  status: JobStatus.PENDING,
1273
1352
  nextRunAt,
@@ -1281,61 +1360,59 @@ var JobProcessor = class {
1281
1360
  heartbeatInterval: "",
1282
1361
  failReason: ""
1283
1362
  }
1284
- });
1285
- } else {
1286
- await this.ctx.collection.updateOne({ _id: job._id }, {
1287
- $set: {
1288
- status: JobStatus.COMPLETED,
1289
- updatedAt: /* @__PURE__ */ new Date()
1290
- },
1291
- $unset: {
1292
- lockedAt: "",
1293
- claimedBy: "",
1294
- lastHeartbeat: "",
1295
- heartbeatInterval: "",
1296
- failReason: ""
1297
- }
1298
- });
1299
- job.status = JobStatus.COMPLETED;
1363
+ }, { returnDocument: "after" });
1364
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1300
1365
  }
1366
+ const result = await this.ctx.collection.findOneAndUpdate({
1367
+ _id: job._id,
1368
+ status: JobStatus.PROCESSING,
1369
+ claimedBy: this.ctx.instanceId
1370
+ }, {
1371
+ $set: {
1372
+ status: JobStatus.COMPLETED,
1373
+ updatedAt: /* @__PURE__ */ new Date()
1374
+ },
1375
+ $unset: {
1376
+ lockedAt: "",
1377
+ claimedBy: "",
1378
+ lastHeartbeat: "",
1379
+ heartbeatInterval: "",
1380
+ failReason: ""
1381
+ }
1382
+ }, { returnDocument: "after" });
1383
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1301
1384
  }
1302
1385
  /**
1303
- * Handle job failure with exponential backoff retry logic.
1386
+ * Handle job failure with exponential backoff retry logic using an atomic status transition.
1387
+ *
1388
+ * Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
1389
+ * preconditions to ensure the transition only occurs if the job is still owned by this
1390
+ * scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
1391
+ * by another instance after stale recovery).
1304
1392
  *
1305
1393
  * Increments `failCount` and calculates next retry time using exponential backoff:
1306
- * `nextRunAt = 2^failCount × baseRetryInterval` (capped by optional `maxBackoffDelay`).
1394
+ * `nextRunAt = 2^failCount * baseRetryInterval` (capped by optional `maxBackoffDelay`).
1307
1395
  *
1308
1396
  * If `failCount >= maxRetries`, marks job as permanently `failed`. Otherwise, resets
1309
1397
  * to `pending` status for retry. Stores error message in `failReason` field.
1310
1398
  *
1311
1399
  * @param job - The job that failed
1312
1400
  * @param error - The error that caused the failure
1401
+ * @returns The updated job document, or `null` if the transition could not be applied
1313
1402
  */
1314
1403
  async failJob(job, error) {
1315
- if (!isPersistedJob(job)) return;
1404
+ if (!isPersistedJob(job)) return null;
1316
1405
  const newFailCount = job.failCount + 1;
1317
- if (newFailCount >= this.ctx.options.maxRetries) await this.ctx.collection.updateOne({ _id: job._id }, {
1318
- $set: {
1319
- status: JobStatus.FAILED,
1320
- failCount: newFailCount,
1321
- failReason: error.message,
1322
- updatedAt: /* @__PURE__ */ new Date()
1323
- },
1324
- $unset: {
1325
- lockedAt: "",
1326
- claimedBy: "",
1327
- lastHeartbeat: "",
1328
- heartbeatInterval: ""
1329
- }
1330
- });
1331
- else {
1332
- const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
1333
- await this.ctx.collection.updateOne({ _id: job._id }, {
1406
+ if (newFailCount >= this.ctx.options.maxRetries) {
1407
+ const result = await this.ctx.collection.findOneAndUpdate({
1408
+ _id: job._id,
1409
+ status: JobStatus.PROCESSING,
1410
+ claimedBy: this.ctx.instanceId
1411
+ }, {
1334
1412
  $set: {
1335
- status: JobStatus.PENDING,
1413
+ status: JobStatus.FAILED,
1336
1414
  failCount: newFailCount,
1337
1415
  failReason: error.message,
1338
- nextRunAt,
1339
1416
  updatedAt: /* @__PURE__ */ new Date()
1340
1417
  },
1341
1418
  $unset: {
@@ -1344,8 +1421,30 @@ var JobProcessor = class {
1344
1421
  lastHeartbeat: "",
1345
1422
  heartbeatInterval: ""
1346
1423
  }
1347
- });
1424
+ }, { returnDocument: "after" });
1425
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1348
1426
  }
1427
+ const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
1428
+ const result = await this.ctx.collection.findOneAndUpdate({
1429
+ _id: job._id,
1430
+ status: JobStatus.PROCESSING,
1431
+ claimedBy: this.ctx.instanceId
1432
+ }, {
1433
+ $set: {
1434
+ status: JobStatus.PENDING,
1435
+ failCount: newFailCount,
1436
+ failReason: error.message,
1437
+ nextRunAt,
1438
+ updatedAt: /* @__PURE__ */ new Date()
1439
+ },
1440
+ $unset: {
1441
+ lockedAt: "",
1442
+ claimedBy: "",
1443
+ lastHeartbeat: "",
1444
+ heartbeatInterval: ""
1445
+ }
1446
+ }, { returnDocument: "after" });
1447
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1349
1448
  }
1350
1449
  /**
1351
1450
  * Update heartbeats for all jobs claimed by this scheduler instance.
@@ -1961,7 +2060,8 @@ var Monque = class extends node_events.EventEmitter {
1961
2060
  instanceConcurrency: options.instanceConcurrency ?? options.maxConcurrency,
1962
2061
  schedulerInstanceId: options.schedulerInstanceId ?? (0, node_crypto.randomUUID)(),
1963
2062
  heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
1964
- jobRetention: options.jobRetention
2063
+ jobRetention: options.jobRetention,
2064
+ skipIndexCreation: options.skipIndexCreation ?? false
1965
2065
  };
1966
2066
  }
1967
2067
  /**
@@ -1974,7 +2074,7 @@ var Monque = class extends node_events.EventEmitter {
1974
2074
  if (this.isInitialized) return;
1975
2075
  try {
1976
2076
  this.collection = this.db.collection(this.options.collectionName);
1977
- await this.createIndexes();
2077
+ if (!this.options.skipIndexCreation) await this.createIndexes();
1978
2078
  if (this.options.recoverStaleJobs) await this.recoverStaleJobs();
1979
2079
  const ctx = this.buildContext();
1980
2080
  this._scheduler = new JobScheduler(ctx);
@@ -2024,7 +2124,7 @@ var Monque = class extends node_events.EventEmitter {
2024
2124
  workers: this.workers,
2025
2125
  isRunning: () => this.isRunning,
2026
2126
  emit: (event, payload) => this.emit(event, payload),
2027
- documentToPersistedJob: (doc) => this.documentToPersistedJob(doc)
2127
+ documentToPersistedJob: (doc) => documentToPersistedJob(doc)
2028
2128
  };
2029
2129
  }
2030
2130
  /**
@@ -2041,43 +2141,64 @@ var Monque = class extends node_events.EventEmitter {
2041
2141
  */
2042
2142
  async createIndexes() {
2043
2143
  if (!this.collection) throw new ConnectionError("Collection not initialized");
2044
- await this.collection.createIndex({
2045
- status: 1,
2046
- nextRunAt: 1
2047
- }, { background: true });
2048
- await this.collection.createIndex({
2049
- name: 1,
2050
- uniqueKey: 1
2051
- }, {
2052
- unique: true,
2053
- partialFilterExpression: {
2054
- uniqueKey: { $exists: true },
2055
- status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
2144
+ await this.collection.createIndexes([
2145
+ {
2146
+ key: {
2147
+ status: 1,
2148
+ nextRunAt: 1
2149
+ },
2150
+ background: true
2056
2151
  },
2057
- background: true
2058
- });
2059
- await this.collection.createIndex({
2060
- name: 1,
2061
- status: 1
2062
- }, { background: true });
2063
- await this.collection.createIndex({
2064
- claimedBy: 1,
2065
- status: 1
2066
- }, { background: true });
2067
- await this.collection.createIndex({
2068
- lastHeartbeat: 1,
2069
- status: 1
2070
- }, { background: true });
2071
- await this.collection.createIndex({
2072
- status: 1,
2073
- nextRunAt: 1,
2074
- claimedBy: 1
2075
- }, { background: true });
2076
- await this.collection.createIndex({
2077
- status: 1,
2078
- lockedAt: 1,
2079
- lastHeartbeat: 1
2080
- }, { background: true });
2152
+ {
2153
+ key: {
2154
+ name: 1,
2155
+ uniqueKey: 1
2156
+ },
2157
+ unique: true,
2158
+ partialFilterExpression: {
2159
+ uniqueKey: { $exists: true },
2160
+ status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
2161
+ },
2162
+ background: true
2163
+ },
2164
+ {
2165
+ key: {
2166
+ name: 1,
2167
+ status: 1
2168
+ },
2169
+ background: true
2170
+ },
2171
+ {
2172
+ key: {
2173
+ claimedBy: 1,
2174
+ status: 1
2175
+ },
2176
+ background: true
2177
+ },
2178
+ {
2179
+ key: {
2180
+ lastHeartbeat: 1,
2181
+ status: 1
2182
+ },
2183
+ background: true
2184
+ },
2185
+ {
2186
+ key: {
2187
+ status: 1,
2188
+ nextRunAt: 1,
2189
+ claimedBy: 1
2190
+ },
2191
+ background: true
2192
+ },
2193
+ {
2194
+ key: {
2195
+ status: 1,
2196
+ lockedAt: 1,
2197
+ lastHeartbeat: 1
2198
+ },
2199
+ background: true
2200
+ }
2201
+ ]);
2081
2202
  }
2082
2203
  /**
2083
2204
  * Recover stale jobs that were left in 'processing' status.
@@ -2679,27 +2800,27 @@ var Monque = class extends node_events.EventEmitter {
2679
2800
  this.changeStreamHandler.setup();
2680
2801
  this.pollIntervalId = setInterval(() => {
2681
2802
  this.processor.poll().catch((error) => {
2682
- this.emit("job:error", { error });
2803
+ this.emit("job:error", { error: toError(error) });
2683
2804
  });
2684
2805
  }, this.options.pollInterval);
2685
2806
  this.heartbeatIntervalId = setInterval(() => {
2686
2807
  this.processor.updateHeartbeats().catch((error) => {
2687
- this.emit("job:error", { error });
2808
+ this.emit("job:error", { error: toError(error) });
2688
2809
  });
2689
2810
  }, this.options.heartbeatInterval);
2690
2811
  if (this.options.jobRetention) {
2691
2812
  const interval = this.options.jobRetention.interval ?? DEFAULTS.retentionInterval;
2692
2813
  this.cleanupJobs().catch((error) => {
2693
- this.emit("job:error", { error });
2814
+ this.emit("job:error", { error: toError(error) });
2694
2815
  });
2695
2816
  this.cleanupIntervalId = setInterval(() => {
2696
2817
  this.cleanupJobs().catch((error) => {
2697
- this.emit("job:error", { error });
2818
+ this.emit("job:error", { error: toError(error) });
2698
2819
  });
2699
2820
  }, interval);
2700
2821
  }
2701
2822
  this.processor.poll().catch((error) => {
2702
- this.emit("job:error", { error });
2823
+ this.emit("job:error", { error: toError(error) });
2703
2824
  });
2704
2825
  }
2705
2826
  /**
@@ -2857,37 +2978,6 @@ var Monque = class extends node_events.EventEmitter {
2857
2978
  return activeJobs;
2858
2979
  }
2859
2980
  /**
2860
- * Convert a MongoDB document to a typed PersistedJob object.
2861
- *
2862
- * Maps raw MongoDB document fields to the strongly-typed `PersistedJob<T>` interface,
2863
- * ensuring type safety and handling optional fields (`lockedAt`, `failReason`, etc.).
2864
- *
2865
- * @private
2866
- * @template T - The job data payload type
2867
- * @param doc - The raw MongoDB document with `_id`
2868
- * @returns A strongly-typed PersistedJob object with guaranteed `_id`
2869
- */
2870
- documentToPersistedJob(doc) {
2871
- const job = {
2872
- _id: doc._id,
2873
- name: doc["name"],
2874
- data: doc["data"],
2875
- status: doc["status"],
2876
- nextRunAt: doc["nextRunAt"],
2877
- failCount: doc["failCount"],
2878
- createdAt: doc["createdAt"],
2879
- updatedAt: doc["updatedAt"]
2880
- };
2881
- if (doc["lockedAt"] !== void 0) job.lockedAt = doc["lockedAt"];
2882
- if (doc["claimedBy"] !== void 0) job.claimedBy = doc["claimedBy"];
2883
- if (doc["lastHeartbeat"] !== void 0) job.lastHeartbeat = doc["lastHeartbeat"];
2884
- if (doc["heartbeatInterval"] !== void 0) job.heartbeatInterval = doc["heartbeatInterval"];
2885
- if (doc["failReason"] !== void 0) job.failReason = doc["failReason"];
2886
- if (doc["repeatInterval"] !== void 0) job.repeatInterval = doc["repeatInterval"];
2887
- if (doc["uniqueKey"] !== void 0) job.uniqueKey = doc["uniqueKey"];
2888
- return job;
2889
- }
2890
- /**
2891
2981
  * Type-safe event emitter methods
2892
2982
  */
2893
2983
  emit(event, payload) {