@bathina_mounika/cron-generator 0.1.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/README.md +37 -0
- package/dist/generator.d.ts +7 -0
- package/dist/generator.js +206 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# cron-generator (TypeScript)
|
|
2
|
+
|
|
3
|
+
Generate cron expressions distributed across daily time slots.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @bathina_mounika/cron-generator
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { generate } from "@bathina_mounika/cron-generator";
|
|
15
|
+
|
|
16
|
+
const crons = generate({
|
|
17
|
+
existingCrons: [],
|
|
18
|
+
runsPerDay: 4,
|
|
19
|
+
maxRunsPerSlot: 1,
|
|
20
|
+
slotSizeMinutes: 60,
|
|
21
|
+
});
|
|
22
|
+
// ['0 0 * * *', '0 6 * * *', '0 12 * * *', '0 18 * * *']
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Development
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install
|
|
29
|
+
npm test
|
|
30
|
+
npm run build
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Publish
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm publish --access public
|
|
37
|
+
```
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
const MINUTES_PER_DAY = 1440;
|
|
2
|
+
export function generate(config) {
|
|
3
|
+
const { existingCrons, runsPerDay, maxRunsPerSlot, slotSizeMinutes, } = config;
|
|
4
|
+
if (runsPerDay < 0) {
|
|
5
|
+
throw new Error("runsPerDay must be >= 0");
|
|
6
|
+
}
|
|
7
|
+
if (runsPerDay === 0) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
if (maxRunsPerSlot <= 0) {
|
|
11
|
+
throw new Error("maxRunsPerSlot must be > 0");
|
|
12
|
+
}
|
|
13
|
+
if (slotSizeMinutes <= 0) {
|
|
14
|
+
throw new Error("slotSizeMinutes must be > 0");
|
|
15
|
+
}
|
|
16
|
+
if (MINUTES_PER_DAY % slotSizeMinutes !== 0) {
|
|
17
|
+
throw new Error(`slotSizeMinutes (${slotSizeMinutes}) must evenly divide ${MINUTES_PER_DAY}`);
|
|
18
|
+
}
|
|
19
|
+
const slotsPerDay = MINUTES_PER_DAY / slotSizeMinutes;
|
|
20
|
+
const slotRuns = new Array(slotsPerDay).fill(0);
|
|
21
|
+
const slotMinutes = Array.from({ length: slotsPerDay }, () => new Set());
|
|
22
|
+
for (const expr of existingCrons) {
|
|
23
|
+
const trimmed = expr.trim();
|
|
24
|
+
if (!trimmed) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
let times;
|
|
28
|
+
try {
|
|
29
|
+
times = dailyRunMinutes(trimmed);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
throw new Error(`invalid existing cron "${expr}": ${message}`);
|
|
34
|
+
}
|
|
35
|
+
for (const minute of times) {
|
|
36
|
+
const slot = Math.floor(minute / slotSizeMinutes);
|
|
37
|
+
slotRuns[slot] += 1;
|
|
38
|
+
slotMinutes[slot].add(minute);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const selectedSlots = pickSlots(slotRuns, runsPerDay, maxRunsPerSlot);
|
|
42
|
+
const crons = [];
|
|
43
|
+
for (const slot of selectedSlots) {
|
|
44
|
+
const minute = pickMinuteInSlot(slot, slotSizeMinutes, slotMinutes[slot]);
|
|
45
|
+
const hour = Math.floor(minute / 60);
|
|
46
|
+
const minOfHour = minute % 60;
|
|
47
|
+
crons.push(`${minOfHour} ${hour} * * *`);
|
|
48
|
+
slotRuns[slot] += 1;
|
|
49
|
+
slotMinutes[slot].add(minute);
|
|
50
|
+
}
|
|
51
|
+
crons.sort((a, b) => cronMinuteOfDay(a) - cronMinuteOfDay(b));
|
|
52
|
+
return crons;
|
|
53
|
+
}
|
|
54
|
+
function pickSlots(slotRuns, runsPerDay, maxRunsPerSlot) {
|
|
55
|
+
const slotsPerDay = slotRuns.length;
|
|
56
|
+
const occupancy = [...slotRuns];
|
|
57
|
+
const selected = [];
|
|
58
|
+
while (selected.length < runsPerDay) {
|
|
59
|
+
const preferred = runsPerDay > 0
|
|
60
|
+
? Math.floor((selected.length * slotsPerDay) / runsPerDay)
|
|
61
|
+
: 0;
|
|
62
|
+
let bestSlot = -1;
|
|
63
|
+
let bestOcc = maxRunsPerSlot + 1;
|
|
64
|
+
let bestDistance = slotsPerDay + 1;
|
|
65
|
+
for (let offset = 0; offset < slotsPerDay; offset += 1) {
|
|
66
|
+
const slot = (preferred + offset) % slotsPerDay;
|
|
67
|
+
if (occupancy[slot] >= maxRunsPerSlot) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (occupancy[slot] < bestOcc) {
|
|
71
|
+
bestOcc = occupancy[slot];
|
|
72
|
+
bestSlot = slot;
|
|
73
|
+
bestDistance = offset;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (occupancy[slot] === bestOcc && offset < bestDistance) {
|
|
77
|
+
bestSlot = slot;
|
|
78
|
+
bestDistance = offset;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (bestSlot === -1) {
|
|
82
|
+
throw new Error(`cannot place ${runsPerDay} runs: only ${selected.length} slot placements available under maxRunsPerSlot`);
|
|
83
|
+
}
|
|
84
|
+
selected.push(bestSlot);
|
|
85
|
+
occupancy[bestSlot] += 1;
|
|
86
|
+
}
|
|
87
|
+
return selected;
|
|
88
|
+
}
|
|
89
|
+
function pickMinuteInSlot(slot, slotSize, occupied) {
|
|
90
|
+
const start = slot * slotSize;
|
|
91
|
+
const end = Math.min(start + slotSize, MINUTES_PER_DAY);
|
|
92
|
+
for (let minute = start; minute < end; minute += 1) {
|
|
93
|
+
if (!occupied.has(minute)) {
|
|
94
|
+
return minute;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`no free minute in slot ${slot}`);
|
|
98
|
+
}
|
|
99
|
+
function cronMinuteOfDay(expr) {
|
|
100
|
+
const fields = expr.split(/\s+/);
|
|
101
|
+
if (fields.length < 2) {
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
const minute = Number.parseInt(fields[0], 10);
|
|
105
|
+
const hour = Number.parseInt(fields[1], 10);
|
|
106
|
+
return hour * 60 + minute;
|
|
107
|
+
}
|
|
108
|
+
function dailyRunMinutes(expr) {
|
|
109
|
+
const fields = expr.trim().split(/\s+/);
|
|
110
|
+
if (fields.length !== 5) {
|
|
111
|
+
throw new Error(`expected 5 cron fields, got ${fields.length}`);
|
|
112
|
+
}
|
|
113
|
+
const minutes = expandField(fields[0], 0, 59);
|
|
114
|
+
const hours = expandField(fields[1], 0, 23);
|
|
115
|
+
const dayFields = fields.slice(2);
|
|
116
|
+
const names = ["day-of-month", "month", "day-of-week"];
|
|
117
|
+
for (let index = 0; index < dayFields.length; index += 1) {
|
|
118
|
+
if (dayFields[index] !== "*") {
|
|
119
|
+
throw new Error(`${names[index]} field "${dayFields[index]}" is not supported (use *)`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const runs = [];
|
|
124
|
+
for (const hour of hours) {
|
|
125
|
+
for (const minute of minutes) {
|
|
126
|
+
const mod = hour * 60 + minute;
|
|
127
|
+
if (seen.has(mod)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
seen.add(mod);
|
|
131
|
+
runs.push(mod);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
runs.sort((a, b) => a - b);
|
|
135
|
+
return runs;
|
|
136
|
+
}
|
|
137
|
+
function expandField(field, minVal, maxVal) {
|
|
138
|
+
if (field === "*") {
|
|
139
|
+
return range(minVal, maxVal, 1);
|
|
140
|
+
}
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const values = [];
|
|
143
|
+
for (const rawPart of field.split(",")) {
|
|
144
|
+
const part = rawPart.trim();
|
|
145
|
+
if (!part) {
|
|
146
|
+
throw new Error("empty field segment");
|
|
147
|
+
}
|
|
148
|
+
let step = 1;
|
|
149
|
+
let base = part;
|
|
150
|
+
if (part.includes("/")) {
|
|
151
|
+
const chunks = part.split("/");
|
|
152
|
+
if (chunks.length !== 2) {
|
|
153
|
+
throw new Error(`invalid step syntax "${part}"`);
|
|
154
|
+
}
|
|
155
|
+
[base] = chunks;
|
|
156
|
+
step = Number.parseInt(chunks[1], 10);
|
|
157
|
+
if (!Number.isFinite(step) || step <= 0) {
|
|
158
|
+
throw new Error(`invalid step value in "${part}"`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
let segment;
|
|
162
|
+
if (base === "*") {
|
|
163
|
+
segment = range(minVal, maxVal, step);
|
|
164
|
+
}
|
|
165
|
+
else if (base.includes("-")) {
|
|
166
|
+
const bounds = base.split("-");
|
|
167
|
+
if (bounds.length !== 2) {
|
|
168
|
+
throw new Error(`invalid range "${base}"`);
|
|
169
|
+
}
|
|
170
|
+
const start = Number.parseInt(bounds[0], 10);
|
|
171
|
+
const end = Number.parseInt(bounds[1], 10);
|
|
172
|
+
if (start < minVal || end > maxVal || start > end) {
|
|
173
|
+
throw new Error(`range "${base}" out of bounds [${minVal},${maxVal}]`);
|
|
174
|
+
}
|
|
175
|
+
segment = range(start, end, step);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const value = Number.parseInt(base, 10);
|
|
179
|
+
if (!Number.isFinite(value)) {
|
|
180
|
+
throw new Error(`invalid value "${base}"`);
|
|
181
|
+
}
|
|
182
|
+
if (value < minVal || value > maxVal) {
|
|
183
|
+
throw new Error(`value ${value} out of bounds [${minVal},${maxVal}]`);
|
|
184
|
+
}
|
|
185
|
+
segment = [value];
|
|
186
|
+
}
|
|
187
|
+
for (const value of segment) {
|
|
188
|
+
if (!seen.has(value)) {
|
|
189
|
+
seen.add(value);
|
|
190
|
+
values.push(value);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (values.length === 0) {
|
|
195
|
+
throw new Error(`field "${field}" expanded to no values`);
|
|
196
|
+
}
|
|
197
|
+
values.sort((a, b) => a - b);
|
|
198
|
+
return values;
|
|
199
|
+
}
|
|
200
|
+
function range(start, end, step) {
|
|
201
|
+
const values = [];
|
|
202
|
+
for (let value = start; value <= end; value += step) {
|
|
203
|
+
values.push(value);
|
|
204
|
+
}
|
|
205
|
+
return values;
|
|
206
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { generate } from "./generator.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bathina_mounika/cron-generator",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate cron expressions distributed across daily time slots",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/bathinaMounika/cron-generator.git",
|
|
26
|
+
"directory": "typescript"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/bathinaMounika/cron-generator/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/bathinaMounika/cron-generator#readme",
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"cron",
|
|
37
|
+
"scheduler",
|
|
38
|
+
"generator"
|
|
39
|
+
],
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^5.4.0",
|
|
42
|
+
"vitest": "^4.1.7"
|
|
43
|
+
}
|
|
44
|
+
}
|