@reventlessdev/reventless-local 3.0.0-alpha.285 → 3.0.0-alpha.287
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/CHANGELOG.md +36 -0
- package/package.json +11 -11
- package/src/adapter/Scheduler/LocalScheduledPublisher.res +11 -51
- package/src/adapter/Scheduler/LocalScheduledPublisher.res.mjs +7 -40
- package/src/adapter/Scheduler/LocalTimers.res +62 -0
- package/src/adapter/Scheduler/LocalTimers.res.mjs +63 -0
- package/src/components/AutomationSlice_Builder.res +31 -2
- package/src/components/AutomationSlice_Builder.res.mjs +31 -3
- package/src/components/LocalAutomationSchedules.res +59 -0
- package/src/components/LocalAutomationSchedules.res.mjs +72 -0
- package/src/components/LocalAutomationSweep.res +61 -0
- package/src/components/LocalAutomationSweep.res.mjs +83 -0
- package/src/test/TestRunner.res +2 -0
- package/src/test/TestRunner.res.mjs +12 -0
- package/tests/adapter/ScheduledPublisherTest.res +156 -155
- package/tests/adapter/ScheduledPublisherTest.res.mjs +239 -137
- package/tests/components/automationslice/AutomationRescheduleFixtures.res +142 -0
- package/tests/components/automationslice/AutomationRescheduleFixtures.res.mjs +259 -0
- package/tests/components/automationslice/AutomationRescheduleTest.res +96 -0
- package/tests/components/automationslice/AutomationRescheduleTest.res.mjs +177 -0
- package/tests/components/automationslice/AutomationScheduleFixtures.res +118 -0
- package/tests/components/automationslice/AutomationScheduleFixtures.res.mjs +263 -0
- package/tests/components/automationslice/AutomationScheduleTest.res +88 -0
- package/tests/components/automationslice/AutomationScheduleTest.res.mjs +108 -0
- package/tests/components/automationslice/AutomationSliceFixtures.res +6 -2
- package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +16 -4
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +6 -1
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +8 -2
- package/tests/components/automationslice/AutomationSweepFixtures.res +131 -0
- package/tests/components/automationslice/AutomationSweepFixtures.res.mjs +223 -0
- package/tests/components/automationslice/AutomationSweepTest.res +107 -0
- package/tests/components/automationslice/AutomationSweepTest.res.mjs +116 -0
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +4 -1
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +14 -3
- package/tests/components/scheduler/SchedulerTest.res +5 -5
- package/tests/components/scheduler/SchedulerTest.res.mjs +22 -14
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Sury from "sury";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
6
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as Pulumi from "@pulumi/pulumi";
|
|
8
|
+
import * as Clock$Reventless from "@reventlessdev/reventless-spec/src/components/Clock.res.mjs";
|
|
9
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
10
|
+
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
11
|
+
import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
|
|
12
|
+
import * as Schedule$Reventless from "@reventlessdev/reventless-spec/src/types/Schedule.res.mjs";
|
|
13
|
+
import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
|
|
14
|
+
import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
|
|
15
|
+
import * as LocalBus$ReventlessLocal from "../../../src/adapter/LocalBus.res.mjs";
|
|
16
|
+
import * as AutomationSlice$Reventless from "@reventlessdev/reventless-spec/src/components/AutomationSlice.res.mjs";
|
|
17
|
+
import * as TestRunner$ReventlessLocal from "../../../src/test/TestRunner.res.mjs";
|
|
18
|
+
import * as TestFixtures$ReventlessLocal from "../../TestFixtures.res.mjs";
|
|
19
|
+
import * as AutomationSlice_Builder$ReventlessLocal from "../../../src/components/AutomationSlice_Builder.res.mjs";
|
|
20
|
+
import * as AutomationSlice_Schedules$ReventlessCore from "@reventlessdev/reventless-core/src/components/AutomationSlice/AutomationSlice_Schedules.res.mjs";
|
|
21
|
+
import * as LocalAutomationSchedules$ReventlessLocal from "../../../src/components/LocalAutomationSchedules.res.mjs";
|
|
22
|
+
|
|
23
|
+
let name = "DigestPreferencesEventTopic";
|
|
24
|
+
|
|
25
|
+
let eventSchema = Sury.union([
|
|
26
|
+
Sury.$schema(s => ({
|
|
27
|
+
TAG: "DigestTimeChosen",
|
|
28
|
+
recipientId: s.m(Sury.string),
|
|
29
|
+
time: s.m(Schedule$Reventless.timeSchema)
|
|
30
|
+
})),
|
|
31
|
+
Sury.$schema(s => ({
|
|
32
|
+
TAG: "Unsubscribed",
|
|
33
|
+
recipientId: s.m(Sury.string)
|
|
34
|
+
}))
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
let PreferencesSource = {
|
|
38
|
+
Id: undefined,
|
|
39
|
+
name: name,
|
|
40
|
+
eventSchema: eventSchema
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
let name$1 = "LocalDigest";
|
|
44
|
+
|
|
45
|
+
let moduleUrl = import.meta.url;
|
|
46
|
+
|
|
47
|
+
let todoItemSchema = Sury.$schema(s => ({
|
|
48
|
+
recipientId: s.m(Sury.string)
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
let commandSchema = Sury.$schema(s => ({
|
|
52
|
+
TAG: "SendDigest",
|
|
53
|
+
recipientId: s.m(DcbTag$Reventless.string)
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
function authorizationOf(param) {
|
|
57
|
+
return "AllowAuthenticated";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function commandTransition(param) {
|
|
61
|
+
return "Undeclared";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let traits = [];
|
|
65
|
+
|
|
66
|
+
let DigestSpec = {
|
|
67
|
+
name: name$1,
|
|
68
|
+
moduleUrl: moduleUrl,
|
|
69
|
+
todoItemSchema: todoItemSchema,
|
|
70
|
+
commandSchema: commandSchema,
|
|
71
|
+
maxRetries: 2,
|
|
72
|
+
heartbeatInterval: 60,
|
|
73
|
+
targetName: "SendDigest",
|
|
74
|
+
schedule: undefined,
|
|
75
|
+
authorizationOf: authorizationOf,
|
|
76
|
+
commandTransition: commandTransition,
|
|
77
|
+
traits: traits
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function collect(param, param$1, _ctx) {
|
|
81
|
+
let key = param.key;
|
|
82
|
+
return [[
|
|
83
|
+
Clock$Reventless.itemId(key, param.at),
|
|
84
|
+
{
|
|
85
|
+
recipientId: key
|
|
86
|
+
}
|
|
87
|
+
]];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolve(param) {
|
|
91
|
+
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function reschedule(param) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let FromClock = AutomationSlice$Reventless.Mapping.Make({
|
|
99
|
+
Id: Id$Reventless.$$String,
|
|
100
|
+
name: Clock$Reventless.name,
|
|
101
|
+
eventSchema: Clock$Reventless.eventSchema
|
|
102
|
+
})(DigestSpec)({
|
|
103
|
+
collect: collect,
|
|
104
|
+
resolve: resolve,
|
|
105
|
+
reschedule: reschedule
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function collect$1(param, param$1, _ctx) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolve$1(param) {
|
|
113
|
+
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function reschedule$1(event) {
|
|
117
|
+
if (event.TAG === "DigestTimeChosen") {
|
|
118
|
+
return [{
|
|
119
|
+
TAG: "Set",
|
|
120
|
+
key: event.recipientId,
|
|
121
|
+
time: event.time
|
|
122
|
+
}];
|
|
123
|
+
} else {
|
|
124
|
+
return [{
|
|
125
|
+
TAG: "Clear",
|
|
126
|
+
key: event.recipientId
|
|
127
|
+
}];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let FromPreferences = AutomationSlice$Reventless.Mapping.Make({
|
|
132
|
+
Id: Id$Reventless.$$String,
|
|
133
|
+
name: name,
|
|
134
|
+
eventSchema: eventSchema
|
|
135
|
+
})(DigestSpec)({
|
|
136
|
+
collect: collect$1,
|
|
137
|
+
resolve: resolve$1,
|
|
138
|
+
reschedule: reschedule$1
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
function process(_id, item, param) {
|
|
142
|
+
return [
|
|
143
|
+
item.recipientId,
|
|
144
|
+
{
|
|
145
|
+
TAG: "SendDigest",
|
|
146
|
+
recipientId: item.recipientId
|
|
147
|
+
}
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function onExhausted(_id, _item) {
|
|
152
|
+
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let moduleUrl$1 = import.meta.url;
|
|
156
|
+
|
|
157
|
+
AutomationSlice$Reventless.Mappings.Make(DigestSpec);
|
|
158
|
+
|
|
159
|
+
let mappings = [
|
|
160
|
+
FromClock,
|
|
161
|
+
FromPreferences
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
let DigestAutomation = {
|
|
165
|
+
process: process,
|
|
166
|
+
onExhausted: onExhausted,
|
|
167
|
+
moduleUrl: moduleUrl$1,
|
|
168
|
+
mappings: mappings
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
let preferencesResource = Adapter$ReventlessInfra.make(Pulumi.output(name), Pulumi.output(name), Pulumi.output(name), Pulumi.output("memory:InMemory"), undefined, undefined, undefined, undefined, undefined, undefined);
|
|
172
|
+
|
|
173
|
+
let Bus = LocalBus$ReventlessLocal.Make({});
|
|
174
|
+
|
|
175
|
+
TestRunner$ReventlessLocal.setup();
|
|
176
|
+
|
|
177
|
+
let AutomationSliceMaker = AutomationSlice_Builder$ReventlessLocal.Make(Bus);
|
|
178
|
+
|
|
179
|
+
let Digest = AutomationSliceMaker.Make(DigestSpec)(DigestAutomation);
|
|
180
|
+
|
|
181
|
+
let publishedCommands = {
|
|
182
|
+
contents: []
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
async function publishJsons(cmds) {
|
|
186
|
+
publishedCommands.contents = publishedCommands.contents.concat(cmds);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function makeSlice() {
|
|
190
|
+
return Digest.make(Object.fromEntries([[
|
|
191
|
+
name,
|
|
192
|
+
{
|
|
193
|
+
resources: [preferencesResource]
|
|
194
|
+
}
|
|
195
|
+
]]), Pulumi.output(publishJsons), {
|
|
196
|
+
environment: "local",
|
|
197
|
+
platformName: "local",
|
|
198
|
+
pluginName: "TestPlugin",
|
|
199
|
+
sliceName: name$1
|
|
200
|
+
}, undefined, undefined);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function eventJson(event, recipientId) {
|
|
204
|
+
let newrecord = {...TestFixtures$ReventlessLocal.makeTestMeta(name)};
|
|
205
|
+
newrecord.time = new Date(Date.now()).toISOString();
|
|
206
|
+
return [
|
|
207
|
+
newrecord,
|
|
208
|
+
Object.fromEntries([
|
|
209
|
+
[
|
|
210
|
+
"id",
|
|
211
|
+
recipientId
|
|
212
|
+
],
|
|
213
|
+
[
|
|
214
|
+
"meta",
|
|
215
|
+
Util_Sury$Reventless.toJson(newrecord, Message$Reventless.metaSchema)
|
|
216
|
+
],
|
|
217
|
+
[
|
|
218
|
+
"event",
|
|
219
|
+
Util_Sury$Reventless.toJson(event, eventSchema)
|
|
220
|
+
]
|
|
221
|
+
])
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function publish(event, recipientId) {
|
|
226
|
+
let match = eventJson(event, recipientId);
|
|
227
|
+
return await Bus.publishEvent(name, "test", match[0], match[1]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function storedSchedules() {
|
|
231
|
+
return Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.map(Bus.getQueryDbScan(Stdlib_Option.getOr(Digest.schedulesDbName, "")), scan => scan()), []).map(row => Util_Sury$Reventless.fromJson(row, AutomationSlice_Schedules$ReventlessCore.rowSchema)), row => Stdlib_Option.map(AutomationSlice_Schedules$ReventlessCore.timeOf(row), t => [
|
|
232
|
+
row.key,
|
|
233
|
+
t
|
|
234
|
+
])).toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0]));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function timers() {
|
|
238
|
+
return LocalAutomationSchedules$ReventlessLocal.platformFor(name$1).names();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export {
|
|
242
|
+
PreferencesSource,
|
|
243
|
+
DigestSpec,
|
|
244
|
+
FromClock,
|
|
245
|
+
FromPreferences,
|
|
246
|
+
DigestAutomation,
|
|
247
|
+
preferencesResource,
|
|
248
|
+
Bus,
|
|
249
|
+
AutomationSliceMaker,
|
|
250
|
+
Digest,
|
|
251
|
+
publishedCommands,
|
|
252
|
+
publishJsons,
|
|
253
|
+
makeSlice,
|
|
254
|
+
eventJson,
|
|
255
|
+
publish,
|
|
256
|
+
storedSchedules,
|
|
257
|
+
timers,
|
|
258
|
+
}
|
|
259
|
+
/* eventSchema Not a pure module */
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Schedules set by events, on the local platform: a recipient's choice is stored and
|
|
2
|
+
// starts their timer, the timer's tick sends their digest, unsubscribing removes
|
|
3
|
+
// both, and a rebuild from the events reproduces the rows and removes a stray timer.
|
|
4
|
+
|
|
5
|
+
open JestGlobals
|
|
6
|
+
open AutomationRescheduleFixtures
|
|
7
|
+
|
|
8
|
+
type jestObj
|
|
9
|
+
@module("@jest/globals") external jest: jestObj = "jest"
|
|
10
|
+
@send external useFakeTimers: jestObj => unit = "useFakeTimers"
|
|
11
|
+
@send external useRealTimers: jestObj => unit = "useRealTimers"
|
|
12
|
+
@send external setSystemTime: (jestObj, float) => unit = "setSystemTime"
|
|
13
|
+
@send
|
|
14
|
+
external advanceTimersByTimeAsync: (jestObj, float) => promise<unit> = "advanceTimersByTimeAsync"
|
|
15
|
+
|
|
16
|
+
let time = iso => Date.fromString(iso)->Date.getTime
|
|
17
|
+
let at18 = Reventless.Schedule.Calendar({calendar: Daily({hour: 18, minute: 0})})
|
|
18
|
+
let at19Berlin = Reventless.Schedule.Calendar({
|
|
19
|
+
calendar: Daily({hour: 19, minute: 0}),
|
|
20
|
+
zone: "Europe/Berlin",
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
let settle = async () =>
|
|
24
|
+
for _ in 1 to 20 {
|
|
25
|
+
await jest->advanceTimersByTimeAsync(0.)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let sorted = keys => keys->Array.toSorted(String.compare)
|
|
29
|
+
|
|
30
|
+
describe("schedules set by events, locally", () => {
|
|
31
|
+
let slice = ref(None)
|
|
32
|
+
let ops = async () =>
|
|
33
|
+
await slice.contents->Option.getOrThrow->ReventlessCore.Component.operations->TestRunner.resolve
|
|
34
|
+
|
|
35
|
+
let _ = beforeAllAsync(async () => {
|
|
36
|
+
jest->useFakeTimers
|
|
37
|
+
jest->setSystemTime(time("2026-10-16T12:00:00Z"))
|
|
38
|
+
slice := Some(makeSlice())
|
|
39
|
+
let _ = await ops()
|
|
40
|
+
await settle()
|
|
41
|
+
})
|
|
42
|
+
let _ = afterAll(() => {
|
|
43
|
+
LocalAutomationSchedules.reset()
|
|
44
|
+
jest->useRealTimers
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
testPromise("a choice is stored and starts the recipient's timer", async () => {
|
|
48
|
+
await publish(DigestTimeChosen({recipientId: "r1", time: at18}), ~recipientId="r1")
|
|
49
|
+
await publish(DigestTimeChosen({recipientId: "r2", time: at19Berlin}), ~recipientId="r2")
|
|
50
|
+
await settle()
|
|
51
|
+
expect((storedSchedules(), (await timers())->sorted))->toEqual((
|
|
52
|
+
[("r1", at18), ("r2", at19Berlin)],
|
|
53
|
+
["r1", "r2"],
|
|
54
|
+
))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
testPromise("each recipient's tick comes at their own time and sends their digest", async () => {
|
|
58
|
+
// 18:00 UTC is r1's; 19:00 in Berlin is 17:00 UTC, r2's.
|
|
59
|
+
await jest->advanceTimersByTimeAsync(5. *. 3_600_000. +. 1_000.)
|
|
60
|
+
await settle()
|
|
61
|
+
expect(publishedCommands.contents->Array.map(c => c.id))->toEqual(["r2"])
|
|
62
|
+
await jest->advanceTimersByTimeAsync(3_600_000.)
|
|
63
|
+
await settle()
|
|
64
|
+
// Nothing answers r2's command here, so 18:00's pass, an hour on, sends it again.
|
|
65
|
+
expect(publishedCommands.contents->Array.map(c => c.id))->toEqual(["r2", "r2", "r1"])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
testPromise("unsubscribing removes the recipient's row and timer, and only theirs", async () => {
|
|
69
|
+
await publish(Unsubscribed({recipientId: "r1"}), ~recipientId="r1")
|
|
70
|
+
await settle()
|
|
71
|
+
expect((storedSchedules(), await timers()))->toEqual(([("r2", at19Berlin)], ["r2"]))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
testPromise("a later reconcile keeps the times events set", async () => {
|
|
75
|
+
let ops = await ops()
|
|
76
|
+
await ops.reconcileSchedules()
|
|
77
|
+
expect((storedSchedules(), await timers()))->toEqual(([("r2", at19Berlin)], ["r2"]))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
testPromise(
|
|
81
|
+
"a rebuild reproduces the rows from the events and removes a stray timer",
|
|
82
|
+
async () => {
|
|
83
|
+
let platform = LocalAutomationSchedules.platformFor(~slice=DigestSpec.name)
|
|
84
|
+
await platform.put(~key="stray", at18)
|
|
85
|
+
let events =
|
|
86
|
+
[
|
|
87
|
+
eventJson(DigestTimeChosen({recipientId: "r1", time: at18}), ~recipientId="r1"),
|
|
88
|
+
eventJson(DigestTimeChosen({recipientId: "r3", time: at19Berlin}), ~recipientId="r3"),
|
|
89
|
+
eventJson(Unsubscribed({recipientId: "r1"}), ~recipientId="r1"),
|
|
90
|
+
]->Array.map(((_, json)) => json)
|
|
91
|
+
let ops = await ops()
|
|
92
|
+
await ops.rebuildSchedules(events)
|
|
93
|
+
expect((storedSchedules(), (await timers())->sorted))->toEqual(([("r3", at19Berlin)], ["r3"]))
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Globals from "@jest/globals";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
6
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
7
|
+
import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
|
|
8
|
+
import * as TestRunner$ReventlessLocal from "../../../src/test/TestRunner.res.mjs";
|
|
9
|
+
import * as LocalAutomationSchedules$ReventlessLocal from "../../../src/components/LocalAutomationSchedules.res.mjs";
|
|
10
|
+
import * as AutomationRescheduleFixtures$ReventlessLocal from "./AutomationRescheduleFixtures.res.mjs";
|
|
11
|
+
|
|
12
|
+
function time(iso) {
|
|
13
|
+
return new Date(iso).getTime();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let at18 = {
|
|
17
|
+
TAG: "Calendar",
|
|
18
|
+
calendar: {
|
|
19
|
+
TAG: "Daily",
|
|
20
|
+
hour: 18,
|
|
21
|
+
minute: 0
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let at19Berlin = {
|
|
26
|
+
TAG: "Calendar",
|
|
27
|
+
calendar: {
|
|
28
|
+
TAG: "Daily",
|
|
29
|
+
hour: 19,
|
|
30
|
+
minute: 0
|
|
31
|
+
},
|
|
32
|
+
zone: "Europe/Berlin"
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async function settle() {
|
|
36
|
+
for (let _for = 1; _for <= 20; ++_for) {
|
|
37
|
+
await Globals.jest.advanceTimersByTimeAsync(0);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sorted(keys) {
|
|
42
|
+
return keys.toSorted(Primitive_string.compare);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
globalThis.describe("schedules set by events, locally", () => {
|
|
46
|
+
let slice = {
|
|
47
|
+
contents: undefined
|
|
48
|
+
};
|
|
49
|
+
let ops = async () => await TestRunner$ReventlessLocal.resolve(Component$ReventlessCore.operations(Stdlib_Option.getOrThrow(slice.contents, undefined)));
|
|
50
|
+
globalThis.beforeAll(async () => {
|
|
51
|
+
Globals.jest.useFakeTimers();
|
|
52
|
+
Globals.jest.setSystemTime(new Date("2026-10-16T12:00:00Z").getTime());
|
|
53
|
+
slice.contents = Primitive_option.some(AutomationRescheduleFixtures$ReventlessLocal.makeSlice());
|
|
54
|
+
await ops();
|
|
55
|
+
return await settle();
|
|
56
|
+
});
|
|
57
|
+
globalThis.afterAll(() => {
|
|
58
|
+
LocalAutomationSchedules$ReventlessLocal.reset();
|
|
59
|
+
Globals.jest.useRealTimers();
|
|
60
|
+
});
|
|
61
|
+
globalThis.test("a choice is stored and starts the recipient's timer", async () => {
|
|
62
|
+
await AutomationRescheduleFixtures$ReventlessLocal.publish({
|
|
63
|
+
TAG: "DigestTimeChosen",
|
|
64
|
+
recipientId: "r1",
|
|
65
|
+
time: at18
|
|
66
|
+
}, "r1");
|
|
67
|
+
await AutomationRescheduleFixtures$ReventlessLocal.publish({
|
|
68
|
+
TAG: "DigestTimeChosen",
|
|
69
|
+
recipientId: "r2",
|
|
70
|
+
time: at19Berlin
|
|
71
|
+
}, "r2");
|
|
72
|
+
await settle();
|
|
73
|
+
globalThis.expect([
|
|
74
|
+
AutomationRescheduleFixtures$ReventlessLocal.storedSchedules(),
|
|
75
|
+
sorted(await AutomationRescheduleFixtures$ReventlessLocal.timers())
|
|
76
|
+
]).toEqual([
|
|
77
|
+
[
|
|
78
|
+
[
|
|
79
|
+
"r1",
|
|
80
|
+
at18
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
"r2",
|
|
84
|
+
at19Berlin
|
|
85
|
+
]
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
"r1",
|
|
89
|
+
"r2"
|
|
90
|
+
]
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
globalThis.test("each recipient's tick comes at their own time and sends their digest", async () => {
|
|
94
|
+
await Globals.jest.advanceTimersByTimeAsync(5 * 3600000 + 1000);
|
|
95
|
+
await settle();
|
|
96
|
+
globalThis.expect(AutomationRescheduleFixtures$ReventlessLocal.publishedCommands.contents.map(c => c.id)).toEqual(["r2"]);
|
|
97
|
+
await Globals.jest.advanceTimersByTimeAsync(3600000);
|
|
98
|
+
await settle();
|
|
99
|
+
globalThis.expect(AutomationRescheduleFixtures$ReventlessLocal.publishedCommands.contents.map(c => c.id)).toEqual([
|
|
100
|
+
"r2",
|
|
101
|
+
"r2",
|
|
102
|
+
"r1"
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
globalThis.test("unsubscribing removes the recipient's row and timer, and only theirs", async () => {
|
|
106
|
+
await AutomationRescheduleFixtures$ReventlessLocal.publish({
|
|
107
|
+
TAG: "Unsubscribed",
|
|
108
|
+
recipientId: "r1"
|
|
109
|
+
}, "r1");
|
|
110
|
+
await settle();
|
|
111
|
+
globalThis.expect([
|
|
112
|
+
AutomationRescheduleFixtures$ReventlessLocal.storedSchedules(),
|
|
113
|
+
await AutomationRescheduleFixtures$ReventlessLocal.timers()
|
|
114
|
+
]).toEqual([
|
|
115
|
+
[[
|
|
116
|
+
"r2",
|
|
117
|
+
at19Berlin
|
|
118
|
+
]],
|
|
119
|
+
["r2"]
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
globalThis.test("a later reconcile keeps the times events set", async () => {
|
|
123
|
+
let ops$1 = await ops();
|
|
124
|
+
await ops$1.reconcileSchedules();
|
|
125
|
+
globalThis.expect([
|
|
126
|
+
AutomationRescheduleFixtures$ReventlessLocal.storedSchedules(),
|
|
127
|
+
await AutomationRescheduleFixtures$ReventlessLocal.timers()
|
|
128
|
+
]).toEqual([
|
|
129
|
+
[[
|
|
130
|
+
"r2",
|
|
131
|
+
at19Berlin
|
|
132
|
+
]],
|
|
133
|
+
["r2"]
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
globalThis.test("a rebuild reproduces the rows from the events and removes a stray timer", async () => {
|
|
137
|
+
let platform = LocalAutomationSchedules$ReventlessLocal.platformFor(AutomationRescheduleFixtures$ReventlessLocal.DigestSpec.name);
|
|
138
|
+
await platform.put("stray", at18);
|
|
139
|
+
let events = [
|
|
140
|
+
AutomationRescheduleFixtures$ReventlessLocal.eventJson({
|
|
141
|
+
TAG: "DigestTimeChosen",
|
|
142
|
+
recipientId: "r1",
|
|
143
|
+
time: at18
|
|
144
|
+
}, "r1"),
|
|
145
|
+
AutomationRescheduleFixtures$ReventlessLocal.eventJson({
|
|
146
|
+
TAG: "DigestTimeChosen",
|
|
147
|
+
recipientId: "r3",
|
|
148
|
+
time: at19Berlin
|
|
149
|
+
}, "r3"),
|
|
150
|
+
AutomationRescheduleFixtures$ReventlessLocal.eventJson({
|
|
151
|
+
TAG: "Unsubscribed",
|
|
152
|
+
recipientId: "r1"
|
|
153
|
+
}, "r1")
|
|
154
|
+
].map(param => param[1]);
|
|
155
|
+
let ops$1 = await ops();
|
|
156
|
+
await ops$1.rebuildSchedules(events);
|
|
157
|
+
globalThis.expect([
|
|
158
|
+
AutomationRescheduleFixtures$ReventlessLocal.storedSchedules(),
|
|
159
|
+
sorted(await AutomationRescheduleFixtures$ReventlessLocal.timers())
|
|
160
|
+
]).toEqual([
|
|
161
|
+
[[
|
|
162
|
+
"r3",
|
|
163
|
+
at19Berlin
|
|
164
|
+
]],
|
|
165
|
+
["r3"]
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export {
|
|
171
|
+
time,
|
|
172
|
+
at18,
|
|
173
|
+
at19Berlin,
|
|
174
|
+
settle,
|
|
175
|
+
sorted,
|
|
176
|
+
}
|
|
177
|
+
/* Not a pure module */
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// A declared schedule: the demand report every morning at 06:00 UTC. Its one
|
|
2
|
+
// mapping reads the framework's Clock source; each tick makes the day's item and
|
|
3
|
+
// `process` requests the report. The slice is made by `makeSlice`, not at import, so
|
|
4
|
+
// a test can fix the time its schedule is set at.
|
|
5
|
+
|
|
6
|
+
open Reventless
|
|
7
|
+
|
|
8
|
+
module DemandReportSpec = {
|
|
9
|
+
let name = "LocalDemandReport"
|
|
10
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
11
|
+
|
|
12
|
+
@schema
|
|
13
|
+
type todoItem = {day: float}
|
|
14
|
+
|
|
15
|
+
@schema
|
|
16
|
+
type command = RequestDemandReport({day: float})
|
|
17
|
+
|
|
18
|
+
let maxRetries = 2
|
|
19
|
+
let heartbeatInterval = 60
|
|
20
|
+
let targetName = "ReportSettings"
|
|
21
|
+
let schedule = Some(Schedule.Calendar({calendar: Daily({hour: 6, minute: 0})}))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module FromClock = AutomationSlice.Mapping.Make(
|
|
25
|
+
Clock,
|
|
26
|
+
DemandReportSpec,
|
|
27
|
+
{
|
|
28
|
+
open Clock
|
|
29
|
+
let collect = (Tick({key, at}), ~sourceId as _, _ctx) => [
|
|
30
|
+
(itemId(key, at), ({day: at}: DemandReportSpec.todoItem)),
|
|
31
|
+
]
|
|
32
|
+
let resolve = _ => None
|
|
33
|
+
let reschedule = _ => []
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
module DemandReportAutomation: AutomationSlice.Automation with module Spec := DemandReportSpec = {
|
|
38
|
+
let process = (id, item: DemandReportSpec.todoItem, ~now as _) => Some((
|
|
39
|
+
id,
|
|
40
|
+
DemandReportSpec.RequestDemandReport({day: item.day}),
|
|
41
|
+
))
|
|
42
|
+
let onExhausted = (_id, _item: DemandReportSpec.todoItem) => None
|
|
43
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
44
|
+
module M = AutomationSlice.Mappings.Make(DemandReportSpec)
|
|
45
|
+
module type Mapping = M.Mapping
|
|
46
|
+
let mappings: array<module(Mapping)> = [module(FromClock)]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The same slice with its schedule left out: nothing would ever tick it.
|
|
50
|
+
module UnscheduledSpec = {
|
|
51
|
+
include DemandReportSpec
|
|
52
|
+
let name = "LocalUnscheduledReport"
|
|
53
|
+
let schedule: option<Schedule.time> = None
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module FromClockUnscheduled = AutomationSlice.Mapping.Make(
|
|
57
|
+
Clock,
|
|
58
|
+
UnscheduledSpec,
|
|
59
|
+
{
|
|
60
|
+
let collect = (_, ~sourceId as _, _ctx) => []
|
|
61
|
+
let resolve = _ => None
|
|
62
|
+
let reschedule = _ => []
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
module UnscheduledAutomation: AutomationSlice.Automation with module Spec := UnscheduledSpec = {
|
|
67
|
+
let process = (_id, _item: UnscheduledSpec.todoItem, ~now as _) => None
|
|
68
|
+
let onExhausted = (_id, _item: UnscheduledSpec.todoItem) => None
|
|
69
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
70
|
+
module M = AutomationSlice.Mappings.Make(UnscheduledSpec)
|
|
71
|
+
module type Mapping = M.Mapping
|
|
72
|
+
let mappings: array<module(Mapping)> = [module(FromClockUnscheduled)]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module Bus = LocalBus.Make()
|
|
76
|
+
let _ = TestRunner.setup()
|
|
77
|
+
|
|
78
|
+
module AutomationSliceMaker = AutomationSlice_Builder.Make(Bus)
|
|
79
|
+
module DemandReport = AutomationSliceMaker.Make(DemandReportSpec, DemandReportAutomation)
|
|
80
|
+
module Unscheduled = AutomationSliceMaker.Make(UnscheduledSpec, UnscheduledAutomation)
|
|
81
|
+
|
|
82
|
+
let publishedCommands: ref<array<Reventless.Message.commandJson>> = ref([])
|
|
83
|
+
let publishJsons: ReventlessInfra.CommandTopic.publishJsons = async cmds =>
|
|
84
|
+
publishedCommands := publishedCommands.contents->Array.concat(cmds)
|
|
85
|
+
|
|
86
|
+
let context = (sliceName): AutomationSlice.context => {
|
|
87
|
+
environment: "local",
|
|
88
|
+
platformName: "local",
|
|
89
|
+
pluginName: "TestPlugin",
|
|
90
|
+
sliceName,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Makes the demand report, which starts its declared schedule at the current time. */
|
|
94
|
+
let makeSlice = () =>
|
|
95
|
+
DemandReport.make(
|
|
96
|
+
~allEventTopics=Dict.make(),
|
|
97
|
+
~publishJsons=publishJsons->Pulumi.Output.make,
|
|
98
|
+
~context=context(DemandReportSpec.name),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
let makeUnscheduled = () =>
|
|
102
|
+
Unscheduled.make(
|
|
103
|
+
~allEventTopics=Dict.make(),
|
|
104
|
+
~publishJsons=publishJsons->Pulumi.Output.make,
|
|
105
|
+
~context=context(UnscheduledSpec.name),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
/** The stored to-do row of `id`, as the query store holds it. */
|
|
109
|
+
let storedRow = id =>
|
|
110
|
+
Bus.getQueryDbScan(DemandReport.queryDbName)
|
|
111
|
+
->Option.map(scan => scan())
|
|
112
|
+
->Option.getOr([])
|
|
113
|
+
->Array.find(row =>
|
|
114
|
+
row->JSON.Decode.object->Option.flatMap(o => o->Dict.get("id")) == Some(id->JSON.Encode.string)
|
|
115
|
+
)
|
|
116
|
+
->Option.map(row =>
|
|
117
|
+
row->Reventless.Util_Sury.fromJson(ReventlessCore.AutomationSlice_Callback.todoRowSchema)
|
|
118
|
+
)
|