@reventlessdev/reventless-aws 3.0.0-alpha.382 → 3.0.0-alpha.384
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 +27 -0
- package/package.json +10 -10
- package/src/Platform.res +9 -2
- package/src/Platform.res.mjs +16 -16
- package/src/adapter/Runtime/AutomationSliceEntryPoint.mjs +13 -9
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +227 -1
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +185 -7
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +150 -4
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +92 -6
- package/src/adapter/ScheduledPublisher/ScheduledPublisher_EventBridgeScheduler_Runtime.res +104 -15
- package/src/adapter/ScheduledPublisher/ScheduledPublisher_EventBridgeScheduler_Runtime.res.mjs +122 -34
- package/src/components/Api/AppSync_Adapter.res +6 -9
- package/src/components/Api/AppSync_Adapter.res.mjs +9 -6
- package/src/components/AutomationSlice_Builder.res +24 -0
- package/src/components/AutomationSlice_Builder.res.mjs +20 -2
- package/src/components/OutboundTranslationSlice_Builder.res.mjs +1 -1
- package/tests/AppSync_AdapterTest.res +52 -0
- package/tests/AppSync_AdapterTest.res.mjs +35 -0
- package/tests/AutomationSliceEntryPoint_OpsTest.res +91 -0
- package/tests/AutomationSliceEntryPoint_OpsTest.res.mjs +171 -35
- package/tests/ScheduledPublisher_EventBridgeSchedulerTest.res +25 -0
- package/tests/ScheduledPublisher_EventBridgeSchedulerTest.res.mjs +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,33 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.384 (2026-10-16)
|
|
7
|
+
|
|
8
|
+
* fix(auth)!: only operators read the framework's own stores ([a2fd202](https://github.com/ReventlessDev/reventless-core/commit/a2fd2022b6876dae855b185df7f8263353fc971d))
|
|
9
|
+
|
|
10
|
+
### BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
* a caller outside the elevated groups is refused the
|
|
13
|
+
framework's to-do, schedule and audit stores. On AWS, a roles-gated
|
|
14
|
+
view's …ByIds, …Refs, …Items and …By<Index> fields refuse callers
|
|
15
|
+
outside its roles.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# 3.0.0-alpha.383 (2026-10-16)
|
|
20
|
+
|
|
21
|
+
* feat!(automation): schedules set by events ([53bc436](https://github.com/ReventlessDev/reventless-core/commit/53bc4363dffce81ef36475728bf0dae704716021))
|
|
22
|
+
* feat!(automation): declared schedules tick an automation ([420acd2](https://github.com/ReventlessDev/reventless-core/commit/420acd2233aadf700141a31d9d3e0fdc8074bef7))
|
|
23
|
+
|
|
24
|
+
### BREAKING CHANGES
|
|
25
|
+
|
|
26
|
+
* a hand-written AutomationSlice.Mapping.Make the PPX does
|
|
27
|
+
not see must define `let reschedule = _ => []`.
|
|
28
|
+
* a hand-written AutomationSlice spec the PPX does not see must
|
|
29
|
+
declare `let schedule = None`.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
6
33
|
# 3.0.0-alpha.382 (2026-10-16)
|
|
7
34
|
|
|
8
35
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-aws",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.384",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -22,19 +22,19 @@
|
|
|
22
22
|
"sury": "11.0.0",
|
|
23
23
|
"uuid": "^13.0.0",
|
|
24
24
|
"yaml": "^2.8.3",
|
|
25
|
-
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.
|
|
26
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.12",
|
|
27
|
-
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.28",
|
|
25
|
+
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.32",
|
|
28
26
|
"@reventlessdev/rescript-node": "2.0.0-alpha.22",
|
|
27
|
+
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.29",
|
|
28
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.12",
|
|
29
29
|
"@reventlessdev/rescript-effect": "1.0.0-alpha.0",
|
|
30
30
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.23",
|
|
31
|
-
"@reventlessdev/rescript-web": "1.0.0-alpha.7",
|
|
32
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.295",
|
|
33
31
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
|
|
34
|
-
"@reventlessdev/
|
|
35
|
-
"@reventlessdev/reventless-
|
|
36
|
-
"@reventlessdev/reventless-
|
|
37
|
-
"@reventlessdev/reventless-
|
|
32
|
+
"@reventlessdev/rescript-web": "1.0.0-alpha.7",
|
|
33
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.297",
|
|
34
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.194",
|
|
35
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.161",
|
|
36
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.40",
|
|
37
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.166"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"rescript": "12.3.1",
|
package/src/Platform.res
CHANGED
|
@@ -815,12 +815,16 @@ module MakeWithConfig = (
|
|
|
815
815
|
| None => resolveHookedApi()
|
|
816
816
|
}
|
|
817
817
|
|
|
818
|
+
// Set by `makeScheduler`, before any plugin builds; the automation Lambda's
|
|
819
|
+
// finalizer, inside `hooks`, reads it for the stack's schedule group.
|
|
820
|
+
let schedulerRoleUrnRef = ref(Pulumi.Output.make(""))
|
|
821
|
+
|
|
818
822
|
let hooks: ReventlessCore.Plugin_Helpers.platformHooks = {
|
|
819
823
|
// AWS uses Interstack for admin extension points — leave ref at empty dict.
|
|
820
824
|
adminExtensionPoints: ref(Pulumi.Output.make(Dict.make())),
|
|
821
825
|
// Platform context — populated by makePlatform/deployPlugin before plugin build.
|
|
822
826
|
scheduler: ref(None),
|
|
823
|
-
schedulerRoleUrn:
|
|
827
|
+
schedulerRoleUrn: schedulerRoleUrnRef,
|
|
824
828
|
api: hooksApiRef,
|
|
825
829
|
apiRole: hooksApiRoleRef,
|
|
826
830
|
adminApi: hooksAdminApiRef,
|
|
@@ -920,7 +924,10 @@ module MakeWithConfig = (
|
|
|
920
924
|
onDcbSlicesCreated: dcbEventLogUnknown => {
|
|
921
925
|
let dcbEventLog = dcbEventLogUnknown->asDcbEventLog
|
|
922
926
|
StateViewSliceRuntime_Builder_Single.finishWithDcbEventLog(dcbEventLog)
|
|
923
|
-
AutomationSliceRuntime_Builder_Single.finishWithDcbEventLog(
|
|
927
|
+
AutomationSliceRuntime_Builder_Single.finishWithDcbEventLog(
|
|
928
|
+
~schedulerRoleArn=schedulerRoleUrnRef.contents,
|
|
929
|
+
dcbEventLog,
|
|
930
|
+
)
|
|
924
931
|
},
|
|
925
932
|
// Heartbeat EP channel hook — extracts SQS queue URL for heartbeat Lambda handler
|
|
926
933
|
// and records the calling plugin's id so the Lambda runtime can emit Connect commands
|
package/src/Platform.res.mjs
CHANGED
|
@@ -544,6 +544,9 @@ function MakeWithConfig(Config) {
|
|
|
544
544
|
return resolveHookedApi();
|
|
545
545
|
}
|
|
546
546
|
};
|
|
547
|
+
let schedulerRoleUrnRef = {
|
|
548
|
+
contents: Pulumi.output("")
|
|
549
|
+
};
|
|
547
550
|
let hooks_subscriptionInfraHook = Stdlib_Option.map(domainEventsApiOpt, eventsApi => (params => {
|
|
548
551
|
let allEventTopics = params.allEventTopics;
|
|
549
552
|
let allQueryDbs = params.allQueryDbs;
|
|
@@ -604,7 +607,7 @@ function MakeWithConfig(Config) {
|
|
|
604
607
|
};
|
|
605
608
|
let hooks_onDcbSlicesCreated = dcbEventLogUnknown => {
|
|
606
609
|
StateViewSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(dcbEventLogUnknown);
|
|
607
|
-
AutomationSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(dcbEventLogUnknown);
|
|
610
|
+
AutomationSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(schedulerRoleUrnRef.contents, dcbEventLogUnknown);
|
|
608
611
|
};
|
|
609
612
|
let hooks_onHeartbeatEpChannelAvailable = (remoteChannelUnknown, pluginId, heartbeatInterval) => {
|
|
610
613
|
let resource = remoteChannelUnknown.resources[0];
|
|
@@ -620,9 +623,6 @@ function MakeWithConfig(Config) {
|
|
|
620
623
|
let hooks_scheduler = {
|
|
621
624
|
contents: undefined
|
|
622
625
|
};
|
|
623
|
-
let hooks_schedulerRoleUrn = {
|
|
624
|
-
contents: Pulumi.output("")
|
|
625
|
-
};
|
|
626
626
|
let hooks_deployTarget = {
|
|
627
627
|
contents: "Domain"
|
|
628
628
|
};
|
|
@@ -638,7 +638,7 @@ function MakeWithConfig(Config) {
|
|
|
638
638
|
onHeartbeatEpChannelAvailable: hooks_onHeartbeatEpChannelAvailable,
|
|
639
639
|
adminExtensionPoints: hooks_adminExtensionPoints,
|
|
640
640
|
scheduler: hooks_scheduler,
|
|
641
|
-
schedulerRoleUrn:
|
|
641
|
+
schedulerRoleUrn: schedulerRoleUrnRef,
|
|
642
642
|
api: hooksApiRef,
|
|
643
643
|
apiRole: hooksApiRoleRef,
|
|
644
644
|
adminApi: hooksAdminApiRef,
|
|
@@ -713,7 +713,7 @@ function MakeWithConfig(Config) {
|
|
|
713
713
|
let makeScheduler = () => {
|
|
714
714
|
let component = Scheduler$ReventlessAws.make(undefined);
|
|
715
715
|
let outputs = Component$ReventlessCore.outputs(component);
|
|
716
|
-
|
|
716
|
+
schedulerRoleUrnRef.contents = outputs.resource.urn;
|
|
717
717
|
return Component$ReventlessCore.operations(component);
|
|
718
718
|
};
|
|
719
719
|
let provisionPgChangeFeedRelay = () => {
|
|
@@ -884,7 +884,7 @@ function MakeWithConfig(Config) {
|
|
|
884
884
|
} else {
|
|
885
885
|
pluginReadModelTableName = undefined;
|
|
886
886
|
}
|
|
887
|
-
PluginExtensionPointRuntime_Builder$ReventlessAws.registerPluginExtensionPoint(pluginReadModelTableName,
|
|
887
|
+
PluginExtensionPointRuntime_Builder$ReventlessAws.registerPluginExtensionPoint(pluginReadModelTableName, schedulerRoleUrnRef.contents, undefined);
|
|
888
888
|
let pluginEpEventTopicArn = Output$Pulumi.flatMap(admin.extensionPointsOutputs, eps => {
|
|
889
889
|
let ep = eps.find(ep => ep.name === PluginExtensionPointSpec$ReventlessInfra.name);
|
|
890
890
|
if (ep !== undefined) {
|
|
@@ -900,7 +900,7 @@ function MakeWithConfig(Config) {
|
|
|
900
900
|
return Pulumi.output("NOT_AVAILABLE");
|
|
901
901
|
}
|
|
902
902
|
});
|
|
903
|
-
PluginRuntime_Builder$ReventlessAws.registerConfig(pluginEpEventTopicArn, pluginReadModelTableName,
|
|
903
|
+
PluginRuntime_Builder$ReventlessAws.registerConfig(pluginEpEventTopicArn, pluginReadModelTableName, schedulerRoleUrnRef.contents, undefined, undefined, domainApiId, Config.cloner, undefined);
|
|
904
904
|
let offloadProtection = Util_StoreLayout$ReventlessAws.protectionOfDeployedStack();
|
|
905
905
|
let offloadBucket = new (Aws.s3.Bucket)("reventless-offload", {
|
|
906
906
|
forceDestroy: offloadProtection === "Unprotected",
|
|
@@ -1960,6 +1960,9 @@ function Make($star) {
|
|
|
1960
1960
|
return resolveHookedApi();
|
|
1961
1961
|
}
|
|
1962
1962
|
};
|
|
1963
|
+
let schedulerRoleUrnRef = {
|
|
1964
|
+
contents: Pulumi.output("")
|
|
1965
|
+
};
|
|
1963
1966
|
let hooks_subscriptionInfraHook = Stdlib_Option.map(domainEventsApiOpt, eventsApi => (params => {
|
|
1964
1967
|
let allEventTopics = params.allEventTopics;
|
|
1965
1968
|
let allQueryDbs = params.allQueryDbs;
|
|
@@ -2020,7 +2023,7 @@ function Make($star) {
|
|
|
2020
2023
|
};
|
|
2021
2024
|
let hooks_onDcbSlicesCreated = dcbEventLogUnknown => {
|
|
2022
2025
|
StateViewSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(dcbEventLogUnknown);
|
|
2023
|
-
AutomationSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(dcbEventLogUnknown);
|
|
2026
|
+
AutomationSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(schedulerRoleUrnRef.contents, dcbEventLogUnknown);
|
|
2024
2027
|
};
|
|
2025
2028
|
let hooks_onHeartbeatEpChannelAvailable = (remoteChannelUnknown, pluginId, heartbeatInterval) => {
|
|
2026
2029
|
let resource = remoteChannelUnknown.resources[0];
|
|
@@ -2036,9 +2039,6 @@ function Make($star) {
|
|
|
2036
2039
|
let hooks_scheduler = {
|
|
2037
2040
|
contents: undefined
|
|
2038
2041
|
};
|
|
2039
|
-
let hooks_schedulerRoleUrn = {
|
|
2040
|
-
contents: Pulumi.output("")
|
|
2041
|
-
};
|
|
2042
2042
|
let hooks_deployTarget = {
|
|
2043
2043
|
contents: "Domain"
|
|
2044
2044
|
};
|
|
@@ -2054,7 +2054,7 @@ function Make($star) {
|
|
|
2054
2054
|
onHeartbeatEpChannelAvailable: hooks_onHeartbeatEpChannelAvailable,
|
|
2055
2055
|
adminExtensionPoints: hooks_adminExtensionPoints,
|
|
2056
2056
|
scheduler: hooks_scheduler,
|
|
2057
|
-
schedulerRoleUrn:
|
|
2057
|
+
schedulerRoleUrn: schedulerRoleUrnRef,
|
|
2058
2058
|
api: hooksApiRef,
|
|
2059
2059
|
apiRole: hooksApiRoleRef,
|
|
2060
2060
|
adminApi: hooksAdminApiRef,
|
|
@@ -2129,7 +2129,7 @@ function Make($star) {
|
|
|
2129
2129
|
let makeScheduler = () => {
|
|
2130
2130
|
let component = Scheduler$ReventlessAws.make(undefined);
|
|
2131
2131
|
let outputs = Component$ReventlessCore.outputs(component);
|
|
2132
|
-
|
|
2132
|
+
schedulerRoleUrnRef.contents = outputs.resource.urn;
|
|
2133
2133
|
return Component$ReventlessCore.operations(component);
|
|
2134
2134
|
};
|
|
2135
2135
|
let provisionPgChangeFeedRelay = () => {
|
|
@@ -2283,7 +2283,7 @@ function Make($star) {
|
|
|
2283
2283
|
} else {
|
|
2284
2284
|
pluginReadModelTableName = undefined;
|
|
2285
2285
|
}
|
|
2286
|
-
PluginExtensionPointRuntime_Builder$ReventlessAws.registerPluginExtensionPoint(pluginReadModelTableName,
|
|
2286
|
+
PluginExtensionPointRuntime_Builder$ReventlessAws.registerPluginExtensionPoint(pluginReadModelTableName, schedulerRoleUrnRef.contents, undefined);
|
|
2287
2287
|
let pluginEpEventTopicArn = Output$Pulumi.flatMap(admin.extensionPointsOutputs, eps => {
|
|
2288
2288
|
let ep = eps.find(ep => ep.name === PluginExtensionPointSpec$ReventlessInfra.name);
|
|
2289
2289
|
if (ep !== undefined) {
|
|
@@ -2299,7 +2299,7 @@ function Make($star) {
|
|
|
2299
2299
|
return Pulumi.output("NOT_AVAILABLE");
|
|
2300
2300
|
}
|
|
2301
2301
|
});
|
|
2302
|
-
PluginRuntime_Builder$ReventlessAws.registerConfig(pluginEpEventTopicArn, pluginReadModelTableName,
|
|
2302
|
+
PluginRuntime_Builder$ReventlessAws.registerConfig(pluginEpEventTopicArn, pluginReadModelTableName, schedulerRoleUrnRef.contents, undefined, undefined, domainApiId, false, undefined);
|
|
2303
2303
|
let offloadProtection = Util_StoreLayout$ReventlessAws.protectionOfDeployedStack();
|
|
2304
2304
|
let offloadBucket = new (Aws.s3.Bucket)("reventless-offload", {
|
|
2305
2305
|
forceDestroy: offloadProtection === "Unprotected",
|
|
@@ -70,7 +70,8 @@ async function buildAllHandlers() {
|
|
|
70
70
|
: Ops.makeAutomationRegisteredHandler(
|
|
71
71
|
entry,
|
|
72
72
|
specModule.name,
|
|
73
|
-
automationSliceCallbackMake(specModule)(bodyModule)
|
|
73
|
+
automationSliceCallbackMake(specModule)(bodyModule),
|
|
74
|
+
specModule.schedule
|
|
74
75
|
);
|
|
75
76
|
// A slice can listen on several streams — its plugin's DCB log and any
|
|
76
77
|
// Aggregate EventTopic it named. Register the one handler under each, since
|
|
@@ -100,11 +101,14 @@ const routed = StreamOps.makeRoutedHandler(
|
|
|
100
101
|
builtPromise.then((b) => b.handlers),
|
|
101
102
|
);
|
|
102
103
|
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
104
|
+
// Three invocations carry no `records`: the sweep (the EventBridge rule's constant
|
|
105
|
+
// Input), a schedule's tick (the Scheduler's input), and the deploy's reconcile of
|
|
106
|
+
// the declared schedules. Telling them apart means probing an untyped Lambda
|
|
107
|
+
// payload, which is what this shell exists for. Everything they dispatch to is
|
|
108
|
+
// type-checked in AutomationSliceEntryPoint_Ops.
|
|
109
|
+
export const handler = async (event, context) => {
|
|
110
|
+
if (event?.reventlessSweep) return Ops.runSweeps((await builtPromise).sweeps);
|
|
111
|
+
if (event?.reventlessTick) return Ops.runTick((await builtPromise).sweeps, event.reventlessTick);
|
|
112
|
+
if (event?.reventlessReconcile) return Ops.reconcileSchedules((await builtPromise).sweeps);
|
|
113
|
+
return routed(event, context);
|
|
114
|
+
};
|
|
@@ -32,6 +32,8 @@ type handlerEntry = {
|
|
|
32
32
|
commandQueueIsFifo: option<bool>,
|
|
33
33
|
sourceUrn: string,
|
|
34
34
|
context: option<Reventless.AutomationSlice.context>,
|
|
35
|
+
/** The slice's schedules store; absent for a slice that maps no `Clock`. */
|
|
36
|
+
schedulesTableName: option<string>,
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
let strOf = (obj: dict<JSON.t>, key: string): option<string> =>
|
|
@@ -59,6 +61,7 @@ let decodeEntry = (json: JSON.t): option<handlerEntry> =>
|
|
|
59
61
|
commandQueueIsFifo: h->Dict.get("commandQueueIsFifo")->Option.flatMap(JSON.Decode.bool),
|
|
60
62
|
sourceUrn: h->strOf("sourceUrn")->Option.getOr(""),
|
|
61
63
|
context: h->Dict.get("context")->Option.flatMap(decodeContext),
|
|
64
|
+
schedulesTableName: h->strOf("schedulesTableName"),
|
|
62
65
|
})
|
|
63
66
|
|
|
64
67
|
// A compact entry names its slice and carries only what the deploy resolved;
|
|
@@ -96,6 +99,7 @@ let decodeCompactEntry = (
|
|
|
96
99
|
// urns stay in the config for the multi-source registration to use.
|
|
97
100
|
sourceUrn: urns->Array.get(0)->Option.getOr(""),
|
|
98
101
|
context: m->Dict.get("context")->Option.flatMap(decodeContext),
|
|
102
|
+
schedulesTableName: h->strOf("s"),
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
|
|
@@ -286,6 +290,94 @@ let makeLoadTodoItems = (
|
|
|
286
290
|
}
|
|
287
291
|
}
|
|
288
292
|
|
|
293
|
+
// ── An automation's schedules ───────────────────────────────────────────────
|
|
294
|
+
// The rows are the truth: they are written before the platform is told, and a
|
|
295
|
+
// cold container reads them all once, as it does the to-do list. Unlike to-do
|
|
296
|
+
// rows, a schedule row can go, so the sync deletes what the dict no longer holds.
|
|
297
|
+
|
|
298
|
+
let scheduleTable = (tableName): Util_DynamoDb_Runtime.resolvedTable => {
|
|
299
|
+
id: "",
|
|
300
|
+
name: tableName,
|
|
301
|
+
arn: "",
|
|
302
|
+
hashKey: "id",
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let makeScheduleStore = (
|
|
306
|
+
~tableName,
|
|
307
|
+
~rows: dict<ReventlessCore.AutomationSlice_Schedules.row>,
|
|
308
|
+
~comp,
|
|
309
|
+
) => {
|
|
310
|
+
let table = scheduleTable(tableName)
|
|
311
|
+
let save = QueryDbStorage_DynamoDb_Runtime.save(table)
|
|
312
|
+
let delete = QueryDbStorage_DynamoDb_Runtime.delete(table)
|
|
313
|
+
let stored: ref<array<string>> = ref([])
|
|
314
|
+
let loaded = ref(false)
|
|
315
|
+
let load = async () =>
|
|
316
|
+
if !loaded.contents {
|
|
317
|
+
loaded := true
|
|
318
|
+
let params: AwsSdk.DynamoDb.DocumentClient.ScanCommand.input = {
|
|
319
|
+
tableName,
|
|
320
|
+
consistentRead: true,
|
|
321
|
+
}
|
|
322
|
+
let _ = await Util_DynamoDb_Runtime.scanStream(params)
|
|
323
|
+
->Stream.runCollect
|
|
324
|
+
->Effect.map(items =>
|
|
325
|
+
items->Array.forEach(item => {
|
|
326
|
+
let json = item->JSON.stringifyAny->Option.getOr("")->JSON.parseOrThrow
|
|
327
|
+
switch json->Reventless.Util_Sury.fromJson(
|
|
328
|
+
ReventlessCore.AutomationSlice_Schedules.rowSchema,
|
|
329
|
+
) {
|
|
330
|
+
| row if rows->Dict.get(row.key)->Option.isNone => rows->Dict.set(row.key, row)
|
|
331
|
+
| _ => ()
|
|
332
|
+
| exception _ => ()
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
)
|
|
336
|
+
->Effect.catchAll(err =>
|
|
337
|
+
ReventlessCore.EffectLogger.logError(
|
|
338
|
+
~comp,
|
|
339
|
+
`restore: couldn't read the schedules from ${tableName}: ${DynamoDb_Error.message(err)}`,
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
->Effect.runPromise
|
|
343
|
+
stored := rows->Dict.keysToArray
|
|
344
|
+
}
|
|
345
|
+
let sync = async () => {
|
|
346
|
+
let keys = rows->Dict.keysToArray
|
|
347
|
+
let _ = await rows
|
|
348
|
+
->Dict.toArray
|
|
349
|
+
->Array.map(((key, row)) =>
|
|
350
|
+
save(
|
|
351
|
+
key,
|
|
352
|
+
row->Reventless.Util_Sury.toJson(ReventlessCore.AutomationSlice_Schedules.rowSchema),
|
|
353
|
+
ReventlessCore.QueryDb.Overwrite,
|
|
354
|
+
None,
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
->Promise.all
|
|
358
|
+
let _ = await stored.contents
|
|
359
|
+
->Array.filter(key => !(keys->Array.includes(key)))
|
|
360
|
+
->Array.map(key => delete(key, None))
|
|
361
|
+
->Promise.all
|
|
362
|
+
stored := keys
|
|
363
|
+
}
|
|
364
|
+
(load, sync)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** This Lambda's own ARN, which the slice's schedules invoke. */
|
|
368
|
+
let ownArn = () => {
|
|
369
|
+
let env = key => NodeProcess.env->Dict.get(key)->Option.getOr("")
|
|
370
|
+
let account = env("SCHEDULER_ROLE_ARN")->String.split(":")->Array.get(4)->Option.getOr("")
|
|
371
|
+
`arn:aws:lambda:${env("AWS_REGION")}:${account}:function:${env("AWS_LAMBDA_FUNCTION_NAME")}`
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let platformSchedulesOf = (~slice) =>
|
|
375
|
+
ScheduledPublisher_EventBridgeScheduler_Runtime.platformSchedules(
|
|
376
|
+
~roleArn=NodeProcess.env->Dict.get("SCHEDULER_ROLE_ARN")->Option.getOr(""),
|
|
377
|
+
~lambdaArn=ownArn(),
|
|
378
|
+
~slice,
|
|
379
|
+
)
|
|
380
|
+
|
|
289
381
|
// ── Platform capabilities ───────────────────────────────────────────────────
|
|
290
382
|
|
|
291
383
|
/**
|
|
@@ -361,6 +453,32 @@ let capabilities = (): Reventless.Capabilities.t => {
|
|
|
361
453
|
type automationCallback = ReventlessCore.AutomationSlice_Callback.runtime
|
|
362
454
|
type outboundCallback<'event> = ReventlessCore.OutboundTranslationSlice_Callback.runtime<'event>
|
|
363
455
|
|
|
456
|
+
/** A scheduled slice's schedule rows, as the Lambda keeps them: load once, sync,
|
|
457
|
+
and the platform that follows them. */
|
|
458
|
+
type schedulesStep = {
|
|
459
|
+
load: unit => promise<unit>,
|
|
460
|
+
sync: unit => promise<unit>,
|
|
461
|
+
platform: ReventlessInfra.AutomationSlice.platformSchedules,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
let loadSchedules = async step =>
|
|
465
|
+
switch step {
|
|
466
|
+
| Some({load}) => await load()
|
|
467
|
+
| None => ()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// The rows are stored first, then the platform follows them.
|
|
471
|
+
let followSchedules = async (step, callback: automationCallback) =>
|
|
472
|
+
switch step {
|
|
473
|
+
| Some({sync, platform}) =>
|
|
474
|
+
let changes = callback.takeScheduleChanges()
|
|
475
|
+
if changes->Array.length > 0 {
|
|
476
|
+
await sync()
|
|
477
|
+
await ReventlessCore.AutomationSlice_Schedules.tell(platform, changes)
|
|
478
|
+
}
|
|
479
|
+
| None => ()
|
|
480
|
+
}
|
|
481
|
+
|
|
364
482
|
// Automation: phase 1 takes the RAW envelope JSONs — the callback routes by
|
|
365
483
|
// `meta.service` and unwraps `{id, meta, event}` itself — plus the context.
|
|
366
484
|
let makeAutomationJsonEventsHandler = (
|
|
@@ -369,6 +487,7 @@ let makeAutomationJsonEventsHandler = (
|
|
|
369
487
|
~publishJsons: ReventlessCore.CommandTopic.publishJsons,
|
|
370
488
|
~syncTodoItems: unit => promise<unit>,
|
|
371
489
|
~loadTodoItems: unit => promise<unit>,
|
|
490
|
+
~schedules: unit => option<schedulesStep>=() => None,
|
|
372
491
|
): ReventlessCore.EventCollector.jsonEventsHandler =>
|
|
373
492
|
stream =>
|
|
374
493
|
stream
|
|
@@ -376,8 +495,10 @@ let makeAutomationJsonEventsHandler = (
|
|
|
376
495
|
->Effect.flatMap(jsons =>
|
|
377
496
|
Effect.promise(async () => {
|
|
378
497
|
await loadTodoItems()
|
|
498
|
+
await loadSchedules(schedules())
|
|
379
499
|
callback.phase1(jsons, context)
|
|
380
500
|
await syncTodoItems()
|
|
501
|
+
await followSchedules(schedules(), callback)
|
|
381
502
|
await callback.phase2(publishJsons)
|
|
382
503
|
await syncTodoItems()
|
|
383
504
|
})
|
|
@@ -447,12 +568,19 @@ type builtSlice = {
|
|
|
447
568
|
registered: StreamRoutedEntryPoint_Ops.registeredHandler,
|
|
448
569
|
sweep: unit => promise<unit>,
|
|
449
570
|
comp: string,
|
|
571
|
+
name: string,
|
|
572
|
+
/** Delivers `Tick({key, at})`; `None` for an outbound slice, which maps no `Clock`. */
|
|
573
|
+
tick: option<(~key: string, ~at: float) => promise<unit>>,
|
|
574
|
+
/** Brings the slice's schedules in line with its spec and its rows, at a deploy;
|
|
575
|
+
`None` for an outbound slice. */
|
|
576
|
+
reconcile: option<unit => promise<unit>>,
|
|
450
577
|
}
|
|
451
578
|
|
|
452
579
|
let makeAutomationRegisteredHandler = (
|
|
453
580
|
entry: handlerEntry,
|
|
454
581
|
~sliceName: string,
|
|
455
582
|
~callback: automationCallback,
|
|
583
|
+
~schedule: option<Reventless.Schedule.time>,
|
|
456
584
|
): builtSlice => {
|
|
457
585
|
let comp = `AutomationSlice(${sliceName})`
|
|
458
586
|
let publishJsons = makePublishJsons(
|
|
@@ -470,15 +598,29 @@ let makeAutomationRegisteredHandler = (
|
|
|
470
598
|
~rowSchema=ReventlessCore.AutomationSlice_Callback.todoRowSchema,
|
|
471
599
|
~comp,
|
|
472
600
|
)
|
|
601
|
+
let context = entry.context->Option.getOr(defaultContext(sliceName))
|
|
602
|
+
// Built on first use: the platform reads the environment, which a test sets late.
|
|
603
|
+
let step = ref(None)
|
|
604
|
+
let schedules = () =>
|
|
605
|
+
switch (step.contents, entry.schedulesTableName) {
|
|
606
|
+
| (Some(s), _) => Some(s)
|
|
607
|
+
| (None, Some(tableName)) =>
|
|
608
|
+
let (load, sync) = makeScheduleStore(~tableName, ~rows=callback.schedules, ~comp)
|
|
609
|
+
let s = {load, sync, platform: platformSchedulesOf(~slice=sliceName)}
|
|
610
|
+
step := Some(s)
|
|
611
|
+
Some(s)
|
|
612
|
+
| (None, None) => None
|
|
613
|
+
}
|
|
473
614
|
{
|
|
474
615
|
registered: {
|
|
475
616
|
handler: StreamRoutedEntryPoint_Ops.toStreamHandler(
|
|
476
617
|
makeAutomationJsonEventsHandler(
|
|
477
|
-
~context
|
|
618
|
+
~context,
|
|
478
619
|
~callback,
|
|
479
620
|
~publishJsons,
|
|
480
621
|
~syncTodoItems,
|
|
481
622
|
~loadTodoItems,
|
|
623
|
+
~schedules,
|
|
482
624
|
),
|
|
483
625
|
),
|
|
484
626
|
comp,
|
|
@@ -489,6 +631,44 @@ let makeAutomationRegisteredHandler = (
|
|
|
489
631
|
await syncTodoItems()
|
|
490
632
|
},
|
|
491
633
|
comp,
|
|
634
|
+
name: sliceName,
|
|
635
|
+
// The same steps as an event batch, with the tick as its one event.
|
|
636
|
+
tick: Some(
|
|
637
|
+
async (~key, ~at) => {
|
|
638
|
+
await loadTodoItems()
|
|
639
|
+
await loadSchedules(schedules())
|
|
640
|
+
callback.phase1([ReventlessCore.AutomationSlice_Callback.tickEvent(~key, ~at)], context)
|
|
641
|
+
await syncTodoItems()
|
|
642
|
+
// A once-only schedule's row goes when it fires.
|
|
643
|
+
switch schedules() {
|
|
644
|
+
| Some({sync}) => await sync()
|
|
645
|
+
| None => ()
|
|
646
|
+
}
|
|
647
|
+
await callback.phase2(publishJsons)
|
|
648
|
+
await syncTodoItems()
|
|
649
|
+
},
|
|
650
|
+
),
|
|
651
|
+
reconcile: Some(
|
|
652
|
+
async () =>
|
|
653
|
+
switch schedules() {
|
|
654
|
+
| Some({load, sync, platform}) =>
|
|
655
|
+
await load()
|
|
656
|
+
await ReventlessCore.AutomationSlice_Schedules.reconcile(
|
|
657
|
+
callback.schedules,
|
|
658
|
+
platform,
|
|
659
|
+
~declaredTime=schedule,
|
|
660
|
+
~now=Date.now(),
|
|
661
|
+
)
|
|
662
|
+
await sync()
|
|
663
|
+
// No Clock mapping, so no schedule of the slice may stay on the platform.
|
|
664
|
+
| None =>
|
|
665
|
+
let platform = platformSchedulesOf(~slice=sliceName)
|
|
666
|
+
let names = await platform.names()
|
|
667
|
+
for i in 0 to names->Array.length - 1 {
|
|
668
|
+
await platform.removeName(names->Array.getUnsafe(i))
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
),
|
|
492
672
|
}
|
|
493
673
|
}
|
|
494
674
|
|
|
@@ -533,6 +713,9 @@ let makeOutboundRegisteredHandler = (
|
|
|
533
713
|
await syncTodoItems()
|
|
534
714
|
},
|
|
535
715
|
comp,
|
|
716
|
+
name: sliceName,
|
|
717
|
+
tick: None,
|
|
718
|
+
reconcile: None,
|
|
536
719
|
}
|
|
537
720
|
}
|
|
538
721
|
|
|
@@ -557,3 +740,46 @@ let runSweeps = async (slices: array<builtSlice>) => {
|
|
|
557
740
|
}
|
|
558
741
|
}
|
|
559
742
|
}
|
|
743
|
+
|
|
744
|
+
// ── Schedules ───────────────────────────────────────────────────────────────
|
|
745
|
+
|
|
746
|
+
/** A Scheduler invocation: which slice's schedule fired, its key, and its scheduled time. */
|
|
747
|
+
type tickInvocation = {slice: string, key: string, at: string}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
Deliver a schedule's firing to its slice. `at` is the Scheduler's placeholder,
|
|
751
|
+
filled in as `2026-10-16T06:00:00Z`. A tick for a slice this Lambda does not host,
|
|
752
|
+
or one that maps no `Clock`, throws, so the Scheduler retries and the error is logged.
|
|
753
|
+
*/
|
|
754
|
+
let runTick = async (slices: array<builtSlice>, invocation: tickInvocation) =>
|
|
755
|
+
switch slices->Array.find(s => s.name == invocation.slice)->Option.flatMap(s => s.tick) {
|
|
756
|
+
| Some(tick) =>
|
|
757
|
+
let at = invocation.at->Date.fromString->Date.getTime
|
|
758
|
+
if at->Float.isNaN {
|
|
759
|
+
JsError.throwWithMessage(`tick for ${invocation.slice}: "${invocation.at}" is not a time`)
|
|
760
|
+
}
|
|
761
|
+
await tick(~key=invocation.key, ~at)
|
|
762
|
+
| None =>
|
|
763
|
+
JsError.throwWithMessage(`tick for ${invocation.slice}: no automation of that name here`)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
Bring every automation's schedules in line, at a deploy: its declared time into its
|
|
768
|
+
rows, and the platform's schedules to its rows. Run by the deploy, so a failure fails
|
|
769
|
+
it; unlike a sweep, one slice's failure stops the rest.
|
|
770
|
+
*/
|
|
771
|
+
let reconcileSchedules = async (slices: array<builtSlice>) => {
|
|
772
|
+
if NodeProcess.env->Dict.get("SCHEDULER_ROLE_ARN")->Option.getOr("") == "" {
|
|
773
|
+
JsError.throwWithMessage(`reconcile: SCHEDULER_ROLE_ARN is not set`)
|
|
774
|
+
}
|
|
775
|
+
let reconciles = slices->Array.filterMap(s => s.reconcile)
|
|
776
|
+
for i in 0 to reconciles->Array.length - 1 {
|
|
777
|
+
await (reconciles->Array.getUnsafe(i))()
|
|
778
|
+
}
|
|
779
|
+
ReventlessCore.EffectLogger.logInfo(
|
|
780
|
+
~comp="AutomationSliceRuntime",
|
|
781
|
+
`reconcile: the schedules of ${reconciles
|
|
782
|
+
->Array.length
|
|
783
|
+
->Int.toString} automation(s) are in line`,
|
|
784
|
+
)->Effect.runSync
|
|
785
|
+
}
|