@reventlessdev/reventless-local 3.0.0-alpha.318 → 3.0.0-alpha.320

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 (59) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +11 -11
  3. package/src/LocalPlatformRegistry.res +7 -1
  4. package/src/LocalPlatformRegistry.res.mjs +7 -5
  5. package/src/Platform.res +16 -0
  6. package/src/Platform.res.mjs +16 -2
  7. package/src/adapter/Api/LocalEvents_Server.res +34 -3
  8. package/src/adapter/Api/LocalEvents_Server.res.mjs +31 -3
  9. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogEntry.res +24 -0
  10. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogEntry.res.mjs +28 -0
  11. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_InMemory.res +26 -0
  12. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_InMemory.res.mjs +35 -0
  13. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_Sqlite.res +61 -0
  14. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_Sqlite.res.mjs +75 -0
  15. package/src/adapter/CommandOutcomeLog/LocalCommandOutcomeLog.res +88 -0
  16. package/src/adapter/CommandOutcomeLog/LocalCommandOutcomeLog.res.mjs +112 -0
  17. package/src/adapter/DomainGraphQL_Server.res +2 -1
  18. package/src/adapter/DomainGraphQL_Server.res.mjs +3 -1
  19. package/src/adapter/LocalFeed.res +16 -2
  20. package/src/adapter/LocalFeed.res.mjs +19 -2
  21. package/src/adapter/LocalIdleExit.res +71 -0
  22. package/src/adapter/LocalIdleExit.res.mjs +96 -0
  23. package/src/adapter/LocalMetrics.res +29 -0
  24. package/src/adapter/LocalMetrics.res.mjs +61 -0
  25. package/src/adapter/LocalPlatformObserve.res +129 -5
  26. package/src/adapter/LocalPlatformObserve.res.mjs +144 -2
  27. package/src/adapter/LocalRequestLog.res +62 -0
  28. package/src/adapter/LocalRequestLog.res.mjs +80 -0
  29. package/src/adapter/LocalTapLines.res +2 -0
  30. package/src/adapter/LocalTapLines.res.mjs +4 -1
  31. package/src/adapter/ProjectionCheckpoint.res.mjs +3 -3
  32. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +11 -3
  33. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +10 -3
  34. package/src/adapter/ViewChangeLog/LocalViewChangeLog.res +95 -0
  35. package/src/adapter/ViewChangeLog/LocalViewChangeLog.res.mjs +116 -0
  36. package/src/adapter/ViewChangeLog/ViewChangeLogEntry.res +33 -0
  37. package/src/adapter/ViewChangeLog/ViewChangeLogEntry.res.mjs +30 -0
  38. package/src/adapter/ViewChangeLog/ViewChangeLogStorage_InMemory.res +28 -0
  39. package/src/adapter/ViewChangeLog/ViewChangeLogStorage_InMemory.res.mjs +40 -0
  40. package/src/adapter/ViewChangeLog/ViewChangeLogStorage_Sqlite.res +76 -0
  41. package/src/adapter/ViewChangeLog/ViewChangeLogStorage_Sqlite.res.mjs +88 -0
  42. package/src/components/AutomationSlice_Builder.res.mjs +1 -1
  43. package/src/components/LocalAutomationSchedules.res.mjs +1 -1
  44. package/src/components/LocalAutomationSweep.res.mjs +1 -1
  45. package/tests/LocalPlatformRegistryTest.res +17 -0
  46. package/tests/LocalPlatformRegistryTest.res.mjs +14 -2
  47. package/tests/LocalPlatformStartTest.res.mjs +1 -1
  48. package/tests/adapter/GraphQL_SchemaInspectorTest.res +3 -2
  49. package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +1 -1
  50. package/tests/adapter/LocalEvents_ServerTest.res +11 -0
  51. package/tests/adapter/LocalEvents_ServerTest.res.mjs +7 -1
  52. package/tests/adapter/LocalIdleExitTest.res +57 -0
  53. package/tests/adapter/LocalIdleExitTest.res.mjs +62 -0
  54. package/tests/adapter/LocalPlatformObserveTest.res +183 -0
  55. package/tests/adapter/LocalPlatformObserveTest.res.mjs +181 -0
  56. package/tests/adapter/LocalRequestLogTest.res +46 -0
  57. package/tests/adapter/LocalRequestLogTest.res.mjs +61 -0
  58. package/tests/adapter/LocalTapLinesTest.res +25 -0
  59. package/tests/adapter/LocalTapLinesTest.res.mjs +35 -3
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
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.320 (2026-11-02)
7
+
8
+ ### Features
9
+
10
+ * a view row knows its last change, and the local platform keeps every change ([1a1fd71](https://github.com/ReventlessDev/reventless-core/commit/1a1fd7117d9837696a7c0080dcd6f0983e57780c))
11
+ * **local:** a platform left running for an editor stops once nothing watches it ([1ccff67](https://github.com/ReventlessDev/reventless-core/commit/1ccff678126053dbce88ee002c6800af1333cac6))
12
+ * **local:** the app's API logs each request and subscription it answers ([31a163c](https://github.com/ReventlessDev/reventless-core/commit/31a163c945d8929e9a604cc0dc8a74939320f85e))
13
+ * **local:** the local platform counts its metrics instead of logging them ([76c6094](https://github.com/ReventlessDev/reventless-core/commit/76c60946fa74520519dccb4ae39fae5880817832))
14
+ * **local:** the local platform keeps every command outcome ([252ed2e](https://github.com/ReventlessDev/reventless-core/commit/252ed2ea46f2634b86851aea402e94af7c371f10))
15
+
16
+
17
+ # 3.0.0-alpha.319 (2026-11-01)
18
+
19
+ ### Features
20
+
21
+ * **core:** a component's log line carries the ids of the message it is about ([9c26267](https://github.com/ReventlessDev/reventless-core/commit/9c26267ca6d03ff66b8406caa32243e205e29b5a))
22
+ * **local:** the registry entry names the platform API, and a command's outcome names its cause ([51717c4](https://github.com/ReventlessDev/reventless-core/commit/51717c441820c19dc59e595559b037b808ca716a))
23
+
24
+
6
25
  # 3.0.0-alpha.318 (2026-11-01)
7
26
 
8
27
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.318",
3
+ "version": "3.0.0-alpha.320",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -34,19 +34,19 @@
34
34
  "graphql-yoga": "^5.21.0",
35
35
  "sury": "11.0.0",
36
36
  "ws": "^8.18.0",
37
- "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.30",
38
- "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
37
+ "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.31",
39
38
  "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
40
39
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.24",
41
- "@reventlessdev/rescript-node": "2.0.0-alpha.25",
40
+ "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
41
+ "@reventlessdev/rescript-node": "2.0.0-alpha.26",
42
+ "@reventlessdev/reventless-core": "3.0.0-alpha.328",
43
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.176",
44
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.277",
42
45
  "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
43
- "@reventlessdev/reventless-core": "3.0.0-alpha.326",
44
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.275",
45
- "@reventlessdev/reventless-infra": "3.0.0-alpha.221",
46
- "@reventlessdev/reventless-seed": "1.0.0-alpha.40",
47
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.174",
48
- "@reventlessdev/reventless-spec": "3.0.0-alpha.192",
49
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.190"
46
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.222",
47
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.41",
48
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.193",
49
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.192"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.1",
@@ -30,6 +30,9 @@ type entry = {
30
30
  // Where the log feed is served, when it is: the platform's recent and live log
31
31
  // records. Optional for the same reason as `tapPort`.
32
32
  logPort?: int,
33
+ // Where the platform API is served: its own port when the platform serves two
34
+ // APIs, else the same endpoint as `endpoint`. Optional for the same reason.
35
+ platformEndpoint?: string,
33
36
  }
34
37
 
35
38
  // Threaded rather than read at each use, so tests need not move the process.
@@ -115,6 +118,7 @@ let write = (
115
118
  ~loginEndpoint: string,
116
119
  ~store: store,
117
120
  ~tapPort: option<int>=?,
121
+ ~platformEndpoint: option<string>=?,
118
122
  ~pid: int=NodeProcess.pid,
119
123
  ~cwd: string=NodeProcess.cwd(),
120
124
  ): string => {
@@ -128,6 +132,7 @@ let write = (
128
132
  store,
129
133
  startedAt: Date.make()->Date.toISOString,
130
134
  ?tapPort,
135
+ ?platformEndpoint,
131
136
  }
132
137
  try {
133
138
  NodeFs.mkdirSync(runningDir(~cwd, ()), {recursive: true})
@@ -187,8 +192,9 @@ let register = (
187
192
  ~loginEndpoint: string,
188
193
  ~store: store,
189
194
  ~tapPort: option<int>=?,
195
+ ~platformEndpoint: option<string>=?,
190
196
  ): unit => {
191
- let path = write(~port, ~endpoint, ~loginEndpoint, ~store, ~tapPort?)
197
+ let path = write(~port, ~endpoint, ~loginEndpoint, ~store, ~tapPort?, ~platformEndpoint?)
192
198
  NodeProcess.onExit(_ => removeQuietly(path))
193
199
  NodeProcess.onSignal(SIGINT, () => {
194
200
  removeQuietly(path)
@@ -23,7 +23,8 @@ let entrySchema = Sury.$schema(s => ({
23
23
  store: s.m(storeSchema),
24
24
  startedAt: s.m(Sury.string),
25
25
  tapPort: s.m(Sury.$option(Sury.int32)),
26
- logPort: s.m(Sury.$option(Sury.int32))
26
+ logPort: s.m(Sury.$option(Sury.int32)),
27
+ platformEndpoint: s.m(Sury.$option(Sury.string))
27
28
  }));
28
29
 
29
30
  function runningDir(cwdOpt, param) {
@@ -110,7 +111,7 @@ function list(cwdOpt, param) {
110
111
  }).toSorted((a, b) => Primitive_int.compare(a.port, b.port));
111
112
  }
112
113
 
113
- function write(port, endpoint, loginEndpoint, store, tapPort, pidOpt, cwdOpt) {
114
+ function write(port, endpoint, loginEndpoint, store, tapPort, platformEndpoint, pidOpt, cwdOpt) {
114
115
  let pid = pidOpt !== undefined ? pidOpt : process.pid;
115
116
  let cwd = cwdOpt !== undefined ? cwdOpt : process.cwd();
116
117
  let path = entryPath(port, cwd);
@@ -124,7 +125,8 @@ function write(port, endpoint, loginEndpoint, store, tapPort, pidOpt, cwdOpt) {
124
125
  loginEndpoint: loginEndpoint,
125
126
  store: store,
126
127
  startedAt: entry_startedAt,
127
- tapPort: tapPort
128
+ tapPort: tapPort,
129
+ platformEndpoint: platformEndpoint
128
130
  };
129
131
  try {
130
132
  Nodefs.mkdirSync(runningDir(cwd, undefined), {
@@ -169,8 +171,8 @@ function publishLogPort(port, logPort, cwdOpt) {
169
171
  }
170
172
  }
171
173
 
172
- function register(port, endpoint, loginEndpoint, store, tapPort) {
173
- let path = write(port, endpoint, loginEndpoint, store, tapPort, undefined, undefined);
174
+ function register(port, endpoint, loginEndpoint, store, tapPort, platformEndpoint) {
175
+ let path = write(port, endpoint, loginEndpoint, store, tapPort, platformEndpoint, undefined, undefined);
174
176
  process.on("exit", param => removeQuietly(path));
175
177
  process.on("SIGINT", () => {
176
178
  removeQuietly(path);
package/src/Platform.res CHANGED
@@ -23,6 +23,10 @@ ReventlessCore.EffectLogger.setDefaultMinLevel(ReventlessCore.Logger.Debug)
23
23
  // REVENTLESS_LOG_FORMAT still wins.
24
24
  Reventless.AnsiStyle.setDefaultFormat(#text)
25
25
 
26
+ // Locally nothing collects metric lines, so metrics are counted and served on the
27
+ // platform API instead of written among the log lines.
28
+ LocalMetrics.install()
29
+
26
30
  let log = ReventlessCore.Logger.fromEnv()
27
31
 
28
32
  // Module-level ref to hold the platform GraphQL server instance in split mode.
@@ -112,11 +116,18 @@ module MakeWithConfig = (
112
116
  // What became of each command, and each row a projection changed, on the
113
117
  // event tap; a rejected command also in the problem ring.
114
118
  LocalTapLines.install()
119
+ // Every row change, kept so a tool that missed one live can read it back.
120
+ LocalViewChangeLog.install()
121
+ // And what became of every command, for the same reason.
122
+ LocalCommandOutcomeLog.install()
115
123
 
116
124
  // What this platform provides for the roles its plugins need: every account it
117
125
  // can sign in as, built-in and loaded alike, or the file the store will load
118
126
  // when the servers start — after the plugins are built and checked.
119
127
  Reventless.Role.provideGroupsFrom(AccountStore.providedGroups)
128
+ // The administrator reads across every owner, as a deployment's does: the same default the
129
+ // AWS platform sets, and like it never a forcing over an explicit `setElevatedGroups`.
130
+ Reventless.OwnerScope.defaultElevatedRoles([Reventless.Role.admin])
120
131
  // Activate Pulumi mock mode — must happen before any component creation.
121
132
  // Idempotent, so safe to call even if TestRunner.setup() was already called.
122
133
  let _ = TestRunner.setup()
@@ -1422,7 +1433,12 @@ module MakeWithConfig = (
1422
1433
  ~endpoint=`http://localhost:${domainPort->Int.toString}/graphql`,
1423
1434
  ~loginEndpoint=`http://localhost:${domainPort->Int.toString}/__inmemory/login`,
1424
1435
  ~store={kind, ?path},
1436
+ ~platformEndpoint=Config.splitApi
1437
+ ? `http://localhost:${platformPort->Int.toString}/graphql`
1438
+ : `http://localhost:${domainPort->Int.toString}/graphql`,
1425
1439
  )
1440
+ // A platform left running for an editor stops once nothing has watched it for a while.
1441
+ LocalIdleExit.start(~env=NodeProcess.env)
1426
1442
  // After the register, and it fills the tap port in once the socket is bound:
1427
1443
  // the port is ephemeral by default, so it is not known until `listen` calls
1428
1444
  // back. The entry therefore never names a port that is not listening.
@@ -39,11 +39,13 @@ import * as AccountStore$ReventlessLocal from "./adapter/Auth/AccountStore.res.m
39
39
  import * as BackendState$ReventlessLocal from "./adapter/BackendState.res.mjs";
40
40
  import * as ComponentType$ReventlessCore from "@reventlessdev/reventless-core/src/ComponentType.res.mjs";
41
41
  import * as LocalLogFeed$ReventlessLocal from "./adapter/LocalLogFeed.res.mjs";
42
+ import * as LocalMetrics$ReventlessLocal from "./adapter/LocalMetrics.res.mjs";
42
43
  import * as SqliteDriver$ReventlessLocal from "./adapter/SqliteDriver.res.mjs";
43
44
  import * as Task_Builder$ReventlessLocal from "./components/Task_Builder.res.mjs";
44
45
  import * as BakedManifest$ReventlessLocal from "./BakedManifest.res.mjs";
45
46
  import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
46
47
  import * as LocalEventTap$ReventlessLocal from "./adapter/LocalEventTap.res.mjs";
48
+ import * as LocalIdleExit$ReventlessLocal from "./adapter/LocalIdleExit.res.mjs";
47
49
  import * as LocalTapLines$ReventlessLocal from "./adapter/LocalTapLines.res.mjs";
48
50
  import * as Platform_Admin$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_Admin.res.mjs";
49
51
  import * as PluginDecision$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginDecision.res.mjs";
@@ -72,6 +74,7 @@ import * as ReadModel_Builder$ReventlessLocal from "./components/ReadModel_Build
72
74
  import * as UiFragmentRegistry$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateChange/UiFragmentRegistry.res.mjs";
73
75
  import * as LocalEvents_Server$ReventlessLocal from "./adapter/Api/LocalEvents_Server.res.mjs";
74
76
  import * as LocalPlatformStart$ReventlessLocal from "./LocalPlatformStart.res.mjs";
77
+ import * as LocalViewChangeLog$ReventlessLocal from "./adapter/ViewChangeLog/LocalViewChangeLog.res.mjs";
75
78
  import * as PlatformMCP_Server$ReventlessLocal from "./adapter/PlatformMCP_Server.res.mjs";
76
79
  import * as ResourceAttribution$ReventlessCore from "@reventlessdev/reventless-core/src/ResourceAttribution.res.mjs";
77
80
  import * as Auth_GraphqlContext$ReventlessLocal from "./adapter/Auth/Auth_GraphqlContext.res.mjs";
@@ -89,6 +92,7 @@ import * as LocalPlatformRegistry$ReventlessLocal from "./LocalPlatformRegistry.
89
92
  import * as UiFragments_Projection$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateView/UiFragments_Projection.res.mjs";
90
93
  import * as EventLogStorage_Sqlite$ReventlessLocal from "./adapter/EventLog/EventLogStorage_Sqlite.res.mjs";
91
94
  import * as ExtensionPoint_Builder$ReventlessLocal from "./components/ExtensionPoint_Builder.res.mjs";
95
+ import * as LocalCommandOutcomeLog$ReventlessLocal from "./adapter/CommandOutcomeLog/LocalCommandOutcomeLog.res.mjs";
92
96
  import * as PlatformGraphQL_Server$ReventlessLocal from "./adapter/PlatformGraphQL_Server.res.mjs";
93
97
  import * as Platform_UIFragmentsApi$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_UIFragmentsApi.res.mjs";
94
98
  import * as StateViewSlice_Builder$ReventlessLocal from "./components/StateViewSlice_Builder.res.mjs";
@@ -119,6 +123,8 @@ EffectLogger$ReventlessCore.setDefaultMinLevel("Debug");
119
123
 
120
124
  AnsiStyle$Reventless.setDefaultFormat("text");
121
125
 
126
+ LocalMetrics$ReventlessLocal.install();
127
+
122
128
  let log = Logger$ReventlessCore.fromEnv();
123
129
 
124
130
  let platformGraphQLRef = {
@@ -152,7 +158,10 @@ function decodeUiFragmentRegistryEventEnvelope(eventJson) {
152
158
  function MakeWithConfig(Config) {
153
159
  LocalLogFeed$ReventlessLocal.install(undefined);
154
160
  LocalTapLines$ReventlessLocal.install();
161
+ LocalViewChangeLog$ReventlessLocal.install();
162
+ LocalCommandOutcomeLog$ReventlessLocal.install();
155
163
  Role$Reventless.provideGroupsFrom(AccountStore$ReventlessLocal.providedGroups);
164
+ OwnerScope$Reventless.defaultElevatedRoles([Role$Reventless.admin]);
156
165
  TestRunner$ReventlessLocal.setup();
157
166
  let match = Config.backend;
158
167
  if (typeof match !== "object") {
@@ -1176,7 +1185,8 @@ function MakeWithConfig(Config) {
1176
1185
  LocalPlatformRegistry$ReventlessLocal.register(domainPort, `http://localhost:` + domainPort.toString() + `/graphql`, `http://localhost:` + domainPort.toString() + `/__inmemory/login`, {
1177
1186
  kind: match[0],
1178
1187
  path: match[1]
1179
- }, undefined);
1188
+ }, undefined, Config.splitApi ? `http://localhost:` + platformPort.toString() + `/graphql` : `http://localhost:` + domainPort.toString() + `/graphql`);
1189
+ LocalIdleExit$ReventlessLocal.start(process.env);
1180
1190
  LocalEventTap$ReventlessLocal.start(undefined, tapPort => LocalPlatformRegistry$ReventlessLocal.publishTapPort(domainPort, tapPort, undefined), undefined);
1181
1191
  LocalLogFeed$ReventlessLocal.start(undefined, logPort => LocalPlatformRegistry$ReventlessLocal.publishLogPort(domainPort, logPort, undefined), undefined);
1182
1192
  Plugin_Helpers$ReventlessCore.firePlatformDeployedHook({
@@ -1960,7 +1970,10 @@ function Make($star) {
1960
1970
  let backend = Backend$ReventlessLocal.fromEnv();
1961
1971
  LocalLogFeed$ReventlessLocal.install(undefined);
1962
1972
  LocalTapLines$ReventlessLocal.install();
1973
+ LocalViewChangeLog$ReventlessLocal.install();
1974
+ LocalCommandOutcomeLog$ReventlessLocal.install();
1963
1975
  Role$Reventless.provideGroupsFrom(AccountStore$ReventlessLocal.providedGroups);
1976
+ OwnerScope$Reventless.defaultElevatedRoles([Role$Reventless.admin]);
1964
1977
  TestRunner$ReventlessLocal.setup();
1965
1978
  if (typeof backend !== "object") {
1966
1979
  BackendState$ReventlessLocal.setMemory();
@@ -2973,7 +2986,8 @@ function Make($star) {
2973
2986
  LocalPlatformRegistry$ReventlessLocal.register(domainPort, `http://localhost:` + domainPort.toString() + `/graphql`, `http://localhost:` + domainPort.toString() + `/__inmemory/login`, {
2974
2987
  kind: match[0],
2975
2988
  path: match[1]
2976
- }, undefined);
2989
+ }, undefined, `http://localhost:` + platformPort.toString() + `/graphql`);
2990
+ LocalIdleExit$ReventlessLocal.start(process.env);
2977
2991
  LocalEventTap$ReventlessLocal.start(undefined, tapPort => LocalPlatformRegistry$ReventlessLocal.publishTapPort(domainPort, tapPort, undefined), undefined);
2978
2992
  LocalLogFeed$ReventlessLocal.start(undefined, logPort => LocalPlatformRegistry$ReventlessLocal.publishLogPort(domainPort, logPort, undefined), undefined);
2979
2993
  Plugin_Helpers$ReventlessCore.firePlatformDeployedHook({
@@ -48,14 +48,16 @@ type connection = {
48
48
  send: string => unit,
49
49
  /** subscription id → channel (exact, or ending in a `*` wildcard). */
50
50
  subscriptions: dict<string>,
51
+ /** Who connected, when a token said. */
52
+ user?: string,
51
53
  }
52
54
 
53
55
  let connections: ref<array<connection>> = ref([])
54
56
  let nextConnectionId = ref(0)
55
57
 
56
- let addConnection = (~send: string => unit): connection => {
58
+ let addConnection = (~send: string => unit, ~user: option<string>=?): connection => {
57
59
  nextConnectionId.contents = nextConnectionId.contents + 1
58
- let conn = {id: nextConnectionId.contents, send, subscriptions: Dict.make()}
60
+ let conn = {id: nextConnectionId.contents, send, subscriptions: Dict.make(), ?user}
59
61
  connections.contents->Array.push(conn)
60
62
  conn
61
63
  }
@@ -176,6 +178,27 @@ let decodeStringField = (json: JSON.t, field: string): option<string> =>
176
178
  ->Option.flatMap(o => o->Dict.get(field))
177
179
  ->Option.flatMap(JSON.Decode.string)
178
180
 
181
+ /** The line a client's subscribing or unsubscribing is told by. */
182
+ let subscriptionLine = (conn: connection, ~verb: string, ~channel: string) =>
183
+ `${verb} ${channel}${conn.user->Option.mapOr("", u => ` by ${u}`)}`
184
+
185
+ let logSubscription = (conn: connection, ~verb: string, ~channel: string) =>
186
+ if ReventlessCore.Logger.shouldLog(Debug, ~comp="Events:Local", ()) {
187
+ ReventlessCore.Logger.emit(
188
+ ~level=Debug,
189
+ ~comp="Events:Local",
190
+ ~detail=JSON.Encode.object(
191
+ Dict.fromArray(
192
+ [
193
+ ("channel", JSON.Encode.string(channel)),
194
+ ("connection", JSON.Encode.int(conn.id)),
195
+ ]->Array.concat(conn.user->Option.mapOr([], u => [("user", JSON.Encode.string(u))])),
196
+ ),
197
+ ),
198
+ subscriptionLine(conn, ~verb, ~channel),
199
+ )
200
+ }
201
+
179
202
  /** Handle one client→server text frame on an established connection. */
180
203
  let handleFrame = (conn: connection, text: string): unit => {
181
204
  let parsed = try Some(text->JSON.parseOrThrow) catch {
@@ -190,6 +213,7 @@ let handleFrame = (conn: connection, text: string): unit => {
190
213
  switch (decodeStringField(json, "id"), decodeStringField(json, "channel")) {
191
214
  | (Some(id), Some(channel)) =>
192
215
  conn.subscriptions->Dict.set(id, channel)
216
+ logSubscription(conn, ~verb="subscribe", ~channel)
193
217
  conn.send(
194
218
  frame([
195
219
  ("type", JSON.Encode.string("subscribe_success")),
@@ -201,6 +225,9 @@ let handleFrame = (conn: connection, text: string): unit => {
201
225
  | Some("unsubscribe") =>
202
226
  switch decodeStringField(json, "id") {
203
227
  | Some(id) =>
228
+ conn.subscriptions
229
+ ->Dict.get(id)
230
+ ->Option.forEach(channel => logSubscription(conn, ~verb="unsubscribe", ~channel))
204
231
  conn.subscriptions->Dict.delete(id)
205
232
  conn.send(
206
233
  frame([
@@ -367,7 +394,11 @@ let attach = (~server: YG.httpServer, ~port: int): unit => {
367
394
  if tokenIsInvalid(auth) {
368
395
  ws->wsClose(4401, "Invalid token")
369
396
  } else {
370
- let conn = addConnection(~send=f => ws->wsSend(f))
397
+ let user =
398
+ auth
399
+ ->Option.flatMap(t => LocalAuth.Login.verifyAndDecode(stripBearer(t)))
400
+ ->Option.map(i => i.username)
401
+ let conn = addConnection(~send=f => ws->wsSend(f), ~user?)
371
402
  ws->onMessage(data => handleFrame(conn, data->wsMessageToString("utf8")))
372
403
  ws->onSocketClose(() => removeConnection(conn))
373
404
  }
@@ -32,14 +32,15 @@ let nextConnectionId = {
32
32
  contents: 0
33
33
  };
34
34
 
35
- function addConnection(send) {
35
+ function addConnection(send, user) {
36
36
  nextConnectionId.contents = nextConnectionId.contents + 1 | 0;
37
37
  let conn_id = nextConnectionId.contents;
38
38
  let conn_subscriptions = {};
39
39
  let conn = {
40
40
  id: conn_id,
41
41
  send: send,
42
- subscriptions: conn_subscriptions
42
+ subscriptions: conn_subscriptions,
43
+ user: user
43
44
  };
44
45
  connections.contents.push(conn);
45
46
  return conn;
@@ -127,6 +128,28 @@ function decodeStringField(json, field) {
127
128
  return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => o[field]), Stdlib_JSON.Decode.string);
128
129
  }
129
130
 
131
+ function subscriptionLine(conn, verb, channel) {
132
+ return verb + ` ` + channel + Stdlib_Option.mapOr(conn.user, "", u => ` by ` + u);
133
+ }
134
+
135
+ function logSubscription(conn, verb, channel) {
136
+ if (Logger$ReventlessCore.shouldLog("Debug", undefined, "Events:Local", undefined)) {
137
+ return Logger$ReventlessCore.emit("Debug", "Events:Local", undefined, Object.fromEntries([
138
+ [
139
+ "channel",
140
+ channel
141
+ ],
142
+ [
143
+ "connection",
144
+ conn.id
145
+ ]
146
+ ].concat(Stdlib_Option.mapOr(conn.user, [], u => [[
147
+ "user",
148
+ u
149
+ ]]))), undefined, subscriptionLine(conn, verb, channel));
150
+ }
151
+ }
152
+
130
153
  function handleFrame(conn, text) {
131
154
  let parsed;
132
155
  try {
@@ -149,6 +172,7 @@ function handleFrame(conn, text) {
149
172
  let match$2 = decodeStringField(parsed, "channel");
150
173
  if (match$1 !== undefined && match$2 !== undefined) {
151
174
  conn.subscriptions[match$1] = match$2;
175
+ logSubscription(conn, "subscribe", match$2);
152
176
  return conn.send(frame([
153
177
  [
154
178
  "type",
@@ -165,6 +189,7 @@ function handleFrame(conn, text) {
165
189
  case "unsubscribe" :
166
190
  let id = decodeStringField(parsed, "id");
167
191
  if (id !== undefined) {
192
+ Stdlib_Option.forEach(conn.subscriptions[id], channel => logSubscription(conn, "unsubscribe", channel));
168
193
  Stdlib_Dict.$$delete(conn.subscriptions, id);
169
194
  return conn.send(frame([
170
195
  [
@@ -320,9 +345,10 @@ function attach(server, port) {
320
345
  ws.close(4401, "Invalid token");
321
346
  return;
322
347
  }
348
+ let user = Stdlib_Option.map(Stdlib_Option.flatMap(auth, t => LocalAuth$ReventlessLocal.Login.verifyAndDecode(stripBearer(t))), i => i.username);
323
349
  let conn = addConnection(f => {
324
350
  ws.send(f);
325
- });
351
+ }, user);
326
352
  ws.on("message", data => handleFrame(conn, data.toString("utf8")));
327
353
  ws.on("close", () => removeConnection(conn));
328
354
  });
@@ -352,6 +378,8 @@ export {
352
378
  broadcastUiSlotsChanged,
353
379
  broadcastStateChange,
354
380
  decodeStringField,
381
+ subscriptionLine,
382
+ logSubscription,
355
383
  handleFrame,
356
384
  stripBearer,
357
385
  tokenIsInvalid,
@@ -0,0 +1,24 @@
1
+ // One stored command outcome: the line the tap sends for it (component, plugin, message id,
2
+ // flow, cause, accepted with its entity and event count or rejected with its code), with the
3
+ // columns a read filters by.
4
+
5
+ type t = {
6
+ seq: int,
7
+ flow?: string,
8
+ msgId: string,
9
+ /** The tap's `commandOutcome` line, as JSON. */
10
+ item: JSON.t,
11
+ }
12
+
13
+ /** What a read asks for: every field narrows, none reads every outcome. */
14
+ type filter = {correlationId?: string, msgId?: string}
15
+
16
+ let matches = (f: filter, e: t) =>
17
+ f.correlationId->Option.mapOr(true, c => e.flow == Some(c)) &&
18
+ f.msgId->Option.mapOr(true, m => e.msgId == m)
19
+
20
+ let filterOfJson = (j: option<JSON.t>): filter => {
21
+ let d = j->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
22
+ let s = k => d->Dict.get(k)->Option.flatMap(JSON.Decode.string)
23
+ {correlationId: ?s("correlationId"), msgId: ?s("msgId")}
24
+ }
@@ -0,0 +1,28 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
+
7
+ function matches(f, e) {
8
+ if (Stdlib_Option.mapOr(f.correlationId, true, c => Primitive_object.equal(e.flow, c))) {
9
+ return Stdlib_Option.mapOr(f.msgId, true, m => e.msgId === m);
10
+ } else {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ function filterOfJson(j) {
16
+ let d = Stdlib_Option.getOr(Stdlib_Option.flatMap(j, Stdlib_JSON.Decode.object), {});
17
+ let s = k => Stdlib_Option.flatMap(d[k], Stdlib_JSON.Decode.string);
18
+ return {
19
+ correlationId: s("correlationId"),
20
+ msgId: s("msgId")
21
+ };
22
+ }
23
+
24
+ export {
25
+ matches,
26
+ filterOfJson,
27
+ }
28
+ /* No side effect */
@@ -0,0 +1,26 @@
1
+ // The command outcomes of an in-memory platform, oldest first, for as long as it runs.
2
+
3
+ type entry = CommandOutcomeLogEntry.t
4
+
5
+ let entries: array<entry> = []
6
+
7
+ let append = (e: entry) => entries->Array.push(e)
8
+
9
+ /** Up to `first` entries matching the filter, newest first, below the `before` sequence number. */
10
+ let read = (~filter: CommandOutcomeLogEntry.filter, ~before: option<int>, ~first: int): array<
11
+ entry,
12
+ > => {
13
+ let out = []
14
+ let i = ref(entries->Array.length - 1)
15
+ while i.contents >= 0 && out->Array.length < first {
16
+ let e = entries->Array.getUnsafe(i.contents)
17
+ if before->Option.mapOr(true, b => e.seq < b) && CommandOutcomeLogEntry.matches(filter, e) {
18
+ out->Array.push(e)
19
+ }
20
+ i := i.contents - 1
21
+ }
22
+ out
23
+ }
24
+
25
+ /** Test seam. */
26
+ let clear = () => entries->Array.splice(~start=0, ~remove=entries->Array.length, ~insert=[])
@@ -0,0 +1,35 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as CommandOutcomeLogEntry$ReventlessLocal from "./CommandOutcomeLogEntry.res.mjs";
5
+
6
+ let entries = [];
7
+
8
+ function append(e) {
9
+ entries.push(e);
10
+ }
11
+
12
+ function read(filter, before, first) {
13
+ let out = [];
14
+ let i = entries.length - 1 | 0;
15
+ while (i >= 0 && out.length < first) {
16
+ let e = entries[i];
17
+ if (Stdlib_Option.mapOr(before, true, b => e.seq < b) && CommandOutcomeLogEntry$ReventlessLocal.matches(filter, e)) {
18
+ out.push(e);
19
+ }
20
+ i = i - 1 | 0;
21
+ };
22
+ return out;
23
+ }
24
+
25
+ function clear() {
26
+ entries.splice(0, entries.length);
27
+ }
28
+
29
+ export {
30
+ entries,
31
+ append,
32
+ read,
33
+ clear,
34
+ }
35
+ /* No side effect */
@@ -0,0 +1,61 @@
1
+ // The command outcomes of a SQLite platform, kept in its store across restarts.
2
+
3
+ type entry = CommandOutcomeLogEntry.t
4
+
5
+ let table = "reventless_command_outcomes"
6
+
7
+ let ensureSchema = db => {
8
+ db->SqliteDriver.exec(
9
+ `CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY AUTOINCREMENT, flow TEXT, msg_id TEXT NOT NULL, item TEXT NOT NULL)`,
10
+ )
11
+ db->SqliteDriver.exec(`CREATE INDEX IF NOT EXISTS ${table}_flow ON ${table} (flow, seq)`)
12
+ db->SqliteDriver.exec(`CREATE INDEX IF NOT EXISTS ${table}_msg ON ${table} (msg_id)`)
13
+ }
14
+
15
+ let str = JSON.Encode.string
16
+
17
+ let append = (db, e: entry) =>
18
+ db
19
+ ->SqliteDriver.prepare(`INSERT INTO ${table} (flow, msg_id, item) VALUES (?, ?, ?)`)
20
+ ->SqliteDriver.run([
21
+ e.flow->Option.mapOr(JSON.Encode.null, str),
22
+ str(e.msgId),
23
+ str(e.item->JSON.stringify),
24
+ ])
25
+
26
+ let entryOfRow = (row: dict<JSON.t>): option<entry> => {
27
+ let s = k => row->Dict.get(k)->Option.flatMap(JSON.Decode.string)
28
+ switch (
29
+ row->Dict.get("seq")->Option.flatMap(JSON.Decode.float),
30
+ s("msg_id"),
31
+ s("item")->Option.flatMap(i =>
32
+ try Some(JSON.parseOrThrow(i)) catch {
33
+ | _ => None
34
+ }
35
+ ),
36
+ ) {
37
+ | (Some(seq), Some(msgId), Some(item)) =>
38
+ Some({seq: seq->Float.toInt, flow: ?s("flow"), msgId, item})
39
+ | _ => None
40
+ }
41
+ }
42
+
43
+ /** Up to `first` entries matching the filter, newest first, below the `before` sequence number. */
44
+ let read = (db, ~filter: CommandOutcomeLogEntry.filter, ~before: option<int>, ~first: int): array<
45
+ entry,
46
+ > => {
47
+ let where = []
48
+ let params = []
49
+ let add = (clause, value) => {
50
+ where->Array.push(clause)
51
+ params->Array.push(value)
52
+ }
53
+ filter.correlationId->Option.forEach(c => add("flow = ?", str(c)))
54
+ filter.msgId->Option.forEach(m => add("msg_id = ?", str(m)))
55
+ before->Option.forEach(b => add("seq < ?", JSON.Encode.int(b)))
56
+ let sql = `SELECT seq, flow, msg_id, item FROM ${table}${where == []
57
+ ? ""
58
+ : " WHERE " ++ where->Array.join(" AND ")} ORDER BY seq DESC LIMIT ?`
59
+ params->Array.push(JSON.Encode.int(first))
60
+ db->SqliteDriver.prepare(sql)->SqliteDriver.all(params)->Array.filterMap(entryOfRow)
61
+ }