@reventlessdev/reventless-aws 3.0.0-alpha.290 → 3.0.0-alpha.291

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,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
+ # 3.0.0-alpha.291 (2026-08-13)
7
+
8
+ ### Features
9
+
10
+ * **aws:** bake the component manifest after every plugin stack is up ([2fff09a](https://github.com/ReventlessDev/reventless-core/commit/2fff09aa32b77cc8e33a65414c58c12026ca009e))
11
+
12
+
6
13
  # 3.0.0-alpha.290 (2026-08-13)
7
14
 
8
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.290",
3
+ "version": "3.0.0-alpha.291",
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.8",
15
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
+ "@reventlessdev/rescript-node": "2.0.0-alpha.5",
18
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.72",
16
19
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
17
- "@reventlessdev/rescript-node": "2.0.0-alpha.4",
18
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.7",
19
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.71",
20
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
21
21
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-core": "3.0.0-alpha.229",
23
- "@reventlessdev/reventless-infra": "3.0.0-alpha.137",
22
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.138",
23
+ "@reventlessdev/reventless-core": "3.0.0-alpha.230",
24
24
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
25
- "@reventlessdev/reventless-spec": "3.0.0-alpha.111",
26
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.93"
25
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.94",
26
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.112"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -1454,17 +1454,24 @@ module MakeWithConfig = (
1454
1454
  // Also register the Plugin RM table with the AllAggregates Lambda runtime
1455
1455
  // so its in-Lambda plugin status gate can read plugin status at command
1456
1456
  // dispatch time (Part 2.3 of the resolver plan).
1457
- switch pluginReadModelTableName {
1457
+ // Handed back so the host-UI half of this deploy can grant the bake its write
1458
+ // into the bucket it creates, and so the function name can be exported for
1459
+ // the post-deploy step. `None` when there is no Plugin RM to scan — there is
1460
+ // then no function, and nothing downstream to grant.
1461
+ let componentDefinitions = switch pluginReadModelTableName {
1458
1462
  | Some(tableName) =>
1459
1463
  AggregateRuntime_Builder_Single.setPluginReadModelTable(~name=tableName)
1460
- Platform_ComponentDefinitions_Lambda.make(
1461
- ~api=platformApi,
1462
- ~pluginReadModelTableName=tableName,
1463
- ~offloadBucketName,
1464
- ~schemaReady=admin.adminSchemaPushed,
1465
- ~opts={},
1464
+ Some(
1465
+ Platform_ComponentDefinitions_Lambda.make(
1466
+ ~api=platformApi,
1467
+ ~pluginReadModelTableName=tableName,
1468
+ ~offloadBucketName,
1469
+ ~schemaReady=admin.adminSchemaPushed,
1470
+ ~bakedManifest=?hostUiBundle->Option.flatMap(cfg => cfg.bakedManifest),
1471
+ ~opts={},
1472
+ ),
1466
1473
  )
1467
- | None => ()
1474
+ | None => None
1468
1475
  }
1469
1476
 
1470
1477
  // Mount the Platform_UIFragments Lambda resolver — scans the UiFragments
@@ -2163,6 +2170,7 @@ module MakeWithConfig = (
2163
2170
  Util_ShellConfig.fields(
2164
2171
  ~computed=withEvents,
2165
2172
  ~viewModes=?cfg.viewModes,
2173
+ ~bakedManifest=?cfg.bakedManifest,
2166
2174
  ~shellConfig=?cfg.shellConfig,
2167
2175
  )
2168
2176
  ->JSON.Encode.object
@@ -2202,6 +2210,50 @@ module MakeWithConfig = (
2202
2210
  )
2203
2211
  }
2204
2212
 
2213
+ // The bake's write, granted here because here is where both facts are
2214
+ // known: which bucket serves the shell, and that this deployment declared a
2215
+ // manifest at all. Scoped to the one key the deploy told the shell to fetch
2216
+ // — a function that may rewrite the bundle would be a much larger grant
2217
+ // than "keep one generated file current".
2218
+ //
2219
+ // Exported alongside so the post-deploy step has the function and the
2220
+ // target without reading either out of the deploy program.
2221
+ switch (componentDefinitions, cfg.bakedManifest) {
2222
+ | (Some(fn), Some(bake)) =>
2223
+ let manifestKey = bake.key->Option.getOr(
2224
+ ReventlessCore.Platform_BakedManifest.defaultKey,
2225
+ )
2226
+ let _ =
2227
+ (fn.roleId, bucketName)
2228
+ ->Pulumi.Output.all2
2229
+ ->Pulumi.Output.apply(((roleId, bucket)) => {
2230
+ open PulumiAws.PolicyDocument
2231
+ let _ = PulumiAws.IAM.RolePolicy.make(
2232
+ ~name="PlatformUIDefinitionsBake",
2233
+ ~args={
2234
+ PulumiAws.IAM.RolePolicy.policy: PulumiAws.PolicyDocument.make(
2235
+ ~id="PlatformUIDefinitionsBakePolicy",
2236
+ ~statements=[
2237
+ {
2238
+ sid: "AllowWriteBakedManifest",
2239
+ effect: Allow,
2240
+ actions: Actions(["s3:PutObject"]),
2241
+ resources: Resource(`arn:aws:s3:::${bucket}/${manifestKey}`),
2242
+ },
2243
+ ],
2244
+ )
2245
+ ->PulumiAws.PolicyDocument.toJsonString
2246
+ ->Pulumi.Input.make,
2247
+ role: roleId->Pulumi.Input.make,
2248
+ },
2249
+ )
2250
+ })
2251
+ Pulumi.Pulumi.export("bakedManifestFunction", fn.functionName)
2252
+ Pulumi.Pulumi.export("bakedManifestBucket", bucketName)
2253
+ Pulumi.Pulumi.export("bakedManifestKey", Pulumi.Output.make(manifestKey))
2254
+ | _ => ()
2255
+ }
2256
+
2205
2257
  Pulumi.Pulumi.export("hostShellUrl", distributionUrl)
2206
2258
  }
2207
2259
 
@@ -22,6 +22,7 @@ import * as Scheduler$ReventlessAws from "./components/Scheduler.res.mjs";
22
22
  import * as Aggregate$ReventlessCore from "@reventlessdev/reventless-core/src/components/Aggregate/Aggregate.res.mjs";
23
23
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
24
24
  import * as DcbBackend$ReventlessAws from "./adapter/DcbEventLog/DcbBackend.res.mjs";
25
+ import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
25
26
  import * as ReadModel$ReventlessCore from "@reventlessdev/reventless-core/src/components/ReadModel/ReadModel.res.mjs";
26
27
  import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginSpec.res.mjs";
27
28
  import * as Util_Bundle$ReventlessAws from "./util/Util_Bundle.res.mjs";
@@ -70,6 +71,7 @@ import * as AutomationSlice_Builder$ReventlessAws from "./components/AutomationS
70
71
  import * as EventTopicPublisher_SNS$ReventlessAws from "./adapter/EventTopic/EventTopicPublisher_SNS.res.mjs";
71
72
  import * as ExtensionPointMapping$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/ExtensionPointMapping.res.mjs";
72
73
  import * as PgQueryResolver_Builder$ReventlessAws from "./adapter/QueryDb/PgQueryResolver_Builder.res.mjs";
74
+ import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
73
75
  import * as UiFragments_Projection$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateViewSlice/UiFragments_Projection.res.mjs";
74
76
  import * as Aggregate_Builder_Single$ReventlessAws from "./components/Aggregate_Builder_Single.res.mjs";
75
77
  import * as ReadModel_Builder_Single$ReventlessAws from "./components/ReadModel_Builder_Single.res.mjs";
@@ -859,10 +861,7 @@ function MakeWithConfig(Config) {
859
861
  restrictPublicBuckets: true
860
862
  });
861
863
  Pulumi$Pulumi.$$export("offloadBucket", offloadBucketName);
862
- if (pluginReadModelTableName !== undefined) {
863
- AggregateRuntime_Builder_Single$ReventlessAws.setPluginReadModelTable(pluginReadModelTableName);
864
- Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, admin.adminSchemaPushed, {});
865
- }
864
+ let componentDefinitions = pluginReadModelTableName !== undefined ? (AggregateRuntime_Builder_Single$ReventlessAws.setPluginReadModelTable(pluginReadModelTableName), Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, admin.adminSchemaPushed, Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), {})) : undefined;
866
865
  let rm = admin.stateViewSlicesOutputs["UiFragments"];
