@reventlessdev/reventless-local 3.0.0-alpha.244 → 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 (48) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +11 -11
  3. package/src/LocalPlatformRegistry.res +54 -63
  4. package/src/LocalPlatformRegistry.res.mjs +24 -5
  5. package/src/LocalPlatformStart.res +84 -0
  6. package/src/LocalPlatformStart.res.mjs +99 -0
  7. package/src/Platform.res +33 -0
  8. package/src/Platform.res.mjs +18 -4
  9. package/src/ShellConfig.res +20 -2
  10. package/src/ShellConfig.res.mjs +9 -4
  11. package/src/UiSlots.res +191 -0
  12. package/src/UiSlots.res.mjs +91 -0
  13. package/src/adapter/Api/LocalEvents_Server.res +18 -0
  14. package/src/adapter/Api/LocalEvents_Server.res.mjs +11 -0
  15. package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +50 -3
  16. package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs +62 -2
  17. package/src/adapter/LocalBus.res +30 -27
  18. package/src/adapter/LocalBus.res.mjs +14 -18
  19. package/src/adapter/LocalEventTap.res +139 -0
  20. package/src/adapter/LocalEventTap.res.mjs +177 -0
  21. package/tests/EventTapSocketFixtures.mjs +17 -0
  22. package/tests/LocalEventTapTest.res +154 -0
  23. package/tests/LocalEventTapTest.res.mjs +140 -0
  24. package/tests/LocalPlatformRegistryTest.res +61 -0
  25. package/tests/LocalPlatformRegistryTest.res.mjs +41 -1
  26. package/tests/LocalPlatformStartTest.res +176 -0
  27. package/tests/LocalPlatformStartTest.res.mjs +210 -0
  28. package/tests/ShellConfigTest.res +41 -0
  29. package/tests/ShellConfigTest.res.mjs +40 -17
  30. package/tests/UiSlotsTest.res +197 -0
  31. package/tests/UiSlotsTest.res.mjs +196 -0
  32. package/tests/adapter/EventHistoryResolverTest.res +46 -0
  33. package/tests/adapter/EventHistoryResolverTest.res.mjs +41 -0
  34. package/tests/adapter/EventTapTest.res +16 -6
  35. package/tests/adapter/EventTapTest.res.mjs +5 -1
  36. package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -1
  37. package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +2 -2
  38. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +3 -3
  39. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +1 -1
  40. package/tests/components/commandgenerator/CommandGeneratorFixtures.res.mjs +1 -1
  41. package/tests/components/commandtopic/CommandTopicFixtures.res.mjs +1 -1
  42. package/tests/components/commandtopic/CommandTopicStreamFixtures.res.mjs +1 -1
  43. package/tests/components/dcb/DcbCrossPartitionFixtures.res.mjs +1 -1
  44. package/tests/components/dcb/DcbFixtures.res.mjs +1 -1
  45. package/tests/components/extensionpoint/ExtensionPointFixtures.res.mjs +2 -2
  46. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +1 -1
  47. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +1 -1
  48. package/tests/components/readmodel/DcbReadModelE2EFixtures.res.mjs +1 -1
