@reventlessdev/reventless-local 3.0.0-alpha.319 → 3.0.0-alpha.321
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +11 -11
- package/src/Platform.res +16 -0
- package/src/Platform.res.mjs +17 -0
- package/src/adapter/Api/LocalEvents_Server.res +34 -3
- package/src/adapter/Api/LocalEvents_Server.res.mjs +31 -3
- package/src/adapter/CommandOutcomeLog/CommandOutcomeLogEntry.res +33 -0
- package/src/adapter/CommandOutcomeLog/CommandOutcomeLogEntry.res.mjs +38 -0
- package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_InMemory.res +26 -0
- package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_InMemory.res.mjs +35 -0
- package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_Sqlite.res +64 -0
- package/src/adapter/CommandOutcomeLog/CommandOutcomeLogStorage_Sqlite.res.mjs +78 -0
- package/src/adapter/CommandOutcomeLog/LocalCommandOutcomeLog.res +88 -0
- package/src/adapter/CommandOutcomeLog/LocalCommandOutcomeLog.res.mjs +112 -0
- package/src/adapter/DomainGraphQL_Server.res +2 -1
- package/src/adapter/DomainGraphQL_Server.res.mjs +3 -1
- package/src/adapter/LocalFeed.res +16 -2
- package/src/adapter/LocalFeed.res.mjs +19 -2
- package/src/adapter/LocalIdleExit.res +71 -0
- package/src/adapter/LocalIdleExit.res.mjs +96 -0
- package/src/adapter/LocalMetrics.res +29 -0
- package/src/adapter/LocalMetrics.res.mjs +61 -0
- package/src/adapter/LocalPlatformObserve.res +130 -5
- package/src/adapter/LocalPlatformObserve.res.mjs +145 -2
- package/src/adapter/LocalRequestLog.res +62 -0
- package/src/adapter/LocalRequestLog.res.mjs +80 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +11 -3
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +10 -3
- package/src/adapter/ViewChangeLog/LocalViewChangeLog.res +95 -0
- package/src/adapter/ViewChangeLog/LocalViewChangeLog.res.mjs +116 -0
- package/src/adapter/ViewChangeLog/ViewChangeLogEntry.res +33 -0
- package/src/adapter/ViewChangeLog/ViewChangeLogEntry.res.mjs +30 -0
- package/src/adapter/ViewChangeLog/ViewChangeLogStorage_InMemory.res +28 -0
- package/src/adapter/ViewChangeLog/ViewChangeLogStorage_InMemory.res.mjs +40 -0
- package/src/adapter/ViewChangeLog/ViewChangeLogStorage_Sqlite.res +76 -0
- package/src/adapter/ViewChangeLog/ViewChangeLogStorage_Sqlite.res.mjs +88 -0
- package/src/reexport/CommandTopic.res.mjs +6 -0
- package/tests/adapter/GraphQL_SchemaInspectorTest.res +3 -2
- package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +1 -1
- package/tests/adapter/LocalEvents_ServerTest.res +11 -0
- package/tests/adapter/LocalEvents_ServerTest.res.mjs +7 -1
- package/tests/adapter/LocalIdleExitTest.res +57 -0
- package/tests/adapter/LocalIdleExitTest.res.mjs +62 -0
- package/tests/adapter/LocalPlatformObserveTest.res +191 -0
- package/tests/adapter/LocalPlatformObserveTest.res.mjs +187 -0
- package/tests/adapter/LocalRequestLogTest.res +46 -0
- package/tests/adapter/LocalRequestLogTest.res.mjs +61 -0
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.321 (2026-11-05)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* each command's outcome is written as a line of its own ([84e5e3f](https://github.com/ReventlessDev/reventless-core/commit/84e5e3fa4d77e629c6f72d9079a8f82f6b26a5ac))
|
|
11
|
+
* **local:** the command outcomes can be read by the flows they started ([e43819d](https://github.com/ReventlessDev/reventless-core/commit/e43819da448ab09ea39a39636a0ef065e3f0c34b))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.320 (2026-11-02)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* a view row knows its last change, and the local platform keeps every change ([1a1fd71](https://github.com/ReventlessDev/reventless-core/commit/1a1fd7117d9837696a7c0080dcd6f0983e57780c))
|
|
19
|
+
* **local:** a platform left running for an editor stops once nothing watches it ([1ccff67](https://github.com/ReventlessDev/reventless-core/commit/1ccff678126053dbce88ee002c6800af1333cac6))
|
|
20
|
+
* **local:** the app's API logs each request and subscription it answers ([31a163c](https://github.com/ReventlessDev/reventless-core/commit/31a163c945d8929e9a604cc0dc8a74939320f85e))
|
|
21
|
+
* **local:** the local platform counts its metrics instead of logging them ([76c6094](https://github.com/ReventlessDev/reventless-core/commit/76c60946fa74520519dccb4ae39fae5880817832))
|
|
22
|
+
* **local:** the local platform keeps every command outcome ([252ed2e](https://github.com/ReventlessDev/reventless-core/commit/252ed2ea46f2634b86851aea402e94af7c371f10))
|
|
23
|
+
|
|
24
|
+
|
|
6
25
|
# 3.0.0-alpha.319 (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.
|
|
3
|
+
"version": "3.0.0-alpha.321",
|
|
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-
|
|
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-
|
|
39
|
+
"@reventlessdev/rescript-effect": "1.0.0-alpha.1",
|
|
41
40
|
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.24",
|
|
42
|
-
"@reventlessdev/
|
|
43
|
-
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.175",
|
|
41
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.26",
|
|
44
42
|
"@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
|
|
45
|
-
"@reventlessdev/reventless-
|
|
46
|
-
"@reventlessdev/reventless-
|
|
47
|
-
"@reventlessdev/reventless-
|
|
48
|
-
"@reventlessdev/reventless-
|
|
49
|
-
"@reventlessdev/reventless-
|
|
43
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.329",
|
|
44
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.278",
|
|
45
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.223",
|
|
46
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.177",
|
|
47
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.193",
|
|
48
|
+
"@reventlessdev/reventless-seed": "1.0.0-alpha.41",
|
|
49
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.194"
|
|
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,21 @@ 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()
|
|
123
|
+
// The tap carries each outcome, so the line a deployed platform writes for it would only
|
|
124
|
+
// repeat it.
|
|
125
|
+
ReventlessCore.CommandTopic_Helpers.outcomeLines := false
|
|
115
126
|
|
|
116
127
|
// What this platform provides for the roles its plugins need: every account it
|
|
117
128
|
// can sign in as, built-in and loaded alike, or the file the store will load
|
|
118
129
|
// when the servers start — after the plugins are built and checked.
|
|
119
130
|
Reventless.Role.provideGroupsFrom(AccountStore.providedGroups)
|
|
131
|
+
// The administrator reads across every owner, as a deployment's does: the same default the
|
|
132
|
+
// AWS platform sets, and like it never a forcing over an explicit `setElevatedGroups`.
|
|
133
|
+
Reventless.OwnerScope.defaultElevatedRoles([Reventless.Role.admin])
|
|
120
134
|
// Activate Pulumi mock mode — must happen before any component creation.
|
|
121
135
|
// Idempotent, so safe to call even if TestRunner.setup() was already called.
|
|
122
136
|
let _ = TestRunner.setup()
|
|
@@ -1426,6 +1440,8 @@ module MakeWithConfig = (
|
|
|
1426
1440
|
? `http://localhost:${platformPort->Int.toString}/graphql`
|
|
1427
1441
|
: `http://localhost:${domainPort->Int.toString}/graphql`,
|
|
1428
1442
|
)
|
|
1443
|
+
// A platform left running for an editor stops once nothing has watched it for a while.
|
|
1444
|
+
LocalIdleExit.start(~env=NodeProcess.env)
|
|
1429
1445
|
// After the register, and it fills the tap port in once the socket is bound:
|
|
1430
1446
|
// the port is ephemeral by default, so it is not known until `listen` calls
|
|
1431
1447
|
// back. The entry therefore never names a port that is not listening.
|
package/src/Platform.res.mjs
CHANGED
|
@@ -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,9 +74,11 @@ 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";
|
|
81
|
+
import * as CommandTopic_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs";
|
|
78
82
|
import * as PgProjectionCatchup$ReventlessLocal from "./adapter/PgProjectionCatchup.res.mjs";
|
|
79
83
|
import * as PluginsReadModelSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs";
|
|
80
84
|
import * as StorePrefixCollision$ReventlessCore from "@reventlessdev/reventless-core/src/util/StorePrefixCollision.res.mjs";
|
|
@@ -89,6 +93,7 @@ import * as LocalPlatformRegistry$ReventlessLocal from "./LocalPlatformRegistry.
|
|
|
89
93
|
import * as UiFragments_Projection$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateView/UiFragments_Projection.res.mjs";
|
|
90
94
|
import * as EventLogStorage_Sqlite$ReventlessLocal from "./adapter/EventLog/EventLogStorage_Sqlite.res.mjs";
|
|
91
95
|
import * as ExtensionPoint_Builder$ReventlessLocal from "./components/ExtensionPoint_Builder.res.mjs";
|
|
96
|
+
import * as LocalCommandOutcomeLog$ReventlessLocal from "./adapter/CommandOutcomeLog/LocalCommandOutcomeLog.res.mjs";
|
|
92
97
|
import * as PlatformGraphQL_Server$ReventlessLocal from "./adapter/PlatformGraphQL_Server.res.mjs";
|
|
93
98
|
import * as Platform_UIFragmentsApi$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_UIFragmentsApi.res.mjs";
|
|
94
99
|
import * as StateViewSlice_Builder$ReventlessLocal from "./components/StateViewSlice_Builder.res.mjs";
|
|
@@ -119,6 +124,8 @@ EffectLogger$ReventlessCore.setDefaultMinLevel("Debug");
|
|
|
119
124
|
|
|
120
125
|
AnsiStyle$Reventless.setDefaultFormat("text");
|
|
121
126
|
|
|
127
|
+
LocalMetrics$ReventlessLocal.install();
|
|
128
|
+
|
|
122
129
|
let log = Logger$ReventlessCore.fromEnv();
|
|
123
130
|
|
|
124
131
|
let platformGraphQLRef = {
|
|
@@ -152,7 +159,11 @@ function decodeUiFragmentRegistryEventEnvelope(eventJson) {
|
|
|
152
159
|
function MakeWithConfig(Config) {
|
|
153
160
|
LocalLogFeed$ReventlessLocal.install(undefined);
|
|
154
161
|
LocalTapLines$ReventlessLocal.install();
|
|
162
|
+
LocalViewChangeLog$ReventlessLocal.install();
|
|
163
|
+
LocalCommandOutcomeLog$ReventlessLocal.install();
|
|
164
|
+
CommandTopic_Helpers$ReventlessCore.outcomeLines.contents = false;
|
|
155
165
|
Role$Reventless.provideGroupsFrom(AccountStore$ReventlessLocal.providedGroups);
|
|
166
|
+
OwnerScope$Reventless.defaultElevatedRoles([Role$Reventless.admin]);
|
|
156
167
|
TestRunner$ReventlessLocal.setup();
|
|
157
168
|
let match = Config.backend;
|
|
158
169
|
if (typeof match !== "object") {
|
|
@@ -1177,6 +1188,7 @@ function MakeWithConfig(Config) {
|
|
|
1177
1188
|
kind: match[0],
|
|
1178
1189
|
path: match[1]
|
|
1179
1190
|
}, undefined, Config.splitApi ? `http://localhost:` + platformPort.toString() + `/graphql` : `http://localhost:` + domainPort.toString() + `/graphql`);
|
|
1191
|
+
LocalIdleExit$ReventlessLocal.start(process.env);
|
|
1180
1192
|
LocalEventTap$ReventlessLocal.start(undefined, tapPort => LocalPlatformRegistry$ReventlessLocal.publishTapPort(domainPort, tapPort, undefined), undefined);
|
|
1181
1193
|
LocalLogFeed$ReventlessLocal.start(undefined, logPort => LocalPlatformRegistry$ReventlessLocal.publishLogPort(domainPort, logPort, undefined), undefined);
|
|
1182
1194
|
Plugin_Helpers$ReventlessCore.firePlatformDeployedHook({
|
|
@@ -1960,7 +1972,11 @@ function Make($star) {
|
|
|
1960
1972
|
let backend = Backend$ReventlessLocal.fromEnv();
|
|
1961
1973
|
LocalLogFeed$ReventlessLocal.install(undefined);
|
|
1962
1974
|
LocalTapLines$ReventlessLocal.install();
|
|
1975
|
+
LocalViewChangeLog$ReventlessLocal.install();
|
|
1976
|
+
LocalCommandOutcomeLog$ReventlessLocal.install();
|
|
1977
|
+
CommandTopic_Helpers$ReventlessCore.outcomeLines.contents = false;
|
|
1963
1978
|
Role$Reventless.provideGroupsFrom(AccountStore$ReventlessLocal.providedGroups);
|
|
1979
|
+
OwnerScope$Reventless.defaultElevatedRoles([Role$Reventless.admin]);
|
|
1964
1980
|
TestRunner$ReventlessLocal.setup();
|
|
1965
1981
|
if (typeof backend !== "object") {
|
|
1966
1982
|
BackendState$ReventlessLocal.setMemory();
|
|
@@ -2974,6 +2990,7 @@ function Make($star) {
|
|
|
2974
2990
|
kind: match[0],
|
|
2975
2991
|
path: match[1]
|
|
2976
2992
|
}, undefined, `http://localhost:` + platformPort.toString() + `/graphql`);
|
|
2993
|
+
LocalIdleExit$ReventlessLocal.start(process.env);
|
|
2977
2994
|
LocalEventTap$ReventlessLocal.start(undefined, tapPort => LocalPlatformRegistry$ReventlessLocal.publishTapPort(domainPort, tapPort, undefined), undefined);
|
|
2978
2995
|
LocalLogFeed$ReventlessLocal.start(undefined, logPort => LocalPlatformRegistry$ReventlessLocal.publishLogPort(domainPort, logPort, undefined), undefined);
|
|
2979
2996
|
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
|
|
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,33 @@
|
|
|
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. `startsFlow` keeps the
|
|
14
|
+
commands that started a flow: an API request, an inbound translation, a timer's work item. */
|
|
15
|
+
type filter = {correlationId?: string, msgId?: string, startsFlow?: bool}
|
|
16
|
+
|
|
17
|
+
/** A flow's first command is the one whose flow id is its own message id. */
|
|
18
|
+
let startsFlow = (e: t) => e.flow == Some(e.msgId)
|
|
19
|
+
|
|
20
|
+
let matches = (f: filter, e: t) =>
|
|
21
|
+
f.correlationId->Option.mapOr(true, c => e.flow == Some(c)) &&
|
|
22
|
+
f.msgId->Option.mapOr(true, m => e.msgId == m) &&
|
|
23
|
+
(f.startsFlow != Some(true) || startsFlow(e))
|
|
24
|
+
|
|
25
|
+
let filterOfJson = (j: option<JSON.t>): filter => {
|
|
26
|
+
let d = j->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
|
|
27
|
+
let s = k => d->Dict.get(k)->Option.flatMap(JSON.Decode.string)
|
|
28
|
+
{
|
|
29
|
+
correlationId: ?s("correlationId"),
|
|
30
|
+
msgId: ?s("msgId"),
|
|
31
|
+
startsFlow: ?(d->Dict.get("startsFlow")->Option.flatMap(JSON.Decode.bool)),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 startsFlow(e) {
|
|
8
|
+
return Primitive_object.equal(e.flow, e.msgId);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function matches(f, e) {
|
|
12
|
+
if (Stdlib_Option.mapOr(f.correlationId, true, c => Primitive_object.equal(e.flow, c)) && Stdlib_Option.mapOr(f.msgId, true, m => e.msgId === m)) {
|
|
13
|
+
if (Primitive_object.notequal(f.startsFlow, true)) {
|
|
14
|
+
return true;
|
|
15
|
+
} else {
|
|
16
|
+
return startsFlow(e);
|
|
17
|
+
}
|
|
18
|
+
} else {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function filterOfJson(j) {
|
|
24
|
+
let d = Stdlib_Option.getOr(Stdlib_Option.flatMap(j, Stdlib_JSON.Decode.object), {});
|
|
25
|
+
let s = k => Stdlib_Option.flatMap(d[k], Stdlib_JSON.Decode.string);
|
|
26
|
+
return {
|
|
27
|
+
correlationId: s("correlationId"),
|
|
28
|
+
msgId: s("msgId"),
|
|
29
|
+
startsFlow: Stdlib_Option.flatMap(d["startsFlow"], Stdlib_JSON.Decode.bool)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
startsFlow,
|
|
35
|
+
matches,
|
|
36
|
+
filterOfJson,
|
|
37
|
+
}
|
|
38
|
+
/* 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,64 @@
|
|
|
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
|
+
if filter.startsFlow == Some(true) {
|
|
56
|
+
where->Array.push("flow = msg_id")
|
|
57
|
+
}
|
|
58
|
+
before->Option.forEach(b => add("seq < ?", JSON.Encode.int(b)))
|
|
59
|
+
let sql = `SELECT seq, flow, msg_id, item FROM ${table}${where == []
|
|
60
|
+
? ""
|
|
61
|
+
: " WHERE " ++ where->Array.join(" AND ")} ORDER BY seq DESC LIMIT ?`
|
|
62
|
+
params->Array.push(JSON.Encode.int(first))
|
|
63
|
+
db->SqliteDriver.prepare(sql)->SqliteDriver.all(params)->Array.filterMap(entryOfRow)
|
|
64
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
if (Primitive_object.equal(filter.startsFlow, true)) {
|
|
60
|
+
where.push("flow = msg_id");
|
|
61
|
+
}
|
|
62
|
+
Stdlib_Option.forEach(before, b => add("seq < ?", b));
|
|
63
|
+
let sql = `SELECT seq, flow, msg_id, item FROM ` + table + (
|
|
64
|
+
Primitive_object.equal(where, []) ? "" : " WHERE " + where.join(" AND ")
|
|
65
|
+
) + ` ORDER BY seq DESC LIMIT ?`;
|
|
66
|
+
params.push(first);
|
|
67
|
+
return Stdlib_Array.filterMap(SqliteDriver$ReventlessLocal.all(SqliteDriver$ReventlessLocal.prepare(db, sql), params), entryOfRow);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export {
|
|
71
|
+
table,
|
|
72
|
+
ensureSchema,
|
|
73
|
+
str,
|
|
74
|
+
append,
|
|
75
|
+
entryOfRow,
|
|
76
|
+
read,
|
|
77
|
+
}
|
|
78
|
+
/* SqliteDriver-ReventlessLocal Not a pure module */
|