867
866
  if (rm !== undefined) {
868
867
  let r$1 = rm.queryDb.resources[0];
@@ -1189,7 +1188,7 @@ function MakeWithConfig(Config) {
1189
1188
  AppSync_EventsApi$ReventlessAws.clientNamespaceName
1190
1189
  ]
1191
1190
  ]) : computed;
1192
- return JSON.stringify(Util_ShellConfig$ReventlessAws.fields(withEvents, hostUiBundle.viewModes, hostUiBundle.shellConfig));
1191
+ return JSON.stringify(Util_ShellConfig$ReventlessAws.fields(withEvents, hostUiBundle.viewModes, hostUiBundle.bakedManifest, hostUiBundle.shellConfig));
1193
1192
  });
1194
1193
  new (Aws.s3.BucketObject)("host-ui-config-json", {
1195
1194
  bucket: bucketName,
@@ -1207,6 +1206,27 @@ function MakeWithConfig(Config) {
1207
1206
  contentType: "application/json"
1208
1207
  });
1209
1208
  }
1209
+ let match$5 = hostUiBundle.bakedManifest;
1210
+ if (componentDefinitions !== undefined && match$5 !== undefined) {
1211
+ let manifestKey = Stdlib_Option.getOr(match$5.key, Platform_BakedManifest$ReventlessCore.defaultKey);
1212
+ Pulumi.all([
1213
+ componentDefinitions.roleId,
1214
+ bucketName
1215
+ ]).apply(param => {
1216
+ new (Aws.iam.RolePolicy)("PlatformUIDefinitionsBake", {
1217
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "PlatformUIDefinitionsBakePolicy", [{
1218
+ Sid: "AllowWriteBakedManifest",
1219
+ Effect: "Allow",
1220
+ Action: ["s3:PutObject"],
1221
+ Resource: `arn:aws:s3:::` + param[1] + `/` + manifestKey
1222
+ }])),
1223
+ role: param[0]
1224
+ });
1225
+ });
1226
+ Pulumi$Pulumi.$$export("bakedManifestFunction", componentDefinitions.functionName);
1227
+ Pulumi$Pulumi.$$export("bakedManifestBucket", bucketName);
1228
+ Pulumi$Pulumi.$$export("bakedManifestKey", Pulumi.output(manifestKey));
1229
+ }
1210
1230
  Pulumi$Pulumi.$$export("hostShellUrl", match$4.distributionUrl);
1211
1231
  }
1212
1232
  return Pulumi$Pulumi.getOutputs();
@@ -2098,10 +2118,7 @@ function Make($star) {
2098
2118
  restrictPublicBuckets: true
2099
2119
  });
2100
2120
  Pulumi$Pulumi.$$export("offloadBucket", offloadBucketName);
2101
- if (pluginReadModelTableName !== undefined) {
2102
- AggregateRuntime_Builder_Single$ReventlessAws.setPluginReadModelTable(pluginReadModelTableName);
2103
- Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, admin.adminSchemaPushed, {});
2104
- }
2121
+ let componentDefinitions = pluginReadModelTableName !== undefined ? (AggregateRuntime_Builder_Single$ReventlessAws.setPluginReadModelTable(pluginReadModelTableName), Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, admin.adminSchemaPushed, Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), {})) : undefined;
2105
2122
  let rm = admin.stateViewSlicesOutputs["UiFragments"];
2106
2123
  if (rm !== undefined) {
2107
2124
  let r$1 = rm.queryDb.resources[0];
@@ -2422,7 +2439,7 @@ function Make($star) {
2422
2439
  AppSync_EventsApi$ReventlessAws.clientNamespaceName
2423
2440
  ]
2424
2441
  ]) : computed;
2425
- return JSON.stringify(Util_ShellConfig$ReventlessAws.fields(withEvents, hostUiBundle.viewModes, hostUiBundle.shellConfig));
2442
+ return JSON.stringify(Util_ShellConfig$ReventlessAws.fields(withEvents, hostUiBundle.viewModes, hostUiBundle.bakedManifest, hostUiBundle.shellConfig));
2426
2443
  });
2427
2444
  new (Aws.s3.BucketObject)("host-ui-config-json", {
2428
2445
  bucket: bucketName,
@@ -2440,6 +2457,27 @@ function Make($star) {
2440
2457
  contentType: "application/json"
2441
2458
  });
2442
2459
  }
2460
+ let match$5 = hostUiBundle.bakedManifest;
2461
+ if (componentDefinitions !== undefined && match$5 !== undefined) {
2462
+ let manifestKey = Stdlib_Option.getOr(match$5.key, Platform_BakedManifest$ReventlessCore.defaultKey);
2463
+ Pulumi.all([
2464
+ componentDefinitions.roleId,
2465
+ bucketName
2466
+ ]).apply(param => {
2467
+ new (Aws.iam.RolePolicy)("PlatformUIDefinitionsBake", {
2468
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "PlatformUIDefinitionsBakePolicy", [{
2469
+ Sid: "AllowWriteBakedManifest",
2470
+ Effect: "Allow",
2471
+ Action: ["s3:PutObject"],
2472
+ Resource: `arn:aws:s3:::` + param[1] + `/` + manifestKey
2473
+ }])),
2474
+ role: param[0]
2475
+ });
2476
+ });
2477
+ Pulumi$Pulumi.$$export("bakedManifestFunction", componentDefinitions.functionName);
2478
+ Pulumi$Pulumi.$$export("bakedManifestBucket", bucketName);
2479
+ Pulumi$Pulumi.$$export("bakedManifestKey", Pulumi.output(manifestKey));
2480
+ }
2443
2481
  Pulumi$Pulumi.$$export("hostShellUrl", match$4.distributionUrl);
