@reventlessdev/reventless-local 3.0.0-alpha.289 → 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.
Files changed (33) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +11 -11
  3. package/src/BakedManifest.res +7 -2
  4. package/src/BakedManifest.res.mjs +4 -4
  5. package/src/HostShellDist.res +11 -0
  6. package/src/HostShellDist.res.mjs +14 -1
  7. package/src/Platform.res +18 -6
  8. package/src/Platform.res.mjs +19 -16
  9. package/src/ShellConfig.res +27 -24
  10. package/src/ShellConfig.res.mjs +25 -13
  11. package/src/UiHints.res +3 -5
  12. package/src/UiSlots.res +2 -3
  13. package/tests/HostShellDistTest.res +70 -0
  14. package/tests/HostShellDistTest.res.mjs +75 -0
  15. package/tests/ShellConfigTest.res +45 -7
  16. package/tests/ShellConfigTest.res.mjs +45 -8
  17. package/tests/components/automationslice/AutomationRescheduleFixtures.res +0 -1
  18. package/tests/components/automationslice/AutomationRescheduleFixtures.res.mjs +0 -1
  19. package/tests/components/automationslice/AutomationScheduleFixtures.res +0 -1
  20. package/tests/components/automationslice/AutomationScheduleFixtures.res.mjs +0 -5
  21. package/tests/components/automationslice/AutomationSliceFixtures.res +0 -2
  22. package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +0 -2
  23. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +0 -1
  24. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +0 -1
  25. package/tests/components/automationslice/AutomationSweepFixtures.res +0 -1
  26. package/tests/components/automationslice/AutomationSweepFixtures.res.mjs +0 -1
  27. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +0 -1
  28. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +0 -1
  29. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +0 -2
  30. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res +0 -2
  31. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res.mjs +0 -2
  32. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +0 -1
  33. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +0 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
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-10-19)
7
+
8
+ ### Features
9
+
10
+ * **spec,aws,local:** tell the host shell which environment it runs in, and write local host UI files into a chosen assetsDir ([4f896d1](https://github.com/ReventlessDev/reventless-core/commit/4f896d13ae9ec0d211c33dbd9271e7197cfebc41))
11
+
12
+
13
+ # 3.0.0-alpha.290 (2026-10-17)
14
+
15
+ **Note:** Version bump only for package @reventlessdev/reventless-local
16
+
17
+
18
+
19
+
20
+
6
21
  # 3.0.0-alpha.289 (2026-10-16)
7
22
 
8
23
  * feat!(inbound): a translation slice can declare a signed webhook URL ([0ad6994](https://github.com/ReventlessDev/reventless-core/commit/0ad69949fcc91c2756c60ca8cb120dd468841d8b))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.289",
3
+ "version": "3.0.0-alpha.291",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -34,24 +34,24 @@
34
34
  "graphql-yoga": "^5.21.0",
35
35
  "sury": "11.0.0",
36
36
  "ws": "^8.18.0",
37
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.12",
37
38
  "@reventlessdev/rescript-effect": "1.0.0-alpha.0",
38
39
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.23",
39
40
  "@reventlessdev/rescript-node": "2.0.0-alpha.22",
40
- "@reventlessdev/rescript-jest": "1.0.0-alpha.12",
41
- "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
42
41
  "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
43
- "@reventlessdev/reventless-core": "3.0.0-alpha.298",
44
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.146",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.246",
46
- "@reventlessdev/reventless-infra": "3.0.0-alpha.195",
47
- "@reventlessdev/reventless-seed": "1.0.0-alpha.34",
48
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.162",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.167"
42
+ "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
43
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.148",
44
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.248",
45
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.164",
46
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.197",
47
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.169",
48
+ "@reventlessdev/reventless-core": "3.0.0-alpha.300",
49
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.34"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.1",
53
53
  "sury-ppx": "11.0.0",
54
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.102"
54
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.103"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "rescript": "^12.3.1"
@@ -1,5 +1,5 @@
1
1
  // Writes the curated component manifest where the local host shell serves its
2
- // static assets from (`HostShellDist`), so `manifestUrl` discovery works in dev
2
+ // static assets from (the chosen `assetsDir`, else `HostShellDist`), so `manifestUrl` discovery works in dev
3
3
  // exactly as it does in a deployment. Rewritten on every boot, which also makes
4
4
  // it self-healing — a `pnpm install` that replaces the package directory costs
5
5
  // one restart, not a debugging session.
@@ -30,6 +30,8 @@ let files = (~config: ReventlessInfra.Platform.bakedManifest): array<(
30
30
  let emit = (
31
31
  ~structures: array<(string, Reventless.Plugin.pluginStructure)>,
32
32
  ~config: ReventlessInfra.Platform.bakedManifest,
33
+ // The platform's chosen `assetsDir`; unset, the host-shell package's `dist/`.
34
+ ~dir: option<string>=?,
33
35
  ) => {
34
36
  let outputs = files(~config)
35
37
  // Every file is curated before any is written. A declaration naming a
@@ -42,7 +44,10 @@ let emit = (
42
44
  | Ok(manifest) => manifest
43
45
  },
44
46
  ))
45
- switch HostShellDist.dir() {
47
+ switch switch dir {
48
+ | Some(_) as given => given
49
+ | None => HostShellDist.dir()
50
+ } {
46
51
  | None =>
47
52
  JsError.throwWithMessage(
48
53
  `baked manifest: cannot resolve ${HostShellDist.package} from ${NodeProcess.cwd()} — ` ++
@@ -11,7 +11,7 @@ let log = Logger$ReventlessCore.fromEnv();
11
11
 
12
12
  let files = Platform_BakedManifest$ReventlessCore.files;
13
13
 
14
- function emit(structures, config) {
14
+ function emit(structures, config, dir) {
15
15
  let outputs = Platform_BakedManifest$ReventlessCore.files(config);
16
16
  let curated = outputs.map(param => {
17
17
  let e = Platform_BakedManifest$ReventlessCore.curate(structures, param[1]);
@@ -22,11 +22,11 @@ function emit(structures, config) {
22
22
  tmp
23
23
  ];
24
24
  });
25
- let dir = HostShellDist$ReventlessLocal.dir();
26
- if (dir !== undefined) {
25
+ let dir$1 = dir !== undefined ? dir : HostShellDist$ReventlessLocal.dir();
26
+ if (dir$1 !== undefined) {
27
27
  curated.forEach(param => {
28
28
  let key = param[0];
29
- let path = Nodepath.join(dir, key);
29
+ let path = Nodepath.join(dir$1, key);
30
30
  Nodefs.writeFileSync(path, JSON.stringify(param[1], undefined, 2), "utf8");
31
31
  log.info("BakedManifest", undefined, `wrote ` + key + `: ` + path);
32
32
  });
@@ -27,3 +27,14 @@ let dir = (): option<string> =>
27
27
  } catch {
28
28
  | _ => None
29
29
  }
30
+
31
+ /** The directory the local writers write into: the app's chosen `assetsDir`,
32
+ created if missing, else the package's `dist/`. */
33
+ let resolve = (~assetsDir: option<string>): option<string> =>
34
+ switch assetsDir {
35
+ | Some(chosen) =>
36
+ let absolute = NodePath.resolve([NodeProcess.cwd(), chosen])
37
+ NodeFs.mkdirSync(absolute, {recursive: true})
38
+ Some(absolute)
39
+ | None => dir()
40
+ }
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Nodefs from "node:fs";
3
4
  import * as Nodepath from "node:path";
4
5
  import * as Nodemodule from "node:module";
5
6
 
@@ -13,8 +14,20 @@ function dir() {
13
14
  }
14
15
  }
15
16
 
17
+ function resolve(assetsDir) {
18
+ if (assetsDir === undefined) {
19
+ return dir();
20
+ }
21
+ let absolute = Nodepath.resolve(process.cwd(), assetsDir);
22
+ Nodefs.mkdirSync(absolute, {
23
+ recursive: true
24
+ });
25
+ return absolute;
26
+ }
27
+
16
28
  export {
17
29
  $$package,
18
30
  dir,
31
+ resolve,
19
32
  }
20
- /* node:path Not a pure module */
33
+ /* node:fs Not a pure module */
package/src/Platform.res CHANGED
@@ -928,6 +928,7 @@ module MakeWithConfig = (
928
928
  let bakeManifest = (
929
929
  ~pluginComponents: array<ReventlessCore.Plugin.component>,
930
930
  ~config: ReventlessInfra.Platform.bakedManifest,
931
+ ~dir: option<string>,
931
932
  ) => {
932
933
  let resolved: array<(string, Reventless.Plugin.pluginStructure)> = []
933
934
  let expected = pluginComponents->Array.length
@@ -942,7 +943,7 @@ module MakeWithConfig = (
942
943
  | None => ()
943
944
  }
944
945
  if resolved->Array.length === expected {
945
- BakedManifest.emit(~structures=resolved, ~config)
946
+ BakedManifest.emit(~structures=resolved, ~config, ~dir?)
946
947
  }
947
948
  })
948
949
  })
@@ -1518,13 +1519,15 @@ module MakeWithConfig = (
1518
1519
  })
1519
1520
 
1520
1521
  // In-memory ignores the infrastructure half of `~hostUiBundle` — the host shell
1521
- // is served from the installed package's own `dist/`, not from a CDN. What a
1522
- // deployment *says* is honoured, in the same three files the AWS deploy writes
1522
+ // is served from a local directory (`assetsDir`, else the installed package's
1523
+ // own `dist/`), not from a CDN. What a deployment *says* is honored, in the same files the AWS deploy writes
1523
1524
  // beside each other: `bakedManifest` through `BakedManifest`, `shellConfig`
1524
1525
  // through `ShellConfig`, which overlays the served `config.json` the way the
1525
1526
  // AWS deploy composes the one it writes, and `uiHintsFile` through `UiHints`,
1526
1527
  // which replaces the served hints file the way the deploy uploads it.
1527
1528
  type hostUiBundleConfig = {
1529
+ // Where every file below is written, as on AWS. Unset: the host-shell
1530
+ // package's `dist/`.
1528
1531
  assetsDir?: string,
1529
1532
  bundleVersion?: string,
1530
1533
  // `makePlatform` serves the declared file in place of the host-shell
@@ -1736,7 +1739,14 @@ module MakeWithConfig = (
1736
1739
  // structure Output to land. Unconditional, unlike the bake, because it is
1737
1740
  // also what restores the shipped file for a platform that has stopped
1738
1741
  // declaring anything.
1742
+ //
1743
+ // All four writers share one directory, resolved once: a second opinion puts
1744
+ // a file where nothing reads it.
1745
+ let hostUiDir = HostShellDist.resolve(
1746
+ ~assetsDir=hostUiBundle->Option.flatMap(cfg => cfg.assetsDir),
1747
+ )
1739
1748
  ShellConfig.emit(
1749
+ ~dir=?hostUiDir,
1740
1750
  ~bakedManifest=hostUiBundle->Option.flatMap(cfg => cfg.bakedManifest),
1741
1751
  // Names the module `UiSlots.emit` serves below. Both or neither: a key
1742
1752
  // without the file is a 404 the shell survives quietly, and a file without
@@ -1747,7 +1757,7 @@ module MakeWithConfig = (
1747
1757
  // Unconditional for the same reason, and one file over: it is also what
1748
1758
  // restores the shell package's own hints for a platform that has stopped
1749
1759
  // declaring its own.
1750
- UiHints.emit(~uiHintsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiHintsFile))
1760
+ UiHints.emit(~uiHintsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiHintsFile), ~dir=?hostUiDir)
1751
1761
  // The dev loop for the file just written. Editing hints is presentation
1752
1762
  // work — you change a label, you want to see the label — and routing that
1753
1763
  // through a platform restart puts fifteen seconds between the two. The
@@ -1759,20 +1769,22 @@ module MakeWithConfig = (
1759
1769
  // would imply a shutdown path this platform does not have.
1760
1770
  let _ = UiHints.watch(
1761
1771
  ~uiHintsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiHintsFile),
1772
+ ~dir=?hostUiDir,
1762
1773
  ~onReload=LocalEvents_Server.broadcastUiHintsChanged,
1763
1774
  )
1764
1775
  // The same pair for the module that draws what a hint cannot say. Also
1765
1776
  // unconditional, and for a reason the hints file does not have: this is what
1766
1777
  // removes a module a platform has stopped declaring, and leaving one behind
1767
1778
  // would go on drawing regions with nothing in the deployment to explain it.
1768
- UiSlots.emit(~uiSlotsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiSlotsFile))
1779
+ UiSlots.emit(~uiSlotsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiSlotsFile), ~dir=?hostUiDir)
1769
1780
  let _ = UiSlots.watch(
1770
1781
  ~uiSlotsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiSlotsFile),
1782
+ ~dir=?hostUiDir,
1771
1783
  ~onReload=LocalEvents_Server.broadcastUiSlotsChanged,
1772
1784
  )
1773
1785
  switch hostUiBundle->Option.flatMap(cfg => cfg.bakedManifest) {
1774
1786
  | None => ()
1775
- | Some(cfg) => bakeManifest(~pluginComponents=plugins, ~config=cfg)
1787
+ | Some(cfg) => bakeManifest(~pluginComponents=plugins, ~config=cfg, ~dir=hostUiDir)
1776
1788
  }
1777
1789
  switch (projectionCatchup, pgProjectionCatchup) {
1778
1790
  | (Some((db, upperBound, dcbUpperBound)), _) =>
@@ -38,6 +38,7 @@ import * as ComponentType$ReventlessCore from "@reventlessdev/reventless-core/sr
38
38
  import * as SqliteDriver$ReventlessLocal from "./adapter/SqliteDriver.res.mjs";
39
39
  import * as Task_Builder$ReventlessLocal from "./components/Task_Builder.res.mjs";
40
40
  import * as BakedManifest$ReventlessLocal from "./BakedManifest.res.mjs";
41
+ import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
41
42
  import * as LocalEventTap$ReventlessLocal from "./adapter/LocalEventTap.res.mjs";
42
43
  import * as Platform_Admin$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_Admin.res.mjs";
43
44
  import * as PluginBehavior$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginBehavior.res.mjs";
@@ -860,7 +861,7 @@ function MakeWithConfig(Config) {
860
861
  let pluginStructuresStore = {
861
862
  contents: {}
862
863
  };
863
- let bakeManifest = (pluginComponents, config) => {
864
+ let bakeManifest = (pluginComponents, config, dir) => {
864
865
  let resolved = [];
865
866
  let expected = pluginComponents.length;
866
867
  pluginComponents.forEach(plugin => {
@@ -877,7 +878,7 @@ function MakeWithConfig(Config) {
877
878
  ]);
878
879
  }
879
880
  if (resolved.length === expected) {
880
- return BakedManifest$ReventlessLocal.emit(resolved, config);
881
+ return BakedManifest$ReventlessLocal.emit(resolved, config, dir);
881
882
  }
882
883
  });
883
884
  });
@@ -1330,14 +1331,15 @@ function MakeWithConfig(Config) {
1330
1331
  adminResources: []
1331
1332
  });
1332
1333
  subscribeToPluginEvents();
1333
- ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), undefined);
1334
- UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined);
1335
- UiHints$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined, LocalEvents_Server$ReventlessLocal.broadcastUiHintsChanged);
1336
- UiSlots$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), undefined);
1337
- UiSlots$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), undefined, LocalEvents_Server$ReventlessLocal.broadcastUiSlotsChanged);
1334
+ let hostUiDir = HostShellDist$ReventlessLocal.resolve(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.assetsDir));
1335
+ ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), hostUiDir);
1336
+ UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), hostUiDir);
1337
+ UiHints$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), hostUiDir, LocalEvents_Server$ReventlessLocal.broadcastUiHintsChanged);
1338
+ UiSlots$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), hostUiDir);
1339
+ UiSlots$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), hostUiDir, LocalEvents_Server$ReventlessLocal.broadcastUiSlotsChanged);
1338
1340
  let cfg = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest);
