@reventlessdev/reventless-local 3.0.0-alpha.245 → 3.0.0-alpha.247
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 +18 -0
- package/package.json +10 -10
- package/src/Platform.res +17 -0
- package/src/Platform.res.mjs +7 -2
- package/src/ShellConfig.res +20 -2
- package/src/ShellConfig.res.mjs +9 -4
- package/src/UiSlots.res +191 -0
- package/src/UiSlots.res.mjs +91 -0
- package/src/adapter/Api/LocalEvents_Server.res +18 -0
- package/src/adapter/Api/LocalEvents_Server.res.mjs +11 -0
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +50 -3
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs +62 -2
- package/tests/ShellConfigTest.res +41 -0
- package/tests/ShellConfigTest.res.mjs +40 -17
- package/tests/UiSlotsTest.res +197 -0
- package/tests/UiSlotsTest.res.mjs +196 -0
- package/tests/adapter/EventHistoryResolverTest.res +46 -0
- package/tests/adapter/EventHistoryResolverTest.res.mjs +41 -0
- package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -1
- package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +2 -2
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +3 -3
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +1 -1
- package/tests/components/commandgenerator/CommandGeneratorFixtures.res.mjs +1 -1
- package/tests/components/commandtopic/CommandTopicFixtures.res.mjs +1 -1
- package/tests/components/commandtopic/CommandTopicStreamFixtures.res.mjs +1 -1
- package/tests/components/dcb/DcbCrossPartitionFixtures.res.mjs +1 -1
- package/tests/components/dcb/DcbFixtures.res.mjs +1 -1
- package/tests/components/extensionpoint/ExtensionPointFixtures.res.mjs +2 -2
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +1 -1
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +1 -1
- package/tests/components/readmodel/DcbReadModelE2EFixtures.res.mjs +1 -1
- package/tests/components/stateviewslice/StateViewSliceDisplayNameFixtures.res +98 -0
- package/tests/components/stateviewslice/StateViewSliceDisplayNameFixtures.res.mjs +187 -0
- package/tests/components/stateviewslice/StateViewSliceDisplayNameTest.res +32 -0
- package/tests/components/stateviewslice/StateViewSliceDisplayNameTest.res.mjs +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,24 @@
|
|
|
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.247 (2026-09-07)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **core:** a state view slice composes its [@display](https://github.com/display)Name like a read model ([851aa1d](https://github.com/ReventlessDev/reventless-core/commit/851aa1d12ddae9dd39f91084f5719cd98fe1cbd4))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.246 (2026-09-07)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **local:** the event-history warning names the caller that asked ([53df2dc](https://github.com/ReventlessDev/reventless-core/commit/53df2dc71a8b4c434c4c5b8c9589c5154315d2e8))
|
|
18
|
+
* **ui:** a declared slot module is named, not only written ([de000ba](https://github.com/ReventlessDev/reventless-core/commit/de000baf428249f64cfc719ed52df3760198b82a))
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* **ui:** a deployment can name the module that draws its own surfaces ([b43828c](https://github.com/ReventlessDev/reventless-core/commit/b43828ca50cddd31f7aefae016669fcb7c059838))
|
|
22
|
+
|
|
23
|
+
|
|
6
24
|
# 3.0.0-alpha.245 (2026-09-04)
|
|
7
25
|
|
|
8
26
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.247",
|
|
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": {
|
|
@@ -36,22 +36,22 @@
|
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
37
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
38
38
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
|
|
39
|
+
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
|
|
39
40
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
40
41
|
"@reventlessdev/rescript-node": "2.0.0-alpha.9",
|
|
41
|
-
"@reventlessdev/
|
|
42
|
-
"@reventlessdev/
|
|
43
|
-
"@reventlessdev/reventless-
|
|
44
|
-
"@reventlessdev/reventless-
|
|
45
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.256",
|
|
46
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.157",
|
|
42
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.258",
|
|
43
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.205",
|
|
44
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.122",
|
|
45
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.159",
|
|
47
46
|
"@reventlessdev/reventless-seed": "1.0.0-alpha.19",
|
|
48
|
-
"@reventlessdev/reventless-
|
|
49
|
-
"@reventlessdev/
|
|
47
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.106",
|
|
48
|
+
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
49
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.131"
|
|
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.
|
|
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)
|
package/src/Platform.res.mjs
CHANGED
|
@@ -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);
|
package/src/ShellConfig.res
CHANGED
|
@@ -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 = [
|
|
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 {
|
package/src/ShellConfig.res.mjs
CHANGED
|
@@ -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) {
|
package/src/UiSlots.res
ADDED
|
@@ -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
|
-
|
|
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.
|
|
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
|
}
|