@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 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.264",
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.209",
22
- "@reventlessdev/reventless-infra": "3.0.0-alpha.124",
23
- "@reventlessdev/reventless-spec": "3.0.0-alpha.99",
24
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.73",
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
- let geocoderEndpoint: Pulumi.Output.t<string> = switch platformStackRef {
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("geocoderEndpoint"):
2282
+ stackRef->Pulumi.StackReference.getOutput("geocoderPlaceIndex"):
2258
2283
  Pulumi.Output.t<option<string>>
2259
2284
  )->Pulumi.Output.apply(o => o->Option.getOr(""))
2260
- | None => geocoderEndpointRef.contents
2285
+ | None => geocoderPlaceIndexRef.contents
2261
2286
  }
2262
- PluginRuntime_Builder.registerCapabilityEnv("GEOCODER_ENDPOINT", geocoderEndpoint)
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()
@@ -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 geocoderEndpoint = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("geocoderEndpoint").apply(o => Stdlib_Option.getOr(o, "")) : geocoderEndpointRef.contents;
1251
- PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("GEOCODER_ENDPOINT", geocoderEndpoint);
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 geocoderEndpoint = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("geocoderEndpoint").apply(o => Stdlib_Option.getOr(o, "")) : geocoderEndpointRef.contents;
2482
- PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("GEOCODER_ENDPOINT", geocoderEndpoint);
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
- export const handler = StreamOps.makeRoutedHandler("AutomationSliceRuntime", buildAllHandlers());
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
- ): StreamRoutedEntryPoint_Ops.registeredHandler => {
231
- handler: StreamRoutedEntryPoint_Ops.toStreamHandler(
232
- makeAutomationJsonEventsHandler(
233
- ~context=entry.context->Option.getOr(defaultContext(sliceName)),
234
- ~callback,
235
- ~publishJsons=makePublishJsons(entry.dcbQueueUrl, ~isFifo=entry.commandQueueIsFifo->Option.getOr(false)),
236
- ~syncTodoItems=makeSyncTodoItems(
237
- ~queryDbTableName=entry.queryDbTableName,
238
- ~todoItems=callback.todoItems,
239
- ~rowSchema=ReventlessCore.AutomationSlice_Callback.todoRowSchema,
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
- comp: `AutomationSlice(${sliceName})`,
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
- ): StreamRoutedEntryPoint_Ops.registeredHandler => {
252
- handler: StreamRoutedEntryPoint_Ops.toStreamHandler(
253
- makeOutboundJsonEventsHandler(
254
- ~consumedEventSchema,
255
- ~callback,
256
- ~publishJsons=makePublishJsons(entry.dcbQueueUrl, ~isFifo=entry.commandQueueIsFifo->Option.getOr(false)),
257
- ~syncTodoItems=makeSyncTodoItems(
258
- ~queryDbTableName=entry.queryDbTableName,
259
- ~todoItems=callback.todoItems,
260
- ~rowSchema=ReventlessCore.OutboundTranslationSlice_Callback.todoRowSchema,
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
- comp: `OutboundTranslationSlice(${sliceName})`,
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 makeAutomationJsonEventsHandler(context, callback, publishJsons, syncTodoItems) {
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
- handler: StreamRoutedEntryPoint_Ops$ReventlessAws.toStreamHandler(makeAutomationJsonEventsHandler(Stdlib_Option.getOr(entry.context, defaultContext(sliceName)), callback, makePublishJsons(entry.dcbQueueUrl, Stdlib_Option.getOr(entry.commandQueueIsFifo, false)), makeSyncTodoItems(entry.queryDbTableName, callback.todoItems, AutomationSlice_Callback$ReventlessCore.todoRowSchema))),
126
- comp: `AutomationSlice(` + sliceName + `)`
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
- handler: StreamRoutedEntryPoint_Ops$ReventlessAws.toStreamHandler(makeOutboundJsonEventsHandler(consumedEventSchema, callback, makePublishJsons(entry.dcbQueueUrl, Stdlib_Option.getOr(entry.commandQueueIsFifo, false)), makeSyncTodoItems(entry.queryDbTableName, callback.todoItems, OutboundTranslationSlice_Callback$ReventlessCore.todoRowSchema))),
133
- comp: `OutboundTranslationSlice(` + sliceName + `)`
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
- expect(steps)->toEqual(["phase1", "sync", "phase2", "sync"])
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
- expect(steps)->toEqual(["phase1", "sync", "phase2", "sync"])
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",