@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.
- package/CHANGELOG.md +17 -0
- package/package.json +7 -7
- package/src/Platform.res +66 -45
- package/src/Platform.res.mjs +31 -35
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +227 -0
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res.mjs +127 -0
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver_Ops.res +55 -0
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver_Ops.res.mjs +48 -0
- 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/tests/Geocoder_AwsLocation_Resolver_OpsTest.res +53 -0
- package/tests/Geocoder_AwsLocation_Resolver_OpsTest.res.mjs +55 -0
- package/src/adapter/Geocoder/Geocoder_AwsLocation.res +0 -166
- package/src/adapter/Geocoder/Geocoder_AwsLocation.res.mjs +0 -105
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res +0 -144
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res.mjs +0 -137
- package/tests/Geocoder_AwsLocation_OpsTest.res +0 -78
- 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
|
-
|
|
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
|
}
|