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