@nicnocquee/dataqueue 1.30.0 → 1.31.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.cjs CHANGED
@@ -7,6 +7,7 @@ var pg = require('pg');
7
7
  var pgConnectionString = require('pg-connection-string');
8
8
  var fs = require('fs');
9
9
  var module$1 = require('module');
10
+ var croner = require('croner');
10
11
 
11
12
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
12
13
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -1005,6 +1006,297 @@ var PostgresBackend = class {
1005
1006
  client.release();
1006
1007
  }
1007
1008
  }
1009
+ // ── Cron schedules ──────────────────────────────────────────────────
1010
+ /** Create a cron schedule and return its ID. */
1011
+ async addCronSchedule(input) {
1012
+ const client = await this.pool.connect();
1013
+ try {
1014
+ const result = await client.query(
1015
+ `INSERT INTO cron_schedules
1016
+ (schedule_name, cron_expression, job_type, payload, max_attempts,
1017
+ priority, timeout_ms, force_kill_on_timeout, tags, timezone,
1018
+ allow_overlap, next_run_at)
1019
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
1020
+ RETURNING id`,
1021
+ [
1022
+ input.scheduleName,
1023
+ input.cronExpression,
1024
+ input.jobType,
1025
+ input.payload,
1026
+ input.maxAttempts,
1027
+ input.priority,
1028
+ input.timeoutMs,
1029
+ input.forceKillOnTimeout,
1030
+ input.tags ?? null,
1031
+ input.timezone,
1032
+ input.allowOverlap,
1033
+ input.nextRunAt
1034
+ ]
1035
+ );
1036
+ const id = result.rows[0].id;
1037
+ log(`Added cron schedule ${id}: "${input.scheduleName}"`);
1038
+ return id;
1039
+ } catch (error) {
1040
+ if (error?.code === "23505") {
1041
+ throw new Error(
1042
+ `Cron schedule with name "${input.scheduleName}" already exists`
1043
+ );
1044
+ }
1045
+ log(`Error adding cron schedule: ${error}`);
1046
+ throw error;
1047
+ } finally {
1048
+ client.release();
1049
+ }
1050
+ }
1051
+ /** Get a cron schedule by ID. */
1052
+ async getCronSchedule(id) {
1053
+ const client = await this.pool.connect();
1054
+ try {
1055
+ const result = await client.query(
1056
+ `SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
1057
+ job_type AS "jobType", payload, max_attempts AS "maxAttempts",
1058
+ priority, timeout_ms AS "timeoutMs",
1059
+ force_kill_on_timeout AS "forceKillOnTimeout", tags,
1060
+ timezone, allow_overlap AS "allowOverlap", status,
1061
+ last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1062
+ next_run_at AS "nextRunAt",
1063
+ created_at AS "createdAt", updated_at AS "updatedAt"
1064
+ FROM cron_schedules WHERE id = $1`,
1065
+ [id]
1066
+ );
1067
+ if (result.rows.length === 0) return null;
1068
+ return result.rows[0];
1069
+ } catch (error) {
1070
+ log(`Error getting cron schedule ${id}: ${error}`);
1071
+ throw error;
1072
+ } finally {
1073
+ client.release();
1074
+ }
1075
+ }
1076
+ /** Get a cron schedule by its unique name. */
1077
+ async getCronScheduleByName(name) {
1078
+ const client = await this.pool.connect();
1079
+ try {
1080
+ const result = await client.query(
1081
+ `SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
1082
+ job_type AS "jobType", payload, max_attempts AS "maxAttempts",
1083
+ priority, timeout_ms AS "timeoutMs",
1084
+ force_kill_on_timeout AS "forceKillOnTimeout", tags,
1085
+ timezone, allow_overlap AS "allowOverlap", status,
1086
+ last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1087
+ next_run_at AS "nextRunAt",
1088
+ created_at AS "createdAt", updated_at AS "updatedAt"
1089
+ FROM cron_schedules WHERE schedule_name = $1`,
1090
+ [name]
1091
+ );
1092
+ if (result.rows.length === 0) return null;
1093
+ return result.rows[0];
1094
+ } catch (error) {
1095
+ log(`Error getting cron schedule by name "${name}": ${error}`);
1096
+ throw error;
1097
+ } finally {
1098
+ client.release();
1099
+ }
1100
+ }
1101
+ /** List cron schedules, optionally filtered by status. */
1102
+ async listCronSchedules(status) {
1103
+ const client = await this.pool.connect();
1104
+ try {
1105
+ let query = `SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
1106
+ job_type AS "jobType", payload, max_attempts AS "maxAttempts",
1107
+ priority, timeout_ms AS "timeoutMs",
1108
+ force_kill_on_timeout AS "forceKillOnTimeout", tags,
1109
+ timezone, allow_overlap AS "allowOverlap", status,
1110
+ last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1111
+ next_run_at AS "nextRunAt",
1112
+ created_at AS "createdAt", updated_at AS "updatedAt"
1113
+ FROM cron_schedules`;
1114
+ const params = [];
1115
+ if (status) {
1116
+ query += ` WHERE status = $1`;
1117
+ params.push(status);
1118
+ }
1119
+ query += ` ORDER BY created_at ASC`;
1120
+ const result = await client.query(query, params);
1121
+ return result.rows;
1122
+ } catch (error) {
1123
+ log(`Error listing cron schedules: ${error}`);
1124
+ throw error;
1125
+ } finally {
1126
+ client.release();
1127
+ }
1128
+ }
1129
+ /** Delete a cron schedule by ID. */
1130
+ async removeCronSchedule(id) {
1131
+ const client = await this.pool.connect();
1132
+ try {
1133
+ await client.query(`DELETE FROM cron_schedules WHERE id = $1`, [id]);
1134
+ log(`Removed cron schedule ${id}`);
1135
+ } catch (error) {
1136
+ log(`Error removing cron schedule ${id}: ${error}`);
1137
+ throw error;
1138
+ } finally {
1139
+ client.release();
1140
+ }
1141
+ }
1142
+ /** Pause a cron schedule. */
1143
+ async pauseCronSchedule(id) {
1144
+ const client = await this.pool.connect();
1145
+ try {
1146
+ await client.query(
1147
+ `UPDATE cron_schedules SET status = 'paused', updated_at = NOW() WHERE id = $1`,
1148
+ [id]
1149
+ );
1150
+ log(`Paused cron schedule ${id}`);
1151
+ } catch (error) {
1152
+ log(`Error pausing cron schedule ${id}: ${error}`);
1153
+ throw error;
1154
+ } finally {
1155
+ client.release();
1156
+ }
1157
+ }
1158
+ /** Resume a paused cron schedule. */
1159
+ async resumeCronSchedule(id) {
1160
+ const client = await this.pool.connect();
1161
+ try {
1162
+ await client.query(
1163
+ `UPDATE cron_schedules SET status = 'active', updated_at = NOW() WHERE id = $1`,
1164
+ [id]
1165
+ );
1166
+ log(`Resumed cron schedule ${id}`);
1167
+ } catch (error) {
1168
+ log(`Error resuming cron schedule ${id}: ${error}`);
1169
+ throw error;
1170
+ } finally {
1171
+ client.release();
1172
+ }
1173
+ }
1174
+ /** Edit a cron schedule. */
1175
+ async editCronSchedule(id, updates, nextRunAt) {
1176
+ const client = await this.pool.connect();
1177
+ try {
1178
+ const updateFields = [];
1179
+ const params = [];
1180
+ let paramIdx = 1;
1181
+ if (updates.cronExpression !== void 0) {
1182
+ updateFields.push(`cron_expression = $${paramIdx++}`);
1183
+ params.push(updates.cronExpression);
1184
+ }
1185
+ if (updates.payload !== void 0) {
1186
+ updateFields.push(`payload = $${paramIdx++}`);
1187
+ params.push(updates.payload);
1188
+ }
1189
+ if (updates.maxAttempts !== void 0) {
1190
+ updateFields.push(`max_attempts = $${paramIdx++}`);
1191
+ params.push(updates.maxAttempts);
1192
+ }
1193
+ if (updates.priority !== void 0) {
1194
+ updateFields.push(`priority = $${paramIdx++}`);
1195
+ params.push(updates.priority);
1196
+ }
1197
+ if (updates.timeoutMs !== void 0) {
1198
+ updateFields.push(`timeout_ms = $${paramIdx++}`);
1199
+ params.push(updates.timeoutMs);
1200
+ }
1201
+ if (updates.forceKillOnTimeout !== void 0) {
1202
+ updateFields.push(`force_kill_on_timeout = $${paramIdx++}`);
1203
+ params.push(updates.forceKillOnTimeout);
1204
+ }
1205
+ if (updates.tags !== void 0) {
1206
+ updateFields.push(`tags = $${paramIdx++}`);
1207
+ params.push(updates.tags);
1208
+ }
1209
+ if (updates.timezone !== void 0) {
1210
+ updateFields.push(`timezone = $${paramIdx++}`);
1211
+ params.push(updates.timezone);
1212
+ }
1213
+ if (updates.allowOverlap !== void 0) {
1214
+ updateFields.push(`allow_overlap = $${paramIdx++}`);
1215
+ params.push(updates.allowOverlap);
1216
+ }
1217
+ if (nextRunAt !== void 0) {
1218
+ updateFields.push(`next_run_at = $${paramIdx++}`);
1219
+ params.push(nextRunAt);
1220
+ }
1221
+ if (updateFields.length === 0) {
1222
+ log(`No fields to update for cron schedule ${id}`);
1223
+ return;
1224
+ }
1225
+ updateFields.push(`updated_at = NOW()`);
1226
+ params.push(id);
1227
+ const query = `UPDATE cron_schedules SET ${updateFields.join(", ")} WHERE id = $${paramIdx}`;
1228
+ await client.query(query, params);
1229
+ log(`Edited cron schedule ${id}`);
1230
+ } catch (error) {
1231
+ log(`Error editing cron schedule ${id}: ${error}`);
1232
+ throw error;
1233
+ } finally {
1234
+ client.release();
1235
+ }
1236
+ }
1237
+ /**
1238
+ * Atomically fetch all active cron schedules whose nextRunAt <= NOW().
1239
+ * Uses FOR UPDATE SKIP LOCKED to prevent duplicate enqueuing across workers.
1240
+ */
1241
+ async getDueCronSchedules() {
1242
+ const client = await this.pool.connect();
1243
+ try {
1244
+ const result = await client.query(
1245
+ `SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
1246
+ job_type AS "jobType", payload, max_attempts AS "maxAttempts",
1247
+ priority, timeout_ms AS "timeoutMs",
1248
+ force_kill_on_timeout AS "forceKillOnTimeout", tags,
1249
+ timezone, allow_overlap AS "allowOverlap", status,
1250
+ last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1251
+ next_run_at AS "nextRunAt",
1252
+ created_at AS "createdAt", updated_at AS "updatedAt"
1253
+ FROM cron_schedules
1254
+ WHERE status = 'active'
1255
+ AND next_run_at IS NOT NULL
1256
+ AND next_run_at <= NOW()
1257
+ ORDER BY next_run_at ASC
1258
+ FOR UPDATE SKIP LOCKED`
1259
+ );
1260
+ log(`Found ${result.rows.length} due cron schedules`);
1261
+ return result.rows;
1262
+ } catch (error) {
1263
+ if (error?.code === "42P01") {
1264
+ log("cron_schedules table does not exist, skipping cron enqueue");
1265
+ return [];
1266
+ }
1267
+ log(`Error getting due cron schedules: ${error}`);
1268
+ throw error;
1269
+ } finally {
1270
+ client.release();
1271
+ }
1272
+ }
1273
+ /**
1274
+ * Update a cron schedule after a job has been enqueued.
1275
+ * Sets lastEnqueuedAt, lastJobId, and advances nextRunAt.
1276
+ */
1277
+ async updateCronScheduleAfterEnqueue(id, lastEnqueuedAt, lastJobId, nextRunAt) {
1278
+ const client = await this.pool.connect();
1279
+ try {
1280
+ await client.query(
1281
+ `UPDATE cron_schedules
1282
+ SET last_enqueued_at = $2,
1283
+ last_job_id = $3,
1284
+ next_run_at = $4,
1285
+ updated_at = NOW()
1286
+ WHERE id = $1`,
1287
+ [id, lastEnqueuedAt, lastJobId, nextRunAt]
1288
+ );
1289
+ log(
1290
+ `Updated cron schedule ${id}: lastJobId=${lastJobId}, nextRunAt=${nextRunAt?.toISOString() ?? "null"}`
1291
+ );
1292
+ } catch (error) {
1293
+ log(`Error updating cron schedule ${id} after enqueue: ${error}`);
1294
+ throw error;
1295
+ } finally {
1296
+ client.release();
1297
+ }
1298
+ }
1299
+ // ── Internal helpers ──────────────────────────────────────────────────
1008
1300
  async setPendingReasonForUnpickedJobs(reason, jobType) {
1009
1301
  const client = await this.pool.connect();
1010
1302
  try {
@@ -1735,7 +2027,7 @@ async function processBatchWithHandlers(backend, workerId, batchSize, jobType, j
1735
2027
  next();
1736
2028
  });
1737
2029
  }