2444
2482
  }
2445
2483
  return Pulumi$Pulumi.getOutputs();
@@ -108,13 +108,17 @@ let compareVersions = (a: string, b: string): int => {
108
108
  // `nameVersionOf` reads the row's `name@version` string; `toEntry` builds the
109
109
  // output entry for a surviving row (returning None drops a malformed row). The
110
110
  // name/version split mirrors ReventlessCore.Plugin.name / compareVersions.
111
+ // Generic in what an entry is: the GraphQL fields collapse to encoded JSON, the
112
+ // bake collapses to the decoded structure it curates. One version-collapse rule
113
+ // either way — a second copy would be a second chance to disagree about which
114
+ // version of a plugin is the deployed one.
111
115
  let latestByName = (
112
116
  items: array<dict<JSON.t>>,
113
117
  ~nameVersionOf: dict<JSON.t> => option<string>,
114
- ~toEntry: (dict<JSON.t>, ~name: string) => option<JSON.t>,
115
- ): array<JSON.t> => {
118
+ ~toEntry: (dict<JSON.t>, ~name: string) => option<'entry>,
119
+ ): array<'entry> => {
116
120
  // bare plugin name -> (version, entry); highest version wins.
117
- let latest: dict<(string, JSON.t)> = Dict.make()
121
+ let latest: dict<(string, 'entry)> = Dict.make()
118
122
  items->Array.forEach(item =>
119
123
  switch item->nameVersionOf {
120
124
  | None => ()
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
4
5
  import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
5
6
  import * as ClientDynamodb from "@aws-sdk/client-dynamodb";
6
7
 
@@ -83,7 +84,7 @@ function latestByName(items, nameVersionOf, toEntry) {
83
84
  }
84
85
  latest[name] = [
85
86
  version,
86
- entry
87
+ Primitive_option.valFromOption(entry)
87
88
  ];
88
89
  });
89
90
  return Object.values(latest).map(param => param[1]);
@@ -37,6 +37,22 @@ export function response(ctx) {
37
37
  }
38
38
  `->Pulumi.Input.make
39
39
 
40
+ /**
41
+ What the platform needs back to finish wiring the bake.
42
+
43
+ The manifest is written into the host-UI bucket, and that bucket is created in a
44
+ later half of the same deploy — so the grant cannot be attached here. Handing the
45
+ role back is what lets the caller attach it at the point both facts are known,
46
+ rather than widening this function's write scope to "some bucket, later".
47
+
48
+ `functionName` is exported so the post-deploy step has something to invoke
49
+ without guessing at a generated name.
50
+ */
51
+ type t = {
52
+ roleId: Pulumi.Output.t<string>,
53
+ functionName: Pulumi.Output.t<string>,
54
+ }
55
+
40
56
  let make = (
41
57
  ~api: Pulumi.Output.t<AppSync.GraphQLApi.t>,
42
58
  ~pluginReadModelTableName: Pulumi.Output.t<string>,
@@ -47,8 +63,13 @@ let make = (
47
63
  // first ships it. `Platform_ComponentDefinitions` predates the gate and is left
48
64
  // ungated so this change adds no dependency edge to an already-deployed resource.
49
65
  ~schemaReady: Pulumi.Output.t<unit>,
66
+ // The deployment's include-list, when it declares one. Carried into the
67
+ // function's environment so the bake curates what the platform program
68
+ // declared and not what its caller asked for — the invocation supplies only
69
+ // where to write.
70
+ ~bakedManifest: option<ReventlessInfra.Platform.bakedManifest>=?,
50
71
  ~opts: Pulumi.ComponentResource.options,
51
- ) => {
72
+ ): t => {
52
73
  let opts = opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
53
74
  let name = "PlatformUIDefinitions"
54
75
 
@@ -106,6 +127,28 @@ let make = (
106
127
  )
107
128
  })
108
129
 
130
+ // The include-list as the handler reads it. Encoded here rather than passed
131
+ // as a record so the deploy input and the runtime input are the same shape —
132
+ // `views`/`commands` absent means "every public component", which a record
133
+ // with explicit nulls would not say.
134
+ let bakeSelectionsJson = switch bakedManifest {
135
+ | None => ""
136
+ | Some(bake) =>
137
+ bake.components
138
+ ->Array.map(sel => {
139
+ let entry = Dict.fromArray([("plugin", JSON.Encode.string(sel.plugin))])
140
+ let strings = (key, value) =>
141
+ value->Option.forEach(names =>
142
+ entry->Dict.set(key, names->Array.map(JSON.Encode.string)->JSON.Encode.array)
143
+ )
144
+ strings("views", sel.views)
145
+ strings("commands", sel.commands)
146
+ entry->JSON.Encode.object
147
+ })
148
+ ->JSON.Encode.array
149
+ ->JSON.stringify
150
+ }
151
+
109
152
  let adminEntryJson =
110
153
  ReventlessCore.Platform_ComponentDefinitionsApi.encodePluginStructureEntry(
111
154
  ~pluginId=ReventlessCore.Platform_Admin_Structure.pluginId,
@@ -167,6 +210,11 @@ let make = (
167
210
  ("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
168
211
  ("PLUGIN_RM_TABLE", pluginReadModelTableName->Pulumi.Output.asInput),
169
212
  ("OFFLOAD_BUCKET", offloadBucketName->Pulumi.Output.asInput),
213
+ // Absent declaration ⇒ empty string ⇒ a bake invocation fails saying
214
+ // this platform declares none, rather than writing an empty shop.
215
+ // An include-list is names only, so it stays far from the 4096-byte
216
+ // total the admin entry already pushed out of this dict.
217
+ ("BAKE_SELECTIONS", bakeSelectionsJson->Pulumi.Input.make),
170
218
  ("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
171
219
  ("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
172
220
  Util_LambdaLogging.logLevelEntry(),
@@ -256,4 +304,6 @@ let make = (
256
304
  ~opts,
257
305
  )
258
306
  )
307
+
308
+ {roleId: lambdaRole.id, functionName: lambda.name}
259
309
  }
@@ -38,7 +38,7 @@ export function response(ctx) {
38
38
  }
39
39
  `;
40
40
 
41
- function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opts) {
41
+ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bakedManifest, opts) {
42
42
  let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
43
43
  let name = "PlatformUIDefinitions";
44
44
  let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "Lambda", Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name + "Lambda", "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
@@ -70,6 +70,18 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
70
70
  role: lambdaRole.id
71
71
  }, opts$1);
