@reventlessdev/reventless-local 3.0.0-alpha.319 → 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 (46) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +11 -11
  3. package/src/Platform.res +13 -0
  4. package/src/Platform.res.mjs +14 -0
  5. package/src/adapter/Api/LocalEvents_Server.res +34 -3
  6. package/src/adapter/Api/LocalEvents_Server.res.mjs +31 -3
  7. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogEntry.res +24 -0
  8. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogEntry.res.mjs +28 -0
  9. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_InMemory.res +26 -0
  10. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_InMemory.res.mjs +35 -0
  11. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_Sqlite.res +61 -0
  12. package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_Sqlite.res.mjs +75 -0
  13. package/src/adapter/CommandOutcomeLog/LocalCommandOutcomeLog.res +88 -0
  14. package/src/adapter/CommandOutcomeLog/LocalCommandOutcomeLog.res.mjs +112 -0
  15. package/src/adapter/DomainGraphQL_Server.res +2 -1
  16. package/src/adapter/DomainGraphQL_Server.res.mjs +3 -1
  17. package/src/adapter/LocalFeed.res +16 -2
  18. package/src/adapter/LocalFeed.res.mjs +19 -2
  19. package/src/adapter/LocalIdleExit.res +71 -0
  20. package/src/adapter/LocalIdleExit.res.mjs +96 -0
  21. package/src/adapter/LocalMetrics.res +29 -0
  22. package/src/adapter/LocalMetrics.res.mjs +61 -0
  23. package/src/adapter/LocalPlatformObserve.res +129 -5
  24. package/src/adapter/LocalPlatformObserve.res.mjs +144 -2
  25. package/src/adapter/LocalRequestLog.res +62 -0
  26. package/src/adapter/LocalRequestLog.res.mjs +80 -0
  27. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +11 -3
  28. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +10 -3
  29. package/src/adapter/ViewChangeLog/LocalViewChangeLog.res +95 -0
  30. package/src/adapter/ViewChangeLog/LocalViewChangeLog.res.mjs +116 -0
  31. package/src/adapter/ViewChangeLog/ViewChangeLogEntry.res +33 -0
  32. package/src/adapter/ViewChangeLog/ViewChangeLogEntry.res.mjs +30 -0
  33. package/src/adapter/ViewChangeLog/ViewChangeLogStorage_InMemory.res +28 -0
  34. package/src/adapter/ViewChangeLog/ViewChangeLogStorage_InMemory.res.mjs +40 -0
  35. package/src/adapter/ViewChangeLog/ViewChangeLogStorage_Sqlite.res +76 -0
  36. package/src/adapter/ViewChangeLog/ViewChangeLogStorage_Sqlite.res.mjs +88 -0
  37. package/tests/adapter/GraphQL_SchemaInspectorTest.res +3 -2
  38. package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +1 -1
  39. package/tests/adapter/LocalEvents_ServerTest.res +11 -0
  40. package/tests/adapter/LocalEvents_ServerTest.res.mjs +7 -1
  41. package/tests/adapter/LocalIdleExitTest.res +57 -0
  42. package/tests/adapter/LocalIdleExitTest.res.mjs +62 -0
  43. package/tests/adapter/LocalPlatformObserveTest.res +183 -0
  44. package/tests/adapter/LocalPlatformObserveTest.res.mjs +181 -0
  45. package/tests/adapter/LocalRequestLogTest.res +46 -0
  46. package/tests/adapter/LocalRequestLogTest.res.mjs +61 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.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
+
6
17
  # 3.0.0-alpha.319 (2026-11-01)
7
18
 
8
19
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.319",
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-effect": "1.0.0-alpha.1",
38
- "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.30",
37
+ "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.31",
39
38
  "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
40
- "@reventlessdev/rescript-node": "2.0.0-alpha.25",
41
39
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.24",
42
- "@reventlessdev/reventless-core": "3.0.0-alpha.327",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.175",
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",
44
45
  "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.276",
46
- "@reventlessdev/reventless-infra": "3.0.0-alpha.221",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.191",
48
- "@reventlessdev/reventless-spec": "3.0.0-alpha.192",
49
- "@reventlessdev/reventless-seed": "1.0.0-alpha.40"
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",
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()
@@ -1426,6 +1437,8 @@ module MakeWithConfig = (
1426
1437
  ? `http://localhost:${platformPort->Int.toString}/graphql`
1427
1438
  : `http://localhost:${domainPort->Int.toString}/graphql`,
1428
1439
  )