1738
- var createProcessor = (backend, handlers, options = {}) => {
2030
+ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
1739
2031
  const {
1740
2032
  workerId = `worker-${Math.random().toString(36).substring(2, 9)}`,
1741
2033
  batchSize = 10,
@@ -1750,6 +2042,18 @@ var createProcessor = (backend, handlers, options = {}) => {
1750
2042
  setLogContext(options.verbose ?? false);
1751
2043
  const processJobs = async () => {
1752
2044
  if (!running) return 0;
2045
+ if (onBeforeBatch) {
2046
+ try {
2047
+ await onBeforeBatch();
2048
+ } catch (hookError) {
2049
+ log(`onBeforeBatch hook error: ${hookError}`);
2050
+ if (onError) {
2051
+ onError(
2052
+ hookError instanceof Error ? hookError : new Error(String(hookError))
2053
+ );
2054
+ }
2055
+ }
2056
+ }
1753
2057
  log(
1754
2058
  `Processing jobs with workerId: ${workerId}${jobType ? ` and jobType: ${Array.isArray(jobType) ? jobType.join(",") : jobType}` : ""}`
1755
2059
  );
@@ -2968,6 +3272,332 @@ var RedisBackend = class {
2968
3272
  return true;
2969
3273
  });
2970
3274
  }
3275
+ // ── Cron schedules ──────────────────────────────────────────────────
3276
+ /** Create a cron schedule and return its ID. */
3277
+ async addCronSchedule(input) {
3278
+ const existingId = await this.client.get(
3279
+ `${this.prefix}cron_name:${input.scheduleName}`
3280
+ );
3281
+ if (existingId !== null) {
3282
+ throw new Error(
3283
+ `Cron schedule with name "${input.scheduleName}" already exists`
3284
+ );
3285
+ }
3286
+ const id = await this.client.incr(`${this.prefix}cron_id_seq`);
3287
+ const now = this.nowMs();
3288
+ const key = `${this.prefix}cron:${id}`;
3289
+ const fields = [
3290
+ "id",
3291
+ id.toString(),
3292
+ "scheduleName",
3293
+ input.scheduleName,
3294
+ "cronExpression",
3295
+ input.cronExpression,
3296
+ "jobType",
3297
+ input.jobType,
3298
+ "payload",
3299
+ JSON.stringify(input.payload),
3300
+ "maxAttempts",
3301
+ input.maxAttempts.toString(),
3302
+ "priority",
3303
+ input.priority.toString(),
3304
+ "timeoutMs",
3305
+ input.timeoutMs !== null ? input.timeoutMs.toString() : "null",
3306
+ "forceKillOnTimeout",
3307
+ input.forceKillOnTimeout ? "true" : "false",
3308
+ "tags",
3309
+ input.tags ? JSON.stringify(input.tags) : "null",
3310
+ "timezone",
3311
+ input.timezone,
3312
+ "allowOverlap",
3313
+ input.allowOverlap ? "true" : "false",
3314
+ "status",
3315
+ "active",
3316
+ "lastEnqueuedAt",
3317
+ "null",
3318
+ "lastJobId",
3319
+ "null",
3320
+ "nextRunAt",
3321
+ input.nextRunAt ? input.nextRunAt.getTime().toString() : "null",
3322
+ "createdAt",
3323
+ now.toString(),
3324
+ "updatedAt",
3325
+ now.toString()
3326
+ ];
3327
+ await this.client.hmset(key, ...fields);
3328
+ await this.client.set(
3329
+ `${this.prefix}cron_name:${input.scheduleName}`,
3330
+ id.toString()
3331
+ );
3332
+ await this.client.sadd(`${this.prefix}crons`, id.toString());
3333
+ await this.client.sadd(`${this.prefix}cron_status:active`, id.toString());
3334
+ if (input.nextRunAt) {
3335
+ await this.client.zadd(
3336
+ `${this.prefix}cron_due`,
3337
+ input.nextRunAt.getTime(),
3338
+ id.toString()
3339
+ );
3340
+ }
3341
+ log(`Added cron schedule ${id}: "${input.scheduleName}"`);
3342
+ return id;
3343
+ }
3344
+ /** Get a cron schedule by ID. */
3345
+ async getCronSchedule(id) {
3346
+ const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
3347
+ if (!data || Object.keys(data).length === 0) return null;
3348
+ return this.deserializeCronSchedule(data);
3349
+ }
3350
+ /** Get a cron schedule by its unique name. */
3351
+ async getCronScheduleByName(name) {
3352
+ const id = await this.client.get(`${this.prefix}cron_name:${name}`);
3353
+ if (id === null) return null;
3354
+ return this.getCronSchedule(Number(id));
3355
+ }
3356
+ /** List cron schedules, optionally filtered by status. */
3357
+ async listCronSchedules(status) {
3358
+ let ids;
3359
+ if (status) {
3360
+ ids = await this.client.smembers(`${this.prefix}cron_status:${status}`);
3361
+ } else {
3362
+ ids = await this.client.smembers(`${this.prefix}crons`);
3363
+ }
3364
+ if (ids.length === 0) return [];
3365
+ const pipeline = this.client.pipeline();
3366
+ for (const id of ids) {
3367
+ pipeline.hgetall(`${this.prefix}cron:${id}`);
3368
+ }
3369
+ const results = await pipeline.exec();
3370
+ const schedules = [];
3371
+ if (results) {
3372
+ for (const [err, data] of results) {
3373
+ if (!err && data && typeof data === "object" && Object.keys(data).length > 0) {
3374
+ schedules.push(
3375
+ this.deserializeCronSchedule(data)
3376
+ );
3377
+ }
3378
+ }
3379
+ }
3380
+ schedules.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
3381
+ return schedules;
3382
+ }
3383
+ /** Delete a cron schedule by ID. */
3384
+ async removeCronSchedule(id) {
3385
+ const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
3386
+ if (!data || Object.keys(data).length === 0) return;
3387
+ const name = data.scheduleName;
3388
+ const status = data.status;
3389
+ await this.client.del(`${this.prefix}cron:${id}`);
3390
+ await this.client.del(`${this.prefix}cron_name:${name}`);
3391
+ await this.client.srem(`${this.prefix}crons`, id.toString());
3392
+ await this.client.srem(
3393
+ `${this.prefix}cron_status:${status}`,
3394
+ id.toString()
3395
+ );
3396
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
3397
+ log(`Removed cron schedule ${id}`);
3398
+ }
3399
+ /** Pause a cron schedule. */
3400
+ async pauseCronSchedule(id) {
3401
+ const now = this.nowMs();
3402
+ await this.client.hset(
3403
+ `${this.prefix}cron:${id}`,
3404
+ "status",
3405
+ "paused",
3406
+ "updatedAt",
3407
+ now.toString()
3408
+ );
3409
+ await this.client.srem(`${this.prefix}cron_status:active`, id.toString());
3410
+ await this.client.sadd(`${this.prefix}cron_status:paused`, id.toString());
3411
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
3412
+ log(`Paused cron schedule ${id}`);
3413
+ }
3414
+ /** Resume a paused cron schedule. */
3415
+ async resumeCronSchedule(id) {
3416
+ const now = this.nowMs();
3417
+ await this.client.hset(
3418
+ `${this.prefix}cron:${id}`,
3419
+ "status",
3420
+ "active",
3421
+ "updatedAt",
3422
+ now.toString()
3423
+ );
3424
+ await this.client.srem(`${this.prefix}cron_status:paused`, id.toString());
3425
+ await this.client.sadd(`${this.prefix}cron_status:active`, id.toString());
3426
+ const nextRunAt = await this.client.hget(
3427
+ `${this.prefix}cron:${id}`,
3428
+ "nextRunAt"
3429
+ );
3430
+ if (nextRunAt && nextRunAt !== "null") {
3431
+ await this.client.zadd(
3432
+ `${this.prefix}cron_due`,
3433
+ Number(nextRunAt),
3434
+ id.toString()
3435
+ );
3436
+ }
3437
+ log(`Resumed cron schedule ${id}`);
3438
+ }
3439
+ /** Edit a cron schedule. */
3440
+ async editCronSchedule(id, updates, nextRunAt) {
3441
+ const now = this.nowMs();
3442
+ const fields = [];
3443
+ if (updates.cronExpression !== void 0) {
3444
+ fields.push("cronExpression", updates.cronExpression);
3445
+ }
3446
+ if (updates.payload !== void 0) {
3447
+ fields.push("payload", JSON.stringify(updates.payload));
3448
+ }
3449
+ if (updates.maxAttempts !== void 0) {
3450
+ fields.push("maxAttempts", updates.maxAttempts.toString());
3451
+ }
3452
+ if (updates.priority !== void 0) {
3453
+ fields.push("priority", updates.priority.toString());
3454
+ }
3455
+ if (updates.timeoutMs !== void 0) {
3456
+ fields.push(
3457
+ "timeoutMs",
3458
+ updates.timeoutMs !== null ? updates.timeoutMs.toString() : "null"
3459
+ );
3460
+ }
3461
+ if (updates.forceKillOnTimeout !== void 0) {
3462
+ fields.push(
3463
+ "forceKillOnTimeout",
3464
+ updates.forceKillOnTimeout ? "true" : "false"
3465
+ );
3466
+ }
3467
+ if (updates.tags !== void 0) {
3468
+ fields.push(
3469
+ "tags",
3470
+ updates.tags !== null ? JSON.stringify(updates.tags) : "null"
3471
+ );
3472
+ }
3473
+ if (updates.timezone !== void 0) {
3474
+ fields.push("timezone", updates.timezone);
3475
+ }
3476
+ if (updates.allowOverlap !== void 0) {
3477
+ fields.push("allowOverlap", updates.allowOverlap ? "true" : "false");
3478
+ }
3479
+ if (nextRunAt !== void 0) {
3480
+ const val = nextRunAt !== null ? nextRunAt.getTime().toString() : "null";
3481
+ fields.push("nextRunAt", val);
3482
+ if (nextRunAt !== null) {
3483
+ await this.client.zadd(
3484
+ `${this.prefix}cron_due`,
3485
+ nextRunAt.getTime(),
3486
+ id.toString()
3487
+ );
3488
+ } else {
3489
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
3490
+ }
3491
+ }
3492
+ if (fields.length === 0) {
3493
+ log(`No fields to update for cron schedule ${id}`);
3494
+ return;
3495
+ }
3496
+ fields.push("updatedAt", now.toString());
3497
+ await this.client.hmset(`${this.prefix}cron:${id}`, ...fields);
3498
+ log(`Edited cron schedule ${id}`);
3499
+ }
3500
+ /**
3501
+ * Fetch all active cron schedules whose nextRunAt <= now.
3502
+ * Uses a sorted set (cron_due) for efficient range query.
3503
+ */
3504
+ async getDueCronSchedules() {
3505
+ const now = this.nowMs();
3506
+ const ids = await this.client.zrangebyscore(
3507
+ `${this.prefix}cron_due`,
3508
+ 0,
3509
+ now
3510
+ );
3511
+ if (ids.length === 0) {
3512
+ log("Found 0 due cron schedules");
3513
+ return [];
3514
+ }
3515
+ const schedules = [];
3516
+ for (const id of ids) {
3517
+ const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
3518
+ if (data && Object.keys(data).length > 0 && data.status === "active") {
3519
+ schedules.push(this.deserializeCronSchedule(data));
3520
+ }
3521
+ }
3522
+ log(`Found ${schedules.length} due cron schedules`);
3523
+ return schedules;
3524
+ }
3525
+ /**
3526
+ * Update a cron schedule after a job has been enqueued.
3527
+ * Sets lastEnqueuedAt, lastJobId, and advances nextRunAt.
3528
+ */
3529
+ async updateCronScheduleAfterEnqueue(id, lastEnqueuedAt, lastJobId, nextRunAt) {
3530
+ const fields = [
3531
+ "lastEnqueuedAt",
3532
+ lastEnqueuedAt.getTime().toString(),
3533
+ "lastJobId",
3534
+ lastJobId.toString(),
3535
+ "nextRunAt",
3536
+ nextRunAt ? nextRunAt.getTime().toString() : "null",
3537
+ "updatedAt",
3538
+ this.nowMs().toString()
3539
+ ];
3540
+ await this.client.hmset(`${this.prefix}cron:${id}`, ...fields);
3541
+ if (nextRunAt) {
3542
+ await this.client.zadd(
3543
+ `${this.prefix}cron_due`,
3544
+ nextRunAt.getTime(),
3545
+ id.toString()
3546
+ );
3547
+ } else {
3548
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
3549
+ }
3550
+ log(
3551
+ `Updated cron schedule ${id}: lastJobId=${lastJobId}, nextRunAt=${nextRunAt?.toISOString() ?? "null"}`
3552
+ );
3553
+ }
3554
+ /** Deserialize a Redis hash into a CronScheduleRecord. */
3555
+ deserializeCronSchedule(h) {
3556
+ const nullish = (v) => v === void 0 || v === "null" || v === "" ? null : v;
3557
+ const numOrNull = (v) => {
3558
+ const n = nullish(v);
3559
+ return n === null ? null : Number(n);
3560
+ };
3561
+ const dateOrNull = (v) => {
3562
+ const n = numOrNull(v);
3563
+ return n === null ? null : new Date(n);
3564
+ };
3565
+ let payload;
3566
+ try {
3567
+ payload = JSON.parse(h.payload);
3568
+ } catch {
3569
+ payload = h.payload;
3570
+ }
3571
+ let tags;
3572
+ try {
3573
+ const raw = h.tags;
3574
+ if (raw && raw !== "null") {
3575
+ tags = JSON.parse(raw);
3576
+ }
3577
+ } catch {
3578
+ }
3579
+ return {
3580
+ id: Number(h.id),
3581
+ scheduleName: h.scheduleName,
3582
+ cronExpression: h.cronExpression,
3583
+ jobType: h.jobType,
3584
+ payload,
3585
+ maxAttempts: Number(h.maxAttempts),
3586
+ priority: Number(h.priority),
3587
+ timeoutMs: numOrNull(h.timeoutMs),
3588
+ forceKillOnTimeout: h.forceKillOnTimeout === "true",
3589
+ tags,
3590
+ timezone: h.timezone,
3591
+ allowOverlap: h.allowOverlap === "true",
3592
+ status: h.status,
3593
+ lastEnqueuedAt: dateOrNull(h.lastEnqueuedAt),
3594
+ lastJobId: numOrNull(h.lastJobId),
3595
+ nextRunAt: dateOrNull(h.nextRunAt),
3596
+ createdAt: new Date(Number(h.createdAt)),
3597
+ updatedAt: new Date(Number(h.updatedAt))
3598
+ };
3599
+ }
3600
+ // ── Private helpers (filters) ─────────────────────────────────────────
2971
3601
  async applyFilters(ids, filters) {
2972
3602
  let result = ids;
2973
3603
  if (filters.jobType) {
@@ -2997,6 +3627,19 @@ var RedisBackend = class {
2997
3627
  return result;
2998
3628
  }
2999
3629
  };
3630
+ function getNextCronOccurrence(cronExpression, timezone = "UTC", after, CronImpl = croner.Cron) {
3631
+ const cron = new CronImpl(cronExpression, { timezone });
3632
+ const next = cron.nextRun(after ?? /* @__PURE__ */ new Date());
3633
+ return next ?? null;
3634
+ }
3635
+ function validateCronExpression(cronExpression, CronImpl = croner.Cron) {
3636
+ try {
3637
+ new CronImpl(cronExpression);
3638
+ return true;
3639
+ } catch {
3640
+ return false;
3641
+ }
3642
+ }
3000
3643
 
3001
3644
  // src/handler-validation.ts
3002
3645
  function validateHandlerSerializable2(handler, jobType) {
@@ -3091,6 +3734,49 @@ var initJobQueue = (config) => {
3091
3734
  }
3092
3735
  return pool;
3093
3736
  };
3737
+ const enqueueDueCronJobsImpl = async () => {
3738
+ const dueSchedules = await backend.getDueCronSchedules();
3739
+ let count = 0;
3740
+ for (const schedule of dueSchedules) {
3741
+ if (!schedule.allowOverlap && schedule.lastJobId !== null) {
3742
+ const lastJob = await backend.getJob(schedule.lastJobId);
3743
+ if (lastJob && (lastJob.status === "pending" || lastJob.status === "processing" || lastJob.status === "waiting")) {
3744
+ const nextRunAt2 = getNextCronOccurrence(
3745
+ schedule.cronExpression,
3746
+ schedule.timezone
3747
+ );
3748
+ await backend.updateCronScheduleAfterEnqueue(
3749
+ schedule.id,
3750
+ /* @__PURE__ */ new Date(),
3751
+ schedule.lastJobId,
3752
+ nextRunAt2
3753
+ );
3754
+ continue;
3755
+ }
3756
+ }
3757
+ const jobId = await backend.addJob({
3758
+ jobType: schedule.jobType,
3759
+ payload: schedule.payload,
3760
+ maxAttempts: schedule.maxAttempts,
3761
+ priority: schedule.priority,
3762
+ timeoutMs: schedule.timeoutMs ?? void 0,
3763
+ forceKillOnTimeout: schedule.forceKillOnTimeout,
3764
+ tags: schedule.tags
3765
+ });
3766
+ const nextRunAt = getNextCronOccurrence(
3767
+ schedule.cronExpression,
3768
+ schedule.timezone
3769
+ );
3770
+ await backend.updateCronScheduleAfterEnqueue(
3771
+ schedule.id,
3772
+ /* @__PURE__ */ new Date(),
3773
+ jobId,
3774
+ nextRunAt
3775
+ );
3776
+ count++;
3777
+ }
3778
+ return count;
3779
+ };
3094
3780
  return {
3095
3781
  // Job queue operations
3096
3782
  addJob: withLogContext(
@@ -3143,8 +3829,10 @@ var initJobQueue = (config) => {
3143
3829
  (tags, mode = "all", limit, offset) => backend.getJobsByTags(tags, mode, limit, offset),
3144
3830
  config.verbose ?? false
3145
3831
  ),
3146
- // Job processing
3147
- createProcessor: (handlers, options) => createProcessor(backend, handlers, options),
3832
+ // Job processing — automatically enqueues due cron jobs before each batch
3833
+ createProcessor: (handlers, options) => createProcessor(backend, handlers, options, async () => {
3834
+ await enqueueDueCronJobsImpl();
3835
+ }),
3148
3836
  // Job events
3149
3837
  getJobEvents: withLogContext(
3150
3838
  (jobId) => backend.getJobEvents(jobId),
@@ -3167,6 +3855,82 @@ var initJobQueue = (config) => {
3167
3855
  () => expireTimedOutWaitpoints(requirePool()),
3168
3856
  config.verbose ?? false
3169
3857
  ),
3858
+ // Cron schedule operations
3859
+ addCronJob: withLogContext(
3860
+ (options) => {
3861
+ if (!validateCronExpression(options.cronExpression)) {
3862
+ return Promise.reject(
3863
+ new Error(`Invalid cron expression: "${options.cronExpression}"`)
3864
+ );
3865
+ }
3866
+ const nextRunAt = getNextCronOccurrence(
3867
+ options.cronExpression,
3868
+ options.timezone ?? "UTC"
3869
+ );
3870
+ const input = {
3871
+ scheduleName: options.scheduleName,
3872
+ cronExpression: options.cronExpression,
3873
+ jobType: options.jobType,
3874
+ payload: options.payload,
3875
+ maxAttempts: options.maxAttempts ?? 3,
3876
+ priority: options.priority ?? 0,
3877
+ timeoutMs: options.timeoutMs ?? null,
3878
+ forceKillOnTimeout: options.forceKillOnTimeout ?? false,
3879
+ tags: options.tags,
3880
+ timezone: options.timezone ?? "UTC",
3881
+ allowOverlap: options.allowOverlap ?? false,
3882
+ nextRunAt
3883
+ };
3884
+ return backend.addCronSchedule(input);
3885
+ },
3886
+ config.verbose ?? false
3887
+ ),
3888
+ getCronJob: withLogContext(
3889
+ (id) => backend.getCronSchedule(id),
3890
+ config.verbose ?? false
3891
+ ),
3892
+ getCronJobByName: withLogContext(
3893
+ (name) => backend.getCronScheduleByName(name),
3894
+ config.verbose ?? false
3895
+ ),
3896
+ listCronJobs: withLogContext(
3897
+ (status) => backend.listCronSchedules(status),
3898
+ config.verbose ?? false
3899
+ ),
3900
+ removeCronJob: withLogContext(
3901
+ (id) => backend.removeCronSchedule(id),
3902
+ config.verbose ?? false
3903
+ ),
3904
+ pauseCronJob: withLogContext(
3905
+ (id) => backend.pauseCronSchedule(id),
3906
+ config.verbose ?? false
3907
+ ),
3908
+ resumeCronJob: withLogContext(
3909
+ (id) => backend.resumeCronSchedule(id),
3910
+ config.verbose ?? false
3911
+ ),
3912
+ editCronJob: withLogContext(
3913
+ async (id, updates) => {
3914
+ if (updates.cronExpression !== void 0 && !validateCronExpression(updates.cronExpression)) {
3915
+ throw new Error(
3916
+ `Invalid cron expression: "${updates.cronExpression}"`
3917
+ );
3918
+ }
3919
+ let nextRunAt;
3920
+ if (updates.cronExpression !== void 0 || updates.timezone !== void 0) {
3921
+ const existing = await backend.getCronSchedule(id);
3922
+ const expr = updates.cronExpression ?? existing?.cronExpression ?? "";
3923
+ const tz = updates.timezone ?? existing?.timezone ?? "UTC";
3924
+ nextRunAt = getNextCronOccurrence(expr, tz);
3925
+ }
3926
+ await backend.editCronSchedule(id, updates, nextRunAt);
3927
+ },
3928
+ config.verbose ?? false
3929
+ ),
3930
+ enqueueDueCronJobs: withLogContext(
3931
+ () => enqueueDueCronJobsImpl(),
3932
+ config.verbose ?? false
3933
+ ),
3170
3934
  // Advanced access
3171
3935
  getPool: () => {
3172
3936
  if (backendType !== "postgres") {
@@ -3195,8 +3959,10 @@ exports.FailureReason = FailureReason;
3195
3959
  exports.JobEventType = JobEventType;
3196
3960
  exports.PostgresBackend = PostgresBackend;
3197
3961
  exports.WaitSignal = WaitSignal;
3962
+ exports.getNextCronOccurrence = getNextCronOccurrence;
3198
3963
  exports.initJobQueue = initJobQueue;
3199
3964
  exports.testHandlerSerialization = testHandlerSerialization;
3965
+ exports.validateCronExpression = validateCronExpression;
3200
3966
  exports.validateHandlerSerializable = validateHandlerSerializable2;
3201
3967
  //# sourceMappingURL=index.cjs.map
3202
3968
  //# sourceMappingURL=index.cjs.map