72
72
  });
73
+ let bakeSelectionsJson = bakedManifest !== undefined ? JSON.stringify(bakedManifest.components.map(sel => {
74
+ let entry = Object.fromEntries([[
75
+ "plugin",
76
+ sel.plugin
77
+ ]]);
78
+ let strings = (key, value) => Stdlib_Option.forEach(value, names => {
79
+ entry[key] = names.map(prim => prim);
80
+ });
81
+ strings("views", sel.views);
82
+ strings("commands", sel.commands);
83
+ return entry;
84
+ })) : "";
73
85
  let adminEntryJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(Platform_Admin_Structure$ReventlessCore.pluginId, Platform_Admin_Structure$ReventlessCore.structure));
74
86
  let packageDirs = Object.fromEntries([[
75
87
  "@reventlessdev/reventless-aws",
@@ -104,6 +116,10 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
104
116
  "OFFLOAD_BUCKET",
105
117
  offloadBucketName
106
118
  ],
119
+ [
120
+ "BAKE_SELECTIONS",
121
+ bakeSelectionsJson
122
+ ],
107
123
  [
108
124
  "NODE_OPTIONS",
109
125
  Util_Bundle$ReventlessAws.esmLoaderNodeOptions
@@ -143,6 +159,10 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
143
159
  }, opts$1);
144
160
  AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "Resolver", api, dataSource.name, "Query", "Platform_ComponentDefinitions", resolverCode, opts$1);
145
161
  schemaReady.apply(() => AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "StructuresResolver", api, dataSource.name, "Query", "Platform_PluginStructures", completeResolverCode, opts$1));
162
+ return {
163
+ roleId: lambdaRole.id,
164
+ functionName: lambda.name
165
+ };
146
166
  }
147
167
 
