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

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 (57) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +11 -11
  3. package/src/LocalPlatformRegistry.res +19 -0
  4. package/src/LocalPlatformRegistry.res.mjs +19 -1
  5. package/src/Platform.res +24 -2
  6. package/src/Platform.res.mjs +22 -2
  7. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +38 -0
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +29 -0
  9. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +87 -0
  10. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +76 -0
  11. package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res +8 -1
  12. package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +17 -4
  13. package/src/adapter/EventLog/EventLogStorage_InMemory.res +49 -2
  14. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +57 -21
  15. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +85 -0
  16. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +50 -0
  17. package/src/adapter/EventLog/LocalEventLogStorage.res +3 -0
  18. package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +6 -2
  19. package/src/adapter/LocalBus.res +32 -5
  20. package/src/adapter/LocalBus.res.mjs +27 -7
  21. package/src/adapter/LocalEventLogRead.res +477 -0
  22. package/src/adapter/LocalEventLogRead.res.mjs +507 -0
  23. package/src/adapter/LocalEventTap.res +18 -80
  24. package/src/adapter/LocalEventTap.res.mjs +19 -108
  25. package/src/adapter/LocalFeed.res +433 -0
  26. package/src/adapter/LocalFeed.res.mjs +419 -0
  27. package/src/adapter/LocalLogFeed.res +217 -0
  28. package/src/adapter/LocalLogFeed.res.mjs +295 -0
  29. package/src/adapter/LocalPgEventLogRead.res +85 -0
  30. package/src/adapter/LocalPgEventLogRead.res.mjs +91 -0
  31. package/src/adapter/LocalPlatformObserve.res +456 -0
  32. package/src/adapter/LocalPlatformObserve.res.mjs +505 -0
  33. package/src/adapter/LocalTapLines.res +147 -0
  34. package/src/adapter/LocalTapLines.res.mjs +229 -0
  35. package/src/adapter/PlatformGraphQL_Server.res +36 -3
  36. package/src/adapter/PlatformGraphQL_Server.res.mjs +15 -1
  37. package/src/reexport/CommandTopic.res.mjs +5 -2
  38. package/tests/adapter/GraphQLWsFixtures.mjs +46 -0
  39. package/tests/adapter/LocalEventLogReadTest.res +539 -0
  40. package/tests/adapter/LocalEventLogReadTest.res.mjs +466 -0
  41. package/tests/adapter/LocalFeedTest.res +314 -0
  42. package/tests/adapter/LocalFeedTest.res.mjs +329 -0
  43. package/tests/adapter/LocalPlatformObserveTest.res +165 -0
  44. package/tests/adapter/LocalPlatformObserveTest.res.mjs +183 -0
  45. package/tests/adapter/LocalTapLinesTest.res +182 -0
  46. package/tests/adapter/LocalTapLinesTest.res.mjs +242 -0
  47. package/tests/adapter/PlatformSubscriptionsTest.res +123 -0
  48. package/tests/adapter/PlatformSubscriptionsTest.res.mjs +132 -0
  49. package/tests/components/automationslice/AutomationSliceCallbackTest.res +80 -0
  50. package/tests/components/automationslice/AutomationSliceCallbackTest.res.mjs +81 -0
  51. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +17 -0
  52. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +24 -0
  53. package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res +30 -0
  54. package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res.mjs +28 -0
  55. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +58 -0
  56. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +71 -19
  57. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePiiTest.res.mjs +7 -7
