@reventlessdev/reventless-aws 3.0.0-alpha.259 → 3.0.0-alpha.261

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 (29) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +9 -9
  3. package/src/Platform.res +79 -4
  4. package/src/Platform.res.mjs +24 -8
  5. package/src/adapter/Geocoder/Geocoder_AwsLocation_Backend.res +77 -0
  6. package/src/adapter/Geocoder/Geocoder_AwsLocation_Backend.res.mjs +87 -0
  7. package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res +42 -36
  8. package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res.mjs +24 -6
  9. package/src/adapter/Runtime/AutomationSliceEntryPoint.mjs +13 -1
  10. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +87 -8
  11. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +36 -5
  12. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +3 -16
  13. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +4 -19
  14. package/src/components/AutomationSlice_Builder.res +13 -0
  15. package/src/components/AutomationSlice_Builder.res.mjs +7 -2
  16. package/src/components/OutboundTranslationSlice_Builder.res +18 -2
  17. package/src/components/OutboundTranslationSlice_Builder.res.mjs +9 -3
  18. package/src/plugin/runtime/PluginRuntime_Builder.res +31 -0
  19. package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +13 -0
  20. package/src/util/Util_DcbMetrics.res +48 -0
  21. package/src/util/Util_DcbMetrics.res.mjs +37 -0
  22. package/src/util/Util_ShellConfig.res +72 -0
  23. package/src/util/Util_ShellConfig.res.mjs +60 -0
  24. package/tests/Geocoder_AwsLocation_OpsTest.res +78 -0
  25. package/tests/Geocoder_AwsLocation_OpsTest.res.mjs +71 -0
  26. package/tests/Util_DcbMetricsTest.res +35 -0
  27. package/tests/Util_DcbMetricsTest.res.mjs +29 -0
  28. package/tests/Util_ShellConfigTest.res +78 -0
  29. package/tests/Util_ShellConfigTest.res.mjs +113 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
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.261 (2026-08-03)
7
+
8
+ ### Features
9
+
10
+ * **aws:** let an outbound slice read an aggregate at runtime ([24585f8](https://github.com/ReventlessDev/reventless-core/commit/24585f869c15a07910434632d76d8c8d8058042b))
11
+
12
+
13
+ # 3.0.0-alpha.260 (2026-08-03)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **aws:** a dimensioned DCB metric cannot carry a default value ([0c105e7](https://github.com/ReventlessDev/reventless-core/commit/0c105e7b7156ed64b923cc75731e3bc49a30bcbd))
18
+ ### Features
19
+
20
+ * **aws:** let a deployment hand its plugins a geocoder ([143bf41](https://github.com/ReventlessDev/reventless-core/commit/143bf4137765362ce07dd42db0f4a57057da9f13))
21
+ * **outbound:** let an outbound slice read an aggregate, and geocode addresses with it ([867e63e](https://github.com/ReventlessDev/reventless-core/commit/867e63e774ebc8b78b2b19c78645c8a12a8d06f6))
22
+ * **platform:** let a deployment choose what the host shell does ([ae0aec3](https://github.com/ReventlessDev/reventless-core/commit/ae0aec33dcf641f02187e8d6a3f3594c506820b6))
23
+
24
+
6
25
  # 3.0.0-alpha.259 (2026-08-02)
7
26
 
8
27
  **Note:** Version bump only for package @reventlessdev/reventless-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.259",
3
+ "version": "3.0.0-alpha.261",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -11,18 +11,18 @@
11
11
  "@aws-sdk/s3-request-presigner": "3.970.0",
12
12
  "sury": "11.0.0-alpha.4",
13
13
  "uuid": "^13.0.0",
14
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.2",
15
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
14
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.3",
16
15
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
16
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
17
  "@reventlessdev/rescript-node": "2.0.0-alpha.1",
18
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.64",
19
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
18
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.65",
20
19
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
21
- "@reventlessdev/reventless-infra": "3.0.0-alpha.122",
22
- "@reventlessdev/reventless-core": "3.0.0-alpha.205",
20
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
23
21
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
24
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.69",
25
- "@reventlessdev/reventless-spec": "3.0.0-alpha.97"
22
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.71",
23
+ "@reventlessdev/reventless-core": "3.0.0-alpha.207",
24
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.124",
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.99"
26
26
  },
27
27
  "devDependencies": {
28
28
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -82,6 +82,20 @@ let objectStoreEndpointsRef: ref<array<objectStoreEndpoint>> = ref([])
82
82
  `getApiConfig` does. */
83
83
  let getObjectStoreEndpoints = () => objectStoreEndpointsRef.contents
84
84
 
85
+ /** The geocoder endpoint this platform provisioned, for the monolithic case.
86
+
87
+ In plugin mode the value crosses to the plugin stack as the `geocoderEndpoint`
88
+ export; when platform and plugin are one program there is no stack to read, so
89
+ `deployPlatform` records it here and `deployPlugin` picks it up — the same two
90
+ halves `objectStoreEndpointsRef` exists for.
91
+
92
+ A plain Output with `""` as the "no geocoder" sentinel, never
93
+ `option<Pulumi.Output.t<_>>`: any generic `Option.*` over an Output runs
94
+ `valFromOption`, whose nested-option probe hits the Output proxy — where every
95
+ property access returns a truthy Output — and corrupts it. Same reasoning, and
96
+ the same sentinel, as `PluginRuntime_Builder.inboundSliceReg.auditTableName`. */
97
+ let geocoderEndpointRef: ref<Pulumi.Output.t<string>> = ref(Pulumi.Output.make(""))
98
+
85
99
  module MakeWithConfig = (
86
100
  Config: {
87
101
  let splitApi: bool
@@ -1149,6 +1163,25 @@ module MakeWithConfig = (
1149
1163
  // writes the prefix once and consumes it on both sides, so a mismatch is
1150
1164
  // unrepresentable rather than merely discouraged.
1151
1165
  uploadBucket?: ReventlessInfra.Platform.objectStore,
1166
+ // Optional view modes the deployed shell loads at boot. Written to
1167
+ // config.json as `viewModes`, with each mode's options flattened beside it
1168
+ // (`Map({style})` ⇒ `"viewModes": ["map"], "mapStyle": …`). Unset ⇒ both
1169
+ // keys omitted and the shell loads no optional mode, so a non-map
1170
+ // deployment still downloads no maplibre chunk and gets a byte-identical
1171
+ // config.json.
1172
+ //
1173
+ // This is the one input that decides what the shell *does* rather than what
1174
+ // it points at, and it is load-bearing twice over: naming `Map` is what
1175
+ // makes both the map view and the map-backed geo-point command input exist,
1176
+ // and it is what makes a provisioned `geocoderPlaceIndex` reachable at all
1177
+ // (the geocoder client is built inside the map chunk).
1178
+ viewModes?: array<ReventlessInfra.Platform.viewMode>,
1179
+ // Shell-owned config.json keys the framework has no opinion about
1180
+ // (`accessTiers`, `platformName`, `assetOrigins`, `uiHintsUrl`, …), merged
1181
+ // in verbatim under the computed keys. Untyped on purpose — re-declaring
1182
+ // the shell's schema here would cost a lockstep core release per UI knob.
1183
+ // A key that collides with a computed one fails the deploy naming it.
1184
+ shellConfig?: dict<JSON.t>,
1152
1185
  }
1153
1186
 
1154
1187
  // Merged-mode outputs of deployPlatform — the merged API(s), plus the
@@ -2033,6 +2066,18 @@ module MakeWithConfig = (
2033
2066
  | None => Pulumi.Output.make(None)
2034
2067
  }
2035
2068
 
2069
+ // Exported as well as written into config.json, because the browser is no
2070
+ // longer the only caller: a plugin stack's slice Lambdas geocode too, and a
2071
+ // plugin stack has no other way to see a value computed in this one.
2072
+ //
2073
+ // `""` when unset rather than a missing output, so the read side has one
2074
+ // shape to handle instead of two — and "" is already what a client reads as
2075
+ // "no geocoder configured".
2076
+ let geocoderEndpointFlat =
2077
+ geocoderEndpointOutput->Pulumi.Output.apply(o => o->Option.getOr(""))
2078
+ Pulumi.Pulumi.export("geocoderEndpoint", geocoderEndpointFlat)
2079
+ geocoderEndpointRef := geocoderEndpointFlat
2080
+
2036
2081
  // Presign endpoints are no longer written to config.json: under route B the
2037
2082
  // client calls the platform API's `Upload_Presign` mutation (reachable from
2038
2083
  // `platformApiEndpoint`, already present) with the store it declares, so there
@@ -2055,7 +2100,7 @@ module MakeWithConfig = (
2055
2100
  eventsEpOpt,
2056
2101
  geocoderEpOpt,
2057
2102
  )) => {
2058
- let fields = [
2103
+ let computed = [
2059
2104
  ("apiEndpoint", JSON.Encode.string(domainEp)),
2060
2105
  ("platformApiEndpoint", JSON.Encode.string(platformEp)),
2061
2106
  ("region", JSON.Encode.string(regionStr)),
@@ -2067,7 +2112,7 @@ module MakeWithConfig = (
2067
2112
  let withEvents = switch eventsEpOpt {
2068
2113
  | Some(ep) =>
2069
2114
  Array.concat(
2070
- fields,
2115
+ computed,
2071
2116
  [
2072
2117
  ("domainApiEventsEndpoint", JSON.Encode.string(ep)),
2073
2118
  ("platformApiEventsEndpoint", JSON.Encode.string(ep)),
@@ -2080,13 +2125,19 @@ module MakeWithConfig = (
2080
2125
  ),
2081
2126
  ],
2082
2127
  )
2083
- | None => fields
2128
+ | None => computed
2084
2129
  }
2085
2130
  let withGeocoder = switch geocoderEpOpt {
2086
2131
  | Some(ep) => Array.concat(withEvents, [("geocoderEndpoint", JSON.Encode.string(ep))])
2087
2132
  | None => withEvents
2088
2133
  }
2089
- withGeocoder->Dict.fromArray->JSON.Encode.object->JSON.stringify
2134
+ Util_ShellConfig.fields(
2135
+ ~computed=withGeocoder,
2136
+ ~viewModes=?cfg.viewModes,
2137
+ ~shellConfig=?cfg.shellConfig,
2138
+ )
2139
+ ->JSON.Encode.object
2140
+ ->JSON.stringify
2090
2141
  })
2091
2142
 
2092
2143
  let _ = PulumiAws.S3.BucketObject.make(
@@ -2181,6 +2232,30 @@ module MakeWithConfig = (
2181
2232
  {Reventless.Offload.store, key, hash, bytes: bytes->String.length}
2182
2233
  })
2183
2234
 
2235
+ // Capability endpoints the platform provisioned, handed to this plugin's
2236
+ // slice Lambdas as environment. Read across the stack reference in plugin
2237
+ // mode and out of the platform's own record when platform and plugin are one
2238
+ // program — the same two halves the offload bucket above is read through.
2239
+ //
2240
+ // Registered before `P.make()`: the slice runtime finalizers fire from inside
2241
+ // the plugin build, and a variable registered after it would be built into
2242
+ // nothing.
2243
+ //
2244
+ // A platform that provisioned no geocoder yields "", and the plugin's client
2245
+ // reports `Unavailable` — retried, then surfaced by the heartbeat sweep. That
2246
+ // is a modelled outcome, which is the point of the empty string: a plugin
2247
+ // deployed against a platform without the capability degrades rather than
2248
+ // failing to deploy.
2249
+ let geocoderEndpoint: Pulumi.Output.t<string> = switch platformStackRef {
2250
+ | Some(stackRef) =>
2251
+ (
2252
+ stackRef->Pulumi.StackReference.getOutput("geocoderEndpoint"):
2253
+ Pulumi.Output.t<option<string>>
2254
+ )->Pulumi.Output.apply(o => o->Option.getOr(""))
2255
+ | None => geocoderEndpointRef.contents
2256
+ }
2257
+ PluginRuntime_Builder.registerCapabilityEnv("GEOCODER_ENDPOINT", geocoderEndpoint)
2258
+
2184
2259
  module P = unpack(plugin)
2185
2260
  let pluginComponent = P.make()
2186
2261
  ReventlessCore.Plugin_Helpers.clearOffload()
@@ -40,6 +40,7 @@ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/s
40
40
  import * as Upload_Claim_S3$ReventlessAws from "./adapter/Upload/Upload_Claim_S3.res.mjs";
41
41
  import * as PgProjectionFeed$ReventlessAws from "./adapter/Postgres/PgProjectionFeed.res.mjs";
42
42
  import * as Util_LocalConfig$ReventlessAws from "./util/Util_LocalConfig.res.mjs";
43
+ import * as Util_ShellConfig$ReventlessAws from "./util/Util_ShellConfig.res.mjs";
43
44
  import * as Util_StoreLayout$ReventlessAws from "./util/Util_StoreLayout.res.mjs";
44
45
  import * as IndexJs from "@pulumi/pulumi/runtime/index.js";
45
46
  import * as AppSync_EventsApi$ReventlessAws from "./adapter/Api/AppSync_EventsApi.res.mjs";
@@ -129,6 +130,10 @@ function getObjectStoreEndpoints() {
129
130
  return objectStoreEndpointsRef.contents;
130
131
  }
131
132
 
133
+ let geocoderEndpointRef = {
134
+ contents: Pulumi.output("")
135
+ };
136
+
132
137
  function MakeWithConfig(Config) {
133
138
  Stdlib_Option.forEach(Config.commandHandlerConfig.aggregates, param => {
134
139
  Stdlib_Option.forEach(param.sync, AggregateRuntime_Builder_Single$ReventlessAws.setConfig);
@@ -1123,6 +1128,9 @@ function MakeWithConfig(Config) {
1123
1128
  let domainEventsEndpointOutput = domainEventsApiOpt !== undefined ? AppSync_EventsApi$ReventlessAws.httpEndpoint(domainEventsApiOpt).apply(ep => ep + "/event") : Pulumi.output(undefined);
1124
1129
  let index = hostUiBundle.geocoderPlaceIndex;
1125
1130
  let geocoderEndpointOutput = index !== undefined ? Geocoder_AwsLocation$ReventlessAws.make(index.indexName, undefined, undefined).url.apply(u => u) : Pulumi.output(undefined);
1131
+ let geocoderEndpointFlat = geocoderEndpointOutput.apply(o => Stdlib_Option.getOr(o, ""));
1132
+ Pulumi$Pulumi.$$export("geocoderEndpoint", geocoderEndpointFlat);
1133
+ geocoderEndpointRef.contents = geocoderEndpointFlat;
1126
1134
  let configJsonContent = Pulumi.all([
1127
1135
  Pulumi.all([
1128
1136
  resolvedDomainApiEndpoint,
@@ -1136,7 +1144,7 @@ function MakeWithConfig(Config) {
1136
1144
  let geocoderEpOpt = param[2];
1137
1145
  let eventsEpOpt = param[1];
1138
1146
  let match = param[0];
1139
- let fields = [
1147
+ let computed = [
1140
1148
  [
1141
1149
  "apiEndpoint",
1142
1150
  match[0]
@@ -1166,7 +1174,7 @@ function MakeWithConfig(Config) {
1166
1174
  true
1167
1175
  ]
1168
1176
  ];
1169
- let withEvents = eventsEpOpt !== undefined ? fields.concat([
1177
+ let withEvents = eventsEpOpt !== undefined ? computed.concat([
1170
1178
  [
1171
1179
  "domainApiEventsEndpoint",
1172
1180
  eventsEpOpt
@@ -1179,12 +1187,12 @@ function MakeWithConfig(Config) {
1179
1187
  "clientEventsNamespace",
1180
1188
  AppSync_EventsApi$ReventlessAws.clientNamespaceName
1181
1189
  ]
1182
- ]) : fields;
1190
+ ]) : computed;
1183
1191
  let withGeocoder = geocoderEpOpt !== undefined ? withEvents.concat([[
1184
1192
  "geocoderEndpoint",
1185
1193
  geocoderEpOpt
1186
1194
  ]]) : withEvents;
1187
- return JSON.stringify(Object.fromEntries(withGeocoder));
1195
+ return JSON.stringify(Util_ShellConfig$ReventlessAws.fields(withGeocoder, hostUiBundle.viewModes, hostUiBundle.shellConfig));
1188
1196
  });
1189
1197
  new (Aws.s3.BucketObject)("host-ui-config-json", {
1190
1198
  bucket: bucketName,
@@ -1239,6 +1247,8 @@ function MakeWithConfig(Config) {
1239
1247
  bytes: bytes.length
1240
1248
  };
1241
1249
  });
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);
1242
1252
  let pluginComponent = plugin.make();
1243
1253
  Plugin_Helpers$ReventlessCore.clearOffload();
1244
1254
  currentDeployTarget.contents = "Domain";
@@ -2349,6 +2359,9 @@ function Make($star) {
2349
2359
  let domainEventsEndpointOutput = domainEventsApiOpt !== undefined ? AppSync_EventsApi$ReventlessAws.httpEndpoint(domainEventsApiOpt).apply(ep => ep + "/event") : Pulumi.output(undefined);
2350
2360
  let index = hostUiBundle.geocoderPlaceIndex;
2351
2361
  let geocoderEndpointOutput = index !== undefined ? Geocoder_AwsLocation$ReventlessAws.make(index.indexName, undefined, undefined).url.apply(u => u) : Pulumi.output(undefined);
2362
+ let geocoderEndpointFlat = geocoderEndpointOutput.apply(o => Stdlib_Option.getOr(o, ""));
2363
+ Pulumi$Pulumi.$$export("geocoderEndpoint", geocoderEndpointFlat);
2364
+ geocoderEndpointRef.contents = geocoderEndpointFlat;
2352
2365
  let configJsonContent = Pulumi.all([
2353
2366
  Pulumi.all([
2354
2367
  resolvedDomainApiEndpoint,
@@ -2362,7 +2375,7 @@ function Make($star) {
2362
2375
  let geocoderEpOpt = param[2];
2363
2376
  let eventsEpOpt = param[1];
2364
2377
  let match = param[0];
2365
- let fields = [
2378
+ let computed = [
2366
2379
  [
2367
2380
  "apiEndpoint",
2368
2381
  match[0]
@@ -2392,7 +2405,7 @@ function Make($star) {
2392
2405
  true
2393
2406
  ]
2394
2407
  ];
2395
- let withEvents = eventsEpOpt !== undefined ? fields.concat([
2408
+ let withEvents = eventsEpOpt !== undefined ? computed.concat([
2396
2409
  [
2397
2410
  "domainApiEventsEndpoint",
2398
2411
  eventsEpOpt
@@ -2405,12 +2418,12 @@ function Make($star) {
2405
2418
  "clientEventsNamespace",
2406
2419
  AppSync_EventsApi$ReventlessAws.clientNamespaceName
2407
2420
  ]
2408
- ]) : fields;
2421
+ ]) : computed;
2409
2422
  let withGeocoder = geocoderEpOpt !== undefined ? withEvents.concat([[
2410
2423
  "geocoderEndpoint",
2411
2424
  geocoderEpOpt
2412
2425
  ]]) : withEvents;
2413
- return JSON.stringify(Object.fromEntries(withGeocoder));
2426
+ return JSON.stringify(Util_ShellConfig$ReventlessAws.fields(withGeocoder, hostUiBundle.viewModes, hostUiBundle.shellConfig));
2414
2427
  });
2415
2428
  new (Aws.s3.BucketObject)("host-ui-config-json", {
2416
2429
  bucket: bucketName,
@@ -2465,6 +2478,8 @@ function Make($star) {
2465
2478
  bytes: bytes.length
2466
2479
  };
2467
2480
  });
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);
2468
2483
  let pluginComponent = plugin.make();
2469
2484
  Plugin_Helpers$ReventlessCore.clearOffload();
2470
2485
  currentDeployTarget.contents = "Domain";
@@ -2616,6 +2631,7 @@ export {
2616
2631
  getSplitApiOutputs,
2617
2632
  objectStoreEndpointsRef,
2618
2633
  getObjectStoreEndpoints,
2634
+ geocoderEndpointRef,
2619
2635
  MakeWithConfig,
2620
2636
  Make,
2621
2637
  }
@@ -0,0 +1,77 @@
1
+ // Amazon Location as a geocoding transport for unattended callers.
2
+ //
3
+ // Policy — what a candidate is, the two ways a lookup fails, and when a ranked
4
+ // list is confident enough to store — lives in `Reventless.Geocoding`, shared
5
+ // with every other transport. This module only knows how to ask AWS and how to
6
+ // turn its answer into that vocabulary.
7
+ //
8
+ // The public Function URL handler next door answers `200 []` for a genuine
9
+ // no-match and `502 []` for a service failure, because a browser search box must
10
+ // degrade quietly while a translator must not. This module makes the same
11
+ // distinction at the source, for a caller that can reach the SDK directly.
12
+ //
13
+ // Runtime-pure: no Pulumi import, so it can be bundled into a Lambda entry point
14
+ // without dragging deploy-time modules into the cold-start graph.
15
+
16
+ module Search = AwsSdk.Location.SearchPlaceIndexForTextCommand
17
+
18
+ /**
19
+ Geocode one address.
20
+
21
+ `Ok([])` is deliberately unreachable — an empty result set is `Error(NoMatch)`,
22
+ because "the call worked and the answer is nothing" is a different fact from
23
+ "the call worked", and the caller has to act differently on it.
24
+
25
+ Candidates come back in the provider's ranking order, unfiltered. Choosing among
26
+ them is `Reventless.Geocoding.confidentMatch`'s job.
27
+ */
28
+ let search = async (~indexName: string, ~text: string, ~maxResults: int=5): result<
29
+ array<Reventless.Geocoding.candidate>,
30
+ Reventless.Geocoding.failure,
31
+ > => {
32
+ let trimmed = text->String.trim
33
+ if indexName == "" {
34
+ Error(Unavailable("no place index configured (PLACE_INDEX_NAME unset)"))
35
+ } else if trimmed == "" {
36
+ // Not a service failure and not a service verdict — an empty query was never
37
+ // going to match, and asking would spend a request to be told so.
38
+ Error(NoMatch)
39
+ } else {
40
+ switch await Search.send(Search.make({indexName, text: trimmed, maxResults})) {
41
+ | resp =>
42
+ let candidates =
43
+ resp.results
44
+ ->Option.getOr([])
45
+ ->Array.filterMap(r =>
46
+ switch r.place {
47
+ | None => None
48
+ | Some(place) =>
49
+ // `[lng, lat]` — RFC 7946 order, converted here and nowhere else.
50
+ switch place.geometry->Option.flatMap(g => g.point) {
51
+ | Some(pt) if pt->Array.length >= 2 =>
52
+ switch Reventless.GeoPoint.make(
53
+ ~lat=pt->Array.getUnsafe(1),
54
+ ~lng=pt->Array.getUnsafe(0),
55
+ ) {
56
+ | Ok(point) =>
57
+ Some({
58
+ Reventless.Geocoding.label: place.label->Option.getOr(""),
59
+ point,
60
+ relevance: r.relevance,
61
+ })
62
+ // A coordinate that fails the range check is dropped rather than
63
+ // propagated: it cannot be stored, and one bad row should not lose
64
+ // the good ones beside it.
65
+ | Error(_) => None
66
+ }
67
+ | _ => None
68
+ }
69
+ }
70
+ )
71
+ candidates->Array.length == 0 ? Error(NoMatch) : Ok(candidates)
72
+ | exception exn =>
73
+ let msg = exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown")
74
+ Error(Unavailable(msg))
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,87 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Location$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/Location.res.mjs";
7
+ import * as GeoPoint$Reventless from "@reventlessdev/reventless-spec/src/semantic/GeoPoint.res.mjs";
8
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
9
+ import * as ClientLocation from "@aws-sdk/client-location";
10
+
11
+ async function search(indexName, text, maxResultsOpt) {
12
+ let maxResults = maxResultsOpt !== undefined ? maxResultsOpt : 5;
13
+ let trimmed = text.trim();
14
+ if (indexName === "") {
15
+ return {
16
+ TAG: "Error",
17
+ _0: {
18
+ TAG: "Unavailable",
19
+ _0: "no place index configured (PLACE_INDEX_NAME unset)"
20
+ }
21
+ };
22
+ }
23
+ if (trimmed === "") {
24
+ return {
25
+ TAG: "Error",
26
+ _0: "NoMatch"
27
+ };
28
+ }
29
+ let resp;
30
+ try {
31
+ resp = await Location$AwsSdk.SearchPlaceIndexForTextCommand.send(new ClientLocation.SearchPlaceIndexForTextCommand({
32
+ IndexName: indexName,
33
+ Text: trimmed,
34
+ MaxResults: maxResults
35
+ }));
36
+ } catch (raw_exn) {
37
+ let exn = Primitive_exceptions.internalToException(raw_exn);
38
+ let msg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown");
39
+ return {
40
+ TAG: "Error",
41
+ _0: {
42
+ TAG: "Unavailable",
43
+ _0: msg
44
+ }
45
+ };
46
+ }
47
+ let candidates = Stdlib_Array.filterMap(Stdlib_Option.getOr(resp.Results, []), r => {
48
+ let place = r.Place;
49
+ if (place === undefined) {
50
+ return;
51
+ }
52
+ let pt = Stdlib_Option.flatMap(place.Geometry, g => g.Point);
53
+ if (pt === undefined) {
54
+ return;
55
+ }
56
+ if (pt.length < 2) {
57
+ return;
58
+ }
59
+ let point = GeoPoint$Reventless.make(pt[1], pt[0]);
60
+ if (point.TAG === "Ok") {
61
+ return {
62
+ label: Stdlib_Option.getOr(place.Label, ""),
63
+ point: point._0,
64
+ relevance: r.Relevance
65
+ };
66
+ }
67
+ });
68
+ if (candidates.length === 0) {
69
+ return {
70
+ TAG: "Error",
71
+ _0: "NoMatch"
72
+ };
73
+ } else {
74
+ return {
75
+ TAG: "Ok",
76
+ _0: candidates
77
+ };
78
+ }
79
+ }
80
+
81
+ let Search;
82
+
83
+ export {
84
+ Search,
85
+ search,
86
+ }
87
+ /* Stdlib_JsExn Not a pure module */
@@ -9,31 +9,9 @@
9
9
  // `[{label, lat, lng}]` as JSON. Any failure degrades to an empty array so the
10
10
  // caller never sees a hard error.
11
11
 
12
- // ── AWS Location SDK bindings (AWS SDK v3, client.send(new Command(...))) ────
13
-
14
- type locationClient
15
-
16
- type searchPlaceIndexForTextInput = {
17
- @as("IndexName") indexName: string,
18
- @as("Text") text: string,
19
- @as("MaxResults") maxResults?: int,
20
- }
21
- // GeoJSON order: [lng, lat].
22
- type point = array<float>
23
- type geometry = {@as("Point") point?: point}
24
- type place = {@as("Label") label?: string, @as("Geometry") geometry?: geometry}
25
- type searchResult = {@as("Place") place?: place}
26
- type searchResponse = {@as("Results") results?: array<searchResult>}
27
- type searchCommand
28
-
29
- @module("@aws-sdk/client-location") @new
30
- external makeLocationClient: unit => locationClient = "LocationClient"
31
-
32
- @module("@aws-sdk/client-location") @new
33
- external makeSearchPlaceIndexForTextCommand: searchPlaceIndexForTextInput => searchCommand =
34
- "SearchPlaceIndexForTextCommand"
35
-
36
- @send external send: (locationClient, searchCommand) => promise<searchResponse> = "send"
12
+ // AWS Location lives in the bindings package, shared with the backend geocoder
13
+ // adapter — one place owns the `[lng, lat]` order and the optional `Relevance`.
14
+ module Search = AwsSdk.Location.SearchPlaceIndexForTextCommand
37
15
 
38
16
  @val external decodeURIComponent: string => string = "decodeURIComponent"
39
17
 
@@ -94,13 +72,22 @@ let handler = async (event: functionUrlEvent): response => {
94
72
  try {
95
73
  let indexName = getEnv("PLACE_INDEX_NAME")->Option.getOr("")
96
74
  let q = readQueryParam(event)->Option.getOr("")
97
- if indexName == "" || q == "" {
75
+ if indexName == "" {
76
+ // A handler with no place index cannot answer anything, so this is the
77
+ // service being misconfigured — not a verdict on the address. It has to
78
+ // read as `502` for the same reason the catch below does: a `200 []` here
79
+ // tells an unattended caller "no such address", and it would then write
80
+ // that verdict, unretried, for every address it is handed while the
81
+ // deployment is broken. The browser is unaffected — it reads the body.
82
+ Console.error("Geocoder: PLACE_INDEX_NAME is unset")
83
+ {statusCode: 502, headers: jsonHeaders(), body: "[]"}
84
+ } else if q == "" {
85
+ // `200`, unlike the arm above: nothing was asked, so "no results" is a
86
+ // true and final answer rather than a failure to produce one. A search box
87
+ // sends this on every cleared input.
98
88
  {statusCode: 200, headers: jsonHeaders(), body: "[]"}
99
89
  } else {
100
- let client = makeLocationClient()
101
- let resp = await client->send(
102
- makeSearchPlaceIndexForTextCommand({indexName, text: q, maxResults: 5}),
103
- )
90
+ let resp = await Search.send(Search.make({indexName, text: q, maxResults: 5}))
104
91
  let results =
105
92
  resp.results
106
93
  ->Option.getOr([])
@@ -113,11 +100,23 @@ let handler = async (event: functionUrlEvent): response => {
113
100
  let lng = pt->Array.getUnsafe(0)
114
101
  let lat = pt->Array.getUnsafe(1)
115
102
  Some(
116
- Dict.fromArray([
117
- ("label", JSON.Encode.string(label)),
118
- ("lat", JSON.Encode.float(lat)),
119
- ("lng", JSON.Encode.float(lng)),
120
- ])->JSON.Encode.object,
103
+ Dict.fromArray(
104
+ Array.concat(
105
+ [
106
+ ("label", JSON.Encode.string(label)),
107
+ ("lat", JSON.Encode.float(lat)),
108
+ ("lng", JSON.Encode.float(lng)),
109
+ ],
110
+ // Additive: the browser client reads the three fields above
111
+ // and ignores this one. An unattended caller needs it to
112
+ // apply `Geocoding.confidentMatch`, which is what keeps a
113
+ // vague match from becoming a confident pin.
114
+ switch r.relevance {
115
+ | Some(rel) => [("relevance", JSON.Encode.float(rel))]
116
+ | None => []
117
+ },
118
+ ),
119
+ )->JSON.Encode.object,
121
120
  )
122
121
  | _ => None
123
122
  }
@@ -133,6 +132,13 @@ let handler = async (event: functionUrlEvent): response => {
133
132
  } catch {
134
133
  | exn =>
135
134
  Console.error2("Geocoder: search failed", exn)
136
- {statusCode: 200, headers: jsonHeaders(), body: "[]"}
135
+ // 502, not 200 and still `[]`, which is the point. A browser search box
136
+ // reads the body and degrades to "no results" whether or not it checks the
137
+ // status, so nothing on that side changes. An unattended caller reads the
138
+ // status and can tell "the service is down" from "there is no such address",
139
+ // which is the difference between retrying and writing a verdict into an
140
+ // event log. One contract serves both because they disagree only about which
141
+ // half of the response they read.
142
+ {statusCode: 502, headers: jsonHeaders(), body: "[]"}
137
143
  }
138
144
  }