148
168
  export {
@@ -236,14 +236,162 @@ let isComplete = (event: JSON.t): bool =>
236
236
  ->Option.flatMap(JSON.Decode.bool)
237
237
  ->Option.getOr(false)
238
238
 
239
+ // ── Bake mode ────────────────────────────────────────────────────────────────
240
+ // The third thing this function does with the same scan: write the curated
241
+ // manifest as a static object instead of answering a query with it.
242
+ //
243
+ // It belongs here rather than in a tool of its own for the reason the two GraphQL
244
+ // fields already share a data source — there is one place that decides what a
245
+ // deployed plugin's structure is, and the scan, the offload resolution and the
246
+ // version collapse are that decision. A separate reader would be a second copy of
247
+ // all three, free to drift about which version of a plugin is deployed.
248
+ //
249
+ // Invoked directly, after every plugin stack is up: the manifest describes the
250
+ // whole deployment, and no single stack's deploy is the moment that is settled.
251
+
252
+ // The persisted structure is served raw on the query paths — nothing decodes it —
253
+ // but curation filters the RECORD and re-encodes through the shared encoder,
254
+ // which is what makes an include-list naming everything produce the bytes the
255
+ // query returns. So the bake decodes, and heals first: healing fills the list
256
+ // fields an older persisted structure simply has no key for, and those are
257
+ // exactly the fields the schema requires.
258
+ //
259
+ // A structure that still fails to decode fails the bake, naming the plugin. The
260
+ // alternative — skipping it — ships a shop silently missing a section.
261
+ let structureOf = (item: dict<JSON.t>, ~name as _: string): option<(
262
+ string,
263
+ Reventless.Plugin.pluginStructure,
264
+ )> =>
265
+ switch (item->str("name"), item->Dict.get("structure")->Option.flatMap(JSON.Decode.object)) {
266
+ | (Some(pluginId), Some(structure)) =>
267
+ let healed = structure->healStructure->JSON.Encode.object
268
+ switch healed->S.parseJsonOrThrow(Reventless.Plugin.pluginStructureSchema) {
269
+ | decoded => Some((pluginId, decoded))
270
+ | exception _ =>
271
+ JsError.throwWithMessage(
272
+ `baked manifest: the structure persisted for "${pluginId}" cannot be decoded — ` ++
273
+ `it was written by a framework version this platform can no longer read. ` ++
274
+ `Redeploy that plugin, or drop it from the include-list.`,
275
+ )
276
+ }
277
+ | _ => None
278
+ }
279
+
280
+ let bakeSelection = (json: JSON.t): option<ReventlessCore.Platform_BakedManifest.selection> => {
281
+ let strings = (o, key) =>
282
+ o
283
+ ->Dict.get(key)
284
+ ->Option.flatMap(JSON.Decode.array)
285
+ ->Option.map(a => a->Array.filterMap(JSON.Decode.string))
286
+ json
287
+ ->JSON.Decode.object
288
+ ->Option.flatMap(o =>
289
+ o
290
+ ->Dict.get("plugin")
291
+ ->Option.flatMap(JSON.Decode.string)
292
+ ->Option.map(plugin => {
293
+ ReventlessCore.Platform_BakedManifest.plugin,
294
+ views: strings(o, "views"),
295
+ commands: strings(o, "commands"),
296
+ })
297
+ )
298
+ }
299
+
300
+ // The declaration travels as an env var because it is deploy input — stated once
301
+ // in the platform program, beside the bucket it is written to. The target travels
302
+ // in the invocation payload instead: the bucket only exists inside the host-UI
303
+ // half of the deploy, long after this function is built, and a caller that had to
304
+ // know the include-list as well would be free to bake something the deployment
305
+ // never declared.
306
+ let bakeSelections = (): array<ReventlessCore.Platform_BakedManifest.selection> =>
307
+ switch NodeProcess.env->Dict.get("BAKE_SELECTIONS") {
308
+ | None | Some("") => []
309
+ | Some(raw) =>
310
+ switch raw->JSON.parseOrThrow->JSON.Decode.array {
311
+ | Some(entries) => entries->Array.filterMap(bakeSelection)
312
+ | None => []
313
+ | exception _ =>
314
+ JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array")
315
+ }
316
+ }
317
+
318
+ type bakeTarget = {bucket: string, key: string}
319
+
320
+ let bakeTargetOf = (event: JSON.t): option<bakeTarget> =>
321
+ event
322
+ ->JSON.Decode.object
323
+ ->Option.flatMap(o =>
324
+ switch (
325
+ o->Dict.get("bake")->Option.flatMap(JSON.Decode.bool),
326
+ o->Dict.get("bucket")->Option.flatMap(JSON.Decode.string),
327
+ ) {
328
+ | (Some(true), Some(bucket)) =>
329
+ Some({
330
+ bucket,
331
+ key: o
332
+ ->Dict.get("key")
333
+ ->Option.flatMap(JSON.Decode.string)
334
+ ->Option.getOr(ReventlessCore.Platform_BakedManifest.defaultKey),
335
+ })
336
+ | _ => None
337
+ }
338
+ )
339
+
340
+ // Every failure mode here is the deployment's own mistake — a name matching no
341
+ // component, a structure too old to read, a bucket the function may not write —
342
+ // and every one of them produces the same symptom if swallowed: a shop that
343
+ // renders nothing, with no line anywhere saying why. So the bake throws, and the
344
+ // pipeline step that invoked it fails.
345
+ let runBake = async (
346
+ ~target: bakeTarget,
347
+ ~structures: array<(string, Reventless.Plugin.pluginStructure)>,
348
+ ): array<JSON.t> => {
349
+ let selections = bakeSelections()
350
+ if selections->Array.length == 0 {
351
+ JsError.throwWithMessage(
352
+ "baked manifest: BAKE_SELECTIONS is empty — this platform declares no bake, " ++
353
+ "so there is nothing to write and a shell pointed at the file would find none.",
354
+ )
355
+ }
356
+ switch ReventlessCore.Platform_BakedManifest.curate(~structures, ~selections) {
357
+ | Error(e) =>
358
+ JsError.throwWithMessage(ReventlessCore.Platform_BakedManifest.describe(e))
359
+ | Ok(manifest) =>
360
+ let body = JSON.stringify(manifest, ~space=2)
361
+ let _ = await AwsSdk.S3.PutObjectCommand.make({
362
+ bucket: target.bucket,
363
+ key: target.key,
364
+ body: AwsSdk.S3.PutObjectCommand.bodyFromString(body),
365
+ contentType: "application/json",
366
+ })->AwsSdk.S3.PutObjectCommand.send
367
+ [
368
+ Dict.fromArray([
369
+ ("baked", JSON.Encode.bool(true)),
370
+ ("bucket", JSON.Encode.string(target.bucket)),
371
+ ("key", JSON.Encode.string(target.key)),
372
+ ("plugins", JSON.Encode.int(selections->Array.length)),
373
+ ("bytes", JSON.Encode.int(body->String.length)),
374
+ ])->JSON.Encode.object,
375
+ ]
376
+ }
377
+ }
378
+
239
379
  let handler = async (event: JSON.t): array<JSON.t> => {
240
380
  let complete = isComplete(event)
381
+ let bakeTarget = bakeTargetOf(event)
241
382
  let toEntry = (item, ~name) => toEntryWith(~filter=!complete, item, ~name)
242
383
  let admin = adminEntry->Option.mapOr([], e => [e])
243
384
  switch NodeProcess.env->Dict.get("PLUGIN_RM_TABLE") {
244
385
  | None | Some("") =>
245
386
  Console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set")
246
- admin
387
+ switch bakeTarget {
388
+ | Some(_) =>
389
+ JsError.throwWithMessage(
390
+ "baked manifest: PLUGIN_RM_TABLE env var not set — the bake would write an " ++
391
+ "empty shop rather than fail.",
392
+ )
393
+ | None => admin
394
+ }
247
395
  | Some(table) =>
248
396
  let rawItems = await Platform_AdminScan_Ops.scanAll(
249
397
  ~tableName=table,
@@ -257,8 +405,25 @@ let handler = async (event: JSON.t): array<JSON.t> => {
257
405
  AwsSdk.S3.GetObjectCommand.getString(~bucket, ~key)
258
406
  )
259
407
  let items = await Promise.all(rawItems->Array.map(item => resolveStructure(fetch, item)))
260
- let userEntries =
261
- Platform_AdminScan_Ops.latestByName(items, ~nameVersionOf=item => item->str("name"), ~toEntry)
262
- Array.concat(admin, userEntries)
408
+ switch bakeTarget {
409
+ | Some(target) =>
410
+ // The built-in admin entry is deliberately absent: it never enters the
411
+ // Plugin read model, and the in-memory bake curates the composed plugins
412
+ // only. A deployment naming it gets `UnknownPlugin`, on both platforms.
413
+ let structures = Platform_AdminScan_Ops.latestByName(
414
+ items,
415
+ ~nameVersionOf=item => item->str("name"),
416
+ ~toEntry=structureOf,
417
+ )
418
+ await runBake(~target, ~structures)
419
+ | None =>
420
+ let userEntries =
421
+ Platform_AdminScan_Ops.latestByName(
422
+ items,
423
+ ~nameVersionOf=item => item->str("name"),
424
+ ~toEntry,
425
+ )
426
+ Array.concat(admin, userEntries)
427
+ }
263
428
  }
264
429
  }
@@ -1,12 +1,18 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as S from "sury/src/S.res.mjs";
3
4
  import * as Nodefs from "node:fs";
4
5
  import * as S3$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/S3.res.mjs";
5
6
  import * as Nodepath from "node:path";
6
7
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
8
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
7
9
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
10
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
11
+ import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
12
+ import * as ClientS3 from "@aws-sdk/client-s3";
8
13
  import * as Offload$Reventless from "@reventlessdev/reventless-spec/src/semantic/Offload.res.mjs";
9
14
  import * as Platform_AdminScan_Ops$ReventlessAws from "./Platform_AdminScan_Ops.res.mjs";
15
+ import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
10
16
 
11
17
  function str(item, key) {
12
18
  return Stdlib_Option.flatMap(item[key], Stdlib_JSON.Decode.string);
@@ -224,16 +230,118 @@ function isComplete(event) {
224
230
  return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["complete"]), Stdlib_JSON.Decode.bool), false);
225
231
  }
226
232
 
