@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 +769 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -1
- package/dist/index.d.ts +216 -1
- package/dist/index.js +768 -4
- package/dist/index.js.map +1 -1
- package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
- package/package.json +3 -2
- package/src/backend.ts +69 -0
- package/src/backends/postgres.ts +331 -1
- package/src/backends/redis.test.ts +350 -0
- package/src/backends/redis.ts +389 -1
- package/src/cron.test.ts +126 -0
- package/src/cron.ts +40 -0
- package/src/index.test.ts +361 -0
- package/src/index.ts +157 -4
- package/src/processor.ts +22 -4
- package/src/types.ts +149 -0
package/src/backends/redis.ts
CHANGED
|
@@ -9,8 +9,16 @@ import {
|
|
|
9
9
|
TagQueryMode,
|
|
10
10
|
JobType,
|
|
11
11
|
RedisJobQueueConfig,
|
|
12
|
+
CronScheduleRecord,
|
|
13
|
+
CronScheduleStatus,
|
|
14
|
+
EditCronScheduleOptions,
|
|
12
15
|
} from '../types.js';
|
|
13
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
QueueBackend,
|
|
18
|
+
JobFilters,
|
|
19
|
+
JobUpdates,
|
|
20
|
+
CronScheduleInput,
|
|
21
|
+
} from '../backend.js';
|
|
14
22
|
import { log } from '../log-context.js';
|
|
15
23
|
import {
|
|
16
24
|
ADD_JOB_SCRIPT,
|
|
@@ -795,6 +803,386 @@ export class RedisBackend implements QueueBackend {
|
|
|
795
803
|
});
|
|
796
804
|
}
|
|
797
805
|
|
|
806
|
+
// ── Cron schedules ──────────────────────────────────────────────────
|
|
807
|
+
|
|
808
|
+
/** Create a cron schedule and return its ID. */
|
|
809
|
+
async addCronSchedule(input: CronScheduleInput): Promise<number> {
|
|
810
|
+
const existingId = await this.client.get(
|
|
811
|
+
`${this.prefix}cron_name:${input.scheduleName}`,
|
|
812
|
+
);
|
|
813
|
+
if (existingId !== null) {
|
|
814
|
+
throw new Error(
|
|
815
|
+
`Cron schedule with name "${input.scheduleName}" already exists`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const id = await this.client.incr(`${this.prefix}cron_id_seq`);
|
|
820
|
+
const now = this.nowMs();
|
|
821
|
+
const key = `${this.prefix}cron:${id}`;
|
|
822
|
+
|
|
823
|
+
const fields: string[] = [
|
|
824
|
+
'id',
|
|
825
|
+
id.toString(),
|
|
826
|
+
'scheduleName',
|
|
827
|
+
input.scheduleName,
|
|
828
|
+
'cronExpression',
|
|
829
|
+
input.cronExpression,
|
|
830
|
+
'jobType',
|
|
831
|
+
input.jobType,
|
|
832
|
+
'payload',
|
|
833
|
+
JSON.stringify(input.payload),
|
|
834
|
+
'maxAttempts',
|
|
835
|
+
input.maxAttempts.toString(),
|
|
836
|
+
'priority',
|
|
837
|
+
input.priority.toString(),
|
|
838
|
+
'timeoutMs',
|
|
839
|
+
input.timeoutMs !== null ? input.timeoutMs.toString() : 'null',
|
|
840
|
+
'forceKillOnTimeout',
|
|
841
|
+
input.forceKillOnTimeout ? 'true' : 'false',
|
|
842
|
+
'tags',
|
|
843
|
+
input.tags ? JSON.stringify(input.tags) : 'null',
|
|
844
|
+
'timezone',
|
|
845
|
+
input.timezone,
|
|
846
|
+
'allowOverlap',
|
|
847
|
+
input.allowOverlap ? 'true' : 'false',
|
|
848
|
+
'status',
|
|
849
|
+
'active',
|
|
850
|
+
'lastEnqueuedAt',
|
|
851
|
+
'null',
|
|
852
|
+
'lastJobId',
|
|
853
|
+
'null',
|
|
854
|
+
'nextRunAt',
|
|
855
|
+
input.nextRunAt ? input.nextRunAt.getTime().toString() : 'null',
|
|
856
|
+
'createdAt',
|
|
857
|
+
now.toString(),
|
|
858
|
+
'updatedAt',
|
|
859
|
+
now.toString(),
|
|
860
|
+
];
|
|
861
|
+
|
|
862
|
+
await (this.client as any).hmset(key, ...fields);
|
|
863
|
+
await this.client.set(
|
|
864
|
+
`${this.prefix}cron_name:${input.scheduleName}`,
|
|
865
|
+
id.toString(),
|
|
866
|
+
);
|
|
867
|
+
await this.client.sadd(`${this.prefix}crons`, id.toString());
|
|
868
|
+
await this.client.sadd(`${this.prefix}cron_status:active`, id.toString());
|
|
869
|
+
|
|
870
|
+
if (input.nextRunAt) {
|
|
871
|
+
await this.client.zadd(
|
|
872
|
+
`${this.prefix}cron_due`,
|
|
873
|
+
input.nextRunAt.getTime(),
|
|
874
|
+
id.toString(),
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
log(`Added cron schedule ${id}: "${input.scheduleName}"`);
|
|
879
|
+
return id;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Get a cron schedule by ID. */
|
|
883
|
+
async getCronSchedule(id: number): Promise<CronScheduleRecord | null> {
|
|
884
|
+
const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
|
|
885
|
+
if (!data || Object.keys(data).length === 0) return null;
|
|
886
|
+
return this.deserializeCronSchedule(data);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Get a cron schedule by its unique name. */
|
|
890
|
+
async getCronScheduleByName(
|
|
891
|
+
name: string,
|
|
892
|
+
): Promise<CronScheduleRecord | null> {
|
|
893
|
+
const id = await this.client.get(`${this.prefix}cron_name:${name}`);
|
|
894
|
+
if (id === null) return null;
|
|
895
|
+
return this.getCronSchedule(Number(id));
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** List cron schedules, optionally filtered by status. */
|
|
899
|
+
async listCronSchedules(
|
|
900
|
+
status?: CronScheduleStatus,
|
|
901
|
+
): Promise<CronScheduleRecord[]> {
|
|
902
|
+
let ids: string[];
|
|
903
|
+
if (status) {
|
|
904
|
+
ids = await this.client.smembers(`${this.prefix}cron_status:${status}`);
|
|
905
|
+
} else {
|
|
906
|
+
ids = await this.client.smembers(`${this.prefix}crons`);
|
|
907
|
+
}
|
|
908
|
+
if (ids.length === 0) return [];
|
|
909
|
+
|
|
910
|
+
const pipeline = this.client.pipeline();
|
|
911
|
+
for (const id of ids) {
|
|
912
|
+
pipeline.hgetall(`${this.prefix}cron:${id}`);
|
|
913
|
+
}
|
|
914
|
+
const results = await pipeline.exec();
|
|
915
|
+
const schedules: CronScheduleRecord[] = [];
|
|
916
|
+
if (results) {
|
|
917
|
+
for (const [err, data] of results) {
|
|
918
|
+
if (
|
|
919
|
+
!err &&
|
|
920
|
+
data &&
|
|
921
|
+
typeof data === 'object' &&
|
|
922
|
+
Object.keys(data as object).length > 0
|
|
923
|
+
) {
|
|
924
|
+
schedules.push(
|
|
925
|
+
this.deserializeCronSchedule(data as Record<string, string>),
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
schedules.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
931
|
+
return schedules;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** Delete a cron schedule by ID. */
|
|
935
|
+
async removeCronSchedule(id: number): Promise<void> {
|
|
936
|
+
const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
|
|
937
|
+
if (!data || Object.keys(data).length === 0) return;
|
|
938
|
+
|
|
939
|
+
const name = data.scheduleName;
|
|
940
|
+
const status = data.status;
|
|
941
|
+
|
|
942
|
+
await this.client.del(`${this.prefix}cron:${id}`);
|
|
943
|
+
await this.client.del(`${this.prefix}cron_name:${name}`);
|
|
944
|
+
await this.client.srem(`${this.prefix}crons`, id.toString());
|
|
945
|
+
await this.client.srem(
|
|
946
|
+
`${this.prefix}cron_status:${status}`,
|
|
947
|
+
id.toString(),
|
|
948
|
+
);
|
|
949
|
+
await this.client.zrem(`${this.prefix}cron_due`, id.toString());
|
|
950
|
+
log(`Removed cron schedule ${id}`);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** Pause a cron schedule. */
|
|
954
|
+
async pauseCronSchedule(id: number): Promise<void> {
|
|
955
|
+
const now = this.nowMs();
|
|
956
|
+
await this.client.hset(
|
|
957
|
+
`${this.prefix}cron:${id}`,
|
|
958
|
+
'status',
|
|
959
|
+
'paused',
|
|
960
|
+
'updatedAt',
|
|
961
|
+
now.toString(),
|
|
962
|
+
);
|
|
963
|
+
await this.client.srem(`${this.prefix}cron_status:active`, id.toString());
|
|
964
|
+
await this.client.sadd(`${this.prefix}cron_status:paused`, id.toString());
|
|
965
|
+
await this.client.zrem(`${this.prefix}cron_due`, id.toString());
|
|
966
|
+
log(`Paused cron schedule ${id}`);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Resume a paused cron schedule. */
|
|
970
|
+
async resumeCronSchedule(id: number): Promise<void> {
|
|
971
|
+
const now = this.nowMs();
|
|
972
|
+
await this.client.hset(
|
|
973
|
+
`${this.prefix}cron:${id}`,
|
|
974
|
+
'status',
|
|
975
|
+
'active',
|
|
976
|
+
'updatedAt',
|
|
977
|
+
now.toString(),
|
|
978
|
+
);
|
|
979
|
+
await this.client.srem(`${this.prefix}cron_status:paused`, id.toString());
|
|
980
|
+
await this.client.sadd(`${this.prefix}cron_status:active`, id.toString());
|
|
981
|
+
|
|
982
|
+
const nextRunAt = await this.client.hget(
|
|
983
|
+
`${this.prefix}cron:${id}`,
|
|
984
|
+
'nextRunAt',
|
|
985
|
+
);
|
|
986
|
+
if (nextRunAt && nextRunAt !== 'null') {
|
|
987
|
+
await this.client.zadd(
|
|
988
|
+
`${this.prefix}cron_due`,
|
|
989
|
+
Number(nextRunAt),
|
|
990
|
+
id.toString(),
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
log(`Resumed cron schedule ${id}`);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Edit a cron schedule. */
|
|
997
|
+
async editCronSchedule(
|
|
998
|
+
id: number,
|
|
999
|
+
updates: EditCronScheduleOptions,
|
|
1000
|
+
nextRunAt?: Date | null,
|
|
1001
|
+
): Promise<void> {
|
|
1002
|
+
const now = this.nowMs();
|
|
1003
|
+
const fields: string[] = [];
|
|
1004
|
+
|
|
1005
|
+
if (updates.cronExpression !== undefined) {
|
|
1006
|
+
fields.push('cronExpression', updates.cronExpression);
|
|
1007
|
+
}
|
|
1008
|
+
if (updates.payload !== undefined) {
|
|
1009
|
+
fields.push('payload', JSON.stringify(updates.payload));
|
|
1010
|
+
}
|
|
1011
|
+
if (updates.maxAttempts !== undefined) {
|
|
1012
|
+
fields.push('maxAttempts', updates.maxAttempts.toString());
|
|
1013
|
+
}
|
|
1014
|
+
if (updates.priority !== undefined) {
|
|
1015
|
+
fields.push('priority', updates.priority.toString());
|
|
1016
|
+
}
|
|
1017
|
+
if (updates.timeoutMs !== undefined) {
|
|
1018
|
+
fields.push(
|
|
1019
|
+
'timeoutMs',
|
|
1020
|
+
updates.timeoutMs !== null ? updates.timeoutMs.toString() : 'null',
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
if (updates.forceKillOnTimeout !== undefined) {
|
|
1024
|
+
fields.push(
|
|
1025
|
+
'forceKillOnTimeout',
|
|
1026
|
+
updates.forceKillOnTimeout ? 'true' : 'false',
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
if (updates.tags !== undefined) {
|
|
1030
|
+
fields.push(
|
|
1031
|
+
'tags',
|
|
1032
|
+
updates.tags !== null ? JSON.stringify(updates.tags) : 'null',
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
if (updates.timezone !== undefined) {
|
|
1036
|
+
fields.push('timezone', updates.timezone);
|
|
1037
|
+
}
|
|
1038
|
+
if (updates.allowOverlap !== undefined) {
|
|
1039
|
+
fields.push('allowOverlap', updates.allowOverlap ? 'true' : 'false');
|
|
1040
|
+
}
|
|
1041
|
+
if (nextRunAt !== undefined) {
|
|
1042
|
+
const val = nextRunAt !== null ? nextRunAt.getTime().toString() : 'null';
|
|
1043
|
+
fields.push('nextRunAt', val);
|
|
1044
|
+
if (nextRunAt !== null) {
|
|
1045
|
+
await this.client.zadd(
|
|
1046
|
+
`${this.prefix}cron_due`,
|
|
1047
|
+
nextRunAt.getTime(),
|
|
1048
|
+
id.toString(),
|
|
1049
|
+
);
|
|
1050
|
+
} else {
|
|
1051
|
+
await this.client.zrem(`${this.prefix}cron_due`, id.toString());
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (fields.length === 0) {
|
|
1056
|
+
log(`No fields to update for cron schedule ${id}`);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
fields.push('updatedAt', now.toString());
|
|
1061
|
+
await (this.client as any).hmset(`${this.prefix}cron:${id}`, ...fields);
|
|
1062
|
+
log(`Edited cron schedule ${id}`);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Fetch all active cron schedules whose nextRunAt <= now.
|
|
1067
|
+
* Uses a sorted set (cron_due) for efficient range query.
|
|
1068
|
+
*/
|
|
1069
|
+
async getDueCronSchedules(): Promise<CronScheduleRecord[]> {
|
|
1070
|
+
const now = this.nowMs();
|
|
1071
|
+
const ids = await this.client.zrangebyscore(
|
|
1072
|
+
`${this.prefix}cron_due`,
|
|
1073
|
+
0,
|
|
1074
|
+
now,
|
|
1075
|
+
);
|
|
1076
|
+
if (ids.length === 0) {
|
|
1077
|
+
log('Found 0 due cron schedules');
|
|
1078
|
+
return [];
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const schedules: CronScheduleRecord[] = [];
|
|
1082
|
+
for (const id of ids) {
|
|
1083
|
+
const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
|
|
1084
|
+
if (data && Object.keys(data).length > 0 && data.status === 'active') {
|
|
1085
|
+
schedules.push(this.deserializeCronSchedule(data));
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
log(`Found ${schedules.length} due cron schedules`);
|
|
1089
|
+
return schedules;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Update a cron schedule after a job has been enqueued.
|
|
1094
|
+
* Sets lastEnqueuedAt, lastJobId, and advances nextRunAt.
|
|
1095
|
+
*/
|
|
1096
|
+
async updateCronScheduleAfterEnqueue(
|
|
1097
|
+
id: number,
|
|
1098
|
+
lastEnqueuedAt: Date,
|
|
1099
|
+
lastJobId: number,
|
|
1100
|
+
nextRunAt: Date | null,
|
|
1101
|
+
): Promise<void> {
|
|
1102
|
+
const fields: string[] = [
|
|
1103
|
+
'lastEnqueuedAt',
|
|
1104
|
+
lastEnqueuedAt.getTime().toString(),
|
|
1105
|
+
'lastJobId',
|
|
1106
|
+
lastJobId.toString(),
|
|
1107
|
+
'nextRunAt',
|
|
1108
|
+
nextRunAt ? nextRunAt.getTime().toString() : 'null',
|
|
1109
|
+
'updatedAt',
|
|
1110
|
+
this.nowMs().toString(),
|
|
1111
|
+
];
|
|
1112
|
+
|
|
1113
|
+
await (this.client as any).hmset(`${this.prefix}cron:${id}`, ...fields);
|
|
1114
|
+
|
|
1115
|
+
if (nextRunAt) {
|
|
1116
|
+
await this.client.zadd(
|
|
1117
|
+
`${this.prefix}cron_due`,
|
|
1118
|
+
nextRunAt.getTime(),
|
|
1119
|
+
id.toString(),
|
|
1120
|
+
);
|
|
1121
|
+
} else {
|
|
1122
|
+
await this.client.zrem(`${this.prefix}cron_due`, id.toString());
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
log(
|
|
1126
|
+
`Updated cron schedule ${id}: lastJobId=${lastJobId}, nextRunAt=${nextRunAt?.toISOString() ?? 'null'}`,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Deserialize a Redis hash into a CronScheduleRecord. */
|
|
1131
|
+
private deserializeCronSchedule(
|
|
1132
|
+
h: Record<string, string>,
|
|
1133
|
+
): CronScheduleRecord {
|
|
1134
|
+
const nullish = (v: string | undefined) =>
|
|
1135
|
+
v === undefined || v === 'null' || v === '' ? null : v;
|
|
1136
|
+
const numOrNull = (v: string | undefined): number | null => {
|
|
1137
|
+
const n = nullish(v);
|
|
1138
|
+
return n === null ? null : Number(n);
|
|
1139
|
+
};
|
|
1140
|
+
const dateOrNull = (v: string | undefined): Date | null => {
|
|
1141
|
+
const n = numOrNull(v);
|
|
1142
|
+
return n === null ? null : new Date(n);
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
let payload: any;
|
|
1146
|
+
try {
|
|
1147
|
+
payload = JSON.parse(h.payload);
|
|
1148
|
+
} catch {
|
|
1149
|
+
payload = h.payload;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
let tags: string[] | undefined;
|
|
1153
|
+
try {
|
|
1154
|
+
const raw = h.tags;
|
|
1155
|
+
if (raw && raw !== 'null') {
|
|
1156
|
+
tags = JSON.parse(raw);
|
|
1157
|
+
}
|
|
1158
|
+
} catch {
|
|
1159
|
+
/* ignore */
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
return {
|
|
1163
|
+
id: Number(h.id),
|
|
1164
|
+
scheduleName: h.scheduleName,
|
|
1165
|
+
cronExpression: h.cronExpression,
|
|
1166
|
+
jobType: h.jobType,
|
|
1167
|
+
payload,
|
|
1168
|
+
maxAttempts: Number(h.maxAttempts),
|
|
1169
|
+
priority: Number(h.priority),
|
|
1170
|
+
timeoutMs: numOrNull(h.timeoutMs),
|
|
1171
|
+
forceKillOnTimeout: h.forceKillOnTimeout === 'true',
|
|
1172
|
+
tags,
|
|
1173
|
+
timezone: h.timezone,
|
|
1174
|
+
allowOverlap: h.allowOverlap === 'true',
|
|
1175
|
+
status: h.status as CronScheduleStatus,
|
|
1176
|
+
lastEnqueuedAt: dateOrNull(h.lastEnqueuedAt),
|
|
1177
|
+
lastJobId: numOrNull(h.lastJobId),
|
|
1178
|
+
nextRunAt: dateOrNull(h.nextRunAt),
|
|
1179
|
+
createdAt: new Date(Number(h.createdAt)),
|
|
1180
|
+
updatedAt: new Date(Number(h.updatedAt)),
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ── Private helpers (filters) ─────────────────────────────────────────
|
|
1185
|
+
|
|
798
1186
|
private async applyFilters(
|
|
799
1187
|
ids: string[],
|
|
800
1188
|
filters: JobFilters,
|
package/src/cron.test.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { getNextCronOccurrence, validateCronExpression } from './cron.js';
|
|
3
|
+
|
|
4
|
+
describe('getNextCronOccurrence', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns the next occurrence for a every-5-minutes expression', () => {
|
|
10
|
+
// Setup
|
|
11
|
+
const after = new Date('2026-01-15T10:02:00Z');
|
|
12
|
+
|
|
13
|
+
// Act
|
|
14
|
+
const next = getNextCronOccurrence('*/5 * * * *', 'UTC', after);
|
|
15
|
+
|
|
16
|
+
// Assert
|
|
17
|
+
expect(next).toEqual(new Date('2026-01-15T10:05:00Z'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns the next occurrence for a daily-at-midnight expression', () => {
|
|
21
|
+
// Setup
|
|
22
|
+
const after = new Date('2026-01-15T10:00:00Z');
|
|
23
|
+
|
|
24
|
+
// Act
|
|
25
|
+
const next = getNextCronOccurrence('0 0 * * *', 'UTC', after);
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(next).toEqual(new Date('2026-01-16T00:00:00Z'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('uses the current time when after is not provided', () => {
|
|
32
|
+
// Act
|
|
33
|
+
const next = getNextCronOccurrence('*/5 * * * *');
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(next).toBeInstanceOf(Date);
|
|
37
|
+
expect(next!.getTime()).toBeGreaterThan(Date.now() - 1000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('respects a non-UTC timezone', () => {
|
|
41
|
+
// Setup — 10:02 UTC is 19:02 in Asia/Tokyo (UTC+9)
|
|
42
|
+
const after = new Date('2026-01-15T10:02:00Z');
|
|
43
|
+
|
|
44
|
+
// Act — "0 20 * * *" = daily at 20:00 Tokyo time = 11:00 UTC
|
|
45
|
+
const next = getNextCronOccurrence('0 20 * * *', 'Asia/Tokyo', after);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(next).toEqual(new Date('2026-01-15T11:00:00Z'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns null when expression cannot produce a future match', () => {
|
|
52
|
+
// Setup — Feb 30 never exists: "0 0 30 2 *"
|
|
53
|
+
const after = new Date('2026-01-01T00:00:00Z');
|
|
54
|
+
|
|
55
|
+
// Act
|
|
56
|
+
const next = getNextCronOccurrence('0 0 30 2 *', 'UTC', after);
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
expect(next).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('defaults to UTC timezone', () => {
|
|
63
|
+
// Setup
|
|
64
|
+
const after = new Date('2026-06-01T23:58:00Z');
|
|
65
|
+
|
|
66
|
+
// Act
|
|
67
|
+
const next = getNextCronOccurrence('0 0 * * *', undefined, after);
|
|
68
|
+
|
|
69
|
+
// Assert
|
|
70
|
+
expect(next).toEqual(new Date('2026-06-02T00:00:00Z'));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('validateCronExpression', () => {
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.restoreAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns true for a valid every-minute expression', () => {
|
|
80
|
+
// Act
|
|
81
|
+
const result = validateCronExpression('* * * * *');
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
expect(result).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns true for a valid complex expression', () => {
|
|
88
|
+
// Act
|
|
89
|
+
const result = validateCronExpression('0 9-17 * * 1-5');
|
|
90
|
+
|
|
91
|
+
// Assert
|
|
92
|
+
expect(result).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns false for an invalid expression with too few fields', () => {
|
|
96
|
+
// Act
|
|
97
|
+
const result = validateCronExpression('* *');
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
100
|
+
expect(result).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns false for an empty string', () => {
|
|
104
|
+
// Act
|
|
105
|
+
const result = validateCronExpression('');
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
108
|
+
expect(result).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns false for a completely invalid string', () => {
|
|
112
|
+
// Act
|
|
113
|
+
const result = validateCronExpression('not a cron expression');
|
|
114
|
+
|
|
115
|
+
// Assert
|
|
116
|
+
expect(result).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns true for an expression with step values', () => {
|
|
120
|
+
// Act
|
|
121
|
+
const result = validateCronExpression('*/15 * * * *');
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
expect(result).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Cron } from 'croner';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculate the next occurrence of a cron expression after a given date.
|
|
5
|
+
*
|
|
6
|
+
* @param cronExpression - A standard cron expression (5 fields, e.g. "0 * * * *").
|
|
7
|
+
* @param timezone - IANA timezone string (default: "UTC").
|
|
8
|
+
* @param after - The reference date to compute the next run from (default: now).
|
|
9
|
+
* @param CronImpl - Cron class for dependency injection (default: croner's Cron).
|
|
10
|
+
* @returns The next occurrence as a Date, or null if the expression will never fire again.
|
|
11
|
+
*/
|
|
12
|
+
export function getNextCronOccurrence(
|
|
13
|
+
cronExpression: string,
|
|
14
|
+
timezone: string = 'UTC',
|
|
15
|
+
after?: Date,
|
|
16
|
+
CronImpl: typeof Cron = Cron,
|
|
17
|
+
): Date | null {
|
|
18
|
+
const cron = new CronImpl(cronExpression, { timezone });
|
|
19
|
+
const next = cron.nextRun(after ?? new Date());
|
|
20
|
+
return next ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validate whether a string is a syntactically correct cron expression.
|
|
25
|
+
*
|
|
26
|
+
* @param cronExpression - The cron expression to validate.
|
|
27
|
+
* @param CronImpl - Cron class for dependency injection (default: croner's Cron).
|
|
28
|
+
* @returns True if the expression is valid, false otherwise.
|
|
29
|
+
*/
|
|
30
|
+
export function validateCronExpression(
|
|
31
|
+
cronExpression: string,
|
|
32
|
+
CronImpl: typeof Cron = Cron,
|
|
33
|
+
): boolean {
|
|
34
|
+
try {
|
|
35
|
+
new CronImpl(cronExpression);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|