@open-mercato/scheduler 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7194.1.ab4fc81f82
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 +2 -1
- package/dist/index.js.map +2 -2
- package/dist/modules/scheduler/lib/cronParser.js +24 -1
- package/dist/modules/scheduler/lib/cronParser.js.map +2 -2
- package/package.json +5 -5
- package/src/index.ts +2 -1
- package/src/modules/scheduler/lib/__tests__/cronParser.test.ts +51 -1
- package/src/modules/scheduler/lib/cronParser.ts +39 -0
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ScheduledJob } from "./modules/scheduler/data/entities.js";
|
|
2
2
|
import { SchedulerService } from "./modules/scheduler/services/schedulerService.js";
|
|
3
|
-
import { parseCronExpression, validateCron } from "./modules/scheduler/lib/cronParser.js";
|
|
3
|
+
import { parseCronExpression, validateCron, validateCronExpression } from "./modules/scheduler/lib/cronParser.js";
|
|
4
4
|
import { parseInterval, validateInterval, intervalToHuman } from "./modules/scheduler/lib/intervalParser.js";
|
|
5
5
|
import { calculateNextRun, recalculateNextRun } from "./modules/scheduler/lib/nextRunCalculator.js";
|
|
6
6
|
export {
|
|
@@ -12,6 +12,7 @@ export {
|
|
|
12
12
|
parseInterval,
|
|
13
13
|
recalculateNextRun,
|
|
14
14
|
validateCron,
|
|
15
|
+
validateCronExpression,
|
|
15
16
|
validateInterval
|
|
16
17
|
};
|
|
17
18
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/index.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * @open-mercato/scheduler\n * \n * Database-managed scheduled jobs with admin UI\n */\n\nexport { ScheduledJob } from './modules/scheduler/data/entities.js'\nexport { SchedulerService } from './modules/scheduler/services/schedulerService.js'\nexport type { ScheduleRegistration } from './modules/scheduler/services/schedulerService.js'\n\n// Parsers and utilities\nexport { parseCronExpression, validateCron } from './modules/scheduler/lib/cronParser.js'\nexport { parseInterval, validateInterval, intervalToHuman } from './modules/scheduler/lib/intervalParser.js'\nexport { calculateNextRun, recalculateNextRun } from './modules/scheduler/lib/nextRunCalculator.js'\n"],
|
|
5
|
-
"mappings": "AAMA,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AAIjC,SAAS,qBAAqB,
|
|
4
|
+
"sourcesContent": ["/**\n * @open-mercato/scheduler\n * \n * Database-managed scheduled jobs with admin UI\n */\n\nexport { ScheduledJob } from './modules/scheduler/data/entities.js'\nexport { SchedulerService } from './modules/scheduler/services/schedulerService.js'\nexport type { ScheduleRegistration } from './modules/scheduler/services/schedulerService.js'\n\n// Parsers and utilities\nexport { parseCronExpression, validateCron, validateCronExpression } from './modules/scheduler/lib/cronParser.js'\nexport type { CronValidationResult } from './modules/scheduler/lib/cronParser.js'\nexport { parseInterval, validateInterval, intervalToHuman } from './modules/scheduler/lib/intervalParser.js'\nexport { calculateNextRun, recalculateNextRun } from './modules/scheduler/lib/nextRunCalculator.js'\n"],
|
|
5
|
+
"mappings": "AAMA,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AAIjC,SAAS,qBAAqB,cAAc,8BAA8B;AAE1E,SAAS,eAAe,kBAAkB,uBAAuB;AACjE,SAAS,kBAAkB,0BAA0B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -39,6 +39,28 @@ function getNextOccurrences(cronExpression, count, timezone = "UTC", currentDate
|
|
|
39
39
|
return [];
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
function validateCronExpression(cronExpression, options = {}) {
|
|
43
|
+
if (!cronExpression || cronExpression.trim() === "") {
|
|
44
|
+
return { ok: false, error: "Cron expression cannot be empty" };
|
|
45
|
+
}
|
|
46
|
+
const count = Math.max(1, Math.min(options.count ?? 3, 10));
|
|
47
|
+
try {
|
|
48
|
+
const interval = parseExpression(cronExpression, {
|
|
49
|
+
currentDate: options.currentDate || /* @__PURE__ */ new Date(),
|
|
50
|
+
tz: options.timezone ?? "UTC"
|
|
51
|
+
});
|
|
52
|
+
const nextRuns = [];
|
|
53
|
+
for (let i = 0; i < count; i++) {
|
|
54
|
+
nextRuns.push(interval.next().toDate());
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, nextRuns };
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: error instanceof Error ? error.message : "Invalid cron expression"
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
42
64
|
function validateCron(cronExpression) {
|
|
43
65
|
if (!cronExpression || cronExpression.trim() === "") {
|
|
44
66
|
return false;
|
|
@@ -57,6 +79,7 @@ function validateCron(cronExpression) {
|
|
|
57
79
|
export {
|
|
58
80
|
getNextOccurrences,
|
|
59
81
|
parseCronExpression,
|
|
60
|
-
validateCron
|
|
82
|
+
validateCron,
|
|
83
|
+
validateCronExpression
|
|
61
84
|
};
|
|
62
85
|
//# sourceMappingURL=cronParser.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/scheduler/lib/cronParser.ts"],
|
|
4
|
-
"sourcesContent": ["import { CronExpressionParser } from 'cron-parser'\n\nconst parseExpression = CronExpressionParser.parse.bind(CronExpressionParser)\n\nexport interface CronParseResult {\n isValid: boolean\n nextRun?: Date\n error?: string\n}\n\n/**\n * Parse and validate a cron expression\n */\nexport function parseCronExpression(\n cronExpression: string,\n timezone: string = 'UTC',\n currentDate?: Date\n): CronParseResult {\n // Explicitly reject empty strings\n if (!cronExpression || cronExpression.trim() === '') {\n return {\n isValid: false,\n error: 'Cron expression cannot be empty',\n }\n }\n \n try {\n const interval = parseExpression(cronExpression, {\n currentDate: currentDate || new Date(),\n tz: timezone,\n })\n \n const nextRun = interval.next().toDate()\n \n return {\n isValid: true,\n nextRun,\n }\n } catch (error) {\n return {\n isValid: false,\n error: error instanceof Error ? error.message : 'Invalid cron expression',\n }\n }\n}\n\n/**\n * Get the next N occurrences of a cron expression\n */\nexport function getNextOccurrences(\n cronExpression: string,\n count: number,\n timezone: string = 'UTC',\n currentDate?: Date\n): Date[] {\n try {\n const interval = parseExpression(cronExpression, {\n currentDate: currentDate || new Date(),\n tz: timezone,\n })\n \n const occurrences: Date[] = []\n for (let i = 0; i < count; i++) {\n occurrences.push(interval.next().toDate())\n }\n \n return occurrences\n } catch (error) {\n return []\n }\n}\n\n/**\n * Validate a cron expression\n * Only supports standard 5-field cron format (minute hour day month weekday)\n */\nexport function validateCron(cronExpression: string): boolean {\n // Explicitly reject empty strings\n if (!cronExpression || cronExpression.trim() === '') {\n return false\n }\n \n // Check for exactly 5 fields (standard cron format)\n // Split by whitespace and filter out empty strings\n const fields = cronExpression.trim().split(/\\s+/).filter(f => f.length > 0)\n if (fields.length !== 5) {\n return false\n }\n \n try {\n parseExpression(cronExpression)\n return true\n } catch {\n return false\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,4BAA4B;AAErC,MAAM,kBAAkB,qBAAqB,MAAM,KAAK,oBAAoB;AAWrE,SAAS,oBACd,gBACA,WAAmB,OACnB,aACiB;AAEjB,MAAI,CAAC,kBAAkB,eAAe,KAAK,MAAM,IAAI;AACnD,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI;AACF,UAAM,WAAW,gBAAgB,gBAAgB;AAAA,MAC/C,aAAa,eAAe,oBAAI,KAAK;AAAA,MACrC,IAAI;AAAA,IACN,CAAC;AAED,UAAM,UAAU,SAAS,KAAK,EAAE,OAAO;AAEvC,WAAO;AAAA,MACL,SAAS;AAAA,MACT;AAAA,IACF;AAAA,EACF,SAAS,OAAO;AACd,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD;AAAA,EACF;AACF;AAKO,SAAS,mBACd,gBACA,OACA,WAAmB,OACnB,aACQ;AACR,MAAI;AACF,UAAM,WAAW,gBAAgB,gBAAgB;AAAA,MAC/C,aAAa,eAAe,oBAAI,KAAK;AAAA,MACrC,IAAI;AAAA,IACN,CAAC;AAED,UAAM,cAAsB,CAAC;AAC7B,aAAS,IAAI,GAAG,IAAI,OAAO,KAAK;AAC9B,kBAAY,KAAK,SAAS,KAAK,EAAE,OAAO,CAAC;AAAA,IAC3C;AAEA,WAAO;AAAA,EACT,SAAS,OAAO;AACd,WAAO,CAAC;AAAA,EACV;AACF;AAMO,SAAS,aAAa,gBAAiC;AAE5D,MAAI,CAAC,kBAAkB,eAAe,KAAK,MAAM,IAAI;AACnD,WAAO;AAAA,EACT;AAIA,QAAM,SAAS,eAAe,KAAK,EAAE,MAAM,KAAK,EAAE,OAAO,OAAK,EAAE,SAAS,CAAC;AAC1E,MAAI,OAAO,WAAW,GAAG;AACvB,WAAO;AAAA,EACT;AAEA,MAAI;AACF,oBAAgB,cAAc;AAC9B,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
|
|
4
|
+
"sourcesContent": ["import { CronExpressionParser } from 'cron-parser'\n\nconst parseExpression = CronExpressionParser.parse.bind(CronExpressionParser)\n\nexport interface CronParseResult {\n isValid: boolean\n nextRun?: Date\n error?: string\n}\n\n/**\n * Parse and validate a cron expression\n */\nexport function parseCronExpression(\n cronExpression: string,\n timezone: string = 'UTC',\n currentDate?: Date\n): CronParseResult {\n // Explicitly reject empty strings\n if (!cronExpression || cronExpression.trim() === '') {\n return {\n isValid: false,\n error: 'Cron expression cannot be empty',\n }\n }\n \n try {\n const interval = parseExpression(cronExpression, {\n currentDate: currentDate || new Date(),\n tz: timezone,\n })\n \n const nextRun = interval.next().toDate()\n \n return {\n isValid: true,\n nextRun,\n }\n } catch (error) {\n return {\n isValid: false,\n error: error instanceof Error ? error.message : 'Invalid cron expression',\n }\n }\n}\n\n/**\n * Get the next N occurrences of a cron expression\n */\nexport function getNextOccurrences(\n cronExpression: string,\n count: number,\n timezone: string = 'UTC',\n currentDate?: Date\n): Date[] {\n try {\n const interval = parseExpression(cronExpression, {\n currentDate: currentDate || new Date(),\n tz: timezone,\n })\n \n const occurrences: Date[] = []\n for (let i = 0; i < count; i++) {\n occurrences.push(interval.next().toDate())\n }\n \n return occurrences\n } catch (error) {\n return []\n }\n}\n\nexport interface CronValidationResult {\n ok: boolean\n nextRuns?: Date[]\n error?: string\n}\n\n/**\n * Semantic cron validation with upcoming occurrences \u2014 the one-call helper for\n * form previews and server-side validators. Accepts anything cron-parser can\n * schedule (standard 5-field and 6-field-with-seconds expressions); a shape\n * that merely LOOKS like cron (`foo bar baz qux quux`) fails here even though\n * it passes token-count regexes.\n */\nexport function validateCronExpression(\n cronExpression: string,\n options: { count?: number; timezone?: string; currentDate?: Date } = {}\n): CronValidationResult {\n if (!cronExpression || cronExpression.trim() === '') {\n return { ok: false, error: 'Cron expression cannot be empty' }\n }\n const count = Math.max(1, Math.min(options.count ?? 3, 10))\n try {\n const interval = parseExpression(cronExpression, {\n currentDate: options.currentDate || new Date(),\n tz: options.timezone ?? 'UTC',\n })\n const nextRuns: Date[] = []\n for (let i = 0; i < count; i++) {\n nextRuns.push(interval.next().toDate())\n }\n return { ok: true, nextRuns }\n } catch (error) {\n return {\n ok: false,\n error: error instanceof Error ? error.message : 'Invalid cron expression',\n }\n }\n}\n\n/**\n * Validate a cron expression\n * Only supports standard 5-field cron format (minute hour day month weekday)\n */\nexport function validateCron(cronExpression: string): boolean {\n // Explicitly reject empty strings\n if (!cronExpression || cronExpression.trim() === '') {\n return false\n }\n \n // Check for exactly 5 fields (standard cron format)\n // Split by whitespace and filter out empty strings\n const fields = cronExpression.trim().split(/\\s+/).filter(f => f.length > 0)\n if (fields.length !== 5) {\n return false\n }\n \n try {\n parseExpression(cronExpression)\n return true\n } catch {\n return false\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,4BAA4B;AAErC,MAAM,kBAAkB,qBAAqB,MAAM,KAAK,oBAAoB;AAWrE,SAAS,oBACd,gBACA,WAAmB,OACnB,aACiB;AAEjB,MAAI,CAAC,kBAAkB,eAAe,KAAK,MAAM,IAAI;AACnD,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI;AACF,UAAM,WAAW,gBAAgB,gBAAgB;AAAA,MAC/C,aAAa,eAAe,oBAAI,KAAK;AAAA,MACrC,IAAI;AAAA,IACN,CAAC;AAED,UAAM,UAAU,SAAS,KAAK,EAAE,OAAO;AAEvC,WAAO;AAAA,MACL,SAAS;AAAA,MACT;AAAA,IACF;AAAA,EACF,SAAS,OAAO;AACd,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD;AAAA,EACF;AACF;AAKO,SAAS,mBACd,gBACA,OACA,WAAmB,OACnB,aACQ;AACR,MAAI;AACF,UAAM,WAAW,gBAAgB,gBAAgB;AAAA,MAC/C,aAAa,eAAe,oBAAI,KAAK;AAAA,MACrC,IAAI;AAAA,IACN,CAAC;AAED,UAAM,cAAsB,CAAC;AAC7B,aAAS,IAAI,GAAG,IAAI,OAAO,KAAK;AAC9B,kBAAY,KAAK,SAAS,KAAK,EAAE,OAAO,CAAC;AAAA,IAC3C;AAEA,WAAO;AAAA,EACT,SAAS,OAAO;AACd,WAAO,CAAC;AAAA,EACV;AACF;AAeO,SAAS,uBACd,gBACA,UAAqE,CAAC,GAChD;AACtB,MAAI,CAAC,kBAAkB,eAAe,KAAK,MAAM,IAAI;AACnD,WAAO,EAAE,IAAI,OAAO,OAAO,kCAAkC;AAAA,EAC/D;AACA,QAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,IAAI,QAAQ,SAAS,GAAG,EAAE,CAAC;AAC1D,MAAI;AACF,UAAM,WAAW,gBAAgB,gBAAgB;AAAA,MAC/C,aAAa,QAAQ,eAAe,oBAAI,KAAK;AAAA,MAC7C,IAAI,QAAQ,YAAY;AAAA,IAC1B,CAAC;AACD,UAAM,WAAmB,CAAC;AAC1B,aAAS,IAAI,GAAG,IAAI,OAAO,KAAK;AAC9B,eAAS,KAAK,SAAS,KAAK,EAAE,OAAO,CAAC;AAAA,IACxC;AACA,WAAO,EAAE,IAAI,MAAM,SAAS;AAAA,EAC9B,SAAS,OAAO;AACd,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD;AAAA,EACF;AACF;AAMO,SAAS,aAAa,gBAAiC;AAE5D,MAAI,CAAC,kBAAkB,eAAe,KAAK,MAAM,IAAI;AACnD,WAAO;AAAA,EACT;AAIA,QAAM,SAAS,eAAe,KAAK,EAAE,MAAM,KAAK,EAAE,OAAO,OAAK,EAAE,SAAS,CAAC;AAC1E,MAAI,OAAO,WAAW,GAAG;AACvB,WAAO;AAAA,EACT;AAEA,MAAI;AACF,oBAAgB,cAAc;AAC9B,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/scheduler",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7194.1.ab4fc81f82",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Database-managed scheduled jobs with admin UI",
|
|
6
6
|
"type": "module",
|
|
@@ -59,13 +59,13 @@
|
|
|
59
59
|
"./*/*/*/*/*/*.json": "./src/*/*/*/*/*/*.json"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@open-mercato/events": "0.7.1-develop.
|
|
63
|
-
"@open-mercato/queue": "0.7.1-develop.
|
|
62
|
+
"@open-mercato/events": "0.7.1-develop.7194.1.ab4fc81f82",
|
|
63
|
+
"@open-mercato/queue": "0.7.1-develop.7194.1.ab4fc81f82",
|
|
64
64
|
"cron-parser": "^5.6.2"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"@mikro-orm/core": "^7.0.14",
|
|
68
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
68
|
+
"@open-mercato/shared": "0.7.1-develop.7194.1.ab4fc81f82",
|
|
69
69
|
"bullmq": "^5.0.0 || ^6.0.0",
|
|
70
70
|
"ioredis": "^5.0.0 || ^6.0.0",
|
|
71
71
|
"zod": ">=3.23.0"
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
82
|
+
"@open-mercato/shared": "0.7.1-develop.7194.1.ab4fc81f82",
|
|
83
83
|
"@types/jest": "^30.0.0",
|
|
84
84
|
"@types/node": "^26.4.0",
|
|
85
85
|
"jest": "^30.4.2",
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { SchedulerService } from './modules/scheduler/services/schedulerService.
|
|
|
9
9
|
export type { ScheduleRegistration } from './modules/scheduler/services/schedulerService.js'
|
|
10
10
|
|
|
11
11
|
// Parsers and utilities
|
|
12
|
-
export { parseCronExpression, validateCron } from './modules/scheduler/lib/cronParser.js'
|
|
12
|
+
export { parseCronExpression, validateCron, validateCronExpression } from './modules/scheduler/lib/cronParser.js'
|
|
13
|
+
export type { CronValidationResult } from './modules/scheduler/lib/cronParser.js'
|
|
13
14
|
export { parseInterval, validateInterval, intervalToHuman } from './modules/scheduler/lib/intervalParser.js'
|
|
14
15
|
export { calculateNextRun, recalculateNextRun } from './modules/scheduler/lib/nextRunCalculator.js'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from '@jest/globals'
|
|
2
|
-
import { parseCronExpression, getNextOccurrences, validateCron } from '../cronParser'
|
|
2
|
+
import { parseCronExpression, getNextOccurrences, validateCron, validateCronExpression } from '../cronParser'
|
|
3
3
|
|
|
4
4
|
describe('cronParser', () => {
|
|
5
5
|
describe('parseCronExpression', () => {
|
|
@@ -241,4 +241,54 @@ describe('cronParser', () => {
|
|
|
241
241
|
expect(nextRun.getUTCDate()).toBe(1)
|
|
242
242
|
})
|
|
243
243
|
})
|
|
244
|
+
|
|
245
|
+
describe('validateCronExpression', () => {
|
|
246
|
+
it('returns ok with three upcoming runs by default for a valid expression', () => {
|
|
247
|
+
const result = validateCronExpression('0 7 * * 1')
|
|
248
|
+
|
|
249
|
+
expect(result.ok).toBe(true)
|
|
250
|
+
expect(result.error).toBeUndefined()
|
|
251
|
+
expect(result.nextRuns).toHaveLength(3)
|
|
252
|
+
for (const run of result.nextRuns!) {
|
|
253
|
+
expect(run).toBeInstanceOf(Date)
|
|
254
|
+
}
|
|
255
|
+
expect(result.nextRuns![0].getTime()).toBeLessThan(result.nextRuns![1].getTime())
|
|
256
|
+
expect(result.nextRuns![1].getTime()).toBeLessThan(result.nextRuns![2].getTime())
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('rejects shape-valid garbage that token-count regexes accept', () => {
|
|
260
|
+
const result = validateCronExpression('foo bar baz qux quux')
|
|
261
|
+
|
|
262
|
+
expect(result.ok).toBe(false)
|
|
263
|
+
expect(result.nextRuns).toBeUndefined()
|
|
264
|
+
expect(result.error).toBeTruthy()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('rejects empty input', () => {
|
|
268
|
+
expect(validateCronExpression('').ok).toBe(false)
|
|
269
|
+
expect(validateCronExpression(' ').ok).toBe(false)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('honors a custom count (clamped to 1..10)', () => {
|
|
273
|
+
expect(validateCronExpression('*/5 * * * *', { count: 5 }).nextRuns).toHaveLength(5)
|
|
274
|
+
expect(validateCronExpression('*/5 * * * *', { count: 0 }).nextRuns).toHaveLength(1)
|
|
275
|
+
expect(validateCronExpression('*/5 * * * *', { count: 99 }).nextRuns).toHaveLength(10)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('computes occurrences in the requested timezone from a fixed date', () => {
|
|
279
|
+
const currentDate = new Date('2026-01-01T00:00:00Z')
|
|
280
|
+
const result = validateCronExpression('0 12 * * *', { timezone: 'America/New_York', currentDate, count: 1 })
|
|
281
|
+
|
|
282
|
+
expect(result.ok).toBe(true)
|
|
283
|
+
// Noon in New York (EST, UTC-5) is 17:00 UTC.
|
|
284
|
+
expect(result.nextRuns![0].toISOString()).toBe('2026-01-01T17:00:00.000Z')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('accepts 6-field (seconds) expressions that cron-parser schedules', () => {
|
|
288
|
+
const result = validateCronExpression('*/30 * * * * *', { count: 2 })
|
|
289
|
+
|
|
290
|
+
expect(result.ok).toBe(true)
|
|
291
|
+
expect(result.nextRuns).toHaveLength(2)
|
|
292
|
+
})
|
|
293
|
+
})
|
|
244
294
|
})
|
|
@@ -70,6 +70,45 @@ export function getNextOccurrences(
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export interface CronValidationResult {
|
|
74
|
+
ok: boolean
|
|
75
|
+
nextRuns?: Date[]
|
|
76
|
+
error?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Semantic cron validation with upcoming occurrences — the one-call helper for
|
|
81
|
+
* form previews and server-side validators. Accepts anything cron-parser can
|
|
82
|
+
* schedule (standard 5-field and 6-field-with-seconds expressions); a shape
|
|
83
|
+
* that merely LOOKS like cron (`foo bar baz qux quux`) fails here even though
|
|
84
|
+
* it passes token-count regexes.
|
|
85
|
+
*/
|
|
86
|
+
export function validateCronExpression(
|
|
87
|
+
cronExpression: string,
|
|
88
|
+
options: { count?: number; timezone?: string; currentDate?: Date } = {}
|
|
89
|
+
): CronValidationResult {
|
|
90
|
+
if (!cronExpression || cronExpression.trim() === '') {
|
|
91
|
+
return { ok: false, error: 'Cron expression cannot be empty' }
|
|
92
|
+
}
|
|
93
|
+
const count = Math.max(1, Math.min(options.count ?? 3, 10))
|
|
94
|
+
try {
|
|
95
|
+
const interval = parseExpression(cronExpression, {
|
|
96
|
+
currentDate: options.currentDate || new Date(),
|
|
97
|
+
tz: options.timezone ?? 'UTC',
|
|
98
|
+
})
|
|
99
|
+
const nextRuns: Date[] = []
|
|
100
|
+
for (let i = 0; i < count; i++) {
|
|
101
|
+
nextRuns.push(interval.next().toDate())
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, nextRuns }
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: error instanceof Error ? error.message : 'Invalid cron expression',
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
73
112
|
/**
|
|
74
113
|
* Validate a cron expression
|
|
75
114
|
* Only supports standard 5-field cron format (minute hour day month weekday)
|