@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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -2
  3. package/dist/alterations.d.ts +6 -0
  4. package/dist/alterations.d.ts.map +1 -0
  5. package/dist/alterations.js +61 -0
  6. package/dist/alterations.js.map +1 -0
  7. package/dist/cjs/alterations.d.ts +5 -0
  8. package/dist/cjs/alterations.js +96 -0
  9. package/dist/cjs/dispatcher.d.ts +38 -0
  10. package/dist/cjs/dispatcher.js +165 -0
  11. package/dist/cjs/dispatcher_store.d.ts +9 -0
  12. package/dist/cjs/dispatcher_store.js +15 -0
  13. package/dist/cjs/generators/anchored.d.ts +3 -0
  14. package/dist/cjs/generators/anchored.js +57 -0
  15. package/dist/cjs/generators/days_of_month.d.ts +3 -0
  16. package/dist/cjs/generators/days_of_month.js +76 -0
  17. package/dist/cjs/generators/days_of_week.d.ts +5 -0
  18. package/dist/cjs/generators/days_of_week.js +65 -0
  19. package/dist/cjs/generators/days_of_year.d.ts +3 -0
  20. package/dist/cjs/generators/days_of_year.js +70 -0
  21. package/dist/cjs/generators/instant.d.ts +3 -0
  22. package/dist/cjs/generators/instant.js +15 -0
  23. package/dist/cjs/generators/local_date_time.d.ts +3 -0
  24. package/dist/cjs/generators/local_date_time.js +31 -0
  25. package/dist/cjs/generators/months_of_year.d.ts +3 -0
  26. package/dist/cjs/generators/months_of_year.js +109 -0
  27. package/dist/cjs/generators/types.d.ts +9 -0
  28. package/dist/cjs/generators/types.js +19 -0
  29. package/dist/cjs/generators/weeks_of_month.d.ts +3 -0
  30. package/dist/cjs/generators/weeks_of_month.js +99 -0
  31. package/dist/cjs/generators/yearly.d.ts +3 -0
  32. package/dist/cjs/generators/yearly.js +52 -0
  33. package/dist/cjs/in_memory_port.d.ts +18 -0
  34. package/dist/cjs/in_memory_port.js +59 -0
  35. package/dist/cjs/index.d.ts +7 -0
  36. package/dist/cjs/index.js +18 -0
  37. package/dist/cjs/occurrence_key.d.ts +5 -0
  38. package/dist/cjs/occurrence_key.js +21 -0
  39. package/dist/cjs/package.json +3 -0
  40. package/dist/cjs/port.d.ts +15 -0
  41. package/dist/cjs/port.js +2 -0
  42. package/dist/cjs/scheduler.d.ts +23 -0
  43. package/dist/cjs/scheduler.js +302 -0
  44. package/dist/cjs/types.d.ts +149 -0
  45. package/dist/cjs/types.js +58 -0
  46. package/dist/cjs/tz.d.ts +22 -0
  47. package/dist/cjs/tz.js +197 -0
  48. package/dist/cjs/validate_rule.d.ts +4 -0
  49. package/dist/cjs/validate_rule.js +267 -0
  50. package/dist/dispatcher.d.ts +39 -0
  51. package/dist/dispatcher.d.ts.map +1 -0
  52. package/dist/dispatcher.js +111 -0
  53. package/dist/dispatcher.js.map +1 -0
  54. package/dist/dispatcher_store.d.ts +10 -0
  55. package/dist/dispatcher_store.d.ts.map +1 -0
  56. package/dist/dispatcher_store.js +13 -0
  57. package/dist/dispatcher_store.js.map +1 -0
  58. package/dist/generators/anchored.d.ts +4 -0
  59. package/dist/generators/anchored.d.ts.map +1 -0
  60. package/dist/generators/anchored.js +42 -0
  61. package/dist/generators/anchored.js.map +1 -0
  62. package/dist/generators/days_of_month.d.ts +4 -0
  63. package/dist/generators/days_of_month.d.ts.map +1 -0
  64. package/dist/generators/days_of_month.js +53 -0
  65. package/dist/generators/days_of_month.js.map +1 -0
  66. package/dist/generators/days_of_week.d.ts +6 -0
  67. package/dist/generators/days_of_week.d.ts.map +1 -0
  68. package/dist/generators/days_of_week.js +43 -0
  69. package/dist/generators/days_of_week.js.map +1 -0
  70. package/dist/generators/days_of_year.d.ts +4 -0
  71. package/dist/generators/days_of_year.d.ts.map +1 -0
  72. package/dist/generators/days_of_year.js +50 -0
  73. package/dist/generators/days_of_year.js.map +1 -0
  74. package/dist/generators/instant.d.ts +4 -0
  75. package/dist/generators/instant.d.ts.map +1 -0
  76. package/dist/generators/instant.js +15 -0
  77. package/dist/generators/instant.js.map +1 -0
  78. package/dist/generators/local_date_time.d.ts +4 -0
  79. package/dist/generators/local_date_time.d.ts.map +1 -0
  80. package/dist/generators/local_date_time.js +20 -0
  81. package/dist/generators/local_date_time.js.map +1 -0
  82. package/dist/generators/months_of_year.d.ts +4 -0
  83. package/dist/generators/months_of_year.d.ts.map +1 -0
  84. package/dist/generators/months_of_year.js +64 -0
  85. package/dist/generators/months_of_year.js.map +1 -0
  86. package/dist/generators/types.d.ts +10 -0
  87. package/dist/generators/types.d.ts.map +1 -0
  88. package/dist/generators/types.js +14 -0
  89. package/dist/generators/types.js.map +1 -0
  90. package/dist/generators/weeks_of_month.d.ts +4 -0
  91. package/dist/generators/weeks_of_month.d.ts.map +1 -0
  92. package/dist/generators/weeks_of_month.js +61 -0
  93. package/dist/generators/weeks_of_month.js.map +1 -0
  94. package/dist/generators/yearly.d.ts +4 -0
  95. package/dist/generators/yearly.d.ts.map +1 -0
  96. package/dist/generators/yearly.js +37 -0
  97. package/dist/generators/yearly.js.map +1 -0
  98. package/dist/in_memory_port.d.ts +19 -0
  99. package/dist/in_memory_port.d.ts.map +1 -0
  100. package/dist/in_memory_port.js +45 -0
  101. package/dist/in_memory_port.js.map +1 -0
  102. package/dist/index.d.ts +8 -0
  103. package/dist/index.d.ts.map +1 -0
  104. package/dist/index.js +7 -0
  105. package/dist/occurrence_key.d.ts +6 -0
  106. package/dist/occurrence_key.d.ts.map +1 -0
  107. package/dist/occurrence_key.js +25 -0
  108. package/dist/occurrence_key.js.map +1 -0
  109. package/dist/port.d.ts +16 -0
  110. package/dist/port.d.ts.map +1 -0
  111. package/dist/scheduler.d.ts +24 -0
  112. package/dist/scheduler.d.ts.map +1 -0
  113. package/dist/scheduler.js +241 -0
  114. package/dist/scheduler.js.map +1 -0
  115. package/dist/types.d.ts +150 -0
  116. package/dist/types.d.ts.map +1 -0
  117. package/dist/types.js +29 -0
  118. package/dist/types.js.map +1 -0
  119. package/dist/tz.d.ts +23 -0
  120. package/dist/tz.d.ts.map +1 -0
  121. package/dist/tz.js +131 -0
  122. package/dist/tz.js.map +1 -0
  123. package/dist/validate_rule.d.ts +5 -0
  124. package/dist/validate_rule.d.ts.map +1 -0
  125. package/dist/validate_rule.js +280 -0
  126. package/dist/validate_rule.js.map +1 -0
  127. package/package.json +76 -4
  128. package/src/__tests__/alterations.test.ts +177 -0
  129. package/src/__tests__/anchored.test.ts +107 -0
  130. package/src/__tests__/days_of_month.test.ts +162 -0
  131. package/src/__tests__/days_of_week.test.ts +134 -0
  132. package/src/__tests__/days_of_year.test.ts +158 -0
  133. package/src/__tests__/dispatcher.test.ts +317 -0
  134. package/src/__tests__/instant.test.ts +50 -0
  135. package/src/__tests__/local_date_time.test.ts +139 -0
  136. package/src/__tests__/months_of_year.test.ts +229 -0
  137. package/src/__tests__/scheduler.test.ts +888 -0
  138. package/src/__tests__/tz.test.ts +201 -0
  139. package/src/__tests__/validate_rule.test.ts +190 -0
  140. package/src/__tests__/weeks_of_month.test.ts +195 -0
  141. package/src/__tests__/yearly.test.ts +108 -0
  142. package/src/alterations.ts +108 -0
  143. package/src/dispatcher.ts +184 -0
  144. package/src/dispatcher_store.ts +26 -0
  145. package/src/generators/anchored.ts +69 -0
  146. package/src/generators/days_of_month.ts +100 -0
  147. package/src/generators/days_of_week.ts +101 -0
  148. package/src/generators/days_of_year.ts +88 -0
  149. package/src/generators/instant.ts +18 -0
  150. package/src/generators/local_date_time.ts +38 -0
  151. package/src/generators/months_of_year.ts +143 -0
  152. package/src/generators/types.ts +26 -0
  153. package/src/generators/weeks_of_month.ts +133 -0
  154. package/src/generators/yearly.ts +73 -0
  155. package/src/in_memory_port.ts +70 -0
  156. package/src/index.ts +25 -0
  157. package/src/occurrence_key.ts +42 -0
  158. package/src/port.ts +31 -0
  159. package/src/scheduler.ts +353 -0
  160. package/src/types.ts +369 -0
  161. package/src/tz.ts +246 -0
  162. package/src/validate_rule.ts +311 -0
  163. package/tsconfig.json +25 -0
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noUnusedLocals": true,
10
+ "noUnusedParameters": true,
11
+ "noFallthroughCasesInSwitch": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "isolatedModules": true,
16
+ "resolveJsonModule": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "outDir": "./dist",
21
+ "rootDir": "./src"
22
+ },
23
+ "include": ["src/**/*"],
24
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/__tests__/**"]
25
+ }