@reventlessdev/reventless-local 3.0.0-alpha.284 → 3.0.0-alpha.286

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 (24) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +9 -9
  3. package/src/adapter/Scheduler/LocalScheduledPublisher.res +43 -36
  4. package/src/adapter/Scheduler/LocalScheduledPublisher.res.mjs +32 -25
  5. package/src/components/AutomationSlice_Builder.res +10 -2
  6. package/src/components/AutomationSlice_Builder.res.mjs +13 -2
  7. package/src/components/LocalAutomationSweep.res +61 -0
  8. package/src/components/LocalAutomationSweep.res.mjs +83 -0
  9. package/src/test/TestRunner.res +1 -0
  10. package/src/test/TestRunner.res.mjs +6 -0
  11. package/tests/adapter/ScheduledPublisherTest.res +156 -155
  12. package/tests/adapter/ScheduledPublisherTest.res.mjs +239 -137
  13. package/tests/components/automationslice/AutomationSliceFixtures.res +2 -2
  14. package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +2 -2
  15. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +4 -1
  16. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +1 -1
  17. package/tests/components/automationslice/AutomationSweepFixtures.res +129 -0
  18. package/tests/components/automationslice/AutomationSweepFixtures.res.mjs +217 -0
  19. package/tests/components/automationslice/AutomationSweepTest.res +107 -0
  20. package/tests/components/automationslice/AutomationSweepTest.res.mjs +116 -0
  21. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +1 -1
  22. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +1 -1
  23. package/tests/components/scheduler/SchedulerTest.res +5 -5
  24. package/tests/components/scheduler/SchedulerTest.res.mjs +22 -14
@@ -1,41 +1,27 @@
1
1
  // Unit tests for LocalScheduledPublisher.
2
- // Uses fake timers to verify createSchedule, deleteSchedule, and reset.
2
+ // Uses fake timers, which also fix Date.now, to check each shape of a time fires when
3
+ // ScheduleTime says it does.
3
4
 
4
5
  open JestGlobals
5
6
 
6
- // ─────────────────────────────────────────────────────────────
7
- // Fake timer bindings
8
- // ─────────────────────────────────────────────────────────────
9
-
10
7
  type jestObj
11
8
  @module("@jest/globals") external jest: jestObj = "jest"
12
9
  @send external useFakeTimers: jestObj => unit = "useFakeTimers"
13
10
  @send external useRealTimers: jestObj => unit = "useRealTimers"
14
- @send external runAllTimers: jestObj => unit = "runAllTimers"
15
- @send external advanceTimersByTime: (jestObj, int) => unit = "advanceTimersByTime"
11
+ @send external advanceTimersByTime: (jestObj, float) => unit = "advanceTimersByTime"
12
+ @send external setSystemTime: (jestObj, float) => unit = "setSystemTime"
16
13
 
17
14
  let _ = beforeAll(() => {
18
15
  jest->useFakeTimers
19
16
  })
20
17
 
21
- // ─────────────────────────────────────────────────────────────
22
18
  // Pulumi mock (needed for Pulumi.Output.make in ScheduledPublisher)
23
- // ─────────────────────────────────────────────────────────────
24
-
25
19
  let _ = TestRunner.setup()
26
20
 
27
- // ─────────────────────────────────────────────────────────────
28
- // Restore real timers after all tests in this file
29
- // ─────────────────────────────────────────────────────────────
30
-
31
21
  let _ = afterAll(() => {
32
22
  jest->useRealTimers
33
23
  })
34
24
 
35
- // ─────────────────────────────────────────────────────────────
36
- // Helper: create a topic resource with a known name
37
- // ─────────────────────────────────────────────────────────────
38
-
39
25
  let makeTopicResource = (topicName: string): ReventlessInfra.Adapter.resolvedResource => {
40
26
  name: topicName,
41
27
  id: topicName,
@@ -49,186 +35,201 @@ let makeTopicResource = (topicName: string): ReventlessInfra.Adapter.resolvedRes
49
35
  tags: Dict.make(),
50
36
  }
51
37
 
