@reventlessdev/reventless-local 3.0.0-alpha.245 → 3.0.0-alpha.246

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 (31) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +8 -8
  3. package/src/Platform.res +17 -0
  4. package/src/Platform.res.mjs +7 -2
  5. package/src/ShellConfig.res +20 -2
  6. package/src/ShellConfig.res.mjs +9 -4
  7. package/src/UiSlots.res +191 -0
  8. package/src/UiSlots.res.mjs +91 -0
  9. package/src/adapter/Api/LocalEvents_Server.res +18 -0
  10. package/src/adapter/Api/LocalEvents_Server.res.mjs +11 -0
  11. package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +50 -3
  12. package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs +62 -2
  13. package/tests/ShellConfigTest.res +41 -0
  14. package/tests/ShellConfigTest.res.mjs +40 -17
  15. package/tests/UiSlotsTest.res +197 -0
  16. package/tests/UiSlotsTest.res.mjs +196 -0
  17. package/tests/adapter/EventHistoryResolverTest.res +46 -0
  18. package/tests/adapter/EventHistoryResolverTest.res.mjs +41 -0
  19. package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -1
  20. package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +2 -2
  21. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +3 -3
  22. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +1 -1
  23. package/tests/components/commandgenerator/CommandGeneratorFixtures.res.mjs +1 -1
  24. package/tests/components/commandtopic/CommandTopicFixtures.res.mjs +1 -1
  25. package/tests/components/commandtopic/CommandTopicStreamFixtures.res.mjs +1 -1
  26. package/tests/components/dcb/DcbCrossPartitionFixtures.res.mjs +1 -1
  27. package/tests/components/dcb/DcbFixtures.res.mjs +1 -1
  28. package/tests/components/extensionpoint/ExtensionPointFixtures.res.mjs +2 -2
  29. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +1 -1
  30. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +1 -1
  31. package/tests/components/readmodel/DcbReadModelE2EFixtures.res.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
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.246 (2026-09-07)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **local:** the event-history warning names the caller that asked ([53df2dc](https://github.com/ReventlessDev/reventless-core/commit/53df2dc71a8b4c434c4c5b8c9589c5154315d2e8))
11
+ * **ui:** a declared slot module is named, not only written ([de000ba](https://github.com/ReventlessDev/reventless-core/commit/de000baf428249f64cfc719ed52df3760198b82a))
12
+ ### Features
13
+
14
+ * **ui:** a deployment can name the module that draws its own surfaces ([b43828c](https://github.com/ReventlessDev/reventless-core/commit/b43828ca50cddd31f7aefae016669fcb7c059838))
15
+
16
+
6
17
  # 3.0.0-alpha.245 (2026-09-04)
7
18
 
8
19
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.245",
3
+ "version": "3.0.0-alpha.246",
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": {
@@ -40,18 +40,18 @@
40
40
  "@reventlessdev/rescript-node": "2.0.0-alpha.9",
41
41
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.104",
44
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.203",
45
- "@reventlessdev/reventless-core": "3.0.0-alpha.256",
46
- "@reventlessdev/reventless-infra": "3.0.0-alpha.157",
43
+ "@reventlessdev/reventless-core": "3.0.0-alpha.257",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.105",
45
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.158",
46
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.121",
47
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.204",
47
48
  "@reventlessdev/reventless-seed": "1.0.0-alpha.19",
48
- "@reventlessdev/reventless-spec": "3.0.0-alpha.129",
49
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.120"
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.130"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
53
53
  "sury-ppx": "11.0.0-rc.2",
54
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.77"
54
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.78"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "rescript": "12.3.0"
package/src/Platform.res CHANGED
@@ -1471,6 +1471,10 @@ module MakeWithConfig = (
1471
1471
  // `makePlatform` serves the declared file in place of the host-shell
1472
1472
  // package's own dev-mode fallback, which an undeclared platform keeps.
1473
1473
  uiHintsFile?: string,
1474
+ // `makePlatform` serves the declared module beside the hints file. No
1475
+ // fallback to keep here: the host-shell package ships no slots module, so an
1476
+ // undeclared platform serves none.
1477
+ uiSlotsFile?: string,
1474
1478
  // `makePlatform` writes the curated manifest where the local host-shell
1475
1479
  // serves its static assets from, and points `config.json` at it.
1476
1480
  bakedManifest?: ReventlessInfra.Platform.bakedManifest,
@@ -1668,6 +1672,10 @@ module MakeWithConfig = (
1668
1672
  // declaring anything.
1669
1673
  ShellConfig.emit(
1670
1674
  ~bakedManifest=hostUiBundle->Option.flatMap(cfg => cfg.bakedManifest),
1675
+ // Names the module `UiSlots.emit` serves below. Both or neither: a key
1676
+ // without the file is a 404 the shell survives quietly, and a file without
1677
+ // the key is a module nothing imports.
1678
+ ~uiSlotsFile=?hostUiBundle->Option.flatMap(cfg => cfg.uiSlotsFile),
1671
1679
  ~shellConfig=hostUiBundle->Option.flatMap(cfg => cfg.shellConfig),
1672
1680
  )
1673
1681
  // Unconditional for the same reason, and one file over: it is also what
@@ -1687,6 +1695,15 @@ module MakeWithConfig = (
1687
1695
  ~uiHintsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiHintsFile),
1688
1696
  ~onReload=LocalEvents_Server.broadcastUiHintsChanged,
1689
1697
  )
1698
+ // The same pair for the module that draws what a hint cannot say. Also
1699
+ // unconditional, and for a reason the hints file does not have: this is what
1700
+ // removes a module a platform has stopped declaring, and leaving one behind
1701
+ // would go on drawing regions with nothing in the deployment to explain it.
1702
+ UiSlots.emit(~uiSlotsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiSlotsFile))
1703
+ let _ = UiSlots.watch(
1704
+ ~uiSlotsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiSlotsFile),
1705
+ ~onReload=LocalEvents_Server.broadcastUiSlotsChanged,
1706
+ )
1690
1707
  switch hostUiBundle->Option.flatMap(cfg => cfg.bakedManifest) {
1691
1708
  | None => ()
1692
1709
  | Some(cfg) => bakeManifest(~pluginComponents=plugins, ~config=cfg)
@@ -21,6 +21,7 @@ import * as Plugin$ReventlessCore from "@reventlessdev/reventless-core/src/plugi
21
21
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
22
22
  import * as Backend$ReventlessLocal from "./adapter/Backend.res.mjs";
23
23
  import * as UiHints$ReventlessLocal from "./UiHints.res.mjs";
24
+ import * as UiSlots$ReventlessLocal from "./UiSlots.res.mjs";
24
25
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
25
26
  import * as LocalBus$ReventlessLocal from "./adapter/LocalBus.res.mjs";
26
27
  import * as Api_Naming$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Naming.res.mjs";
@@ -1314,9 +1315,11 @@ function MakeWithConfig(Config) {
1314
1315
  adminResources: []
1315
1316
  });
1316
1317
  subscribeToPluginEvents();
1317
- ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), undefined);
1318
+ 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);
1318
1319
  UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined);
1319
1320
  UiHints$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined, LocalEvents_Server$ReventlessLocal.broadcastUiHintsChanged);