1339
1341
  if (cfg !== undefined) {
1340
- bakeManifest(plugins$1, cfg);
1342
+ bakeManifest(plugins$1, cfg, hostUiDir);
1341
1343
  }
1342
1344
  if (projectionCatchup !== undefined) {
1343
1345
  Stdlib_Promise.$$catch(ProjectionCheckpoint$ReventlessLocal.startupCatchup(projectionCatchup[0], projectionCatchup[1], projectionCatchup[2], () => Bus.projectionCatchupHandlers()), e => {
@@ -2620,7 +2622,7 @@ function Make($star) {
2620
2622
  let pluginStructuresStore = {
2621
2623
  contents: {}
2622
2624
  };
2623
- let bakeManifest = (pluginComponents, config) => {
2625
+ let bakeManifest = (pluginComponents, config, dir) => {
2624
2626
  let resolved = [];
2625
2627
  let expected = pluginComponents.length;
2626
2628
  pluginComponents.forEach(plugin => {
@@ -2637,7 +2639,7 @@ function Make($star) {
2637
2639
  ]);
2638
2640
  }
2639
2641
  if (resolved.length === expected) {
2640
- return BakedManifest$ReventlessLocal.emit(resolved, config);
2642
+ return BakedManifest$ReventlessLocal.emit(resolved, config, dir);
2641
2643
  }
2642
2644
  });
2643
2645
  });
@@ -3085,14 +3087,15 @@ function Make($star) {
3085
3087
  adminResources: []
3086
3088
  });
3087
3089
  subscribeToPluginEvents();
3088
- ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), undefined);
3089
- UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined);
3090
- UiHints$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined, LocalEvents_Server$ReventlessLocal.broadcastUiHintsChanged);
3091
- UiSlots$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), undefined);
3092
- UiSlots$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), undefined, LocalEvents_Server$ReventlessLocal.broadcastUiSlotsChanged);
3090
+ let hostUiDir = HostShellDist$ReventlessLocal.resolve(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.assetsDir));
3091
+ ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), hostUiDir);
3092
+ UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), hostUiDir);
3093
+ UiHints$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), hostUiDir, LocalEvents_Server$ReventlessLocal.broadcastUiHintsChanged);
3094
+ UiSlots$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), hostUiDir);
3095
+ UiSlots$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), hostUiDir, LocalEvents_Server$ReventlessLocal.broadcastUiSlotsChanged);
3093
3096
  let cfg = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest);