233
+ function structureOf(item, param) {
234
+ let match = str(item, "name");
235
+ let match$1 = Stdlib_Option.flatMap(item["structure"], Stdlib_JSON.Decode.object);
236
+ if (match === undefined) {
237
+ return;
238
+ }
239
+ if (match$1 === undefined) {
240
+ return;
241
+ }
242
+ let healed = healStructure(match$1);
243
+ let decoded;
244
+ try {
245
+ decoded = S.parseJsonOrThrow(healed, Plugin$Reventless.pluginStructureSchema);
246
+ } catch (exn) {
247
+ return Stdlib_JsError.throwWithMessage(`baked manifest: the structure persisted for "` + match + `" cannot be decoded — it was written by a framework version this platform can no longer read. Redeploy that plugin, or drop it from the include-list.`);
248
+ }
249
+ return [
250
+ match,
251
+ decoded
252
+ ];
253
+ }
254
+
255
+ function bakeSelection(json) {
256
+ let strings = (o, key) => Stdlib_Option.map(Stdlib_Option.flatMap(o[key], Stdlib_JSON.Decode.array), a => Stdlib_Array.filterMap(a, Stdlib_JSON.Decode.string));
257
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => Stdlib_Option.map(Stdlib_Option.flatMap(o["plugin"], Stdlib_JSON.Decode.string), plugin => ({
258
+ plugin: plugin,
259
+ views: strings(o, "views"),
260
+ commands: strings(o, "commands")
261
+ })));
262
+ }
263
+
264
+ function bakeSelections() {
265
+ let raw = process.env["BAKE_SELECTIONS"];
266
+ if (raw === undefined) {
267
+ return [];
268
+ }
269
+ if (raw === "") {
270
+ return [];
271
+ }
272
+ let entries;
273
+ try {
274
+ entries = Stdlib_JSON.Decode.array(JSON.parse(raw));
275
+ } catch (exn) {
276
+ return Stdlib_JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array");
277
+ }
278
+ if (entries !== undefined) {
279
+ return Stdlib_Array.filterMap(entries, bakeSelection);
280
+ } else {
281
+ return [];
282
+ }
283
+ }
284
+
285
+ function bakeTargetOf(event) {
286
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => {
287
+ let match = Stdlib_Option.flatMap(o["bake"], Stdlib_JSON.Decode.bool);
288
+ let match$1 = Stdlib_Option.flatMap(o["bucket"], Stdlib_JSON.Decode.string);
289
+ if (match !== undefined && match && match$1 !== undefined) {
290
+ return {
291
+ bucket: match$1,
292
+ key: Stdlib_Option.getOr(Stdlib_Option.flatMap(o["key"], Stdlib_JSON.Decode.string), Platform_BakedManifest$ReventlessCore.defaultKey)
293
+ };
294
+ }
295
+ });
296
+ }
297
+
298
+ async function runBake(target, structures) {
299
+ let selections = bakeSelections();
300
+ if (selections.length === 0) {
301
+ 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.");
302
+ }
303
+ let e = Platform_BakedManifest$ReventlessCore.curate(structures, selections);
304
+ if (e.TAG !== "Ok") {
305
+ return Stdlib_JsError.throwWithMessage(Platform_BakedManifest$ReventlessCore.describe(e._0));
306
+ }
307
+ let body = JSON.stringify(e._0, undefined, 2);
308
+ await S3$AwsSdk.PutObjectCommand.send(new ClientS3.PutObjectCommand({
309
+ Bucket: target.bucket,
310
+ Key: target.key,
311
+ Body: body,
312
+ ContentType: "application/json"
313
+ }));
314
+ return [Object.fromEntries([
315
+ [
316
+ "baked",
317
+ true
318
+ ],
319
+ [
320
+ "bucket",
321
+ target.bucket
322
+ ],
323
+ [
324
+ "key",
325
+ target.key
326
+ ],
327
+ [
328
+ "plugins",
329
+ selections.length
330
+ ],
331
+ [
332
+ "bytes",
333
+ body.length
334
+ ]
335
+ ])];
336
+ }
337
+
227
338
  async function handler(event) {
228
339
  let complete = isComplete(event);
340
+ let bakeTarget = bakeTargetOf(event);
229
341
  let toEntry = (item, name) => toEntryWith(!complete, item, name);
230
342
  let admin = Stdlib_Option.mapOr(adminEntry, [], e => [e]);
231
343
  let table = process.env["PLUGIN_RM_TABLE"];
232
- if (table !== undefined) {
233
- if (table === "") {
234
- console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set");
235
- return admin;
236
- }
344
+ if (table !== undefined && table !== "") {
237
345
  let rawItems = await Platform_AdminScan_Ops$ReventlessAws.scanAll(table, "contains(#status, :connected)", Object.fromEntries([[
238
346
  "#status",
239
347
  "status"
@@ -244,11 +352,19 @@ async function handler(event) {
244
352
  let bucket = Stdlib_Option.getOr(process.env["OFFLOAD_BUCKET"], "");
245
353
  let fetch = Offload$Reventless.cachedFetch(key => S3$AwsSdk.GetObjectCommand.getString(bucket, key));
246
354
  let items = await Promise.all(rawItems.map(item => resolveStructure(fetch, item)));
355
+ if (bakeTarget !== undefined) {
356
+ let structures = Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), structureOf);
357
+ return await runBake(bakeTarget, structures);
358
+ }
247
359
  let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), toEntry);
248
360
  return admin.concat(userEntries);
249
361
  }
250
362
  console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set");
251
- return admin;
363
+ if (bakeTarget !== undefined) {
364
+ return Stdlib_JsError.throwWithMessage("baked manifest: PLUGIN_RM_TABLE env var not set — the bake would write an empty shop rather than fail.");
365
+ } else {
366
+ return admin;
367
+ }
252
368
  }
253
369
 
254
370
  export {
@@ -266,6 +382,11 @@ export {
266
382
  adminEntry,
267
383
  resolveStructure,
268
384
  isComplete,
385
+ structureOf,
386
+ bakeSelection,
387
+ bakeSelections,
388
+ bakeTargetOf,
389
+ runBake,
269
390
  handler,
270
391
  }
271
392
  /* adminEntry Not a pure module */
@@ -32,18 +32,33 @@ The config.json field set.
32
32
 
33
33
  `computed` arrives in wire order and keeps it. `viewModes` unset ⇒ no
34
34
  `viewModes` key and no per-mode key, so a deployment that wants no optional mode
35
- gets byte-identical output to before this input existed. `shellConfig` merges in
36
- last; a key it shares with one already present fails the deploy naming it,
37
- because silently resolving the collision either way produces an app pointed
35
+ gets byte-identical output to before this input existed. `bakedManifest` unset
36
+ no `manifestUrl`, and every caller keeps the admin discovery path. `shellConfig`
37
+ merges in last; a key it shares with one already present fails the deploy naming
38
+ it, because silently resolving the collision either way produces an app pointed
38
39
  somewhere unintended with nothing in the diff to say so.
39
40
  */
40
41
  let fields = (
41
42
  ~computed: array<(string, JSON.t)>,
42
43
  ~viewModes: option<array<Platform.viewMode>>=?,
44
+ ~bakedManifest: option<Platform.bakedManifest>=?,
43
45
  ~shellConfig: option<dict<JSON.t>>=?,
44
46
  ): dict<JSON.t> => {
45
47
  let out = computed->Dict.fromArray
46
48
 
49
+ // A declared bake is what turns the shell's non-elevated audience on: an
50
+ // operator keeps the admin queries, everyone else discovers from this file.
51
+ // Computed rather than left to `shellConfig` because the deploy is what
52
+ // decides where the file goes — a passthrough could point the shell at a key
53
+ // nothing writes, and a statically-discovered shell has no admin API behind it
54
+ // to notice.
55
+ bakedManifest->Option.forEach(bake =>
56
+ out->Dict.set(
57
+ "manifestUrl",
58
+ JSON.Encode.string(ReventlessCore.Platform_BakedManifest.urlForKey(bake.key)),
59
+ )
60
+ )
61
+
47
62
  switch viewModes {
48
63
  | Some(modes) =>
49
64
  out->Dict.set(
@@ -4,6 +4,7 @@ import * as Pervasives from "@rescript/runtime/lib/es6/Pervasives.js";
4
4
  import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
5
5
  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
+ import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
7
8
 
8
9
  function modeOptions(mode) {
9
10
  if (mode.TAG === "Map") {
@@ -28,8 +29,11 @@ function modeOptions(mode) {
28
29
  }
29
30
  }
30
31
 
31
- function fields(computed, viewModes, shellConfig) {
32
+ function fields(computed, viewModes, bakedManifest, shellConfig) {
32
33
  let out = Object.fromEntries(computed);
34
+ Stdlib_Option.forEach(bakedManifest, bake => {
35
+ out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(bake.key);
36
+ });
33
37
  if (viewModes !== undefined) {
34
38
  out["viewModes"] = viewModes.map(Platform$ReventlessInfra.viewModeToString);
35
39
  viewModes.forEach(m => {
@@ -176,3 +176,67 @@ describe("toEntryWith carries Internal queryables on the filtered field", () =>
176
176
  expect(names)->toEqual([])
177
177
  })
178
178
  })
179
+
180
+ // ── Bake mode ────────────────────────────────────────────────────────────────
181
+ // The same scan, written out instead of answered with. Two inputs decide what a
182
+ // bake does and neither is checkable at deploy time: the include-list the
183
+ // platform put in the environment, and the target its caller supplied. Both are
184
+ // pure, so both are assertable without a Lambda.
185
+
186
+ describe("bakeTargetOf", () => {
187
+ let target = raw => Platform_ComponentDefinitions_Lambda_Ops.bakeTargetOf(JSON.parseOrThrow(raw))
188
+
189
+ // The read paths must stay read paths: an AppSync resolver invokes with
190
+ // `{}` or `{complete: true}`, and neither may write an object.
191
+ testSync("a query invocation is not a bake", () => {
192
+ expect(target(`{}`)->Option.isNone)->toBe(true)
193
+ expect(target(`{"complete": true}`)->Option.isNone)->toBe(true)
194
+ })
195
+
196
+ // No bucket, no default. Guessing one would write the manifest somewhere
197
+ // nothing serves it, which reads as a bake that worked.
198
+ testSync("a bake without a target is not a bake", () =>
199
+ expect(target(`{"bake": true}`)->Option.isNone)->toBe(true)
200
+ )
201
+
202
+ testSync("defaults the key to the one the deploy tells the shell to fetch", () =>
203
+ expect(target(`{"bake": true, "bucket": "host-ui"}`)->Option.map(t => t.key))->toEqual(
204
+ Some("component-manifest.json"),
205
+ )
206
+ )
207
+
208
+ testSync("takes an explicit key", () =>
209
+ expect(
210
+ target(`{"bake": true, "bucket": "host-ui", "key": "storefront.json"}`)->Option.map(t => (
211
+ t.bucket,
212
+ t.key,
213
+ )),
214
+ )->toEqual(Some(("host-ui", "storefront.json")))
215
+ )
216
+ })
217
+
218
+ describe("bakeSelection", () => {
219
+ let sel = raw => Platform_ComponentDefinitions_Lambda_Ops.bakeSelection(JSON.parseOrThrow(raw))
220
+
221
+ // Absent is not empty. `views` absent means every public component of that
222
+ // plugin; `views: []` means none of them, and a decoder that collapsed the two
223
+ // would silently bake an empty section.
224
+ testSync("keeps absent and empty apart", () => {
225
+ expect(sel(`{"plugin": "Catalog"}`)->Option.flatMap(s => s.views))->toEqual(None)
226
+ expect(sel(`{"plugin": "Catalog", "views": []}`)->Option.flatMap(s => s.views))->toEqual(
227
+ Some([]),
228
+ )
229
+ })
230
+
231
+ testSync("reads the named components", () =>
232
+ expect(
233
+ sel(`{"plugin": "Ordering", "views": ["Orders"], "commands": ["PlaceOrder"]}`)->Option.map(
234
+ s => (s.plugin, s.views, s.commands),
235
+ ),
236
+ )->toEqual(Some(("Ordering", Some(["Orders"]), Some(["PlaceOrder"]))))
237
+ )
238
+
239
+ testSync("refuses an entry naming no plugin", () =>
240
+ expect(sel(`{"views": ["Orders"]}`)->Option.isNone)->toBe(true)
241
+ )
242
+ })
@@ -108,6 +108,49 @@ globalThis.describe("toEntryWith carries Internal queryables on the filtered fie
108
108
  });
109
109
  });
110
110
 
111
+ globalThis.describe("bakeTargetOf", () => {
112
+ globalThis.test("a query invocation is not a bake", () => {
113
+ globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeTargetOf(JSON.parse(`{}`)))).toBe(true);
114
+ globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeTargetOf(JSON.parse(`{"complete": true}`)))).toBe(true);
115
+ });
116
+ globalThis.test("a bake without a target is not a bake", () => {
117
+ globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeTargetOf(JSON.parse(`{"bake": true}`)))).toBe(true);
118
+ });
119
+ globalThis.test("defaults the key to the one the deploy tells the shell to fetch", () => {
120
+ globalThis.expect(Stdlib_Option.map(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeTargetOf(JSON.parse(`{"bake": true, "bucket": "host-ui"}`)), t => t.key)).toEqual("component-manifest.json");
121
+ });
122
+ globalThis.test("takes an explicit key", () => {
123
+ globalThis.expect(Stdlib_Option.map(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeTargetOf(JSON.parse(`{"bake": true, "bucket": "host-ui", "key": "storefront.json"}`)), t => [
124
+ t.bucket,
125
+ t.key
126
+ ])).toEqual([
127
+ "host-ui",
128
+ "storefront.json"
129
+ ]);
130
+ });
131
+ });
132
+
133
+ globalThis.describe("bakeSelection", () => {
134
+ globalThis.test("keeps absent and empty apart", () => {
135
+ globalThis.expect(Stdlib_Option.flatMap(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeSelection(JSON.parse(`{"plugin": "Catalog"}`)), s => s.views)).toEqual(undefined);
136
+ globalThis.expect(Stdlib_Option.flatMap(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeSelection(JSON.parse(`{"plugin": "Catalog", "views": []}`)), s => s.views)).toEqual([]);
137
+ });
138
+ globalThis.test("reads the named components", () => {
139
+ globalThis.expect(Stdlib_Option.map(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeSelection(JSON.parse(`{"plugin": "Ordering", "views": ["Orders"], "commands": ["PlaceOrder"]}`)), s => [
140
+ s.plugin,
141
+ s.views,
142
+ s.commands
143
+ ])).toEqual([
144
+ "Ordering",
145
+ ["Orders"],
146
+ ["PlaceOrder"]
147
+ ]);
148
+ });
149
+ globalThis.test("refuses an entry naming no plugin", () => {
150
+ globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeSelection(JSON.parse(`{"views": ["Orders"]}`)))).toBe(true);
151
+ });
152
+ });
153
+
111
154
  export {
112
155
  entry,
113
156
  members,
@@ -40,6 +40,47 @@ describe("Util_ShellConfig.fields — viewModes", () => {
40
40
  })
41
41
  })
