@reventlessdev/reventless-seed-aws 1.0.0-alpha.4 → 1.0.0-alpha.5

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 (37) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/.sourcedirs.json +1 -0
  4. package/lib/bs/compiler-info.json +2 -2
  5. package/lib/bs/src/ReventlessSeedAws.ast +0 -0
  6. package/lib/bs/src/ReventlessSeedAws.cmi +0 -0
  7. package/lib/bs/src/ReventlessSeedAws.cmj +0 -0
  8. package/lib/bs/src/ReventlessSeedAws.cmt +0 -0
  9. package/lib/bs/src/ReventlessSeedAws.res +125 -68
  10. package/lib/bs/src/ReventlessSeedAws.res.mjs +63 -30
  11. package/lib/bs/src/ReventlessSeedAws_Reset.cmi +0 -0
  12. package/lib/bs/src/ReventlessSeedAws_Reset.cmt +0 -0
  13. package/lib/bs/tests/EndpointResolutionTest.ast +0 -0
  14. package/lib/bs/tests/EndpointResolutionTest.cmi +0 -0
  15. package/lib/bs/tests/EndpointResolutionTest.cmj +0 -0
  16. package/lib/bs/tests/EndpointResolutionTest.cmt +0 -0
  17. package/lib/bs/tests/EndpointResolutionTest.res +181 -0
  18. package/lib/bs/tests/EndpointResolutionTest.res.mjs +208 -0
  19. package/lib/ocaml/.compiler.log +2 -2
  20. package/lib/ocaml/EndpointResolutionTest.ast +0 -0
  21. package/lib/ocaml/EndpointResolutionTest.cmi +0 -0
  22. package/lib/ocaml/EndpointResolutionTest.cmj +0 -0
  23. package/lib/ocaml/EndpointResolutionTest.cmt +0 -0
  24. package/lib/ocaml/EndpointResolutionTest.res +181 -0
  25. package/lib/ocaml/ReventlessSeedAws.ast +0 -0
  26. package/lib/ocaml/ReventlessSeedAws.cmi +0 -0
  27. package/lib/ocaml/ReventlessSeedAws.cmj +0 -0
  28. package/lib/ocaml/ReventlessSeedAws.cmt +0 -0
  29. package/lib/ocaml/ReventlessSeedAws.res +125 -68
  30. package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
  31. package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
  32. package/package.json +4 -3
  33. package/rescript.json +7 -1
  34. package/src/ReventlessSeedAws.res +125 -68
  35. package/src/ReventlessSeedAws.res.mjs +63 -30
  36. package/tests/EndpointResolutionTest.res +181 -0
  37. package/tests/EndpointResolutionTest.res.mjs +208 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
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
+ # 1.0.0-alpha.5 (2026-07-31)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **seed:** address a deployment's per-store upload endpoints ([45b226e](https://github.com/ReventlessDev/reventless-core/commit/45b226e00604af9fd28b6fb0b75ba7d3414791f2))
11
+
12
+
6
13
  # 1.0.0-alpha.4 (2026-07-27)
7
14
 
8
15
  ### Features
@@ -1,2 +1,2 @@
1
- #Start(1785181007711)
2
- #Done(1785181012329)
1
+ #Start(1785495045190)
2
+ #Done(1785495045219)
@@ -0,0 +1 @@
1
+ {"cmt_scan":[{"also_scan_build_root":true,"build_root":"lib/bs","scan_dirs":["src","tests"]}],"dirs":["src","tests"],"generated":[],"pkgs":[["@reventlessdev/rescript-aws-sdk","/home/runner/work/reventless-core/reventless-core/rescript/aws-sdk"],["@reventlessdev/rescript-jest","/home/runner/work/reventless-core/reventless-core/rescript/jest"],["@reventlessdev/reventless-seed","/home/runner/work/reventless-core/reventless-core/reventless/seed"]],"version":2}
@@ -2,7 +2,7 @@
2
2
  "version": "12.3.0",