1440
+ // A platform left running for an editor stops once nothing has watched it for a while.
1441
+ LocalIdleExit.start(~env=NodeProcess.env)
1429
1442
  // After the register, and it fills the tap port in once the socket is bound:
1430
1443
  // the port is ephemeral by default, so it is not known until `listen` calls
1431
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") {
@@ -1177,6 +1186,7 @@ function MakeWithConfig(Config) {
1177
1186
  kind: match[0],
1178
1187
  path: match[1]
1179
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();
@@ -2974,6 +2987,7 @@ function Make($star) {
2974
2987
  kind: match[0],
2975
2988
  path: match[1]
2976
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
+ }
@@ -0,0 +1,75 @@
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_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
7
+ import * as SqliteDriver$ReventlessLocal from "../SqliteDriver.res.mjs";
8
+
9
+ let table = "reventless_command_outcomes";
10
+
11
+ function ensureSchema(db) {
12
+ SqliteDriver$ReventlessLocal.exec(db, `CREATE TABLE IF NOT EXISTS ` + table + ` (seq INTEGER PRIMARY KEY AUTOINCREMENT, flow TEXT, msg_id TEXT NOT NULL, item TEXT NOT NULL)`);
13
+ SqliteDriver$ReventlessLocal.exec(db, `CREATE INDEX IF NOT EXISTS ` + table + `_flow ON ` + table + ` (flow, seq)`);
14
+ SqliteDriver$ReventlessLocal.exec(db, `CREATE INDEX IF NOT EXISTS ` + table + `_msg ON ` + table + ` (msg_id)`);
15
+ }
16
+
17
+ function str(prim) {
18
+ return prim;
19
+ }
20
+
21
+ function append(db, e) {
22
+ SqliteDriver$ReventlessLocal.run(SqliteDriver$ReventlessLocal.prepare(db, `INSERT INTO ` + table + ` (flow, msg_id, item) VALUES (?, ?, ?)`), [
23
+ Stdlib_Option.mapOr(e.flow, null, str),
24
+ e.msgId,
25
+ JSON.stringify(e.item)
26
+ ]);
27
+ }
28
+
29
+ function entryOfRow(row) {
30
+ let s = k => Stdlib_Option.flatMap(row[k], Stdlib_JSON.Decode.string);
31
+ let match = Stdlib_Option.flatMap(row["seq"], Stdlib_JSON.Decode.float);
32
+ let match$1 = s("msg_id");
33
+ let match$2 = Stdlib_Option.flatMap(s("item"), i => {
34
+ try {
35
+ return JSON.parse(i);
36
+ } catch (exn) {
37
+ return;
38
+ }
39
+ });
40
+ if (match !== undefined && match$1 !== undefined && match$2 !== undefined) {
41
+ return {
42
+ seq: match | 0,
43
+ flow: s("flow"),
44
+ msgId: match$1,
45
+ item: match$2
46
+ };
47
+ }
48
+ }
49
+
50
+ function read(db, filter, before, first) {
51
+ let where = [];
52
+ let params = [];
53
+ let add = (clause, value) => {
54
+ where.push(clause);
55
+ params.push(value);
56
+ };
57
+ Stdlib_Option.forEach(filter.correlationId, c => add("flow = ?", c));
58
+ Stdlib_Option.forEach(filter.msgId, m => add("msg_id = ?", m));
59
+ Stdlib_Option.forEach(before, b => add("seq < ?", b));
60
+ let sql = `SELECT seq, flow, msg_id, item FROM ` + table + (
61
+ Primitive_object.equal(where, []) ? "" : " WHERE " + where.join(" AND ")
62
+ ) + ` ORDER BY seq DESC LIMIT ?`;
63
+ params.push(first);
64
+ return Stdlib_Array.filterMap(SqliteDriver$ReventlessLocal.all(SqliteDriver$ReventlessLocal.prepare(db, sql), params), entryOfRow);
65
+ }
66
+
67
+ export {
68
+ table,
69
+ ensureSchema,
70
+ str,
71
+ append,
72
+ entryOfRow,
73
+ read,
74
+ }
75
+ /* SqliteDriver-ReventlessLocal Not a pure module */