@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/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: {
@@ -1173,7 +1236,7 @@ var JobProcessor = class {
1173
1236
  worker.activeJobs.set(job._id.toString(), job);
1174
1237
  this.processJob(job, worker).catch((error) => {
1175
1238
  this.ctx.emit("job:error", {
1176
- error,
1239
+ error: toError(error),
1177
1240
  job
1178
1241
  });
1179
1242
  });
@@ -1225,6 +1288,10 @@ var JobProcessor = class {
1225
1288
  * both success and failure cases. On success, calls `completeJob()`. On failure,
1226
1289
  * calls `failJob()` which implements exponential backoff retry logic.
1227
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
+ *
1228
1295
  * @param job - The job to process
1229
1296
  * @param worker - The worker registration containing the handler and active job tracking
1230
1297
  */
@@ -1235,38 +1302,50 @@ var JobProcessor = class {
1235
1302
  try {
1236
1303
  await worker.handler(job);
1237
1304
  const duration = Date.now() - startTime;
1238
- await this.completeJob(job);
1239
- this.ctx.emit("job:complete", {
1240
- job,
1305
+ const updatedJob = await this.completeJob(job);
1306
+ if (updatedJob) this.ctx.emit("job:complete", {
1307
+ job: updatedJob,
1241
1308
  duration
1242
1309
  });
1243
1310
  } catch (error) {
1244
1311
  const err = error instanceof Error ? error : new Error(String(error));
1245
- await this.failJob(job, err);
1246
- const willRetry = job.failCount + 1 < this.ctx.options.maxRetries;
1247
- this.ctx.emit("job:fail", {
1248
- job,
1249
- error: err,
1250
- willRetry
1251
- });
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
+ }
1252
1321
  } finally {
1253
1322
  worker.activeJobs.delete(jobId);
1254
1323
  }
1255
1324
  }
1256
1325
  /**
1257
- * 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).
1258
1332
  *
1259
1333
  * For recurring jobs (with `repeatInterval`), schedules the next run based on the cron
1260
1334
  * expression and resets `failCount` to 0. For one-time jobs, sets status to `completed`.
1261
1335
  * Clears `lockedAt` and `failReason` fields in both cases.
1262
1336
  *
1263
1337
  * @param job - The job that completed successfully
1338
+ * @returns The updated job document, or `null` if the transition could not be applied
1264
1339
  */
1265
1340
  async completeJob(job) {
1266
- if (!isPersistedJob(job)) return;
1341
+ if (!isPersistedJob(job)) return null;
1267
1342
  if (job.repeatInterval) {
1268
1343
  const nextRunAt = getNextCronDate(job.repeatInterval);
1269
- 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
+ }, {
1270
1349
  $set: {
1271
1350
  status: JobStatus.PENDING,
1272
1351
  nextRunAt,
@@ -1280,61 +1359,59 @@ var JobProcessor = class {
1280
1359
  heartbeatInterval: "",
1281
1360
  failReason: ""
1282
1361
  }
1283
- });
1284
- } else {
1285
- await this.ctx.collection.updateOne({ _id: job._id }, {
1286
- $set: {
1287
- status: JobStatus.COMPLETED,
1288
- updatedAt: /* @__PURE__ */ new Date()
1289
- },
1290
- $unset: {
1291
- lockedAt: "",
1292
- claimedBy: "",
1293
- lastHeartbeat: "",
1294
- heartbeatInterval: "",
1295
- failReason: ""
1296
- }
1297
- });
1298
- job.status = JobStatus.COMPLETED;
1362
+ }, { returnDocument: "after" });
1363
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1299
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;
1300
1383
  }
1301
1384
  /**
1302
- * 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).
1303
1391
  *
1304
1392
  * Increments `failCount` and calculates next retry time using exponential backoff:
1305
- * `nextRunAt = 2^failCount × baseRetryInterval` (capped by optional `maxBackoffDelay`).
1393
+ * `nextRunAt = 2^failCount * baseRetryInterval` (capped by optional `maxBackoffDelay`).
1306
1394
  *
1307
1395
  * If `failCount >= maxRetries`, marks job as permanently `failed`. Otherwise, resets
1308
1396
  * to `pending` status for retry. Stores error message in `failReason` field.
1309
1397
  *
1310
1398
  * @param job - The job that failed
1311
1399
  * @param error - The error that caused the failure
1400
+ * @returns The updated job document, or `null` if the transition could not be applied
1312
1401
  */
1313
1402
  async failJob(job, error) {
1314
- if (!isPersistedJob(job)) return;
1403
+ if (!isPersistedJob(job)) return null;
1315
1404
  const newFailCount = job.failCount + 1;
1316
- if (newFailCount >= this.ctx.options.maxRetries) await this.ctx.collection.updateOne({ _id: job._id }, {
1317
- $set: {
1318
- status: JobStatus.FAILED,
1319
- failCount: newFailCount,
1320
- failReason: error.message,
1321
- updatedAt: /* @__PURE__ */ new Date()
1322
- },
1323
- $unset: {
1324
- lockedAt: "",
1325
- claimedBy: "",
1326
- lastHeartbeat: "",
1327
- heartbeatInterval: ""
1328
- }
1329
- });
1330
- else {
1331
- const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
1332
- 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
+ }, {
1333
1411
  $set: {
1334
- status: JobStatus.PENDING,
1412
+ status: JobStatus.FAILED,
1335
1413
  failCount: newFailCount,
1336
1414
  failReason: error.message,
1337
- nextRunAt,
1338
1415
  updatedAt: /* @__PURE__ */ new Date()
1339
1416
  },
1340
1417
  $unset: {
@@ -1343,8 +1420,30 @@ var JobProcessor = class {
1343
1420
  lastHeartbeat: "",
1344
1421
  heartbeatInterval: ""
1345
1422
  }
1346
- });
1423
+ }, { returnDocument: "after" });
1424
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1347
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;
1348
1447
  }