@@ -0,0 +1,165 @@
1
+ // What the platform API and the platform MCP server offer a watching tool: the
2
+ // MCP resources for recent logs and all events answer and filter, and the API
3
+ // fields are refused to a caller without the admin group.
4
+
5
+ @@warning("-44")
6
+
7
+ open JestGlobals
8
+
9
+ module Logger = ReventlessCore.Logger
10
+
11
+ let _ = TestRunner.setup()
12
+ let opts: Pulumi.CustomResourceOptions.t = {}
13
+
14
+ let identity = (~groups): Reventless.Identity.t => {
15
+ userId: "u-1",
16
+ username: "u",
17
+ groups,
18
+ provider: InMemory,
19
+ }
20
+
21
+ let context = (~groups): JSON.t => Obj.magic({"identity": identity(~groups), "authenticated": true})
22
+
23
+ let admin = () => context(~groups=[Reventless.Role.adminGroup()])
24
+
25
+ let textOf = (r: McpSdk.readResourceResult): JSON.t =>
26
+ (r.contents->Array.getUnsafe(0)).text->JSON.parseOrThrow
27
+
28
+ let field = (json: JSON.t, key: string): JSON.t =>
29
+ json->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))->Option.getOr(JSON.Encode.null)
30
+
31
+ let strings = (items: JSON.t, key: string): array<string> =>
32
+ items
33
+ ->JSON.Decode.array
34
+ ->Option.getOr([])
35
+ ->Array.filterMap(i => i->field(key)->JSON.Decode.string)
36
+
37
+ describe("MCP resource platform/logs/recent", () => {
38
+ afterEach(() => LocalLogFeed.resetForTests())
39
+
40
+ testPromise("answers with the recent records, filtered by its query", async () => {
41
+ LocalLogFeed.install(~env=Dict.make())
42
+ Logger.emit(~level=Warn, ~comp="Projection", ~plugin="Ordering", "warned")
43
+ Logger.emit(~level=Info, ~comp="Projection", ~plugin="Ordering", "informed")
44
+ Logger.emit(~level=Error, ~comp="Projection", ~plugin="Catalog", "failed")
45
+ let all = await LocalPlatformObserve.recentLogs("platform/logs/recent")
46
+ expect(all->textOf->field("records")->strings("message"))->toEqual([
47
+ "warned",
48
+ "informed",
49
+ "failed",
50
+ ])
51
+ let narrowed = await LocalPlatformObserve.recentLogs(
52
+ "platform/logs/recent?plugins=Ordering&level=warn",
53
+ )
54
+ expect(narrowed->textOf->field("records")->strings("message"))->toEqual(["warned"])
55
+ let last = await LocalPlatformObserve.recentLogs("platform/logs/recent?limit=1")
56
+ expect(last->textOf->field("records")->strings("message"))->toEqual(["failed"])
57
+ })
58
+ })
59
+
60
+ describe("MCP resource platform/events", () => {
61
+ testPromise("answers with every log's events newest first, filtered by its query", async () => {
62
+ BackendState.setMemory()
63
+ LocalEventLogRead.resetForTests()
64
+ module Bus = LocalBus.MakeSilent()
65
+ module Dcb = LocalDcbEventLogStorage.Make(Bus)
66
+ let catalog = await Dcb.make(
67
+ ~name="CatalogDcbEventLog",
68
+ ~indexes=[],
69
+ ~partitionTag=Reventless.DcbTag.Simple({key: "productId"}),
70
+ ~opts,
71
+ ).operations->TestRunner.resolve
72
+ LocalEventLogRead.notePlugin(~log="CatalogDcbEventLog", ~plugin="Catalog")
73
+ let add = async (productId, correlationId) => {
74
+ let _ = await catalog.append([
75
+ {
76
+ eventType: "Added",
77
+ data: JSON.Encode.null,
78
+ tags: [{key: "productId", value: productId}],
79
+ meta: ReventlessCore.Message.generateMeta(~service="test", ~correlationId),
80
+ },
81
+ ])
82
+ }
83
+ await add("p-1", "flow-a")
84
+ await add("p-2", "flow-b")
85
+ await add("p-3", "flow-a")
86
+ let all = await LocalPlatformObserve.events("platform/events")
87
+ let tagValues = page =>
88
+ page
89
+ ->textOf
90
+ ->field("edges")
91
+ ->JSON.Decode.array
92
+ ->Option.getOr([])
93
+ ->Array.map(edge => edge->field("node")->field("tags")->strings("value")->Array.join(","))
94
+ expect(all->tagValues)->toEqual(["p-3", "p-2", "p-1"])
95
+ let flow = await LocalPlatformObserve.events("platform/events?correlationId=flow-a&first=1")
96
+ expect(flow->tagValues)->toEqual(["p-3"])
97
+ let cursor = flow->textOf->field("pageInfo")->field("endCursor")->JSON.Decode.string
98
+ let next = await LocalPlatformObserve.events(
99
+ `platform/events?correlationId=flow-a&first=1&after=${cursor->Option.getOr("")}`,
100
+ )
101
+ expect(next->tagValues)->toEqual(["p-1"])
102
+ let none = await LocalPlatformObserve.events("platform/events?plugins=Ordering")
103
+ expect(none->tagValues)->toEqual([])
104
+ })
105
+ })
106
+
107
+ describe("the platform API's watching fields", () => {
108
+ afterEach(() => Logger.clearLevels())
109
+
110
+ let server = () => {
111
+ let s = ReventlessGraphqlServer.GraphQL_ServerInstance.make(~label="Test")
112
+ LocalPlatformObserve.registerGraphQL(s)
113
+ s
114
+ }
115
+
116
+ testPromise("are refused to a caller without the admin group", async () => {
117
+ let s = server()
118
+ let refused = async (resolver: option<GraphqlYoga.resolverFn>) => {
119
+ let run = resolver->Option.getOrThrow
120
+ switch await run(JSON.Encode.null, JSON.Encode.null, context(~groups=["Customer"])) {
121
+ | _ => false
122
+ | exception _ => true
123
+ }
124
+ }
125
+ expect(await refused(s.getQueryResolver(LocalPlatformObserve.eventLogField)))->toBe(true)
126
+ expect(await refused(s.getQueryResolver(LocalPlatformObserve.logLevelsField)))->toBe(true)
127
+ expect(await refused(s.getMutationResolver(LocalPlatformObserve.setLogLevelField)))->toBe(true)
128
+ })
129
+
130
+ testPromise("set and list the log levels for an admin", async () => {
131
+ let s = server()
132
+ let set = s.getMutationResolver(LocalPlatformObserve.setLogLevelField)->Option.getOrThrow
133
+ let args = JSON.parseOrThrow(`{"plugin":"Ordering","comp":"Projection","level":"DEBUG"}`)
134
+ let answer = await set(JSON.Encode.null, args, admin())
135
+ expect(answer->field("settings"))->toEqual(
136
+ JSON.parseOrThrow(`[{"level":"DEBUG","plugin":"Ordering","comp":"Projection"}]`),
137
+ )
138
+ expect(Logger.minLevelFor(~plugin="Ordering", ~comp="Projection", ()))->toEqual(
139
+ Some(Logger.Debug),
140
+ )
141
+ let cleared = await set(
142
+ JSON.Encode.null,
143
+ JSON.parseOrThrow(`{"plugin":"Ordering","comp":"Projection","level":null}`),
144
+ admin(),
145
+ )
146
+ expect(cleared->field("settings"))->toEqual(JSON.parseOrThrow(`[]`))
147
+ })
148
+
149
+ testPromise("the event log answers an admin with a connection", async () => {
150
+ BackendState.setMemory()
151
+ LocalEventLogRead.resetForTests()
152
+ let s = server()
153
+ let read = s.getQueryResolver(LocalPlatformObserve.eventLogField)->Option.getOrThrow
154
+ let answer = await read(JSON.Encode.null, JSON.parseOrThrow(`{"first":5}`), admin())
155
+ expect(answer->field("edges"))->toEqual(JSON.Encode.array([]))
156
+ expect(answer->field("pageInfo")->field("hasNextPage"))->toEqual(JSON.Encode.bool(false))
157
+ })
158
+
159
+ testSync("the SDL builds into a schema", () => {
160
+ let s = server()
161
+ let sdl = s.buildSdl()
162
+ expect(sdl->String.includes("Platform_EventLog(filter: Platform_EventLogFilter"))->toBe(true)
163
+ let _ = GraphqlYoga.createSchema({"typeDefs": sdl, "resolvers": Dict.make()})
164
+ })
165
+ })
@@ -0,0 +1,183 @@
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 GraphqlYoga from "graphql-yoga";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
8
+ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
9
+ import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
10
+ import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
11
+ import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
12
+ import * as BackendState$ReventlessLocal from "../../src/adapter/BackendState.res.mjs";
13
+ import * as LocalLogFeed$ReventlessLocal from "../../src/adapter/LocalLogFeed.res.mjs";
14
+ import * as LocalEventLogRead$ReventlessLocal from "../../src/adapter/LocalEventLogRead.res.mjs";
15
+ import * as LocalPlatformObserve$ReventlessLocal from "../../src/adapter/LocalPlatformObserve.res.mjs";
16
+ import * as LocalDcbEventLogStorage$ReventlessLocal from "../../src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs";
17
+ import * as GraphQL_ServerInstance$ReventlessGraphqlServer from "@reventlessdev/reventless-graphql-server/src/GraphQL_ServerInstance.res.mjs";
18
+
19
+ TestRunner$ReventlessLocal.setup();
20
+
21
+ let opts = {};
22
+
23
+ function identity(groups) {
24
+ return {
25
+ userId: "u-1",
26
+ username: "u",
27
+ groups: groups,
28
+ provider: "InMemory"
29
+ };
30
+ }
31
+
32
+ function context(groups) {
33
+ return {
34
+ identity: identity(groups),
35
+ authenticated: true
36
+ };
37
+ }
38
+
39
+ function admin() {
40
+ return context([Role$Reventless.adminGroup()]);
41
+ }
42
+
43
+ function textOf(r) {
44
+ return JSON.parse(r.contents[0].text);
45
+ }
46
+
47
+ function field(json, key) {
48
+ return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), d => d[key]), null);
49
+ }
50
+
51
+ function strings(items, key) {
52
+ return Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_JSON.Decode.array(items), []), i => Stdlib_JSON.Decode.string(field(i, key)));
53
+ }
54
+
55
+ globalThis.describe("MCP resource platform/logs/recent", () => {
56
+ globalThis.afterEach(() => LocalLogFeed$ReventlessLocal.resetForTests());
57
+ globalThis.test("answers with the recent records, filtered by its query", async () => {
58
+ LocalLogFeed$ReventlessLocal.install({});
59
+ Logger$ReventlessCore.emit("Warn", "Projection", "Ordering", undefined, undefined, "warned");
60
+ Logger$ReventlessCore.emit("Info", "Projection", "Ordering", undefined, undefined, "informed");
61
+ Logger$ReventlessCore.emit("Error", "Projection", "Catalog", undefined, undefined, "failed");
62
+ let all = await LocalPlatformObserve$ReventlessLocal.recentLogs("platform/logs/recent");
63
+ globalThis.expect(strings(field(textOf(all), "records"), "message")).toEqual([
64
+ "warned",
65
+ "informed",
66
+ "failed"
67
+ ]);
68
+ let narrowed = await LocalPlatformObserve$ReventlessLocal.recentLogs("platform/logs/recent?plugins=Ordering&level=warn");
69
+ globalThis.expect(strings(field(textOf(narrowed), "records"), "message")).toEqual(["warned"]);
70
+ let last = await LocalPlatformObserve$ReventlessLocal.recentLogs("platform/logs/recent?limit=1");
71
+ globalThis.expect(strings(field(textOf(last), "records"), "message")).toEqual(["failed"]);
72
+ });
73
+ });
74
+
75
+ globalThis.describe("MCP resource platform/events", () => {
76
+ globalThis.test("answers with every log's events newest first, filtered by its query", async () => {
77
+ BackendState$ReventlessLocal.setMemory();
78
+ LocalEventLogRead$ReventlessLocal.resetForTests();
79
+ let Bus = LocalBus$ReventlessLocal.MakeSilent({});
80
+ let Dcb = LocalDcbEventLogStorage$ReventlessLocal.Make(Bus);
81
+ let catalog = await TestRunner$ReventlessLocal.resolve(Dcb.make("CatalogDcbEventLog", [], {
82
+ TAG: "Simple",
83
+ _0: {
84
+ key: "productId"
85
+ }
86
+ }, undefined, opts).operations);
87
+ LocalEventLogRead$ReventlessLocal.notePlugin("CatalogDcbEventLog", "Catalog");
88
+ let add = async (productId, correlationId) => {
89
+ await catalog.append([{
90
+ eventType: "Added",
91
+ data: null,
92
+ tags: [{
93
+ key: "productId",
94
+ value: productId
95
+ }],
96
+ meta: Message$ReventlessCore.generateMeta("test", undefined, undefined, undefined, correlationId, undefined, undefined)
97
+ }], undefined);
98
+ };
99
+ await add("p-1", "flow-a");
100
+ await add("p-2", "flow-b");
101
+ await add("p-3", "flow-a");
102
+ let all = await LocalPlatformObserve$ReventlessLocal.events("platform/events");
103
+ let tagValues = page => Stdlib_Option.getOr(Stdlib_JSON.Decode.array(field(textOf(page), "edges")), []).map(edge => strings(field(field(edge, "node"), "tags"), "value").join(","));
104
+ globalThis.expect(tagValues(all)).toEqual([
105
+ "p-3",
106
+ "p-2",
107
+ "p-1"
108
+ ]);
109
+ let flow = await LocalPlatformObserve$ReventlessLocal.events("platform/events?correlationId=flow-a&first=1");
110
+ globalThis.expect(tagValues(flow)).toEqual(["p-3"]);
111
+ let cursor = Stdlib_JSON.Decode.string(field(field(textOf(flow), "pageInfo"), "endCursor"));
112
+ let next = await LocalPlatformObserve$ReventlessLocal.events(`platform/events?correlationId=flow-a&first=1&after=` + Stdlib_Option.getOr(cursor, ""));
113
+ globalThis.expect(tagValues(next)).toEqual(["p-1"]);
114
+ let none = await LocalPlatformObserve$ReventlessLocal.events("platform/events?plugins=Ordering");
115
+ globalThis.expect(tagValues(none)).toEqual([]);
116
+ });
117
+ });
118
+
119
+ globalThis.describe("the platform API's watching fields", () => {
120
+ globalThis.afterEach(() => Logger$ReventlessCore.clearLevels());
121
+ let server = () => {
122
+ let s = GraphQL_ServerInstance$ReventlessGraphqlServer.make("Test");
123
+ LocalPlatformObserve$ReventlessLocal.registerGraphQL(s);
124
+ return s;
125
+ };
126
+ globalThis.test("are refused to a caller without the admin group", async () => {
127
+ let s = server();
128
+ let refused = async resolver => {
129
+ let run = Stdlib_Option.getOrThrow(resolver, undefined);
130
+ try {
131
+ await run(null, null, context(["Customer"]));
132
+ return false;
133
+ } catch (exn) {
134
+ return true;
135
+ }
136
+ };
137
+ globalThis.expect(await refused(s.getQueryResolver(LocalPlatformObserve$ReventlessLocal.eventLogField))).toBe(true);
138
+ globalThis.expect(await refused(s.getQueryResolver(LocalPlatformObserve$ReventlessLocal.logLevelsField))).toBe(true);
139
+ globalThis.expect(await refused(s.getMutationResolver(LocalPlatformObserve$ReventlessLocal.setLogLevelField))).toBe(true);
140
+ });
141
+ globalThis.test("set and list the log levels for an admin", async () => {
142
+ let s = server();
143
+ let set = Stdlib_Option.getOrThrow(s.getMutationResolver(LocalPlatformObserve$ReventlessLocal.setLogLevelField), undefined);
144
+ let args = JSON.parse(`{"plugin":"Ordering","comp":"Projection","level":"DEBUG"}`);
145
+ let answer = await set(null, args, context([Role$Reventless.adminGroup()]));
146
+ globalThis.expect(field(answer, "settings")).toEqual(JSON.parse(`[{"level":"DEBUG","plugin":"Ordering","comp":"Projection"}]`));
147
+ globalThis.expect(Logger$ReventlessCore.minLevelFor("Ordering", "Projection", undefined)).toEqual("Debug");
148
+ let cleared = await set(null, JSON.parse(`{"plugin":"Ordering","comp":"Projection","level":null}`), context([Role$Reventless.adminGroup()]));
149
+ globalThis.expect(field(cleared, "settings")).toEqual(JSON.parse(`[]`));
150
+ });
151
+ globalThis.test("the event log answers an admin with a connection", async () => {
152
+ BackendState$ReventlessLocal.setMemory();
153
+ LocalEventLogRead$ReventlessLocal.resetForTests();
154
+ let s = server();
155
+ let read = Stdlib_Option.getOrThrow(s.getQueryResolver(LocalPlatformObserve$ReventlessLocal.eventLogField), undefined);
156
+ let answer = await read(null, JSON.parse(`{"first":5}`), context([Role$Reventless.adminGroup()]));
157
+ globalThis.expect(field(answer, "edges")).toEqual([]);
158
+ globalThis.expect(field(field(answer, "pageInfo"), "hasNextPage")).toEqual(false);
159
+ });
160
+ globalThis.test("the SDL builds into a schema", () => {
161
+ let s = server();
162
+ let sdl = s.buildSdl();
163
+ globalThis.expect(sdl.includes("Platform_EventLog(filter: Platform_EventLogFilter")).toBe(true);
164
+ GraphqlYoga.createSchema({
165
+ typeDefs: sdl,
166
+ resolvers: {}
167
+ });
168
+ });
169
+ });
170
+
171
+ let Logger;
172
+
173
+ export {
174
+ Logger,
175
+ opts,
176
+ identity,
177
+ context,
178
+ admin,
179
+ textOf,
180
+ field,
181
+ strings,
182
+ }
183
+ /* Not a pure module */
@@ -0,0 +1,182 @@
1
+ // The event tap's lines besides events: each command's outcome, once, and each
2
+ // row a projection changed, its values redacted; a rejected command is also kept
3
+ // in the problem ring.
4
+
5
+ open JestGlobals
6
+
7
+ module Outcome = ReventlessCore.CommandTopic_Helpers
8
+ module ViewChange = ReventlessCore.ViewChange
9
+
10
+ let sleep = (ms: int): promise<unit> =>
11
+ Promise.make((resolve, _) => {
12
+ let _ = setTimeout(() => resolve(), ms)
13
+ })
14
+
15
+ let until = async (~ms=2000, cond: unit => bool): bool => {
16
+ let waited = ref(0)
17
+ while !cond() && waited.contents < ms {
18
+ await sleep(5)
19
+ waited := waited.contents + 5
20
+ }
21
+ cond()
22
+ }
23
+
24
+ type client = {socket: NodeNet.socket, mutable received: string}
25
+
26
+ let connect = (port: int): promise<client> =>
27
+ Promise.make((resolve, _) => {
28
+ let c = ref(None)
29
+ let socket = NodeNet.connect(port, "127.0.0.1", () =>
30
+ c.contents->Option.forEach(client => resolve(client))
31
+ )
32
+ socket->NodeNet.setEncoding("utf8")
33
+ socket->NodeNet.onSocketError(_ => ())
34
+ let client = {socket, received: ""}
35
+ socket->NodeNet.onSocketData(chunk => client.received = client.received ++ chunk)
36
+ c := Some(client)
37
+ })
38
+
39
+ /** The lines of a kind, as objects, without their time. */
40
+ let linesOf = (c: client, ~kind: string): array<dict<JSON.t>> =>
41
+ c.received
42
+ ->String.split("\n")
43
+ ->Array.filterMap(l =>
44
+ switch l->JSON.parseOrThrow->JSON.Decode.object {
45
+ | Some(d) if d->Dict.get("kind") == Some(JSON.String(kind)) =>
46
+ d->Dict.delete("ts")
47
+ Some(d)
48
+ | _ => None
49
+ | exception _ => None
50
+ }
51
+ )
52
+
53
+ @schema
54
+ type row = {
55
+ name: string,
56
+ token: @s.matches(Reventless.Sensitive.string) string,
57
+ }
58
+
59
+ let connectTap = async () => {
60
+ LocalEventTap.resetForTests()
61
+ LocalLogFeed.resetForTests()
62
+ LocalTapLines.resetForTests()
63
+ LocalLogFeed.install(~env=Dict.make())
64
+ LocalTapLines.install()
65
+ let port = await Promise.make((resolve, _) =>
66
+ LocalEventTap.start(~env=Dict.make(), ~onBound=resolve, ())
67
+ )
68
+ let tap = await connect(port)
69
+ let _ = await until(() => LocalEventTap.hasReaders())
70
+ tap
71
+ }
72
+
73
+ let disconnect = async (tap: client) => {
74
+ NodeNet.destroySocket(tap.socket)
75
+ LocalTapLines.resetForTests()
76
+ await LocalEventTap.stopForTests()
77
+ LocalLogFeed.resetForTests()
78
+ }
79
+
80
+ describe("LocalTapLines", () => {
81
+ test("an accepted and a rejected command each appear once on the tap", async () => {
82
+ let tap = await connectTap()
83
+
84
+ Outcome.reportAccepted(
85
+ ~component="Order",
86
+ ~correlationId="flow-1",
87
+ "m-1",
88
+ {entityId: "o-1", eventCount: 2},
89
+ )
90
+ Outcome.reportRejected(
91
+ ~component="Order",
92
+ ~cause=DomainRejection,
93
+ ~correlationId="flow-1",
94
+ "m-2",
95
+ {errorCode: "AlreadyExists", errorDetail: `{"name":"Widget"}`},
96
+ )
97
+ let _ = await until(() => tap->linesOf(~kind="commandOutcome")->Array.length >= 2)
98
+ await sleep(20)
99
+
100
+ expect(tap->linesOf(~kind="commandOutcome"))->toEqual([
101
+ Dict.fromArray([
102
+ ("kind", JSON.String("commandOutcome")),
103
+ ("component", JSON.String("Order")),
104
+ ("msgId", JSON.String("m-1")),
105
+ ("correlationId", JSON.String("flow-1")),
106
+ ("outcome", JSON.String("accepted")),
107
+ ("entityId", JSON.String("o-1")),
108
+ ("eventCount", JSON.Number(2.)),
109
+ ]),
110
+ Dict.fromArray([
111
+ ("kind", JSON.String("commandOutcome")),
112
+ ("component", JSON.String("Order")),
113
+ ("msgId", JSON.String("m-2")),
114
+ ("correlationId", JSON.String("flow-1")),
115
+ ("outcome", JSON.String("rejected")),
116
+ ("errorCode", JSON.String("AlreadyExists")),
117
+ ("cause", JSON.String("DOMAIN")),
118
+ ]),
119
+ ])
120
+ // Not an event line: no sentinel prefix.
121
+ expect(tap.received->String.includes("@@RVLESS_EVT@@"))->toBe(false)
122
+
123
+ // The rejection, and only it, is kept as a warning in the problem ring.
124
+ let kept =
125
+ LocalLogFeed.recentRecords(~filter={level: Warn})->Array.filter(
126
+ r => r.message == "command rejected",
127
+ )
128
+ expect(kept->Array.map(r => (r.comp, r.annotations->Dict.get("msgId"))))->toEqual([
129
+ (Some("CommandTopic(Order)"), Some("m-2")),
130
+ ])
131
+ expect(
132
+ (kept->Array.getUnsafe(0)).detail->Option.map(j => j->JSON.stringify)->Option.getOr(""),
133
+ )->toBe(`{"component":"Order","errorCode":"AlreadyExists","cause":"DOMAIN","errorDetail":{"name":"Widget"}}`)
134
+
135
+ await disconnect(tap)
136
+ })
137
+
138
+ test("an infrastructure failure is kept as an error", async () => {
139
+ let tap = await connectTap()
140
+ Outcome.reportRejected(
141
+ ~component="Order",
142
+ ~cause=InfrastructureFailure,
143
+ "m-3",
144
+ {errorCode: "AppendFailed", errorDetail: "store down"},
145
+ )
146
+ let kept =
147
+ LocalLogFeed.recentRecords(~filter={level: Error})->Array.filter(
148
+ r => r.message == "command rejected",
149
+ )
150
+ expect(kept->Array.length)->toBe(1)
151
+ await disconnect(tap)
152
+ })
153
+
154
+ test("a view change is sent with its values redacted", async () => {
155
+ let tap = await connectTap()
156
+ let onChange = ViewChange.reporter(
157
+ ~comp="ReadModel(Accounts)",
158
+ ~view="Accounts",
159
+ ~stateToJson=r => r->Reventless.Util_Sury.toJson(rowSchema),
160
+ )
161
+ onChange({
162
+ key: "a-1",
163
+ kind: Changed,
164
+ before: {name: "Ada", token: "old-secret"},
165
+ after: {name: "Ada L.", token: "new-secret"},
166
+ sources: [{msgId: "m-1", correlationId: "flow-1", entityId: "a-1", entityType: "Account"}],
167
+ })
168
+ let _ = await until(() => tap->linesOf(~kind="viewChange")->Array.length >= 1)
169
+
170
+ let lines = tap->linesOf(~kind="viewChange")
171
+ expect(lines->Array.length)->toBe(1)
172
+ let line = lines->Array.getUnsafe(0)->JSON.Encode.object->JSON.stringify
173
+ expect(line->String.includes("secret"))->toBe(false)
174
+ expect(line->String.includes(Reventless.LogRedaction.placeholder))->toBe(true)
175
+ expect(line->String.includes(`"name":"Ada L."`))->toBe(true)
176
+ expect(
177
+ line->String.startsWith(`{"kind":"viewChange","view":"Accounts","key":"a-1","change":"changed","fields":["name","token"],"sources":[{"msgId":"m-1","correlationId":"flow-1","entityId":"a-1","entityType":"Account"}],"correlationId":"flow-1","before":`),
178
+ )->toBe(true)
179
+
180
+ await disconnect(tap)
181
+ })
182
+ })