42
42
 
43
+ describe("Util_ShellConfig.fields — bakedManifest", () => {
44
+ let bake = (~key: option<string>=?): ReventlessInfra.Platform.bakedManifest => {
45
+ components: [{plugin: "Catalog", views: ["Products"], commands: []}],
46
+ key: ?key,
47
+ }
48
+
49
+ testSync("unset ⇒ no manifestUrl, so every caller keeps the admin path", () => {
50
+ let out = Util_ShellConfig.fields(~computed)
51
+ expect(out->Dict.get("manifestUrl")->Option.isNone)->toBe(true)
52
+ })
53
+
54
+ testSync("declared ⇒ the URL the deploy writes the file to", () => {
55
+ let out = Util_ShellConfig.fields(~computed, ~bakedManifest=bake())
56
+ expect(out->get("manifestUrl"))->toEqual(JSON.Encode.string("/component-manifest.json"))
57
+ })
58
+
59
+ // The key and the URL are one string; a renamed bake that kept the default
60
+ // URL would 404 on a shell with no admin API to fall back to.
61
+ testSync("follows a renamed bake", () => {
62
+ let out = Util_ShellConfig.fields(~computed, ~bakedManifest=bake(~key="storefront.json"))
63
+ expect(out->get("manifestUrl"))->toEqual(JSON.Encode.string("/storefront.json"))
64
+ })
65
+
66
+ testSync("a shellConfig manifestUrl fails the deploy rather than redirecting it", () => {
67
+ let failure = try {
68
+ let _ = Util_ShellConfig.fields(
69
+ ~computed,
70
+ ~bakedManifest=bake(),
71
+ ~shellConfig=Dict.fromArray([("manifestUrl", JSON.Encode.string("/elsewhere.json"))]),
72
+ )
73
+ None
74
+ } catch {
75
+ | Failure(message) => Some(message)
76
+ }
77
+ switch failure {
78
+ | Some(message) => expect(message->String.includes("manifestUrl"))->toBe(true)
79
+ | None => fail("a shellConfig key redirecting the computed manifest must fail the deploy")
80
+ }
81
+ })
82
+ })
83
+
43
84
  describe("Util_ShellConfig.fields — shellConfig passthrough", () => {
44
85
  testSync("shell-owned keys land verbatim, under the computed ones", () => {
45
86
  let out = Util_ShellConfig.fields(
@@ -22,7 +22,7 @@ function get(out, key) {
22
22
 
23
23
  globalThis.describe("Util_ShellConfig.fields — viewModes", () => {
24
24
  globalThis.test("unset ⇒ no viewModes key and the computed set is untouched", () => {
25
- let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, undefined);
25
+ let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, undefined, undefined);
26
26
  globalThis.expect(Object.keys(out)).toEqual([
27
27
  "apiEndpoint",
28
28
  "region"
@@ -33,7 +33,7 @@ globalThis.describe("Util_ShellConfig.fields — viewModes", () => {
33
33
  let out = Util_ShellConfig$ReventlessAws.fields(computed, [{
34
34
  TAG: "Map",
35
35
  _0: {}
36
- }], undefined);
36
+ }], undefined, undefined);
37
37
  globalThis.expect(get(out, "viewModes")).toEqual(["map"]);
38
38
  globalThis.expect(Stdlib_Option.isNone(out["mapStyle"])).toBe(true);
39
39
  });
@@ -51,7 +51,7 @@ globalThis.describe("Util_ShellConfig.fields — viewModes", () => {
51
51
  layout: "dagre"
52
52
  }
53
53
  }
54
- ], undefined);
54
+ ], undefined, undefined);
55
55
  globalThis.expect(get(out, "viewModes")).toEqual([
56
56
  "map",
57
57
  "graph"
@@ -61,9 +61,55 @@ globalThis.describe("Util_ShellConfig.fields — viewModes", () => {
61
61
  });
62
62
  });
63
63
 
64
+ globalThis.describe("Util_ShellConfig.fields — bakedManifest", () => {
65
+ let bake = key => ({
66
+ components: [{
67
+ plugin: "Catalog",
68
+ views: ["Products"],
69
+ commands: []
70
+ }],
71
+ key: key
72
+ });
73
+ globalThis.test("unset ⇒ no manifestUrl, so every caller keeps the admin path", () => {
74
+ let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, undefined, undefined);
75
+ globalThis.expect(Stdlib_Option.isNone(out["manifestUrl"])).toBe(true);
76
+ });
77
+ globalThis.test("declared ⇒ the URL the deploy writes the file to", () => {
78
+ let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, bake(undefined), undefined);
79
+ globalThis.expect(get(out, "manifestUrl")).toEqual("/component-manifest.json");
80
+ });
81
+ globalThis.test("follows a renamed bake", () => {
82
+ let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, bake("storefront.json"), undefined);
83
+ globalThis.expect(get(out, "manifestUrl")).toEqual("/storefront.json");
84
+ });
85
+ globalThis.test("a shellConfig manifestUrl fails the deploy rather than redirecting it", () => {
86
+ let failure;
87
+ try {
88
+ Util_ShellConfig$ReventlessAws.fields(computed, undefined, bake(undefined), Object.fromEntries([[
89
+ "manifestUrl",
90
+ "/elsewhere.json"
91
+ ]]));
92
+ failure = undefined;
93
+ } catch (raw_message) {
94
+ let message = Primitive_exceptions.internalToException(raw_message);
95
+ if (message.RE_EXN_ID === "Failure") {
96
+ failure = message._1;
97
+ } else {
98
+ throw message;
99
+ }
100
+ }
101
+ if (failure !== undefined) {
102
+ globalThis.expect(failure.includes("manifestUrl")).toBe(true);
103
+ return;
104
+ } else {
105
+ return JestGlobals.fail("a shellConfig key redirecting the computed manifest must fail the deploy");
106
+ }
107
+ });
108
+ });
109
+
64
110
  globalThis.describe("Util_ShellConfig.fields — shellConfig passthrough", () => {
65
111
  globalThis.test("shell-owned keys land verbatim, under the computed ones", () => {
66
- let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, Object.fromEntries([
112
+ let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, undefined, Object.fromEntries([
67
113
  [
68
114
  "platformName",
69
115
  "Online Shop"
@@ -84,7 +130,7 @@ globalThis.describe("Util_ShellConfig.fields — shellConfig passthrough", () =>
84
130
  globalThis.test("a key the deploy computes fails the deploy, naming it", () => {
85
131
  let failure;
86
132
  try {
87
- Util_ShellConfig$ReventlessAws.fields(computed, undefined, Object.fromEntries([[
133
+ Util_ShellConfig$ReventlessAws.fields(computed, undefined, undefined, Object.fromEntries([[
88
134
  "apiEndpoint",
89
135
  "https://wrong.example/graphql"
90
136
  ]]));