@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.
@@ -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 { QueueBackend, JobFilters, JobUpdates } from '../backend.js';
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,
@@ -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
+ }