@@ -0,0 +1,154 @@
1
+ // The tap's second sink. A tool that did not spawn the platform has no stdout to
2
+ // read, so an attached runner's timeline is dark without this.
3
+
4
+ @@warning("-44")
5
+
6
+ open JestGlobals
7
+
8
+ type recorder = {socket: NodeNet.socket, written: array<string>}
9
+
10
+ @module("./EventTapSocketFixtures.mjs")
11
+ external recordingSocket: unit => recorder = "recordingSocket"
12
+
13
+ @module("./EventTapSocketFixtures.mjs")
14
+ external throwingSocket: unit => NodeNet.socket = "throwingSocket"
15
+
16
+ let envWith = (value: option<string>): dict<string> =>
17
+ switch value {
18
+ | Some(v) => Dict.fromArray([(LocalEventTap.envVar, v)])
19
+ | None => Dict.make()
20
+ }
21
+
22
+ describe("LocalEventTap.settingFromEnv", () => {
23
+ // The default is the point: the runner attaches to whatever `pnpm run serve`
24
+ // started, and a tap nobody remembered to switch on is a dark timeline.
25
+ testSync("defaults to an ephemeral socket when the var is unset", () => {
26
+ expect(LocalEventTap.settingFromEnv(~env=envWith(None)))->toEqual(LocalEventTap.Ephemeral)
27
+ expect(LocalEventTap.settingFromEnv(~env=envWith(Some(""))))->toEqual(LocalEventTap.Ephemeral)
28
+ })
29
+
30
+ testSync("takes a port when the value is one", () =>
31
+ expect(LocalEventTap.settingFromEnv(~env=envWith(Some("4100"))))->toEqual(
32
+ LocalEventTap.Fixed(4100),
33
+ )
34
+ )
35
+
36
+ // `ndjson` is what the runner has passed since the tap existed, and `=1` means
37
+ // "on" to anyone who writes it — never port 1. Both take the default socket.
38
+ testSync("treats a non-port value as the default, not as a port", () => {
39
+ expect(LocalEventTap.settingFromEnv(~env=envWith(Some("ndjson"))))->toEqual(
40
+ LocalEventTap.Ephemeral,
41
+ )
42
+ expect(LocalEventTap.settingFromEnv(~env=envWith(Some("1"))))->toEqual(LocalEventTap.Ephemeral)
43
+ expect(LocalEventTap.settingFromEnv(~env=envWith(Some("80"))))->toEqual(LocalEventTap.Ephemeral)
44
+ expect(LocalEventTap.settingFromEnv(~env=envWith(Some("70000"))))->toEqual(
45
+ LocalEventTap.Ephemeral,
46
+ )
47
+ })
48
+
49
+ testSync("switches the socket off on request", () =>
50
+ ["off", "false", "none"]->Array.forEach(v =>
51
+ expect(LocalEventTap.settingFromEnv(~env=envWith(Some(v))))->toEqual(LocalEventTap.Off)
52
+ )
53
+ )
54
+ })
55
+
56
+ describe("LocalEventTap.stdoutEnabled", () => {
57
+ // The half with a visible cost keeps its opt-in: a line per event would drown
58
+ // `pnpm run serve`, which is the command the socket default exists to serve.
59
+ testSync("stays off unless the var is set", () => {
60
+ expect(LocalEventTap.stdoutEnabled(~env=envWith(None)))->toEqual(false)
61
+ expect(LocalEventTap.stdoutEnabled(~env=envWith(Some("ndjson"))))->toEqual(true)
62
+ expect(LocalEventTap.stdoutEnabled(~env=envWith(Some("4100"))))->toEqual(true)
63
+ })
64
+
65
+ testSync("is silenced by off, along with the socket", () =>
66
+ expect(LocalEventTap.stdoutEnabled(~env=envWith(Some("off"))))->toEqual(false)
67
+ )
68
+ })
69
+
70
+ describe("LocalEventTap.broadcast", () => {
71
+ testSync("hands every reader the line, newline-terminated", () => {
72
+ LocalEventTap.resetForTests()
73
+ let a = recordingSocket()
74
+ let b = recordingSocket()
75
+ LocalEventTap.addConnectionForTests(a.socket)
76
+ LocalEventTap.addConnectionForTests(b.socket)
77
+
78
+ LocalEventTap.broadcast(`@@RVLESS_EVT@@ {"seq":1}`)
79
+
80
+ expect(a.written)->toEqual([`@@RVLESS_EVT@@ {"seq":1}\n`])
81
+ expect(b.written)->toEqual([`@@RVLESS_EVT@@ {"seq":1}\n`])
82
+ })
83
+
84
+ // A write to a closed peer throws. One dead reader must not cost the others
85
+ // their events, or the platform its run.
86
+ testSync("drops a reader whose write throws, and keeps serving the rest", () => {
87
+ LocalEventTap.resetForTests()
88
+ let live = recordingSocket()
89
+ LocalEventTap.addConnectionForTests(throwingSocket())
90
+ LocalEventTap.addConnectionForTests(live.socket)
91
+
92
+ LocalEventTap.broadcast("first")
93
+ LocalEventTap.broadcast("second")
94
+
95
+ expect(live.written)->toEqual(["first\n", "second\n"])
96
+ })
97
+
98
+ testSync("is inert with no readers", () => {
99
+ LocalEventTap.resetForTests()
100
+ LocalEventTap.broadcast("nobody is listening")
101
+ expect(LocalEventTap.port())->toEqual(None)
102
+ })
103
+ })
104
+
105
+ describe("LocalEventTap.start", () => {
106
+ testSync("does not listen when the socket is switched off", () => {
107
+ LocalEventTap.resetForTests()
108
+ LocalEventTap.start(~env=envWith(Some("off")), ())
109
+ expect(LocalEventTap.port())->toEqual(None)
110
+ })
111
+
112
+ // The default path, and the one that matters: no env var at all, a port the OS
113
+ // picks, reported only once it is actually bound.
114
+ test("binds an ephemeral port by default and reports the one it got", async () => {
115
+ LocalEventTap.resetForTests()
116
+ let bound = await Promise.make((resolve, _reject) =>
117
+ LocalEventTap.start(~env=envWith(None), ~onBound=p => resolve(p), ())
118
+ )
119
+ expect(bound > 0)->toEqual(true)
120
+ // What the registry entry will carry — the real port, not a hoped-for one.
121
+ expect(LocalEventTap.port())->toEqual(Some(bound))
122
+ await LocalEventTap.stopForTests()
123
+ })
124
+
125
+ // The round trip the runner will make: connect to the advertised port, read
126
+ // the same sentinel-prefixed lines it reads off stdout today.
127
+ test("serves the lines it is sent on the port it was named", async () => {
128
+ LocalEventTap.resetForTests()
129
+ let port = 47311
130
+ let bound = await Promise.make((resolve, _reject) =>
131
+ LocalEventTap.start(~env=envWith(Some(port->Int.toString)), ~onBound=p => resolve(p), ())
132
+ )
133
+ expect(bound)->toEqual(port)
134
+
135
+ let received = await Promise.make((resolve, _reject) => {
136
+ let socket = NodeNet.connect(port, "127.0.0.1", () => ())
137
+ socket->NodeNet.setEncoding("utf8")
138
+ socket->NodeNet.onSocketError(e =>
139
+ resolve("connect failed: " ++ e->JsExn.message->Option.getOr("unknown"))
140
+ )
141
+ // Re-sent until it lands: the server's accept and the client's connect are
142
+ // separate events, so a single broadcast could beat the connection.
143
+ let ticker = ref(None)
144
+ socket->NodeNet.onSocketData(chunk => {
145
+ ticker.contents->Option.forEach(clearInterval)
146
+ resolve(chunk)
147
+ })
148
+ ticker := Some(setInterval(() => LocalEventTap.broadcast(`@@RVLESS_EVT@@ {"seq":7}`), 5))
149
+ })
150
+
151
+ expect(received)->toEqual(`@@RVLESS_EVT@@ {"seq":7}\n`)
152
+ await LocalEventTap.stopForTests()
153
+ })
154
+ })
@@ -0,0 +1,140 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodenet from "node:net";
4
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
7
+ import * as EventTapSocketFixturesMjs from "./EventTapSocketFixtures.mjs";
8
+ import * as LocalEventTap$ReventlessLocal from "../src/adapter/LocalEventTap.res.mjs";
9
+
10
+ function recordingSocket(prim) {
11
+ return EventTapSocketFixturesMjs.recordingSocket();
12
+ }
13
+
14
+ function throwingSocket(prim) {
15
+ return EventTapSocketFixturesMjs.throwingSocket();
16
+ }
17
+
18
+ function envWith(value) {
19
+ if (value !== undefined) {
20
+ return Object.fromEntries([[
21
+ LocalEventTap$ReventlessLocal.envVar,
22
+ value
23
+ ]]);
24
+ } else {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ globalThis.describe("LocalEventTap.settingFromEnv", () => {
30
+ globalThis.test("defaults to an ephemeral socket when the var is unset", () => {
31
+ globalThis.expect(LocalEventTap$ReventlessLocal.settingFromEnv(envWith(undefined))).toEqual("Ephemeral");
32
+ globalThis.expect(LocalEventTap$ReventlessLocal.settingFromEnv(envWith(""))).toEqual("Ephemeral");
33
+ });
34
+ globalThis.test("takes a port when the value is one", () => {
35
+ globalThis.expect(LocalEventTap$ReventlessLocal.settingFromEnv(envWith("4100"))).toEqual({
36
+ TAG: "Fixed",
37
+ _0: 4100
38
+ });
39
+ });
40
+ globalThis.test("treats a non-port value as the default, not as a port", () => {
41
+ globalThis.expect(LocalEventTap$ReventlessLocal.settingFromEnv(envWith("ndjson"))).toEqual("Ephemeral");
42
+ globalThis.expect(LocalEventTap$ReventlessLocal.settingFromEnv(envWith("1"))).toEqual("Ephemeral");
43
+ globalThis.expect(LocalEventTap$ReventlessLocal.settingFromEnv(envWith("80"))).toEqual("Ephemeral");
44
+ globalThis.expect(LocalEventTap$ReventlessLocal.settingFromEnv(envWith("70000"))).toEqual("Ephemeral");
45
+ });
46
+ globalThis.test("switches the socket off on request", () => {
47
+ [
48
+ "off",
49
+ "false",
50
+ "none"
51
+ ].forEach(v => {
52
+ globalThis.expect(LocalEventTap$ReventlessLocal.settingFromEnv(envWith(v))).toEqual("Off");
53
+ });
54
+ });
55
+ });
56
+
57
+ globalThis.describe("LocalEventTap.stdoutEnabled", () => {
58
+ globalThis.test("stays off unless the var is set", () => {
59
+ globalThis.expect(LocalEventTap$ReventlessLocal.stdoutEnabled(envWith(undefined))).toEqual(false);
60
+ globalThis.expect(LocalEventTap$ReventlessLocal.stdoutEnabled(envWith("ndjson"))).toEqual(true);
61
+ globalThis.expect(LocalEventTap$ReventlessLocal.stdoutEnabled(envWith("4100"))).toEqual(true);
62
+ });
63
+ globalThis.test("is silenced by off, along with the socket", () => {
64
+ globalThis.expect(LocalEventTap$ReventlessLocal.stdoutEnabled(envWith("off"))).toEqual(false);
65
+ });
66
+ });
67
+
68
+ globalThis.describe("LocalEventTap.broadcast", () => {
69
+ globalThis.test("hands every reader the line, newline-terminated", () => {
70
+ LocalEventTap$ReventlessLocal.resetForTests();
71
+ let a = EventTapSocketFixturesMjs.recordingSocket();
72
+ let b = EventTapSocketFixturesMjs.recordingSocket();
73
+ LocalEventTap$ReventlessLocal.addConnectionForTests(a.socket);
74
+ LocalEventTap$ReventlessLocal.addConnectionForTests(b.socket);
75
+ LocalEventTap$ReventlessLocal.broadcast(`@@RVLESS_EVT@@ {"seq":1}`);
76
+ globalThis.expect(a.written).toEqual([`@@RVLESS_EVT@@ {"seq":1}\n`]);
77
+ globalThis.expect(b.written).toEqual([`@@RVLESS_EVT@@ {"seq":1}\n`]);
78
+ });
79
+ globalThis.test("drops a reader whose write throws, and keeps serving the rest", () => {
80
+ LocalEventTap$ReventlessLocal.resetForTests();
81
+ let live = EventTapSocketFixturesMjs.recordingSocket();
82
+ LocalEventTap$ReventlessLocal.addConnectionForTests(EventTapSocketFixturesMjs.throwingSocket());
83
+ LocalEventTap$ReventlessLocal.addConnectionForTests(live.socket);
84
+ LocalEventTap$ReventlessLocal.broadcast("first");
85
+ LocalEventTap$ReventlessLocal.broadcast("second");
86
+ globalThis.expect(live.written).toEqual([
87
+ "first\n",
88
+ "second\n"
89
+ ]);
90
+ });
91
+ globalThis.test("is inert with no readers", () => {
92
+ LocalEventTap$ReventlessLocal.resetForTests();
93
+ LocalEventTap$ReventlessLocal.broadcast("nobody is listening");
94
+ globalThis.expect(LocalEventTap$ReventlessLocal.port()).toEqual(undefined);
95
+ });
96
+ });
97
+
98
+ globalThis.describe("LocalEventTap.start", () => {
99
+ globalThis.test("does not listen when the socket is switched off", () => {
100
+ LocalEventTap$ReventlessLocal.resetForTests();
101
+ LocalEventTap$ReventlessLocal.start(envWith("off"), undefined, undefined);
102
+ globalThis.expect(LocalEventTap$ReventlessLocal.port()).toEqual(undefined);
103
+ });
104
+ globalThis.test("binds an ephemeral port by default and reports the one it got", async () => {
105
+ LocalEventTap$ReventlessLocal.resetForTests();
106
+ let bound = await new Promise((resolve, _reject) => LocalEventTap$ReventlessLocal.start(envWith(undefined), p => resolve(p), undefined));
107
+ globalThis.expect(bound > 0).toEqual(true);
108
+ globalThis.expect(LocalEventTap$ReventlessLocal.port()).toEqual(bound);
109
+ return await LocalEventTap$ReventlessLocal.stopForTests();
110
+ });
111
+ globalThis.test("serves the lines it is sent on the port it was named", async () => {
112
+ LocalEventTap$ReventlessLocal.resetForTests();
113
+ let bound = await new Promise((resolve, _reject) => LocalEventTap$ReventlessLocal.start(envWith((47311).toString()), p => resolve(p), undefined));
114
+ globalThis.expect(bound).toEqual(47311);
115
+ let received = await new Promise((resolve, _reject) => {
116
+ let socket = Nodenet.createConnection(47311, "127.0.0.1", () => {});
117
+ socket.setEncoding("utf8");
118
+ socket.on("error", e => resolve("connect failed: " + Stdlib_Option.getOr(Stdlib_JsExn.message(e), "unknown")));
119
+ let ticker = {
120
+ contents: undefined
121
+ };
122
+ socket.on("data", chunk => {
123
+ Stdlib_Option.forEach(ticker.contents, prim => {
124
+ clearInterval(prim);
125
+ });
126
+ resolve(chunk);
127
+ });
128
+ ticker.contents = Primitive_option.some(setInterval(() => LocalEventTap$ReventlessLocal.broadcast(`@@RVLESS_EVT@@ {"seq":7}`), 5));
129
+ });
130
+ globalThis.expect(received).toEqual(`@@RVLESS_EVT@@ {"seq":7}\n`);
131
+ return await LocalEventTap$ReventlessLocal.stopForTests();
132
+ });
133
+ });
134
+
135
+ export {
136
+ recordingSocket,
137
+ throwingSocket,
138
+ envWith,
139
+ }
140
+ /* Not a pure module */
@@ -141,3 +141,64 @@ describe("LocalSeedTarget.storePath", () => {
141
141
  )->toEqual(true)
142
142
  })
143
143
  })
