@reventlessdev/reventless-aws 3.0.0-alpha.301 → 3.0.0-alpha.302

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.302 (2026-08-15)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** let a stream resource answer for its own stream ([4617773](https://github.com/ReventlessDev/reventless-core/commit/461777349f10ebc83085d59907bac5b5537429ed))
11
+ ### Features
12
+
13
+ * **aws:** serve one baked manifest per journey ([16d6307](https://github.com/ReventlessDev/reventless-core/commit/16d63071f059cf1fc7025f9be9206dda51f71053))
14
+
15
+
6
16
  # 3.0.0-alpha.301 (2026-08-15)
7
17
 
8
18
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.301",
3
+ "version": "3.0.0-alpha.302",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -12,18 +12,18 @@
12
12
  "@aws-sdk/s3-request-presigner": "3.970.0",
13
13
  "sury": "11.0.0-alpha.4",
14
14
  "uuid": "^13.0.0",
15
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
15
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
16
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
17
  "@reventlessdev/rescript-node": "2.0.0-alpha.7",
18
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
18
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
19
19
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.76",
20
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
20
21
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
21
- "@reventlessdev/reventless-core": "3.0.0-alpha.235",
22
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.142",
23
+ "@reventlessdev/reventless-core": "3.0.0-alpha.236",
22
24
  "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
23
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
24
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.99",
25
- "@reventlessdev/reventless-spec": "3.0.0-alpha.114",
26
- "@reventlessdev/reventless-infra": "3.0.0-alpha.142"
25
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.100",
26
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.114"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -2221,9 +2221,14 @@ module MakeWithConfig = (
2221
2221
  // target without reading either out of the deploy program.
2222
2222
  switch (componentDefinitions, cfg.bakedManifest) {
2223
2223
  | (Some(fn), Some(bake)) =>
2224
- let manifestKey = bake.key->Option.getOr(
2225
- ReventlessCore.Platform_BakedManifest.defaultKey,
2226
- )
2224
+ // Every file the declaration produces: the default journey's, which is
2225
+ // what the shell's `manifestUrl` points at, and one per declared journey.
2226
+ // Resolved through the same function the bake and `config.json` use, so
2227
+ // a grant cannot be scoped to a key nothing writes.
2228
+ let manifestKeys =
2229
+ ReventlessCore.Platform_BakedManifest.files(~config=bake)->Array.map(((key, _)) => key)
2230
+ let manifestKey =
2231
+ manifestKeys->Array.get(0)->Option.getOr(ReventlessCore.Platform_BakedManifest.defaultKey)
2227
2232
  let _ =
2228
2233
  (fn.roleId, bucketName)
2229
2234
  ->Pulumi.Output.all2
@@ -2239,7 +2244,16 @@ module MakeWithConfig = (
2239
2244
  sid: "AllowWriteBakedManifest",
2240
2245
  effect: Allow,
2241
2246
  actions: Actions(["s3:PutObject"]),
2242
- resources: Resource(`arn:aws:s3:::${bucket}/${manifestKey}`),
2247
+ // A single key stays a bare `Resource` string: the two
2248
+ // forms mean the same thing to IAM, and a deployment that
2249
+ // declares no journey should not see its policy rewritten
2250
+ // for a field it does not use.
2251
+ resources: switch manifestKeys->Array.map(key =>
2252
+ `arn:aws:s3:::${bucket}/${key}`
2253
+ ) {
2254
+ | [only] => Resource(only)
2255
+ | arns => Resources(arns)
2256
+ },
2243
2257
  },
2244
2258
  ],
2245
2259
  )
@@ -1210,17 +1210,27 @@ function MakeWithConfig(Config) {
1210
1210
  }
1211
1211
  let match$5 = hostUiBundle.bakedManifest;
1212
1212
  if (componentDefinitions !== undefined && match$5 !== undefined) {
1213
- let manifestKey = Stdlib_Option.getOr(match$5.key, Platform_BakedManifest$ReventlessCore.defaultKey);
1213
+ let manifestKeys = Platform_BakedManifest$ReventlessCore.files(match$5).map(param => param[0]);
1214
+ let manifestKey = Stdlib_Option.getOr(manifestKeys[0], Platform_BakedManifest$ReventlessCore.defaultKey);
1214
1215
  Pulumi.all([
1215
1216
  componentDefinitions.roleId,
1216
1217
  bucketName
1217
1218
  ]).apply(param => {
1219
+ let bucket = param[1];
1220
+ let arns = manifestKeys.map(key => `arn:aws:s3:::` + bucket + `/` + key);
1221
+ let tmp;
1222
+ if (arns.length !== 1) {
1223
+ tmp = arns;
1224
+ } else {
1225
+ let only = arns[0];
1226
+ tmp = only;
1227
+ }
1218
1228
  new (Aws.iam.RolePolicy)("PlatformUIDefinitionsBake", {
1219
1229
  policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "PlatformUIDefinitionsBakePolicy", [{
1220
1230
  Sid: "AllowWriteBakedManifest",
1221
1231
  Effect: "Allow",
1222
1232
  Action: ["s3:PutObject"],
1223
- Resource: `arn:aws:s3:::` + param[1] + `/` + manifestKey
1233
+ Resource: tmp
1224
1234
  }])),
1225
1235
  role: param[0]
1226
1236
  });
@@ -2490,17 +2500,27 @@ function Make($star) {
2490
2500
  }
2491
2501
  let match$5 = hostUiBundle.bakedManifest;
2492
2502
  if (componentDefinitions !== undefined && match$5 !== undefined) {
2493
- let manifestKey = Stdlib_Option.getOr(match$5.key, Platform_BakedManifest$ReventlessCore.defaultKey);
2503
+ let manifestKeys = Platform_BakedManifest$ReventlessCore.files(match$5).map(param => param[0]);
2504
+ let manifestKey = Stdlib_Option.getOr(manifestKeys[0], Platform_BakedManifest$ReventlessCore.defaultKey);
2494
2505
  Pulumi.all([
2495
2506
  componentDefinitions.roleId,
2496
2507
  bucketName
2497
2508
  ]).apply(param => {
2509
+ let bucket = param[1];
2510
+ let arns = manifestKeys.map(key => `arn:aws:s3:::` + bucket + `/` + key);
2511
+ let tmp;
2512
+ if (arns.length !== 1) {
2513
+ tmp = arns;
2514
+ } else {
2515
+ let only = arns[0];
2516
+ tmp = only;
2517
+ }
2498
2518
  new (Aws.iam.RolePolicy)("PlatformUIDefinitionsBake", {
2499
2519
  policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "PlatformUIDefinitionsBakePolicy", [{
2500
2520
  Sid: "AllowWriteBakedManifest",
2501
2521
  Effect: "Allow",
2502
2522
  Action: ["s3:PutObject"],
2503
- Resource: `arn:aws:s3:::` + param[1] + `/` + manifestKey
2523
+ Resource: tmp
2504
2524
  }])),
2505
2525
  role: param[0]
2506
2526
  });
@@ -141,10 +141,8 @@ let make = (
141
141
  // as a record so the deploy input and the runtime input are the same shape —
142
142
  // `views`/`commands` absent means "every public component", which a record
143
143
  // with explicit nulls would not say.
144
- let bakeSelectionsJson = switch bakedManifest {
145
- | None => ""
146
- | Some(bake) =>
147
- bake.components
144
+ let encodeSelections = (components: array<ReventlessCore.Platform_BakedManifest.selection>) =>
145
+ components
148
146
  ->Array.map(sel => {
149
147
  let entry = Dict.fromArray([("plugin", JSON.Encode.string(sel.plugin))])
150
148
  let strings = (key, value) =>
@@ -157,6 +155,39 @@ let make = (
157
155
  entry->JSON.Encode.object
158
156
  })
159
157
  ->JSON.Encode.array
158
+
159
+ let bakeSelectionsJson = switch bakedManifest {
160
+ | None => ""
161
+ | Some(bake) =>
162
+ bake.components
163
+ ->Array.map(ReventlessCore.Platform_BakedManifest.toSelection)
164
+ ->encodeSelections
165
+ ->JSON.stringify
166
+ }
167
+
168
+ // The journeys, in a variable of their own rather than folded into the one
169
+ // above: a deployment that declares none then puts the same string in the same
170
+ // place it always did, and its function is not replaced for a field it does
171
+ // not use.
172
+ //
173
+ // Each carries the key the DECLARATION resolves to, taken from the function
174
+ // that also decides the IAM grant and the URL `config.json` publishes. A key
175
+ // the handler derived for itself would be a fourth derivation of one name, and
176
+ // the way it fails is a file written where nothing fetches it.
177
+ let bakeJourneysJson = switch bakedManifest->Option.mapOr([], config =>
178
+ ReventlessCore.Platform_BakedManifest.journeyFiles(~config)
179
+ ) {
180
+ | [] => ""
181
+ | journeys =>
182
+ journeys
183
+ ->Array.map(j =>
184
+ Dict.fromArray([
185
+ ("group", JSON.Encode.string(j.group)),
186
+ ("key", JSON.Encode.string(j.key)),
187
+ ("components", j.selections->encodeSelections),
188
+ ])->JSON.Encode.object
189
+ )
190
+ ->JSON.Encode.array
160
191
  ->JSON.stringify
161
192
  }
162
193
 
@@ -226,6 +257,10 @@ let make = (
226
257
  // An include-list is names only, so it stays far from the 4096-byte
227
258
  // total the admin entry already pushed out of this dict.
228
259
  ("BAKE_SELECTIONS", bakeSelectionsJson->Pulumi.Input.make),
260
+ // One curated surface per audience beside the default one. Empty for
261
+ // a deployment that declares none, which is every deployment that
262
+ // predates journeys.
263
+ ("BAKE_JOURNEYS", bakeJourneysJson->Pulumi.Input.make),
229
264
  ("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
230
265
  ("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
231
266
  Util_LambdaLogging.logLevelEntry(),
@@ -13,6 +13,7 @@ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
13
13
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
14
14
  import * as Util_LambdaLogging$ReventlessAws from "../../util/Util_LambdaLogging.res.mjs";
15
15
  import * as AppSync_Resolver_Native$ReventlessAws from "./AppSync_Resolver_Native.res.mjs";
16
+ import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
16
17
  import * as Platform_Admin_Structure$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_Admin_Structure.res.mjs";
17
18
  import * as Platform_ComponentDefinitionsApi$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_ComponentDefinitionsApi.res.mjs";
18
19
 
@@ -77,19 +78,35 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bak
77
78
  role: lambdaRole.id
78
79
  }, opts$1);
79
80
  });
80
- let bakeSelectionsJson = bakedManifest !== undefined ? JSON.stringify(bakedManifest.components.map(sel => {
81
- let entry = Object.fromEntries([[
82
- "plugin",
83
- sel.plugin
84
- ]]);
85
- let strings = (key, value) => Stdlib_Option.forEach(value, names => {
86
- entry[key] = names.map(prim => prim);
87
- });
88
- strings("views", sel.views);
89
- strings("commands", sel.commands);
90
- strings("derived", sel.derived);
91
- return entry;
92
- })) : "";
81
+ let encodeSelections = components => components.map(sel => {
82
+ let entry = Object.fromEntries([[
83
+ "plugin",
84
+ sel.plugin
85
+ ]]);
86
+ let strings = (key, value) => Stdlib_Option.forEach(value, names => {
87
+ entry[key] = names.map(prim => prim);
88
+ });
89
+ strings("views", sel.views);
90
+ strings("commands", sel.commands);
91
+ strings("derived", sel.derived);
92
+ return entry;
93
+ });
94
+ let bakeSelectionsJson = bakedManifest !== undefined ? JSON.stringify(encodeSelections(bakedManifest.components.map(Platform_BakedManifest$ReventlessCore.toSelection))) : "";
95
+ let journeys = Stdlib_Option.mapOr(bakedManifest, [], Platform_BakedManifest$ReventlessCore.journeyFiles);
96
+ let bakeJourneysJson = journeys.length !== 0 ? JSON.stringify(journeys.map(j => Object.fromEntries([
97
+ [
98
+ "group",
99
+ j.group
100
+ ],
101
+ [
102
+ "key",
103
+ j.key
104
+ ],
105
+ [
106
+ "components",
107
+ encodeSelections(j.selections)
108
+ ]
109
+ ]))) : "";
93
110
  let adminEntryJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(Platform_Admin_Structure$ReventlessCore.pluginId, undefined, Platform_Admin_Structure$ReventlessCore.structure));
94
111
  let packageDirs = Object.fromEntries([[
95
112
  "@reventlessdev/reventless-aws",
@@ -128,6 +145,10 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bak
128
145
  "BAKE_SELECTIONS",
129
146
  bakeSelectionsJson
130
147
  ],
148
+ [
149
+ "BAKE_JOURNEYS",
150
+ bakeJourneysJson
151
+ ],
131
152
  [
132
153
  "NODE_OPTIONS",
133
154
  Util_Bundle$ReventlessAws.esmLoaderNodeOptions
@@ -288,6 +288,18 @@ let structureOf = (item: dict<JSON.t>, ~name as _: string): option<(
288
288
  | _ => None
289
289
  }
290
290
 
291
+ // One curated surface per audience, beside the default one. Only what the write
292
+ // needs travels: the file's key, resolved by the deploy so that the grant, the
293
+ // URL in `config.json` and the object written here are one string, and the
294
+ // include-list that decides its contents. The group the journey serves is
295
+ // carried too, purely so the invocation's report names the audience a file was
296
+ // written for.
297
+ type journey = {
298
+ group: string,
299
+ key: string,
300
+ selections: array<ReventlessCore.Platform_BakedManifest.selection>,
301
+ }
302
+
291
303
  let bakeSelection = (json: JSON.t): option<ReventlessCore.Platform_BakedManifest.selection> => {
292
304
  let strings = (o, key) =>
293
305
  o
@@ -309,6 +321,9 @@ let bakeSelection = (json: JSON.t): option<ReventlessCore.Platform_BakedManifest
309
321
  )
310
322
  }
311
323
 
324
+ let decodeSelections = (json: JSON.t): array<ReventlessCore.Platform_BakedManifest.selection> =>
325
+ json->JSON.Decode.array->Option.getOr([])->Array.filterMap(bakeSelection)
326
+
312
327
  // The declaration travels as an env var because it is deploy input — stated once
313
328
  // in the platform program, beside the bucket it is written to. The target travels
314
329
  // in the invocation payload instead: the bucket only exists inside the host-UI
@@ -319,14 +334,44 @@ let bakeSelections = (): array<ReventlessCore.Platform_BakedManifest.selection>
319
334
  switch NodeProcess.env->Dict.get("BAKE_SELECTIONS") {
320
335
  | None | Some("") => []
321
336
  | Some(raw) =>
322
- switch raw->JSON.parseOrThrow->JSON.Decode.array {
323
- | Some(entries) => entries->Array.filterMap(bakeSelection)
324
- | None => []
337
+ switch raw->JSON.parseOrThrow {
338
+ | json => json->decodeSelections
325
339
  | exception _ =>
326
340
  JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array")
327
341
  }
328
342
  }
329
343
 
344
+ let bakeJourney = (json: JSON.t): option<journey> =>
345
+ json
346
+ ->JSON.Decode.object
347
+ ->Option.flatMap(o =>
348
+ switch (
349
+ o->Dict.get("group")->Option.flatMap(JSON.Decode.string),
350
+ o->Dict.get("key")->Option.flatMap(JSON.Decode.string),
351
+ ) {
352
+ | (Some(group), Some(key)) =>
353
+ Some({
354
+ group,
355
+ key,
356
+ selections: o->Dict.get("components")->Option.mapOr([], decodeSelections),
357
+ })
358
+ | _ => None
359
+ }
360
+ )
361
+
362
+ // Absent for every deployment that declares one audience, which is what the
363
+ // declaration meant before journeys existed and what it still means.
364
+ let bakeJourneys = (): array<journey> =>
365
+ switch NodeProcess.env->Dict.get("BAKE_JOURNEYS") {
366
+ | None | Some("") => []
367
+ | Some(raw) =>
368
+ switch raw->JSON.parseOrThrow->JSON.Decode.array {
369
+ | Some(entries) => entries->Array.filterMap(bakeJourney)
370
+ | None => []
371
+ | exception _ => JsError.throwWithMessage("baked manifest: BAKE_JOURNEYS is not a JSON array")
372
+ }
373
+ }
374
+
330
375
  type bakeTarget = {bucket: string, key: string}
331
376
 
332
377
  let bakeTargetOf = (event: JSON.t): option<bakeTarget> =>
@@ -417,27 +462,49 @@ let runBake = async (
417
462
  "so there is nothing to write and a shell pointed at the file would find none.",
418
463
  )
419
464
  }
420
- switch ReventlessCore.Platform_BakedManifest.curate(~structures, ~selections) {
421
- | Error(e) =>
422
- JsError.throwWithMessage(ReventlessCore.Platform_BakedManifest.describe(e))
423
- | Ok(manifest) =>
424
- let body = JSON.stringify(manifest, ~space=2)
425
- let _ = await AwsSdk.S3.PutObjectCommand.make({
426
- bucket: target.bucket,
427
- key: target.key,
428
- body: AwsSdk.S3.PutObjectCommand.bodyFromString(body),
429
- contentType: "application/json",
430
- })->AwsSdk.S3.PutObjectCommand.send
431
- [
432
- Dict.fromArray([
465
+ // The default journey first and always — the caller supplies its key, because
466
+ // the caller is what knows where the shell it configured fetches from — then
467
+ // one file per declared journey, under the key the deploy resolved.
468
+ let files = Array.concat(
469
+ [(None, target.key, selections)],
470
+ bakeJourneys()->Array.map(j => (Some(j.group), j.key, j.selections)),
471
+ )
472
+
473
+ // Every file is curated before any is written, for the reason the in-memory
474
+ // platform curates before it writes: a declaration naming a component that
475
+ // does not exist fails the bake, and failing it halfway would leave the bucket
476
+ // serving one audience's file beside a stale copy of another's.
477
+ let curated = files->Array.map(((group, key, selections)) => (
478
+ group,
479
+ key,
480
+ selections,
481
+ switch ReventlessCore.Platform_BakedManifest.curate(~structures, ~selections) {
482
+ | Error(e) => JsError.throwWithMessage(ReventlessCore.Platform_BakedManifest.describe(e))
483
+ | Ok(manifest) => JSON.stringify(manifest, ~space=2)
484
+ },
485
+ ))
486
+
487
+ await Promise.all(
488
+ curated->Array.map(async ((group, key, selections, body)) => {
489
+ let _ = await AwsSdk.S3.PutObjectCommand.make({
490
+ bucket: target.bucket,
491
+ key,
492
+ body: AwsSdk.S3.PutObjectCommand.bodyFromString(body),
493
+ contentType: "application/json",
494
+ })->AwsSdk.S3.PutObjectCommand.send
495
+ let report = Dict.fromArray([
433
496
  ("baked", JSON.Encode.bool(true)),
434
497
  ("bucket", JSON.Encode.string(target.bucket)),
435
- ("key", JSON.Encode.string(target.key)),
498
+ ("key", JSON.Encode.string(key)),
436
499
  ("plugins", JSON.Encode.int(selections->Array.length)),
437
500
  ("bytes", JSON.Encode.int(body->String.length)),
438
- ])->JSON.Encode.object,
439
- ]
440
- }
501
+ ])
502
+ // Only a journey's entry carries the audience it was written for, so the
503
+ // default one reports exactly what it reported before journeys existed.
504
+ group->Option.forEach(g => report->Dict.set("group", JSON.Encode.string(g)))
505
+ report->JSON.Encode.object
506
+ }),
507
+ )
441
508
  }
442
509
 
443
510
  let handler = async (event: JSON.t): array<JSON.t> => {
@@ -268,6 +268,10 @@ function bakeSelection(json) {
268
268
  })));
269
269
  }
270
270
 
271
+ function decodeSelections(json) {
272
+ return Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_JSON.Decode.array(json), []), bakeSelection);
273
+ }
274
+
271
275
  function bakeSelections() {
272
276
  let raw = process.env["BAKE_SELECTIONS"];
273
277
  if (raw === undefined) {
@@ -276,14 +280,45 @@ function bakeSelections() {
276
280
  if (raw === "") {
277
281
  return [];
278
282
  }
283
+ let json;
284
+ try {
285
+ json = JSON.parse(raw);
286
+ } catch (exn) {
287
+ return Stdlib_JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array");
288
+ }
289
+ return decodeSelections(json);
290
+ }
291
+
292
+ function bakeJourney(json) {
293
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => {
294
+ let match = Stdlib_Option.flatMap(o["group"], Stdlib_JSON.Decode.string);
295
+ let match$1 = Stdlib_Option.flatMap(o["key"], Stdlib_JSON.Decode.string);
296
+ if (match !== undefined && match$1 !== undefined) {
297
+ return {
298
+ group: match,
299
+ key: match$1,
300
+ selections: Stdlib_Option.mapOr(o["components"], [], decodeSelections)
301
+ };
302
+ }
303
+ });
304
+ }
305
+
306
+ function bakeJourneys() {
307
+ let raw = process.env["BAKE_JOURNEYS"];
308
+ if (raw === undefined) {
309
+ return [];
310
+ }
311
+ if (raw === "") {
312
+ return [];
313
+ }
279
314
  let entries;
280
315
  try {
281
316
  entries = Stdlib_JSON.Decode.array(JSON.parse(raw));
282
317
  } catch (exn) {
283
- return Stdlib_JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array");
318
+ return Stdlib_JsError.throwWithMessage("baked manifest: BAKE_JOURNEYS is not a JSON array");
284
319
  }
285
320
  if (entries !== undefined) {
286
- return Stdlib_Array.filterMap(entries, bakeSelection);
321
+ return Stdlib_Array.filterMap(entries, bakeJourney);
287
322
  } else {
288
323
  return [];
289
324
  }
@@ -336,18 +371,37 @@ async function runBake(target, structures) {
336
371
  if (selections.length === 0) {
337
372
  Stdlib_JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is empty — this platform declares no bake, so there is nothing to write and a shell pointed at the file would find none.");
338
373
  }
339
- let e = Platform_BakedManifest$ReventlessCore.curate(structures, selections);
340
- if (e.TAG !== "Ok") {
341
- return Stdlib_JsError.throwWithMessage(Platform_BakedManifest$ReventlessCore.describe(e._0));
342
- }
343
- let body = JSON.stringify(e._0, undefined, 2);
344
- await S3$AwsSdk.PutObjectCommand.send(new ClientS3.PutObjectCommand({
345
- Bucket: target.bucket,
346
- Key: target.key,
347
- Body: body,
348
- ContentType: "application/json"
349
- }));
350
- return [Object.fromEntries([
374
+ let files = [[
375
+ undefined,
376
+ target.key,
377
+ selections
378
+ ]].concat(bakeJourneys().map(j => [
379
+ j.group,
380
+ j.key,
381
+ j.selections
382
+ ]));
383
+ let curated = files.map(param => {
384
+ let selections = param[2];
385
+ let e = Platform_BakedManifest$ReventlessCore.curate(structures, selections);
386
+ let tmp;
387
+ tmp = e.TAG === "Ok" ? JSON.stringify(e._0, undefined, 2) : Stdlib_JsError.throwWithMessage(Platform_BakedManifest$ReventlessCore.describe(e._0));
388
+ return [
389
+ param[0],
390
+ param[1],
391
+ selections,
392
+ tmp
393
+ ];
394
+ });
395
+ return await Promise.all(curated.map(async param => {
396
+ let body = param[3];
397
+ let key = param[1];
398
+ await S3$AwsSdk.PutObjectCommand.send(new ClientS3.PutObjectCommand({
399
+ Bucket: target.bucket,
400
+ Key: key,
401
+ Body: body,
402
+ ContentType: "application/json"
403
+ }));
404
+ let report = Object.fromEntries([
351
405
  [
352
406
  "baked",
353
407
  true
@@ -358,17 +412,22 @@ async function runBake(target, structures) {
358
412
  ],
359
413
  [
360
414
  "key",
361
- target.key
415
+ key
362
416
  ],
363
417
  [
364
418
  "plugins",
365
- selections.length
419
+ param[2].length
366
420
  ],
367
421
  [
368
422
  "bytes",
369
423
  body.length
370
424
  ]
371
- ])];
425
+ ]);
426
+ Stdlib_Option.forEach(param[0], g => {
427
+ report["group"] = g;
428
+ });
429
+ return report;
430
+ }));
372
431
  }
373
432
 
374
433
  async function handler(event) {
@@ -433,7 +492,10 @@ export {
433
492
  isComplete,
434
493
  structureOf,
435
494
  bakeSelection,
495
+ decodeSelections,
436
496
  bakeSelections,
497
+ bakeJourney,
498
+ bakeJourneys,
437
499
  bakeTargetOf,
438
500
  bakeExpectations,
439
501
  structureRefKey,
@@ -1,13 +1,17 @@
1
1
  let toResourceInfo = (table: PulumiAws.DynamoDb.Table.t) =>
2
2
  table.streamArn->Pulumi.Output.apply(streamArn => ReventlessInfra.Adapter.StreamSource({sourceUrn: streamArn}))
3
3
 
4
+ // The table name rides IN the apply rather than being read with `Output.get` on
5
+ // the failure arm: `get` throws "Cannot call '.get' during update or preview" on
6
+ // every deploy, so the arm meant to name the offending table replaced its own
7
+ // message with that one and hid which resource was missing a stream.
4
8
  let streamArnFromDynamoDbTableResource = (resource: ReventlessInfra.Adapter.resource) =>
5
- resource.resourceInfo->Pulumi.Output.apply(resourceInfo =>
9
+ (resource.resourceInfo, resource.name)
10
+ ->Pulumi.Output.all2
11
+ ->Pulumi.Output.apply(((resourceInfo, tableName)) =>
6
12
  switch resourceInfo {
7
13
  | StreamSource({sourceUrn}) => sourceUrn
8
- | _ =>
9
- let tableName = resource.name->Pulumi.Output.get
10
- JsError.throwWithMessage("No streamArn field given for table " ++ tableName)
14
+ | _ => JsError.throwWithMessage("No streamArn field given for table " ++ tableName)
11
15
  }
12
16
  )
13
17
 
@@ -30,6 +34,13 @@ let toStreamResource = (table: ReventlessInfra.Adapter.resource): ReventlessInfr
30
34
  ~id=streamArn,
31
35
  ~urn=streamArn,
32
36
  ~service=table.name->Pulumi.Output.apply(_ => AWS.DynamoDbStream.service),
37
+ // Carries the same StreamSource the table resource carries, so asking a
38
+ // stream resource for its ARN answers instead of throwing. The event-topic
39
+ // resources a publisher exports are these, and `Upload_Claim_S3` asks
40
+ // exactly that of resources[0].
41
+ ~resourceInfo=streamArn->Pulumi.Output.apply(sourceUrn => ReventlessInfra.Adapter.StreamSource({
42
+ sourceUrn: sourceUrn,
43
+ })),
33
44
  ~resourceType="aws:dynamodb:Stream"->Pulumi.Output.make,
34
45
  )
35
46
  }
@@ -22,12 +22,19 @@ function toResourceInfo(table) {
22
22
  }
23
23
 
24
24
  function streamArnFromDynamoDbTableResource(resource) {
25
- return resource.resourceInfo.apply(resourceInfo => {
26
- if (typeof resourceInfo === "object" && resourceInfo.TAG === "StreamSource") {
25
+ return Pulumi.all([
26
+ resource.resourceInfo,
27
+ resource.name
28
+ ]).apply(param => {
29
+ let tableName = param[1];
30
+ let resourceInfo = param[0];
31
+ if (typeof resourceInfo !== "object") {
32
+ return Stdlib_JsError.throwWithMessage("No streamArn field given for table " + tableName);
33
+ } else if (resourceInfo.TAG === "StreamSource") {
27
34
  return resourceInfo.sourceUrn;
35
+ } else {
36
+ return Stdlib_JsError.throwWithMessage("No streamArn field given for table " + tableName);
28
37
  }
29
- let tableName = resource.name.get();
30
- return Stdlib_JsError.throwWithMessage("No streamArn field given for table " + tableName);
31
38
  });
32
39
  }
33
40
 
@@ -37,7 +44,10 @@ function toResource(tags, table) {
37
44
 
38
45
  function toStreamResource(table) {
39
46
  let streamArn = streamArnFromDynamoDbTableResource(table);
40
- return Adapter$ReventlessInfra.make(table.name, streamArn, streamArn, table.name.apply(param => AWS$ReventlessAws.DynamoDbStream.service), undefined, undefined, undefined, Pulumi.output("aws:dynamodb:Stream"), undefined, undefined);
47
+ return Adapter$ReventlessInfra.make(table.name, streamArn, streamArn, table.name.apply(param => AWS$ReventlessAws.DynamoDbStream.service), streamArn.apply(sourceUrn => ({
48
+ TAG: "StreamSource",
49
+ sourceUrn: sourceUrn
50
+ })), undefined, undefined, Pulumi.output("aws:dynamodb:Stream"), undefined, undefined);
41
51
  }
42
52
 
43
53
  let log = Logger$ReventlessCore.fromEnv();
@@ -9,6 +9,10 @@
9
9
 
10
10
  module Platform = ReventlessInfra.Platform
11
11
 
12
+ // Where a caller acting as a given role discovers from — a group→url map. Named
13
+ // here as the shell reads it, the same way `manifestUrl` is.
14
+ let journeyManifestsKey = "journeyManifestUrls"
15
+
12
16
  // A mode's options, flattened to the wire shape. They are payloads of their arm
13
17
  // on the deploy side (so `mapStyle` with the map off cannot be expressed) and
14
18
  // flat siblings of `viewModes` on the wire (because that is where the released
@@ -52,12 +56,28 @@ let fields = (
52
56
  // decides where the file goes — a passthrough could point the shell at a key
53
57
  // nothing writes, and a statically-discovered shell has no admin API behind it
54
58
  // to notice.
55
- bakedManifest->Option.forEach(bake =>
59
+ bakedManifest->Option.forEach(bake => {
56
60
  out->Dict.set(
57
61
  "manifestUrl",
58
62
  JSON.Encode.string(ReventlessCore.Platform_BakedManifest.urlForKey(bake.key)),
59
63
  )
60
- )
64
+ // Where a caller acting as a given role discovers from, beside the default
65
+ // rather than instead of it: `manifestUrl` stays the default journey, which
66
+ // is what a caller matching no declared group gets. Omitted entirely when
67
+ // nothing is declared, so a single-audience deployment writes the key set it
68
+ // always did and a shell that has never heard of journeys sees no new key.
69
+ switch ReventlessCore.Platform_BakedManifest.journeyUrls(~config=bake) {
70
+ | [] => ()
71
+ | urls =>
72
+ out->Dict.set(
73
+ journeyManifestsKey,
74
+ urls
75
+ ->Array.map(((group, url)) => (group, JSON.Encode.string(url)))
76
+ ->Dict.fromArray
77
+ ->JSON.Encode.object,
78
+ )
79
+ }
80
+ })
61
81
 
62
82
  switch viewModes {
63
83
  | Some(modes) =>
@@ -6,6 +6,8 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Platform$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/Platform.res.mjs";
7
7
  import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
8
8
 
9
+ let journeyManifestsKey = "journeyManifestUrls";
10
+
9
11
  function modeOptions(mode) {
10
12
  if (mode.TAG === "Map") {
11
13
  let style = mode._0.style;
@@ -33,6 +35,14 @@ function fields(computed, viewModes, bakedManifest, shellConfig) {
33
35
  let out = Object.fromEntries(computed);
34
36
  Stdlib_Option.forEach(bakedManifest, bake => {
35
37
  out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(bake.key);
38
+ let urls = Platform_BakedManifest$ReventlessCore.journeyUrls(bake);
39
+ if (urls.length !== 0) {
40
+ out[journeyManifestsKey] = Object.fromEntries(urls.map(param => [
41
+ param[0],
42
+ param[1]
43
+ ]));
44
+ return;
45
+ }
36
46
  });
37
47
  if (viewModes !== undefined) {
38
48
  out["viewModes"] = viewModes.map(Platform$ReventlessInfra.viewModeToString);
@@ -58,6 +68,7 @@ let Platform;
58
68
 
59
69
  export {
60
70
  Platform,
71
+ journeyManifestsKey,
61
72
  modeOptions,
62
73
  fields,
63
74
  }
@@ -241,6 +241,52 @@ describe("bakeSelection", () => {
241
241
  )
242
242
  })
243
243
 
244
+ // A journey travels from the deploy in the function's environment, exactly as the
245
+ // default include-list does, carrying the key the deploy resolved. Deriving the
246
+ // key a second time here would be a file written where `config.json` does not
247
+ // send anybody, so the handler only reads it.
248
+ describe("bakeJourney", () => {
249
+ let journey = raw => Platform_ComponentDefinitions_Lambda_Ops.bakeJourney(JSON.parseOrThrow(raw))
250
+
251
+ testSync("reads the group, the key and the include-list the deploy encoded", () => {
252
+ let decoded = journey(`{
253
+ "group": "Fulfilment",
254
+ "key": "component-manifest-fulfilment.json",
255
+ "components": [{"plugin": "Ordering", "views": ["Orders"], "derived": ["lifecycles"]}]
256
+ }`)
257
+ expect(decoded->Option.map(j => (j.group, j.key, j.selections->Array.map(s => s.plugin))))->toEqual(
258
+ Some(("Fulfilment", "component-manifest-fulfilment.json", ["Ordering"])),
259
+ )
260
+ expect(
261
+ decoded->Option.flatMap(j => j.selections->Array.get(0))->Option.flatMap(s => s.derived),
262
+ )->toEqual(Some(["lifecycles"]))
263
+ })
264
+
265
+ // Absent is not empty here either: a journey whose selection names no `views`
266
+ // takes every public view of that plugin.
267
+ testSync("keeps a selection's absent lists absent", () => {
268
+ let decoded = journey(`{"group": "Shopper", "key": "s.json", "components": [{"plugin": "Catalog"}]}`)
269
+ expect(
270
+ decoded->Option.flatMap(j => j.selections->Array.get(0))->Option.flatMap(s => s.views),
271
+ )->toEqual(None)
272
+ })
273
+
274
+ // Both are what the write needs. A journey missing either would be baked to a
275
+ // key nobody granted or reported as belonging to no audience.
276
+ testSync("refuses an entry naming no group or no key", () => {
277
+ expect(journey(`{"key": "s.json", "components": []}`)->Option.isNone)->toBe(true)
278
+ expect(journey(`{"group": "Shopper", "components": []}`)->Option.isNone)->toBe(true)
279
+ })
280
+
281
+ // A journey that curates nothing is still a journey — it writes an empty file
282
+ // rather than falling back to the default one.
283
+ testSync("a journey with no components decodes to an empty include-list", () =>
284
+ expect(
285
+ journey(`{"group": "Shopper", "key": "s.json"}`)->Option.map(j => j.selections->Array.length),
286
+ )->toEqual(Some(0))
287
+ )
288
+ })
289
+
244
290
  // The bake reads a read model the deploy updates asynchronously, so "is this the
245
291
  // deployment I was asked to bake" is a question it has to be able to answer. The
246
292
  // answer is an equality check against the key each plugin stack just wrote.
@@ -154,6 +154,37 @@ globalThis.describe("bakeSelection", () => {
154
154
  });
155
155
  });
156
156
 
157
+ globalThis.describe("bakeJourney", () => {
158
+ globalThis.test("reads the group, the key and the include-list the deploy encoded", () => {
159
+ let decoded = Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{
160
+ "group": "Fulfilment",
161
+ "key": "component-manifest-fulfilment.json",
162
+ "components": [{"plugin": "Ordering", "views": ["Orders"], "derived": ["lifecycles"]}]
163
+ }`));
164
+ globalThis.expect(Stdlib_Option.map(decoded, j => [
165
+ j.group,
166
+ j.key,
167
+ j.selections.map(s => s.plugin)
168
+ ])).toEqual([
169
+ "Fulfilment",
170
+ "component-manifest-fulfilment.json",
171
+ ["Ordering"]
172
+ ]);
173
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(decoded, j => j.selections[0]), s => s.derived)).toEqual(["lifecycles"]);
174
+ });
175
+ globalThis.test("keeps a selection's absent lists absent", () => {
176
+ let decoded = Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "key": "s.json", "components": [{"plugin": "Catalog"}]}`));
177
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(decoded, j => j.selections[0]), s => s.views)).toEqual(undefined);
178
+ });
179
+ globalThis.test("refuses an entry naming no group or no key", () => {
180
+ globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"key": "s.json", "components": []}`)))).toBe(true);
181
+ globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "components": []}`)))).toBe(true);
182
+ });
183
+ globalThis.test("a journey with no components decodes to an empty include-list", () => {
184
+ globalThis.expect(Stdlib_Option.map(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "key": "s.json"}`)), j => j.selections.length)).toEqual(0);
185
+ });
186
+ });
187
+
157
188
  globalThis.describe("pendingRegistrations", () => {
158
189
  let row = (name, key, param) => {
159
190
  let item = Object.fromEntries([[
@@ -0,0 +1,43 @@
1
+ open JestGlobals
2
+
3
+ // Guards that a stream resource describes its own stream.
4
+ //
5
+ // `toStreamResource` used to leave `resourceInfo` at its `NoInfo` default even
6
+ // though it was built FROM a stream ARN. The event-topic resources a DynamoDB
7
+ // stream publisher exports are these, so every reader that asks an event topic
8
+ // for its stream ARN — `Upload_Claim_S3` does, to grant its Lambda
9
+ // `dynamodb:GetRecords` — took the arm that has no ARN to give and failed the
10
+ // deploy. The failure only surfaced once a plugin's first StateChangeSlice
11
+ // declared a `@storageRef` field, which is what registers the claimer at all.
12
+
13
+ let resolve = (output: Pulumi.Output.t<'a>): promise<'a> =>
14
+ Promise.make((resolve, _) => {
15
+ let _ = output->Pulumi.Output.apply(value => resolve(value))
16
+ })
17
+
18
+ let streamArn = "arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123/stream/2026-01-01T00:00:00.000"
19
+
20
+ let tableResource = ReventlessInfra.Adapter.make(
21
+ ~name="CatalogDcbEventLog-abc123"->Pulumi.Output.make,
22
+ ~id="CatalogDcbEventLog-abc123"->Pulumi.Output.make,
23
+ ~urn="arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123"->Pulumi.Output.make,
24
+ ~service=AWS.DynamoDbStream.service->Pulumi.Output.make,
25
+ ~resourceInfo=ReventlessInfra.Adapter.StreamSource({sourceUrn: streamArn})->Pulumi.Output.make,
26
+ )
27
+
28
+ describe("Util_DynamoDbStream.toStreamResource", () => {
29
+ test("carries the source stream in resourceInfo", async () => {
30
+ let stream = tableResource->Util_DynamoDbStream.toStreamResource
31
+ let resourceInfo = await stream.resourceInfo->resolve
32
+ expect(resourceInfo)->toEqual(ReventlessInfra.Adapter.StreamSource({sourceUrn: streamArn}))
33
+ })
34
+
35
+ test("answers its own stream ARN", async () => {
36
+ let arn =
37
+ await tableResource
38
+ ->Util_DynamoDbStream.toStreamResource
39
+ ->Util_DynamoDbStream.streamArnFromDynamoDbTableResource
40
+ ->resolve
41
+ expect(arn)->toBe(streamArn)
42
+ })
43
+ })
@@ -0,0 +1,41 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Pulumi from "@pulumi/pulumi";
4
+ import * as AWS$ReventlessAws from "../src/adapter/AWS.res.mjs";
5
+ import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
6
+ import * as Util_DynamoDbStream$ReventlessAws from "../src/util/Util_DynamoDbStream.res.mjs";
7
+
8
+ function resolve(output) {
9
+ return new Promise((resolve, param) => {
10
+ output.apply(value => resolve(value));
11
+ });
12
+ }
13
+
14
+ let streamArn = "arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123/stream/2026-01-01T00:00:00.000";
15
+
16
+ let tableResource = Adapter$ReventlessInfra.make(Pulumi.output("CatalogDcbEventLog-abc123"), Pulumi.output("CatalogDcbEventLog-abc123"), Pulumi.output("arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123"), Pulumi.output(AWS$ReventlessAws.DynamoDbStream.service), Pulumi.output({
17
+ TAG: "StreamSource",
18
+ sourceUrn: streamArn
19
+ }), undefined, undefined, undefined, undefined, undefined);
20
+
21
+ globalThis.describe("Util_DynamoDbStream.toStreamResource", () => {
22
+ globalThis.test("carries the source stream in resourceInfo", async () => {
23
+ let stream = Util_DynamoDbStream$ReventlessAws.toStreamResource(tableResource);
24
+ let resourceInfo = await resolve(stream.resourceInfo);
25
+ globalThis.expect(resourceInfo).toEqual({
26
+ TAG: "StreamSource",
27
+ sourceUrn: streamArn
28
+ });
29
+ });
30
+ globalThis.test("answers its own stream ARN", async () => {
31
+ let arn = await resolve(Util_DynamoDbStream$ReventlessAws.streamArnFromDynamoDbTableResource(Util_DynamoDbStream$ReventlessAws.toStreamResource(tableResource)));
32
+ globalThis.expect(arn).toBe(streamArn);
33
+ });
34
+ });
35
+
36
+ export {
37
+ resolve,
38
+ streamArn,
39
+ tableResource,
40
+ }
41
+ /* tableResource Not a pure module */
@@ -81,6 +81,113 @@ describe("Util_ShellConfig.fields — bakedManifest", () => {
81
81
  })
82
82
  })
83
83
 
84
+ // ── Journeys ──────────────────────────────────────────────────────────────
85
+ //
86
+ // One curated surface per audience, beside the default one. The property under
87
+ // test throughout is that a deployment declaring none is untouched: every
88
+ // deployment that predates journeys has exactly one audience, and its
89
+ // config.json must not grow a key for a feature it does not use.
90
+ describe("Util_ShellConfig.fields — journeys", () => {
91
+ let withJourneys = (
92
+ ~journeys: array<ReventlessInfra.Platform.bakedJourney>,
93
+ ): ReventlessInfra.Platform.bakedManifest => {
94
+ components: [{plugin: "Catalog", views: ["Products"], commands: []}],
95
+ journeys,
96
+ }
97
+
98
+ let shopper: ReventlessInfra.Platform.bakedJourney = {
99
+ group: "Shopper",
100
+ components: [{plugin: "Catalog", views: ["Products"], commands: []}],
101
+ }
102
+
103
+ let fulfilment: ReventlessInfra.Platform.bakedJourney = {
104
+ group: "Fulfilment",
105
+ components: [{plugin: "Ordering", views: ["Orders"], commands: ["ShipOrder"]}],
106
+ key: "fulfilment.json",
107
+ }
108
+
109
+ testSync("a bake declaring no journeys writes no map", () => {
110
+ let out = Util_ShellConfig.fields(
111
+ ~computed,
112
+ ~bakedManifest={components: [{plugin: "Catalog", views: ["Products"], commands: []}]},
113
+ )
114
+ expect(out->Dict.get("journeyManifestUrls")->Option.isNone)->toBe(true)
115
+ })
116
+
117
+ testSync("an empty journeys array is the same as none", () => {
118
+ let out = Util_ShellConfig.fields(~computed, ~bakedManifest=withJourneys(~journeys=[]))
119
+ expect(out->Dict.get("journeyManifestUrls")->Option.isNone)->toBe(true)
120
+ })
121
+
122
+ // The default journey keeps `manifestUrl`, so a caller matching no declared
123
+ // group lands where every caller landed before.
124
+ testSync("keeps manifestUrl as the default journey", () => {
125
+ let out = Util_ShellConfig.fields(~computed, ~bakedManifest=withJourneys(~journeys=[shopper]))
126
+ expect(out->get("manifestUrl"))->toEqual(JSON.Encode.string("/component-manifest.json"))
127
+ })
128
+
129
+ testSync("maps each declared group to its own file", () => {
130
+ let out = Util_ShellConfig.fields(
131
+ ~computed,
132
+ ~bakedManifest=withJourneys(~journeys=[shopper, fulfilment]),
133
+ )
134
+ expect(out->get("journeyManifestUrls"))->toEqual(
135
+ JSON.Encode.object(
136
+ Dict.fromArray([
137
+ // Derived from the group, lower-cased, because a key is part of a URL.
138
+ ("Shopper", JSON.Encode.string("/component-manifest-shopper.json")),
139
+ // Named explicitly, and the declaration wins.
140
+ ("Fulfilment", JSON.Encode.string("/fulfilment.json")),
141
+ ]),
142
+ ),
143
+ )
144
+ })
145
+
146
+ // The URL a shell fetches and the key the bake writes are one string, derived
147
+ // once — a second derivation is a file written where nothing looks for it. So
148
+ // every key the bake writes is published, and the default one is published as
149
+ // `manifestUrl` rather than in the map.
150
+ testSync("publishes the URL of every key the bake writes, exactly once", () => {
151
+ let bake = withJourneys(~journeys=[shopper, fulfilment])
152
+ let out = Util_ShellConfig.fields(~computed, ~bakedManifest=bake)
153
+ let published = Array.concat(
154
+ out->get("manifestUrl")->JSON.Decode.string->Option.mapOr([], url => [url]),
155
+ out
156
+ ->get("journeyManifestUrls")
157
+ ->JSON.Decode.object
158
+ ->Option.getOr(Dict.make())
159
+ ->Dict.valuesToArray
160
+ ->Array.filterMap(JSON.Decode.string),
161
+ )
162
+ expect(published)->toEqual(
163
+ ReventlessCore.Platform_BakedManifest.files(~config=bake)->Array.map(((key, _)) =>
164
+ "/" ++ key
165
+ ),
166
+ )
167
+ })
168
+
169
+ // A passthrough cannot redirect a key the deploy computes — the same rule
170
+ // `manifestUrl` already carries, extended to the map beside it.
171
+ testSync("a shellConfig journey map fails the deploy rather than redirecting it", () => {
172
+ let failure = try {
173
+ let _ = Util_ShellConfig.fields(
174
+ ~computed,
175
+ ~bakedManifest=withJourneys(~journeys=[shopper]),
176
+ ~shellConfig=Dict.fromArray([
177
+ ("journeyManifestUrls", JSON.Encode.object(Dict.make())),
178
+ ]),
179
+ )
180
+ None
181
+ } catch {
182
+ | Failure(message) => Some(message)
183
+ }
184
+ switch failure {
185
+ | Some(message) => expect(message->String.includes("journeyManifestUrls"))->toBe(true)
186
+ | None => fail("a shellConfig key redirecting the computed journey map must fail the deploy")
187
+ }
188
+ })
189
+ })
190
+
84
191
  describe("Util_ShellConfig.fields — shellConfig passthrough", () => {
85
192
  testSync("shell-owned keys land verbatim, under the computed ones", () => {
86
193
  let out = Util_ShellConfig.fields(
@@ -1,9 +1,12 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as JestGlobals from "@reventlessdev/rescript-jest/src/JestGlobals.res.mjs";
4
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
7
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
6
8
  import * as Util_ShellConfig$ReventlessAws from "../src/util/Util_ShellConfig.res.mjs";
9
+ import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
7
10
 
8
11
  let computed = [
9
12
  [
@@ -107,6 +110,103 @@ globalThis.describe("Util_ShellConfig.fields — bakedManifest", () => {
107
110
  });
108
111
  });
109
112
 
113
+ globalThis.describe("Util_ShellConfig.fields — journeys", () => {
114
+ let withJourneys = journeys => ({
115
+ components: [{
116
+ plugin: "Catalog",
117
+ views: ["Products"],
118
+ commands: []
119
+ }],
120
+ journeys: journeys
121
+ });
122
+ let shopper_components = [{
123
+ plugin: "Catalog",
124
+ views: ["Products"],
125
+ commands: []
126
+ }];
127
+ let shopper = {
128
+ group: "Shopper",
129
+ components: shopper_components
130
+ };
131
+ let fulfilment_components = [{
132
+ plugin: "Ordering",
133
+ views: ["Orders"],
134
+ commands: ["ShipOrder"]
135
+ }];
136
+ let fulfilment_key = "fulfilment.json";
137
+ let fulfilment = {
138
+ group: "Fulfilment",
139
+ components: fulfilment_components,
140
+ key: fulfilment_key
141
+ };
142
+ globalThis.test("a bake declaring no journeys writes no map", () => {
143
+ let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, {
144
+ components: [{
145
+ plugin: "Catalog",
146
+ views: ["Products"],
147
+ commands: []
148
+ }]
149
+ }, undefined);
150
+ globalThis.expect(Stdlib_Option.isNone(out["journeyManifestUrls"])).toBe(true);
151
+ });
152
+ globalThis.test("an empty journeys array is the same as none", () => {
153
+ let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, withJourneys([]), undefined);
154
+ globalThis.expect(Stdlib_Option.isNone(out["journeyManifestUrls"])).toBe(true);
155
+ });
156
+ globalThis.test("keeps manifestUrl as the default journey", () => {
157
+ let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, withJourneys([shopper]), undefined);
158
+ globalThis.expect(get(out, "manifestUrl")).toEqual("/component-manifest.json");
159
+ });
160
+ globalThis.test("maps each declared group to its own file", () => {
161
+ let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, withJourneys([
162
+ shopper,
163
+ fulfilment
164
+ ]), undefined);
165
+ globalThis.expect(get(out, "journeyManifestUrls")).toEqual(Object.fromEntries([
166
+ [
167
+ "Shopper",
168
+ "/component-manifest-shopper.json"
169
+ ],
170
+ [
171
+ "Fulfilment",
172
+ "/fulfilment.json"
173
+ ]
174
+ ]));
175
+ });
176
+ globalThis.test("publishes the URL of every key the bake writes, exactly once", () => {
177
+ let bake = withJourneys([
178
+ shopper,
179
+ fulfilment
180
+ ]);
181
+ let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, bake, undefined);
182
+ let published = Stdlib_Option.mapOr(Stdlib_JSON.Decode.string(get(out, "manifestUrl")), [], url => [url]).concat(Stdlib_Array.filterMap(Object.values(Stdlib_Option.getOr(Stdlib_JSON.Decode.object(get(out, "journeyManifestUrls")), {})), Stdlib_JSON.Decode.string));
183
+ globalThis.expect(published).toEqual(Platform_BakedManifest$ReventlessCore.files(bake).map(param => "/" + param[0]));
184
+ });
185
+ globalThis.test("a shellConfig journey map fails the deploy rather than redirecting it", () => {
186
+ let failure;
187
+ try {
188
+ Util_ShellConfig$ReventlessAws.fields(computed, undefined, withJourneys([shopper]), Object.fromEntries([[
189
+ "journeyManifestUrls",
190
+ {}
191
+ ]]));
192
+ failure = undefined;
193
+ } catch (raw_message) {
194
+ let message = Primitive_exceptions.internalToException(raw_message);
195
+ if (message.RE_EXN_ID === "Failure") {
196
+ failure = message._1;
197
+ } else {
198
+ throw message;
199
+ }
200
+ }
201
+ if (failure !== undefined) {
202
+ globalThis.expect(failure.includes("journeyManifestUrls")).toBe(true);
203
+ return;
204
+ } else {
205
+ return JestGlobals.fail("a shellConfig key redirecting the computed journey map must fail the deploy");
206
+ }
207
+ });
208
+ });
209
+
110
210
  globalThis.describe("Util_ShellConfig.fields — shellConfig passthrough", () => {
111
211
  globalThis.test("shell-owned keys land verbatim, under the computed ones", () => {
112
212
  let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, undefined, Object.fromEntries([