@reventlessdev/reventless-aws 3.0.0-alpha.264 → 3.0.0-alpha.265
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 +10 -0
- package/package.json +5 -5
- package/src/Platform.res +30 -4
- package/src/Platform.res.mjs +19 -4
- package/src/adapter/Runtime/AutomationSliceEntryPoint.mjs +21 -3
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +219 -27
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +121 -7
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +118 -0
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +61 -0
- package/src/components/OutboundTranslationSlice_Builder.res +15 -0
- package/src/components/OutboundTranslationSlice_Builder.res.mjs +11 -1
- package/src/plugin/runtime/PluginRuntime_Builder.res +26 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +26 -0
- package/tests/AutomationSliceEntryPoint_OpsTest.res +44 -3
- package/tests/AutomationSliceEntryPoint_OpsTest.res.mjs +65 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.265 (2026-08-04)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **aws:** make a slice's TODO backlog survive, and actually sweep it ([f35dcbd](https://github.com/ReventlessDev/reventless-core/commit/f35dcbd86374124106c2d1e48d29c6f41fdbec2c))
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **outbound:** hand translate its geocoder instead of making it fetch one ([fb18312](https://github.com/ReventlessDev/reventless-core/commit/fb1831216b37c9562868c46e1a09054e69418c67))
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
# 3.0.0-alpha.264 (2026-08-03)
|
|
7
17
|
|
|
8
18
|
### 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.265",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
"@reventlessdev/rescript-node": "2.0.0-alpha.1",
|
|
19
19
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
20
20
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
21
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
22
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
23
|
-
"@reventlessdev/reventless-
|
|
24
|
-
"@reventlessdev/reventless-
|
|
21
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.210",
|
|
22
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.125",
|
|
23
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.74",
|
|
24
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.100",
|
|
25
25
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.30"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
package/src/Platform.res
CHANGED
|
@@ -96,6 +96,12 @@ let getObjectStoreEndpoints = () => objectStoreEndpointsRef.contents
|
|
|
96
96
|
the same sentinel, as `PluginRuntime_Builder.inboundSliceReg.auditTableName`. */
|
|
97
97
|
let geocoderEndpointRef: ref<Pulumi.Output.t<string>> = ref(Pulumi.Output.make(""))
|
|
98
98
|
|
|
99
|
+
/** The geocoding place index, for the monolithic case — the SDK-side half of the
|
|
100
|
+
same capability, carried for exactly the reasons above and with the same `""`
|
|
101
|
+
sentinel. Slice Lambdas call Amazon Location directly with this; only the
|
|
102
|
+
browser needs the Function URL. */
|
|
103
|
+
let geocoderPlaceIndexRef: ref<Pulumi.Output.t<string>> = ref(Pulumi.Output.make(""))
|
|
104
|
+
|
|
99
105
|
module MakeWithConfig = (
|
|
100
106
|
Config: {
|
|
101
107
|
let splitApi: bool
|
|
@@ -2083,6 +2089,19 @@ module MakeWithConfig = (
|
|
|
2083
2089
|
Pulumi.Pulumi.export("geocoderEndpoint", geocoderEndpointFlat)
|
|
2084
2090
|
geocoderEndpointRef := geocoderEndpointFlat
|
|
2085
2091
|
|
|
2092
|
+
// The place index itself, exported beside the Function URL because the two
|
|
2093
|
+
// callers need different things from the same capability. A browser cannot
|
|
2094
|
+
// sign an SDK call and gets the URL; a slice's Lambda can, and gets the
|
|
2095
|
+
// index name — no proxy hop, and no dependence on a public unauthenticated
|
|
2096
|
+
// endpoint for an unattended path. `""` when unset, for the same
|
|
2097
|
+
// one-shape-to-handle reason as the endpoint above.
|
|
2098
|
+
let geocoderPlaceIndexFlat = switch cfg.geocoderPlaceIndex {
|
|
2099
|
+
| Some(index) => index.indexName->Pulumi.Output.fromInput
|
|
2100
|
+
| None => Pulumi.Output.make("")
|
|
2101
|
+
}
|
|
2102
|
+
Pulumi.Pulumi.export("geocoderPlaceIndex", geocoderPlaceIndexFlat)
|
|
2103
|
+
geocoderPlaceIndexRef := geocoderPlaceIndexFlat
|
|
2104
|
+
|
|
2086
2105
|
// Presign endpoints are no longer written to config.json: under route B the
|
|
2087
2106
|
// client calls the platform API's `Upload_Presign` mutation (reachable from
|
|
2088
2107
|
// `platformApiEndpoint`, already present) with the store it declares, so there
|
|
@@ -2251,15 +2270,22 @@ module MakeWithConfig = (
|
|
|
2251
2270
|
// is a modelled outcome, which is the point of the empty string: a plugin
|
|
2252
2271
|
// deployed against a platform without the capability degrades rather than
|
|
2253
2272
|
// failing to deploy.
|
|
2254
|
-
|
|
2273
|
+
// The place index, not the Function URL. A slice's Lambda reaches Amazon
|
|
2274
|
+
// Location through the SDK now, so what it needs is the index's name — and
|
|
2275
|
+
// the grant that goes with it, which is why this is also handed to
|
|
2276
|
+
// `registerGeocoderPlaceIndex` rather than only to the environment. The
|
|
2277
|
+
// browser keeps using the Function URL until the platform API carries a
|
|
2278
|
+
// geocode field; the two doors are independent.
|
|
2279
|
+
let geocoderPlaceIndex: Pulumi.Output.t<string> = switch platformStackRef {
|
|
2255
2280
|
| Some(stackRef) =>
|
|
2256
2281
|
(
|
|
2257
|
-
stackRef->Pulumi.StackReference.getOutput("
|
|
2282
|
+
stackRef->Pulumi.StackReference.getOutput("geocoderPlaceIndex"):
|
|
2258
2283
|
Pulumi.Output.t<option<string>>
|
|
2259
2284
|
)->Pulumi.Output.apply(o => o->Option.getOr(""))
|
|
2260
|
-
| None =>
|
|
2285
|
+
| None => geocoderPlaceIndexRef.contents
|
|
2261
2286
|
}
|
|
2262
|
-
PluginRuntime_Builder.registerCapabilityEnv("
|
|
2287
|
+
PluginRuntime_Builder.registerCapabilityEnv("PLACE_INDEX_NAME", geocoderPlaceIndex)
|
|
2288
|
+
PluginRuntime_Builder.registerGeocoderPlaceIndex(geocoderPlaceIndex)
|
|
2263
2289
|
|
|
2264
2290
|
module P = unpack(plugin)
|
|
2265
2291
|
let pluginComponent = P.make()
|
package/src/Platform.res.mjs
CHANGED
|
@@ -134,6 +134,10 @@ let geocoderEndpointRef = {
|
|
|
134
134
|
contents: Pulumi.output("")
|
|
135
135
|
};
|
|
136
136
|
|
|
137
|
+
let geocoderPlaceIndexRef = {
|
|
138
|
+
contents: Pulumi.output("")
|
|
139
|
+
};
|
|
140
|
+
|
|
137
141
|
function MakeWithConfig(Config) {
|
|
138
142
|
Stdlib_Option.forEach(Config.commandHandlerConfig.aggregates, param => {
|
|
139
143
|
Stdlib_Option.forEach(param.sync, AggregateRuntime_Builder_Single$ReventlessAws.setConfig);
|
|
@@ -1131,6 +1135,10 @@ function MakeWithConfig(Config) {
|
|
|
1131
1135
|
let geocoderEndpointFlat = geocoderEndpointOutput.apply(o => Stdlib_Option.getOr(o, ""));
|
|
1132
1136
|
Pulumi$Pulumi.$$export("geocoderEndpoint", geocoderEndpointFlat);
|
|
1133
1137
|
geocoderEndpointRef.contents = geocoderEndpointFlat;
|
|
1138
|
+
let index$1 = hostUiBundle.geocoderPlaceIndex;
|
|
1139
|
+
let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
|
|
1140
|
+
Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
|
|
1141
|
+
geocoderPlaceIndexRef.contents = geocoderPlaceIndexFlat;
|
|
1134
1142
|
let configJsonContent = Pulumi.all([
|
|
1135
1143
|
Pulumi.all([
|
|
1136
1144
|
resolvedDomainApiEndpoint,
|
|
@@ -1247,8 +1255,9 @@ function MakeWithConfig(Config) {
|
|
|
1247
1255
|
bytes: bytes.length
|
|
1248
1256
|
};
|
|
1249
1257
|
});
|
|
1250
|
-
let
|
|
1251
|
-
PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("
|
|
1258
|
+
let geocoderPlaceIndex = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("geocoderPlaceIndex").apply(o => Stdlib_Option.getOr(o, "")) : geocoderPlaceIndexRef.contents;
|
|
1259
|
+
PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("PLACE_INDEX_NAME", geocoderPlaceIndex);
|
|
1260
|
+
PluginRuntime_Builder$ReventlessAws.registerGeocoderPlaceIndex(geocoderPlaceIndex);
|
|
1252
1261
|
let pluginComponent = plugin.make();
|
|
1253
1262
|
Plugin_Helpers$ReventlessCore.clearOffload();
|
|
1254
1263
|
currentDeployTarget.contents = "Domain";
|
|
@@ -2362,6 +2371,10 @@ function Make($star) {
|
|
|
2362
2371
|
let geocoderEndpointFlat = geocoderEndpointOutput.apply(o => Stdlib_Option.getOr(o, ""));
|
|
2363
2372
|
Pulumi$Pulumi.$$export("geocoderEndpoint", geocoderEndpointFlat);
|
|
2364
2373
|
geocoderEndpointRef.contents = geocoderEndpointFlat;
|
|
2374
|
+
let index$1 = hostUiBundle.geocoderPlaceIndex;
|
|
2375
|
+
let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
|
|
2376
|
+
Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
|
|
2377
|
+
geocoderPlaceIndexRef.contents = geocoderPlaceIndexFlat;
|
|
2365
2378
|
let configJsonContent = Pulumi.all([
|
|
2366
2379
|
Pulumi.all([
|
|
2367
2380
|
resolvedDomainApiEndpoint,
|
|
@@ -2478,8 +2491,9 @@ function Make($star) {
|
|
|
2478
2491
|
bytes: bytes.length
|
|
2479
2492
|
};
|
|
2480
2493
|
});
|
|
2481
|
-
let
|
|
2482
|
-
PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("
|
|
2494
|
+
let geocoderPlaceIndex = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("geocoderPlaceIndex").apply(o => Stdlib_Option.getOr(o, "")) : geocoderPlaceIndexRef.contents;
|
|
2495
|
+
PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("PLACE_INDEX_NAME", geocoderPlaceIndex);
|
|
2496
|
+
PluginRuntime_Builder$ReventlessAws.registerGeocoderPlaceIndex(geocoderPlaceIndex);
|
|
2483
2497
|
let pluginComponent = plugin.make();
|
|
2484
2498
|
Plugin_Helpers$ReventlessCore.clearOffload();
|
|
2485
2499
|
currentDeployTarget.contents = "Domain";
|
|
@@ -2632,6 +2646,7 @@ export {
|
|
|
2632
2646
|
objectStoreEndpointsRef,
|
|
2633
2647
|
getObjectStoreEndpoints,
|
|
2634
2648
|
geocoderEndpointRef,
|
|
2649
|
+
geocoderPlaceIndexRef,
|
|
2635
2650
|
MakeWithConfig,
|
|
2636
2651
|
Make,
|
|
2637
2652
|
}
|
|
@@ -22,6 +22,7 @@ const dynamicImport = (specifier) => import('/var/task/node_modules/' + specifie
|
|
|
22
22
|
|
|
23
23
|
async function buildAllHandlers() {
|
|
24
24
|
const handlers = {};
|
|
25
|
+
const sweeps = [];
|
|
25
26
|
const entries = Ops.parseHandlerConfig(process.env["HANDLER_CONFIG"] || "");
|
|
26
27
|
|
|
27
28
|
await Promise.all(entries.map(async (entry) => {
|
|
@@ -56,11 +57,28 @@ async function buildAllHandlers() {
|
|
|
56
57
|
? [entry.sourceUrn]
|
|
57
58
|
: [];
|
|
58
59
|
for (const sourceUrn of sourceUrns) {
|
|
59
|
-
StreamOps.addToRegistry(handlers, sourceUrn, registered);
|
|
60
|
+
StreamOps.addToRegistry(handlers, sourceUrn, registered.registered);
|
|
60
61
|
}
|
|
62
|
+
// Every slice can also be swept without an event — see the scheduled branch
|
|
63
|
+
// below. Collected regardless of how many streams it listens on, so a
|
|
64
|
+
// multi-source slice is swept once rather than once per stream.
|
|
65
|
+
sweeps.push(registered);
|
|
61
66
|
}));
|
|
62
67
|
|
|
63
|
-
return handlers;
|
|
68
|
+
return { handlers, sweeps };
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
|
|
71
|
+
const builtPromise = buildAllHandlers();
|
|
72
|
+
const routed = StreamOps.makeRoutedHandler(
|
|
73
|
+
"AutomationSliceRuntime",
|
|
74
|
+
builtPromise.then((b) => b.handlers),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// The scheduled sweep arrives as the EventBridge rule's constant Input, which
|
|
78
|
+
// has no `records` — so this branch is the one place that must probe an untyped
|
|
79
|
+
// Lambda payload, which is what this shell exists for. Everything it dispatches
|
|
80
|
+
// to is type-checked in AutomationSliceEntryPoint_Ops.
|
|
81
|
+
export const handler = async (event, context) =>
|
|
82
|
+
event?.reventlessSweep
|
|
83
|
+
? Ops.runSweeps((await builtPromise).sweeps)
|
|
84
|
+
: routed(event, context);
|
|
@@ -128,6 +128,111 @@ let makeSyncTodoItems = (
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Rehydrate the in-memory TODO list from the slice's view table, once per
|
|
132
|
+
// container.
|
|
133
|
+
//
|
|
134
|
+
// `todoItems` is a module-level dict populated only by phase 1, so before this
|
|
135
|
+
// existed a cold start began with an empty list and every row already persisted
|
|
136
|
+
// was unreachable: `phase2` admits `Failed && retryCount < maxRetries`, but only
|
|
137
|
+
// for rows it can see. A transient failure whose container then recycled was
|
|
138
|
+
// stranded permanently, in a row reading `Failed` that nothing would ever pick
|
|
139
|
+
// up again — while `makeSyncTodoItems` kept faithfully writing it back out.
|
|
140
|
+
//
|
|
141
|
+
// Once per container rather than per invocation: within a container the dict is
|
|
142
|
+
// authoritative and `phase2` already re-attempts every actionable row on each
|
|
143
|
+
// batch, so re-reading would buy nothing and cost a scan each time. The backlog
|
|
144
|
+
// is therefore retried on the first event a new container handles.
|
|
145
|
+
//
|
|
146
|
+
// Memory wins on conflict. A row already in the dict may be mid-flight
|
|
147
|
+
// (`Processing`) or a status this invocation just advanced; the stored copy is
|
|
148
|
+
// by definition no fresher.
|
|
149
|
+
//
|
|
150
|
+
// Only `Pending` and `Failed` are read. `Completed` rows are the bulk of a
|
|
151
|
+
// mature table and are never actionable. Retry-exhausted rows are not excluded —
|
|
152
|
+
// `maxRetries` is Spec-level and not known here — but `phase2` filters them, so
|
|
153
|
+
// they are inert rather than wrong.
|
|
154
|
+
let makeLoadTodoItems = (
|
|
155
|
+
~queryDbTableName: string,
|
|
156
|
+
~todoItems: dict<'row>,
|
|
157
|
+
~rowSchema: S.t<'row>,
|
|
158
|
+
~comp: string,
|
|
159
|
+
): (unit => promise<unit>) => {
|
|
160
|
+
let loaded = ref(false)
|
|
161
|
+
async () =>
|
|
162
|
+
if loaded.contents {
|
|
163
|
+
()
|
|
164
|
+
} else {
|
|
165
|
+
loaded := true
|
|
166
|
+
let params: AwsSdk.DynamoDb.DocumentClient.ScanCommand.input = {
|
|
167
|
+
tableName: queryDbTableName,
|
|
168
|
+
consistentRead: true,
|
|
169
|
+
filterExpression: "#s = :pending OR #s = :failed",
|
|
170
|
+
expressionAttributeNames: [("#s", "status")]->Dict.fromArray,
|
|
171
|
+
expressionAttributeValues: [
|
|
172
|
+
(":pending", "Pending"->JSON.Encode.string),
|
|
173
|
+
(":failed", "Failed"->JSON.Encode.string),
|
|
174
|
+
]->Dict.fromArray,
|
|
175
|
+
}
|
|
176
|
+
let restored = await Util_DynamoDb_Runtime.scanStream(params)
|
|
177
|
+
->Stream.runCollect
|
|
178
|
+
->Effect.map(items =>
|
|
179
|
+
items->Array.reduce(0, (count, item) => {
|
|
180
|
+
let json = item->JSON.stringifyAny->Option.getOr("")->JSON.parseOrThrow
|
|
181
|
+
let id = json->JSON.Decode.object->Option.flatMap(d => d->Dict.get("id"))->Option.flatMap(JSON.Decode.string)
|
|
182
|
+
switch id {
|
|
183
|
+
| Some(id) if todoItems->Dict.get(id)->Option.isNone =>
|
|
184
|
+
switch json->S.parseJsonOrThrow(rowSchema) {
|
|
185
|
+
| row =>
|
|
186
|
+
todoItems->Dict.set(id, row)
|
|
187
|
+
count + 1
|
|
188
|
+
| exception _ => count
|
|
189
|
+
}
|
|
190
|
+
| _ => count
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
)
|
|
194
|
+
->Effect.catchAll(err =>
|
|
195
|
+
ReventlessCore.EffectLogger.logError(
|
|
196
|
+
~comp,
|
|
197
|
+
`restore: couldn't read pending TODO rows from ${queryDbTableName}: ${DynamoDb_Error.message(
|
|
198
|
+
err,
|
|
199
|
+
)}`,
|
|
200
|
+
)->Effect.map(_ => 0)
|
|
201
|
+
)
|
|
202
|
+
->Effect.runPromise
|
|
203
|
+
if restored > 0 {
|
|
204
|
+
ReventlessCore.EffectLogger.logInfo(
|
|
205
|
+
~comp,
|
|
206
|
+
`restore: reloaded ${restored->Int.toString} unfinished TODO row(s) from ${queryDbTableName}`,
|
|
207
|
+
)->Effect.runSync
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Platform capabilities ───────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
What this Lambda hands a slice's `translate`.
|
|
216
|
+
|
|
217
|
+
Built here rather than reached for inside the plugin, because a plugin depends on
|
|
218
|
+
`reventless-spec` and cannot name Amazon Location — the whole point of the
|
|
219
|
+
injected record. This module is the AWS side of that seam and calls the SDK
|
|
220
|
+
directly: no proxy hop through the browser's Function URL, and no dependence on a
|
|
221
|
+
public unauthenticated endpoint for an unattended path.
|
|
222
|
+
|
|
223
|
+
Resolved per call rather than captured once, so a Lambda whose configuration is
|
|
224
|
+
updated does not need a cold start to see it. An unset `PLACE_INDEX_NAME` reaches
|
|
225
|
+
`Geocoder_AwsLocation_Backend` as `""`, which answers `Unavailable` — a retryable
|
|
226
|
+
outcome, not a verdict on the address.
|
|
227
|
+
*/
|
|
228
|
+
let capabilities = (): Reventless.Capabilities.t => {
|
|
229
|
+
geocode: (~text) =>
|
|
230
|
+
Geocoder_AwsLocation_Backend.search(
|
|
231
|
+
~indexName=NodeProcess.env->Dict.get("PLACE_INDEX_NAME")->Option.getOr(""),
|
|
232
|
+
~text,
|
|
233
|
+
),
|
|
234
|
+
}
|
|
235
|
+
|
|
131
236
|
// ── Phase-1/phase-2 pipelines ───────────────────────────────────────────────
|
|
132
237
|
// Both run: collect the batch → phase 1 → sync (so consumers observe Pending
|
|
133
238
|
// rows even if phase 2 fails) → phase 2 → sync. Unlike the in-process builders
|
|
@@ -161,12 +266,14 @@ let makeAutomationJsonEventsHandler = (
|
|
|
161
266
|
~callback: automationCallback,
|
|
162
267
|
~publishJsons: ReventlessCore.CommandTopic.publishJsons,
|
|
163
268
|
~syncTodoItems: unit => promise<unit>,
|
|
269
|
+
~loadTodoItems: unit => promise<unit>,
|
|
164
270
|
): ReventlessCore.EventCollector.jsonEventsHandler =>
|
|
165
271
|
stream =>
|
|
166
272
|
stream
|
|
167
273
|
->Stream.runCollect
|
|
168
274
|
->Effect.flatMap(jsons =>
|
|
169
275
|
Effect.promise(async () => {
|
|
276
|
+
await loadTodoItems()
|
|
170
277
|
callback.phase1(jsons, context)
|
|
171
278
|
await syncTodoItems()
|
|
172
279
|
await callback.phase2(publishJsons)
|
|
@@ -183,6 +290,7 @@ let makeOutboundJsonEventsHandler = (
|
|
|
183
290
|
~callback: outboundCallback<'event>,
|
|
184
291
|
~publishJsons: ReventlessCore.CommandTopic.publishJsons,
|
|
185
292
|
~syncTodoItems: unit => promise<unit>,
|
|
293
|
+
~loadTodoItems: unit => promise<unit>,
|
|
186
294
|
): ReventlessCore.EventCollector.jsonEventsHandler => {
|
|
187
295
|
let decoder = Reventless.DcbDecode.makeDecoder(consumedEventSchema)
|
|
188
296
|
stream =>
|
|
@@ -211,9 +319,10 @@ let makeOutboundJsonEventsHandler = (
|
|
|
211
319
|
->Stream.runCollect
|
|
212
320
|
->Effect.flatMap(events =>
|
|
213
321
|
Effect.promise(async () => {
|
|
322
|
+
await loadTodoItems()
|
|
214
323
|
callback.phase1(events)
|
|
215
324
|
await syncTodoItems()
|
|
216
|
-
await callback.phase2(publishJsons)
|
|
325
|
+
await callback.phase2(publishJsons, ~capabilities=capabilities())
|
|
217
326
|
await syncTodoItems()
|
|
218
327
|
})
|
|
219
328
|
)
|
|
@@ -223,24 +332,62 @@ let makeOutboundJsonEventsHandler = (
|
|
|
223
332
|
// Comp matches what the slice's own callback logs under, so a filter catches
|
|
224
333
|
// both the framework's lines and the application handler's.
|
|
225
334
|
|
|
335
|
+
/**
|
|
336
|
+
A built slice, in the two ways the Lambda drives it.
|
|
337
|
+
|
|
338
|
+
`registered` handles a stream batch. `sweep` runs the TODO backlog with no event
|
|
339
|
+
to trigger it — the scheduled path, and the reason `translatePending` exists on
|
|
340
|
+
the in-process builder. Both close over the *same* publish/sync/load closures, so
|
|
341
|
+
a sweep landing on a container that has already served a batch reuses the load it
|
|
342
|
+
did then, and one landing cold does the reload itself.
|
|
343
|
+
*/
|
|
344
|
+
type builtSlice = {
|
|
345
|
+
registered: StreamRoutedEntryPoint_Ops.registeredHandler,
|
|
346
|
+
sweep: unit => promise<unit>,
|
|
347
|
+
comp: string,
|
|
348
|
+
}
|
|
349
|
+
|
|
226
350
|
let makeAutomationRegisteredHandler = (
|
|
227
351
|
entry: handlerEntry,
|
|
228
352
|
~sliceName: string,
|
|
229
353
|
~callback: automationCallback,
|
|
230
|
-
):
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
354
|
+
): builtSlice => {
|
|
355
|
+
let comp = `AutomationSlice(${sliceName})`
|
|
356
|
+
let publishJsons = makePublishJsons(
|
|
357
|
+
entry.dcbQueueUrl,
|
|
358
|
+
~isFifo=entry.commandQueueIsFifo->Option.getOr(false),
|
|
359
|
+
)
|
|
360
|
+
let syncTodoItems = makeSyncTodoItems(
|
|
361
|
+
~queryDbTableName=entry.queryDbTableName,
|
|
362
|
+
~todoItems=callback.todoItems,
|
|
363
|
+
~rowSchema=ReventlessCore.AutomationSlice_Callback.todoRowSchema,
|
|
364
|
+
)
|
|
365
|
+
let loadTodoItems = makeLoadTodoItems(
|
|
366
|
+
~queryDbTableName=entry.queryDbTableName,
|
|
367
|
+
~todoItems=callback.todoItems,
|
|
368
|
+
~rowSchema=ReventlessCore.AutomationSlice_Callback.todoRowSchema,
|
|
369
|
+
~comp,
|
|
370
|
+
)
|
|
371
|
+
{
|
|
372
|
+
registered: {
|
|
373
|
+
handler: StreamRoutedEntryPoint_Ops.toStreamHandler(
|
|
374
|
+
makeAutomationJsonEventsHandler(
|
|
375
|
+
~context=entry.context->Option.getOr(defaultContext(sliceName)),
|
|
376
|
+
~callback,
|
|
377
|
+
~publishJsons,
|
|
378
|
+
~syncTodoItems,
|
|
379
|
+
~loadTodoItems,
|
|
380
|
+
),
|
|
240
381
|
),
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
382
|
+
comp,
|
|
383
|
+
},
|
|
384
|
+
sweep: async () => {
|
|
385
|
+
await loadTodoItems()
|
|
386
|
+
await callback.phase2(publishJsons)
|
|
387
|
+
await syncTodoItems()
|
|
388
|
+
},
|
|
389
|
+
comp,
|
|
390
|
+
}
|
|
244
391
|
}
|
|
245
392
|
|
|
246
393
|
let makeOutboundRegisteredHandler = (
|
|
@@ -248,18 +395,63 @@ let makeOutboundRegisteredHandler = (
|
|
|
248
395
|
~sliceName: string,
|
|
249
396
|
~consumedEventSchema: S.t<'event>,
|
|
250
397
|
~callback: outboundCallback<'event>,
|
|
251
|
-
):
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
398
|
+
): builtSlice => {
|
|
399
|
+
let comp = `OutboundTranslationSlice(${sliceName})`
|
|
400
|
+
let publishJsons = makePublishJsons(
|
|
401
|
+
entry.dcbQueueUrl,
|
|
402
|
+
~isFifo=entry.commandQueueIsFifo->Option.getOr(false),
|
|
403
|
+
)
|
|
404
|
+
let syncTodoItems = makeSyncTodoItems(
|
|
405
|
+
~queryDbTableName=entry.queryDbTableName,
|
|
406
|
+
~todoItems=callback.todoItems,
|
|
407
|
+
~rowSchema=ReventlessCore.OutboundTranslationSlice_Callback.todoRowSchema,
|
|
408
|
+
)
|
|
409
|
+
let loadTodoItems = makeLoadTodoItems(
|
|
410
|
+
~queryDbTableName=entry.queryDbTableName,
|
|
411
|
+
~todoItems=callback.todoItems,
|
|
412
|
+
~rowSchema=ReventlessCore.OutboundTranslationSlice_Callback.todoRowSchema,
|
|
413
|
+
~comp,
|
|
414
|
+
)
|
|
415
|
+
{
|
|
416
|
+
registered: {
|
|
417
|
+
handler: StreamRoutedEntryPoint_Ops.toStreamHandler(
|
|
418
|
+
makeOutboundJsonEventsHandler(
|
|
419
|
+
~consumedEventSchema,
|
|
420
|
+
~callback,
|
|
421
|
+
~publishJsons,
|
|
422
|
+
~syncTodoItems,
|
|
423
|
+
~loadTodoItems,
|
|
424
|
+
),
|
|
261
425
|
),
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
426
|
+
comp,
|
|
427
|
+
},
|
|
428
|
+
sweep: async () => {
|
|
429
|
+
await loadTodoItems()
|
|
430
|
+
await callback.phase2(publishJsons, ~capabilities=capabilities())
|
|
431
|
+
await syncTodoItems()
|
|
432
|
+
},
|
|
433
|
+
comp,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
Run every slice's TODO backlog, for a scheduled invocation carrying no records.
|
|
439
|
+
|
|
440
|
+
Sequential rather than concurrent: the slices in one Lambda share its memory and
|
|
441
|
+
its downstream quotas, and a sweep is never latency-critical. A slice that throws
|
|
442
|
+
is logged and does not stop the rest — a sweep that abandoned the remaining
|
|
443
|
+
slices because one geocoder was down would be a worse version of the problem it
|
|
444
|
+
exists to fix.
|
|
445
|
+
*/
|
|
446
|
+
let runSweeps = async (slices: array<builtSlice>) => {
|
|
447
|
+
for i in 0 to slices->Array.length - 1 {
|
|
448
|
+
let slice = slices->Array.getUnsafe(i)
|
|
449
|
+
try {
|
|
450
|
+
await slice.sweep()
|
|
451
|
+
} catch {
|
|
452
|
+
| exn =>
|
|
453
|
+
let msg = exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown")
|
|
454
|
+
ReventlessCore.EffectLogger.logError(~comp=slice.comp, `sweep failed: ${msg}`)->Effect.runSync
|
|
455
|
+
}
|
|
456
|
+
}
|
|
265
457
|
}
|
|
@@ -4,14 +4,20 @@ import * as S from "sury/src/S.res.mjs";
|
|
|
4
4
|
import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
|
|
5
5
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
6
6
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
7
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
7
8
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
9
|
import * as Effect from "effect/Effect";
|
|
9
10
|
import * as Stream$1 from "effect/Stream";
|
|
10
11
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
11
12
|
import * as DcbDecode$Reventless from "@reventlessdev/reventless-spec/src/components/DcbDecode.res.mjs";
|
|
13
|
+
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
12
14
|
import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
|
|
15
|
+
import * as EffectLogger$ReventlessCore from "@reventlessdev/reventless-core/src/util/EffectLogger.res.mjs";
|
|
16
|
+
import * as DynamoDb_Error$ReventlessAws from "../../errors/DynamoDb_Error.res.mjs";
|
|
17
|
+
import * as Util_DynamoDb_Runtime$ReventlessAws from "../../util/Util_DynamoDb_Runtime.res.mjs";
|
|
13
18
|
import * as AutomationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs";
|
|
14
19
|
import * as StreamRoutedEntryPoint_Ops$ReventlessAws from "./StreamRoutedEntryPoint_Ops.res.mjs";
|
|
20
|
+
import * as Geocoder_AwsLocation_Backend$ReventlessAws from "../Geocoder/Geocoder_AwsLocation_Backend.res.mjs";
|
|
15
21
|
import * as CommandTopicChannel_SQS_Runtime$ReventlessAws from "../CommandTopic/CommandTopicChannel_SQS_Runtime.res.mjs";
|
|
16
22
|
import * as QueryDbStorage_DynamoDb_Runtime$ReventlessAws from "../QueryDb/QueryDbStorage_DynamoDb_Runtime.res.mjs";
|
|
17
23
|
import * as OutboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs";
|
|
@@ -87,8 +93,71 @@ function makeSyncTodoItems(queryDbTableName, todoItems, rowSchema) {
|
|
|
87
93
|
};
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
function
|
|
96
|
+
function makeLoadTodoItems(queryDbTableName, todoItems, rowSchema, comp) {
|
|
97
|
+
let loaded = {
|
|
98
|
+
contents: false
|
|
99
|
+
};
|
|
100
|
+
return async () => {
|
|
101
|
+
if (loaded.contents) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
loaded.contents = true;
|
|
105
|
+
let params_ConsistentRead = true;
|
|
106
|
+
let params_ExpressionAttributeNames = Object.fromEntries([[
|
|
107
|
+
"#s",
|
|
108
|
+
"status"
|
|
109
|
+
]]);
|
|
110
|
+
let params_ExpressionAttributeValues = Object.fromEntries([
|
|
111
|
+
[
|
|
112
|
+
":pending",
|
|
113
|
+
"Pending"
|
|
114
|
+
],
|
|
115
|
+
[
|
|
116
|
+
":failed",
|
|
117
|
+
"Failed"
|
|
118
|
+
]
|
|
119
|
+
]);
|
|
120
|
+
let params_FilterExpression = "#s = :pending OR #s = :failed";
|
|
121
|
+
let params = {
|
|
122
|
+
TableName: queryDbTableName,
|
|
123
|
+
ConsistentRead: params_ConsistentRead,
|
|
124
|
+
ExpressionAttributeNames: params_ExpressionAttributeNames,
|
|
125
|
+
ExpressionAttributeValues: params_ExpressionAttributeValues,
|
|
126
|
+
FilterExpression: params_FilterExpression
|
|
127
|
+
};
|
|
128
|
+
let restored = await Effect.runPromise(Effect.catchAll(Effect.map(Stream.runCollect(Util_DynamoDb_Runtime$ReventlessAws.scanStream(params)), items => Stdlib_Array.reduce(items, 0, (count, item) => {
|
|
129
|
+
let json = JSON.parse(Stdlib_Option.getOr(JSON.stringify(item), ""));
|
|
130
|
+
let id = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), d => d["id"]), Stdlib_JSON.Decode.string);
|
|
131
|
+
if (id === undefined) {
|
|
132
|
+
return count;
|
|
133
|
+
}
|
|
134
|
+
if (!Stdlib_Option.isNone(todoItems[id])) {
|
|
135
|
+
return count;
|
|
136
|
+
}
|
|
137
|
+
let row;
|
|
138
|
+
try {
|
|
139
|
+
row = S.parseJsonOrThrow(json, rowSchema);
|
|
140
|
+
} catch (exn) {
|
|
141
|
+
return count;
|
|
142
|
+
}
|
|
143
|
+
todoItems[id] = row;
|
|
144
|
+
return count + 1 | 0;
|
|
145
|
+
})), err => Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `restore: couldn't read pending TODO rows from ` + queryDbTableName + `: ` + DynamoDb_Error$ReventlessAws.message(err)), () => 0)));
|
|
146
|
+
if (restored > 0) {
|
|
147
|
+
return Effect.runSync(EffectLogger$ReventlessCore.logInfo(comp, undefined, `restore: reloaded ` + restored.toString() + ` unfinished TODO row(s) from ` + queryDbTableName));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function capabilities() {
|
|
153
|
+
return {
|
|
154
|
+
geocode: text => Geocoder_AwsLocation_Backend$ReventlessAws.search(Stdlib_Option.getOr(process.env["PLACE_INDEX_NAME"], ""), text, undefined)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function makeAutomationJsonEventsHandler(context, callback, publishJsons, syncTodoItems, loadTodoItems) {
|
|
91
159
|
return stream => Effect.flatMap(Stream.runCollect(stream), jsons => Effect.promise(async () => {
|
|
160
|
+
await loadTodoItems();
|
|
92
161
|
callback.phase1(jsons, context);
|
|
93
162
|
await syncTodoItems();
|
|
94
163
|
await callback.phase2(publishJsons);
|
|
@@ -96,7 +165,7 @@ function makeAutomationJsonEventsHandler(context, callback, publishJsons, syncTo
|
|
|
96
165
|
}));
|
|
97
166
|
}
|
|
98
167
|
|
|
99
|
-
function makeOutboundJsonEventsHandler(consumedEventSchema, callback, publishJsons, syncTodoItems) {
|
|
168
|
+
function makeOutboundJsonEventsHandler(consumedEventSchema, callback, publishJsons, syncTodoItems, loadTodoItems) {
|
|
100
169
|
let decoder = DcbDecode$Reventless.makeDecoder(consumedEventSchema);
|
|
101
170
|
return stream => Effect.flatMap(Stream.runCollect(Stream$1.flatMap(Stream$1.mapEffect(stream, json => Effect.sync(() => {
|
|
102
171
|
let envelope = Stdlib_JSON.Decode.object(json);
|
|
@@ -113,27 +182,69 @@ function makeOutboundJsonEventsHandler(consumedEventSchema, callback, publishJso
|
|
|
113
182
|
return [];
|
|
114
183
|
}
|
|
115
184
|
})), events => Stream$1.fromIterable(events))), events => Effect.promise(async () => {
|
|
185
|
+
await loadTodoItems();
|
|
116
186
|
callback.phase1(events);
|
|
117
187
|
await syncTodoItems();
|
|
118
|
-
await callback.phase2(publishJsons
|
|
188
|
+
await callback.phase2(publishJsons, {
|
|
189
|
+
geocode: text => Geocoder_AwsLocation_Backend$ReventlessAws.search(Stdlib_Option.getOr(process.env["PLACE_INDEX_NAME"], ""), text, undefined)
|
|
190
|
+
});
|
|
119
191
|
return await syncTodoItems();
|
|
120
192
|
}));
|
|
121
193
|
}
|
|
122
194
|
|
|
123
195
|
function makeAutomationRegisteredHandler(entry, sliceName, callback) {
|
|
196
|
+
let comp = `AutomationSlice(` + sliceName + `)`;
|
|
197
|
+
let publishJsons = makePublishJsons(entry.dcbQueueUrl, Stdlib_Option.getOr(entry.commandQueueIsFifo, false));
|
|
198
|
+
let syncTodoItems = makeSyncTodoItems(entry.queryDbTableName, callback.todoItems, AutomationSlice_Callback$ReventlessCore.todoRowSchema);
|
|
199
|
+
let loadTodoItems = makeLoadTodoItems(entry.queryDbTableName, callback.todoItems, AutomationSlice_Callback$ReventlessCore.todoRowSchema, comp);
|
|
124
200
|
return {
|
|
125
|
-
|
|
126
|
-
|
|
201
|
+
registered: {
|
|
202
|
+
handler: StreamRoutedEntryPoint_Ops$ReventlessAws.toStreamHandler(makeAutomationJsonEventsHandler(Stdlib_Option.getOr(entry.context, defaultContext(sliceName)), callback, publishJsons, syncTodoItems, loadTodoItems)),
|
|
203
|
+
comp: comp
|
|
204
|
+
},
|
|
205
|
+
sweep: async () => {
|
|
206
|
+
await loadTodoItems();
|
|
207
|
+
await callback.phase2(publishJsons);
|
|
208
|
+
return await syncTodoItems();
|
|
209
|
+
},
|
|
210
|
+
comp: comp
|
|
127
211
|
};
|
|
128
212
|
}
|
|
129
213
|
|
|
130
214
|
function makeOutboundRegisteredHandler(entry, sliceName, consumedEventSchema, callback) {
|
|
215
|
+
let comp = `OutboundTranslationSlice(` + sliceName + `)`;
|
|
216
|
+
let publishJsons = makePublishJsons(entry.dcbQueueUrl, Stdlib_Option.getOr(entry.commandQueueIsFifo, false));
|
|
217
|
+
let syncTodoItems = makeSyncTodoItems(entry.queryDbTableName, callback.todoItems, OutboundTranslationSlice_Callback$ReventlessCore.todoRowSchema);
|
|
218
|
+
let loadTodoItems = makeLoadTodoItems(entry.queryDbTableName, callback.todoItems, OutboundTranslationSlice_Callback$ReventlessCore.todoRowSchema, comp);
|
|
131
219
|
return {
|
|
132
|
-
|
|
133
|
-
|
|
220
|
+
registered: {
|
|
221
|
+
handler: StreamRoutedEntryPoint_Ops$ReventlessAws.toStreamHandler(makeOutboundJsonEventsHandler(consumedEventSchema, callback, publishJsons, syncTodoItems, loadTodoItems)),
|
|
222
|
+
comp: comp
|
|
223
|
+
},
|
|
224
|
+
sweep: async () => {
|
|
225
|
+
await loadTodoItems();
|
|
226
|
+
await callback.phase2(publishJsons, {
|
|
227
|
+
geocode: text => Geocoder_AwsLocation_Backend$ReventlessAws.search(Stdlib_Option.getOr(process.env["PLACE_INDEX_NAME"], ""), text, undefined)
|
|
228
|
+
});
|
|
229
|
+
return await syncTodoItems();
|
|
230
|
+
},
|
|
231
|
+
comp: comp
|
|
134
232
|
};
|
|
135
233
|
}
|
|
136
234
|
|
|
235
|
+
async function runSweeps(slices) {
|
|
236
|
+
for (let i = 0, i_finish = slices.length; i < i_finish; ++i) {
|
|
237
|
+
let slice = slices[i];
|
|
238
|
+
try {
|
|
239
|
+
await slice.sweep();
|
|
240
|
+
} catch (raw_exn) {
|
|
241
|
+
let exn = Primitive_exceptions.internalToException(raw_exn);
|
|
242
|
+
let msg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown");
|
|
243
|
+
Effect.runSync(EffectLogger$ReventlessCore.logError(slice.comp, undefined, `sweep failed: ` + msg));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
137
248
|
export {
|
|
138
249
|
strOf,
|
|
139
250
|
decodeContext,
|
|
@@ -143,9 +254,12 @@ export {
|
|
|
143
254
|
defaultContext,
|
|
144
255
|
makePublishJsons,
|
|
145
256
|
makeSyncTodoItems,
|
|
257
|
+
makeLoadTodoItems,
|
|
258
|
+
capabilities,
|
|
146
259
|
makeAutomationJsonEventsHandler,
|
|
147
260
|
makeOutboundJsonEventsHandler,
|
|
148
261
|
makeAutomationRegisteredHandler,
|
|
149
262
|
makeOutboundRegisteredHandler,
|
|
263
|
+
runSweeps,
|
|
150
264
|
}
|
|
151
265
|
/* S Not a pure module */
|
|
@@ -181,6 +181,121 @@ let forEventCollector: ReventlessCore.Runtime.forEventCollector<
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// How often the shared automation Lambda is invoked with no records, to work
|
|
185
|
+
// its slices' TODO backlogs.
|
|
186
|
+
//
|
|
187
|
+
// Without it a backlog only moves when the *next event* arrives, so a slice
|
|
188
|
+
// whose traffic stops holds its failed items indefinitely — and D4's "retried to
|
|
189
|
+
// maxRetries, then swept" is what an outbound slice is chosen for over a
|
|
190
|
+
// hand-rolled Task. Five minutes is short enough that a transient outage clears
|
|
191
|
+
// on its own without anyone watching, and long enough that the invocations are
|
|
192
|
+
// noise against the event traffic (~288/day per deployment, most of them finding
|
|
193
|
+
// an empty backlog and exiting).
|
|
194
|
+
let sweepIntervalMinutes = 5
|
|
195
|
+
|
|
196
|
+
// The scheduled trigger: a rule, permission for EventBridge to invoke, and a
|
|
197
|
+
// target carrying the constant payload the entry point's shell branches on. The
|
|
198
|
+
// payload is the whole event — a scheduled invocation has no `records`, which is
|
|
199
|
+
// exactly how the shell tells the two apart.
|
|
200
|
+
let makeSweepSchedule = (~runtime: ReventlessCore.Runtime.environment<runtimeParts>, ~opts) => {
|
|
201
|
+
let name = "AllAutomationSlicesSweep"
|
|
202
|
+
let customOpts = opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
|
|
203
|
+
|
|
204
|
+
let rule = {
|
|
205
|
+
open PulumiAws.Cloudwatch
|
|
206
|
+
EventRule.make(
|
|
207
|
+
~name=Pulumi.Pulumi.getStackName() ++ ("-" ++ name),
|
|
208
|
+
~args={
|
|
209
|
+
description: "Work the automation/outbound slices' TODO backlogs"->Pulumi.Input.make,
|
|
210
|
+
scheduleExpression: EventRule.ScheduleExpression.every(sweepIntervalMinutes->Minutes),
|
|
211
|
+
tags: AWS.Tags.make(
|
|
212
|
+
~name=Pulumi.Pulumi.getStackName() ++ ("-" ++ name),
|
|
213
|
+
~kind=ReventlessCore.ComponentType.AutomationSlice,
|
|
214
|
+
~role=Scheduler,
|
|
215
|
+
~component=name,
|
|
216
|
+
),
|
|
217
|
+
},
|
|
218
|
+
~opts=customOpts,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The permission and target need the Lambda's resolved arn/name, so they stay
|
|
223
|
+
// inside an apply — the same shape the plugin heartbeat's runner uses.
|
|
224
|
+
let _permissionAndTarget =
|
|
225
|
+
(
|
|
226
|
+
runtime.parts.lambda->Pulumi.Output.flatMap(l => l.arn),
|
|
227
|
+
runtime.parts.lambda->Pulumi.Output.flatMap(l => l.name),
|
|
228
|
+
)
|
|
229
|
+
->Pulumi.Output.all2
|
|
230
|
+
->Pulumi.Output.apply(((lambdaArn, lambdaName)) => {
|
|
231
|
+
let _permission = PulumiAws.Lambda.Permission.make(
|
|
232
|
+
~name,
|
|
233
|
+
~args={
|
|
234
|
+
action: "lambda:InvokeFunction",
|
|
235
|
+
function: lambdaName->Pulumi.Input.make,
|
|
236
|
+
principal: AWS.CloudwatchEventRule.principal,
|
|
237
|
+
},
|
|
238
|
+
~opts=customOpts,
|
|
239
|
+
)
|
|
240
|
+
let _target = {
|
|
241
|
+
open PulumiAws.Cloudwatch
|
|
242
|
+
EventTarget.make(
|
|
243
|
+
~name,
|
|
244
|
+
~args={
|
|
245
|
+
rule: EventTarget.Rule.ofEventRule(rule),
|
|
246
|
+
arn: lambdaArn->Pulumi.Input.make,
|
|
247
|
+
input: `{"reventlessSweep":true}`->Pulumi.Input.make,
|
|
248
|
+
},
|
|
249
|
+
~opts=customOpts,
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Least-privilege `geo:SearchPlaceIndexForText` on the platform's place index,
|
|
256
|
+
// for the slices that call the geocoder through the injected capability.
|
|
257
|
+
//
|
|
258
|
+
// Only attached when the platform actually provisioned an index — that bit is a
|
|
259
|
+
// plain bool and therefore known synchronously, while the index *name* is an
|
|
260
|
+
// Output. Without the split this would either grant on a nonsense ARN or need
|
|
261
|
+
// `option<Pulumi.Output.t<_>>`, which corrupts the Output proxy.
|
|
262
|
+
//
|
|
263
|
+
// The RolePolicy is created at top level with an Output-valued document, not
|
|
264
|
+
// inside an `apply`. A resource created in an apply callback does not reliably
|
|
265
|
+
// register with the engine — the same defect that intermittently left the
|
|
266
|
+
// heartbeat Lambda without its SQS grant.
|
|
267
|
+
let makeGeocoderGrant = (~runtime: ReventlessCore.Runtime.environment<runtimeParts>, ~opts) =>
|
|
268
|
+
if PluginRuntime_Builder.geocoderProvisioned() {
|
|
269
|
+
let customOpts =
|
|
270
|
+
opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
|
|
271
|
+
let policyJson =
|
|
272
|
+
PluginRuntime_Builder.geocoderPlaceIndex()->Pulumi.Output.apply(idx => {
|
|
273
|
+
open PulumiAws.PolicyDocument
|
|
274
|
+
PulumiAws.PolicyDocument.make(
|
|
275
|
+
~id="AllAutomationSlicesGeocode",
|
|
276
|
+
~statements=[
|
|
277
|
+
{
|
|
278
|
+
sid: "AllowGeocode",
|
|
279
|
+
effect: Allow,
|
|
280
|
+
actions: Action("geo:SearchPlaceIndexForText"),
|
|
281
|
+
// Account/region wildcarded, matching the Function URL handler's
|
|
282
|
+
// own policy: the index name is what identifies it, and the stack
|
|
283
|
+
// has no other account to reach.
|
|
284
|
+
resources: Resource(`arn:aws:geo:*:*:place-index/${idx}`),
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
)->PulumiAws.PolicyDocument.toJsonString
|
|
288
|
+
})
|
|
289
|
+
let _ = PulumiAws.IAM.RolePolicy.make(
|
|
290
|
+
~name="AllAutomationSlicesGeocode",
|
|
291
|
+
~args={
|
|
292
|
+
policy: policyJson->Pulumi.Output.asInput,
|
|
293
|
+
role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
|
|
294
|
+
},
|
|
295
|
+
~opts=customOpts,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
184
299
|
let finished = ref(false)
|
|
185
300
|
|
|
186
301
|
// Legacy finalizer, kept only to satisfy `EventCollectorRuntime_Builder.T`.
|
|
@@ -501,6 +616,9 @@ let finishWithDcbEventLog = (dcbEventLog: ReventlessCore.DcbEventLog.component)
|
|
|
501
616
|
~runtime,
|
|
502
617
|
~opts,
|
|
503
618
|
)
|
|
619
|
+
|
|
620
|
+
makeSweepSchedule(~runtime, ~opts)
|
|
621
|
+
makeGeocoderGrant(~runtime, ~opts)
|
|
504
622
|
| None =>
|
|
505
623
|
log.warn(
|
|
506
624
|
~comp="AutomationSliceRuntime_Builder_Single",
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Aws from "@pulumi/aws";
|
|
3
4
|
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
4
5
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
|
+
import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
|
|
5
7
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
8
|
import * as Pulumi from "@pulumi/pulumi";
|
|
7
9
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
10
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
11
|
+
import * as AWS$ReventlessAws from "../AWS.res.mjs";
|
|
8
12
|
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
13
|
+
import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
|
|
9
14
|
import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
|
|
15
|
+
import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
|
|
10
16
|
import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
|
|
17
|
+
import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
|
|
18
|
+
import * as Cloudwatch_EventRule$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Cloudwatch/Cloudwatch_EventRule.res.mjs";
|
|
19
|
+
import * as Cloudwatch_EventTarget$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Cloudwatch/Cloudwatch_EventTarget.res.mjs";
|
|
11
20
|
import * as CommandTopicRegistry$ReventlessAws from "../CommandTopic/CommandTopicRegistry.res.mjs";
|
|
12
21
|
import * as PluginRuntime_Builder$ReventlessAws from "../../plugin/runtime/PluginRuntime_Builder.res.mjs";
|
|
13
22
|
import * as RuntimeEnvironment_Lambda$ReventlessAws from "./RuntimeEnvironment_Lambda.res.mjs";
|
|
@@ -96,6 +105,51 @@ function forEventCollector(param, eventTopics, resources, memorySizeOpt, timeout
|
|
|
96
105
|
log.info("AutomationSliceRuntime_Builder_Single", undefined, `registered ` + eventCollectorName + ` for ` + parentName);
|
|
97
106
|
}
|
|
98
107
|
|
|
108
|
+
function makeSweepSchedule(runtime, opts) {
|
|
109
|
+
let name = "AllAutomationSlicesSweep";
|
|
110
|
+
let customOpts = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
|
|
111
|
+
let rule = new (Aws.cloudwatch.EventRule)(Pulumi.getStack() + ("-" + name), {
|
|
112
|
+
description: "Work the automation/outbound slices' TODO backlogs",
|
|
113
|
+
scheduleExpression: Primitive_option.some(Cloudwatch_EventRule$PulumiAws.ScheduleExpression.every({
|
|
114
|
+
TAG: "Minutes",
|
|
115
|
+
_0: 5
|
|
116
|
+
})),
|
|
117
|
+
tags: AWS_Tags$ReventlessAws.make(Pulumi.getStack() + ("-" + name), "AutomationSlice", "Scheduler", undefined, name, undefined, undefined, undefined)
|
|
118
|
+
}, customOpts);
|
|
119
|
+
Pulumi.all([
|
|
120
|
+
Output$Pulumi.flatMap(runtime.parts.lambda, l => l.arn),
|
|
121
|
+
Output$Pulumi.flatMap(runtime.parts.lambda, l => l.name)
|
|
122
|
+
]).apply(param => {
|
|
123
|
+
new (Aws.lambda.Permission)(name, {
|
|
124
|
+
action: "lambda:InvokeFunction",
|
|
125
|
+
function: param[1],
|
|
126
|
+
principal: AWS$ReventlessAws.CloudwatchEventRule.principal
|
|
127
|
+
}, customOpts);
|
|
128
|
+
new (Aws.cloudwatch.EventTarget)(name, {
|
|
129
|
+
rule: Cloudwatch_EventTarget$PulumiAws.Rule.ofEventRule(rule),
|
|
130
|
+
arn: param[0],
|
|
131
|
+
input: `{"reventlessSweep":true}`
|
|
132
|
+
}, customOpts);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function makeGeocoderGrant(runtime, opts) {
|
|
137
|
+
if (!PluginRuntime_Builder$ReventlessAws.geocoderProvisioned()) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
let customOpts = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
|
|
141
|
+
let policyJson = PluginRuntime_Builder$ReventlessAws.geocoderPlaceIndex().apply(idx => PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "AllAutomationSlicesGeocode", [{
|
|
142
|
+
Sid: "AllowGeocode",
|
|
143
|
+
Effect: "Allow",
|
|
144
|
+
Action: "geo:SearchPlaceIndexForText",
|
|
145
|
+
Resource: `arn:aws:geo:*:*:place-index/` + idx
|
|
146
|
+
}])));
|
|
147
|
+
new (Aws.iam.RolePolicy)("AllAutomationSlicesGeocode", {
|
|
148
|
+
policy: policyJson,
|
|
149
|
+
role: runtime.parts.lambdaRole.id
|
|
150
|
+
}, customOpts);
|
|
151
|
+
}
|
|
152
|
+
|
|
99
153
|
let finished = {
|
|
100
154
|
contents: false
|
|
101
155
|
};
|
|
@@ -271,6 +325,8 @@ function finishWithDcbEventLog(dcbEventLog) {
|
|
|
271
325
|
eventTopics: eventTopics,
|
|
272
326
|
resources: allQueryDbResources
|
|
273
327
|
}], runtime, opts);
|
|
328
|
+
makeSweepSchedule(runtime, opts);
|
|
329
|
+
makeGeocoderGrant(runtime, opts);
|
|
274
330
|
} else {
|
|
275
331
|
log.warn("AutomationSliceRuntime_Builder_Single", undefined, "finishWithDcbEventLog: DCB EventLog has no parent");
|
|
276
332
|
}
|
|
@@ -282,6 +338,8 @@ let EventCollectorChannel;
|
|
|
282
338
|
|
|
283
339
|
let RuntimeEnvironment;
|
|
284
340
|
|
|
341
|
+
let sweepIntervalMinutes = 5;
|
|
342
|
+
|
|
285
343
|
export {
|
|
286
344
|
EventCollectorChannel,
|
|
287
345
|
RuntimeEnvironment,
|
|
@@ -296,6 +354,9 @@ export {
|
|
|
296
354
|
storedSpecs,
|
|
297
355
|
grandParent,
|
|
298
356
|
forEventCollector,
|
|
357
|
+
sweepIntervalMinutes,
|
|
358
|
+
makeSweepSchedule,
|
|
359
|
+
makeGeocoderGrant,
|
|
299
360
|
finished,
|
|
300
361
|
finish,
|
|
301
362
|
finishWithDcbEventLog,
|
|
@@ -14,6 +14,20 @@ module EventCollectorRuntimeBuilder = {
|
|
|
14
14
|
let finish = Inner.finish
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// On AWS the core builder's in-process handler is never the thing that runs a
|
|
18
|
+
// translation: `forEventCollector` registers module paths, and the shared
|
|
19
|
+
// `AllAutomationSlices` Lambda rebuilds the callback from them and drives phase 2
|
|
20
|
+
// itself. So the capabilities that matter are the ones
|
|
21
|
+
// `AutomationSliceEntryPoint_Ops.capabilities` builds at runtime, from the
|
|
22
|
+
// Lambda's environment — where the place index name actually is.
|
|
23
|
+
//
|
|
24
|
+
// This deploy-time value therefore has no caller. `none` rather than a real
|
|
25
|
+
// geocoder because inventing one here would suggest a path that does not exist,
|
|
26
|
+
// and because a deploy-time module has no environment to read anyway.
|
|
27
|
+
module DeployTimeCapabilities = {
|
|
28
|
+
let capabilities = () => Reventless.Capabilities.none
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
module Make = (Api: {
|
|
18
32
|
let api: unit => Types.AppSync.api
|
|
19
33
|
let apiRole: unit => Types.AppSync.role
|
|
@@ -25,6 +39,7 @@ module Make = (Api: {
|
|
|
25
39
|
EventCollectorChannel,
|
|
26
40
|
EventCollectorRuntimeBuilder,
|
|
27
41
|
Api,
|
|
42
|
+
DeployTimeCapabilities,
|
|
28
43
|
)
|
|
29
44
|
|
|
30
45
|
module Make = (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
|
|
4
|
+
import * as Capabilities$Reventless from "@reventlessdev/reventless-spec/src/semantic/Capabilities.res.mjs";
|
|
4
5
|
import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
|
|
5
6
|
import * as EventTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/EventTopic/EventTopic.res.mjs";
|
|
6
7
|
import * as Util_Bundle$ReventlessAws from "../util/Util_Bundle.res.mjs";
|
|
@@ -18,6 +19,14 @@ let EventCollectorRuntimeBuilder = {
|
|
|
18
19
|
finish: AutomationSliceRuntime_Builder_Single$ReventlessAws.finish
|
|
19
20
|
};
|
|
20
21
|
|
|
22
|
+
function capabilities() {
|
|
23
|
+
return Capabilities$Reventless.none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let DeployTimeCapabilities = {
|
|
27
|
+
capabilities: capabilities
|
|
28
|
+
};
|
|
29
|
+
|
|
21
30
|
function Make(Api) {
|
|
22
31
|
let Inner = OutboundTranslationSlice_Builder$ReventlessCore.Make({
|
|
23
32
|
make: RuntimeEnvironment_Lambda$ReventlessAws.make,
|
|
@@ -42,7 +51,7 @@ function Make(Api) {
|
|
|
42
51
|
},
|
|
43
52
|
forEventCollector: AutomationSliceRuntime_Builder_Single$ReventlessAws.forEventCollector,
|
|
44
53
|
finish: AutomationSliceRuntime_Builder_Single$ReventlessAws.finish
|
|
45
|
-
})(Api);
|
|
54
|
+
})(Api)(DeployTimeCapabilities);
|
|
46
55
|
let Make$1 = Spec => (Translation => {
|
|
47
56
|
let InnerMake = Inner.Make(Spec)(Translation);
|
|
48
57
|
let make = (dcbEventLog, allEventTopicsOpt, publishJsons, runtime, opts) => {
|
|
@@ -80,6 +89,7 @@ export {
|
|
|
80
89
|
EventCollectorChannel,
|
|
81
90
|
RuntimeEnvironment,
|
|
82
91
|
EventCollectorRuntimeBuilder,
|
|
92
|
+
DeployTimeCapabilities,
|
|
83
93
|
Make,
|
|
84
94
|
}
|
|
85
95
|
/* Component-ReventlessCore Not a pure module */
|
|
@@ -51,6 +51,32 @@ let registerCapabilityEnv = (name: string, value: Pulumi.Output.t<string>) =>
|
|
|
51
51
|
capability, so this returns `[]` rather than throwing. */
|
|
52
52
|
let capabilityEnv = () => capabilityEnvRef->Dict.toArray
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
The geocoding place index, when the platform provisioned one.
|
|
56
|
+
|
|
57
|
+
Separate from `capabilityEnv` because an env dict cannot express a *grant*: a
|
|
58
|
+
slice Lambda calling Amazon Location needs `geo:SearchPlaceIndexForText` on this
|
|
59
|
+
index, and its role is created later, in the slice runtime's finalizer. So the
|
|
60
|
+
name travels twice — once as a variable the runtime reads, once as the resource
|
|
61
|
+
its role must be allowed to touch.
|
|
62
|
+
|
|
63
|
+
A plain Output with `""` for "not provisioned", plus a separate bool for "was it
|
|
64
|
+
registered at all", rather than `ref<option<Pulumi.Output.t<_>>>`: that shape
|
|
65
|
+
compiles to `Option.getOr`, whose nested-option probe corrupts the Output proxy.
|
|
66
|
+
The bool is an ordinary value and is known synchronously, which is what the
|
|
67
|
+
finalizer needs in order to decide whether to attach a policy at all.
|
|
68
|
+
*/
|
|
69
|
+
let geocoderPlaceIndexRef: ref<Pulumi.Output.t<string>> = ref(Pulumi.Output.make(""))
|
|
70
|
+
let geocoderProvisionedRef = ref(false)
|
|
71
|
+
|
|
72
|
+
let registerGeocoderPlaceIndex = (indexName: Pulumi.Output.t<string>) => {
|
|
73
|
+
geocoderPlaceIndexRef := indexName
|
|
74
|
+
geocoderProvisionedRef := true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let geocoderPlaceIndex = () => geocoderPlaceIndexRef.contents
|
|
78
|
+
let geocoderProvisioned = () => geocoderProvisionedRef.contents
|
|
79
|
+
|
|
54
80
|
type sliceModulePaths = {
|
|
55
81
|
specPath: string,
|
|
56
82
|
behaviorPath: string,
|
|
@@ -44,6 +44,27 @@ function capabilityEnv() {
|
|
|
44
44
|
return Object.entries(capabilityEnvRef);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
let geocoderPlaceIndexRef = {
|
|
48
|
+
contents: Pulumi.output("")
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let geocoderProvisionedRef = {
|
|
52
|
+
contents: false
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function registerGeocoderPlaceIndex(indexName) {
|
|
56
|
+
geocoderPlaceIndexRef.contents = indexName;
|
|
57
|
+
geocoderProvisionedRef.contents = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function geocoderPlaceIndex() {
|
|
61
|
+
return geocoderPlaceIndexRef.contents;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function geocoderProvisioned() {
|
|
65
|
+
return geocoderProvisionedRef.contents;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
let dcbConfigRef = {
|
|
48
69
|
contents: {
|
|
49
70
|
pluginName: "",
|
|
@@ -506,6 +527,11 @@ export {
|
|
|
506
527
|
capabilityEnvRef,
|
|
507
528
|
registerCapabilityEnv,
|
|
508
529
|
capabilityEnv,
|
|
530
|
+
geocoderPlaceIndexRef,
|
|
531
|
+
geocoderProvisionedRef,
|
|
532
|
+
registerGeocoderPlaceIndex,
|
|
533
|
+
geocoderPlaceIndex,
|
|
534
|
+
geocoderProvisioned,
|
|
509
535
|
dcbConfigRef,
|
|
510
536
|
registeredSliceModulePaths,
|
|
511
537
|
registerStateChangeSliceSpec,
|
|
@@ -112,6 +112,7 @@ describe("AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandler", () =>
|
|
|
112
112
|
~callback,
|
|
113
113
|
~publishJsons=noopPublish,
|
|
114
114
|
~syncTodoItems=async () => steps->Array.push("sync"),
|
|
115
|
+
~loadTodoItems=async () => steps->Array.push("restore"),
|
|
115
116
|
)
|
|
116
117
|
let envelope = obj([("meta", obj([("service", str("Order"))])), ("event", str("Placed"))])
|
|
117
118
|
await Stream.fromIterable([envelope, str("bare")])->handler->Effect.runPromise
|
|
@@ -120,7 +121,43 @@ describe("AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandler", () =>
|
|
|
120
121
|
expect(receivedCtx.contents->Option.map(c => c.Reventless.AutomationSlice.sliceName))->toEqual(
|
|
121
122
|
Some("S"),
|
|
122
123
|
)
|
|
123
|
-
|
|
124
|
+
// `restore` first: rehydrating the TODO list after phase 1 would let a stored
|
|
125
|
+
// row overwrite one this batch just collected, and after phase 2 would leave
|
|
126
|
+
// the reloaded backlog unprocessed until another invocation.
|
|
127
|
+
expect(steps)->toEqual(["restore", "phase1", "sync", "phase2", "sync"])
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe("AutomationSliceEntryPoint_Ops.runSweeps", () => {
|
|
132
|
+
let dummyRegistered: StreamRoutedEntryPoint_Ops.registeredHandler = {
|
|
133
|
+
handler: (_event, _context) => Effect.succeed(),
|
|
134
|
+
}
|
|
135
|
+
let sliceThat = (~comp, ~run): AutomationSliceEntryPoint_Ops.builtSlice => {
|
|
136
|
+
registered: dummyRegistered,
|
|
137
|
+
sweep: run,
|
|
138
|
+
comp,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test("sweeps every slice", async () => {
|
|
142
|
+
let swept = []
|
|
143
|
+
await AutomationSliceEntryPoint_Ops.runSweeps([
|
|
144
|
+
sliceThat(~comp="A", ~run=async () => swept->Array.push("a")),
|
|
145
|
+
sliceThat(~comp="B", ~run=async () => swept->Array.push("b")),
|
|
146
|
+
])
|
|
147
|
+
expect(swept)->toEqual(["a", "b"])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// A sweep exists to recover from a failing external call, so one slice whose
|
|
151
|
+
// geocoder is still down must not cost every other slice on the Lambda its
|
|
152
|
+
// turn — that would be a worse version of the problem being fixed.
|
|
153
|
+
test("a throwing slice does not stop the others", async () => {
|
|
154
|
+
let swept = []
|
|
155
|
+
await AutomationSliceEntryPoint_Ops.runSweeps([
|
|
156
|
+
sliceThat(~comp="A", ~run=async () => swept->Array.push("a")),
|
|
157
|
+
sliceThat(~comp="Boom", ~run=async () => JsError.throwWithMessage("gateway down")),
|
|
158
|
+
sliceThat(~comp="C", ~run=async () => swept->Array.push("c")),
|
|
159
|
+
])
|
|
160
|
+
expect(swept)->toEqual(["a", "c"])
|
|
124
161
|
})
|
|
125
162
|
})
|
|
126
163
|
|
|
@@ -142,13 +179,14 @@ describe("AutomationSliceEntryPoint_Ops.makeOutboundJsonEventsHandler", () => {
|
|
|
142
179
|
received := events
|
|
143
180
|
steps->Array.push("phase1")
|
|
144
181
|
},
|
|
145
|
-
phase2: async _publish => steps->Array.push("phase2"),
|
|
182
|
+
phase2: async (_publish, ~capabilities as _) => steps->Array.push("phase2"),
|
|
146
183
|
}
|
|
147
184
|
let handler = AutomationSliceEntryPoint_Ops.makeOutboundJsonEventsHandler(
|
|
148
185
|
~consumedEventSchema=outboundEventSchema,
|
|
149
186
|
~callback,
|
|
150
187
|
~publishJsons=noopPublish,
|
|
151
188
|
~syncTodoItems=async () => steps->Array.push("sync"),
|
|
189
|
+
~loadTodoItems=async () => steps->Array.push("restore"),
|
|
152
190
|
)
|
|
153
191
|
// `id` is the envelope's subject — the entity the event was published for.
|
|
154
192
|
// An Aggregate's event payload does not repeat it, so this is the only place
|
|
@@ -170,6 +208,9 @@ describe("AutomationSliceEntryPoint_Ops.makeOutboundJsonEventsHandler", () => {
|
|
|
170
208
|
("ord-1", OrderPlaced({orderId: "ord-1"})),
|
|
171
209
|
("", OrderArchived),
|
|
172
210
|
])
|
|
173
|
-
|
|
211
|
+
// `restore` first: rehydrating the TODO list after phase 1 would let a stored
|
|
212
|
+
// row overwrite one this batch just collected, and after phase 2 would leave
|
|
213
|
+
// the reloaded backlog unprocessed until another invocation.
|
|
214
|
+
expect(steps)->toEqual(["restore", "phase1", "sync", "phase2", "sync"])
|
|
174
215
|
})
|
|
175
216
|
})
|
|
@@ -153,6 +153,8 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandl
|
|
|
153
153
|
sliceName: "S"
|
|
154
154
|
}, callback, noopPublish, async () => {
|
|
155
155
|
steps.push("sync");
|
|
156
|
+
}, async () => {
|
|
157
|
+
steps.push("restore");
|
|
156
158
|
});
|
|
157
159
|
let envelope = Object.fromEntries([
|
|
158
160
|
[
|
|
@@ -177,6 +179,7 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandl
|
|
|
177
179
|
]);
|
|
178
180
|
globalThis.expect(Stdlib_Option.map(receivedCtx.contents, c => c.sliceName)).toEqual("S");
|
|
179
181
|
globalThis.expect(steps).toEqual([
|
|
182
|
+
"restore",
|
|
180
183
|
"phase1",
|
|
181
184
|
"sync",
|
|
182
185
|
"phase2",
|
|
@@ -185,6 +188,64 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandl
|
|
|
185
188
|
});
|
|
186
189
|
});
|
|
187
190
|
|
|
191
|
+
globalThis.describe("AutomationSliceEntryPoint_Ops.runSweeps", () => {
|
|
192
|
+
let dummyRegistered_handler = (_event, _context) => Effect.succeed();
|
|
193
|
+
let dummyRegistered = {
|
|
194
|
+
handler: dummyRegistered_handler
|
|
195
|
+
};
|
|
196
|
+
globalThis.test("sweeps every slice", async () => {
|
|
197
|
+
let swept = [];
|
|
198
|
+
await AutomationSliceEntryPoint_Ops$ReventlessAws.runSweeps([
|
|
199
|
+
{
|
|
200
|
+
registered: dummyRegistered,
|
|
201
|
+
sweep: async () => {
|
|
202
|
+
swept.push("a");
|
|
203
|
+
},
|
|
204
|
+
comp: "A"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
registered: dummyRegistered,
|
|
208
|
+
sweep: async () => {
|
|
209
|
+
swept.push("b");
|
|
210
|
+
},
|
|
211
|
+
comp: "B"
|
|
212
|
+
}
|
|
213
|
+
]);
|
|
214
|
+
globalThis.expect(swept).toEqual([
|
|
215
|
+
"a",
|
|
216
|
+
"b"
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
globalThis.test("a throwing slice does not stop the others", async () => {
|
|
220
|
+
let swept = [];
|
|
221
|
+
await AutomationSliceEntryPoint_Ops$ReventlessAws.runSweeps([
|
|
222
|
+
{
|
|
223
|
+
registered: dummyRegistered,
|
|
224
|
+
sweep: async () => {
|
|
225
|
+
swept.push("a");
|
|
226
|
+
},
|
|
227
|
+
comp: "A"
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
registered: dummyRegistered,
|
|
231
|
+
sweep: async () => Stdlib_JsError.throwWithMessage("gateway down"),
|
|
232
|
+
comp: "Boom"
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
registered: dummyRegistered,
|
|
236
|
+
sweep: async () => {
|
|
237
|
+
swept.push("c");
|
|
238
|
+
},
|
|
239
|
+
comp: "C"
|
|
240
|
+
}
|
|
241
|
+
]);
|
|
242
|
+
globalThis.expect(swept).toEqual([
|
|
243
|
+
"a",
|
|
244
|
+
"c"
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
188
249
|
let outboundEventSchema = S.union([
|
|
189
250
|
S.schema(s => ({
|
|
190
251
|
TAG: "OrderPlaced",
|
|
@@ -204,7 +265,7 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.makeOutboundJsonEventsHandler
|
|
|
204
265
|
received.contents = events;
|
|
205
266
|
steps.push("phase1");
|
|
206
267
|
};
|
|
207
|
-
let callback_phase2 = async _publish => {
|
|
268
|
+
let callback_phase2 = async (_publish, param) => {
|
|
208
269
|
steps.push("phase2");
|
|
209
270
|
};
|
|
210
271
|
let callback = {
|
|
@@ -214,6 +275,8 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.makeOutboundJsonEventsHandler
|
|
|
214
275
|
};
|
|
215
276
|
let handler = AutomationSliceEntryPoint_Ops$ReventlessAws.makeOutboundJsonEventsHandler(outboundEventSchema, callback, noopPublish, async () => {
|
|
216
277
|
steps.push("sync");
|
|
278
|
+
}, async () => {
|
|
279
|
+
steps.push("restore");
|
|
217
280
|
});
|
|
218
281
|
let placed = Object.fromEntries([
|
|
219
282
|
[
|
|
@@ -273,6 +336,7 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.makeOutboundJsonEventsHandler
|
|
|
273
336
|
]
|
|
274
337
|
]);
|
|
275
338
|
globalThis.expect(steps).toEqual([
|
|
339
|
+
"restore",
|
|
276
340
|
"phase1",
|
|
277
341
|
"sync",
|
|
278
342
|
"phase2",
|