1349
1448
  /**
1350
1449
  * Update heartbeats for all jobs claimed by this scheduler instance.
@@ -1960,7 +2059,8 @@ var Monque = class extends EventEmitter {
1960
2059
  instanceConcurrency: options.instanceConcurrency ?? options.maxConcurrency,
1961
2060
  schedulerInstanceId: options.schedulerInstanceId ?? randomUUID(),
1962
2061
  heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
1963
- jobRetention: options.jobRetention
2062
+ jobRetention: options.jobRetention,
2063
+ skipIndexCreation: options.skipIndexCreation ?? false
1964
2064
  };
1965
2065
  }
1966
2066
  /**
@@ -1973,7 +2073,7 @@ var Monque = class extends EventEmitter {
1973
2073
  if (this.isInitialized) return;
1974
2074
  try {
1975
2075
  this.collection = this.db.collection(this.options.collectionName);
1976
- await this.createIndexes();
2076
+ if (!this.options.skipIndexCreation) await this.createIndexes();
1977
2077
  if (this.options.recoverStaleJobs) await this.recoverStaleJobs();
1978
2078
  const ctx = this.buildContext();
1979
2079
  this._scheduler = new JobScheduler(ctx);
@@ -2023,7 +2123,7 @@ var Monque = class extends EventEmitter {
2023
2123
  workers: this.workers,
2024
2124
  isRunning: () => this.isRunning,
2025
2125
  emit: (event, payload) => this.emit(event, payload),
2026
- documentToPersistedJob: (doc) => this.documentToPersistedJob(doc)
2126
+ documentToPersistedJob: (doc) => documentToPersistedJob(doc)
2027
2127
  };
2028
2128
  }
2029
2129
  /**
@@ -2040,43 +2140,64 @@ var Monque = class extends EventEmitter {
2040
2140
  */