144
+
145
+ describe("LocalPlatformRegistry.tapPort", () => {
146
+ // Optional on purpose: `list` DELETES an entry it cannot decode, so a required
147
+ // field would make every platform from an older build vanish from the seed
148
+ // tools rather than fail loudly.
149
+ testSync("reads an entry written before the field existed", () => {
150
+ let cwd = tempRoot()
151
+ let dir = LocalPlatformRegistry.runningDir(~cwd, ())
152
+ NodeFs.mkdirSync(dir, {recursive: true})
153
+ let path = NodePath.join([dir, "4000.json"])
154
+ NodeFs.writeFileSync(
155
+ path,
156
+ `{"app":"old","port":4000,"pid":${NodeProcess.pid->Int.toString},"endpoint":"http://localhost:4000/graphql","loginEndpoint":"http://localhost:4000/__inmemory/login","store":{"kind":"memory"},"startedAt":"2026-08-14T00:00:00.000Z"}`,
157
+ )
158
+
159
+ switch LocalPlatformRegistry.list(~cwd, ())->Array.get(0) {
160
+ | Some(entry) => expect(entry.tapPort)->toEqual(None)
161
+ | None => fail("an entry without tapPort must still be listed")
162
+ }
163
+ expect(NodeFs.existsSync(path))->toEqual(true)
164
+ })
165
+
166
+ // The socket binds asynchronously, so the entry is published first and the port
167
+ // filled in on the listen callback — which is what keeps it from ever naming a
168
+ // port that is not listening.
169
+ testSync("fills the port in on an entry already written", () => {
170
+ let cwd = tempRoot()
171
+ let _ = writeAt(~cwd, ~port=4000, ~store=memoryStore)
172
+ expect(LocalPlatformRegistry.list(~cwd, ())->Array.map(e => e.tapPort))->toEqual([None])
173
+
174
+ LocalPlatformRegistry.publishTapPort(~port=4000, ~tapPort=58909, ~cwd)
175
+
176
+ switch LocalPlatformRegistry.list(~cwd, ())->Array.get(0) {
177
+ | Some(entry) =>
178
+ expect(entry.tapPort)->toEqual(Some(58909))
179
+ // The rest of the entry survives the rewrite — a reader still finds the
180
+ // endpoint and store it came for.
181
+ expect(entry.endpoint)->toEqual("http://localhost:4000/graphql")
182
+ expect(entry.store.kind)->toEqual("memory")
183
+ | None => fail("the updated entry must still be listed")
184
+ }
185
+ })
186
+
187
+ testSync("ignores a platform that never published an entry", () =>
188
+ LocalPlatformRegistry.publishTapPort(~port=4000, ~tapPort=1, ~cwd=tempRoot())
189
+ )
190
+
191
+ testSync("round-trips the port a platform serving a tap publishes", () => {
192
+ let cwd = tempRoot()
193
+ let _ = LocalPlatformRegistry.write(
194
+ ~port=4000,
195
+ ~endpoint="http://localhost:4000/graphql",
196
+ ~loginEndpoint="http://localhost:4000/__inmemory/login",
197
+ ~store=memoryStore,
198
+ ~tapPort=4100,
199
+ ~cwd,
200
+ )
201
+
202
+ expect(LocalPlatformRegistry.list(~cwd, ())->Array.map(e => e.tapPort))->toEqual([Some(4100)])
203
+ })
204
+ })
@@ -25,7 +25,7 @@ let memoryStore = {
25
25
 
26
26
  function writeAt(cwd, port, store, pidOpt) {
27
27
  let pid = pidOpt !== undefined ? pidOpt : process.pid;
28
- return LocalPlatformRegistry$ReventlessLocal.write(port, `http://localhost:` + port.toString() + `/graphql`, `http://localhost:` + port.toString() + `/__inmemory/login`, store, pid, cwd);
28
+ return LocalPlatformRegistry$ReventlessLocal.write(port, `http://localhost:` + port.toString() + `/graphql`, `http://localhost:` + port.toString() + `/__inmemory/login`, store, undefined, pid, cwd);
29
29
  }
30
30
 
31
31
  globalThis.describe("LocalPlatformRegistry", () => {
@@ -140,6 +140,46 @@ globalThis.describe("LocalSeedTarget.storePath", () => {
140
140
  });
141
141
  });
142
142
 
143
+ globalThis.describe("LocalPlatformRegistry.tapPort", () => {
144
+ globalThis.test("reads an entry written before the field existed", () => {
145
+ let cwd = tempRoot();
146
+ let dir = LocalPlatformRegistry$ReventlessLocal.runningDir(cwd, undefined);
147
+ Nodefs.mkdirSync(dir, {
148
+ recursive: true
149
+ });
150
+ let path = Nodepath.join(dir, "4000.json");
151
+ Nodefs.writeFileSync(path, `{"app":"old","port":4000,"pid":` + process.pid.toString() + `,"endpoint":"http://localhost:4000/graphql","loginEndpoint":"http://localhost:4000/__inmemory/login","store":{"kind":"memory"},"startedAt":"2026-08-14T00:00:00.000Z"}`, "utf8");
152
+ let entry = LocalPlatformRegistry$ReventlessLocal.list(cwd, undefined)[0];
153
+ if (entry !== undefined) {
154
+ globalThis.expect(entry.tapPort).toEqual(undefined);
155
+ } else {
156
+ JestGlobals.fail("an entry without tapPort must still be listed");
157
+ }
158
+ globalThis.expect(Nodefs.existsSync(path)).toEqual(true);
159
+ });
160
+ globalThis.test("fills the port in on an entry already written", () => {
161
+ let cwd = tempRoot();
162
+ writeAt(cwd, 4000, memoryStore, undefined);
163
+ globalThis.expect(LocalPlatformRegistry$ReventlessLocal.list(cwd, undefined).map(e => e.tapPort)).toEqual([undefined]);
164
+ LocalPlatformRegistry$ReventlessLocal.publishTapPort(4000, 58909, cwd);
165
+ let entry = LocalPlatformRegistry$ReventlessLocal.list(cwd, undefined)[0];
166
+ if (entry !== undefined) {
167
+ globalThis.expect(entry.tapPort).toEqual(58909);
168
+ globalThis.expect(entry.endpoint).toEqual("http://localhost:4000/graphql");
169
+ globalThis.expect(entry.store.kind).toEqual("memory");
170
+ return;
171
+ } else {
172
+ return JestGlobals.fail("the updated entry must still be listed");
173
+ }
174
+ });
175
+ globalThis.test("ignores a platform that never published an entry", () => LocalPlatformRegistry$ReventlessLocal.publishTapPort(4000, 1, tempRoot()));
176
+ globalThis.test("round-trips the port a platform serving a tap publishes", () => {
177
+ let cwd = tempRoot();
178
+ LocalPlatformRegistry$ReventlessLocal.write(4000, "http://localhost:4000/graphql", "http://localhost:4000/__inmemory/login", memoryStore, 4100, undefined, cwd);
179
+ globalThis.expect(LocalPlatformRegistry$ReventlessLocal.list(cwd, undefined).map(e => e.tapPort)).toEqual([4100]);
180
+ });
181
+ });
182
+
143
183
  let deadPid = 2147483646;
144
184
 
145
185
  export {
@@ -0,0 +1,176 @@
1
+ // A reset used to unlink a served store at construction and only discover at the
2
+ // bind, ~1200 lines later, that it had lost the race.
3
+
4
+ @@warning("-44")
5
+
6
+ open JestGlobals
7
+
8
+ let tempRoot = (): string =>
9
+ NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), "reventless-start-"]))
10
+
11
+ let deadPid = 2147483646
12
+
13
+ let sqliteStore = (path): LocalPlatformRegistry.store => {kind: "sqlite", path: Some(path)}
14
+ let memoryStore: LocalPlatformRegistry.store = {kind: "memory", path: None}
15
+
16
+ let writeAt = (~cwd, ~port, ~store, ~pid=NodeProcess.pid) =>
17
+ LocalPlatformRegistry.write(
18
+ ~port,
19
+ ~endpoint=`http://localhost:${port->Int.toString}/graphql`,
20
+ ~loginEndpoint=`http://localhost:${port->Int.toString}/__inmemory/login`,
21
+ ~store,
22
+ ~pid,
23
+ ~cwd,
24
+ )
25
+
26
+ let withoutBypass = (f: unit => unit) => {
27
+ let previous = NodeProcess.env->Dict.get(LocalPlatformStart.bypassEnv)
28
+ NodeProcess.env->Dict.delete(LocalPlatformStart.bypassEnv)
29
+ let restore = () =>
30
+ switch previous {
31
+ | Some(v) => NodeProcess.env->Dict.set(LocalPlatformStart.bypassEnv, v)
32
+ | None => ()
33
+ }
34
+ try {
35
+ f()
36
+ restore()
37
+ } catch {
38
+ | e =>
39
+ restore()
40
+ throw(e)
41
+ }
42
+ }
43
+
44
+ describe("LocalPlatformStart.guardReset", () => {
45
+ // The exact sequence that lost data.
46
+ testSync("refuses a reset of a store a live platform opened, naming it", () => {
47
+ let cwd = tempRoot()
48
+ let store = NodePath.join([cwd, ".reventless", "local.db"])
49
+ let _ = writeAt(~cwd, ~port=4000, ~store=sqliteStore(store))
50
+
51
+ switch LocalPlatformStart.guardReset(~path=store, ~cwd, ()) {
52
+ | () => fail("expected the reset to be refused")
53
+ | exception JsExn(e) =>
54
+ let message = e->JsExn.message->Option.getOr("")
55
+ expect(message->String.includes(":4000"))->toEqual(true)
56
+ expect(message->String.includes(NodeProcess.pid->Int.toString))->toEqual(true)
57
+ }
58
+ })
59
+
60
+ // Entry paths are absolute; the configured one is what an operator typed.
61
+ testSync("matches a relative configured path against the absolute registered one", () => {
62
+ let cwd = tempRoot()
63
+ let absolute = NodePath.join([NodeProcess.cwd(), ".reventless", "guard-probe.db"])
64
+ let _ = writeAt(~cwd, ~port=4000, ~store=sqliteStore(absolute))
65
+
66
+ expect(
67
+ LocalPlatformStart.servedBy(~path="./.reventless/guard-probe.db", ~cwd, ())->Option.isSome,
68
+ )->toEqual(true)
69
+ })
70
+
71
+ testSync("lets a reset through when nothing is serving that file", () => {
72
+ let cwd = tempRoot()
73
+ let _ = writeAt(~cwd, ~port=4000, ~store=sqliteStore(NodePath.join([cwd, "other.db"])))
74
+
75
+ expect(LocalPlatformStart.servedBy(~path=NodePath.join([cwd, "local.db"]), ~cwd, ()))->toEqual(
76
+ None,
77
+ )
78
+ LocalPlatformStart.guardReset(~path=NodePath.join([cwd, "local.db"]), ~cwd, ())
79
+ })
80
+
81
+ // A `kill -9` leaves the entry behind; a guard that trusted it would never pass.
82
+ testSync("lets a reset through when the platform holding the store is gone", () => {
83
+ let cwd = tempRoot()
84
+ let store = NodePath.join([cwd, "local.db"])
85
+ let _ = writeAt(~cwd, ~port=4000, ~store=sqliteStore(store), ~pid=deadPid)
86
+
87
+ LocalPlatformStart.guardReset(~path=store, ~cwd, ())
88
+ })
89
+
90
+ // An in-memory platform names no path, so it can never hold a store file.
91
+ testSync("ignores a platform that keeps its store in memory", () => {
92
+ let cwd = tempRoot()
93
+ let _ = writeAt(~cwd, ~port=4010, ~store=memoryStore)
94
+
95
+ LocalPlatformStart.guardReset(~path=NodePath.join([cwd, "local.db"]), ~cwd, ())
96
+ })
97
+ })
98
+
99
+ describe("LocalPlatformStart.orAddressRunning", () => {
100
+ // The addressing path exits 0, so running it first would report success for a
101
+ // wipe that never happened.
102
+ testSync("refuses a reset before it addresses the platform holding the store", () => {
103
+ let cwd = tempRoot()
104
+ let relative = "./.reventless/order-probe.db"
105
+ let _ = writeAt(~cwd, ~port=4000, ~store=sqliteStore(NodePath.resolve([relative])))
106
+
107
+ let previousBackend = NodeProcess.env->Dict.get("REVENTLESS_LOCAL_BACKEND")
108
+ NodeProcess.env->Dict.set("REVENTLESS_LOCAL_BACKEND", `sqlite:${relative}?reset`)
109
+ let outcome = switch LocalPlatformStart.orAddressRunning(~cwd, ()) {
110
+ | () => "returned or exited"
111
+ | exception JsExn(e) => e->JsExn.message->Option.getOr("")
112
+ }
113
+ switch previousBackend {
114
+ | Some(v) => NodeProcess.env->Dict.set("REVENTLESS_LOCAL_BACKEND", v)
115
+ | None => NodeProcess.env->Dict.delete("REVENTLESS_LOCAL_BACKEND")
116
+ }
117
+
118
+ expect(outcome->String.includes("refusing to reset"))->toEqual(true)
119
+ expect(outcome->String.includes(":4000"))->toEqual(true)
120
+ })
121
+ })
122
+
123
+ describe("LocalPlatformStart.decide", () => {
124
+ testSync("starts where nothing is running", () =>
125
+ withoutBypass(() =>
126
+ expect(LocalPlatformStart.decide(~cwd=tempRoot(), ()))->toEqual(LocalPlatformStart.Start)
127
+ )
128
+ )
129
+
130
+ testSync("addresses the one platform already serving this directory", () =>
131
+ withoutBypass(() => {
132
+ let cwd = tempRoot()
133
+ let _ = writeAt(~cwd, ~port=4000, ~store=sqliteStore(NodePath.join([cwd, "local.db"])))
134
+
135
+ switch LocalPlatformStart.decide(~cwd, ()) {
136
+ | Start => fail("expected the running platform to be addressed")
137
+ | AlreadyRunning(entries) =>
138
+ expect(entries->Array.map(e => e.port))->toEqual([4000])
139
+ let lines = LocalPlatformStart.report(entries)
140
+ expect(lines->Array.length)->toEqual(2)
141
+ expect(lines->Array.join("\n")->String.includes(":4000"))->toEqual(true)
142
+ }
143
+ })
144
+ )
145
+
146
+ // The state this is trying to make impossible: say so rather than pick one.
147
+ testSync("names all of them when two are running", () =>
148
+ withoutBypass(() => {
149
+ let cwd = tempRoot()
150
+ let _ = writeAt(~cwd, ~port=4000, ~store=sqliteStore(NodePath.join([cwd, "local.db"])))
151
+ let _ = writeAt(~cwd, ~port=4010, ~store=memoryStore)
152
+
153
+ switch LocalPlatformStart.decide(~cwd, ()) {
154
+ | Start => fail("expected both running platforms to be reported")
155
+ | AlreadyRunning(entries) =>
156
+ let printed = LocalPlatformStart.report(entries)->Array.join("\n")
157
+ expect(printed->String.includes(":4000"))->toEqual(true)
158
+ expect(printed->String.includes(":4010"))->toEqual(true)
159
+ }
160
+ })
161
+ )
162
+
163
+ // The escape hatch the e2e suites and the VS Code runner already take.
164
+ testSync("starts anyway when REVENTLESS_DOMAIN_PORT names a port", () => {
165
+ let cwd = tempRoot()
166
+ let _ = writeAt(~cwd, ~port=4000, ~store=sqliteStore(NodePath.join([cwd, "local.db"])))
167
+ let previous = NodeProcess.env->Dict.get(LocalPlatformStart.bypassEnv)
168
+ NodeProcess.env->Dict.set(LocalPlatformStart.bypassEnv, "4010")
169
+ let decision = LocalPlatformStart.decide(~cwd, ())
170
+ switch previous {
171
+ | Some(v) => NodeProcess.env->Dict.set(LocalPlatformStart.bypassEnv, v)
172
+ | None => NodeProcess.env->Dict.delete(LocalPlatformStart.bypassEnv)
173
+ }
174
+ expect(decision)->toEqual(LocalPlatformStart.Start)
175
+ })
176
+ })