3
3
  "bsc_path": "/home/runner/work/reventless-core/reventless-core/node_modules/@rescript/linux-x64/bin/bsc.exe",
4
4
  "bsc_hash": "75e3a59c95cc953608fd3dd6ea6ed68141bba4414a20a52f114261b8a48385fa",
5
- "rescript_config_hash": "3d482ebc161b93b84503a064c1b55dbce9106efcb53cc320aba2d3d291d8ab86",
5
+ "rescript_config_hash": "80febfc1b083fe35268276ef15c3829eae3bbacfedb30515f97c99ea938ae1a4",
6
6
  "runtime_path": "/home/runner/work/reventless-core/reventless-core/node_modules/@rescript/runtime",
7
- "generated_at": "1785181012338"
7
+ "generated_at": "1785495045225"
8
8
  }
Binary file
Binary file
Binary file
Binary file
@@ -41,6 +41,18 @@ let asString = (json: JSON.t): option<string> =>
41
41
  | _ => None
42
42
  }
43
43
 
44
+ // Non-string members are dropped rather than failing the whole map: one
45
+ // malformed entry should not cost a run every endpoint it could have used.
46
+ let asStringDict = (json: JSON.t): dict<string> =>
47
+ switch json {
48
+ | Object(obj) =>
49
+ obj
50
+ ->Dict.toArray
51
+ ->Array.filterMap(((k, v)) => v->asString->Option.map(s => (k, s)))
52
+ ->Dict.fromArray
53
+ | _ => Dict.make()
54
+ }
55
+
44
56
  // ── Stack discovery ───────────────────────────────────────────────────────────
45
57
 
46
58
  // Which Pulumi backend the `pulumi` subprocess reads from. When `backend` is
@@ -147,70 +159,118 @@ let resolveField = (~envKey: string, ~fromSource: option<string>, ~human: string
147
159
  }
148
160
  }
149
161
 
