@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jared Barnes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @j13b/scheduler
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A timezone-aware recurrence and dispatch engine. Pure-data rule DSL, IANA timezone support, DST-aware generators, and a cron-style dispatcher.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @j13b/scheduler
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
Scheduler,
|
|
16
|
+
InMemorySchedulerPort,
|
|
17
|
+
Dispatcher,
|
|
18
|
+
InMemoryDispatcherStore,
|
|
19
|
+
Day,
|
|
20
|
+
} from "@j13b/scheduler";
|
|
21
|
+
|
|
22
|
+
const port = new InMemorySchedulerPort();
|
|
23
|
+
const scheduler = new Scheduler(port);
|
|
24
|
+
|
|
25
|
+
await scheduler.add({
|
|
26
|
+
id: "standup",
|
|
27
|
+
timezone: "America/Denver",
|
|
28
|
+
rule: {
|
|
29
|
+
kind: "daysOfWeek",
|
|
30
|
+
days: [Day.Monday, Day.Tuesday, Day.Wednesday, Day.Thursday, Day.Friday],
|
|
31
|
+
timeOfDay: 9 * 60 * 60 * 1000, // 9:00 wall-clock
|
|
32
|
+
weekInterval: 1,
|
|
33
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
34
|
+
seriesEnd: null, // open-ended
|
|
35
|
+
},
|
|
36
|
+
payload: { type: "standup" },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const dispatcher = new Dispatcher({
|
|
40
|
+
scheduler,
|
|
41
|
+
store: new InMemoryDispatcherStore(),
|
|
42
|
+
fire: async (event) => {
|
|
43
|
+
console.log(`Fire ${event.templateId} at ${new Date(event.startTimestamp).toISOString()}`);
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await dispatcher.start();
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## What's in the box
|
|
51
|
+
|
|
52
|
+
- **Recurrence rules** — `InstantRule`, `LocalDateTimeRule`, `AnchoredRule`, `DaysOfWeekRule`, `WeeksOfMonthRule`, `MonthsOfYearRule`, `YearlyRule`, `DaysOfMonthRule`, `DaysOfYearRule`. Pure-data, JSON-serializable, exhaustive TypeScript union. The two single-occurrence kinds: `InstantRule` fires at an absolute UTC instant (timezone-independent); `LocalDateTimeRule` fires once at a wall-clock date/time resolved (DST-aware) in the template's timezone.
|
|
53
|
+
- **TimeSpan** — two flavors of "how long does an occurrence last":
|
|
54
|
+
- `{ type: "duration", ms }` — fixed real-elapsed time (payroll-correct, ignores DST jumps).
|
|
55
|
+
- `{ type: "hourSpan", hours }` — wall-clock hours (calendar-correct, flexes with DST).
|
|
56
|
+
- **Timezone math** — built on `Intl.DateTimeFormat` over IANA zones, zero deps for date logic. Spring-forward gaps and fall-back overlaps are resolved by a configurable per-template `disambiguation` policy (`"compatible"` default, plus `"earlier"`, `"later"`, `"reject"`, and `"skip"`), mirroring the Temporal proposal.
|
|
57
|
+
- **Alterations** — per-occurrence overrides (skip, reschedule, replace).
|
|
58
|
+
- **Dispatcher** — drives a Scheduler in real time; catch-up pass for missed events, resumes early when the schedule changes.
|
|
59
|
+
|
|
60
|
+
## API surface
|
|
61
|
+
|
|
62
|
+
See [`src/index.ts`](./src/index.ts) for the full export list. Notable types:
|
|
63
|
+
|
|
64
|
+
- `RecurrenceRule` — discriminated union over the nine rule kinds.
|
|
65
|
+
- `ScheduleTemplate` — `{ id, rule, timezone, disambiguation?, payload }`. `timezone` is required and validated at write time (use `"UTC"` for zone-free scheduling; it's ignored by the absolute `instant`/`anchored` rules).
|
|
66
|
+
- `validateRule(rule)` — checks a rule's whole shape (every required field present, typed and in range; no fields its kind lacks) and throws an `Error` naming the field and the form it expects. `Scheduler.add`/`update` run it, so a rule from JSON or an LLM fails with a clear message instead of crashing a generator. `validateTimeSpan` and `RULE_KINDS` are exported too.
|
|
67
|
+
- `ScheduledEvent` — `{ templateId, occurrenceKey, startTimestamp, endTimestamp, payload, alterationId? }`.
|
|
68
|
+
- `Disambiguation` — DST-edge resolution policy: `"compatible" | "earlier" | "later" | "reject" | "skip"`.
|
|
69
|
+
- `SchedulerPort` — async storage interface; an `InMemorySchedulerPort` ships with the package.
|
|
70
|
+
- `DispatcherStore` — durable record of the dispatcher's catch-up cursor.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { KeyedOccurrence } from './occurrence_key.js';
|
|
2
|
+
import { Alteration, ScheduledEvent } from './types.js';
|
|
3
|
+
export declare function applyAlterations<TPayload extends {
|
|
4
|
+
type: string;
|
|
5
|
+
}>(templateId: string, basePayload: TPayload, keyedOccurrences: readonly KeyedOccurrence[], alterations: readonly Alteration<TPayload>[], tz: string | undefined, windowStart: number, windowEnd: number): ScheduledEvent<TPayload>[];
|
|
6
|
+
//# sourceMappingURL=alterations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"alterations.d.ts","sourceRoot":"","sources":["../src/alterations.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAmB7D,wBAAgB,gBAAgB,CAAC,QAAQ,SAAS;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,EAChE,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,QAAQ,EACrB,gBAAgB,EAAE,SAAS,eAAe,EAAE,EAC5C,WAAW,EAAE,SAAS,UAAU,CAAC,QAAQ,CAAC,EAAE,EAC5C,EAAE,EAAE,MAAM,GAAG,SAAS,EACtB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,cAAc,CAAC,QAAQ,CAAC,EAAE,CAsE5B"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { intersects } from "./generators/types.js";
|
|
2
|
+
import { computeSpanEnd } from "./tz.js";
|
|
3
|
+
// src/alterations.ts
|
|
4
|
+
function applyAlterations(templateId, basePayload, keyedOccurrences, alterations, tz, windowStart, windowEnd) {
|
|
5
|
+
const altByKey = /* @__PURE__ */ new Map();
|
|
6
|
+
for (const alt of alterations) if (alt.templateId === templateId) altByKey.set(alt.occurrenceKey, alt);
|
|
7
|
+
const out = [];
|
|
8
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
9
|
+
for (const occ of keyedOccurrences) {
|
|
10
|
+
seenKeys.add(occ.occurrenceKey);
|
|
11
|
+
const alt = altByKey.get(occ.occurrenceKey);
|
|
12
|
+
if (alt == null) {
|
|
13
|
+
out.push({
|
|
14
|
+
templateId,
|
|
15
|
+
occurrenceKey: occ.occurrenceKey,
|
|
16
|
+
startTimestamp: occ.startTimestamp,
|
|
17
|
+
endTimestamp: occ.endTimestamp,
|
|
18
|
+
payload: basePayload
|
|
19
|
+
});
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (alt.kind === "cancel") continue;
|
|
23
|
+
const start = alt.startTimestamp ?? occ.startTimestamp;
|
|
24
|
+
const end = alt.timeSpan != null ? computeSpanEnd(start, alt.timeSpan, tz) : start + (occ.endTimestamp - occ.startTimestamp);
|
|
25
|
+
if (!intersects(start, end, windowStart, windowEnd)) continue;
|
|
26
|
+
out.push({
|
|
27
|
+
templateId,
|
|
28
|
+
occurrenceKey: occ.occurrenceKey,
|
|
29
|
+
startTimestamp: start,
|
|
30
|
+
endTimestamp: end,
|
|
31
|
+
payload: _mergePayload(basePayload, alt.payloadOverride),
|
|
32
|
+
alterationId: alt.id
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
for (const alt of altByKey.values()) {
|
|
36
|
+
if (seenKeys.has(alt.occurrenceKey)) continue;
|
|
37
|
+
if (alt.kind !== "reschedule") continue;
|
|
38
|
+
if (alt.startTimestamp == null) continue;
|
|
39
|
+
const end = alt.timeSpan != null ? computeSpanEnd(alt.startTimestamp, alt.timeSpan, tz) : alt.startTimestamp + 1;
|
|
40
|
+
if (!intersects(alt.startTimestamp, end, windowStart, windowEnd)) continue;
|
|
41
|
+
out.push({
|
|
42
|
+
templateId,
|
|
43
|
+
occurrenceKey: alt.occurrenceKey,
|
|
44
|
+
startTimestamp: alt.startTimestamp,
|
|
45
|
+
endTimestamp: end,
|
|
46
|
+
payload: _mergePayload(basePayload, alt.payloadOverride),
|
|
47
|
+
alterationId: alt.id
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return out.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
51
|
+
}
|
|
52
|
+
function _mergePayload(base, override) {
|
|
53
|
+
if (override == null) return base;
|
|
54
|
+
return {
|
|
55
|
+
...base,
|
|
56
|
+
...override
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export { applyAlterations };
|
|
60
|
+
|
|
61
|
+
//# sourceMappingURL=alterations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"alterations.js","names":[],"sources":["../src/alterations.ts"],"sourcesContent":["import { intersects } from \"./generators/types.js\";\nimport type { KeyedOccurrence } from \"./occurrence_key.js\";\nimport type { Alteration, ScheduledEvent } from \"./types.js\";\nimport { computeSpanEnd } from \"./tz.js\";\n\n// Apply a template's alterations to its base occurrences and emit the final\n// ScheduledEvents.\n//\n// Two passes:\n// 1. Walk base occurrences. Cancel suppresses; reschedule replaces (and\n// may move out of window, in which case it's not emitted).\n// 2. Walk reschedules whose original key is NOT in the base list (the\n// original was outside the window) but whose new time IS in the window.\n// Synthesize an event for them. This is what activity-board does in its\n// separate AlterationEventGenerator.\n//\n// `tz` is the template's timezone. Used to interpret hourSpan on the\n// alteration's timeSpan (if any). When the alteration carries no timeSpan,\n// we preserve the original occurrence's duration (end - start in UTC ms).\n//\n// Result is sorted ascending by startTimestamp.\nexport function applyAlterations<TPayload extends { type: string }>(\n templateId: string,\n basePayload: TPayload,\n keyedOccurrences: readonly KeyedOccurrence[],\n alterations: readonly Alteration<TPayload>[],\n tz: string | undefined,\n windowStart: number,\n windowEnd: number\n): ScheduledEvent<TPayload>[] {\n // Index alterations for THIS template by occurrence key. If multiple\n // alterations target the same key, last-write-wins.\n const altByKey = new Map<string, Alteration<TPayload>>();\n for (const alt of alterations) {\n if (alt.templateId === templateId) altByKey.set(alt.occurrenceKey, alt);\n }\n\n const out: ScheduledEvent<TPayload>[] = [];\n const seenKeys = new Set<string>();\n\n for (const occ of keyedOccurrences) {\n seenKeys.add(occ.occurrenceKey);\n const alt = altByKey.get(occ.occurrenceKey);\n\n if (alt == null) {\n out.push({\n templateId,\n occurrenceKey: occ.occurrenceKey,\n startTimestamp: occ.startTimestamp,\n endTimestamp: occ.endTimestamp,\n payload: basePayload,\n });\n continue;\n }\n if (alt.kind === \"cancel\") continue;\n\n // reschedule\n const start = alt.startTimestamp ?? occ.startTimestamp;\n // If the alteration specifies a timeSpan, use it (with tz semantics).\n // Otherwise preserve the original UTC duration.\n const end =\n alt.timeSpan != null\n ? computeSpanEnd(start, alt.timeSpan, tz)\n : start + (occ.endTimestamp - occ.startTimestamp);\n if (!intersects(start, end, windowStart, windowEnd)) continue;\n out.push({\n templateId,\n occurrenceKey: occ.occurrenceKey,\n startTimestamp: start,\n endTimestamp: end,\n payload: _mergePayload(basePayload, alt.payloadOverride),\n alterationId: alt.id,\n });\n }\n\n // Orphan reschedules: alteration's occurrenceKey isn't in the base list\n // (because the original fell outside the window) but the rescheduled time\n // is. Cancels for orphans are no-ops — there was nothing to cancel here.\n for (const alt of altByKey.values()) {\n if (seenKeys.has(alt.occurrenceKey)) continue;\n if (alt.kind !== \"reschedule\") continue;\n if (alt.startTimestamp == null) continue;\n // No original to inherit duration from; default to 1ms if no timeSpan.\n const end =\n alt.timeSpan != null\n ? computeSpanEnd(alt.startTimestamp, alt.timeSpan, tz)\n : alt.startTimestamp + 1;\n if (!intersects(alt.startTimestamp, end, windowStart, windowEnd)) continue;\n out.push({\n templateId,\n occurrenceKey: alt.occurrenceKey,\n startTimestamp: alt.startTimestamp,\n endTimestamp: end,\n payload: _mergePayload(basePayload, alt.payloadOverride),\n alterationId: alt.id,\n });\n }\n\n return out.sort((a, b) => a.startTimestamp - b.startTimestamp);\n}\n\nfunction _mergePayload<TPayload extends { type: string }>(\n base: TPayload,\n override: Partial<TPayload> | undefined\n): TPayload {\n if (override == null) return base;\n return { ...base, ...override } as TPayload;\n}\n"],"mappings":";;;AAqBA,SAAgB,iBACd,YACA,aACA,kBACA,aACA,IACA,aACA,WAC4B;CAG5B,MAAM,2BAAW,IAAI,IAAkC;CACvD,KAAK,MAAM,OAAO,aAChB,IAAI,IAAI,eAAe,YAAY,SAAS,IAAI,IAAI,eAAe,GAAG;CAGxE,MAAM,MAAkC,CAAC;CACzC,MAAM,2BAAW,IAAI,IAAY;CAEjC,KAAK,MAAM,OAAO,kBAAkB;EAClC,SAAS,IAAI,IAAI,aAAa;EAC9B,MAAM,MAAM,SAAS,IAAI,IAAI,aAAa;EAE1C,IAAI,OAAO,MAAM;GACf,IAAI,KAAK;IACP;IACA,eAAe,IAAI;IACnB,gBAAgB,IAAI;IACpB,cAAc,IAAI;IAClB,SAAS;GACX,CAAC;GACD;EACF;EACA,IAAI,IAAI,SAAS,UAAU;EAG3B,MAAM,QAAQ,IAAI,kBAAkB,IAAI;EAGxC,MAAM,MACJ,IAAI,YAAY,OACZ,eAAe,OAAO,IAAI,UAAU,EAAE,IACtC,SAAS,IAAI,eAAe,IAAI;EACtC,IAAI,CAAC,WAAW,OAAO,KAAK,aAAa,SAAS,GAAG;EACrD,IAAI,KAAK;GACP;GACA,eAAe,IAAI;GACnB,gBAAgB;GAChB,cAAc;GACd,SAAS,cAAc,aAAa,IAAI,eAAe;GACvD,cAAc,IAAI;EACpB,CAAC;CACH;CAKA,KAAK,MAAM,OAAO,SAAS,OAAO,GAAG;EACnC,IAAI,SAAS,IAAI,IAAI,aAAa,GAAG;EACrC,IAAI,IAAI,SAAS,cAAc;EAC/B,IAAI,IAAI,kBAAkB,MAAM;EAEhC,MAAM,MACJ,IAAI,YAAY,OACZ,eAAe,IAAI,gBAAgB,IAAI,UAAU,EAAE,IACnD,IAAI,iBAAiB;EAC3B,IAAI,CAAC,WAAW,IAAI,gBAAgB,KAAK,aAAa,SAAS,GAAG;EAClE,IAAI,KAAK;GACP;GACA,eAAe,IAAI;GACnB,gBAAgB,IAAI;GACpB,cAAc;GACd,SAAS,cAAc,aAAa,IAAI,eAAe;GACvD,cAAc,IAAI;EACpB,CAAC;CACH;CAEA,OAAO,IAAI,MAAM,GAAG,MAAM,EAAE,iBAAiB,EAAE,cAAc;AAC/D;AAEA,SAAS,cACP,MACA,UACU;CACV,IAAI,YAAY,MAAM,OAAO;CAC7B,OAAO;EAAE,GAAG;EAAM,GAAG;CAAS;AAChC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { KeyedOccurrence } from "./occurrence_key.js";
|
|
2
|
+
import type { Alteration, ScheduledEvent } from "./types.js";
|
|
3
|
+
export declare function applyAlterations<TPayload extends {
|
|
4
|
+
type: string;
|
|
5
|
+
}>(templateId: string, basePayload: TPayload, keyedOccurrences: readonly KeyedOccurrence[], alterations: readonly Alteration<TPayload>[], tz: string | undefined, windowStart: number, windowEnd: number): ScheduledEvent<TPayload>[];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyAlterations = applyAlterations;
|
|
4
|
+
const types_js_1 = require("./generators/types.js");
|
|
5
|
+
const tz_js_1 = require("./tz.js");
|
|
6
|
+
// Apply a template's alterations to its base occurrences and emit the final
|
|
7
|
+
// ScheduledEvents.
|
|
8
|
+
//
|
|
9
|
+
// Two passes:
|
|
10
|
+
// 1. Walk base occurrences. Cancel suppresses; reschedule replaces (and
|
|
11
|
+
// may move out of window, in which case it's not emitted).
|
|
12
|
+
// 2. Walk reschedules whose original key is NOT in the base list (the
|
|
13
|
+
// original was outside the window) but whose new time IS in the window.
|
|
14
|
+
// Synthesize an event for them. This is what activity-board does in its
|
|
15
|
+
// separate AlterationEventGenerator.
|
|
16
|
+
//
|
|
17
|
+
// `tz` is the template's timezone. Used to interpret hourSpan on the
|
|
18
|
+
// alteration's timeSpan (if any). When the alteration carries no timeSpan,
|
|
19
|
+
// we preserve the original occurrence's duration (end - start in UTC ms).
|
|
20
|
+
//
|
|
21
|
+
// Result is sorted ascending by startTimestamp.
|
|
22
|
+
function applyAlterations(templateId, basePayload, keyedOccurrences, alterations, tz, windowStart, windowEnd) {
|
|
23
|
+
// Index alterations for THIS template by occurrence key. If multiple
|
|
24
|
+
// alterations target the same key, last-write-wins.
|
|
25
|
+
const altByKey = new Map();
|
|
26
|
+
for (const alt of alterations) {
|
|
27
|
+
if (alt.templateId === templateId)
|
|
28
|
+
altByKey.set(alt.occurrenceKey, alt);
|
|
29
|
+
}
|
|
30
|
+
const out = [];
|
|
31
|
+
const seenKeys = new Set();
|
|
32
|
+
for (const occ of keyedOccurrences) {
|
|
33
|
+
seenKeys.add(occ.occurrenceKey);
|
|
34
|
+
const alt = altByKey.get(occ.occurrenceKey);
|
|
35
|
+
if (alt == null) {
|
|
36
|
+
out.push({
|
|
37
|
+
templateId,
|
|
38
|
+
occurrenceKey: occ.occurrenceKey,
|
|
39
|
+
startTimestamp: occ.startTimestamp,
|
|
40
|
+
endTimestamp: occ.endTimestamp,
|
|
41
|
+
payload: basePayload,
|
|
42
|
+
});
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (alt.kind === "cancel")
|
|
46
|
+
continue;
|
|
47
|
+
// reschedule
|
|
48
|
+
const start = alt.startTimestamp ?? occ.startTimestamp;
|
|
49
|
+
// If the alteration specifies a timeSpan, use it (with tz semantics).
|
|
50
|
+
// Otherwise preserve the original UTC duration.
|
|
51
|
+
const end = alt.timeSpan != null
|
|
52
|
+
? (0, tz_js_1.computeSpanEnd)(start, alt.timeSpan, tz)
|
|
53
|
+
: start + (occ.endTimestamp - occ.startTimestamp);
|
|
54
|
+
if (!(0, types_js_1.intersects)(start, end, windowStart, windowEnd))
|
|
55
|
+
continue;
|
|
56
|
+
out.push({
|
|
57
|
+
templateId,
|
|
58
|
+
occurrenceKey: occ.occurrenceKey,
|
|
59
|
+
startTimestamp: start,
|
|
60
|
+
endTimestamp: end,
|
|
61
|
+
payload: _mergePayload(basePayload, alt.payloadOverride),
|
|
62
|
+
alterationId: alt.id,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// Orphan reschedules: alteration's occurrenceKey isn't in the base list
|
|
66
|
+
// (because the original fell outside the window) but the rescheduled time
|
|
67
|
+
// is. Cancels for orphans are no-ops — there was nothing to cancel here.
|
|
68
|
+
for (const alt of altByKey.values()) {
|
|
69
|
+
if (seenKeys.has(alt.occurrenceKey))
|
|
70
|
+
continue;
|
|
71
|
+
if (alt.kind !== "reschedule")
|
|
72
|
+
continue;
|
|
73
|
+
if (alt.startTimestamp == null)
|
|
74
|
+
continue;
|
|
75
|
+
// No original to inherit duration from; default to 1ms if no timeSpan.
|
|
76
|
+
const end = alt.timeSpan != null
|
|
77
|
+
? (0, tz_js_1.computeSpanEnd)(alt.startTimestamp, alt.timeSpan, tz)
|
|
78
|
+
: alt.startTimestamp + 1;
|
|
79
|
+
if (!(0, types_js_1.intersects)(alt.startTimestamp, end, windowStart, windowEnd))
|
|
80
|
+
continue;
|
|
81
|
+
out.push({
|
|
82
|
+
templateId,
|
|
83
|
+
occurrenceKey: alt.occurrenceKey,
|
|
84
|
+
startTimestamp: alt.startTimestamp,
|
|
85
|
+
endTimestamp: end,
|
|
86
|
+
payload: _mergePayload(basePayload, alt.payloadOverride),
|
|
87
|
+
alterationId: alt.id,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return out.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
91
|
+
}
|
|
92
|
+
function _mergePayload(base, override) {
|
|
93
|
+
if (override == null)
|
|
94
|
+
return base;
|
|
95
|
+
return { ...base, ...override };
|
|
96
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type DispatcherStore } from "./dispatcher_store.js";
|
|
2
|
+
import type { Scheduler } from "./scheduler.js";
|
|
3
|
+
import type { ScheduledEvent } from "./types.js";
|
|
4
|
+
export interface DispatcherConfig<TPayload extends {
|
|
5
|
+
type: string;
|
|
6
|
+
}> {
|
|
7
|
+
scheduler: Scheduler<TPayload>;
|
|
8
|
+
fire: (event: ScheduledEvent<TPayload>) => Promise<void> | void;
|
|
9
|
+
store?: DispatcherStore;
|
|
10
|
+
onError?: (err: unknown, event?: ScheduledEvent<TPayload>) => void;
|
|
11
|
+
idleTimeoutMs?: number;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
setTimeout?: (fn: () => void, ms: number) => unknown;
|
|
14
|
+
clearTimeout?: (handle: unknown) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare class Dispatcher<TPayload extends {
|
|
17
|
+
type: string;
|
|
18
|
+
}> {
|
|
19
|
+
private readonly _scheduler;
|
|
20
|
+
private readonly _fire;
|
|
21
|
+
private readonly _store;
|
|
22
|
+
private readonly _onError;
|
|
23
|
+
private readonly _idleTimeoutMs;
|
|
24
|
+
private readonly _now;
|
|
25
|
+
private readonly _setTimeout;
|
|
26
|
+
private readonly _clearTimeout;
|
|
27
|
+
private _running;
|
|
28
|
+
private _wakeResolve;
|
|
29
|
+
private _changeSubscription;
|
|
30
|
+
constructor(config: DispatcherConfig<TPayload>);
|
|
31
|
+
start(): Promise<void>;
|
|
32
|
+
stop(): void;
|
|
33
|
+
private _loop;
|
|
34
|
+
private _findNextTarget;
|
|
35
|
+
private _tick;
|
|
36
|
+
private _sleepUntil;
|
|
37
|
+
private _wake;
|
|
38
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Dispatcher = void 0;
|
|
4
|
+
const dispatcher_store_js_1 = require("./dispatcher_store.js");
|
|
5
|
+
// Default safety-net wait when nothing is scheduled. The Scheduler.changed
|
|
6
|
+
// event normally wakes us as soon as something gets added, so this is just
|
|
7
|
+
// a backstop in case a notification is missed.
|
|
8
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 60 * 60 * 1000;
|
|
9
|
+
// Drives a Scheduler in real time. The loop:
|
|
10
|
+
// 1. Catch up: list(cursor, now) → fire each → advance cursor → save.
|
|
11
|
+
// 2. Find next: nextDue(now, 1) → wait until its startTimestamp.
|
|
12
|
+
// 3. Wait may end early via Scheduler.changed (a write happened) or stop().
|
|
13
|
+
// Catch-up handles crashes AND in-process adds we missed. The change event
|
|
14
|
+
// handles in-process adds while we're sleeping — the dispatcher wakes
|
|
15
|
+
// immediately and re-queries instead of firing the stale "next" event late.
|
|
16
|
+
class Dispatcher {
|
|
17
|
+
_scheduler;
|
|
18
|
+
_fire;
|
|
19
|
+
_store;
|
|
20
|
+
_onError;
|
|
21
|
+
_idleTimeoutMs;
|
|
22
|
+
_now;
|
|
23
|
+
_setTimeout;
|
|
24
|
+
_clearTimeout;
|
|
25
|
+
_running = false;
|
|
26
|
+
_wakeResolve = null;
|
|
27
|
+
_changeSubscription = null;
|
|
28
|
+
constructor(config) {
|
|
29
|
+
this._scheduler = config.scheduler;
|
|
30
|
+
this._fire = config.fire;
|
|
31
|
+
this._store = config.store ?? new dispatcher_store_js_1.InMemoryDispatcherStore();
|
|
32
|
+
this._onError = config.onError ?? defaultOnError;
|
|
33
|
+
this._idleTimeoutMs = config.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
34
|
+
this._now = config.now ?? Date.now;
|
|
35
|
+
this._setTimeout = config.setTimeout ?? ((fn, ms) => globalThis.setTimeout(fn, ms));
|
|
36
|
+
this._clearTimeout =
|
|
37
|
+
config.clearTimeout ?? ((h) => globalThis.clearTimeout(h));
|
|
38
|
+
}
|
|
39
|
+
// Starts the loop. Resolves after the first catch-up pass completes (so
|
|
40
|
+
// tests can await an initial sync). The loop keeps running in the
|
|
41
|
+
// background until stop() is called.
|
|
42
|
+
async start() {
|
|
43
|
+
if (this._running)
|
|
44
|
+
return;
|
|
45
|
+
this._running = true;
|
|
46
|
+
this._changeSubscription = this._scheduler.changed.subscribe(() => this._wake());
|
|
47
|
+
// First iteration in foreground so callers can await initial catch-up.
|
|
48
|
+
await this._tick();
|
|
49
|
+
if (this._running)
|
|
50
|
+
void this._loop();
|
|
51
|
+
}
|
|
52
|
+
// Halts the loop. Any in-flight fire() completes; subsequent fires are
|
|
53
|
+
// skipped. The Scheduler subscription is removed.
|
|
54
|
+
stop() {
|
|
55
|
+
if (!this._running)
|
|
56
|
+
return;
|
|
57
|
+
this._running = false;
|
|
58
|
+
if (this._changeSubscription != null) {
|
|
59
|
+
this._changeSubscription.unsubscribe();
|
|
60
|
+
this._changeSubscription = null;
|
|
61
|
+
}
|
|
62
|
+
this._wake();
|
|
63
|
+
}
|
|
64
|
+
// ─── Internals ──────────────────────────────────────────────
|
|
65
|
+
async _loop() {
|
|
66
|
+
while (this._running) {
|
|
67
|
+
const target = await this._findNextTarget();
|
|
68
|
+
if (!this._running)
|
|
69
|
+
return;
|
|
70
|
+
await this._sleepUntil(target);
|
|
71
|
+
if (!this._running)
|
|
72
|
+
return;
|
|
73
|
+
await this._tick();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Returns the epoch-ms instant the dispatcher should next wake. Either
|
|
77
|
+
// the next due event's startTimestamp, or now + idleTimeoutMs.
|
|
78
|
+
async _findNextTarget() {
|
|
79
|
+
try {
|
|
80
|
+
const upcoming = await this._scheduler.nextDue(this._now(), 1);
|
|
81
|
+
const next = upcoming[0];
|
|
82
|
+
return next != null ? next.startTimestamp : this._now() + this._idleTimeoutMs;
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
this._onError(err);
|
|
86
|
+
return this._now() + this._idleTimeoutMs;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Catch-up pass: fire everything with start in [cursor, now]. We use
|
|
90
|
+
// `now + 1` as the half-open windowEnd so an event scheduled exactly at
|
|
91
|
+
// the wake instant is included (the wake from setTimeout lands on the
|
|
92
|
+
// event's startTimestamp, and we don't want to miss it). Advancing the
|
|
93
|
+
// cursor to `now + 1` likewise avoids re-listing the same event on the
|
|
94
|
+
// next tick.
|
|
95
|
+
async _tick() {
|
|
96
|
+
const cursor = (await this._store.loadCursor()) ?? this._now();
|
|
97
|
+
const now = this._now();
|
|
98
|
+
const windowEnd = now + 1;
|
|
99
|
+
if (windowEnd <= cursor) {
|
|
100
|
+
// Cursor is already at or past `now + 1` — happens on the very first
|
|
101
|
+
// tick when we just-saved-and-rewoke. Nothing to do.
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let events;
|
|
105
|
+
try {
|
|
106
|
+
events = await this._scheduler.list(cursor, windowEnd);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
this._onError(err);
|
|
110
|
+
// Don't advance cursor on a list failure — try again next tick.
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
for (const event of events) {
|
|
114
|
+
if (!this._running)
|
|
115
|
+
return;
|
|
116
|
+
try {
|
|
117
|
+
await this._fire(event);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
this._onError(err, event);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
await this._store.saveCursor(windowEnd);
|
|
124
|
+
}
|
|
125
|
+
// Sleeps until either the target epoch-ms instant, or wake() is called
|
|
126
|
+
// (via Scheduler.changed or stop()). No-op if target is already past.
|
|
127
|
+
async _sleepUntil(targetMs) {
|
|
128
|
+
const remaining = targetMs - this._now();
|
|
129
|
+
if (remaining <= 0)
|
|
130
|
+
return;
|
|
131
|
+
await new Promise((resolve) => {
|
|
132
|
+
let done = false;
|
|
133
|
+
const timer = this._setTimeout(() => {
|
|
134
|
+
if (done)
|
|
135
|
+
return;
|
|
136
|
+
done = true;
|
|
137
|
+
this._wakeResolve = null;
|
|
138
|
+
resolve();
|
|
139
|
+
}, remaining);
|
|
140
|
+
this._wakeResolve = () => {
|
|
141
|
+
if (done)
|
|
142
|
+
return;
|
|
143
|
+
done = true;
|
|
144
|
+
this._clearTimeout(timer);
|
|
145
|
+
this._wakeResolve = null;
|
|
146
|
+
resolve();
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// Resolves any in-flight _sleepUntil. Called by stop() and by the
|
|
151
|
+
// Scheduler.changed subscription.
|
|
152
|
+
_wake() {
|
|
153
|
+
if (this._wakeResolve != null)
|
|
154
|
+
this._wakeResolve();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
exports.Dispatcher = Dispatcher;
|
|
158
|
+
function defaultOnError(err, event) {
|
|
159
|
+
if (event != null) {
|
|
160
|
+
console.error(`[Dispatcher] fire failed for ${event.templateId}/${event.occurrenceKey}:`, err);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
console.error("[Dispatcher] error:", err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface DispatcherStore {
|
|
2
|
+
loadCursor(): Promise<number | null>;
|
|
3
|
+
saveCursor(cursor: number): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
export declare class InMemoryDispatcherStore implements DispatcherStore {
|
|
6
|
+
private _cursor;
|
|
7
|
+
loadCursor(): Promise<number | null>;
|
|
8
|
+
saveCursor(cursor: number): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InMemoryDispatcherStore = void 0;
|
|
4
|
+
// Reference in-memory implementation. Useful for tests and short-lived
|
|
5
|
+
// processes that don't need crash recovery.
|
|
6
|
+
class InMemoryDispatcherStore {
|
|
7
|
+
_cursor = null;
|
|
8
|
+
async loadCursor() {
|
|
9
|
+
return this._cursor;
|
|
10
|
+
}
|
|
11
|
+
async saveCursor(cursor) {
|
|
12
|
+
this._cursor = cursor;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.InMemoryDispatcherStore = InMemoryDispatcherStore;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateAnchored = generateAnchored;
|
|
4
|
+
const tz_js_1 = require("../tz.js");
|
|
5
|
+
const types_js_1 = require("./types.js");
|
|
6
|
+
// Anchored is tz-independent for STEPPING (every occurrence is exactly
|
|
7
|
+
// intervalMs ms from the anchor), but tz can still affect the end-of-
|
|
8
|
+
// occurrence when hourSpan is used. seriesStart/seriesEnd bound in UTC.
|
|
9
|
+
function generateAnchored(rule, tz, windowStart, windowEnd) {
|
|
10
|
+
if (rule.intervalMs <= 0) {
|
|
11
|
+
throw new Error(`AnchoredRule.intervalMs must be > 0 (got ${rule.intervalMs})`);
|
|
12
|
+
}
|
|
13
|
+
if (rule.count != null && rule.count < 0) {
|
|
14
|
+
throw new Error(`AnchoredRule.count must be >= 0 (got ${rule.count})`);
|
|
15
|
+
}
|
|
16
|
+
const seriesEnd = rule.seriesEnd ?? Number.POSITIVE_INFINITY;
|
|
17
|
+
const effectiveEnd = Math.min(windowEnd, seriesEnd);
|
|
18
|
+
const effectiveStart = Math.max(windowStart, rule.seriesStart);
|
|
19
|
+
if (effectiveStart >= effectiveEnd)
|
|
20
|
+
return [];
|
|
21
|
+
// If `count` is set we must start counting from the anchor; otherwise we
|
|
22
|
+
// can fast-forward to the window start.
|
|
23
|
+
let index;
|
|
24
|
+
let occurrenceStart;
|
|
25
|
+
if (rule.count != null) {
|
|
26
|
+
index = 0;
|
|
27
|
+
occurrenceStart = rule.anchorDate;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
if (effectiveStart <= rule.anchorDate) {
|
|
31
|
+
index = 0;
|
|
32
|
+
occurrenceStart = rule.anchorDate;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
// First occurrence at or after effectiveStart.
|
|
36
|
+
const stepsAhead = Math.ceil((effectiveStart - rule.anchorDate) / rule.intervalMs);
|
|
37
|
+
index = stepsAhead;
|
|
38
|
+
occurrenceStart = rule.anchorDate + stepsAhead * rule.intervalMs;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const out = [];
|
|
42
|
+
while (occurrenceStart < effectiveEnd) {
|
|
43
|
+
if (rule.count != null && index >= rule.count)
|
|
44
|
+
break;
|
|
45
|
+
const occEnd = (0, tz_js_1.computeSpanEnd)(occurrenceStart, rule.timeSpan, tz);
|
|
46
|
+
if ((0, types_js_1.intersects)(occurrenceStart, occEnd, windowStart, windowEnd) &&
|
|
47
|
+
occurrenceStart >= rule.seriesStart) {
|
|
48
|
+
out.push({ startTimestamp: occurrenceStart, endTimestamp: occEnd });
|
|
49
|
+
if (out.length > types_js_1.MAX_OCCURRENCES_PER_GENERATE) {
|
|
50
|
+
throw new types_js_1.TooManyOccurrencesError("anchored");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
index++;
|
|
54
|
+
occurrenceStart += rule.intervalMs;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { DaysOfMonthRule, Disambiguation } from "../types.js";
|
|
2
|
+
import { type Occurrence } from "./types.js";
|
|
3
|
+
export declare function generateDaysOfMonth(rule: DaysOfMonthRule, tz: string | undefined, windowStart: number, windowEnd: number, disambiguation?: Disambiguation): Occurrence[];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateDaysOfMonth = generateDaysOfMonth;
|
|
4
|
+
const tz_js_1 = require("../tz.js");
|
|
5
|
+
const types_js_1 = require("./types.js");
|
|
6
|
+
// Numerical date-based recurrence: months × daysOfMonth. Days clamp per
|
|
7
|
+
// month (day 31 in April → Apr 30, day 29 in Feb non-leap → Feb 28).
|
|
8
|
+
function generateDaysOfMonth(rule, tz, windowStart, windowEnd, disambiguation = "compatible") {
|
|
9
|
+
if (rule.yearInterval <= 0) {
|
|
10
|
+
throw new Error(`DaysOfMonthRule.yearInterval must be > 0 (got ${rule.yearInterval})`);
|
|
11
|
+
}
|
|
12
|
+
if (rule.months.length === 0) {
|
|
13
|
+
throw new Error("DaysOfMonthRule.months must be non-empty");
|
|
14
|
+
}
|
|
15
|
+
for (const m of rule.months) {
|
|
16
|
+
if (m < 0 || m > 11) {
|
|
17
|
+
throw new Error(`DaysOfMonthRule.months entries must be in 0..11 (got ${m})`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (rule.daysOfMonth.length === 0) {
|
|
21
|
+
throw new Error("DaysOfMonthRule.daysOfMonth must be non-empty");
|
|
22
|
+
}
|
|
23
|
+
for (const d of rule.daysOfMonth) {
|
|
24
|
+
if (d < 1 || d > 31) {
|
|
25
|
+
throw new Error(`DaysOfMonthRule.daysOfMonth entries must be in 1..31 (got ${d})`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (rule.count != null && rule.count < 0) {
|
|
29
|
+
throw new Error(`DaysOfMonthRule.count must be >= 0 (got ${rule.count})`);
|
|
30
|
+
}
|
|
31
|
+
const seriesEnd = rule.seriesEnd ?? Number.POSITIVE_INFINITY;
|
|
32
|
+
const effectiveEnd = Math.min(windowEnd, seriesEnd);
|
|
33
|
+
if (rule.seriesStart >= effectiveEnd)
|
|
34
|
+
return [];
|
|
35
|
+
const months = [...new Set(rule.months)].sort((a, b) => a - b);
|
|
36
|
+
const daysOfMonth = [...new Set(rule.daysOfMonth)].sort((a, b) => a - b);
|
|
37
|
+
const startYear = (0, tz_js_1.utcToWallParts)(rule.seriesStart, tz).year;
|
|
38
|
+
const endYear = (0, tz_js_1.utcToWallParts)(Math.min(effectiveEnd, Number.MAX_SAFE_INTEGER), tz).year;
|
|
39
|
+
const out = [];
|
|
40
|
+
let count = 0;
|
|
41
|
+
for (let year = startYear; year <= endYear; year += rule.yearInterval) {
|
|
42
|
+
const yearEmits = [];
|
|
43
|
+
for (const month of months) {
|
|
44
|
+
const len = (0, tz_js_1.monthLength)(year, month);
|
|
45
|
+
// Dedup clamped days so day-31 + day-30 in April don't both emit Apr 30.
|
|
46
|
+
const clampedDays = [...new Set(daysOfMonth.map((d) => Math.min(d, len)))];
|
|
47
|
+
for (const day of clampedDays) {
|
|
48
|
+
if (disambiguation === "skip" &&
|
|
49
|
+
(0, tz_js_1.isNonexistentWallTime)(year, month, day, rule.timeOfDay, tz)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const occStart = (0, tz_js_1.wallTimeToUtc)(year, month, day, rule.timeOfDay, tz, disambiguation);
|
|
53
|
+
const occEnd = (0, tz_js_1.computeSpanEnd)(occStart, rule.timeSpan, tz);
|
|
54
|
+
if (occStart < rule.seriesStart)
|
|
55
|
+
continue;
|
|
56
|
+
if (occStart >= seriesEnd)
|
|
57
|
+
continue;
|
|
58
|
+
if (rule.count != null && count >= rule.count) {
|
|
59
|
+
yearEmits.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
60
|
+
out.push(...yearEmits);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
count++;
|
|
64
|
+
if ((0, types_js_1.intersects)(occStart, occEnd, windowStart, windowEnd)) {
|
|
65
|
+
yearEmits.push({ startTimestamp: occStart, endTimestamp: occEnd });
|
|
66
|
+
if (out.length + yearEmits.length > types_js_1.MAX_OCCURRENCES_PER_GENERATE) {
|
|
67
|
+
throw new types_js_1.TooManyOccurrencesError("daysOfMonth");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
yearEmits.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
73
|
+
out.push(...yearEmits);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|