38
+ let minute = 60_000.
39
+ let hour = 60. *. minute
40
+ let day = 24. *. hour
41
+ let at = iso => Date.fromString(iso)->Date.getTime
42
+
43
+ // Two microtask ticks: publishEvent uses Effect.all(concurrency=unbounded) which forks
44
+ // child fibers — one tick for the child fiber to offer to the Queue, a second for the
45
+ // drain fiber to run the callback.
46
+ let flush = async () => {
47
+ let _ = await Promise.resolve()
48
+ let _ = await Promise.resolve()
49
+ }
50
+
51
+ let advance = async ms => {
52
+ jest->advanceTimersByTime(ms)
53
+ await flush()
54
+ }
55
+
56
+ module TestBus = LocalBus.Make()
57
+ module SP = LocalScheduledPublisher.Make(TestBus)
58
+
59
+ let topicCount = ref(0)
60
+ let fired = topic => {
61
+ let count = ref(0)
62
+ TestBus.subscribeToEvents(topic, async (_, _, _) => {
63
+ count := count.contents + 1
64
+ })
65
+ count
66
+ }
67
+
68
+ // A fresh topic per test, so a subscriber never sees another test's firings.
69
+ let schedule = async (~now, time) => {
70
+ jest->setSystemTime(now->at)
71
+ topicCount := topicCount.contents + 1
72
+ let topic = `sched-topic-${topicCount.contents->Int.toString}`
73
+ let count = fired(topic)
74
+ let ops = await SP.make(~name="scheduler", ~opts={}).operations->TestRunner.resolve
75
+ await ops.createSchedule([makeTopicResource(topic)], {name: topic, time, payload: "{}"})
76
+ (ops, topic, count)
77
+ }
78
+
52
79
  describe("LocalScheduledPublisher", () => {
53
- describe("createSchedule", () => {
80
+ let _ = afterEach(() => SP.reset())
81
+
82
+ describe("Every", () => {
54
83
  testPromise(
55
- "single-shot schedule fires event after runAllTimers",
84
+ "fires one interval after it is set, and again each interval",
56
85
  async () => {
57
- module TestBus = LocalBus.Make()
58
- module SP = LocalScheduledPublisher.Make(TestBus)
59
- let count: ref<int> = ref(0)
60
- TestBus.subscribeToEvents(
61
- "sched-topic",
62
- async (_, _, _) => {
63
- count := count.contents + 1
64
- },
86
+ let (_, _, count) = await schedule(~now="2026-10-16T10:00:00Z", Every(Minutes(3)))
87
+ await advance(3. *. minute -. 1.)
88
+ expect(count.contents)->toBe(0)
89
+ await advance(1.)
90
+ expect(count.contents)->toBe(1)
91
+ await advance(3. *. minute)
92
+ expect(count.contents)->toBe(2)
93
+ },
94
+ )
95
+
96
+ testPromise(
97
+ "Hours(n) fires after n hours",
98
+ async () => {
99
+ let (_, _, count) = await schedule(~now="2026-10-16T10:00:00Z", Every(Hours(2)))
100
+ await advance(2. *. hour)
101
+ expect(count.contents)->toBe(1)
102
+ },
103
+ )
104
+ })
105
+
106
+ describe("Calendar", () => {
107
+ testPromise(
108
+ "fires at the wall-clock time each day, not 24 hours after it is set",
109
+ async () => {
110
+ let (_, _, count) = await schedule(
111
+ ~now="2026-10-16T05:58:00Z",
112
+ Calendar({calendar: Daily({hour: 6, minute: 0})}),
65
113
  )
66
- let pub = SP.make(~name="scheduler", ~opts={})
67
- let ops = await pub.operations->TestRunner.resolve
68
- let schedule: Reventless.Schedule.schedule = {
69
- name: "one-shot",
70
- rate: Reventless.Schedule.Single(2024, 1, 1, 0, 0),
71
- payload: "{}",
72
- }
73
- await ops.createSchedule([makeTopicResource("sched-topic")], schedule)
74
- jest->runAllTimers
75
- // Two microtask ticks: publishEvent uses Effect.all(concurrency=unbounded) which forks
76
- // child fibers via startFork — one tick for the child fiber to offer to the Queue,
77
- // a second tick for the drain fiber to run the callback.
78
- let _ = await Promise.resolve()
79
- let _ = await Promise.resolve()
114
+ await advance(2. *. minute)
80
115
  expect(count.contents)->toBe(1)
81
- SP.reset()
116
+ await advance(day)
117
+ expect(count.contents)->toBe(2)
82
118
  },
83
119
  )
84
120
 
85
121
  testPromise(
86
- "recurring schedule fires on each interval advance",
122
+ "skips a day whose time falls in the hour daylight saving skips",
87
123
  async () => {
88
- module TestBus = LocalBus.Make()
89
- module SP = LocalScheduledPublisher.Make(TestBus)
90
- let count: ref<int> = ref(0)
91
- TestBus.subscribeToEvents(
92
- "repeat-topic",
93
- async (_, _, _) => {
94
- count := count.contents + 1
95
- },
124
+ let (_, _, count) = await schedule(
125
+ ~now="2027-03-27T12:00:00Z",
126
+ Calendar({calendar: Daily({hour: 2, minute: 30}), zone: "Europe/Berlin"}),
96
127
  )
97
- let pub = SP.make(~name="scheduler2", ~opts={})
98
- let ops = await pub.operations->TestRunner.resolve
99
- let schedule: Reventless.Schedule.schedule = {
100
- name: "every-minute",
101
- rate: Reventless.Schedule.Minutes(1),
102
- payload: "{}",
103
- }
104
- await ops.createSchedule([makeTopicResource("repeat-topic")], schedule)
105
- jest->advanceTimersByTime(60 * 1000)
106
- let _ = await Promise.resolve()
107
- let _ = await Promise.resolve()
108
- jest->advanceTimersByTime(60 * 1000)
109
- let _ = await Promise.resolve()
110
- let _ = await Promise.resolve()
128
+ // 28 March fires; 29 March has no 02:30 in Berlin; 30 March fires
129
+ await advance(at("2027-03-29T12:00:00Z") -. at("2027-03-27T12:00:00Z"))
130
+ expect(count.contents)->toBe(1)
131
+ await advance(at("2027-03-30T12:00:00Z") -. at("2027-03-29T12:00:00Z"))
111
132
  expect(count.contents)->toBe(2)
112
- SP.reset()
113
133
  },
114
134
  )
115
- })
116
135
 
117
- describe("deleteSchedule", () => {
118
136
  testPromise(
119
- "clears a named schedule; advancing timers does not fire it",
137
+ "reaches a firing further away than a timer can wait",
120
138
  async () => {
121
- module TestBus = LocalBus.Make()
122
- module SP = LocalScheduledPublisher.Make(TestBus)
123
- let count: ref<int> = ref(0)
124
- TestBus.subscribeToEvents(
125
- "del-topic",
126
- async (_, _, _) => {
127
- count := count.contents + 1
128
- },
139
+ let (_, _, count) = await schedule(
140
+ ~now="2026-10-16T10:00:00Z",
141
+ Calendar({calendar: Yearly({month: 1, day: 1, hour: 6, minute: 0})}),
129
142
  )
130
- let pub = SP.make(~name="scheduler3", ~opts={})
131
- let ops = await pub.operations->TestRunner.resolve
132
- let schedule: Reventless.Schedule.schedule = {
133
- name: "to-delete",
134
- rate: Reventless.Schedule.Minutes(1),
135
- payload: "{}",
136
- }
137
- await ops.createSchedule([makeTopicResource("del-topic")], schedule)
138
- await ops.deleteSchedule([], "to-delete")
139
- jest->advanceTimersByTime(60 * 1000)
143
+ await advance(at("2027-01-01T05:59:00Z") -. at("2026-10-16T10:00:00Z"))
140
144
  expect(count.contents)->toBe(0)
141
- SP.reset()
145
+ await advance(minute)
146
+ expect(count.contents)->toBe(1)
142
147
  },
143
148
  )
144
149
  })
145
150
 
146
- describe("reset", () => {
151
+ describe("Once", () => {
147
152
  testPromise(
148
- "clears all active timers; subsequent advance does not fire",
153
+ "fires once, a period after it is set",
149
154
  async () => {
150
- module TestBus = LocalBus.Make()
151
- module SP = LocalScheduledPublisher.Make(TestBus)
152
- let count: ref<int> = ref(0)
153
- TestBus.subscribeToEvents(
154
- "reset-topic",
155
- async (_, _, _) => {
156
- count := count.contents + 1
157
- },
155
+ let (_, _, count) = await schedule(
156
+ ~now="2026-10-16T10:00:00Z",
157
+ Once({moment: After(Minutes(10))}),
158
158
  )
159
- let pub = SP.make(~name="scheduler4", ~opts={})
160
- let ops = await pub.operations->TestRunner.resolve
161
- let schedule: Reventless.Schedule.schedule = {
162
- name: "reset-sched",
163
- rate: Reventless.Schedule.Minutes(5),
164
- payload: "{}",
165
- }
166
- await ops.createSchedule([makeTopicResource("reset-topic")], schedule)
167
- SP.reset()
168
- jest->advanceTimersByTime(5 * 60 * 1000)
159
+ await advance(10. *. minute)
160
+ expect(count.contents)->toBe(1)
161
+ await advance(day)
162
+ expect(count.contents)->toBe(1)
163
+ },
164
+ )
165
+
166
+ testPromise(
167
+ "never fires at a moment already past",
168
+ async () => {
169
+ let (_, _, count) = await schedule(
170
+ ~now="2026-10-16T10:00:00Z",
171
+ Once({moment: At({year: 2024, month: 1, day: 1, hour: 0, minute: 0})}),
172
+ )
173
+ await advance(day)
169
174
  expect(count.contents)->toBe(0)
170
175
  },
171
176
  )
172
177
  })
173
178
 
174
- describe("rateToMs", () => {
179
+ describe("createSchedule", () => {
175
180
  testPromise(
176
- "Minutes(n) fires after n minutes",
181
+ "replaces a schedule of the same name",
177
182
  async () => {
178
- module TestBus = LocalBus.Make()
179
- module SP = LocalScheduledPublisher.Make(TestBus)
180
- let count: ref<int> = ref(0)
181
- TestBus.subscribeToEvents(
182
- "rate-topic",
183
- async (_, _, _) => {
184
- count := count.contents + 1
185
- },
183
+ let (ops, topic, count) = await schedule(~now="2026-10-16T10:00:00Z", Every(Minutes(1)))
184
+ await ops.createSchedule(
185
+ [makeTopicResource(topic)],
186
+ {name: topic, time: Every(Minutes(5)), payload: "{}"},
186
187
  )
187
- let pub = SP.make(~name="rate-scheduler", ~opts={})
188
- let ops = await pub.operations->TestRunner.resolve
189
- let schedule: Reventless.Schedule.schedule = {
190
- name: "rate-sched",
191
- rate: Reventless.Schedule.Minutes(3),
192
- payload: "{}",
193
- }
194
- await ops.createSchedule([makeTopicResource("rate-topic")], schedule)
195
- // Advance by slightly less than 3 minutes — should not fire
196
- jest->advanceTimersByTime(3 * 60 * 1000 - 1)
197
- expect(count.contents)->toBe(0)
198
- // Advance the remaining millisecond — now fires; two ticks for Effect.all drain
199
- jest->advanceTimersByTime(1)
200
- let _ = await Promise.resolve()
201
- let _ = await Promise.resolve()
188
+ await advance(5. *. minute)
202
189
  expect(count.contents)->toBe(1)
203
- SP.reset()
204
190
  },
205
191
  )
206
192
 
207
193
  testPromise(
208
- "Hours(n) fires after n hours",
194
+ "refuses a time that cannot fire",
209
195
  async () => {
210
- module TestBus = LocalBus.Make()
211
- module SP = LocalScheduledPublisher.Make(TestBus)
212
- let count: ref<int> = ref(0)
213
- TestBus.subscribeToEvents(
214
- "hours-topic",
215
- async (_, _, _) => {
216
- count := count.contents + 1
196
+ let ops = await SP.make(~name="scheduler", ~opts={}).operations->TestRunner.resolve
197
+ let refused = switch await ops.createSchedule(
198
+ [makeTopicResource("refused")],
199
+ {
200
+ name: "refused",
201
+ time: Calendar({calendar: Daily({hour: 25, minute: 0}), zone: "Mars/Base"}),
202
+ payload: "{}",
217
203
  },
218
- )
219
- let pub = SP.make(~name="hours-scheduler", ~opts={})
220
- let ops = await pub.operations->TestRunner.resolve
221
- let schedule: Reventless.Schedule.schedule = {
222
- name: "hours-sched",
223
- rate: Reventless.Schedule.Hours(2),
224
- payload: "{}",
204
+ ) {
205
+ | _ => false
206
+ | exception ReventlessCore.ScheduleTime.InvalidTime(_) => true
225
207
  }
226
- await ops.createSchedule([makeTopicResource("hours-topic")], schedule)
227
- jest->advanceTimersByTime(2 * 60 * 60 * 1000)
228
- let _ = await Promise.resolve()
229
- let _ = await Promise.resolve()
230
- expect(count.contents)->toBe(1)
208
+ expect(refused)->toBe(true)
209
+ },
210
+ )
211
+ })
212
+
213
+ describe("deleteSchedule", () => {
214
+ testPromise(
215
+ "stops a schedule; advancing timers does not fire it",
216
+ async () => {
217
+ let (ops, topic, count) = await schedule(~now="2026-10-16T10:00:00Z", Every(Minutes(1)))
218
+ await ops.deleteSchedule([], topic)
219
+ await advance(minute)
220
+ expect(count.contents)->toBe(0)
221
+ },
222
+ )
223
+ })
224
+
225
+ describe("reset", () => {
226
+ testPromise(
227
+ "clears all active timers; subsequent advance does not fire",
228
+ async () => {
229
+ let (_, _, count) = await schedule(~now="2026-10-16T10:00:00Z", Every(Minutes(5)))
231
230
  SP.reset()
231
+ await advance(5. *. minute)
232
+ expect(count.contents)->toBe(0)
232
233
  },
233
234
  )
234
235
  })