@j13b/scheduler 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/alterations.d.ts +6 -0
- package/dist/alterations.d.ts.map +1 -0
- package/dist/alterations.js +61 -0
- package/dist/alterations.js.map +1 -0
- package/dist/cjs/alterations.d.ts +5 -0
- package/dist/cjs/alterations.js +96 -0
- package/dist/cjs/dispatcher.d.ts +38 -0
- package/dist/cjs/dispatcher.js +165 -0
- package/dist/cjs/dispatcher_store.d.ts +9 -0
- package/dist/cjs/dispatcher_store.js +15 -0
- package/dist/cjs/generators/anchored.d.ts +3 -0
- package/dist/cjs/generators/anchored.js +57 -0
- package/dist/cjs/generators/days_of_month.d.ts +3 -0
- package/dist/cjs/generators/days_of_month.js +76 -0
- package/dist/cjs/generators/days_of_week.d.ts +5 -0
- package/dist/cjs/generators/days_of_week.js +65 -0
- package/dist/cjs/generators/days_of_year.d.ts +3 -0
- package/dist/cjs/generators/days_of_year.js +70 -0
- package/dist/cjs/generators/instant.d.ts +3 -0
- package/dist/cjs/generators/instant.js +15 -0
- package/dist/cjs/generators/local_date_time.d.ts +3 -0
- package/dist/cjs/generators/local_date_time.js +31 -0
- package/dist/cjs/generators/months_of_year.d.ts +3 -0
- package/dist/cjs/generators/months_of_year.js +109 -0
- package/dist/cjs/generators/types.d.ts +9 -0
- package/dist/cjs/generators/types.js +19 -0
- package/dist/cjs/generators/weeks_of_month.d.ts +3 -0
- package/dist/cjs/generators/weeks_of_month.js +99 -0
- package/dist/cjs/generators/yearly.d.ts +3 -0
- package/dist/cjs/generators/yearly.js +52 -0
- package/dist/cjs/in_memory_port.d.ts +18 -0
- package/dist/cjs/in_memory_port.js +59 -0
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/index.js +18 -0
- package/dist/cjs/occurrence_key.d.ts +5 -0
- package/dist/cjs/occurrence_key.js +21 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/port.d.ts +15 -0
- package/dist/cjs/port.js +2 -0
- package/dist/cjs/scheduler.d.ts +23 -0
- package/dist/cjs/scheduler.js +302 -0
- package/dist/cjs/types.d.ts +149 -0
- package/dist/cjs/types.js +58 -0
- package/dist/cjs/tz.d.ts +22 -0
- package/dist/cjs/tz.js +197 -0
- package/dist/cjs/validate_rule.d.ts +4 -0
- package/dist/cjs/validate_rule.js +267 -0
- package/dist/dispatcher.d.ts +39 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +111 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/dispatcher_store.d.ts +10 -0
- package/dist/dispatcher_store.d.ts.map +1 -0
- package/dist/dispatcher_store.js +13 -0
- package/dist/dispatcher_store.js.map +1 -0
- package/dist/generators/anchored.d.ts +4 -0
- package/dist/generators/anchored.d.ts.map +1 -0
- package/dist/generators/anchored.js +42 -0
- package/dist/generators/anchored.js.map +1 -0
- package/dist/generators/days_of_month.d.ts +4 -0
- package/dist/generators/days_of_month.d.ts.map +1 -0
- package/dist/generators/days_of_month.js +53 -0
- package/dist/generators/days_of_month.js.map +1 -0
- package/dist/generators/days_of_week.d.ts +6 -0
- package/dist/generators/days_of_week.d.ts.map +1 -0
- package/dist/generators/days_of_week.js +43 -0
- package/dist/generators/days_of_week.js.map +1 -0
- package/dist/generators/days_of_year.d.ts +4 -0
- package/dist/generators/days_of_year.d.ts.map +1 -0
- package/dist/generators/days_of_year.js +50 -0
- package/dist/generators/days_of_year.js.map +1 -0
- package/dist/generators/instant.d.ts +4 -0
- package/dist/generators/instant.d.ts.map +1 -0
- package/dist/generators/instant.js +15 -0
- package/dist/generators/instant.js.map +1 -0
- package/dist/generators/local_date_time.d.ts +4 -0
- package/dist/generators/local_date_time.d.ts.map +1 -0
- package/dist/generators/local_date_time.js +20 -0
- package/dist/generators/local_date_time.js.map +1 -0
- package/dist/generators/months_of_year.d.ts +4 -0
- package/dist/generators/months_of_year.d.ts.map +1 -0
- package/dist/generators/months_of_year.js +64 -0
- package/dist/generators/months_of_year.js.map +1 -0
- package/dist/generators/types.d.ts +10 -0
- package/dist/generators/types.d.ts.map +1 -0
- package/dist/generators/types.js +14 -0
- package/dist/generators/types.js.map +1 -0
- package/dist/generators/weeks_of_month.d.ts +4 -0
- package/dist/generators/weeks_of_month.d.ts.map +1 -0
- package/dist/generators/weeks_of_month.js +61 -0
- package/dist/generators/weeks_of_month.js.map +1 -0
- package/dist/generators/yearly.d.ts +4 -0
- package/dist/generators/yearly.d.ts.map +1 -0
- package/dist/generators/yearly.js +37 -0
- package/dist/generators/yearly.js.map +1 -0
- package/dist/in_memory_port.d.ts +19 -0
- package/dist/in_memory_port.d.ts.map +1 -0
- package/dist/in_memory_port.js +45 -0
- package/dist/in_memory_port.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/occurrence_key.d.ts +6 -0
- package/dist/occurrence_key.d.ts.map +1 -0
- package/dist/occurrence_key.js +25 -0
- package/dist/occurrence_key.js.map +1 -0
- package/dist/port.d.ts +16 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/scheduler.d.ts +24 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +241 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/dist/tz.d.ts +23 -0
- package/dist/tz.d.ts.map +1 -0
- package/dist/tz.js +131 -0
- package/dist/tz.js.map +1 -0
- package/dist/validate_rule.d.ts +5 -0
- package/dist/validate_rule.d.ts.map +1 -0
- package/dist/validate_rule.js +280 -0
- package/dist/validate_rule.js.map +1 -0
- package/package.json +76 -4
- package/src/__tests__/alterations.test.ts +177 -0
- package/src/__tests__/anchored.test.ts +107 -0
- package/src/__tests__/days_of_month.test.ts +162 -0
- package/src/__tests__/days_of_week.test.ts +134 -0
- package/src/__tests__/days_of_year.test.ts +158 -0
- package/src/__tests__/dispatcher.test.ts +317 -0
- package/src/__tests__/instant.test.ts +50 -0
- package/src/__tests__/local_date_time.test.ts +139 -0
- package/src/__tests__/months_of_year.test.ts +229 -0
- package/src/__tests__/scheduler.test.ts +888 -0
- package/src/__tests__/tz.test.ts +201 -0
- package/src/__tests__/validate_rule.test.ts +190 -0
- package/src/__tests__/weeks_of_month.test.ts +195 -0
- package/src/__tests__/yearly.test.ts +108 -0
- package/src/alterations.ts +108 -0
- package/src/dispatcher.ts +184 -0
- package/src/dispatcher_store.ts +26 -0
- package/src/generators/anchored.ts +69 -0
- package/src/generators/days_of_month.ts +100 -0
- package/src/generators/days_of_week.ts +101 -0
- package/src/generators/days_of_year.ts +88 -0
- package/src/generators/instant.ts +18 -0
- package/src/generators/local_date_time.ts +38 -0
- package/src/generators/months_of_year.ts +143 -0
- package/src/generators/types.ts +26 -0
- package/src/generators/weeks_of_month.ts +133 -0
- package/src/generators/yearly.ts +73 -0
- package/src/in_memory_port.ts +70 -0
- package/src/index.ts +25 -0
- package/src/occurrence_key.ts +42 -0
- package/src/port.ts +31 -0
- package/src/scheduler.ts +353 -0
- package/src/types.ts +369 -0
- package/src/tz.ts +246 -0
- package/src/validate_rule.ts +311 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// src/validate_rule.ts
|
|
2
|
+
var DAYS_HINT = "Day numbers 0..6 (0=Sunday, 1=Monday … 6=Saturday)";
|
|
3
|
+
var MONTHS_HINT = "Month numbers 0..11 (0=January … 11=December)";
|
|
4
|
+
var WEEKS_HINT = "week-of-month numbers 1..5 (1=first) or -1..-5 (-1=last)";
|
|
5
|
+
var TIME_OF_DAY_HINT = "ms after local midnight, 0..86_399_999 (7:00 = 25_200_000)";
|
|
6
|
+
var TIMESTAMP_HINT = "a UTC timestamp in ms";
|
|
7
|
+
var SERIES = {
|
|
8
|
+
timeSpan: {
|
|
9
|
+
required: false,
|
|
10
|
+
check: (v) => validateTimeSpan(v)
|
|
11
|
+
},
|
|
12
|
+
seriesStart: {
|
|
13
|
+
required: true,
|
|
14
|
+
check: timestamp
|
|
15
|
+
},
|
|
16
|
+
seriesEnd: {
|
|
17
|
+
required: true,
|
|
18
|
+
check: nullable(timestamp, "null (no end)")
|
|
19
|
+
},
|
|
20
|
+
count: {
|
|
21
|
+
required: false,
|
|
22
|
+
check: nullable(int(0, Number.MAX_SAFE_INTEGER, "a whole number >= 0"), "null")
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var TIME_OF_DAY = {
|
|
26
|
+
required: true,
|
|
27
|
+
check: int(0, 86399999, TIME_OF_DAY_HINT)
|
|
28
|
+
};
|
|
29
|
+
var KINDS = {
|
|
30
|
+
instant: {
|
|
31
|
+
name: "InstantRule",
|
|
32
|
+
fields: {
|
|
33
|
+
startOn: {
|
|
34
|
+
required: true,
|
|
35
|
+
check: timestamp
|
|
36
|
+
},
|
|
37
|
+
timeSpan: {
|
|
38
|
+
required: false,
|
|
39
|
+
check: (v) => validateTimeSpan(v)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
localDateTime: {
|
|
44
|
+
name: "LocalDateTimeRule",
|
|
45
|
+
fields: {
|
|
46
|
+
year: {
|
|
47
|
+
required: true,
|
|
48
|
+
check: int(1, 9999, "a year such as 2026")
|
|
49
|
+
},
|
|
50
|
+
month: {
|
|
51
|
+
required: true,
|
|
52
|
+
check: int(0, 11, MONTHS_HINT)
|
|
53
|
+
},
|
|
54
|
+
day: {
|
|
55
|
+
required: true,
|
|
56
|
+
check: int(1, 31, "a day of the month 1..31")
|
|
57
|
+
},
|
|
58
|
+
timeOfDay: TIME_OF_DAY,
|
|
59
|
+
timeSpan: {
|
|
60
|
+
required: false,
|
|
61
|
+
check: (v) => validateTimeSpan(v)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
anchored: {
|
|
66
|
+
name: "AnchoredRule",
|
|
67
|
+
fields: {
|
|
68
|
+
anchorDate: {
|
|
69
|
+
required: true,
|
|
70
|
+
check: timestamp
|
|
71
|
+
},
|
|
72
|
+
intervalMs: {
|
|
73
|
+
required: true,
|
|
74
|
+
check: positive("an interval in ms > 0")
|
|
75
|
+
},
|
|
76
|
+
...SERIES
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
daysOfWeek: {
|
|
80
|
+
name: "DaysOfWeekRule",
|
|
81
|
+
fields: {
|
|
82
|
+
days: {
|
|
83
|
+
required: true,
|
|
84
|
+
check: intList(0, 6, DAYS_HINT)
|
|
85
|
+
},
|
|
86
|
+
weekInterval: {
|
|
87
|
+
required: true,
|
|
88
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every week)")
|
|
89
|
+
},
|
|
90
|
+
timeOfDay: TIME_OF_DAY,
|
|
91
|
+
...SERIES
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
weeksOfMonth: {
|
|
95
|
+
name: "WeeksOfMonthRule",
|
|
96
|
+
fields: {
|
|
97
|
+
days: {
|
|
98
|
+
required: true,
|
|
99
|
+
check: intList(0, 6, DAYS_HINT)
|
|
100
|
+
},
|
|
101
|
+
weeks: {
|
|
102
|
+
required: true,
|
|
103
|
+
check: weekList
|
|
104
|
+
},
|
|
105
|
+
monthInterval: {
|
|
106
|
+
required: true,
|
|
107
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every month)")
|
|
108
|
+
},
|
|
109
|
+
timeOfDay: TIME_OF_DAY,
|
|
110
|
+
...SERIES
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
monthsOfYear: {
|
|
114
|
+
name: "MonthsOfYearRule",
|
|
115
|
+
fields: {
|
|
116
|
+
months: {
|
|
117
|
+
required: true,
|
|
118
|
+
check: intList(0, 11, MONTHS_HINT)
|
|
119
|
+
},
|
|
120
|
+
weeks: {
|
|
121
|
+
required: true,
|
|
122
|
+
check: weekList
|
|
123
|
+
},
|
|
124
|
+
days: {
|
|
125
|
+
required: true,
|
|
126
|
+
check: intList(0, 6, DAYS_HINT)
|
|
127
|
+
},
|
|
128
|
+
yearInterval: {
|
|
129
|
+
required: true,
|
|
130
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every year)")
|
|
131
|
+
},
|
|
132
|
+
timeOfDay: TIME_OF_DAY,
|
|
133
|
+
...SERIES
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
yearly: {
|
|
137
|
+
name: "YearlyRule",
|
|
138
|
+
fields: {
|
|
139
|
+
month: {
|
|
140
|
+
required: true,
|
|
141
|
+
check: int(0, 11, MONTHS_HINT)
|
|
142
|
+
},
|
|
143
|
+
day: {
|
|
144
|
+
required: true,
|
|
145
|
+
check: int(1, 31, "a day of the month 1..31")
|
|
146
|
+
},
|
|
147
|
+
yearInterval: {
|
|
148
|
+
required: true,
|
|
149
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every year)")
|
|
150
|
+
},
|
|
151
|
+
timeOfDay: TIME_OF_DAY,
|
|
152
|
+
...SERIES
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
daysOfMonth: {
|
|
156
|
+
name: "DaysOfMonthRule",
|
|
157
|
+
fields: {
|
|
158
|
+
months: {
|
|
159
|
+
required: true,
|
|
160
|
+
check: intList(0, 11, MONTHS_HINT)
|
|
161
|
+
},
|
|
162
|
+
daysOfMonth: {
|
|
163
|
+
required: true,
|
|
164
|
+
check: intList(1, 31, "days of the month 1..31")
|
|
165
|
+
},
|
|
166
|
+
yearInterval: {
|
|
167
|
+
required: true,
|
|
168
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every year)")
|
|
169
|
+
},
|
|
170
|
+
timeOfDay: TIME_OF_DAY,
|
|
171
|
+
...SERIES
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
daysOfYear: {
|
|
175
|
+
name: "DaysOfYearRule",
|
|
176
|
+
fields: {
|
|
177
|
+
daysOfYear: {
|
|
178
|
+
required: true,
|
|
179
|
+
check: intList(1, 366, "days of the year 1..366")
|
|
180
|
+
},
|
|
181
|
+
yearInterval: {
|
|
182
|
+
required: true,
|
|
183
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every year)")
|
|
184
|
+
},
|
|
185
|
+
timeOfDay: TIME_OF_DAY,
|
|
186
|
+
...SERIES
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
var RULE_KINDS = Object.keys(KINDS);
|
|
191
|
+
function validateRule(rule) {
|
|
192
|
+
const raw = rule;
|
|
193
|
+
if (typeof raw !== "object" || raw == null || Array.isArray(raw)) throw new Error(`A rule must be an object with a "kind" (one of ${RULE_KINDS.join(", ")})`);
|
|
194
|
+
const obj = raw;
|
|
195
|
+
const kind = KINDS[obj.kind];
|
|
196
|
+
if (kind == null) throw new Error(`Unknown rule kind ${JSON.stringify(obj.kind)} (expected one of ${RULE_KINDS.join(", ")})`);
|
|
197
|
+
const known = Object.keys(kind.fields);
|
|
198
|
+
for (const key of Object.keys(obj)) if (key !== "kind" && !known.includes(key)) throw new Error(`${kind.name} has no field "${key}" (its fields are: ${known.join(", ")})`);
|
|
199
|
+
for (const [field, spec] of Object.entries(kind.fields)) {
|
|
200
|
+
const label = `${kind.name}.${field}`;
|
|
201
|
+
if (obj[field] === void 0) {
|
|
202
|
+
if (spec.required) throw new Error(`${label} is required: ${describe(spec, label)}`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
spec.check(obj[field], label);
|
|
206
|
+
}
|
|
207
|
+
const seriesStart = obj.seriesStart;
|
|
208
|
+
const seriesEnd = obj.seriesEnd;
|
|
209
|
+
if (seriesStart != null && seriesEnd != null && seriesEnd <= seriesStart) throw new Error(`${kind.name}.seriesEnd must be > seriesStart`);
|
|
210
|
+
}
|
|
211
|
+
function validateTimeSpan(span) {
|
|
212
|
+
if (span === void 0) return;
|
|
213
|
+
const form = "{ \"type\": \"duration\", \"ms\": <ms >= 1> } or { \"type\": \"hourSpan\", \"hours\": <hours > 0> }";
|
|
214
|
+
if (typeof span !== "object" || span == null) throw new Error(`timeSpan must be ${form}`);
|
|
215
|
+
const s = span;
|
|
216
|
+
if (s.type === "duration") {
|
|
217
|
+
if (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 1) throw new Error(`timeSpan.ms must be >= 1 if supplied (got ${String(s.ms)})`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (s.type === "hourSpan") {
|
|
221
|
+
if (typeof s.hours !== "number" || !Number.isFinite(s.hours) || s.hours <= 0) throw new Error(`timeSpan.hours must be > 0 (got ${String(s.hours)})`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`timeSpan must be ${form} (got type ${JSON.stringify(s.type)})`);
|
|
225
|
+
}
|
|
226
|
+
function describe(spec, label) {
|
|
227
|
+
try {
|
|
228
|
+
spec.check(void 0, label);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
231
|
+
const at = msg.indexOf(" must be ");
|
|
232
|
+
if (at >= 0) return msg.slice(at + 9).replace(/ \(got [^)]*\)$/, "");
|
|
233
|
+
}
|
|
234
|
+
return "see the rule's type";
|
|
235
|
+
}
|
|
236
|
+
function show(v) {
|
|
237
|
+
return v === void 0 ? "nothing" : JSON.stringify(v);
|
|
238
|
+
}
|
|
239
|
+
function isInt(v) {
|
|
240
|
+
return typeof v === "number" && Number.isInteger(v);
|
|
241
|
+
}
|
|
242
|
+
function int(min, max, hint) {
|
|
243
|
+
return (v, field) => {
|
|
244
|
+
if (!isInt(v) || v < min || v > max) throw new Error(`${field} must be ${hint} (got ${show(v)})`);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function positive(hint) {
|
|
248
|
+
return (v, field) => {
|
|
249
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) throw new Error(`${field} must be ${hint} (got ${show(v)})`);
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function timestamp(v, field) {
|
|
253
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${field} must be ${TIMESTAMP_HINT} (got ${show(v)})`);
|
|
254
|
+
}
|
|
255
|
+
function nullable(check, nullHint) {
|
|
256
|
+
return (v, field) => {
|
|
257
|
+
if (v === null) return;
|
|
258
|
+
try {
|
|
259
|
+
check(v, field);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
262
|
+
throw new Error(msg.replace(/ \(got /, ` or ${nullHint} (got `));
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function intList(min, max, hint) {
|
|
267
|
+
return (v, field) => {
|
|
268
|
+
const form = `a non-empty array of ${hint}`;
|
|
269
|
+
if (!Array.isArray(v) || v.length === 0) throw new Error(`${field} must be ${form} (got ${show(v)})`);
|
|
270
|
+
for (const x of v) if (!isInt(x) || x < min || x > max) throw new Error(`${field} must be ${form} (got ${show(v)}; ${show(x)} is out of range)`);
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function weekList(v, field) {
|
|
274
|
+
const form = `a non-empty array of ${WEEKS_HINT}`;
|
|
275
|
+
if (!Array.isArray(v) || v.length === 0) throw new Error(`${field} must be ${form} (got ${show(v)})`);
|
|
276
|
+
for (const w of v) if (!isInt(w) || w === 0 || w < -5 || w > 5) throw new Error(`${field} must be ${form} (got ${show(v)}; ${show(w)} is out of range)`);
|
|
277
|
+
}
|
|
278
|
+
export { RULE_KINDS, validateRule, validateTimeSpan };
|
|
279
|
+
|
|
280
|
+
//# sourceMappingURL=validate_rule.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate_rule.js","names":[],"sources":["../src/validate_rule.ts"],"sourcesContent":["import type { RecurrenceRule, TimeSpan } from \"./types.js\";\n\n// ─── Rule validation ──────────────────────────────────────────\n//\n// A rule often comes from outside the type system: JSON in a database, a\n// request body, or an LLM tool call. So validation checks the WHOLE shape —\n// every required field present, of the right type, in range, and no fields\n// the kind does not have — and throws an Error that names the field and the\n// form it expects. A bad rule never reaches a generator, where it would crash\n// with \"Cannot read properties of undefined\" or \"Invalid time value\".\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\n\nconst DAYS_HINT = \"Day numbers 0..6 (0=Sunday, 1=Monday … 6=Saturday)\";\nconst MONTHS_HINT = \"Month numbers 0..11 (0=January … 11=December)\";\nconst WEEKS_HINT = \"week-of-month numbers 1..5 (1=first) or -1..-5 (-1=last)\";\nconst TIME_OF_DAY_HINT = \"ms after local midnight, 0..86_399_999 (7:00 = 25_200_000)\";\nconst TIMESTAMP_HINT = \"a UTC timestamp in ms\";\n\n// One field of a rule kind: whether it must be there, and how to check it.\ninterface FieldSpec {\n required: boolean;\n check: (value: unknown, field: string) => void;\n}\n\n// The common series fields every repeating kind has.\nconst SERIES: Record<string, FieldSpec> = {\n timeSpan: { required: false, check: (v) => validateTimeSpan(v) },\n seriesStart: { required: true, check: timestamp },\n seriesEnd: { required: true, check: nullable(timestamp, \"null (no end)\") },\n count: {\n required: false,\n check: nullable(int(0, Number.MAX_SAFE_INTEGER, \"a whole number >= 0\"), \"null\"),\n },\n};\n\nconst TIME_OF_DAY: FieldSpec = { required: true, check: int(0, DAY_MS - 1, TIME_OF_DAY_HINT) };\n\nconst KINDS: Record<RecurrenceRule[\"kind\"], { name: string; fields: Record<string, FieldSpec> }> = {\n instant: {\n name: \"InstantRule\",\n fields: {\n startOn: { required: true, check: timestamp },\n timeSpan: { required: false, check: (v) => validateTimeSpan(v) },\n },\n },\n localDateTime: {\n name: \"LocalDateTimeRule\",\n fields: {\n year: { required: true, check: int(1, 9999, \"a year such as 2026\") },\n month: { required: true, check: int(0, 11, MONTHS_HINT) },\n day: { required: true, check: int(1, 31, \"a day of the month 1..31\") },\n timeOfDay: TIME_OF_DAY,\n timeSpan: { required: false, check: (v) => validateTimeSpan(v) },\n },\n },\n anchored: {\n name: \"AnchoredRule\",\n fields: {\n anchorDate: { required: true, check: timestamp },\n intervalMs: { required: true, check: positive(\"an interval in ms > 0\") },\n ...SERIES,\n },\n },\n daysOfWeek: {\n name: \"DaysOfWeekRule\",\n fields: {\n days: { required: true, check: intList(0, 6, DAYS_HINT) },\n weekInterval: {\n required: true,\n check: int(1, Number.MAX_SAFE_INTEGER, \"a whole number >= 1 (1 = every week)\"),\n },\n timeOfDay: TIME_OF_DAY,\n ...SERIES,\n },\n },\n weeksOfMonth: {\n name: \"WeeksOfMonthRule\",\n fields: {\n days: { required: true, check: intList(0, 6, DAYS_HINT) },\n weeks: { required: true, check: weekList },\n monthInterval: {\n required: true,\n check: int(1, Number.MAX_SAFE_INTEGER, \"a whole number >= 1 (1 = every month)\"),\n },\n timeOfDay: TIME_OF_DAY,\n ...SERIES,\n },\n },\n monthsOfYear: {\n name: \"MonthsOfYearRule\",\n fields: {\n months: { required: true, check: intList(0, 11, MONTHS_HINT) },\n weeks: { required: true, check: weekList },\n days: { required: true, check: intList(0, 6, DAYS_HINT) },\n yearInterval: {\n required: true,\n check: int(1, Number.MAX_SAFE_INTEGER, \"a whole number >= 1 (1 = every year)\"),\n },\n timeOfDay: TIME_OF_DAY,\n ...SERIES,\n },\n },\n yearly: {\n name: \"YearlyRule\",\n fields: {\n month: { required: true, check: int(0, 11, MONTHS_HINT) },\n day: { required: true, check: int(1, 31, \"a day of the month 1..31\") },\n yearInterval: {\n required: true,\n check: int(1, Number.MAX_SAFE_INTEGER, \"a whole number >= 1 (1 = every year)\"),\n },\n timeOfDay: TIME_OF_DAY,\n ...SERIES,\n },\n },\n daysOfMonth: {\n name: \"DaysOfMonthRule\",\n fields: {\n months: { required: true, check: intList(0, 11, MONTHS_HINT) },\n daysOfMonth: { required: true, check: intList(1, 31, \"days of the month 1..31\") },\n yearInterval: {\n required: true,\n check: int(1, Number.MAX_SAFE_INTEGER, \"a whole number >= 1 (1 = every year)\"),\n },\n timeOfDay: TIME_OF_DAY,\n ...SERIES,\n },\n },\n daysOfYear: {\n name: \"DaysOfYearRule\",\n fields: {\n daysOfYear: { required: true, check: intList(1, 366, \"days of the year 1..366\") },\n yearInterval: {\n required: true,\n check: int(1, Number.MAX_SAFE_INTEGER, \"a whole number >= 1 (1 = every year)\"),\n },\n timeOfDay: TIME_OF_DAY,\n ...SERIES,\n },\n },\n};\n\n// The rule kinds, in the order the types declare them.\nexport const RULE_KINDS = Object.keys(KINDS) as RecurrenceRule[\"kind\"][];\n\n// Throws when `rule` is not a valid RecurrenceRule. The message names the\n// first wrong field and the form it expects.\nexport function validateRule(rule: RecurrenceRule): void {\n const raw: unknown = rule;\n\n if (typeof raw !== \"object\" || raw == null || Array.isArray(raw)) {\n throw new Error(`A rule must be an object with a \"kind\" (one of ${RULE_KINDS.join(\", \")})`);\n }\n\n const obj = raw as Record<string, unknown>;\n const kind = KINDS[obj.kind as RecurrenceRule[\"kind\"]];\n\n if (kind == null) {\n throw new Error(\n `Unknown rule kind ${JSON.stringify(obj.kind)} (expected one of ${RULE_KINDS.join(\", \")})`\n );\n }\n\n const known = Object.keys(kind.fields);\n\n for (const key of Object.keys(obj)) {\n if (key !== \"kind\" && !known.includes(key)) {\n throw new Error(`${kind.name} has no field \"${key}\" (its fields are: ${known.join(\", \")})`);\n }\n }\n\n for (const [field, spec] of Object.entries(kind.fields)) {\n const label = `${kind.name}.${field}`;\n\n if (obj[field] === undefined) {\n if (spec.required) throw new Error(`${label} is required: ${describe(spec, label)}`);\n continue;\n }\n\n spec.check(obj[field], label);\n }\n\n const seriesStart = obj.seriesStart as number | undefined;\n const seriesEnd = obj.seriesEnd as number | null | undefined;\n\n if (seriesStart != null && seriesEnd != null && seriesEnd <= seriesStart) {\n throw new Error(`${kind.name}.seriesEnd must be > seriesStart`);\n }\n}\n\n// Throws when `span` is not a TimeSpan. Undefined is valid (the rule's\n// default span applies).\nexport function validateTimeSpan(span: unknown): asserts span is TimeSpan | undefined {\n if (span === undefined) return;\n\n const form =\n '{ \"type\": \"duration\", \"ms\": <ms >= 1> } or { \"type\": \"hourSpan\", \"hours\": <hours > 0> }';\n\n if (typeof span !== \"object\" || span == null) {\n throw new Error(`timeSpan must be ${form}`);\n }\n\n const s = span as Record<string, unknown>;\n\n if (s.type === \"duration\") {\n if (typeof s.ms !== \"number\" || !Number.isFinite(s.ms) || s.ms < 1) {\n throw new Error(`timeSpan.ms must be >= 1 if supplied (got ${String(s.ms)})`);\n }\n\n return;\n }\n\n if (s.type === \"hourSpan\") {\n if (typeof s.hours !== \"number\" || !Number.isFinite(s.hours) || s.hours <= 0) {\n throw new Error(`timeSpan.hours must be > 0 (got ${String(s.hours)})`);\n }\n\n return;\n }\n\n throw new Error(`timeSpan must be ${form} (got type ${JSON.stringify(s.type)})`);\n}\n\n// ─── Field checks ─────────────────────────────────────────────\n\n// What the field should hold, for \"is required\" messages: each check is\n// called on undefined and its message reused.\nfunction describe(spec: FieldSpec, label: string): string {\n try {\n spec.check(undefined, label);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n const at = msg.indexOf(\" must be \");\n\n if (at >= 0) return msg.slice(at + \" must be \".length).replace(/ \\(got [^)]*\\)$/, \"\");\n }\n\n return \"see the rule's type\";\n}\n\nfunction show(v: unknown): string {\n return v === undefined ? \"nothing\" : JSON.stringify(v);\n}\n\nfunction isInt(v: unknown): v is number {\n return typeof v === \"number\" && Number.isInteger(v);\n}\n\nfunction int(min: number, max: number, hint: string) {\n return (v: unknown, field: string): void => {\n if (!isInt(v) || v < min || v > max)\n throw new Error(`${field} must be ${hint} (got ${show(v)})`);\n };\n}\n\nfunction positive(hint: string) {\n return (v: unknown, field: string): void => {\n if (typeof v !== \"number\" || !Number.isFinite(v) || v <= 0) {\n throw new Error(`${field} must be ${hint} (got ${show(v)})`);\n }\n };\n}\n\nfunction timestamp(v: unknown, field: string): void {\n if (typeof v !== \"number\" || !Number.isFinite(v)) {\n throw new Error(`${field} must be ${TIMESTAMP_HINT} (got ${show(v)})`);\n }\n}\n\nfunction nullable(check: (v: unknown, field: string) => void, nullHint: string) {\n return (v: unknown, field: string): void => {\n if (v === null) return;\n\n try {\n check(v, field);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n\n throw new Error(msg.replace(/ \\(got /, ` or ${nullHint} (got `));\n }\n };\n}\n\nfunction intList(min: number, max: number, hint: string) {\n return (v: unknown, field: string): void => {\n const form = `a non-empty array of ${hint}`;\n\n if (!Array.isArray(v) || v.length === 0)\n throw new Error(`${field} must be ${form} (got ${show(v)})`);\n\n for (const x of v) {\n if (!isInt(x) || x < min || x > max) {\n throw new Error(`${field} must be ${form} (got ${show(v)}; ${show(x)} is out of range)`);\n }\n }\n };\n}\n\nfunction weekList(v: unknown, field: string): void {\n const form = `a non-empty array of ${WEEKS_HINT}`;\n\n if (!Array.isArray(v) || v.length === 0)\n throw new Error(`${field} must be ${form} (got ${show(v)})`);\n\n for (const w of v) {\n if (!isInt(w) || w === 0 || w < -5 || w > 5) {\n throw new Error(`${field} must be ${form} (got ${show(v)}; ${show(w)} is out of range)`);\n }\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@j13b/scheduler",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A timezone-aware recurrence and dispatch engine: a pure-data rule DSL, IANA timezones, DST-aware generators and a cron-style dispatcher.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Jared J Barnes",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/cjs/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"require": {
|
|
18
|
+
"types": "./dist/cjs/index.d.ts",
|
|
19
|
+
"default": "./dist/cjs/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src",
|
|
27
|
+
"tsconfig.json",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "vite build && node scripts/build-cjs.mjs",
|
|
33
|
+
"check:types": "tsc --noEmit",
|
|
34
|
+
"test": "vitest",
|
|
35
|
+
"test:run": "vitest run",
|
|
36
|
+
"test:coverage": "vitest run --coverage",
|
|
37
|
+
"lint": "biome lint .",
|
|
38
|
+
"lint:fix": "biome lint --write .",
|
|
39
|
+
"format": "biome format --write .",
|
|
40
|
+
"check": "biome check .",
|
|
41
|
+
"check:fix": "biome check --write .",
|
|
42
|
+
"prepublishOnly": "npm run check:types && npm run test:run && npm run build"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/jaredjbarnes/scheduler.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/jaredjbarnes/scheduler#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/jaredjbarnes/scheduler/issues"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"keywords": [
|
|
56
|
+
"scheduler",
|
|
57
|
+
"recurrence",
|
|
58
|
+
"rrule",
|
|
59
|
+
"calendar",
|
|
60
|
+
"timezone",
|
|
61
|
+
"dst",
|
|
62
|
+
"cron",
|
|
63
|
+
"dispatcher"
|
|
64
|
+
],
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"@j13b/state": "^0.2.0"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@biomejs/biome": "^1.9.4",
|
|
70
|
+
"@j13b/state": "^0.2.0",
|
|
71
|
+
"@types/node": "^20.0.0",
|
|
72
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
73
|
+
"typescript": "^5.4.0",
|
|
74
|
+
"vite": "^8.1.3",
|
|
75
|
+
"vite-plugin-dts": "^4.2.0",
|
|
76
|
+
"vitest": "^4.1.9"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { applyAlterations } from "../alterations.js";
|
|
3
|
+
import { type KeyedOccurrence, assignOccurrenceKeys } from "../occurrence_key.js";
|
|
4
|
+
import type { Alteration } from "../types.js";
|
|
5
|
+
|
|
6
|
+
interface CronPayload {
|
|
7
|
+
type: "cron";
|
|
8
|
+
command: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const HOUR = 3_600_000;
|
|
12
|
+
const TPL_ID = "tpl-1";
|
|
13
|
+
const PAYLOAD: CronPayload = { type: "cron", command: "ls" };
|
|
14
|
+
|
|
15
|
+
function keyed(starts: number[], duration: number): KeyedOccurrence[] {
|
|
16
|
+
return assignOccurrenceKeys(
|
|
17
|
+
starts.map((s) => ({ startTimestamp: s, endTimestamp: s + duration })),
|
|
18
|
+
"UTC"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("assignOccurrenceKeys", () => {
|
|
23
|
+
test("YYYY-MM-DD when one per day", () => {
|
|
24
|
+
const k = keyed([Date.UTC(2026, 4, 11, 9), Date.UTC(2026, 4, 12, 9)], HOUR);
|
|
25
|
+
expect(k.map((o) => o.occurrenceKey)).toEqual(["2026-05-11", "2026-05-12"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("YYYY-MM-DD#N when multiple per day", () => {
|
|
29
|
+
const k = keyed(
|
|
30
|
+
[Date.UTC(2026, 4, 11, 9), Date.UTC(2026, 4, 11, 15), Date.UTC(2026, 4, 11, 21)],
|
|
31
|
+
HOUR
|
|
32
|
+
);
|
|
33
|
+
expect(k.map((o) => o.occurrenceKey)).toEqual(["2026-05-11", "2026-05-11#1", "2026-05-11#2"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("respects tz when bucketing", () => {
|
|
37
|
+
// 2026-05-12T03:00Z is May 11 21:00 in America/Denver (MDT, -06)
|
|
38
|
+
const k = assignOccurrenceKeys(
|
|
39
|
+
[
|
|
40
|
+
{ startTimestamp: Date.UTC(2026, 4, 11, 21), endTimestamp: 0 }, // 15:00 Denver = May 11
|
|
41
|
+
{ startTimestamp: Date.UTC(2026, 4, 12, 3), endTimestamp: 0 }, // 21:00 Denver = May 11
|
|
42
|
+
],
|
|
43
|
+
"America/Denver"
|
|
44
|
+
);
|
|
45
|
+
expect(k.map((o) => o.occurrenceKey)).toEqual(["2026-05-11", "2026-05-11#1"]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("applyAlterations", () => {
|
|
50
|
+
const winStart = Date.UTC(2026, 4, 11);
|
|
51
|
+
const winEnd = Date.UTC(2026, 4, 14);
|
|
52
|
+
const occs = keyed(
|
|
53
|
+
[
|
|
54
|
+
Date.UTC(2026, 4, 11, 9), // key 2026-05-11
|
|
55
|
+
Date.UTC(2026, 4, 12, 9), // key 2026-05-12
|
|
56
|
+
Date.UTC(2026, 4, 13, 9), // key 2026-05-13
|
|
57
|
+
],
|
|
58
|
+
HOUR
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
test("no alterations: emits all base occurrences", () => {
|
|
62
|
+
const out = applyAlterations(TPL_ID, PAYLOAD, occs, [], "UTC", winStart, winEnd);
|
|
63
|
+
expect(out).toHaveLength(3);
|
|
64
|
+
expect(out.every((o) => o.alterationId == null)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("cancel suppresses the matched occurrence", () => {
|
|
68
|
+
const alt: Alteration<CronPayload> = {
|
|
69
|
+
id: "a1",
|
|
70
|
+
templateId: TPL_ID,
|
|
71
|
+
occurrenceKey: "2026-05-12",
|
|
72
|
+
kind: "cancel",
|
|
73
|
+
};
|
|
74
|
+
const out = applyAlterations(TPL_ID, PAYLOAD, occs, [alt], "UTC", winStart, winEnd);
|
|
75
|
+
expect(out).toHaveLength(2);
|
|
76
|
+
expect(out.map((o) => o.occurrenceKey)).toEqual(["2026-05-11", "2026-05-13"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("reschedule shifts the time and tags alterationId", () => {
|
|
80
|
+
const newStart = Date.UTC(2026, 4, 12, 17);
|
|
81
|
+
const alt: Alteration<CronPayload> = {
|
|
82
|
+
id: "a1",
|
|
83
|
+
templateId: TPL_ID,
|
|
84
|
+
occurrenceKey: "2026-05-12",
|
|
85
|
+
kind: "reschedule",
|
|
86
|
+
startTimestamp: newStart,
|
|
87
|
+
timeSpan: { type: "duration", ms: 2 * HOUR },
|
|
88
|
+
};
|
|
89
|
+
const out = applyAlterations(TPL_ID, PAYLOAD, occs, [alt], "UTC", winStart, winEnd);
|
|
90
|
+
const moved = out.find((o) => o.occurrenceKey === "2026-05-12");
|
|
91
|
+
expect(moved?.startTimestamp).toBe(newStart);
|
|
92
|
+
expect(moved?.endTimestamp).toBe(newStart + 2 * HOUR);
|
|
93
|
+
expect(moved?.alterationId).toBe("a1");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("reschedule without timeSpan inherits original duration", () => {
|
|
97
|
+
const newStart = Date.UTC(2026, 4, 12, 17);
|
|
98
|
+
const alt: Alteration<CronPayload> = {
|
|
99
|
+
id: "a1",
|
|
100
|
+
templateId: TPL_ID,
|
|
101
|
+
occurrenceKey: "2026-05-12",
|
|
102
|
+
kind: "reschedule",
|
|
103
|
+
startTimestamp: newStart,
|
|
104
|
+
// no timeSpan → inherits original UTC duration (1 hour)
|
|
105
|
+
};
|
|
106
|
+
const out = applyAlterations(TPL_ID, PAYLOAD, occs, [alt], "UTC", winStart, winEnd);
|
|
107
|
+
const moved = out.find((o) => o.occurrenceKey === "2026-05-12");
|
|
108
|
+
expect(moved?.endTimestamp).toBe(newStart + HOUR);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("payloadOverride merges into base payload", () => {
|
|
112
|
+
const alt: Alteration<CronPayload> = {
|
|
113
|
+
id: "a1",
|
|
114
|
+
templateId: TPL_ID,
|
|
115
|
+
occurrenceKey: "2026-05-12",
|
|
116
|
+
kind: "reschedule",
|
|
117
|
+
startTimestamp: Date.UTC(2026, 4, 12, 9), // same time
|
|
118
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
119
|
+
payloadOverride: { command: "echo override" },
|
|
120
|
+
};
|
|
121
|
+
const out = applyAlterations(TPL_ID, PAYLOAD, occs, [alt], "UTC", winStart, winEnd);
|
|
122
|
+
const moved = out.find((o) => o.occurrenceKey === "2026-05-12");
|
|
123
|
+
expect(moved?.payload).toEqual({ type: "cron", command: "echo override" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("reschedule that moves event out of window is not emitted", () => {
|
|
127
|
+
const alt: Alteration<CronPayload> = {
|
|
128
|
+
id: "a1",
|
|
129
|
+
templateId: TPL_ID,
|
|
130
|
+
occurrenceKey: "2026-05-12",
|
|
131
|
+
kind: "reschedule",
|
|
132
|
+
startTimestamp: Date.UTC(2026, 5, 1), // out of window
|
|
133
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
134
|
+
};
|
|
135
|
+
const out = applyAlterations(TPL_ID, PAYLOAD, occs, [alt], "UTC", winStart, winEnd);
|
|
136
|
+
expect(out.map((o) => o.occurrenceKey)).toEqual(["2026-05-11", "2026-05-13"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("orphan reschedule (original outside window, new time inside) is emitted", () => {
|
|
140
|
+
const alt: Alteration<CronPayload> = {
|
|
141
|
+
id: "orphan",
|
|
142
|
+
templateId: TPL_ID,
|
|
143
|
+
occurrenceKey: "2026-05-01",
|
|
144
|
+
kind: "reschedule",
|
|
145
|
+
startTimestamp: Date.UTC(2026, 4, 12, 13),
|
|
146
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
147
|
+
};
|
|
148
|
+
const out = applyAlterations(TPL_ID, PAYLOAD, occs, [alt], "UTC", winStart, winEnd);
|
|
149
|
+
expect(out.find((o) => o.alterationId === "orphan")).toBeDefined();
|
|
150
|
+
expect(out).toHaveLength(4); // 3 base + 1 orphan
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("alterations for other templates are ignored", () => {
|
|
154
|
+
const alt: Alteration<CronPayload> = {
|
|
155
|
+
id: "wrong",
|
|
156
|
+
templateId: "other-template",
|
|
157
|
+
occurrenceKey: "2026-05-12",
|
|
158
|
+
kind: "cancel",
|
|
159
|
+
};
|
|
160
|
+
const out = applyAlterations(TPL_ID, PAYLOAD, occs, [alt], "UTC", winStart, winEnd);
|
|
161
|
+
expect(out).toHaveLength(3);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("output is sorted ascending by startTimestamp", () => {
|
|
165
|
+
const alt: Alteration<CronPayload> = {
|
|
166
|
+
id: "a1",
|
|
167
|
+
templateId: TPL_ID,
|
|
168
|
+
occurrenceKey: "2026-05-11",
|
|
169
|
+
kind: "reschedule",
|
|
170
|
+
startTimestamp: Date.UTC(2026, 4, 13, 18), // move the 11th to after the 13th
|
|
171
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
172
|
+
};
|
|
173
|
+
const out = applyAlterations(TPL_ID, PAYLOAD, occs, [alt], "UTC", winStart, winEnd);
|
|
174
|
+
const starts = out.map((o) => o.startTimestamp);
|
|
175
|
+
expect(starts).toEqual([...starts].sort((a, b) => a - b));
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { generateAnchored } from "../generators/anchored.js";
|
|
3
|
+
import type { AnchoredRule } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const HOUR = 3_600_000;
|
|
6
|
+
const DAY = 24 * HOUR;
|
|
7
|
+
|
|
8
|
+
describe("generateAnchored", () => {
|
|
9
|
+
test("emits occurrences at intervalMs from anchor within window", () => {
|
|
10
|
+
const rule: AnchoredRule = {
|
|
11
|
+
kind: "anchored",
|
|
12
|
+
anchorDate: Date.UTC(2026, 4, 11, 9, 0, 0),
|
|
13
|
+
intervalMs: 6 * HOUR,
|
|
14
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
15
|
+
seriesStart: Date.UTC(2026, 4, 11),
|
|
16
|
+
seriesEnd: null,
|
|
17
|
+
};
|
|
18
|
+
const out = generateAnchored(rule, undefined, Date.UTC(2026, 4, 11), Date.UTC(2026, 4, 12));
|
|
19
|
+
// Anchor 09:00 + 6h steps = 09, 15, 21 on the 11th (3 events)
|
|
20
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
21
|
+
"2026-05-11T09:00:00.000Z",
|
|
22
|
+
"2026-05-11T15:00:00.000Z",
|
|
23
|
+
"2026-05-11T21:00:00.000Z",
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("fast-forwards into the future without count", () => {
|
|
28
|
+
const rule: AnchoredRule = {
|
|
29
|
+
kind: "anchored",
|
|
30
|
+
anchorDate: Date.UTC(2020, 0, 1),
|
|
31
|
+
intervalMs: DAY,
|
|
32
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
33
|
+
seriesStart: Date.UTC(2020, 0, 1),
|
|
34
|
+
seriesEnd: null,
|
|
35
|
+
};
|
|
36
|
+
const out = generateAnchored(rule, undefined, Date.UTC(2026, 4, 11), Date.UTC(2026, 4, 14));
|
|
37
|
+
// 3 days in window, daily cadence, anchor far in the past → 3 events
|
|
38
|
+
expect(out).toHaveLength(3);
|
|
39
|
+
expect(new Date(out[0].startTimestamp).toISOString()).toBe("2026-05-11T00:00:00.000Z");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("respects count and counts from anchor (not from window start)", () => {
|
|
43
|
+
const rule: AnchoredRule = {
|
|
44
|
+
kind: "anchored",
|
|
45
|
+
anchorDate: Date.UTC(2026, 4, 11),
|
|
46
|
+
intervalMs: DAY,
|
|
47
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
48
|
+
seriesStart: Date.UTC(2026, 4, 11),
|
|
49
|
+
seriesEnd: null,
|
|
50
|
+
count: 3,
|
|
51
|
+
};
|
|
52
|
+
// Window covers many days, but count=3 caps at 3 events
|
|
53
|
+
const out = generateAnchored(rule, undefined, Date.UTC(2026, 4, 11), Date.UTC(2026, 4, 30));
|
|
54
|
+
expect(out).toHaveLength(3);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("count=3 with window after the 3rd event → empty", () => {
|
|
58
|
+
const rule: AnchoredRule = {
|
|
59
|
+
kind: "anchored",
|
|
60
|
+
anchorDate: Date.UTC(2026, 4, 11),
|
|
61
|
+
intervalMs: DAY,
|
|
62
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
63
|
+
seriesStart: Date.UTC(2026, 4, 11),
|
|
64
|
+
seriesEnd: null,
|
|
65
|
+
count: 3,
|
|
66
|
+
};
|
|
67
|
+
const out = generateAnchored(rule, undefined, Date.UTC(2026, 4, 20), Date.UTC(2026, 4, 30));
|
|
68
|
+
expect(out).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("respects seriesEnd as exclusive bound", () => {
|
|
72
|
+
const rule: AnchoredRule = {
|
|
73
|
+
kind: "anchored",
|
|
74
|
+
anchorDate: Date.UTC(2026, 4, 11),
|
|
75
|
+
intervalMs: DAY,
|
|
76
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
77
|
+
seriesStart: Date.UTC(2026, 4, 11),
|
|
78
|
+
seriesEnd: Date.UTC(2026, 4, 14), // 11, 12, 13 should emit; 14 should not
|
|
79
|
+
};
|
|
80
|
+
const out = generateAnchored(rule, undefined, Date.UTC(2026, 4, 11), Date.UTC(2026, 4, 30));
|
|
81
|
+
expect(out).toHaveLength(3);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("throws on intervalMs <= 0 (no infinite loop)", () => {
|
|
85
|
+
const rule: AnchoredRule = {
|
|
86
|
+
kind: "anchored",
|
|
87
|
+
anchorDate: 0,
|
|
88
|
+
intervalMs: 0,
|
|
89
|
+
seriesStart: 0,
|
|
90
|
+
seriesEnd: null,
|
|
91
|
+
};
|
|
92
|
+
expect(() => generateAnchored(rule, undefined, 0, 1000)).toThrow(/intervalMs/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("throws on too many occurrences", () => {
|
|
96
|
+
const rule: AnchoredRule = {
|
|
97
|
+
kind: "anchored",
|
|
98
|
+
anchorDate: 0,
|
|
99
|
+
intervalMs: 1, // every ms
|
|
100
|
+
timeSpan: { type: "duration", ms: 1 }, // non-zero so half-open intersection emits
|
|
101
|
+
seriesStart: 0,
|
|
102
|
+
seriesEnd: null,
|
|
103
|
+
};
|
|
104
|
+
// Window is 100k ms, no count → would emit 100k events. Should throw.
|
|
105
|
+
expect(() => generateAnchored(rule, undefined, 0, 100_000)).toThrow(/more than/);
|
|
106
|
+
});
|
|
107
|
+
});
|