150
- // Returns (graphqlEndpoint, uploadEndpoint, cognitoRegion, cognitoClientId).
151
- // `uploadEndpoint` is "" when the deployment publishes none the data set's
152
- // upload phase no-ops on empty.
153
- let resolveEndpoints = async (~projectDir: string, ~backend: option<string>, ~stack: string): (
154
- string,
155
- string,
156
- string,
157
- string,
158
- ) => {
159
- let outputs = stackOutputs(~projectDir, ~backend, stack)
160
- switch outputs->field("hostShellUrl")->Option.flatMap(asString) {
161
- | Some(hostShellUrl) =>
162
- let cfg = await fetchConfig(hostShellUrl)
162
+ // Same precedence, for a value whose absence is a legitimate answer rather than
163
+ // a broken deployment: a stack that declares no store publishes no upload
164
+ // endpoint, and that must stay a no-op at the data set instead of failing the
165
+ // run before a single command is sent.
166
+ let optionalField = (~envKey: string, ~fromSource: option<string>): string =>
167
+ switch Seed.Prompt.envValue(envKey) {
168
+ | Some(v) => v
169
+ | None => fromSource->Option.getOr("")
170
+ }
171
+
172
+ // Which document publishes the deployment's endpoints. A stack that serves a
173
+ // host shell publishes them in the shell's `config.json` under the client's key
174
+ // names; one that does not publishes them as stack outputs under Pulumi's. The
175
+ // two arms name different keys for the same values, which is why this is a
176
+ // variant and not a merged lookup.
177
+ type source = HostShellConfig(JSON.t) | StackOutputs(JSON.t)
178
+
179
+ type endpoints = {
180
+ graphql: string,
181
+ // The legacy single presign service; "" when the deployment publishes none.
182
+ uploadEndpoint: string,
183
+ // Qualified `{plugin}.{store}` → that store's presign endpoint. Empty when the
184
+ // deployment declares no store.
185
+ uploadEndpoints: dict<string>,
186
+ cognitoRegion: string,
187
+ cognitoClientId: string,
188
+ }
189
+
190
+ /**
191
+ * The endpoints a source document publishes — pure, so the branch selection and
192
+ * every key it reads can be exercised with synthetic documents.
193
+ *
194
+ * `uploadEndpoints` is read on **both** arms. It was read on neither: the
195
+ * host-shell arm looked only at the singular `uploadEndpoint`, and the
196
+ * stack-output arm had no upload lookup at all, so a platform that publishes its
197
+ * stores and no host shell resolved "" and reported itself as serving no
198
+ * uploads.
199
+ *
200
+ * `REVENTLESS_UPLOAD_ENDPOINT` still overrides the singular endpoint and does
201
+ * not touch the map: one string cannot express a map, so it could only ever
202
+ * override one store and would need a naming convention to say which.
203
+ */
204
+ let endpointsFrom = (~stack: string, source: source): endpoints =>
205
+ switch source {
206
+ | HostShellConfig(cfg) =>
163
207
  let fromCfg = key => cfg->field(key)->Option.flatMap(asString)
164
- let endpoint = resolveField(
165
- ~envKey="REVENTLESS_GRAPHQL_ENDPOINT",
166
- ~fromSource=fromCfg("apiEndpoint"),
167
- ~human="apiEndpoint",
168
- )
169
- let uploadEndpoint = resolveField(
170
- ~envKey="REVENTLESS_UPLOAD_ENDPOINT",
171
- ~fromSource=fromCfg("uploadEndpoint"),
172
- ~human="uploadEndpoint",
173
- )
174
- let region = resolveField(
175
- ~envKey="AWS_REGION",
176
- ~fromSource=fromCfg("region"),
177
- ~human="region",
178
- )
179
- let clientId = resolveField(
180
- ~envKey="COGNITO_CLIENT_ID",
181
- ~fromSource=fromCfg("cognitoClientId"),
182
- ~human="cognitoClientId",
183
- )
184
- (endpoint, uploadEndpoint, region, clientId)
185
- | None =>
208
+ {
209
+ graphql: resolveField(
210
+ ~envKey="REVENTLESS_GRAPHQL_ENDPOINT",
211
+ ~fromSource=fromCfg("apiEndpoint"),
212
+ ~human="apiEndpoint",
213
+ ),
214
+ uploadEndpoint: optionalField(
215
+ ~envKey="REVENTLESS_UPLOAD_ENDPOINT",
216
+ ~fromSource=fromCfg("uploadEndpoint"),
217
+ ),
218
+ uploadEndpoints: cfg->field("uploadEndpoints")->Option.mapOr(Dict.make(), asStringDict),
219
+ cognitoRegion: resolveField(
220
+ ~envKey="AWS_REGION",
221
+ ~fromSource=fromCfg("region"),
222
+ ~human="region",
223
+ ),
224
+ cognitoClientId: resolveField(
225
+ ~envKey="COGNITO_CLIENT_ID",
226
+ ~fromSource=fromCfg("cognitoClientId"),
227
+ ~human="cognitoClientId",
228
+ ),
229
+ }
230
+ | StackOutputs(outputs) =>
186
231
  let out = key => outputs->field(key)->Option.flatMap(asString)
187
- let endpoint = switch (
188
- Seed.Prompt.envValue("REVENTLESS_GRAPHQL_ENDPOINT"),
189
- out("domainMergedApiEndpoint"),
190
- out("domainApiEndpoint"),
191
- ) {
192
- | (Some(v), _, _) | (_, Some(v), _) | (_, _, Some(v)) => v
193
- | _ =>
194
- throw(
195
- Seed.Failed(
196
- `stack "${stack}" exports neither domainMergedApiEndpoint nor domainApiEndpoint`,
197
- ),
198
- )
232
+ {
233
+ graphql: switch (
234
+ Seed.Prompt.envValue("REVENTLESS_GRAPHQL_ENDPOINT"),
235
+ out("domainMergedApiEndpoint"),
236
+ out("domainApiEndpoint"),
237
+ ) {
238
+ | (Some(v), _, _) | (_, Some(v), _) | (_, _, Some(v)) => v
239
+ | _ =>
240
+ throw(
241
+ Seed.Failed(
242
+ `stack "${stack}" exports neither domainMergedApiEndpoint nor domainApiEndpoint`,
243
+ ),
244
+ )
245
+ },
246
+ // No stack output carries the legacy single service — it exists only in a
247
+ // host shell's config.json — so here the env override is its only source.
248
+ uploadEndpoint: optionalField(~envKey="REVENTLESS_UPLOAD_ENDPOINT", ~fromSource=None),
249
+ uploadEndpoints: outputs->field("uploadEndpoints")->Option.mapOr(Dict.make(), asStringDict),
250
+ cognitoRegion: resolveField(
251
+ ~envKey="AWS_REGION",
252
+ ~fromSource=out("cognitoRegion"),
253
+ ~human="cognitoRegion",
254
+ ),
255
+ cognitoClientId: resolveField(
256
+ ~envKey="COGNITO_CLIENT_ID",
257
+ ~fromSource=out("cognitoUserPoolClientId"),
258
+ ~human="cognitoUserPoolClientId",
259
+ ),
199
260
  }
200
- let region = resolveField(
201
- ~envKey="AWS_REGION",
202
- ~fromSource=out("cognitoRegion"),
203
- ~human="cognitoRegion",
204
- )
205
- let clientId = resolveField(
206
- ~envKey="COGNITO_CLIENT_ID",
207
- ~fromSource=out("cognitoUserPoolClientId"),
208
- ~human="cognitoUserPoolClientId",
209
- )
210
- // Absent → "" → the data set skips its upload phase.
211
- let uploadEndpoint = Seed.Prompt.envValue("REVENTLESS_UPLOAD_ENDPOINT")->Option.getOr("")
212
- (endpoint, uploadEndpoint, region, clientId)
213
261
  }
262
+
263
+ let resolveEndpoints = async (
264
+ ~projectDir: string,
265
+ ~backend: option<string>,
266
+ ~stack: string,
267
+ ): endpoints => {
268
+ let outputs = stackOutputs(~projectDir, ~backend, stack)
269
+ let source = switch outputs->field("hostShellUrl")->Option.flatMap(asString) {
270
+ | Some(hostShellUrl) => HostShellConfig(await fetchConfig(hostShellUrl))
271
+ | None => StackOutputs(outputs)
272
+ }
273
+ endpointsFrom(~stack, source)
214
274
  }
215
275
 
216
276
  // ── Cognito login ─────────────────────────────────────────────────────────────
@@ -302,15 +362,12 @@ let connect = (~projectDir: string=".", ~stack=?, ~backend=?, ()): (
302
362
  | None => backend
303
363
  }
304
364
  let stackName = await resolveStack(~projectDir, ~backend, ~stack)
305
- let (endpoint, uploadEndpoint, region, clientId) = await resolveEndpoints(
306
- ~projectDir,
307
- ~backend,
308
- ~stack=stackName,
309
- )
365
+ let eps = await resolveEndpoints(~projectDir, ~backend, ~stack=stackName)
310
366
  await Seed.Connect.make(
311
367
  ~label=stackName,
312
- ~endpoint,
313
- ~uploadEndpoint,
314
- ~login=cognito(~region, ~clientId),
368
+ ~endpoint=eps.graphql,
369
+ ~uploadEndpoint=eps.uploadEndpoint,
370
+ ~uploadEndpoints=eps.uploadEndpoints,
371
+ ~login=cognito(~region=eps.cognitoRegion, ~clientId=eps.cognitoClientId),
315
372
  )
316
373
  }
@@ -19,6 +19,20 @@ function asString(json) {
19
19
  }
20
20
  }
21
21
 
22
+ function asStringDict(json) {
23
+ if (typeof json === "object" && json !== null && !Array.isArray(json)) {
24
+ return Object.fromEntries(Stdlib_Array.filterMap(Object.entries(json), param => {
25
+ let k = param[0];
26
+ return Stdlib_Option.map(asString(param[1]), s => [
27
+ k,
28
+ s
29
+ ]);
30
+ }));
31
+ } else {
32
+ return {};
33
+ }
34
+ }
35
+
22
36
  function envForBackend(backend) {
23
37
  if (backend === undefined) {
24
38
  return;
@@ -152,34 +166,39 @@ function resolveField(envKey, fromSource, human) {
152
166
  };
153
167
  }
154
168
 
155
- async function resolveEndpoints(projectDir, backend, stack) {
156
- let outputs = stackOutputs(projectDir, backend, stack);
157
- let hostShellUrl = Stdlib_Option.flatMap(field(outputs, "hostShellUrl"), asString);
158
- if (hostShellUrl !== undefined) {
159
- let cfg = await fetchConfig(hostShellUrl);
169
+ function optionalField(envKey, fromSource) {
170
+ let v = Seed_Prompt$ReventlessSeed.envValue(envKey);
171
+ if (v !== undefined) {
172
+ return v;
173
+ } else {
174
+ return Stdlib_Option.getOr(fromSource, "");
175
+ }
176
+ }
177
+
178
+ function endpointsFrom(stack, source) {
179
+ if (source.TAG === "HostShellConfig") {
180
+ let cfg = source._0;
160
181
  let fromCfg = key => Stdlib_Option.flatMap(field(cfg, key), asString);
161
- let endpoint = resolveField("REVENTLESS_GRAPHQL_ENDPOINT", fromCfg("apiEndpoint"), "apiEndpoint");
162
- let uploadEndpoint = resolveField("REVENTLESS_UPLOAD_ENDPOINT", fromCfg("uploadEndpoint"), "uploadEndpoint");
163
- let region = resolveField("AWS_REGION", fromCfg("region"), "region");
164
- let clientId = resolveField("COGNITO_CLIENT_ID", fromCfg("cognitoClientId"), "cognitoClientId");
165
- return [
166
- endpoint,
167
- uploadEndpoint,
168
- region,
169
- clientId
170
- ];
182
+ return {
183
+ graphql: resolveField("REVENTLESS_GRAPHQL_ENDPOINT", fromCfg("apiEndpoint"), "apiEndpoint"),
184
+ uploadEndpoint: optionalField("REVENTLESS_UPLOAD_ENDPOINT", fromCfg("uploadEndpoint")),
185
+ uploadEndpoints: Stdlib_Option.mapOr(field(cfg, "uploadEndpoints"), {}, asStringDict),
186
+ cognitoRegion: resolveField("AWS_REGION", fromCfg("region"), "region"),
187
+ cognitoClientId: resolveField("COGNITO_CLIENT_ID", fromCfg("cognitoClientId"), "cognitoClientId")
188
+ };
171
189
  }
190
+ let outputs = source._0;
172
191
  let out = key => Stdlib_Option.flatMap(field(outputs, key), asString);
173
192
  let match = Seed_Prompt$ReventlessSeed.envValue("REVENTLESS_GRAPHQL_ENDPOINT");
174
193
  let match$1 = out("domainMergedApiEndpoint");
175
194
  let match$2 = out("domainApiEndpoint");
176
- let endpoint$1;
195
+ let tmp;
177
196
  if (match !== undefined) {
178
- endpoint$1 = match;
197
+ tmp = match;
179
198
  } else if (match$1 !== undefined) {
180
- endpoint$1 = match$1;
199
+ tmp = match$1;
181
200
  } else if (match$2 !== undefined) {
182
- endpoint$1 = match$2;
201
+ tmp = match$2;
183
202
  } else {
184
203
  throw {
185
204
  RE_EXN_ID: Seed$ReventlessSeed.Failed,
@@ -187,15 +206,26 @@ async function resolveEndpoints(projectDir, backend, stack) {
187
206
  Error: new Error()
188
207
  };
189
208
  }
190
- let region$1 = resolveField("AWS_REGION", out("cognitoRegion"), "cognitoRegion");
191
- let clientId$1 = resolveField("COGNITO_CLIENT_ID", out("cognitoUserPoolClientId"), "cognitoUserPoolClientId");
192
- let uploadEndpoint$1 = Stdlib_Option.getOr(Seed_Prompt$ReventlessSeed.envValue("REVENTLESS_UPLOAD_ENDPOINT"), "");
193
- return [
194
- endpoint$1,
195
- uploadEndpoint$1,
196
- region$1,
197
- clientId$1
198
- ];
209
+ return {
210
+ graphql: tmp,
211
+ uploadEndpoint: optionalField("REVENTLESS_UPLOAD_ENDPOINT", undefined),
212
+ uploadEndpoints: Stdlib_Option.mapOr(field(outputs, "uploadEndpoints"), {}, asStringDict),
213
+ cognitoRegion: resolveField("AWS_REGION", out("cognitoRegion"), "cognitoRegion"),
214
+ cognitoClientId: resolveField("COGNITO_CLIENT_ID", out("cognitoUserPoolClientId"), "cognitoUserPoolClientId")
215
+ };
216
+ }
217
+
218
+ async function resolveEndpoints(projectDir, backend, stack) {
219
+ let outputs = stackOutputs(projectDir, backend, stack);
220
+ let hostShellUrl = Stdlib_Option.flatMap(field(outputs, "hostShellUrl"), asString);
221
+ let source = hostShellUrl !== undefined ? ({
222
+ TAG: "HostShellConfig",
223
+ _0: await fetchConfig(hostShellUrl)
224
+ }) : ({
225
+ TAG: "StackOutputs",
226
+ _0: outputs
227
+ });
228
+ return endpointsFrom(stack, source);
199
229
  }
200
230
 
201
231
  function cognito(region, clientId) {
@@ -282,14 +312,15 @@ function connect($staropt$star, stack, backend, param) {
282
312
  let url = Seed_Prompt$ReventlessSeed.envValue("SEED_PULUMI_BACKEND");
283
313
  let backend$1 = url !== undefined ? url : backend;
284
314
  let stackName = await resolveStack(projectDir, backend$1, stack);
285
- let match = await resolveEndpoints(projectDir, backend$1, stackName);
286
- return await Seed_Connect$ReventlessSeed.make(stackName, match[0], match[1], cognito(match[2], match[3]), undefined);
315
+ let eps = await resolveEndpoints(projectDir, backend$1, stackName);
316
+ return await Seed_Connect$ReventlessSeed.make(stackName, eps.graphql, eps.uploadEndpoint, eps.uploadEndpoints, cognito(eps.cognitoRegion, eps.cognitoClientId), undefined);
287
317
  };
288
318
  }
289
319
 
290
320
  export {
291
321
  field,
292
322
  asString,
323
+ asStringDict,
293
324
  envForBackend,
294
325
  pulumi,
295
326
  deployedStacks,
@@ -298,6 +329,8 @@ export {
298
329
  resolveStack,
299
330
  fetchConfig,
300
331
  resolveField,
332
+ optionalField,
333
+ endpointsFrom,
301
334
  resolveEndpoints,
302
335
  cognito,
303
336
  connect,
@@ -0,0 +1,181 @@
1
+ // Which document `resolveEndpoints` reads, and what each arm takes from it.
2
+ //
3
+ // `endpointsFrom` is the pure half of `resolveEndpoints`; the impure half is a
4
+ // `pulumi stack output` subprocess and a `config.json` fetch, and the decision
5
+ // these tests pin is in neither. The defect they lock out: **neither arm read
6
+ // `uploadEndpoints`**, so a platform that publishes a presign endpoint per
7
+ // declared store resolved "" and the seeder reported it as serving no uploads.
8
+ // The stack-outputs arm is the one that matters — a platform deploying no host
9
+ // shell publishes no `hostShellUrl` by construction, so it takes that arm, and
10
+ // the per-store endpoint is its only way to reach the store.
11
+
12
+ open JestGlobals
13
+ open ReventlessSeed
14
+
15
+ @val @scope("process") external processEnv: dict<string> = "env"
16
+
17
+ // Every value read here has an env override, and an ambient one in the runner's
18
+ // environment (AWS_REGION is routinely set) would silently stand in for the
19
+ // document under test. An empty string reads as unset.
20
+ let clearOverrides = () =>
21
+ [
22
+ "REVENTLESS_GRAPHQL_ENDPOINT",
23
+ "REVENTLESS_UPLOAD_ENDPOINT",
24
+ "AWS_REGION",
25
+ "COGNITO_CLIENT_ID",
26
+ "SEED_SKIP_UPLOADS",
27
+ ]->Array.forEach(k => processEnv->Dict.set(k, ""))
28
+
29
+ let obj = entries => JSON.Encode.object(entries->Dict.fromArray)
30
+ let str = JSON.Encode.string
31
+
32
+ // A host shell's config.json and the stack outputs of a platform that serves no
33
+ // shell — the two documents the two arms read, minus the upload keys each test
34
+ // supplies.
35
+ let config = (~extra: array<(string, JSON.t)>) =>
36
+ obj(
37
+ Array.concat(
38
+ [
39
+ ("apiEndpoint", str("https://api.example/graphql")),
40
+ ("region", str("eu-west-1")),
41
+ ("cognitoClientId", str("client-abc")),
42
+ ],
43
+ extra,
44
+ ),
45
+ )
46
+
47
+ let outputs = (~extra: array<(string, JSON.t)>) =>
48
+ obj(
49
+ Array.concat(
50
+ [
51
+ ("domainApiEndpoint", str("https://domain.example/graphql")),
52
+ ("cognitoRegion", str("eu-west-1")),
53
+ ("cognitoUserPoolClientId", str("client-abc")),
54
+ ],
55
+ extra,
56
+ ),
57
+ )
58
+
59
+ let storeEndpoints = obj([("Catalog.productImages", str("https://presign.example/"))])
60
+
61
+ describe("endpointsFrom — host shell arm", () => {
62
+ beforeEach(clearOverrides)
63
+
64
+ testSync("reads the legacy single service and the per-store map together", () => {
65
+ let eps = ReventlessSeedAws.endpointsFrom(
66
+ ~stack="alpha",
67
+ HostShellConfig(
68
+ config(
69
+ ~extra=[
70
+ ("uploadEndpoint", str("https://legacy.example/")),
71
+ ("uploadEndpoints", storeEndpoints),
72
+ ],
73
+ ),
74
+ ),
75
+ )
76
+ expect(eps.uploadEndpoint)->toBe("https://legacy.example/")
77
+ expect(eps.uploadEndpoints)->toEqual(
78
+ Dict.fromArray([("Catalog.productImages", "https://presign.example/")]),
79
+ )
80
+ })
81
+
82
+ // A shell fronting only declared stores writes no singular key. That used to
83
+ // fail the whole run before a command was sent ("deployment is missing
84
+ // uploadEndpoint"), which is a harder failure than the one this plan fixes.
85
+ testSync("a shell publishing only per-store endpoints resolves an empty legacy service", () => {
86
+ let eps = ReventlessSeedAws.endpointsFrom(
87
+ ~stack="alpha",
88
+ HostShellConfig(config(~extra=[("uploadEndpoints", storeEndpoints)])),
89
+ )
90
+ expect(eps.uploadEndpoint)->toBe("")
91
+ expect(eps.uploadEndpoints->Dict.keysToArray)->toEqual(["Catalog.productImages"])
92
+ })
93
+
94
+ testSync("REVENTLESS_UPLOAD_ENDPOINT overrides the legacy service and leaves the map alone", () => {
95
+ processEnv->Dict.set("REVENTLESS_UPLOAD_ENDPOINT", "https://override.example/")
96
+ let eps = ReventlessSeedAws.endpointsFrom(
97
+ ~stack="alpha",
98
+ HostShellConfig(
99
+ config(
100
+ ~extra=[
101
+ ("uploadEndpoint", str("https://legacy.example/")),
102
+ ("uploadEndpoints", storeEndpoints),
103
+ ],
104
+ ),
105
+ ),
106
+ )
107
+ expect(eps.uploadEndpoint)->toBe("https://override.example/")
108
+ expect(eps.uploadEndpoints->Dict.get("Catalog.productImages"))->toEqual(
109
+ Some("https://presign.example/"),
110
+ )
111
+ })
112
+ })
113
+
114
+ describe("endpointsFrom — stack outputs arm", () => {
115
+ beforeEach(clearOverrides)
116
+
117
+ // The lookup that was missing entirely: this arm had no upload source but the
118
+ // env var, so a `PlatformOwned` deployment resolved "" whatever it published.
119
+ testSync("reads the uploadEndpoints stack output", () => {
120
+ let eps = ReventlessSeedAws.endpointsFrom(
121
+ ~stack="pr-verify",
122
+ StackOutputs(outputs(~extra=[("uploadEndpoints", storeEndpoints)])),
123
+ )
124
+ expect(eps.uploadEndpoints)->toEqual(
125
+ Dict.fromArray([("Catalog.productImages", "https://presign.example/")]),
126
+ )
127
+ })
128
+
129
+ // Declaring no store is a legitimate deployment, not a broken one — it stays a
130
+ // no-op at the data set rather than failing the run.
131
+ testSync("a deployment declaring no store resolves an empty map without failing", () => {
132
+ let eps = ReventlessSeedAws.endpointsFrom(~stack="pr-verify", StackOutputs(outputs(~extra=[])))
133
+ expect(eps.uploadEndpoint)->toBe("")
134
+ expect(eps.uploadEndpoints->Dict.keysToArray)->toEqual([])
135
+ })
136
+
137
+ testSync("prefers the merged API endpoint over the per-plugin one", () => {
138
+ let eps = ReventlessSeedAws.endpointsFrom(
139
+ ~stack="pr-verify",
140
+ StackOutputs(outputs(~extra=[("domainMergedApiEndpoint", str("https://merged.example/"))])),
141
+ )
142
+ expect(eps.graphql)->toBe("https://merged.example/")
143
+ })
144
+
145
+ testSync("fails naming both keys when the stack exports neither API endpoint", () =>
146
+ switch ReventlessSeedAws.endpointsFrom(
147
+ ~stack="pr-verify",
148
+ StackOutputs(obj([("cognitoRegion", str("eu-west-1"))])),
149
+ ) {
150
+ | _ => fail("expected endpointsFrom to fail")
151
+ | exception Seed.Failed(msg) =>
152
+ expect(msg)->toContain("domainMergedApiEndpoint")
153
+ expect(msg)->toContain("domainApiEndpoint")
154
+ }
155
+ )
156
+ })
157
+
158
+ describe("endpointsFrom — malformed uploadEndpoints", () => {
159
+ beforeEach(clearOverrides)
160
+
161
+ // One bad member should not cost the run every endpoint it could have used.
162
+ testSync("drops a non-string member rather than the whole map", () => {
163
+ let eps = ReventlessSeedAws.endpointsFrom(
164
+ ~stack="pr-verify",
165
+ StackOutputs(
166
+ outputs(
167
+ ~extra=[
168
+ (
169
+ "uploadEndpoints",
170
+ obj([
171
+ ("Catalog.productImages", str("https://presign.example/")),
172
+ ("Catalog.broken", JSON.Encode.int(7)),
173
+ ]),
174
+ ),
175
+ ],
176
+ ),
177
+ ),
178
+ )
179
+ expect(eps.uploadEndpoints->Dict.keysToArray)->toEqual(["Catalog.productImages"])
180
+ })
181
+ })