@j13b/scheduler 0.1.0 → 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/README.md +1 -0
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/scheduler.d.ts +1 -2
- package/dist/cjs/scheduler.js +4 -162
- package/dist/cjs/validate_rule.d.ts +4 -0
- package/dist/cjs/validate_rule.js +267 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/scheduler.d.ts +1 -2
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +3 -94
- package/dist/scheduler.js.map +1 -1
- 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 +8 -2
- package/src/__tests__/validate_rule.test.ts +190 -0
- package/src/index.ts +1 -0
- package/src/scheduler.ts +2 -148
- package/src/validate_rule.ts +311 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { InMemorySchedulerPort } from "../in_memory_port.js";
|
|
3
|
+
import { Scheduler } from "../scheduler.js";
|
|
4
|
+
import type { RecurrenceRule } from "../types.js";
|
|
5
|
+
import { RULE_KINDS, validateRule, validateTimeSpan } from "../validate_rule.js";
|
|
6
|
+
|
|
7
|
+
const START = Date.UTC(2026, 0, 1);
|
|
8
|
+
const SEVEN_AM = 7 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
// A rule as it arrives from JSON: anything at all.
|
|
11
|
+
const loose = (rule: unknown): RecurrenceRule => rule as RecurrenceRule;
|
|
12
|
+
|
|
13
|
+
const VALID: RecurrenceRule[] = [
|
|
14
|
+
{ kind: "instant", startOn: START },
|
|
15
|
+
{ kind: "localDateTime", year: 2026, month: 0, day: 1, timeOfDay: SEVEN_AM },
|
|
16
|
+
{ kind: "anchored", anchorDate: START, intervalMs: 1000, seriesStart: START, seriesEnd: null },
|
|
17
|
+
{
|
|
18
|
+
kind: "daysOfWeek",
|
|
19
|
+
days: [1],
|
|
20
|
+
weekInterval: 1,
|
|
21
|
+
timeOfDay: 0,
|
|
22
|
+
seriesStart: START,
|
|
23
|
+
seriesEnd: null,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
kind: "weeksOfMonth",
|
|
27
|
+
days: [0],
|
|
28
|
+
weeks: [1, 3],
|
|
29
|
+
monthInterval: 1,
|
|
30
|
+
timeOfDay: SEVEN_AM,
|
|
31
|
+
seriesStart: START,
|
|
32
|
+
seriesEnd: null,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
kind: "monthsOfYear",
|
|
36
|
+
months: [10],
|
|
37
|
+
weeks: [4],
|
|
38
|
+
days: [4],
|
|
39
|
+
yearInterval: 1,
|
|
40
|
+
timeOfDay: 0,
|
|
41
|
+
seriesStart: START,
|
|
42
|
+
seriesEnd: null,
|
|
43
|
+
count: 3,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
kind: "yearly",
|
|
47
|
+
month: 6,
|
|
48
|
+
day: 4,
|
|
49
|
+
yearInterval: 1,
|
|
50
|
+
timeOfDay: 0,
|
|
51
|
+
seriesStart: START,
|
|
52
|
+
seriesEnd: null,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
kind: "daysOfMonth",
|
|
56
|
+
months: [0, 1],
|
|
57
|
+
daysOfMonth: [1, 15],
|
|
58
|
+
yearInterval: 1,
|
|
59
|
+
timeOfDay: 0,
|
|
60
|
+
seriesStart: START,
|
|
61
|
+
seriesEnd: START + 1,
|
|
62
|
+
timeSpan: { type: "duration", ms: 60_000 },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
kind: "daysOfYear",
|
|
66
|
+
daysOfYear: [1, 366],
|
|
67
|
+
yearInterval: 1,
|
|
68
|
+
timeOfDay: 0,
|
|
69
|
+
seriesStart: START,
|
|
70
|
+
seriesEnd: null,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
describe("validateRule accepts", () => {
|
|
75
|
+
test.each(VALID.map((r) => [r.kind, r] as const))("a whole %s rule", (_kind, rule) => {
|
|
76
|
+
expect(() => validateRule(rule)).not.toThrow();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("every kind is covered here", () => {
|
|
80
|
+
expect(VALID.map((r) => r.kind).sort()).toEqual([...RULE_KINDS].sort());
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// The rules an LLM sent for "the 1st and 3rd Sunday at 7am". Before, each one
|
|
85
|
+
// crashed inside the scheduler ("Cannot read properties of undefined", "Invalid
|
|
86
|
+
// time value"); now each names what is wrong.
|
|
87
|
+
describe("validateRule names the field that is wrong", () => {
|
|
88
|
+
test("a field the kind does not have", () => {
|
|
89
|
+
expect(() =>
|
|
90
|
+
validateRule(loose({ kind: "weeksOfMonth", weeks: [1, 3], weekdays: [0] }))
|
|
91
|
+
).toThrow(
|
|
92
|
+
'WeeksOfMonthRule has no field "weekdays" (its fields are: days, weeks, monthInterval'
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a required field that is missing, with the form it expects", () => {
|
|
97
|
+
expect(() => validateRule(loose({ kind: "weeksOfMonth", weeks: [1, 3], days: [0] }))).toThrow(
|
|
98
|
+
"WeeksOfMonthRule.monthInterval is required: a whole number >= 1 (1 = every month)"
|
|
99
|
+
);
|
|
100
|
+
expect(() =>
|
|
101
|
+
validateRule(loose({ kind: "weeksOfMonth", weeks: [1, 3], days: [0], monthInterval: 1 }))
|
|
102
|
+
).toThrow("WeeksOfMonthRule.timeOfDay is required: ms after local midnight");
|
|
103
|
+
expect(() =>
|
|
104
|
+
validateRule(
|
|
105
|
+
loose({
|
|
106
|
+
kind: "weeksOfMonth",
|
|
107
|
+
weeks: [1, 3],
|
|
108
|
+
days: [0],
|
|
109
|
+
monthInterval: 1,
|
|
110
|
+
timeOfDay: SEVEN_AM,
|
|
111
|
+
})
|
|
112
|
+
)
|
|
113
|
+
).toThrow("WeeksOfMonthRule.seriesStart is required: a UTC timestamp in ms");
|
|
114
|
+
expect(() =>
|
|
115
|
+
validateRule(
|
|
116
|
+
loose({
|
|
117
|
+
kind: "weeksOfMonth",
|
|
118
|
+
weeks: [1, 3],
|
|
119
|
+
days: [0],
|
|
120
|
+
monthInterval: 1,
|
|
121
|
+
timeOfDay: SEVEN_AM,
|
|
122
|
+
seriesStart: START,
|
|
123
|
+
})
|
|
124
|
+
)
|
|
125
|
+
).toThrow("WeeksOfMonthRule.seriesEnd is required: a UTC timestamp in ms or null (no end)");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a wrong type or value", () => {
|
|
129
|
+
const base = VALID[4] as unknown as Record<string, unknown>;
|
|
130
|
+
|
|
131
|
+
expect(() => validateRule(loose({ ...base, days: [] }))).toThrow(
|
|
132
|
+
"WeeksOfMonthRule.days must be a non-empty array"
|
|
133
|
+
);
|
|
134
|
+
expect(() => validateRule(loose({ ...base, days: 0 }))).toThrow(
|
|
135
|
+
"WeeksOfMonthRule.days must be a non-empty array"
|
|
136
|
+
);
|
|
137
|
+
expect(() => validateRule(loose({ ...base, days: [7] }))).toThrow("7 is out of range");
|
|
138
|
+
expect(() => validateRule(loose({ ...base, weeks: [0] }))).toThrow("0 is out of range");
|
|
139
|
+
expect(() => validateRule(loose({ ...base, timeOfDay: "07:00" }))).toThrow(
|
|
140
|
+
"WeeksOfMonthRule.timeOfDay must be ms after local midnight"
|
|
141
|
+
);
|
|
142
|
+
expect(() => validateRule(loose({ ...base, seriesStart: "2026-01-01" }))).toThrow(
|
|
143
|
+
"seriesStart must be a UTC timestamp"
|
|
144
|
+
);
|
|
145
|
+
expect(() => validateRule(loose({ ...base, count: -1 }))).toThrow(
|
|
146
|
+
"count must be a whole number >= 0 or null"
|
|
147
|
+
);
|
|
148
|
+
expect(() => validateRule(loose({ ...base, seriesEnd: START }))).toThrow(
|
|
149
|
+
"seriesEnd must be > seriesStart"
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("an unknown kind, or no rule at all", () => {
|
|
154
|
+
expect(() => validateRule(loose({ kind: "monthly" }))).toThrow(
|
|
155
|
+
'Unknown rule kind "monthly" (expected one of instant,'
|
|
156
|
+
);
|
|
157
|
+
expect(() => validateRule(loose(null))).toThrow('A rule must be an object with a "kind"');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("validateTimeSpan", () => {
|
|
162
|
+
test("accepts nothing, a duration, or an hour span", () => {
|
|
163
|
+
expect(() => validateTimeSpan(undefined)).not.toThrow();
|
|
164
|
+
expect(() => validateTimeSpan({ type: "duration", ms: 1 })).not.toThrow();
|
|
165
|
+
expect(() => validateTimeSpan({ type: "hourSpan", hours: 1.5 })).not.toThrow();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("rejects anything else with the forms it takes", () => {
|
|
169
|
+
expect(() => validateTimeSpan({ ms: 5 })).toThrow('timeSpan must be { "type": "duration"');
|
|
170
|
+
expect(() => validateTimeSpan({ type: "duration", ms: 0 })).toThrow("timeSpan.ms must be >= 1");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("the scheduler refuses a malformed rule before storing it", () => {
|
|
175
|
+
test("add throws the field message and stores nothing", async () => {
|
|
176
|
+
const port = new InMemorySchedulerPort<{ type: "event" }>();
|
|
177
|
+
const scheduler = new Scheduler(port);
|
|
178
|
+
|
|
179
|
+
await expect(
|
|
180
|
+
scheduler.add({
|
|
181
|
+
id: "t1",
|
|
182
|
+
rule: loose({ kind: "weeksOfMonth", weeks: [1, 3], weekdays: [0] }),
|
|
183
|
+
timezone: "UTC",
|
|
184
|
+
payload: { type: "event" },
|
|
185
|
+
})
|
|
186
|
+
).rejects.toThrow('has no field "weekdays"');
|
|
187
|
+
|
|
188
|
+
expect(await port.getTemplate("t1")).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/index.ts
CHANGED
package/src/scheduler.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
TimeSpan,
|
|
22
22
|
} from "./types.js";
|
|
23
23
|
import { isValidTimeZone } from "./tz.js";
|
|
24
|
+
import { validateRule, validateTimeSpan } from "./validate_rule.js";
|
|
24
25
|
|
|
25
26
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
27
|
// Search horizon for next() / nextDue() before giving up. Five years covers
|
|
@@ -185,7 +186,7 @@ export class Scheduler<TPayload extends { type: string }> {
|
|
|
185
186
|
newTimeSpan?: TimeSpan,
|
|
186
187
|
payloadOverride?: Partial<TPayload>
|
|
187
188
|
): Promise<string> {
|
|
188
|
-
if (newTimeSpan != null)
|
|
189
|
+
if (newTimeSpan != null) validateTimeSpan(newTimeSpan);
|
|
189
190
|
const id = `alt-${templateId}-${occurrenceKey}-${Date.now()}`;
|
|
190
191
|
await this._removeExistingAlteration(templateId, occurrenceKey);
|
|
191
192
|
const alt: Alteration<TPayload> = {
|
|
@@ -277,138 +278,6 @@ export class Scheduler<TPayload extends { type: string }> {
|
|
|
277
278
|
|
|
278
279
|
// ─── Validation ───────────────────────────────────────────────
|
|
279
280
|
|
|
280
|
-
export function validateRule(rule: RecurrenceRule): void {
|
|
281
|
-
switch (rule.kind) {
|
|
282
|
-
case "instant":
|
|
283
|
-
_validateTimeSpan(rule.timeSpan);
|
|
284
|
-
return;
|
|
285
|
-
case "localDateTime":
|
|
286
|
-
if (!Number.isInteger(rule.year)) {
|
|
287
|
-
throw new Error("LocalDateTimeRule.year must be an integer");
|
|
288
|
-
}
|
|
289
|
-
if (rule.month < 0 || rule.month > 11) {
|
|
290
|
-
throw new Error("LocalDateTimeRule.month must be in 0..11");
|
|
291
|
-
}
|
|
292
|
-
if (rule.day < 1 || rule.day > 31) throw new Error("LocalDateTimeRule.day must be in 1..31");
|
|
293
|
-
_validateTimeOfDay(rule.timeOfDay);
|
|
294
|
-
_validateTimeSpan(rule.timeSpan);
|
|
295
|
-
return;
|
|
296
|
-
case "anchored":
|
|
297
|
-
if (rule.intervalMs <= 0) throw new Error("AnchoredRule.intervalMs must be > 0");
|
|
298
|
-
_validateTimeSpan(rule.timeSpan);
|
|
299
|
-
_validateSeries(rule.seriesStart, rule.seriesEnd, rule.count);
|
|
300
|
-
return;
|
|
301
|
-
case "daysOfWeek":
|
|
302
|
-
if (rule.weekInterval <= 0) throw new Error("DaysOfWeekRule.weekInterval must be > 0");
|
|
303
|
-
if (rule.days.length === 0) throw new Error("DaysOfWeekRule.days must be non-empty");
|
|
304
|
-
_validateTimeOfDay(rule.timeOfDay);
|
|
305
|
-
_validateTimeSpan(rule.timeSpan);
|
|
306
|
-
_validateSeries(rule.seriesStart, rule.seriesEnd, rule.count);
|
|
307
|
-
return;
|
|
308
|
-
case "weeksOfMonth":
|
|
309
|
-
if (rule.monthInterval <= 0) throw new Error("WeeksOfMonthRule.monthInterval must be > 0");
|
|
310
|
-
if (rule.weeks.length === 0) throw new Error("WeeksOfMonthRule.weeks must be non-empty");
|
|
311
|
-
for (const w of rule.weeks) {
|
|
312
|
-
if (w === 0 || w < -5 || w > 5) {
|
|
313
|
-
throw new Error("WeeksOfMonthRule.weeks entries must be in [-5..-1] or [1..5]");
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
if (rule.days.length === 0) throw new Error("WeeksOfMonthRule.days must be non-empty");
|
|
317
|
-
_validateTimeOfDay(rule.timeOfDay);
|
|
318
|
-
_validateTimeSpan(rule.timeSpan);
|
|
319
|
-
_validateSeries(rule.seriesStart, rule.seriesEnd, rule.count);
|
|
320
|
-
return;
|
|
321
|
-
case "monthsOfYear":
|
|
322
|
-
if (rule.yearInterval <= 0) throw new Error("MonthsOfYearRule.yearInterval must be > 0");
|
|
323
|
-
if (rule.months.length === 0) throw new Error("MonthsOfYearRule.months must be non-empty");
|
|
324
|
-
for (const m of rule.months) {
|
|
325
|
-
if (m < 0 || m > 11) {
|
|
326
|
-
throw new Error("MonthsOfYearRule.months entries must be in 0..11");
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
if (rule.weeks.length === 0) throw new Error("MonthsOfYearRule.weeks must be non-empty");
|
|
330
|
-
for (const w of rule.weeks) {
|
|
331
|
-
if (w === 0 || w < -5 || w > 5) {
|
|
332
|
-
throw new Error("MonthsOfYearRule.weeks entries must be in [-5..-1] or [1..5]");
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
if (rule.days.length === 0) throw new Error("MonthsOfYearRule.days must be non-empty");
|
|
336
|
-
_validateTimeOfDay(rule.timeOfDay);
|
|
337
|
-
_validateTimeSpan(rule.timeSpan);
|
|
338
|
-
_validateSeries(rule.seriesStart, rule.seriesEnd, rule.count);
|
|
339
|
-
return;
|
|
340
|
-
case "yearly":
|
|
341
|
-
if (rule.yearInterval <= 0) throw new Error("YearlyRule.yearInterval must be > 0");
|
|
342
|
-
if (rule.month < 0 || rule.month > 11) {
|
|
343
|
-
throw new Error("YearlyRule.month must be in 0..11");
|
|
344
|
-
}
|
|
345
|
-
if (rule.day < 1 || rule.day > 31) throw new Error("YearlyRule.day must be in 1..31");
|
|
346
|
-
_validateTimeOfDay(rule.timeOfDay);
|
|
347
|
-
_validateTimeSpan(rule.timeSpan);
|
|
348
|
-
_validateSeries(rule.seriesStart, rule.seriesEnd, rule.count);
|
|
349
|
-
return;
|
|
350
|
-
case "daysOfMonth":
|
|
351
|
-
if (rule.yearInterval <= 0) throw new Error("DaysOfMonthRule.yearInterval must be > 0");
|
|
352
|
-
if (rule.months.length === 0) throw new Error("DaysOfMonthRule.months must be non-empty");
|
|
353
|
-
for (const m of rule.months) {
|
|
354
|
-
if (m < 0 || m > 11) {
|
|
355
|
-
throw new Error("DaysOfMonthRule.months entries must be in 0..11");
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
if (rule.daysOfMonth.length === 0) {
|
|
359
|
-
throw new Error("DaysOfMonthRule.daysOfMonth must be non-empty");
|
|
360
|
-
}
|
|
361
|
-
for (const d of rule.daysOfMonth) {
|
|
362
|
-
if (d < 1 || d > 31) {
|
|
363
|
-
throw new Error("DaysOfMonthRule.daysOfMonth entries must be in 1..31");
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
_validateTimeOfDay(rule.timeOfDay);
|
|
367
|
-
_validateTimeSpan(rule.timeSpan);
|
|
368
|
-
_validateSeries(rule.seriesStart, rule.seriesEnd, rule.count);
|
|
369
|
-
return;
|
|
370
|
-
case "daysOfYear":
|
|
371
|
-
if (rule.yearInterval <= 0) throw new Error("DaysOfYearRule.yearInterval must be > 0");
|
|
372
|
-
if (rule.daysOfYear.length === 0) {
|
|
373
|
-
throw new Error("DaysOfYearRule.daysOfYear must be non-empty");
|
|
374
|
-
}
|
|
375
|
-
for (const d of rule.daysOfYear) {
|
|
376
|
-
if (d < 1 || d > 366) {
|
|
377
|
-
throw new Error("DaysOfYearRule.daysOfYear entries must be in 1..366");
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
_validateTimeOfDay(rule.timeOfDay);
|
|
381
|
-
_validateTimeSpan(rule.timeSpan);
|
|
382
|
-
_validateSeries(rule.seriesStart, rule.seriesEnd, rule.count);
|
|
383
|
-
return;
|
|
384
|
-
default: {
|
|
385
|
-
const _exhaustive: never = rule;
|
|
386
|
-
throw new Error(`Unknown rule kind: ${JSON.stringify(_exhaustive)}`);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Validates the TimeSpan shape. Throws on { type: "duration", ms < 1 } or
|
|
392
|
-
// { type: "hourSpan", hours <= 0 }. Undefined is valid (defaults to 1ms).
|
|
393
|
-
function _validateTimeSpan(span: TimeSpan | undefined): void {
|
|
394
|
-
if (span == null) return;
|
|
395
|
-
if (span.type === "duration") {
|
|
396
|
-
if (span.ms < 1) {
|
|
397
|
-
throw new Error(`timeSpan.ms must be >= 1 if supplied (got ${span.ms})`);
|
|
398
|
-
}
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
if (span.type === "hourSpan") {
|
|
402
|
-
if (!Number.isFinite(span.hours) || span.hours <= 0) {
|
|
403
|
-
throw new Error(`timeSpan.hours must be > 0 (got ${span.hours})`);
|
|
404
|
-
}
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
// Exhaustiveness — TS will flag a missing case if TimeSpan grows.
|
|
408
|
-
const _exhaustive: never = span;
|
|
409
|
-
throw new Error(`Unknown TimeSpan type: ${JSON.stringify(_exhaustive)}`);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
281
|
// Returns a copy of `rule` with timeSpan defaulted when omitted, so
|
|
413
282
|
// generators don't have to handle undefined repeatedly.
|
|
414
283
|
function _normalizeRule(rule: RecurrenceRule): RecurrenceRule {
|
|
@@ -442,21 +311,6 @@ function _validateDisambiguation(d: Disambiguation | undefined): void {
|
|
|
442
311
|
}
|
|
443
312
|
}
|
|
444
313
|
|
|
445
|
-
function _validateTimeOfDay(ms: number): void {
|
|
446
|
-
if (ms < 0 || ms >= 24 * 60 * 60 * 1000) {
|
|
447
|
-
throw new Error(`timeOfDay must be in [0, 86_400_000) (got ${ms})`);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function _validateSeries(
|
|
452
|
-
start: number,
|
|
453
|
-
end: number | null,
|
|
454
|
-
count: number | null | undefined
|
|
455
|
-
): void {
|
|
456
|
-
if (end != null && end <= start) throw new Error("seriesEnd must be > seriesStart");
|
|
457
|
-
if (count != null && count < 0) throw new Error("count must be >= 0");
|
|
458
|
-
}
|
|
459
|
-
|
|
460
314
|
function _rulesEqual(a: RecurrenceRule, b: RecurrenceRule): boolean {
|
|
461
315
|
return JSON.stringify(_canonicalize(a)) === JSON.stringify(_canonicalize(b));
|
|
462
316
|
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { RecurrenceRule, TimeSpan } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// ─── Rule validation ──────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// A rule often comes from outside the type system: JSON in a database, a
|
|
6
|
+
// request body, or an LLM tool call. So validation checks the WHOLE shape —
|
|
7
|
+
// every required field present, of the right type, in range, and no fields
|
|
8
|
+
// the kind does not have — and throws an Error that names the field and the
|
|
9
|
+
// form it expects. A bad rule never reaches a generator, where it would crash
|
|
10
|
+
// with "Cannot read properties of undefined" or "Invalid time value".
|
|
11
|
+
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
const DAYS_HINT = "Day numbers 0..6 (0=Sunday, 1=Monday … 6=Saturday)";
|
|
15
|
+
const MONTHS_HINT = "Month numbers 0..11 (0=January … 11=December)";
|
|
16
|
+
const WEEKS_HINT = "week-of-month numbers 1..5 (1=first) or -1..-5 (-1=last)";
|
|
17
|
+
const TIME_OF_DAY_HINT = "ms after local midnight, 0..86_399_999 (7:00 = 25_200_000)";
|
|
18
|
+
const TIMESTAMP_HINT = "a UTC timestamp in ms";
|
|
19
|
+
|
|
20
|
+
// One field of a rule kind: whether it must be there, and how to check it.
|
|
21
|
+
interface FieldSpec {
|
|
22
|
+
required: boolean;
|
|
23
|
+
check: (value: unknown, field: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The common series fields every repeating kind has.
|
|
27
|
+
const SERIES: Record<string, FieldSpec> = {
|
|
28
|
+
timeSpan: { required: false, check: (v) => validateTimeSpan(v) },
|
|
29
|
+
seriesStart: { required: true, check: timestamp },
|
|
30
|
+
seriesEnd: { required: true, check: nullable(timestamp, "null (no end)") },
|
|
31
|
+
count: {
|
|
32
|
+
required: false,
|
|
33
|
+
check: nullable(int(0, Number.MAX_SAFE_INTEGER, "a whole number >= 0"), "null"),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const TIME_OF_DAY: FieldSpec = { required: true, check: int(0, DAY_MS - 1, TIME_OF_DAY_HINT) };
|
|
38
|
+
|
|
39
|
+
const KINDS: Record<RecurrenceRule["kind"], { name: string; fields: Record<string, FieldSpec> }> = {
|
|
40
|
+
instant: {
|
|
41
|
+
name: "InstantRule",
|
|
42
|
+
fields: {
|
|
43
|
+
startOn: { required: true, check: timestamp },
|
|
44
|
+
timeSpan: { required: false, check: (v) => validateTimeSpan(v) },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
localDateTime: {
|
|
48
|
+
name: "LocalDateTimeRule",
|
|
49
|
+
fields: {
|
|
50
|
+
year: { required: true, check: int(1, 9999, "a year such as 2026") },
|
|
51
|
+
month: { required: true, check: int(0, 11, MONTHS_HINT) },
|
|
52
|
+
day: { required: true, check: int(1, 31, "a day of the month 1..31") },
|
|
53
|
+
timeOfDay: TIME_OF_DAY,
|
|
54
|
+
timeSpan: { required: false, check: (v) => validateTimeSpan(v) },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
anchored: {
|
|
58
|
+
name: "AnchoredRule",
|
|
59
|
+
fields: {
|
|
60
|
+
anchorDate: { required: true, check: timestamp },
|
|
61
|
+
intervalMs: { required: true, check: positive("an interval in ms > 0") },
|
|
62
|
+
...SERIES,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
daysOfWeek: {
|
|
66
|
+
name: "DaysOfWeekRule",
|
|
67
|
+
fields: {
|
|
68
|
+
days: { required: true, check: intList(0, 6, DAYS_HINT) },
|
|
69
|
+
weekInterval: {
|
|
70
|
+
required: true,
|
|
71
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every week)"),
|
|
72
|
+
},
|
|
73
|
+
timeOfDay: TIME_OF_DAY,
|
|
74
|
+
...SERIES,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
weeksOfMonth: {
|
|
78
|
+
name: "WeeksOfMonthRule",
|
|
79
|
+
fields: {
|
|
80
|
+
days: { required: true, check: intList(0, 6, DAYS_HINT) },
|
|
81
|
+
weeks: { required: true, check: weekList },
|
|
82
|
+
monthInterval: {
|
|
83
|
+
required: true,
|
|
84
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every month)"),
|
|
85
|
+
},
|
|
86
|
+
timeOfDay: TIME_OF_DAY,
|
|
87
|
+
...SERIES,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
monthsOfYear: {
|
|
91
|
+
name: "MonthsOfYearRule",
|
|
92
|
+
fields: {
|
|
93
|
+
months: { required: true, check: intList(0, 11, MONTHS_HINT) },
|
|
94
|
+
weeks: { required: true, check: weekList },
|
|
95
|
+
days: { required: true, check: intList(0, 6, DAYS_HINT) },
|
|
96
|
+
yearInterval: {
|
|
97
|
+
required: true,
|
|
98
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every year)"),
|
|
99
|
+
},
|
|
100
|
+
timeOfDay: TIME_OF_DAY,
|
|
101
|
+
...SERIES,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
yearly: {
|
|
105
|
+
name: "YearlyRule",
|
|
106
|
+
fields: {
|
|
107
|
+
month: { required: true, check: int(0, 11, MONTHS_HINT) },
|
|
108
|
+
day: { required: true, check: int(1, 31, "a day of the month 1..31") },
|
|
109
|
+
yearInterval: {
|
|
110
|
+
required: true,
|
|
111
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every year)"),
|
|
112
|
+
},
|
|
113
|
+
timeOfDay: TIME_OF_DAY,
|
|
114
|
+
...SERIES,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
daysOfMonth: {
|
|
118
|
+
name: "DaysOfMonthRule",
|
|
119
|
+
fields: {
|
|
120
|
+
months: { required: true, check: intList(0, 11, MONTHS_HINT) },
|
|
121
|
+
daysOfMonth: { required: true, check: intList(1, 31, "days of the month 1..31") },
|
|
122
|
+
yearInterval: {
|
|
123
|
+
required: true,
|
|
124
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every year)"),
|
|
125
|
+
},
|
|
126
|
+
timeOfDay: TIME_OF_DAY,
|
|
127
|
+
...SERIES,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
daysOfYear: {
|
|
131
|
+
name: "DaysOfYearRule",
|
|
132
|
+
fields: {
|
|
133
|
+
daysOfYear: { required: true, check: intList(1, 366, "days of the year 1..366") },
|
|
134
|
+
yearInterval: {
|
|
135
|
+
required: true,
|
|
136
|
+
check: int(1, Number.MAX_SAFE_INTEGER, "a whole number >= 1 (1 = every year)"),
|
|
137
|
+
},
|
|
138
|
+
timeOfDay: TIME_OF_DAY,
|
|
139
|
+
...SERIES,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// The rule kinds, in the order the types declare them.
|
|
145
|
+
export const RULE_KINDS = Object.keys(KINDS) as RecurrenceRule["kind"][];
|
|
146
|
+
|
|
147
|
+
// Throws when `rule` is not a valid RecurrenceRule. The message names the
|
|
148
|
+
// first wrong field and the form it expects.
|
|
149
|
+
export function validateRule(rule: RecurrenceRule): void {
|
|
150
|
+
const raw: unknown = rule;
|
|
151
|
+
|
|
152
|
+
if (typeof raw !== "object" || raw == null || Array.isArray(raw)) {
|
|
153
|
+
throw new Error(`A rule must be an object with a "kind" (one of ${RULE_KINDS.join(", ")})`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const obj = raw as Record<string, unknown>;
|
|
157
|
+
const kind = KINDS[obj.kind as RecurrenceRule["kind"]];
|
|
158
|
+
|
|
159
|
+
if (kind == null) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Unknown rule kind ${JSON.stringify(obj.kind)} (expected one of ${RULE_KINDS.join(", ")})`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const known = Object.keys(kind.fields);
|
|
166
|
+
|
|
167
|
+
for (const key of Object.keys(obj)) {
|
|
168
|
+
if (key !== "kind" && !known.includes(key)) {
|
|
169
|
+
throw new Error(`${kind.name} has no field "${key}" (its fields are: ${known.join(", ")})`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const [field, spec] of Object.entries(kind.fields)) {
|
|
174
|
+
const label = `${kind.name}.${field}`;
|
|
175
|
+
|
|
176
|
+
if (obj[field] === undefined) {
|
|
177
|
+
if (spec.required) throw new Error(`${label} is required: ${describe(spec, label)}`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
spec.check(obj[field], label);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const seriesStart = obj.seriesStart as number | undefined;
|
|
185
|
+
const seriesEnd = obj.seriesEnd as number | null | undefined;
|
|
186
|
+
|
|
187
|
+
if (seriesStart != null && seriesEnd != null && seriesEnd <= seriesStart) {
|
|
188
|
+
throw new Error(`${kind.name}.seriesEnd must be > seriesStart`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Throws when `span` is not a TimeSpan. Undefined is valid (the rule's
|
|
193
|
+
// default span applies).
|
|
194
|
+
export function validateTimeSpan(span: unknown): asserts span is TimeSpan | undefined {
|
|
195
|
+
if (span === undefined) return;
|
|
196
|
+
|
|
197
|
+
const form =
|
|
198
|
+
'{ "type": "duration", "ms": <ms >= 1> } or { "type": "hourSpan", "hours": <hours > 0> }';
|
|
199
|
+
|
|
200
|
+
if (typeof span !== "object" || span == null) {
|
|
201
|
+
throw new Error(`timeSpan must be ${form}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const s = span as Record<string, unknown>;
|
|
205
|
+
|
|
206
|
+
if (s.type === "duration") {
|
|
207
|
+
if (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 1) {
|
|
208
|
+
throw new Error(`timeSpan.ms must be >= 1 if supplied (got ${String(s.ms)})`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (s.type === "hourSpan") {
|
|
215
|
+
if (typeof s.hours !== "number" || !Number.isFinite(s.hours) || s.hours <= 0) {
|
|
216
|
+
throw new Error(`timeSpan.hours must be > 0 (got ${String(s.hours)})`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
throw new Error(`timeSpan must be ${form} (got type ${JSON.stringify(s.type)})`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Field checks ─────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
// What the field should hold, for "is required" messages: each check is
|
|
228
|
+
// called on undefined and its message reused.
|
|
229
|
+
function describe(spec: FieldSpec, label: string): string {
|
|
230
|
+
try {
|
|
231
|
+
spec.check(undefined, label);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
234
|
+
const at = msg.indexOf(" must be ");
|
|
235
|
+
|
|
236
|
+
if (at >= 0) return msg.slice(at + " must be ".length).replace(/ \(got [^)]*\)$/, "");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return "see the rule's type";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function show(v: unknown): string {
|
|
243
|
+
return v === undefined ? "nothing" : JSON.stringify(v);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isInt(v: unknown): v is number {
|
|
247
|
+
return typeof v === "number" && Number.isInteger(v);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function int(min: number, max: number, hint: string) {
|
|
251
|
+
return (v: unknown, field: string): void => {
|
|
252
|
+
if (!isInt(v) || v < min || v > max)
|
|
253
|
+
throw new Error(`${field} must be ${hint} (got ${show(v)})`);
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function positive(hint: string) {
|
|
258
|
+
return (v: unknown, field: string): void => {
|
|
259
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
|
|
260
|
+
throw new Error(`${field} must be ${hint} (got ${show(v)})`);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function timestamp(v: unknown, field: string): void {
|
|
266
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
267
|
+
throw new Error(`${field} must be ${TIMESTAMP_HINT} (got ${show(v)})`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function nullable(check: (v: unknown, field: string) => void, nullHint: string) {
|
|
272
|
+
return (v: unknown, field: string): void => {
|
|
273
|
+
if (v === null) return;
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
check(v, field);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
279
|
+
|
|
280
|
+
throw new Error(msg.replace(/ \(got /, ` or ${nullHint} (got `));
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function intList(min: number, max: number, hint: string) {
|
|
286
|
+
return (v: unknown, field: string): void => {
|
|
287
|
+
const form = `a non-empty array of ${hint}`;
|
|
288
|
+
|
|
289
|
+
if (!Array.isArray(v) || v.length === 0)
|
|
290
|
+
throw new Error(`${field} must be ${form} (got ${show(v)})`);
|
|
291
|
+
|
|
292
|
+
for (const x of v) {
|
|
293
|
+
if (!isInt(x) || x < min || x > max) {
|
|
294
|
+
throw new Error(`${field} must be ${form} (got ${show(v)}; ${show(x)} is out of range)`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function weekList(v: unknown, field: string): void {
|
|
301
|
+
const form = `a non-empty array of ${WEEKS_HINT}`;
|
|
302
|
+
|
|
303
|
+
if (!Array.isArray(v) || v.length === 0)
|
|
304
|
+
throw new Error(`${field} must be ${form} (got ${show(v)})`);
|
|
305
|
+
|
|
306
|
+
for (const w of v) {
|
|
307
|
+
if (!isInt(w) || w === 0 || w < -5 || w > 5) {
|
|
308
|
+
throw new Error(`${field} must be ${form} (got ${show(v)}; ${show(w)} is out of range)`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|