1321
+ UiSlots$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), undefined);
1322
+ UiSlots$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), undefined, LocalEvents_Server$ReventlessLocal.broadcastUiSlotsChanged);
1320
1323
  let cfg = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest);
1321
1324
  if (cfg !== undefined) {
1322
1325
  bakeManifest(plugins$1, cfg);
@@ -3053,9 +3056,11 @@ function Make($star) {
3053
3056
  adminResources: []
3054
3057
  });
3055
3058
  subscribeToPluginEvents();
3056
- ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), undefined);
3059
+ 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);
3057
3060
  UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined);
3058
3061
  UiHints$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined, LocalEvents_Server$ReventlessLocal.broadcastUiHintsChanged);
3062
+ UiSlots$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), undefined);
3063
+ UiSlots$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), undefined, LocalEvents_Server$ReventlessLocal.broadcastUiSlotsChanged);
3059
3064
  let cfg = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest);
3060
3065
  if (cfg !== undefined) {
3061
3066
  bakeManifest(plugins$1, cfg);
@@ -41,7 +41,11 @@ let manifestUrlOf = (config: ReventlessInfra.Platform.bakedManifest): string =>
41
41
  // caller matching no declared group gets and what every existing deployment has.
42
42
  let journeyManifestsKey = "journeyManifestUrls"
43
43
 
44
- let computedKeys = ["manifestUrl", journeyManifestsKey]
44
+ let computedKeys = [
45
+ "manifestUrl",
46
+ journeyManifestsKey,
47
+ ReventlessCore.Platform_UiSlots.configKey,
48
+ ]
45
49
 
46
50
  /**
47
51
  The overlay this platform puts on top of the shipped `config.json`.
@@ -52,10 +56,23 @@ let computedKeys = ["manifestUrl", journeyManifestsKey]
52
56
  */
53
57
  let overlay = (
54
58
  ~bakedManifest: option<ReventlessInfra.Platform.bakedManifest>,
59
+ ~uiSlotsFile: option<string>=?,
55
60
  ~shellConfig: option<dict<JSON.t>>,
56
61
  ): dict<JSON.t> => {
57
62
  let out = Dict.make()
58
63
 
64
+ // `UiSlots` serves the module; this is what tells the shell to import it. The
65
+ // two are separate writes to separate files, which is exactly why the name
66
+ // they share is stated once in `Platform_UiSlots` — and why withdrawal has to
67
+ // clear both. The baseline does that half: drop the key here and the shipped
68
+ // `config.json` comes back without it.
69
+ uiSlotsFile->Option.forEach(_ =>
70
+ out->Dict.set(
71
+ ReventlessCore.Platform_UiSlots.configKey,
72
+ JSON.Encode.string(ReventlessCore.Platform_UiSlots.url),
73
+ )
74
+ )
75
+
59
76
  bakedManifest->Option.forEach(config => {
60
77
  out->Dict.set("manifestUrl", JSON.Encode.string(manifestUrlOf(config)))
61
78
  // Omitted entirely when nothing is declared, so a single-audience deployment
@@ -108,13 +125,14 @@ let readObject = (~path: string, ~label: string): dict<JSON.t> =>
108
125
  */
109
126
  let emit = (
110
127
  ~bakedManifest: option<ReventlessInfra.Platform.bakedManifest>,
128
+ ~uiSlotsFile: option<string>=?,
111
129
  ~shellConfig: option<dict<JSON.t>>,
112
130
  // Test seam. The baseline dance is the part of this module with state behind
113
131
  // it — "boot twice and the second write still starts from the shipped file" is
114
132
  // not a property a pure function can carry.
115
133
  ~dir: option<string>=?,
116
134
  ) => {
117
- let overlay = overlay(~bakedManifest, ~shellConfig)
135
+ let overlay = overlay(~bakedManifest, ~uiSlotsFile?, ~shellConfig)
118
136
 
119
137
  switch (
120
138
  switch dir {
@@ -8,6 +8,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
8
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
9
9
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
10
10
  import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
11
+ import * as Platform_UiSlots$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_UiSlots.res.mjs";
11
12
  import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
12
13
 
13
14
  let log = Logger$ReventlessCore.fromEnv();
@@ -24,11 +25,15 @@ let journeyManifestsKey = "journeyManifestUrls";
24
25
 
25
26
  let computedKeys = [
26
27
  "manifestUrl",
27
- journeyManifestsKey
28
+ journeyManifestsKey,
29
+ Platform_UiSlots$ReventlessCore.configKey
28
30
  ];
29
31
 
30
- function overlay(bakedManifest, shellConfig) {
32
+ function overlay(bakedManifest, uiSlotsFile, shellConfig) {
31
33
  let out = {};
34
+ Stdlib_Option.forEach(uiSlotsFile, param => {
35
+ out[Platform_UiSlots$ReventlessCore.configKey] = Platform_UiSlots$ReventlessCore.url;
36
+ });
32
37
  Stdlib_Option.forEach(bakedManifest, config => {
33
38
  out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(config.key);
34
39
  let urls = Platform_BakedManifest$ReventlessCore.journeyUrls(config);
@@ -66,8 +71,8 @@ function readObject(path, label) {
66
71
  }
67
72
  }
68
73
 
69
- function emit(bakedManifest, shellConfig, dir) {
70
- let overlay$1 = overlay(bakedManifest, shellConfig);
74
+ function emit(bakedManifest, uiSlotsFile, shellConfig, dir) {
75
+ let overlay$1 = overlay(bakedManifest, uiSlotsFile, shellConfig);
71
76
  let dir$1 = dir !== undefined ? dir : HostShellDist$ReventlessLocal.dir();
72
77
  if (dir$1 === undefined) {
73
78
  if (Object.keys(overlay$1).length !== 0) {
@@ -0,0 +1,191 @@
1
+ // Serves the AutoUI slot module a deployment declared, where the local host
2
+ // shell serves its static assets from (`HostShellDist`).
3
+ //
4
+ // The same seam as `UiHints`, one file over: on AWS `uiSlotsFile` is read and
5
+ // written verbatim as a `BucketObject` beside `config.json`, and locally the
6
+ // declared file is copied into the served dist/ and watched, so editing a
7
+ // renderer is a browser refresh rather than a platform restart.
8
+ //
9
+ // Two things differ from the hints file, and both are why this is its own
10
+ // module rather than a parameter of that one.
11
+ //
12
+ // **There is no baseline, because there is nothing to protect.** The hints work
13
+ // had to reckon with the host-shell package shipping its own `ui-hints.json` as
14
+ // a dev fallback, which an undeclared platform goes on serving. The shell ships
15
+ // no slots module, so "undeclared" means "no file" with no third party's opinion
16
+ // underneath — and it should stay that way. A dev fallback here would be a
17
+ // fallback for *appearance*, and inheriting a stranger's appearance is exactly
18
+ // the failure that is hard to notice.
19
+ //
20
+ // So withdrawal removes the served file rather than restoring one. Any file at
21
+ // this path is one this module wrote: nothing else in the dist/ is named it, and
22
+ // the AWS bundle upload excludes the name for the same reason.
23
+ //
24
+ // **A broken module fails differently from broken hints.** Malformed hints
25
+ // decode leniently and the surface survives; a module that throws at import
26
+ // takes its whole registration with it. That is the shell's to report — log,
27
+ // skip, continue — and the only thing this side owes is not to make it worse by
28
+ // serving a stale copy after a failed edit. Hence no content check: serve what
29
+ // is on disk, and let the consumer say what it could not use.
30
+
31
+ let log = ReventlessCore.Logger.fromEnv()
32
+
33
+ // Shared with the AWS deploy and with both `config.json` writers, because the
34
+ // name the module is served under and the `uiSlotsUrl` naming it have to agree.
35
+ let fileName = ReventlessCore.Platform_UiSlots.fileName
36
+
37
+ /**
38
+ Write the declared slot module into the served `dist/`, or remove a previously
39
+ served one when nothing is declared.
40
+
41
+ A no-op when there is nothing to say and nothing was said before, so a platform
42
+ that declares no slots is byte-identical to one built before this existed.
43
+
44
+ Once a file is declared the write happens or fails loudly: a path that does not
45
+ resolve and a missing shell package are both the deployment's own mistake, and
46
+ both produce the same symptom if swallowed — regions that quietly go on being
47
+ drawn by their mode, which is indistinguishable from a renderer that was never
48
+ written.
49
+
50
+ What is deliberately *not* checked is the content. The deploy half cannot parse
51
+ a module either, and a check that only ran locally would let a file pass the
52
+ loop it was authored in and fail the one it ships to.
53
+ */
54
+ let emit = (
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.
59
+ ~dir: option<string>=?,
60
+ ) => {
61
+ // Read before anything is touched, so a bad declaration cannot leave the
62
+ // served file half-replaced.
63
+ let declared = uiSlotsFile->Option.map(path =>
64
+ switch NodeFs.readFileSync(path) {
65
+ | contents => contents
66
+ | exception _ =>
67
+ JsError.throwWithMessage(
68
+ `host UI ${fileName}: cannot read the declared uiSlotsFile at ${path} — ` ++
69
+ `the shell imports this file at boot, so a declaration pointing nowhere ` ++
70
+ `registers no renderers and says nothing about why.`,
71
+ )
72
+ }
73
+ )
74
+
75
+ switch (
76
+ switch dir {
77
+ | Some(_) as given => given
78
+ | None => HostShellDist.dir()
79
+ }
80
+ ) {
81
+ | None =>
82
+ // No shell installed is the ordinary case for a platform nobody points a
83
+ // browser at; only a declaration makes the missing package an error.
84
+ if declared->Option.isSome {
85
+ JsError.throwWithMessage(
86
+ `host UI ${fileName}: cannot resolve ${HostShellDist.package} from ${NodeProcess.cwd()} — ` ++
87
+ `the local shell imports its slot renderers from that package's dist/, so ` ++
88
+ `declaring a uiSlotsFile without the package installed would write nothing ` ++
89
+ `and leave every mode drawing its own regions.`,
90
+ )
91
+ }
92
+ | Some(dir) =>
93
+ let path = NodePath.join([dir, fileName])
94
+ switch declared {
95
+ | Some(contents) =>
96
+ NodeFs.writeFileSync(path, contents)
97
+ log.info(~comp="UiSlots", `wrote ${fileName} from the declared uiSlotsFile: ${path}`)
98
+ | None =>
99
+ // The counterpart to the hints baseline, and the reason this is a delete:
100
+ // leaving yesterday's renderers in place with nothing in the diff to
101
+ // explain them is the failure a baseline exists to prevent, and here the
102
+ // state to return to is "no file".
103
+ if NodeFs.existsSync(path) {
104
+ NodeFs.unlinkSync(path)
105
+ log.info(~comp="UiSlots", `removed ${fileName}: no uiSlotsFile is declared`)
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ Re-copy the declared module into the served `dist/` whenever the file changes,
113
+ so editing a renderer is a browser refresh rather than a platform restart.
114
+
115
+ Local only, and deliberately so: on AWS the file is an object written once by a
116
+ deploy, and "the running deployment follows my working copy" is not a thing a
117
+ deployment should be able to do.
118
+
119
+ **Failures here are logged, not thrown**, which is the one place this parts
120
+ company with `emit`, for the reason `UiHints.watch` gives: at boot a declaration
121
+ that does not resolve is the deployment's mistake and taking the process down is
122
+ the point, while mid-session an unreadable file is almost always an editor
123
+ saving in two steps, and killing a running dev server over a keystroke would
124
+ make the feature worse than the restart it replaces.
125
+
126
+ A module mid-save is a narrower window than a JSON file mid-save, because
127
+ nothing here parses it: only a read that fails outright is caught, and a save
128
+ that lands a syntactically broken module is served as written. That is the
129
+ consumer's to report, and it reports it against the file the developer is
130
+ looking at.
131
+
132
+ `onReload` runs only after a re-copy actually succeeded, so a subscriber cannot
133
+ be told to re-import a file that did not change.
134
+ */
135
+ let watch = (
136
+ ~uiSlotsFile: option<string>,
137
+ ~dir: option<string>=?,
138
+ ~onReload: unit => unit,
139
+ ): option<NodeFs.watcher> =>
140
+ uiSlotsFile->Option.flatMap(path => {
141
+ // Named apart from the `~dir` above, which is where the file is SERVED. This
142
+ // is where it is AUTHORED, and the two are never the same place — letting
143
+ // one shadow the other would re-serve the module into the source tree beside
144
+ // the file just edited.
145
+ let sourceDir = NodePath.dirname(path)
146
+ let base = NodePath.basename(path)
147
+ if !NodeFs.existsSync(sourceDir) {
148
+ // `emit` has already thrown on an unreadable declaration by the time this
149
+ // is reached, so this is the narrow case of a path whose directory went
150
+ // away between the two — worth a line, not worth a throw.
151
+ log.warn(
152
+ ~comp="UiSlots",
153
+ `not watching ${base}: ${sourceDir} does not exist, so changes to the declared ` ++
154
+ `uiSlotsFile will need a restart`,
155
+ )
156
+ None
157
+ } else {
158
+ // Editors coalesce badly: one save can raise `rename` and `change` within
159
+ // a millisecond of each other, and re-copying twice would re-import twice.
160
+ // The trailing timer collapses a burst into the single reload the
161
+ // developer actually made.
162
+ let pending = ref(None)
163
+ let reload = () => {
164
+ pending := None
165
+ switch emit(~uiSlotsFile, ~dir?) {
166
+ | () =>
167
+ log.info(~comp="UiSlots", `${base} changed — re-served`)
168
+ onReload()
169
+ | exception JsExn(e) =>
170
+ log.warn(
171
+ ~comp="UiSlots",
172
+ `${base} changed but could not be re-served: ` ++
173
+ e->JsExn.message->Option.getOr("unknown error"),
174
+ )
175
+ }
176
+ }
177
+ let watcher = NodeFs.watch(sourceDir, (_event, filename) =>
178
+ switch filename->Nullable.toOption {
179
+ | Some(name) if name == base =>
180
+ pending.contents->Option.forEach(clearTimeout)
181
+ pending := Some(setTimeout(reload, 50))
182
+ | _ => ()
183
+ }
184
+ )
185
+ log.info(~comp="UiSlots", `watching ${path} — edits are served without a restart`)
186
+ // Never the reason a process stays alive. A platform booted by a test that
187
+ // happens to declare slots would otherwise hold the event loop open and
188
+ // hang the run.
189
+ Some(watcher->NodeFs.watcherUnref)
190
+ }
191
+ })
@@ -0,0 +1,91 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodepath from "node:path";
5
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
9
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
10
+ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
11
+ import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
12
+ import * as Platform_UiSlots$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_UiSlots.res.mjs";
13
+
14
+ let log = Logger$ReventlessCore.fromEnv();
15
+
16
+ function emit(uiSlotsFile, dir) {
17
+ let declared = Stdlib_Option.map(uiSlotsFile, path => {
18
+ try {
19
+ return Nodefs.readFileSync(path, "utf8");
20
+ } catch (exn) {
21
+ return Stdlib_JsError.throwWithMessage(`host UI ` + Platform_UiSlots$ReventlessCore.fileName + `: cannot read the declared uiSlotsFile at ` + path + ` — the shell imports this file at boot, so a declaration pointing nowhere registers no renderers and says nothing about why.`);
22
+ }
23
+ });
24
+ let dir$1 = dir !== undefined ? dir : HostShellDist$ReventlessLocal.dir();
25
+ if (dir$1 === undefined) {
26
+ if (Stdlib_Option.isSome(declared)) {
27
+ return Stdlib_JsError.throwWithMessage(`host UI ` + Platform_UiSlots$ReventlessCore.fileName + `: cannot resolve ` + HostShellDist$ReventlessLocal.$$package + ` from ` + process.cwd() + ` — the local shell imports its slot renderers from that package's dist/, so declaring a uiSlotsFile without the package installed would write nothing and leave every mode drawing its own regions.`);
28
+ } else {
29
+ return;
30
+ }
31
+ }
32
+ let path = Nodepath.join(dir$1, Platform_UiSlots$ReventlessCore.fileName);
33
+ if (declared !== undefined) {
34
+ Nodefs.writeFileSync(path, declared, "utf8");
35
+ return log.info("UiSlots", undefined, `wrote ` + Platform_UiSlots$ReventlessCore.fileName + ` from the declared uiSlotsFile: ` + path);
36
+ } else if (Nodefs.existsSync(path)) {
37
+ Nodefs.unlinkSync(path);
38
+ return log.info("UiSlots", undefined, `removed ` + Platform_UiSlots$ReventlessCore.fileName + `: no uiSlotsFile is declared`);
39
+ } else {
40
+ return;
41
+ }
42
+ }
43
+
44
+ function watch(uiSlotsFile, dir, onReload) {
45
+ return Stdlib_Option.flatMap(uiSlotsFile, path => {
46
+ let sourceDir = Nodepath.dirname(path);
47
+ let base = Nodepath.basename(path);
48
+ if (Nodefs.existsSync(sourceDir)) {
49
+ let pending = {
50
+ contents: undefined
51
+ };
52
+ let reload = () => {
53
+ pending.contents = undefined;
54
+ let val;
55
+ try {
56
+ val = emit(uiSlotsFile, dir);
57
+ } catch (raw_e) {
58
+ let e = Primitive_exceptions.internalToException(raw_e);
59
+ if (e.RE_EXN_ID === "JsExn") {
60
+ return log.warn("UiSlots", undefined, base + ` changed but could not be re-served: ` + Stdlib_Option.getOr(Stdlib_JsExn.message(e._1), "unknown error"));
61
+ }
62
+ throw e;
63
+ }
64
+ log.info("UiSlots", undefined, base + ` changed — re-served`);
65
+ onReload();
66
+ };
67
+ let watcher = Nodefs.watch(sourceDir, (_event, filename) => {
68
+ if (!(filename == null) && filename === base) {
69
+ Stdlib_Option.forEach(pending.contents, prim => {
70
+ clearTimeout(prim);
71
+ });
72
+ pending.contents = Primitive_option.some(setTimeout(reload, 50));
73
+ return;
74
+ }
75
+ });
76
+ log.info("UiSlots", undefined, `watching ` + path + ` — edits are served without a restart`);
77
+ return Primitive_option.some(watcher.unref());
78
+ }
79
+ log.warn("UiSlots", undefined, `not watching ` + base + `: ` + sourceDir + ` does not exist, so changes to the declared uiSlotsFile will need a restart`);
80
+ });
81
+ }
82
+
83
+ let fileName = Platform_UiSlots$ReventlessCore.fileName;
84
+
85
+ export {
86
+ log,
87
+ fileName,
88
+ emit,
89
+ watch,
90
+ }
91
+ /* log Not a pure module */
@@ -124,6 +124,24 @@ let broadcastUiHintsChanged = (): unit =>
124
124
  ~event=frame([("kind", JSON.Encode.string("uiHintsChanged"))]),
125
125
  )
126
126
 
127
+ /** The channel a shell listens on to learn that this platform re-served its slot
128
+ module. A channel of its own rather than a second `kind` on the hints one,
129
+ because what a listener does about it differs: re-fetching hints is a data
130
+ refresh, while re-importing a module is not something a live page can
131
+ generally undo, and a listener has to be free to answer the two differently. */
132
+ let devUiSlotsChannel = "/default/dev/uiSlots"
133
+
134
+ /** Tell every connected shell that `ui-slots.js` has been re-served.
135
+
136
+ Carries no renderers, for the reason the hints signal carries no hints: this
137
+ is a cache-invalidation signal, not a second delivery path that could
138
+ disagree with the first. */
139
+ let broadcastUiSlotsChanged = (): unit =>
140
+ broadcast(
141
+ ~channel=devUiSlotsChannel,
142
+ ~event=frame([("kind", JSON.Encode.string("uiSlotsChanged"))]),
143
+ )
144
+
127
145
  /** LocalBus bridge: a Source B change descriptor becomes a publish on the
128
146
  same channel the AWS StateTopic Lambda would use. No-op without matching
129
147
  subscribers, so wiring order against server start doesn't matter. */
@@ -104,6 +104,15 @@ function broadcastUiHintsChanged() {
104
104
  ]]));
105
105
  }
106
106
 
107
+ let devUiSlotsChannel = "/default/dev/uiSlots";
108
+
109
+ function broadcastUiSlotsChanged() {
110
+ broadcast(devUiSlotsChannel, frame([[
111
+ "kind",
112
+ "uiSlotsChanged"
113
+ ]]));
114
+ }
115
+
107
116
  function broadcastStateChange(name, descriptor) {
108
117
  let entityKey = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(descriptor), o => o["id"]), Stdlib_JSON.Decode.string), "");
109
118
  if (entityKey === "") {
@@ -339,6 +348,8 @@ export {
339
348
  broadcast,
340
349
  devUiHintsChannel,
341
350
  broadcastUiHintsChanged,
351
+ devUiSlotsChannel,
352
+ broadcastUiSlotsChanged,
342
353
  broadcastStateChange,
343
354
  decodeStringField,
344
355
  handleFrame,
@@ -133,6 +133,50 @@ let readFilter = (args: JSON.t): filter => {
133
133
  }
134
134
  }
135
135
 
136
+ // The filter keys a caller did send. A caller that lost its entity id still
137
+ // sends the rest, so naming them is what tells a reader which client this was.
138
+ let suppliedFilterKeys = (f: filter): array<string> =>
139
+ [
140
+ ("entityId", f.entityId->Option.isSome),
141
+ ("tagKey", f.tagKey->Option.isSome),
142
+ ("tagValue", f.tagValue->Option.isSome),
143
+ ("eventTypes", f.eventTypes->Option.isSome),
144
+ ("user", f.user->Option.isSome),
145
+ ("timeFrom", f.timeFrom->Option.isSome),
146
+ ("timeTo", f.timeTo->Option.isSome),
147
+ ]->Array.filterMap(((key, supplied)) => supplied ? Some(key) : None)
148
+
149
+ /**
150
+ Who asked, named the way the caller named itself: the GraphQL operation name
151
+ off yoga's request params, and the identity `buildAuthContext` resolved.
152
+
153
+ Both are best-effort. A context carrying neither still logs the field, because
154
+ a warning that needs a well-formed context to appear is a warning that goes
155
+ missing exactly when something is wrong.
156
+ */
157
+ let callerOf = (ctx: JSON.t): string => {
158
+ let operation =
159
+ ctx
160
+ ->JSON.Decode.object
161
+ ->Option.flatMap(d => d->Dict.get("params"))
162
+ ->Option.flatMap(JSON.Decode.object)
163
+ ->Option.flatMap(d => d->Dict.get("operationName"))
164
+ ->Option.flatMap(JSON.Decode.string)
165
+ let user = Auth_GraphqlContext.extractIdentity(ctx).userId
166
+ switch operation {
167
+ | Some(name) => `operation "${name}" as ${user}`
168
+ | None => `an unnamed operation as ${user}`
169
+ }
170
+ }
171
+
172
+ // What the caller sent, in one clause: who asked and which filter keys came
173
+ // with it.
174
+ let describeCaller = (~ctx: JSON.t, ~filter: filter): string =>
175
+ switch suppliedFilterKeys(filter) {
176
+ | [] => `${callerOf(ctx)}, with no filter`
177
+ | keys => `${callerOf(ctx)}, with filter [${keys->Array.join(", ")}]`
178
+ }
179
+
136
180
  // The tag a caller means: the precise (tagKey, tagValue) pair when given,
137
181
  // otherwise the `entityId` shortcut matched against ANY tag value.
138
182
  let matchesTag = (r: record, f: filter): bool =>
@@ -305,7 +349,7 @@ module Make = (Bus: LocalBus.T) => {
305
349
  let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (
306
350
  _root,
307
351
  args,
308
- _ctx,
352
+ ctx,
309
353
  ) => {
310
354
  let f = readFilter(args)
311
355
  let records = switch Bus.getEventLogReplay(entry.busKey) {
@@ -315,10 +359,13 @@ module Make = (Bus: LocalBus.T) => {
315
359
  | None =>
316
360
  // Not a silent empty page: an aggregate log genuinely cannot be
317
361
  // read without an id, and a caller who omitted one asked a
318
- // question this log cannot answer.
362
+ // question this log cannot answer. Named, because the client that
363
+ // sent it is what a reader needs next and the warning alone sent
364
+ // them hunting.
319
365
  log.warn(
320
366
  ~comp="EventHistoryResolvers_GraphQL",
321
- `${fieldName}: ${displayName} is an aggregate event log — it can only be read per entity. Supply filter.entityId.`,
367
+ `${fieldName}: ${displayName} is an aggregate event log — it can only be read per entity. ` ++
368
+ `Supply filter.entityId. Asked by ${describeCaller(~ctx, ~filter=f)}.`,
322
369
  )
323
370
  []
324
371
  }