@reventlessdev/reventless-aws 3.0.0-alpha.264 → 3.0.0-alpha.266

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +7 -7
  3. package/src/Platform.res +66 -45
  4. package/src/Platform.res.mjs +31 -35
  5. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +227 -0
  6. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res.mjs +127 -0
  7. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver_Ops.res +55 -0
  8. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver_Ops.res.mjs +48 -0
  9. package/src/adapter/Runtime/AutomationSliceEntryPoint.mjs +21 -3
  10. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +219 -27
  11. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +121 -7
  12. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +118 -0
  13. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +61 -0
  14. package/src/components/OutboundTranslationSlice_Builder.res +15 -0
  15. package/src/components/OutboundTranslationSlice_Builder.res.mjs +11 -1
  16. package/src/plugin/runtime/PluginRuntime_Builder.res +26 -0
  17. package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +26 -0
  18. package/tests/AutomationSliceEntryPoint_OpsTest.res +44 -3
  19. package/tests/AutomationSliceEntryPoint_OpsTest.res.mjs +65 -1
  20. package/tests/Geocoder_AwsLocation_Resolver_OpsTest.res +53 -0
  21. package/tests/Geocoder_AwsLocation_Resolver_OpsTest.res.mjs +55 -0
  22. package/src/adapter/Geocoder/Geocoder_AwsLocation.res +0 -166
  23. package/src/adapter/Geocoder/Geocoder_AwsLocation.res.mjs +0 -105
  24. package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res +0 -144
  25. package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res.mjs +0 -137
  26. package/tests/Geocoder_AwsLocation_OpsTest.res +0 -78
  27. package/tests/Geocoder_AwsLocation_OpsTest.res.mjs +0 -71