2041
2141
  async createIndexes() {
2042
2142
  if (!this.collection) throw new ConnectionError("Collection not initialized");
2043
- await this.collection.createIndex({
2044
- status: 1,
2045
- nextRunAt: 1
2046
- }, { background: true });
2047
- await this.collection.createIndex({
2048
- name: 1,
2049
- uniqueKey: 1
2050
- }, {
2051
- unique: true,
2052
- partialFilterExpression: {
2053
- uniqueKey: { $exists: true },
2054
- status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
2143
+ await this.collection.createIndexes([
2144
+ {
2145
+ key: {
2146
+ status: 1,
2147
+ nextRunAt: 1
2148
+ },
2149
+ background: true
2055
2150
  },
2056
- background: true
2057
- });
2058
- await this.collection.createIndex({
2059
- name: 1,
2060
- status: 1
2061
- }, { background: true });
2062
- await this.collection.createIndex({
2063
- claimedBy: 1,
2064
- status: 1
2065
- }, { background: true });
2066
- await this.collection.createIndex({
2067
- lastHeartbeat: 1,
2068
- status: 1
2069
- }, { background: true });
2070
- await this.collection.createIndex({
2071
- status: 1,
2072
- nextRunAt: 1,
2073
- claimedBy: 1
2074
- }, { background: true });
2075
- await this.collection.createIndex({
2076
- status: 1,
2077
- lockedAt: 1,
2078
- lastHeartbeat: 1
2079
- }, { 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
+ ]);
2080
2201
  }
2081
2202
  /**
2082
2203
  * Recover stale jobs that were left in 'processing' status.
@@ -2678,27 +2799,27 @@ var Monque = class extends EventEmitter {
2678
2799
  this.changeStreamHandler.setup();
2679
2800
  this.pollIntervalId = setInterval(() => {
2680
2801
  this.processor.poll().catch((error) => {
2681
- this.emit("job:error", { error });
2802
+ this.emit("job:error", { error: toError(error) });
2682
2803
  });
2683
2804
  }, this.options.pollInterval);
2684
2805
  this.heartbeatIntervalId = setInterval(() => {
2685
2806
  this.processor.updateHeartbeats().catch((error) => {
2686
- this.emit("job:error", { error });
2807
+ this.emit("job:error", { error: toError(error) });
2687
2808
  });
2688
2809
  }, this.options.heartbeatInterval);
2689
2810
  if (this.options.jobRetention) {
2690
2811
  const interval = this.options.jobRetention.interval ?? DEFAULTS.retentionInterval;
2691
2812
  this.cleanupJobs().catch((error) => {
2692
- this.emit("job:error", { error });
2813
+ this.emit("job:error", { error: toError(error) });
2693
2814
  });
2694
2815
  this.cleanupIntervalId = setInterval(() => {
2695
2816
  this.cleanupJobs().catch((error) => {
2696
- this.emit("job:error", { error });
2817
+ this.emit("job:error", { error: toError(error) });
2697
2818
  });
2698
2819
  }, interval);
2699
2820
  }
2700
2821
  this.processor.poll().catch((error) => {
2701
- this.emit("job:error", { error });
2822
+ this.emit("job:error", { error: toError(error) });
2702
2823
  });
2703
2824
  }
2704
2825
  /**
@@ -2856,37 +2977,6 @@ var Monque = class extends EventEmitter {
2856
2977
  return activeJobs;
2857
2978
  }
2858
2979
  /**
2859
- * Convert a MongoDB document to a typed PersistedJob object.
2860
- *
2861
- * Maps raw MongoDB document fields to the strongly-typed `PersistedJob<T>` interface,
2862
- * ensuring type safety and handling optional fields (`lockedAt`, `failReason`, etc.).
2863
- *
2864
- * @private
2865
- * @template T - The job data payload type
2866
- * @param doc - The raw MongoDB document with `_id`
2867
- * @returns A strongly-typed PersistedJob object with guaranteed `_id`
2868
- */
2869
- documentToPersistedJob(doc) {
2870
- const job = {
2871
- _id: doc._id,
2872
- name: doc["name"],
2873
- data: doc["data"],
2874
- status: doc["status"],
2875
- nextRunAt: doc["nextRunAt"],
2876
- failCount: doc["failCount"],
2877
- createdAt: doc["createdAt"],
2878
- updatedAt: doc["updatedAt"]
2879
- };
2880
- if (doc["lockedAt"] !== void 0) job.lockedAt = doc["lockedAt"];
2881
- if (doc["claimedBy"] !== void 0) job.claimedBy = doc["claimedBy"];
2882
- if (doc["lastHeartbeat"] !== void 0) job.lastHeartbeat = doc["lastHeartbeat"];
2883
- if (doc["heartbeatInterval"] !== void 0) job.heartbeatInterval = doc["heartbeatInterval"];
2884
- if (doc["failReason"] !== void 0) job.failReason = doc["failReason"];
2885
- if (doc["repeatInterval"] !== void 0) job.repeatInterval = doc["repeatInterval"];
2886
- if (doc["uniqueKey"] !== void 0) job.uniqueKey = doc["uniqueKey"];
2887
- return job;
2888
- }
2889
- /**
2890
2980
  * Type-safe event emitter methods
2891
2981
  */
2892
2982
  emit(event, payload) {