@j13b/scheduler 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -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 +6 -0
- package/dist/cjs/index.js +14 -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 +24 -0
- package/dist/cjs/scheduler.js +460 -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/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 +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -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 +25 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +332 -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/package.json +70 -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__/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 +24 -0
- package/src/occurrence_key.ts +42 -0
- package/src/port.ts +31 -0
- package/src/scheduler.ts +499 -0
- package/src/types.ts +369 -0
- package/src/tz.ts +246 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { Day } from "../types.js";
|
|
3
|
+
import {
|
|
4
|
+
addDaysInTz,
|
|
5
|
+
addMonthsInTz,
|
|
6
|
+
dayOfWeekInTz,
|
|
7
|
+
monthLength,
|
|
8
|
+
utcToWallParts,
|
|
9
|
+
wallTimeToUtc,
|
|
10
|
+
} from "../tz.js";
|
|
11
|
+
|
|
12
|
+
// Reference moments:
|
|
13
|
+
// 2025-03-09 09:00 UTC = 2025-03-09 02:00 MST = 03:00 MDT (the gap)
|
|
14
|
+
// 2025-11-02 08:00 UTC = 2025-11-02 02:00 MDT = 01:00 MST (the overlap)
|
|
15
|
+
// America/Denver: -07:00 in MDT, -06:00 in MST. Wait — opposite. MST is
|
|
16
|
+
// standard (-07), MDT is daylight (-06). Spring forward = MST→MDT (lose
|
|
17
|
+
// an hour). Fall back = MDT→MST (gain an hour).
|
|
18
|
+
|
|
19
|
+
describe("tz — UTC pass-through", () => {
|
|
20
|
+
test("utcToWallParts treats UTC as itself", () => {
|
|
21
|
+
const w = utcToWallParts(Date.UTC(2026, 4, 11, 14, 30, 45, 250), "UTC");
|
|
22
|
+
expect(w).toEqual({
|
|
23
|
+
year: 2026,
|
|
24
|
+
month: 4,
|
|
25
|
+
day: 11,
|
|
26
|
+
hour: 14,
|
|
27
|
+
minute: 30,
|
|
28
|
+
second: 45,
|
|
29
|
+
ms: 250,
|
|
30
|
+
dayOfWeek: Day.Monday,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("wallTimeToUtc in UTC = Date.UTC", () => {
|
|
35
|
+
const expected = Date.UTC(2026, 4, 11, 14, 30, 45, 250);
|
|
36
|
+
const msOfDay = 14 * 3_600_000 + 30 * 60_000 + 45 * 1000 + 250;
|
|
37
|
+
expect(wallTimeToUtc(2026, 4, 11, msOfDay, "UTC")).toBe(expected);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("tz — IANA round-trips", () => {
|
|
42
|
+
test("New York: 9am wall = 14:00 UTC in winter (EST)", () => {
|
|
43
|
+
const utc = wallTimeToUtc(2026, 0, 15, 9 * 3_600_000, "America/New_York");
|
|
44
|
+
expect(new Date(utc).toISOString()).toBe("2026-01-15T14:00:00.000Z");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("New York: 9am wall = 13:00 UTC in summer (EDT)", () => {
|
|
48
|
+
const utc = wallTimeToUtc(2026, 6, 15, 9 * 3_600_000, "America/New_York");
|
|
49
|
+
expect(new Date(utc).toISOString()).toBe("2026-07-15T13:00:00.000Z");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("Tokyo: 9am wall = 00:00 UTC year-round (no DST)", () => {
|
|
53
|
+
const winter = wallTimeToUtc(2026, 0, 15, 9 * 3_600_000, "Asia/Tokyo");
|
|
54
|
+
const summer = wallTimeToUtc(2026, 6, 15, 9 * 3_600_000, "Asia/Tokyo");
|
|
55
|
+
expect(new Date(winter).toISOString()).toBe("2026-01-15T00:00:00.000Z");
|
|
56
|
+
expect(new Date(summer).toISOString()).toBe("2026-07-15T00:00:00.000Z");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("Round-trip: utcToWallParts → wallTimeToUtc preserves the instant", () => {
|
|
60
|
+
const original = Date.UTC(2025, 5, 15, 18, 42, 17);
|
|
61
|
+
const w = utcToWallParts(original, "Europe/London");
|
|
62
|
+
const msOfDay = w.hour * 3_600_000 + w.minute * 60_000 + w.second * 1000;
|
|
63
|
+
expect(wallTimeToUtc(w.year, w.month, w.day, msOfDay, "Europe/London")).toBe(original);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("tz — DST boundaries", () => {
|
|
68
|
+
// Spring forward: 2025-03-09, America/Denver. Clock jumps 02:00 MST → 03:00 MDT.
|
|
69
|
+
// 02:30 wall is "non-existent". We resolve to the EARLIER instant — i.e.
|
|
70
|
+
// treat the wall fields with the pre-transition (MST, -07) offset. So
|
|
71
|
+
// 02:30 → 09:30 UTC (since pre-transition Denver is UTC-7).
|
|
72
|
+
// Wait: actually pre-transition offset is MST (-7), so 02:30 MST → 09:30 UTC.
|
|
73
|
+
// The IMPLEMENTATION returns 08:30 UTC, which corresponds to 02:30 in the
|
|
74
|
+
// pre-jump timeline interpreted as if it were ALREADY MDT (-6). Hmm.
|
|
75
|
+
//
|
|
76
|
+
// What's actually happening: the algorithm reads the offset at the
|
|
77
|
+
// post-correction guess (which lands AFTER the jump), so it uses MDT (-6).
|
|
78
|
+
// 02:30 wall - (-6h) = 08:30 UTC. So we resolve to the EARLIER UTC instant
|
|
79
|
+
// by using the POST-transition (smaller magnitude) offset. This is the
|
|
80
|
+
// Luxon / date-fns-tz default for spring-forward.
|
|
81
|
+
// Default disambiguation is now "compatible": a spring-forward gap moves
|
|
82
|
+
// FORWARD to the later instant. 02:30 (nonexistent) → 09:30 UTC (i.e. the
|
|
83
|
+
// clock jumps to 03:30 MDT). "earlier" recovers the old 08:30 UTC behavior.
|
|
84
|
+
test("spring forward gap: default (compatible) resolves to the later instant", () => {
|
|
85
|
+
const utc = wallTimeToUtc(2025, 2, 9, 2 * 3_600_000 + 30 * 60_000, "America/Denver");
|
|
86
|
+
expect(new Date(utc).toISOString()).toBe("2025-03-09T09:30:00.000Z");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("spring forward gap: disambiguation policies pick gap direction", () => {
|
|
90
|
+
const wall = 2 * 3_600_000 + 30 * 60_000;
|
|
91
|
+
const earlier = wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "earlier");
|
|
92
|
+
const later = wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "later");
|
|
93
|
+
const compatible = wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "compatible");
|
|
94
|
+
expect(new Date(earlier).toISOString()).toBe("2025-03-09T08:30:00.000Z");
|
|
95
|
+
expect(new Date(later).toISOString()).toBe("2025-03-09T09:30:00.000Z");
|
|
96
|
+
expect(compatible).toBe(later);
|
|
97
|
+
expect(() => wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "reject")).toThrow(/gap/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Fall back: 2025-11-02, America/Denver. Clock falls 02:00 MDT → 01:00 MST.
|
|
101
|
+
// 01:30 wall is ambiguous. We resolve to the EARLIER instant: 07:30 UTC
|
|
102
|
+
// (the first occurrence, while still on MDT), not 08:30 UTC.
|
|
103
|
+
test("fall back ambiguous wall time resolves to earlier instant (compatible default)", () => {
|
|
104
|
+
const utc = wallTimeToUtc(2025, 10, 2, 1 * 3_600_000 + 30 * 60_000, "America/Denver");
|
|
105
|
+
expect(new Date(utc).toISOString()).toBe("2025-11-02T07:30:00.000Z");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("fall back overlap: disambiguation policies pick which occurrence", () => {
|
|
109
|
+
const wall = 1 * 3_600_000 + 30 * 60_000;
|
|
110
|
+
const earlier = wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "earlier");
|
|
111
|
+
const later = wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "later");
|
|
112
|
+
expect(new Date(earlier).toISOString()).toBe("2025-11-02T07:30:00.000Z");
|
|
113
|
+
expect(new Date(later).toISOString()).toBe("2025-11-02T08:30:00.000Z");
|
|
114
|
+
// "compatible" picks the earlier occurrence for an overlap.
|
|
115
|
+
expect(wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "compatible")).toBe(earlier);
|
|
116
|
+
expect(() => wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "reject")).toThrow(/overlap/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Same wall-clock time, two different UTCs across spring forward.
|
|
120
|
+
test("9am Denver wall is 16:00 UTC before, 15:00 UTC after spring forward", () => {
|
|
121
|
+
const before = wallTimeToUtc(2025, 2, 8, 9 * 3_600_000, "America/Denver"); // Sat
|
|
122
|
+
const after = wallTimeToUtc(2025, 2, 10, 9 * 3_600_000, "America/Denver"); // Mon
|
|
123
|
+
expect(new Date(before).toISOString()).toBe("2025-03-08T16:00:00.000Z");
|
|
124
|
+
expect(new Date(after).toISOString()).toBe("2025-03-10T15:00:00.000Z");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("tz — addDaysInTz", () => {
|
|
129
|
+
test("adding 1 day across spring forward keeps wall time stable", () => {
|
|
130
|
+
// 2025-03-08 09:00 Denver
|
|
131
|
+
const start = wallTimeToUtc(2025, 2, 8, 9 * 3_600_000, "America/Denver");
|
|
132
|
+
const next = addDaysInTz(start, 1, "America/Denver");
|
|
133
|
+
const w = utcToWallParts(next, "America/Denver");
|
|
134
|
+
expect({ year: w.year, month: w.month, day: w.day, hour: w.hour }).toEqual({
|
|
135
|
+
year: 2025,
|
|
136
|
+
month: 2,
|
|
137
|
+
day: 9,
|
|
138
|
+
hour: 9,
|
|
139
|
+
});
|
|
140
|
+
// The actual elapsed UTC ms is 23 hours, not 24.
|
|
141
|
+
expect(next - start).toBe(23 * 3_600_000);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("adding 1 day across fall back keeps wall time stable", () => {
|
|
145
|
+
const start = wallTimeToUtc(2025, 10, 1, 9 * 3_600_000, "America/Denver");
|
|
146
|
+
const next = addDaysInTz(start, 1, "America/Denver");
|
|
147
|
+
const w = utcToWallParts(next, "America/Denver");
|
|
148
|
+
expect({ year: w.year, month: w.month, day: w.day, hour: w.hour }).toEqual({
|
|
149
|
+
year: 2025,
|
|
150
|
+
month: 10,
|
|
151
|
+
day: 2,
|
|
152
|
+
hour: 9,
|
|
153
|
+
});
|
|
154
|
+
// 25-hour day.
|
|
155
|
+
expect(next - start).toBe(25 * 3_600_000);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("adding negative days works", () => {
|
|
159
|
+
const start = wallTimeToUtc(2026, 4, 11, 12 * 3_600_000, "UTC");
|
|
160
|
+
const before = addDaysInTz(start, -10, "UTC");
|
|
161
|
+
expect(new Date(before).toISOString()).toBe("2026-05-01T12:00:00.000Z");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("tz — addMonthsInTz", () => {
|
|
166
|
+
test("adds months in wall sense", () => {
|
|
167
|
+
const start = wallTimeToUtc(2026, 0, 31, 12 * 3_600_000, "UTC"); // Jan 31
|
|
168
|
+
const next = addMonthsInTz(start, 1, "UTC"); // Feb 31 → Mar 3 (JS rolls over)
|
|
169
|
+
const w = utcToWallParts(next, "UTC");
|
|
170
|
+
expect({ year: w.year, month: w.month, day: w.day }).toEqual({
|
|
171
|
+
year: 2026,
|
|
172
|
+
month: 2, // March (JS rolls Feb 31 → Mar 3)
|
|
173
|
+
day: 3,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("tz — dayOfWeekInTz", () => {
|
|
179
|
+
test("crosses date boundary correctly in non-UTC tz", () => {
|
|
180
|
+
// 2026-05-11 23:00 UTC = 2026-05-11 16:00 PDT (Monday in LA, Monday in UTC)
|
|
181
|
+
const utc = Date.UTC(2026, 4, 11, 23, 0, 0);
|
|
182
|
+
expect(dayOfWeekInTz(utc, "UTC")).toBe(Day.Monday);
|
|
183
|
+
expect(dayOfWeekInTz(utc, "America/Los_Angeles")).toBe(Day.Monday);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("date boundary case: 04:00 UTC Tuesday is still Monday in LA", () => {
|
|
187
|
+
// 2026-05-12 04:00 UTC = 2026-05-11 21:00 PDT
|
|
188
|
+
const utc = Date.UTC(2026, 4, 12, 4, 0, 0);
|
|
189
|
+
expect(dayOfWeekInTz(utc, "UTC")).toBe(Day.Tuesday);
|
|
190
|
+
expect(dayOfWeekInTz(utc, "America/Los_Angeles")).toBe(Day.Monday);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("tz — monthLength", () => {
|
|
195
|
+
test("returns civil month length", () => {
|
|
196
|
+
expect(monthLength(2024, 1)).toBe(29); // Feb 2024 (leap)
|
|
197
|
+
expect(monthLength(2025, 1)).toBe(28); // Feb 2025
|
|
198
|
+
expect(monthLength(2026, 0)).toBe(31); // Jan
|
|
199
|
+
expect(monthLength(2026, 3)).toBe(30); // Apr
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { generateWeeksOfMonth } from "../generators/weeks_of_month.js";
|
|
3
|
+
import { Day, type WeeksOfMonthRule } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const HOUR = 3_600_000;
|
|
6
|
+
|
|
7
|
+
describe("generateWeeksOfMonth", () => {
|
|
8
|
+
test("2nd Tuesday of every month for a quarter", () => {
|
|
9
|
+
const rule: WeeksOfMonthRule = {
|
|
10
|
+
kind: "weeksOfMonth",
|
|
11
|
+
days: [Day.Tuesday],
|
|
12
|
+
weeks: [2],
|
|
13
|
+
monthInterval: 1,
|
|
14
|
+
timeOfDay: 14 * HOUR,
|
|
15
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
16
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
17
|
+
seriesEnd: null,
|
|
18
|
+
};
|
|
19
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 3, 1));
|
|
20
|
+
// 2nd Tuesdays: Jan 13, Feb 10, Mar 10
|
|
21
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
22
|
+
"2026-01-13T14:00:00.000Z",
|
|
23
|
+
"2026-02-10T14:00:00.000Z",
|
|
24
|
+
"2026-03-10T14:00:00.000Z",
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("last Friday of every month", () => {
|
|
29
|
+
const rule: WeeksOfMonthRule = {
|
|
30
|
+
kind: "weeksOfMonth",
|
|
31
|
+
days: [Day.Friday],
|
|
32
|
+
weeks: [-1],
|
|
33
|
+
monthInterval: 1,
|
|
34
|
+
timeOfDay: 17 * HOUR,
|
|
35
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
36
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
37
|
+
seriesEnd: null,
|
|
38
|
+
};
|
|
39
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 3, 1));
|
|
40
|
+
// Last Fridays: Jan 30, Feb 27, Mar 27
|
|
41
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
42
|
+
"2026-01-30T17:00:00.000Z",
|
|
43
|
+
"2026-02-27T17:00:00.000Z",
|
|
44
|
+
"2026-03-27T17:00:00.000Z",
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("5th Friday: emits only in months that have one", () => {
|
|
49
|
+
const rule: WeeksOfMonthRule = {
|
|
50
|
+
kind: "weeksOfMonth",
|
|
51
|
+
days: [Day.Friday],
|
|
52
|
+
weeks: [5],
|
|
53
|
+
monthInterval: 1,
|
|
54
|
+
timeOfDay: 0,
|
|
55
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
56
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
57
|
+
seriesEnd: null,
|
|
58
|
+
};
|
|
59
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1));
|
|
60
|
+
// 2026: months with a 5th Friday are Jan (30), May (29), Jul (31), Oct (30).
|
|
61
|
+
// Wait — let me recompute: 5th Friday means the month has 5 Fridays.
|
|
62
|
+
// Jan 2026 Fridays: 2,9,16,23,30 → 5 Fridays, 5th = 30
|
|
63
|
+
// May 2026 Fridays: 1,8,15,22,29 → 5 Fridays, 5th = 29
|
|
64
|
+
// Jul 2026 Fridays: 3,10,17,24,31 → 5 Fridays, 5th = 31
|
|
65
|
+
// Oct 2026 Fridays: 2,9,16,23,30 → 5 Fridays, 5th = 30
|
|
66
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
67
|
+
"2026-01-30T00:00:00.000Z",
|
|
68
|
+
"2026-05-29T00:00:00.000Z",
|
|
69
|
+
"2026-07-31T00:00:00.000Z",
|
|
70
|
+
"2026-10-30T00:00:00.000Z",
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("every other month: skips months correctly", () => {
|
|
75
|
+
const rule: WeeksOfMonthRule = {
|
|
76
|
+
kind: "weeksOfMonth",
|
|
77
|
+
days: [Day.Wednesday],
|
|
78
|
+
weeks: [1],
|
|
79
|
+
monthInterval: 2,
|
|
80
|
+
timeOfDay: 12 * HOUR,
|
|
81
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
82
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
83
|
+
seriesEnd: null,
|
|
84
|
+
};
|
|
85
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 6, 1));
|
|
86
|
+
// 1st Wed of Jan/Mar/May 2026 = Jan 7, Mar 4, May 6
|
|
87
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
88
|
+
"2026-01-07T12:00:00.000Z",
|
|
89
|
+
"2026-03-04T12:00:00.000Z",
|
|
90
|
+
"2026-05-06T12:00:00.000Z",
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("throws on week = 0", () => {
|
|
95
|
+
const rule: WeeksOfMonthRule = {
|
|
96
|
+
kind: "weeksOfMonth",
|
|
97
|
+
days: [Day.Monday],
|
|
98
|
+
weeks: [0],
|
|
99
|
+
monthInterval: 1,
|
|
100
|
+
timeOfDay: 0,
|
|
101
|
+
seriesStart: 0,
|
|
102
|
+
seriesEnd: null,
|
|
103
|
+
};
|
|
104
|
+
expect(() => generateWeeksOfMonth(rule, "UTC", 0, 1000)).toThrow(/week/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("throws on monthInterval = 0", () => {
|
|
108
|
+
const rule: WeeksOfMonthRule = {
|
|
109
|
+
kind: "weeksOfMonth",
|
|
110
|
+
days: [Day.Monday],
|
|
111
|
+
weeks: [1],
|
|
112
|
+
monthInterval: 0,
|
|
113
|
+
timeOfDay: 0,
|
|
114
|
+
seriesStart: 0,
|
|
115
|
+
seriesEnd: null,
|
|
116
|
+
};
|
|
117
|
+
expect(() => generateWeeksOfMonth(rule, "UTC", 0, 1000)).toThrow(/monthInterval/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("first AND last week: Mon/Wed/Fri on weeks [1, -1] of every month", () => {
|
|
121
|
+
// The user's original use case.
|
|
122
|
+
const rule: WeeksOfMonthRule = {
|
|
123
|
+
kind: "weeksOfMonth",
|
|
124
|
+
days: [Day.Monday, Day.Wednesday, Day.Friday],
|
|
125
|
+
weeks: [1, -1],
|
|
126
|
+
monthInterval: 1,
|
|
127
|
+
timeOfDay: 9 * HOUR,
|
|
128
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
129
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
130
|
+
seriesEnd: null,
|
|
131
|
+
};
|
|
132
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 1, 1));
|
|
133
|
+
// Jan 2026 starts Thursday. Days of week:
|
|
134
|
+
// Thu Jan 1, Fri Jan 2 (1st Fri), Sat Jan 3, Sun Jan 4,
|
|
135
|
+
// Mon Jan 5 (1st Mon), Tue Jan 6, Wed Jan 7 (1st Wed), ...
|
|
136
|
+
// Last week of Jan 2026:
|
|
137
|
+
// Mon Jan 26 (last Mon), Wed Jan 28 (last Wed), Fri Jan 30 (last Fri).
|
|
138
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
139
|
+
"2026-01-02T09:00:00.000Z", // 1st Fri
|
|
140
|
+
"2026-01-05T09:00:00.000Z", // 1st Mon
|
|
141
|
+
"2026-01-07T09:00:00.000Z", // 1st Wed
|
|
142
|
+
"2026-01-26T09:00:00.000Z", // last Mon
|
|
143
|
+
"2026-01-28T09:00:00.000Z", // last Wed
|
|
144
|
+
"2026-01-30T09:00:00.000Z", // last Fri
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("multiple positive weeks: 1st, 3rd, 5th Tuesday", () => {
|
|
149
|
+
const rule: WeeksOfMonthRule = {
|
|
150
|
+
kind: "weeksOfMonth",
|
|
151
|
+
days: [Day.Tuesday],
|
|
152
|
+
weeks: [1, 3, 5],
|
|
153
|
+
monthInterval: 1,
|
|
154
|
+
timeOfDay: 0,
|
|
155
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
156
|
+
seriesStart: Date.UTC(2026, 5, 1),
|
|
157
|
+
seriesEnd: null,
|
|
158
|
+
};
|
|
159
|
+
// June 2026 Tuesdays: 2, 9, 16, 23, 30 → 1st=2, 3rd=16, 5th=30
|
|
160
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 5, 1), Date.UTC(2026, 6, 1));
|
|
161
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
162
|
+
"2026-06-02T00:00:00.000Z",
|
|
163
|
+
"2026-06-16T00:00:00.000Z",
|
|
164
|
+
"2026-06-30T00:00:00.000Z",
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("duplicate weeks are deduped (no double-emit)", () => {
|
|
169
|
+
const rule: WeeksOfMonthRule = {
|
|
170
|
+
kind: "weeksOfMonth",
|
|
171
|
+
days: [Day.Monday],
|
|
172
|
+
weeks: [1, 1, 1],
|
|
173
|
+
monthInterval: 1,
|
|
174
|
+
timeOfDay: 0,
|
|
175
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
176
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
177
|
+
seriesEnd: null,
|
|
178
|
+
};
|
|
179
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 1, 1));
|
|
180
|
+
expect(out).toHaveLength(1); // 1st Monday of Jan 2026 = Jan 5
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("throws on empty weeks", () => {
|
|
184
|
+
const rule: WeeksOfMonthRule = {
|
|
185
|
+
kind: "weeksOfMonth",
|
|
186
|
+
days: [Day.Monday],
|
|
187
|
+
weeks: [],
|
|
188
|
+
monthInterval: 1,
|
|
189
|
+
timeOfDay: 0,
|
|
190
|
+
seriesStart: 0,
|
|
191
|
+
seriesEnd: null,
|
|
192
|
+
};
|
|
193
|
+
expect(() => generateWeeksOfMonth(rule, "UTC", 0, 1000)).toThrow(/weeks/);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { generateYearly } from "../generators/yearly.js";
|
|
3
|
+
import { Month, type YearlyRule } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const HOUR = 3_600_000;
|
|
6
|
+
|
|
7
|
+
describe("generateYearly", () => {
|
|
8
|
+
test("every June 11", () => {
|
|
9
|
+
const rule: YearlyRule = {
|
|
10
|
+
kind: "yearly",
|
|
11
|
+
month: Month.June,
|
|
12
|
+
day: 11,
|
|
13
|
+
yearInterval: 1,
|
|
14
|
+
timeOfDay: 9 * HOUR,
|
|
15
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
16
|
+
seriesStart: Date.UTC(2024, 0, 1),
|
|
17
|
+
seriesEnd: null,
|
|
18
|
+
};
|
|
19
|
+
const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2027, 0, 1));
|
|
20
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
21
|
+
"2024-06-11T09:00:00.000Z",
|
|
22
|
+
"2025-06-11T09:00:00.000Z",
|
|
23
|
+
"2026-06-11T09:00:00.000Z",
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("every other Jan 1", () => {
|
|
28
|
+
const rule: YearlyRule = {
|
|
29
|
+
kind: "yearly",
|
|
30
|
+
month: Month.January,
|
|
31
|
+
day: 1,
|
|
32
|
+
yearInterval: 2,
|
|
33
|
+
timeOfDay: 0,
|
|
34
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
35
|
+
seriesStart: Date.UTC(2024, 0, 1),
|
|
36
|
+
seriesEnd: null,
|
|
37
|
+
};
|
|
38
|
+
const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2030, 0, 1));
|
|
39
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
40
|
+
"2024-01-01T00:00:00.000Z",
|
|
41
|
+
"2026-01-01T00:00:00.000Z",
|
|
42
|
+
"2028-01-01T00:00:00.000Z",
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("Feb 29 falls back to Feb 28 in non-leap years", () => {
|
|
47
|
+
const rule: YearlyRule = {
|
|
48
|
+
kind: "yearly",
|
|
49
|
+
month: Month.February,
|
|
50
|
+
day: 29,
|
|
51
|
+
yearInterval: 1,
|
|
52
|
+
timeOfDay: 0,
|
|
53
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
54
|
+
seriesStart: Date.UTC(2024, 0, 1),
|
|
55
|
+
seriesEnd: null,
|
|
56
|
+
};
|
|
57
|
+
const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2027, 0, 1));
|
|
58
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
59
|
+
"2024-02-29T00:00:00.000Z", // leap
|
|
60
|
+
"2025-02-28T00:00:00.000Z", // fallback
|
|
61
|
+
"2026-02-28T00:00:00.000Z", // fallback
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("count caps emissions", () => {
|
|
66
|
+
const rule: YearlyRule = {
|
|
67
|
+
kind: "yearly",
|
|
68
|
+
month: Month.July,
|
|
69
|
+
day: 4,
|
|
70
|
+
yearInterval: 1,
|
|
71
|
+
timeOfDay: 0,
|
|
72
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
73
|
+
seriesStart: Date.UTC(2024, 0, 1),
|
|
74
|
+
seriesEnd: null,
|
|
75
|
+
count: 2,
|
|
76
|
+
};
|
|
77
|
+
const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2030, 0, 1));
|
|
78
|
+
expect(out).toHaveLength(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("throws on month out of range", () => {
|
|
82
|
+
const rule: YearlyRule = {
|
|
83
|
+
kind: "yearly",
|
|
84
|
+
month: 12 as Month,
|
|
85
|
+
day: 1,
|
|
86
|
+
yearInterval: 1,
|
|
87
|
+
timeOfDay: 0,
|
|
88
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
89
|
+
seriesStart: 0,
|
|
90
|
+
seriesEnd: null,
|
|
91
|
+
};
|
|
92
|
+
expect(() => generateYearly(rule, "UTC", 0, 1000)).toThrow(/month/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("throws on day < 1 or > 31", () => {
|
|
96
|
+
const rule: YearlyRule = {
|
|
97
|
+
kind: "yearly",
|
|
98
|
+
month: Month.January,
|
|
99
|
+
day: 32,
|
|
100
|
+
yearInterval: 1,
|
|
101
|
+
timeOfDay: 0,
|
|
102
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
103
|
+
seriesStart: 0,
|
|
104
|
+
seriesEnd: null,
|
|
105
|
+
};
|
|
106
|
+
expect(() => generateYearly(rule, "UTC", 0, 1000)).toThrow(/day/);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { intersects } from "./generators/types.js";
|
|
2
|
+
import type { KeyedOccurrence } from "./occurrence_key.js";
|
|
3
|
+
import type { Alteration, ScheduledEvent } from "./types.js";
|
|
4
|
+
import { computeSpanEnd } from "./tz.js";
|
|
5
|
+
|
|
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
|
+
export function applyAlterations<TPayload extends { type: string }>(
|
|
23
|
+
templateId: string,
|
|
24
|
+
basePayload: TPayload,
|
|
25
|
+
keyedOccurrences: readonly KeyedOccurrence[],
|
|
26
|
+
alterations: readonly Alteration<TPayload>[],
|
|
27
|
+
tz: string | undefined,
|
|
28
|
+
windowStart: number,
|
|
29
|
+
windowEnd: number
|
|
30
|
+
): ScheduledEvent<TPayload>[] {
|
|
31
|
+
// Index alterations for THIS template by occurrence key. If multiple
|
|
32
|
+
// alterations target the same key, last-write-wins.
|
|
33
|
+
const altByKey = new Map<string, Alteration<TPayload>>();
|
|
34
|
+
for (const alt of alterations) {
|
|
35
|
+
if (alt.templateId === templateId) altByKey.set(alt.occurrenceKey, alt);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const out: ScheduledEvent<TPayload>[] = [];
|
|
39
|
+
const seenKeys = new Set<string>();
|
|
40
|
+
|
|
41
|
+
for (const occ of keyedOccurrences) {
|
|
42
|
+
seenKeys.add(occ.occurrenceKey);
|
|
43
|
+
const alt = altByKey.get(occ.occurrenceKey);
|
|
44
|
+
|
|
45
|
+
if (alt == null) {
|
|
46
|
+
out.push({
|
|
47
|
+
templateId,
|
|
48
|
+
occurrenceKey: occ.occurrenceKey,
|
|
49
|
+
startTimestamp: occ.startTimestamp,
|
|
50
|
+
endTimestamp: occ.endTimestamp,
|
|
51
|
+
payload: basePayload,
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (alt.kind === "cancel") continue;
|
|
56
|
+
|
|
57
|
+
// reschedule
|
|
58
|
+
const start = alt.startTimestamp ?? occ.startTimestamp;
|
|
59
|
+
// If the alteration specifies a timeSpan, use it (with tz semantics).
|
|
60
|
+
// Otherwise preserve the original UTC duration.
|
|
61
|
+
const end =
|
|
62
|
+
alt.timeSpan != null
|
|
63
|
+
? computeSpanEnd(start, alt.timeSpan, tz)
|
|
64
|
+
: start + (occ.endTimestamp - occ.startTimestamp);
|
|
65
|
+
if (!intersects(start, end, windowStart, windowEnd)) continue;
|
|
66
|
+
out.push({
|
|
67
|
+
templateId,
|
|
68
|
+
occurrenceKey: occ.occurrenceKey,
|
|
69
|
+
startTimestamp: start,
|
|
70
|
+
endTimestamp: end,
|
|
71
|
+
payload: _mergePayload(basePayload, alt.payloadOverride),
|
|
72
|
+
alterationId: alt.id,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Orphan reschedules: alteration's occurrenceKey isn't in the base list
|
|
77
|
+
// (because the original fell outside the window) but the rescheduled time
|
|
78
|
+
// is. Cancels for orphans are no-ops — there was nothing to cancel here.
|
|
79
|
+
for (const alt of altByKey.values()) {
|
|
80
|
+
if (seenKeys.has(alt.occurrenceKey)) continue;
|
|
81
|
+
if (alt.kind !== "reschedule") continue;
|
|
82
|
+
if (alt.startTimestamp == null) continue;
|
|
83
|
+
// No original to inherit duration from; default to 1ms if no timeSpan.
|
|
84
|
+
const end =
|
|
85
|
+
alt.timeSpan != null
|
|
86
|
+
? computeSpanEnd(alt.startTimestamp, alt.timeSpan, tz)
|
|
87
|
+
: alt.startTimestamp + 1;
|
|
88
|
+
if (!intersects(alt.startTimestamp, end, windowStart, windowEnd)) continue;
|
|
89
|
+
out.push({
|
|
90
|
+
templateId,
|
|
91
|
+
occurrenceKey: alt.occurrenceKey,
|
|
92
|
+
startTimestamp: alt.startTimestamp,
|
|
93
|
+
endTimestamp: end,
|
|
94
|
+
payload: _mergePayload(basePayload, alt.payloadOverride),
|
|
95
|
+
alterationId: alt.id,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return out.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _mergePayload<TPayload extends { type: string }>(
|
|
103
|
+
base: TPayload,
|
|
104
|
+
override: Partial<TPayload> | undefined
|
|
105
|
+
): TPayload {
|
|
106
|
+
if (override == null) return base;
|
|
107
|
+
return { ...base, ...override } as TPayload;
|
|
108
|
+
}
|