@@ -0,0 +1,127 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Aws from "@pulumi/aws";
4
+ import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.res.mjs";
5
+ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Pulumi from "@pulumi/pulumi";
8
+ import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
9
+ import * as AWS$ReventlessAws from "../AWS.res.mjs";
10
+ import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
11
+ import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
12
+ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
13
+ import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
14
+ import * as Util_LambdaLogging$ReventlessAws from "../../util/Util_LambdaLogging.res.mjs";
15
+ import * as AppSync_Resolver_Native$ReventlessAws from "../Api/AppSync_Resolver_Native.res.mjs";
16
+
17
+ let invokeCode = `import { util } from '@aws-appsync/utils';
18
+ export function request(ctx) {
19
+ return { operation: 'Invoke', payload: { arguments: ctx.args } };
20
+ }
21
+ export function response(ctx) {
22
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
23
+ return ctx.result;
24
+ }
25
+ `;
26
+
27
+ function make(api, placeIndexName, nameOpt, opts) {
28
+ let name = nameOpt !== undefined ? nameOpt : "GeocodeService";
29
+ let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
30
+ let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "Lambda", Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name + "Lambda", "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
31
+ placeIndexName.apply(idx => {
32
+ let arn = `arn:aws:geo:*:*:place-index/` + idx;
33
+ new (Aws.iam.RolePolicy)(name + "LambdaPolicy", {
34
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "LambdaPolicy", [
35
+ {
36
+ Sid: "AllowLambdaLogging",
37
+ Effect: "Allow",
38
+ Action: [
39
+ "logs:CreateLogGroup",
40
+ "logs:CreateLogStream",
41
+ "logs:PutLogEvents"
42
+ ],
43
+ Resource: "arn:aws:logs:*:*:*"
44
+ },
45
+ {
46
+ Sid: "AllowGeocode",
47
+ Effect: "Allow",
48
+ Action: "geo:SearchPlaceIndexForText",
49
+ Resource: arn
50
+ }
51
+ ])),
52
+ role: lambdaRole.id
53
+ }, opts$1);
54
+ });
55
+ let packageDirs = Object.fromEntries([[
56
+ "@reventlessdev/reventless-aws",
57
+ Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-aws")
58
+ ]]);
59
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver_Ops.res.mjs", packageDirs, undefined);
60
+ let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
61
+ let lambda = new (Aws.lambda.Function)(name + "Lambda", {
62
+ handler: "index.handler",
63
+ runtime: "nodejs22.x",
64
+ code: match.code,
65
+ role: lambdaRole.arn,
66
+ memorySize: 256,
67
+ timeout: 30,
68
+ layers: layers,
69
+ tags: AWS_Tags$ReventlessAws.make(name + "Lambda", "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined),
70
+ environment: {
71
+ variables: Object.fromEntries([
72
+ [
73
+ "Environment",
74
+ Pulumi.getStack()
75
+ ],
76
+ [
77
+ "PLACE_INDEX_NAME",
78
+ placeIndexName
79
+ ],
80
+ [
81
+ "NODE_OPTIONS",
82
+ Util_Bundle$ReventlessAws.esmLoaderNodeOptions
83
+ ],
84
+ [
85
+ "ESM_FALLBACK_DIRS",
86
+ Util_Bundle$ReventlessAws.esmFallbackDirs
87
+ ],
88
+ Util_LambdaLogging$ReventlessAws.logLevelEntry()
89
+ ])
90
+ },
91
+ sourceCodeHash: match.sourceCodeHash
92
+ }, opts$1);
93
+ Util_LambdaLogging$ReventlessAws.makeManagedLogGroup(name + "Lambda", lambda.name, AWS_Tags$ReventlessAws.make(name + "LambdaLogGroup", "Platform", "Logs", "Platform", undefined, undefined, undefined, undefined), opts$1, undefined);
94
+ let dataSourceRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "DataSource", Pulumi.output(AWS$ReventlessAws.AppSync.principal), AWS_Tags$ReventlessAws.make(name + "DataSource", "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
95
+ Pulumi.all([
96
+ lambda.arn,
97
+ dataSourceRole.id
98
+ ]).apply(param => {
99
+ new (Aws.iam.RolePolicy)(name + "DataSource", {
100
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "DataSourcePolicy", [{
101
+ Sid: "AllowDataSourceInvokeLambda",
102
+ Effect: "Allow",
103
+ Action: "lambda:InvokeFunction",
104
+ Resource: param[0]
105
+ }])),
106
+ role: param[1]
107
+ }, opts$1);
108
+ });
109
+ let dataSource = new (Aws.appsync.DataSource)(name + "DataSource", {
110
+ type: "AWS_LAMBDA",
111
+ apiId: Output$Pulumi.flatMap(api, api => api.id),
112
+ lambdaConfig: {
113
+ functionArn: lambda.arn
114
+ },
115
+ serviceRoleArn: dataSourceRole.arn
116
+ }, opts$1);
117
+ AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "Geocode", api, dataSource.name, "Query", "geocode", invokeCode, opts$1);
118
+ return {
119
+ resources: [lambda.arn]
120
+ };
121
+ }
122
+
123
+ export {
124
+ invokeCode,
125
+ make,
126
+ }
127
+ /* @pulumi/aws Not a pure module */
@@ -0,0 +1,55 @@
1
+ // Runtime handler for the geocoder's client door — compiled, type-checked, and
2
+ // Pulumi-free so it can be shipped as an EntryPoint module
3
+ // (`Geocoder_AwsLocation_Resolver` bundles it and attaches it as the platform API's
4
+ // `Query.geocode` Lambda data source). Keeping it out of the deploy-time module
5
+ // avoids both the serialized-closure SDK skew and a deploy-time Pulumi import
6
+ // leaking into the Lambda's cold-start graph.
7
+ //
8
+ // Invoked by an AppSync resolver, not a Function URL: the platform API's Cognito
9
+ // authorizer has already authenticated the caller, so there is no token to decode
10
+ // and no anonymous surface. The SDK call, the `[lng, lat]` order and the relevance
11
+ // handling all live in `Geocoder_AwsLocation_Backend`, shared with the unattended
12
+ // slice path so one module owns the AWS Location call.
13
+ //
14
+ // Returns the ranked candidates as `[{label, lat, lng, relevance?}]`. A no-match is
15
+ // an empty array — a browser search box degrades to "no results". A service failure
16
+ // throws, which the resolver's response mapper turns into a GraphQL error rather
17
+ // than an empty answer: the client half of D2's status contract, so a browser can
18
+ // tell "no such address" from "the geocoder is down" the way the Function URL's
19
+ // `200`/`502` split did.
20
+
21
+ let candidateJson = (c: Reventless.Geocoding.candidate): JSON.t =>
22
+ Dict.fromArray(
23
+ Array.concat(
24
+ [
25
+ ("label", JSON.Encode.string(c.label)),
26
+ ("lat", JSON.Encode.float(c.point.lat)),
27
+ ("lng", JSON.Encode.float(c.point.lng)),
28
+ ],
29
+ switch c.relevance {
30
+ | Some(rel) => [("relevance", JSON.Encode.float(rel))]
31
+ | None => []
32
+ },
33
+ ),
34
+ )->JSON.Encode.object
35
+
36
+ // Only `text` is read; `ctx.identity` is forwarded by the resolver but the geocoder
37
+ // does not scope by caller, so the handler ignores it.
38
+ type geocodeArgs = {text?: string}
39
+ type appSyncEvent = {arguments?: geocodeArgs}
40
+
41
+ let handler = async (event: appSyncEvent): array<JSON.t> => {
42
+ let indexName = switch NodeProcess.env->Dict.get("PLACE_INDEX_NAME") {
43
+ | Some("") | None => ""
44
+ | Some(v) => v
45
+ }
46
+ let text = event.arguments->Option.flatMap(a => a.text)->Option.getOr("")
47
+ switch await Geocoder_AwsLocation_Backend.search(~indexName, ~text) {
48
+ | Ok(candidates) => candidates->Array.map(candidateJson)
49
+ // The provider answered and had nothing — a true, final empty answer.
50
+ | Error(NoMatch) => []
51
+ // Misconfigured (no index) or the service failed: throw so `ctx.error` is set and
52
+ // the caller sees an error, never a silent empty list it would read as "no match".
53
+ | Error(Unavailable(msg)) => JsError.throwWithMessage(`geocoder unavailable: ${msg}`)
54
+ }
55
+ }
@@ -0,0 +1,48 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
5
+ import * as Geocoder_AwsLocation_Backend$ReventlessAws from "./Geocoder_AwsLocation_Backend.res.mjs";
6
+
7
+ function candidateJson(c) {
8
+ let rel = c.relevance;
9
+ return Object.fromEntries([
10
+ [
11
+ "label",
12
+ c.label
13
+ ],
14
+ [
15
+ "lat",
16
+ c.point.lat
17
+ ],
18
+ [
19
+ "lng",
20
+ c.point.lng
21
+ ]
22
+ ].concat(rel !== undefined ? [[
23
+ "relevance",
24
+ rel
25
+ ]] : []));
26
+ }
27
+
28
+ async function handler(event) {
29
+ let v = process.env["PLACE_INDEX_NAME"];
30
+ let indexName = v !== undefined && v !== "" ? v : "";
31
+ let text = Stdlib_Option.getOr(Stdlib_Option.flatMap(event.arguments, a => a.text), "");
32
+ let candidates = await Geocoder_AwsLocation_Backend$ReventlessAws.search(indexName, text, undefined);
33
+ if (candidates.TAG === "Ok") {
34
+ return candidates._0.map(candidateJson);
35
+ }
36
+ let msg = candidates._0;
37
+ if (typeof msg !== "object") {
38
+ return [];
39
+ } else {
40
+ return Stdlib_JsError.throwWithMessage(`geocoder unavailable: ` + msg._0);
41
+ }
42
+ }
43
+
44
+ export {
45
+ candidateJson,
46
+ handler,
47
+ }
48
+ /* Geocoder_AwsLocation_Backend-ReventlessAws Not a pure module */
@@ -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
  }