3094
3097
  if (cfg !== undefined) {
3095
- bakeManifest(plugins$1, cfg);
3098
+ bakeManifest(plugins$1, cfg, hostUiDir);
3096
3099
  }
3097
3100
  if (projectionCatchup !== undefined) {
3098
3101
  Stdlib_Promise.$$catch(ProjectionCheckpoint$ReventlessLocal.startupCatchup(projectionCatchup[0], projectionCatchup[1], projectionCatchup[2], () => Bus.projectionCatchupHandlers()), e => {
@@ -17,8 +17,9 @@
17
17
  // `pnpm install` that replaces the package directory costs one restart, because
18
18
  // the baseline is re-seeded from whatever the new package ships.
19
19
  //
20
- // One key is computed (`manifestUrl`, from the bake declaration — local knows
21
- // where it put the file); the rest are the deployment's own `shellConfig`,
20
+ // The computed keys are the environment (always `local`), `manifestUrl` and the
21
+ // journey map (from the bake declaration — local knows where it put the file),
22
+ // `uiSlotsUrl` and `adminGroup`; the rest are the deployment's own `shellConfig`,
22
23
  // passed through verbatim. A `shellConfig` that names a computed key fails the
23
24
  // same way it does on AWS: a passthrough cannot redirect a key the platform
24
25
  // decides, and silently resolving it either way points the shell somewhere
@@ -41,7 +42,10 @@ let manifestUrlOf = (config: ReventlessInfra.Platform.bakedManifest): string =>
41
42
  // caller matching no declared group gets and what every existing deployment has.
42
43
  let journeyManifestsKey = "journeyManifestUrls"
43
44
 
45
+ let environmentKeys = [Reventless.ShellEnvironment.nameKey, Reventless.ShellEnvironment.purposeKey]
46
+
44
47
  let computedKeys = [
48
+ ...environmentKeys,
45
49
  "manifestUrl",
46
50
  journeyManifestsKey,
47
51
  ReventlessCore.Platform_UiSlots.configKey,
@@ -60,7 +64,10 @@ let overlay = (
60
64
  ~uiSlotsFile: option<string>=?,
61
65
  ~shellConfig: option<dict<JSON.t>>,
62
66
  ): dict<JSON.t> => {
63
- let out = Dict.make()
67
+ let out =
68
+ Reventless.ShellEnvironment.local
69
+ ->Reventless.ShellEnvironment.configFields
70
+ ->Dict.fromArray
64
71
 
65
72
  // `UiSlots` serves the module; this is what tells the shell to import it. The
66
73
  // two are separate writes to separate files, which is exactly why the name
@@ -120,23 +127,22 @@ let readObject = (~path: string, ~label: string): dict<JSON.t> =>
120
127
  /**
121
128
  Merge the overlay onto the shipped `config.json` in the host shell's `dist/`.
122
129
 
123
- A no-op when there is nothing to say and nothing was said before — a platform
124
- that declares neither a bake nor any `shellConfig` never touches the file, so
125
- its dev shell is byte-identical to one built before this module existed. Once
126
- either is declared the write happens or fails loudly, for the reason
127
- `BakedManifest.emit` gives: both failure modes it can hit are the deployment's
128
- own mistake, and both produce the same silent symptom if swallowed.
130
+ The environment keys are always written, so the shell can say it is local;
131
+ without the package they are dropped silently, since a platform nobody points a
132
+ browser at has nothing to badge. Anything declared beyond them is written or
133
+ fails loudly, for the reason `BakedManifest.emit` gives: both failure modes it
134
+ can hit are the deployment's own mistake, and both produce the same silent
135
+ symptom if swallowed.
129
136
  */
130
137
  let emit = (
131
138
  ~bakedManifest: option<ReventlessInfra.Platform.bakedManifest>,
132
139
  ~uiSlotsFile: option<string>=?,
133
140
  ~shellConfig: option<dict<JSON.t>>,
134
- // Test seam. The baseline dance is the part of this module with state behind
135
- // it — "boot twice and the second write still starts from the shipped file" is
136
- // not a property a pure function can carry.
141
+ // The platform's chosen `assetsDir`; unset, the host-shell package's `dist/`.
137
142
  ~dir: option<string>=?,
138
143
  ) => {
139
144
  let overlay = overlay(~bakedManifest, ~uiSlotsFile?, ~shellConfig)
145
+ let declared = overlay->Dict.keysToArray->Array.filter(k => !(environmentKeys->Array.includes(k)))
140
146
 
141
147
  switch switch dir {
142
148
  | Some(_) as given => given
@@ -146,7 +152,7 @@ let emit = (
146
152
  // Nothing declared and no shell installed is the ordinary case for a
147
153
  // platform nobody points a browser at; only a declaration makes the missing
148
154
  // package an error, and then it is the same error the bake gives.
149
- if overlay->Dict.keysToArray->Array.length > 0 {
155
+ if declared->Array.length > 0 {
150
156
  JsError.throwWithMessage(
151
157
  `host UI config.json: cannot resolve ${HostShellDist.package} from ${NodeProcess.cwd()} — ` ++
152
158
  `the local shell reads its config from that package's dist/, so declaring shell ` ++
@@ -157,29 +163,26 @@ let emit = (
157
163
  let path = NodePath.join([dir, fileName])
158
164
  let baselinePath = NodePath.join([dir, baselineFileName])
159
165
 
160
- // Seeded only when there is an overlay to apply. A platform that declares
161
- // nothing must not leave a `config.base.json` behind for the next one to
162
- // read as authoritative.
163
- if !NodeFs.existsSync(baselinePath) && overlay->Dict.keysToArray->Array.length > 0 {
164
- if !NodeFs.existsSync(path) {
166
+ // The baseline is whatever `config.json` the directory held first: the
167
+ // shell's shipped file, or the app's own in a chosen `assetsDir`.
168
+ if !NodeFs.existsSync(baselinePath) {
169
+ if NodeFs.existsSync(path) {
170
+ NodeFs.writeFileSync(baselinePath, NodeFs.readFileSync(path))
171
+ } else if declared->Array.length > 0 {
165
172
  JsError.throwWithMessage(
166
- `host UI config.json: ${HostShellDist.package} ships no ${fileName} at ${dir} — ` ++
173
+ `host UI config.json: there is no ${fileName} at ${dir} — ` ++
167
174
  `there is no baseline to overlay, so the shell would boot with only the keys ` ++ `declared here and none of the ones it expects.`,
168
175
  )
169
176
  }
170
- NodeFs.writeFileSync(baselinePath, NodeFs.readFileSync(path))
171
177
  }
172
178
 
173
179
  if NodeFs.existsSync(baselinePath) {
174
180
  let merged = readObject(~path=baselinePath, ~label="config baseline")
175
181
  overlay->Dict.forEachWithKey((v, k) => merged->Dict.set(k, v))
176
182
  NodeFs.writeFileSync(path, JSON.stringify(merged->JSON.Encode.object, ~space=2))
177
- let keys = overlay->Dict.keysToArray
178
183
  log.info(
179
184
  ~comp="ShellConfig",
180
- keys->Array.length == 0
181
- ? `restored ${fileName} from ${baselineFileName}: ${path}`
182
- : `wrote ${fileName} with ${keys->Array.join(", ")}: ${path}`,
185
+ `wrote ${fileName} with ${overlay->Dict.keysToArray->Array.join(", ")}: ${path}`,
183
186
  )
184
187
  }
185
188
  }
@@ -2,11 +2,13 @@
2
2
 
3
3
  import * as Nodefs from "node:fs";
4
4
  import * as Nodepath from "node:path";
5
+ import * as Belt_Array from "@rescript/runtime/lib/es6/Belt_Array.js";
5
6
  import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
6
7
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
7
8
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
9
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
9
10
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
11
+ import * as ShellEnvironment$Reventless from "@reventlessdev/reventless-spec/src/types/ShellEnvironment.res.mjs";
10
12
  import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
11
13
  import * as Platform_UiSlots$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_UiSlots.res.mjs";
12
14
  import * as Platform_AdminGroup$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_AdminGroup.res.mjs";
@@ -24,15 +26,23 @@ function manifestUrlOf(config) {
24
26
 
25
27
  let journeyManifestsKey = "journeyManifestUrls";
26
28
 
27
- let computedKeys = [
28
- "manifestUrl",
29
- journeyManifestsKey,
30
- Platform_UiSlots$ReventlessCore.configKey,
31
- Platform_AdminGroup$ReventlessCore.configKey
29
+ let environmentKeys = [
30
+ ShellEnvironment$Reventless.nameKey,
31
+ ShellEnvironment$Reventless.purposeKey
32
32
  ];
33
33
 
34
+ let computedKeys = Belt_Array.concatMany([
35
+ environmentKeys,
36
+ [
37
+ "manifestUrl",
38
+ journeyManifestsKey,
39
+ Platform_UiSlots$ReventlessCore.configKey,
40
+ Platform_AdminGroup$ReventlessCore.configKey
41
+ ]
42
+ ]);
43
+
34
44
  function overlay(bakedManifest, uiSlotsFile, shellConfig) {
35
- let out = {};
45
+ let out = Object.fromEntries(ShellEnvironment$Reventless.configFields(ShellEnvironment$Reventless.local));
36
46
  Stdlib_Option.forEach(uiSlotsFile, param => {
37
47
  out[Platform_UiSlots$ReventlessCore.configKey] = Platform_UiSlots$ReventlessCore.url;
38
48
  });
@@ -78,9 +88,10 @@ function readObject(path, label) {
78
88
 
79
89
  function emit(bakedManifest, uiSlotsFile, shellConfig, dir) {
80
90
  let overlay$1 = overlay(bakedManifest, uiSlotsFile, shellConfig);
91
+ let declared = Object.keys(overlay$1).filter(k => !environmentKeys.includes(k));
81
92
  let dir$1 = dir !== undefined ? dir : HostShellDist$ReventlessLocal.dir();
82
93
  if (dir$1 === undefined) {
83
- if (Object.keys(overlay$1).length !== 0) {
94
+ if (declared.length !== 0) {
84
95
  return Stdlib_JsError.throwWithMessage(`host UI config.json: cannot resolve ` + HostShellDist$ReventlessLocal.$$package + ` from ` + process.cwd() + ` — the local shell reads its config from that package's dist/, so declaring shell config without the package installed would write nothing and leave the shell configured as it shipped.`);
85
96
  } else {
86
97
  return;
@@ -88,11 +99,12 @@ function emit(bakedManifest, uiSlotsFile, shellConfig, dir) {
88
99
  }
89
100
  let path = Nodepath.join(dir$1, fileName);
90
101
  let baselinePath = Nodepath.join(dir$1, baselineFileName);
91
- if (!Nodefs.existsSync(baselinePath) && Object.keys(overlay$1).length !== 0) {
92
- if (!Nodefs.existsSync(path)) {
93
- Stdlib_JsError.throwWithMessage(`host UI config.json: ` + HostShellDist$ReventlessLocal.$$package + ` ships no ` + fileName + ` at ` + dir$1 + ` — there is no baseline to overlay, so the shell would boot with only the keys declared here and none of the ones it expects.`);
102
+ if (!Nodefs.existsSync(baselinePath)) {
103
+ if (Nodefs.existsSync(path)) {
104
+ Nodefs.writeFileSync(baselinePath, Nodefs.readFileSync(path, "utf8"), "utf8");
105
+ } else if (declared.length !== 0) {
106
+ Stdlib_JsError.throwWithMessage(`host UI config.json: there is no ` + fileName + ` at ` + dir$1 + ` — there is no baseline to overlay, so the shell would boot with only the keys declared here and none of the ones it expects.`);
94
107
  }
95
- Nodefs.writeFileSync(baselinePath, Nodefs.readFileSync(path, "utf8"), "utf8");
96
108
  }
97
109
  if (!Nodefs.existsSync(baselinePath)) {
98
110
  return;
@@ -102,8 +114,7 @@ function emit(bakedManifest, uiSlotsFile, shellConfig, dir) {
102
114
  merged[k] = v;
103
115
  });
104
116
  Nodefs.writeFileSync(path, JSON.stringify(merged, undefined, 2), "utf8");
105
- let keys = Object.keys(overlay$1);
106
- log.info("ShellConfig", undefined, keys.length === 0 ? `restored ` + fileName + ` from ` + baselineFileName + `: ` + path : `wrote ` + fileName + ` with ` + keys.join(", ") + `: ` + path);
117
+ log.info("ShellConfig", undefined, `wrote ` + fileName + ` with ` + Object.keys(overlay$1).join(", ") + `: ` + path);
107
118
  }
108
119
 
109
120
  export {
@@ -112,6 +123,7 @@ export {
112
123
  baselineFileName,
113
124
  manifestUrlOf,
114
125
  journeyManifestsKey,
126
+ environmentKeys,
115
127
  computedKeys,
116
128
  overlay,
117
129
  readObject,
package/src/UiHints.res CHANGED
@@ -37,9 +37,8 @@ let baselineFileName = "ui-hints.base.json"
37
37
  */
38
38
  let emit = (
39
39
  ~uiHintsFile: option<string>,
40
- // Test seam, as in `ShellConfig.emit`: the baseline dance is the part with
41
- // state behind it, and "boot twice and the second write still starts from the
42
- // shipped file" is not a property a pure function can carry.
40
+ // Where the file is served from: the platform's chosen `assetsDir`; unset, the
41
+ // host-shell package's `dist/`.
43
42
  ~dir: option<string>=?,
44
43
  ) => {
45
44
  // Read before anything is touched, so a bad declaration cannot leave the
@@ -125,8 +124,7 @@ let emit = (
125
124
  */
126
125
  let watch = (
127
126
  ~uiHintsFile: option<string>,
128
- // The same seam `emit` takes, and threaded to it: a test has to be able to
129
- // watch a file it wrote into a temp dir without a host-shell package on disk.
127
+ // The directory `emit` takes, threaded to it.
130
128
  ~dir: option<string>=?,
131
129
  ~onReload: unit => unit,
132
130
  ): option<NodeFs.watcher> =>
package/src/UiSlots.res CHANGED
@@ -53,9 +53,8 @@ let fileName = ReventlessCore.Platform_UiSlots.fileName
53
53
  */
54
54
  let emit = (
55
55
  ~uiSlotsFile: option<string>,
56
- // Test seam, as in `UiHints.emit`: the removal on withdrawal is the part with
57
- // state behind it, and "declare, then withdraw, and the file is gone" is not a
58
- // property a pure function can carry.
56
+ // Where the module is served from: the platform's chosen `assetsDir`; unset,
57
+ // the host-shell package's `dist/`.
59
58
  ~dir: option<string>=?,
60
59
  ) => {
61
60
  // Read before anything is touched, so a bad declaration cannot leave the
@@ -0,0 +1,70 @@
1
+ open JestGlobals
2
+
3
+ // The one directory every local host-UI writer writes into. An app that composes
4
+ // its own UI bundle names it as `assetsDir`, as on AWS; a writer that took a
5
+ // second opinion would put its file where nothing serves it.
6
+
7
+ let _ = TestRunner.setup()
8
+
9
+ let tmpdir = prefix => NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), prefix]))
10
+
11
+ // What the package's dist/ holds, so a test can say it was not touched. Empty
12
+ // when the package is not installed where the test runs.
13
+ let snapshot = () =>
14
+ HostShellDist.dir()
15
+ ->Option.filter(NodeFs.existsSync)
16
+ ->Option.mapOr([], dir =>
17
+ NodeFs.readdirSyncNames(dir, {})
18
+ ->Array.toSorted(String.compare)
19
+ ->Array.map(name => {
20
+ let path = NodePath.join([dir, name])
21
+ (name, NodeFs.statSync(path)->NodeFs.mtimeMs)
22
+ })
23
+ )
24
+
25
+ describe("HostShellDist.resolve", () => {
26
+ testSync("is the package's dist/ when no directory is chosen", () =>
27
+ expect(HostShellDist.resolve(~assetsDir=None))->toEqual(HostShellDist.dir())
28
+ )
29
+
30
+ testSync("is the chosen directory, created when missing", () => {
31
+ let chosen = NodePath.join([tmpdir("reventless-assetsdir-"), "app", "dist"])
32
+ expect(HostShellDist.resolve(~assetsDir=Some(chosen)))->toEqual(Some(chosen))
33
+ expect(NodeFs.existsSync(chosen))->toBe(true)
34
+ })
35
+ })
36
+
37
+ describe("the local writers in a chosen assetsDir", () => {
38
+ testSync("all four write there and leave the package's dist/ alone", () => {
39
+ let before = snapshot()
40
+ let dir =
41
+ HostShellDist.resolve(
42
+ ~assetsDir=Some(NodePath.join([tmpdir("reventless-assetsdir-"), "ui"])),
43
+ )->Option.getOrThrow
44
+ // The app's own config.json is the baseline the overlay goes on.
45
+ NodeFs.writeFileSync(NodePath.join([dir, "config.json"]), `{"appName": "App"}`)
46
+ let source = name => {
47
+ let path = NodePath.join([tmpdir("reventless-assetsdir-src-"), name])
48
+ NodeFs.writeFileSync(
49
+ path,
50
+ name->String.endsWith(".json") ? "{}" : "export function register() {}",
51
+ )
52
+ path
53
+ }
54
+ let bake: ReventlessInfra.Platform.bakedManifest = {components: []}
55
+
56
+ ShellConfig.emit(~bakedManifest=Some(bake), ~shellConfig=None, ~dir)
57
+ UiHints.emit(~uiHintsFile=Some(source("ui-hints.json")), ~dir)
58
+ UiSlots.emit(~uiSlotsFile=Some(source("slots.js")), ~dir)
59
+ BakedManifest.emit(~structures=[], ~config=bake, ~dir)
60
+
61
+ expect(NodeFs.readdirSyncNames(dir, {})->Array.toSorted(String.compare))->toEqual([
62
+ "component-manifest.json",
63
+ "config.base.json",
64
+ "config.json",
65
+ "ui-hints.json",
66
+ "ui-slots.js",
67
+ ])
68
+ expect(snapshot())->toEqual(before)
69
+ })
70
+ })
@@ -0,0 +1,75 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodeos from "node:os";
5
+ import * as Nodepath from "node:path";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
8
+ import * as UiHints$ReventlessLocal from "../src/UiHints.res.mjs";
9
+ import * as UiSlots$ReventlessLocal from "../src/UiSlots.res.mjs";
10
+ import * as TestRunner$ReventlessLocal from "../src/test/TestRunner.res.mjs";
11
+ import * as ShellConfig$ReventlessLocal from "../src/ShellConfig.res.mjs";
12
+ import * as BakedManifest$ReventlessLocal from "../src/BakedManifest.res.mjs";
13
+ import * as HostShellDist$ReventlessLocal from "../src/HostShellDist.res.mjs";
14
+
15
+ TestRunner$ReventlessLocal.setup();
16
+
17
+ function tmpdir(prefix) {
18
+ return Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), prefix));
19
+ }
20
+
21
+ function snapshot() {
22
+ return Stdlib_Option.mapOr(Stdlib_Option.filter(HostShellDist$ReventlessLocal.dir(), prim => Nodefs.existsSync(prim)), [], dir => Nodefs.readdirSync(dir, {}).toSorted(Primitive_string.compare).map(name => {
23
+ let path = Nodepath.join(dir, name);
24
+ return [
25
+ name,
26
+ Nodefs.statSync(path).mtimeMs
27
+ ];
28
+ }));
29
+ }
30
+
31
+ globalThis.describe("HostShellDist.resolve", () => {
32
+ globalThis.test("is the package's dist/ when no directory is chosen", () => {
33
+ globalThis.expect(HostShellDist$ReventlessLocal.resolve(undefined)).toEqual(HostShellDist$ReventlessLocal.dir());
34
+ });
35
+ globalThis.test("is the chosen directory, created when missing", () => {
36
+ let chosen = Nodepath.join(tmpdir("reventless-assetsdir-"), "app", "dist");
37
+ globalThis.expect(HostShellDist$ReventlessLocal.resolve(chosen)).toEqual(chosen);
38
+ globalThis.expect(Nodefs.existsSync(chosen)).toBe(true);
39
+ });
40
+ });
41
+
42
+ globalThis.describe("the local writers in a chosen assetsDir", () => {
43
+ globalThis.test("all four write there and leave the package's dist/ alone", () => {
44
+ let before = snapshot();
45
+ let dir = Stdlib_Option.getOrThrow(HostShellDist$ReventlessLocal.resolve(Nodepath.join(tmpdir("reventless-assetsdir-"), "ui")), undefined);
46
+ Nodefs.writeFileSync(Nodepath.join(dir, "config.json"), `{"appName": "App"}`, "utf8");
47
+ let source = name => {
48
+ let path = Nodepath.join(tmpdir("reventless-assetsdir-src-"), name);
49
+ Nodefs.writeFileSync(path, name.endsWith(".json") ? "{}" : "export function register() {}", "utf8");
50
+ return path;
51
+ };
52
+ let bake_components = [];
53
+ let bake = {
54
+ components: bake_components
55
+ };
56
+ ShellConfig$ReventlessLocal.emit(bake, undefined, undefined, dir);
57
+ UiHints$ReventlessLocal.emit(source("ui-hints.json"), dir);
58
+ UiSlots$ReventlessLocal.emit(source("slots.js"), dir);
59
+ BakedManifest$ReventlessLocal.emit([], bake, dir);
60
+ globalThis.expect(Nodefs.readdirSync(dir, {}).toSorted(Primitive_string.compare)).toEqual([
61
+ "component-manifest.json",
62
+ "config.base.json",
63
+ "config.json",
64
+ "ui-hints.json",
65
+ "ui-slots.js"
66
+ ]);
67
+ globalThis.expect(snapshot()).toEqual(before);
68
+ });
69
+ });
70
+
71
+ export {
72
+ tmpdir,
73
+ snapshot,
74
+ }
75
+ /* Not a pure module */
@@ -26,11 +26,35 @@ let threw = (f: unit => unit): bool =>
26
26
  }
27
27
 
28
28
  describe("ShellConfig.overlay", () => {
29
- testSync("is empty when the deployment declares nothing", () => {
29
+ testSync("carries only the environment when the deployment declares nothing", () => {
30
30
  let out = ShellConfig.overlay(~bakedManifest=None, ~shellConfig=None)
31
- expect(out->Dict.keysToArray)->toEqual([])
31
+ expect(out->Dict.keysToArray)->toEqual(["environment", "environmentPurpose"])
32
32
  })
33
33
 
34
+ // The shell badges everything that is not production; locally it says so.
35
+ testSync("names the environment and its purpose local", () => {
36
+ let out = ShellConfig.overlay(~bakedManifest=None, ~shellConfig=None)
37
+ expect((out->str("environment"), out->str("environmentPurpose")))->toEqual((
38
+ Some("local"),
39
+ Some("local"),
40
+ ))
41
+ })
42
+
43
+ testSync("refuses a shellConfig naming either environment key", () =>
44
+ expect(
45
+ ["environment", "environmentPurpose"]->Array.map(
46
+ key =>
47
+ threw(
48
+ () =>
49
+ ShellConfig.overlay(
50
+ ~bakedManifest=None,
51
+ ~shellConfig=Some(Dict.fromArray([(key, JSON.Encode.string("prod"))])),
52
+ )->ignore,
53
+ ),
54
+ ),
55
+ )->toEqual([true, true])
56
+ )
57
+
34
58
  testSync("points manifestUrl at the bake's default key", () => {
35
59
  let out = ShellConfig.overlay(~bakedManifest=Some(manifest()), ~shellConfig=None)
36
60
  expect(out->str("manifestUrl"))->toEqual(Some("/component-manifest.json"))
@@ -147,11 +171,25 @@ describe("ShellConfig.emit", () => {
147
171
  expect(out->str("apiEndpoint"))->toEqual(Some("/graphql"))
148
172
  })
149
173
 
150
- testSync("leaves a platform that declares nothing entirely alone", () => {
174
+ testSync("adds only the environment for a platform that declares nothing", () => {
151
175
  let dir = distWithConfig()
152
176
  ShellConfig.emit(~bakedManifest=None, ~shellConfig=None, ~dir)
153
- expect(NodeFs.readFileSync(NodePath.join([dir, "config.json"])))->toEqual(shipped)
154
- expect(NodeFs.existsSync(NodePath.join([dir, "config.base.json"])))->toBe(false)
177
+ let out = readConfig(dir)
178
+ expect(out->Dict.keysToArray)->toEqual([
179
+ "apiEndpoint",
180
+ "appName",
181
+ "environment",
182
+ "environmentPurpose",
183
+ ])
184
+ expect(readBaseline(dir)->str("appName"))->toEqual(Some("Shipped"))
185
+ })
186
+
187
+ // The environment alone is no declaration: a directory with no config.json to
188
+ // overlay is left as it is rather than refused.
189
+ testSync("writes nothing and refuses nothing when only the environment has no baseline", () => {
190
+ let dir = NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), "reventless-shellconfig-"]))
191
+ ShellConfig.emit(~bakedManifest=None, ~shellConfig=None, ~dir)
192
+ expect(NodeFs.readdirSyncNames(dir, {}))->toEqual([])
155
193
  })
156
194
 
157
195
  // The whole reason a baseline is kept: boot 2 overlaying boot 1's output would
@@ -273,8 +311,8 @@ describe("ShellConfig.overlay — adminGroup", () => {
273
311
  testSync("is absent while the administrator role keeps its own name", () => {
274
312
  Reventless.Role.clearGroups()
275
313
  expect(
276
- ShellConfig.overlay(~bakedManifest=None, ~shellConfig=None)->Dict.keysToArray,
277
- )->toEqual([])
314
+ ShellConfig.overlay(~bakedManifest=None, ~shellConfig=None)->Dict.get("adminGroup"),
315
+ )->toEqual(None)
278
316
  })
279
317
 
280
318
  testSync("carries the group a renamed administrator role maps to", () => {
@@ -36,9 +36,36 @@ function threw(f) {
36
36
  }
37
37
 
38
38
  globalThis.describe("ShellConfig.overlay", () => {
39
- globalThis.test("is empty when the deployment declares nothing", () => {
39
+ globalThis.test("carries only the environment when the deployment declares nothing", () => {
40
40
  let out = ShellConfig$ReventlessLocal.overlay(undefined, undefined, undefined);
41
- globalThis.expect(Object.keys(out)).toEqual([]);
41
+ globalThis.expect(Object.keys(out)).toEqual([
42
+ "environment",
43
+ "environmentPurpose"
44
+ ]);
45
+ });
46
+ globalThis.test("names the environment and its purpose local", () => {
47
+ let out = ShellConfig$ReventlessLocal.overlay(undefined, undefined, undefined);
48
+ globalThis.expect([
49
+ str(out, "environment"),
50
+ str(out, "environmentPurpose")
51
+ ]).toEqual([
52
+ "local",
53
+ "local"
54
+ ]);
55
+ });
56
+ globalThis.test("refuses a shellConfig naming either environment key", () => {
57
+ globalThis.expect([
58
+ "environment",
59
+ "environmentPurpose"
60
+ ].map(key => threw(() => {
61
+ ShellConfig$ReventlessLocal.overlay(undefined, undefined, Object.fromEntries([[
62
+ key,
63
+ "prod"
64
+ ]]));
65
+ }))).toEqual([
66
+ true,
67
+ true
68
+ ]);
42
69
  });
43
70
  globalThis.test("points manifestUrl at the bake's default key", () => {
44
71
  let out = ShellConfig$ReventlessLocal.overlay(manifest(undefined), undefined, undefined);
@@ -96,10 +123,9 @@ globalThis.describe("ShellConfig.overlay — uiSlotsFile", () => {
96
123
  });
97
124
 
98
125
  globalThis.describe("ShellConfig.emit", () => {
99
- let shipped = `{\n "apiEndpoint": "/graphql",\n "appName": "Shipped"\n}`;
100
126
  let distWithConfig = () => {
101
127
  let dir = Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), "reventless-shellconfig-"));
102
- Nodefs.writeFileSync(Nodepath.join(dir, "config.json"), shipped, "utf8");
128
+ Nodefs.writeFileSync(Nodepath.join(dir, "config.json"), `{\n "apiEndpoint": "/graphql",\n "appName": "Shipped"\n}`, "utf8");
103
129
  return dir;
104
130
  };
105
131
  let readJson = path => Stdlib_Option.getOrThrow(Stdlib_JSON.Decode.object(JSON.parse(Nodefs.readFileSync(path, "utf8"))), undefined);
@@ -116,11 +142,22 @@ globalThis.describe("ShellConfig.emit", () => {
116
142
  globalThis.expect(str(out, "manifestUrl")).toEqual("/component-manifest.json");
117
143
  globalThis.expect(str(out, "apiEndpoint")).toEqual("/graphql");
118
144
  });
119
- globalThis.test("leaves a platform that declares nothing entirely alone", () => {
145
+ globalThis.test("adds only the environment for a platform that declares nothing", () => {
120
146
  let dir = distWithConfig();
121
147
  ShellConfig$ReventlessLocal.emit(undefined, undefined, undefined, dir);
122
- globalThis.expect(Nodefs.readFileSync(Nodepath.join(dir, "config.json"), "utf8")).toEqual(shipped);
123
- globalThis.expect(Nodefs.existsSync(Nodepath.join(dir, "config.base.json"))).toBe(false);
148
+ let out = readConfig(dir);
149
+ globalThis.expect(Object.keys(out)).toEqual([
150
+ "apiEndpoint",
151
+ "appName",
152
+ "environment",
153
+ "environmentPurpose"
154
+ ]);
155
+ globalThis.expect(str(readBaseline(dir), "appName")).toEqual("Shipped");
156
+ });
157
+ globalThis.test("writes nothing and refuses nothing when only the environment has no baseline", () => {
158
+ let dir = Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), "reventless-shellconfig-"));
159
+ ShellConfig$ReventlessLocal.emit(undefined, undefined, undefined, dir);
160
+ globalThis.expect(Nodefs.readdirSync(dir, {})).toEqual([]);
124
161
  });
125
162
  globalThis.test("starts every boot from the shipped file, not the last output", () => {
126
163
  let dir = distWithConfig();
@@ -219,7 +256,7 @@ globalThis.describe("ShellConfig.overlay — journeys", () => {
219
256
  globalThis.describe("ShellConfig.overlay — adminGroup", () => {
220
257
  globalThis.test("is absent while the administrator role keeps its own name", () => {
221
258
  Role$Reventless.clearGroups();
222
- globalThis.expect(Object.keys(ShellConfig$ReventlessLocal.overlay(undefined, undefined, undefined))).toEqual([]);
259
+ globalThis.expect(ShellConfig$ReventlessLocal.overlay(undefined, undefined, undefined)["adminGroup"]).toEqual(undefined);
223
260
  });
224
261
  globalThis.test("carries the group a renamed administrator role maps to", () => {
225
262
  Role$Reventless.clearGroups();
@@ -26,7 +26,6 @@ module DigestSpec = {
26
26
  type command = SendDigest({recipientId: @s.matches(DcbTag.string) string})
27
27
 
28
28
  let maxRetries = 2
29
- let heartbeatInterval = 60
30
29
  let targetName = "SendDigest"
31
30
  let schedule = None
32
31
  }
@@ -69,7 +69,6 @@ let DigestSpec = {
69
69
  todoItemSchema: todoItemSchema,
70
70
  commandSchema: commandSchema,
71
71
  maxRetries: 2,
72
- heartbeatInterval: 60,
73
72
  targetName: "SendDigest",
74
73
  schedule: undefined,
75
74
  authorizationOf: authorizationOf,
@@ -16,7 +16,6 @@ module DemandReportSpec = {
16
16
  type command = RequestDemandReport({day: float})
17
17
 
18
18
  let maxRetries = 2
19
- let heartbeatInterval = 60
20
19
  let targetName = "ReportSettings"
21
20
  let schedule = Some(Schedule.Calendar({calendar: Daily({hour: 6, minute: 0})}))
22
21
  }
@@ -54,7 +54,6 @@ let DemandReportSpec = {
54
54
  todoItemSchema: todoItemSchema,
55
55
  commandSchema: commandSchema,
56
56
  maxRetries: 2,
57
- heartbeatInterval: 60,
58
57
  targetName: targetName,
59
58
  schedule: DemandReportSpec_schedule,
60
59
  authorizationOf: authorizationOf,
@@ -124,7 +123,6 @@ let UnscheduledSpec = {
124
123
  todoItemSchema: todoItemSchema,
125
124
  commandSchema: commandSchema,
126
125
  maxRetries: 2,
127
- heartbeatInterval: 60,
128
126
  targetName: targetName,
129
127
  authorizationOf: authorizationOf,
130
128
  commandTransition: commandTransition,
@@ -155,7 +153,6 @@ let FromClockUnscheduled = AutomationSlice$Reventless.Mapping.Make({
155
153
  todoItemSchema: todoItemSchema,
156
154
  commandSchema: commandSchema,
157
155
  maxRetries: 2,
158
- heartbeatInterval: 60,
159
156
  targetName: targetName,
160
157
  schedule: undefined
161
158
  })({
@@ -180,7 +177,6 @@ AutomationSlice$Reventless.Mappings.Make({
180
177
  todoItemSchema: todoItemSchema,
181
178
  commandSchema: commandSchema,
182
179
  maxRetries: 2,
183
- heartbeatInterval: 60,
184
180
  targetName: targetName,
185
181
  schedule: undefined
186
182
  });
@@ -208,7 +204,6 @@ let Unscheduled = AutomationSliceMaker.Make({
208
204
  todoItemSchema: todoItemSchema,
209
205
  commandSchema: commandSchema,
210
206
  maxRetries: 2,
211
- heartbeatInterval: 60,
212
207
  targetName: targetName,
213
208
  schedule: undefined
214
209
  })(UnscheduledAutomation);
@@ -39,7 +39,6 @@ module ShipOrderSpec = {
39
39
  type command = CreateShipment({orderId: @s.matches(Reventless.DcbTag.string) string})
40
40
 
41
41
  let maxRetries = 3
42
- let heartbeatInterval = 60
43
42
  let targetName = "CreateShipment"
44
43
  let schedule = None
45
44
  }
@@ -55,7 +54,6 @@ module SkipProcessSpec = {
55
54
  type command = Noop
56
55
 
57
56
  let maxRetries = 0
58
- let heartbeatInterval = 60
59
57
  let targetName = "Noop"
60
58
  let schedule = None
61
59
  }
@@ -75,7 +75,6 @@ let ShipOrderSpec = {
75
75
  todoItemSchema: todoItemSchema,
76
76
  commandSchema: commandSchema,
77
77
  maxRetries: 3,
78
- heartbeatInterval: 60,
79
78
  targetName: "CreateShipment",
80
79
  schedule: undefined,
81
80
  authorizationOf: authorizationOf,
@@ -107,7 +106,6 @@ let SkipProcessSpec = {
107
106
  todoItemSchema: todoItemSchema$1,
108
107
  commandSchema: commandSchema$1,
109
108
  maxRetries: 0,
110
- heartbeatInterval: 60,
111
109
  targetName: "Noop",
112
110
  schedule: undefined,
113
111
  authorizationOf: authorizationOf$1,
@@ -134,7 +134,6 @@ module AutoShipSpec = {
134
134
  type command = Ship({orderId: @s.matches(Reventless.DcbTag.string) string})
135
135
 
136
136
  let maxRetries = 3
137
- let heartbeatInterval = 60
138
137
  let targetName = "Ship"
139
138
  let schedule = None
140
139
  }
@@ -241,7 +241,6 @@ let AutoShipSpec = {
241
241
  todoItemSchema: todoItemSchema,
242
242
  commandSchema: commandSchema$2,
243
243
  maxRetries: 3,
244
- heartbeatInterval: 60,
245
244
  targetName: "Ship",
246
245
  schedule: undefined,
247
246
  authorizationOf: authorizationOf$2,
@@ -24,7 +24,6 @@ module CancelUnshippedSpec = {
24
24
  type command = CancelOrder({orderId: @s.matches(DcbTag.string) string})
25
25
 
26
26
  let maxRetries = 2
27
- let heartbeatInterval = 60
28
27
  let targetName = "CancelOrder"
29
28
  let schedule = None
30
29
  }
@@ -62,7 +62,6 @@ let CancelUnshippedSpec = {
62
62
  todoItemSchema: todoItemSchema,
63
63
  commandSchema: commandSchema,
64
64
  maxRetries: 2,
65
- heartbeatInterval: 60,
66
65
  targetName: "CancelOrder",
67
66
  schedule: undefined,
68
67
  authorizationOf: authorizationOf,
@@ -53,7 +53,6 @@ module AutoFulfillSpec = {
53
53
  })
54
54
 
55
55
  let maxRetries = 3
56
- let heartbeatInterval = 60
57
56
  let targetName = "MarkFulfilled"
58
57
  let schedule = None
59
58
  }
@@ -80,7 +80,6 @@ let AutoFulfillSpec = {
80
80
  todoItemSchema: todoItemSchema,
81
81
  commandSchema: commandSchema,
82
82
  maxRetries: 3,
83
- heartbeatInterval: 60,
84
83
  targetName: "MarkFulfilled",
85
84
  schedule: undefined,
86
85
  authorizationOf: authorizationOf,
@@ -44,7 +44,6 @@ let FireForgetCallback = OutboundTranslationSlice_Callback$ReventlessCore.Make({
44
44
  outboundItemSchema: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.outboundItemSchema,
45
45
  inboundCommandSchema: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.inboundCommandSchema,
46
46
  maxRetries: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.maxRetries,
47
- heartbeatInterval: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.heartbeatInterval,
48
47
  targetName: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.targetName,
49
48
  sourceNames: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.sourceNames,
50
49
  externalSystem: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.externalSystem,
@@ -59,7 +58,6 @@ let CommandBackCallback = OutboundTranslationSlice_Callback$ReventlessCore.Make(
59
58
  outboundItemSchema: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.outboundItemSchema,
60
59
  inboundCommandSchema: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.inboundCommandSchema,
61
60
  maxRetries: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.maxRetries,
62
- heartbeatInterval: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.heartbeatInterval,
63
61
  targetName: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.targetName,
64
62
  sourceNames: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.sourceNames,
65
63
  externalSystem: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.externalSystem,
@@ -38,7 +38,6 @@ module SendTrackingEmailSpec = {
38
38
  let onExhausted = (_id, _item, ~lastError as _) => None
39
39
 
40
40
  let maxRetries = 3
41
- let heartbeatInterval = 60
42
41
  let targetName = None
43
42
  let sourceNames: array<string> = []
44
43
  let externalSystem = None
@@ -86,7 +85,6 @@ module ProcessPaymentSpec = {
86
85
  let onExhausted = (id, item, ~lastError) => onExhaustedFn.contents(id, item, lastError)
87
86
 
88
87
  let maxRetries = 2
89
- let heartbeatInterval = 30
90
88
  let targetName = Some("ConfirmPayment")
91
89
  let sourceNames: array<string> = []
92
90
  let externalSystem = None
@@ -76,7 +76,6 @@ let SendTrackingEmailSpec = {
76
76
  translate: translate,
77
77
  onExhausted: onExhausted,
78
78
  maxRetries: 3,
79
- heartbeatInterval: 60,
80
79
  targetName: undefined,
81
80
  sourceNames: sourceNames,
82
81
  externalSystem: undefined,
@@ -158,7 +157,6 @@ let ProcessPaymentSpec = {
158
157
  onExhaustedFn: onExhaustedFn,
159
158
  onExhausted: onExhausted$1,
160
159
  maxRetries: 2,
161
- heartbeatInterval: 30,
162
160
  targetName: ProcessPaymentSpec_targetName,
163
161
  sourceNames: sourceNames$1,
164
162
  externalSystem: undefined,
@@ -78,7 +78,6 @@ module SendConfirmSpec = {
78
78
  type inboundCommand = unit
79
79
 
80
80
  let maxRetries = 3
81
- let heartbeatInterval = 60
82
81
  let targetName = None
83
82
  let sourceNames: array<string> = []
84
83
  let externalSystem = Some("EmailService")
@@ -121,7 +121,6 @@ let SendConfirmSpec = {
121
121
  outboundItemSchema: outboundItemSchema,
122
122
  inboundCommandSchema: inboundCommandSchema,
123
123
  maxRetries: 3,
124
- heartbeatInterval: 60,
125
124
  targetName: undefined,
126
125
  sourceNames: sourceNames,
127
126
  externalSystem: SendConfirmSpec_externalSystem,