@reventlessdev/reventless-local 3.0.0-alpha.172 → 3.0.0-alpha.174
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 +15 -0
- package/package.json +7 -7
- package/src/Platform.res +9 -0
- package/src/Platform.res.mjs +7 -0
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +352 -0
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs +362 -0
- package/tests/adapter/EventHistoryResolverTest.res +208 -0
- package/tests/adapter/EventHistoryResolverTest.res.mjs +286 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
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.174 (2026-07-27)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **api:** event-history query — the Source A read counterpart ([9268b33](https://github.com/ReventlessDev/reventless-core/commit/9268b33a4835d4cd4bc79b38f19bc2b974853fa4))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.173 (2026-07-27)
|
|
14
|
+
|
|
15
|
+
**Note:** Version bump only for package @reventlessdev/reventless-local
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.172 (2026-07-27)
|
|
7
22
|
|
|
8
23
|
**Note:** Version bump only for package @reventlessdev/reventless-local
|
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.174",
|
|
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
|
"jest": {
|
|
@@ -34,13 +34,13 @@
|
|
|
34
34
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.31",
|
|
35
35
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.25",
|
|
36
36
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.9",
|
|
37
|
-
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
|
|
38
37
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
|
|
39
|
-
"@reventlessdev/
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
42
|
-
"@reventlessdev/reventless-
|
|
43
|
-
"@reventlessdev/reventless-
|
|
38
|
+
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
|
|
39
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.186",
|
|
40
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.34",
|
|
41
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.133",
|
|
42
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.106",
|
|
43
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.50",
|
|
44
44
|
"@reventlessdev/reventless-spec": "3.0.0-alpha.81"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/Platform.res
CHANGED
|
@@ -221,6 +221,10 @@ module MakeWithConfig = (
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// Event-history query resolvers. Reads the same event logs the Source A
|
|
225
|
+
// subscription bridge below streams, but historically and per entity.
|
|
226
|
+
module EventHistoryResolvers = EventHistoryResolvers_GraphQL.Make(Bus)
|
|
227
|
+
|
|
224
228
|
// Platform hook record — callbacks known at MakeWithConfig time plus a
|
|
225
229
|
// mutable ref for admin extension points (set by makePlatform/deployPlugin
|
|
226
230
|
// after Admin.construct returns, before plugins are built).
|
|
@@ -269,6 +273,11 @@ module MakeWithConfig = (
|
|
|
269
273
|
inboundMutationBindReceiveHook: InboundTranslationResolvers_GraphQL.bindReceive,
|
|
270
274
|
// Register GraphQL type definitions from the generated schema fragment.
|
|
271
275
|
schemaTypeRegistrationHook: sdlTypes => resolveTargetGraphQL().registerTypes(~sdlTypes),
|
|
276
|
+
// Event-history queries — the historical read counterpart of the Source A
|
|
277
|
+
// subscription bridged further down. Same `eventLogEntries`, so the two
|
|
278
|
+
// always describe the same set of streams.
|
|
279
|
+
eventQueryResolverHook: params =>
|
|
280
|
+
EventHistoryResolvers.register(~server=resolveTargetGraphQL(), params),
|
|
272
281
|
// MCP tools and resources — registered during plugin construction.
|
|
273
282
|
// See the large lambda below; it references Bus for QueryDb lookups.
|
|
274
283
|
mcpSchemaRegistrationHook: ({pluginName, mutationEntries, queryEntries, eventLogEntries, subscriptionFields}) => {
|
package/src/Platform.res.mjs
CHANGED
|
@@ -76,6 +76,7 @@ import * as DcbEventLogStorage_Sqlite$ReventlessLocal from "./adapter/DcbEventLo
|
|
|
76
76
|
import * as LocalEventCollectorChannel$ReventlessLocal from "./adapter/EventCollector/LocalEventCollectorChannel.res.mjs";
|
|
77
77
|
import * as PluginRuntime_Builder_Micro$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Runtime/PluginRuntime_Builder_Micro.res.mjs";
|
|
78
78
|
import * as UiFragmentRegistry_Behavior$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry_Behavior.res.mjs";
|
|
79
|
+
import * as EventHistoryResolvers_GraphQL$ReventlessLocal from "./adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs";
|
|
79
80
|
import * as InboundTranslationSlice_Builder$ReventlessLocal from "./components/InboundTranslationSlice_Builder.res.mjs";
|
|
80
81
|
import * as Platform_ComponentDefinitionsApi$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_ComponentDefinitionsApi.res.mjs";
|
|
81
82
|
import * as OutboundTranslationSlice_Builder$ReventlessLocal from "./components/OutboundTranslationSlice_Builder.res.mjs";
|
|
@@ -192,6 +193,7 @@ function MakeWithConfig(Config) {
|
|
|
192
193
|
return PlatformMCP_Server$ReventlessLocal.asInterface;
|
|
193
194
|
}
|
|
194
195
|
};
|
|
196
|
+
let EventHistoryResolvers = EventHistoryResolvers_GraphQL$ReventlessLocal.Make(Bus);
|
|
195
197
|
let hooks_mutationResolverHook = (kind, fields, commandSchema, commandAuthorization) => {
|
|
196
198
|
let server = resolveTargetGraphQL();
|
|
197
199
|
if (kind === "Aggregate") {
|
|
@@ -407,6 +409,7 @@ function MakeWithConfig(Config) {
|
|
|
407
409
|
});
|
|
408
410
|
LocalGraphQL_SubscriptionResolvers$ReventlessLocal.registerAll(server, subscriptionFields, sourceAEntries, []);
|
|
409
411
|
};
|
|
412
|
+
let hooks_eventQueryResolverHook = params => EventHistoryResolvers.register(resolveTargetGraphQL(), params);
|
|
410
413
|
let hooks_adminExtensionPoints = {
|
|
411
414
|
contents: Pulumi.output({})
|
|
412
415
|
};
|
|
@@ -435,6 +438,7 @@ function MakeWithConfig(Config) {
|
|
|
435
438
|
inboundMutationBindReceiveHook: hooks_inboundMutationBindReceiveHook,
|
|
436
439
|
schemaTypeRegistrationHook: hooks_schemaTypeRegistrationHook,
|
|
437
440
|
mcpSchemaRegistrationHook: hooks_mcpSchemaRegistrationHook,
|
|
441
|
+
eventQueryResolverHook: hooks_eventQueryResolverHook,
|
|
438
442
|
adminExtensionPoints: hooks_adminExtensionPoints,
|
|
439
443
|
scheduler: hooks_scheduler,
|
|
440
444
|
schedulerRoleUrn: hooks_schedulerRoleUrn,
|
|
@@ -1862,6 +1866,7 @@ function Make($star) {
|
|
|
1862
1866
|
return PlatformMCP_Server$ReventlessLocal.asInterface;
|
|
1863
1867
|
}
|
|
1864
1868
|
};
|
|
1869
|
+
let EventHistoryResolvers = EventHistoryResolvers_GraphQL$ReventlessLocal.Make(Bus);
|
|
1865
1870
|
let hooks_mutationResolverHook = (kind, fields, commandSchema, commandAuthorization) => {
|
|
1866
1871
|
let server = resolveTargetGraphQL();
|
|
1867
1872
|
if (kind === "Aggregate") {
|
|
@@ -2077,6 +2082,7 @@ function Make($star) {
|
|
|
2077
2082
|
});
|
|
2078
2083
|
LocalGraphQL_SubscriptionResolvers$ReventlessLocal.registerAll(server, subscriptionFields, sourceAEntries, []);
|
|
2079
2084
|
};
|
|
2085
|
+
let hooks_eventQueryResolverHook = params => EventHistoryResolvers.register(resolveTargetGraphQL(), params);
|
|
2080
2086
|
let hooks_adminExtensionPoints = {
|
|
2081
2087
|
contents: Pulumi.output({})
|
|
2082
2088
|
};
|
|
@@ -2105,6 +2111,7 @@ function Make($star) {
|
|
|
2105
2111
|
inboundMutationBindReceiveHook: hooks_inboundMutationBindReceiveHook,
|
|
2106
2112
|
schemaTypeRegistrationHook: hooks_schemaTypeRegistrationHook,
|
|
2107
2113
|
mcpSchemaRegistrationHook: hooks_mcpSchemaRegistrationHook,
|
|
2114
|
+
eventQueryResolverHook: hooks_eventQueryResolverHook,
|
|
2108
2115
|
adminExtensionPoints: hooks_adminExtensionPoints,
|
|
2109
2116
|
scheduler: hooks_scheduler,
|
|
2110
2117
|
schedulerRoleUrn: hooks_schedulerRoleUrn,
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Local (yoga) resolvers for the event-history queries whose SDL is emitted by
|
|
2
|
+
// `Plugin_EventQuerySchema` — the historical read counterpart of the Source A
|
|
3
|
+
// raw-event subscription.
|
|
4
|
+
//
|
|
5
|
+
// Two flavours of event log land in the same `eventLogEntries` array and are
|
|
6
|
+
// read differently, exactly as the MCP event-history handler in Platform.res
|
|
7
|
+
// forks them:
|
|
8
|
+
//
|
|
9
|
+
// aggregate log → Bus.getEventLogReplay(busKey) — replay by entity id
|
|
10
|
+
// DCB log → Bus.getDcbEventLogRead(busKey) — read(~query) by tag
|
|
11
|
+
//
|
|
12
|
+
// The difference that matters to a caller: an aggregate log is only readable
|
|
13
|
+
// *per entity* (replay takes an id), so a filter-less plugin-wide query over
|
|
14
|
+
// one returns nothing and says so. A DCB log answers both.
|
|
15
|
+
//
|
|
16
|
+
// Unlike the MCP handler, this keeps `meta` and `recordedAt` — who caused each
|
|
17
|
+
// change and when is the point of reading a history at all.
|
|
18
|
+
|
|
19
|
+
open ReventlessCore
|
|
20
|
+
|
|
21
|
+
// Positions are numeric strings. Comparing them lexically silently breaks the
|
|
22
|
+
// moment a log crosses a digit boundary ("10" > "9" is false as strings), which
|
|
23
|
+
// makes cursor pages skip or duplicate events. One implementation, used for
|
|
24
|
+
// both the sort and the cursor bound.
|
|
25
|
+
let comparePosition = (a: string, b: string): float =>
|
|
26
|
+
switch (Int.fromString(a), Int.fromString(b)) {
|
|
27
|
+
| (Some(ai), Some(bi)) => (ai - bi)->Int.toFloat
|
|
28
|
+
| _ =>
|
|
29
|
+
if a < b {
|
|
30
|
+
-1.
|
|
31
|
+
} else if a > b {
|
|
32
|
+
1.
|
|
33
|
+
} else {
|
|
34
|
+
0.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let positionGt = (a, b) => comparePosition(a, b) > 0.
|
|
39
|
+
let positionLt = (a, b) => comparePosition(a, b) < 0.
|
|
40
|
+
|
|
41
|
+
// ── The normalised record every branch produces ──────────────────────────────
|
|
42
|
+
// Mirrors the `{N}EventRecord` SDL type field for field.
|
|
43
|
+
|
|
44
|
+
type record = {
|
|
45
|
+
position: string,
|
|
46
|
+
eventType: string,
|
|
47
|
+
payload: JSON.t,
|
|
48
|
+
tags: array<Reventless.DcbTag.tag>,
|
|
49
|
+
meta: JSON.t,
|
|
50
|
+
recordedAt: string,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let str = JSON.Encode.string
|
|
54
|
+
let optStr = o => o->Option.mapOr(JSON.Encode.null, str)
|
|
55
|
+
|
|
56
|
+
// Only the audit-relevant subset of `Message.meta` crosses to the client —
|
|
57
|
+
// `ip`, `traceparent`, `schemaVersion` and the `headers` context bag are
|
|
58
|
+
// deliberately dropped here as well as in the SDL, so a future SDL widening
|
|
59
|
+
// can't quietly start leaking them.
|
|
60
|
+
let metaJson = (m: Reventless.Message.meta): JSON.t =>
|
|
61
|
+
Dict.fromArray([
|
|
62
|
+
("service", str(m.service)),
|
|
63
|
+
("time", str(m.time)),
|
|
64
|
+
("user", optStr(m.user)),
|
|
65
|
+
("msgId", str(m.msgId)),
|
|
66
|
+
("correlationId", str(m.correlationId)),
|
|
67
|
+
("causationId", optStr(m.causationId)),
|
|
68
|
+
])->JSON.Encode.object
|
|
69
|
+
|
|
70
|
+
// Same subset, read out of a stored aggregate envelope (which is raw JSON, not
|
|
71
|
+
// a decoded `Message.meta`).
|
|
72
|
+
let metaJsonFromEnvelope = (envelope: Dict.t<JSON.t>): JSON.t => {
|
|
73
|
+
let m = envelope->Dict.get("meta")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
|
|
74
|
+
let get = k => m->Dict.get(k)->Option.flatMap(JSON.Decode.string)
|
|
75
|
+
Dict.fromArray([
|
|
76
|
+
("service", get("service")->Option.getOr("")->str),
|
|
77
|
+
("time", get("time")->Option.getOr("")->str),
|
|
78
|
+
("user", get("user")->optStr),
|
|
79
|
+
("msgId", get("msgId")->Option.getOr("")->str),
|
|
80
|
+
("correlationId", get("correlationId")->Option.getOr("")->str),
|
|
81
|
+
("causationId", get("causationId")->optStr),
|
|
82
|
+
])->JSON.Encode.object
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let recordJson = (r: record): JSON.t =>
|
|
86
|
+
Dict.fromArray([
|
|
87
|
+
("position", str(r.position)),
|
|
88
|
+
("eventType", str(r.eventType)),
|
|
89
|
+
("payload", r.payload),
|
|
90
|
+
(
|
|
91
|
+
"tags",
|
|
92
|
+
r.tags
|
|
93
|
+
->Array.map(t =>
|
|
94
|
+
Dict.fromArray([("key", str(t.key)), ("value", str(t.value))])->JSON.Encode.object
|
|
95
|
+
)
|
|
96
|
+
->JSON.Encode.array,
|
|
97
|
+
),
|
|
98
|
+
("meta", r.meta),
|
|
99
|
+
("recordedAt", str(r.recordedAt)),
|
|
100
|
+
])->JSON.Encode.object
|
|
101
|
+
|
|
102
|
+
// ── Filter arguments ─────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
type filter = {
|
|
105
|
+
entityId?: string,
|
|
106
|
+
tagKey?: string,
|
|
107
|
+
tagValue?: string,
|
|
108
|
+
eventTypes?: array<string>,
|
|
109
|
+
user?: string,
|
|
110
|
+
timeFrom?: string,
|
|
111
|
+
timeTo?: string,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let readFilter = (args: JSON.t): filter => {
|
|
115
|
+
let f =
|
|
116
|
+
args
|
|
117
|
+
->JSON.Decode.object
|
|
118
|
+
->Option.flatMap(d => d->Dict.get("filter"))
|
|
119
|
+
->Option.flatMap(JSON.Decode.object)
|
|
120
|
+
->Option.getOr(Dict.make())
|
|
121
|
+
let s = k => f->Dict.get(k)->Option.flatMap(JSON.Decode.string)
|
|
122
|
+
{
|
|
123
|
+
entityId: ?s("entityId"),
|
|
124
|
+
tagKey: ?s("tagKey"),
|
|
125
|
+
tagValue: ?s("tagValue"),
|
|
126
|
+
eventTypes: ?f
|
|
127
|
+
->Dict.get("eventTypes")
|
|
128
|
+
->Option.flatMap(JSON.Decode.array)
|
|
129
|
+
->Option.map(a => a->Array.filterMap(JSON.Decode.string)),
|
|
130
|
+
user: ?s("user"),
|
|
131
|
+
timeFrom: ?s("timeFrom"),
|
|
132
|
+
timeTo: ?s("timeTo"),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The tag a caller means: the precise (tagKey, tagValue) pair when given,
|
|
137
|
+
// otherwise the `entityId` shortcut matched against ANY tag value.
|
|
138
|
+
let matchesTag = (r: record, f: filter): bool =>
|
|
139
|
+
switch (f.tagKey, f.tagValue) {
|
|
140
|
+
| (Some(k), Some(v)) => r.tags->Array.some(t => t.key == k && t.value == v)
|
|
141
|
+
| (None, Some(v)) => r.tags->Array.some(t => t.value == v)
|
|
142
|
+
| (Some(k), None) => r.tags->Array.some(t => t.key == k)
|
|
143
|
+
| (None, None) =>
|
|
144
|
+
switch f.entityId {
|
|
145
|
+
| Some(id) => r.tags->Array.some(t => t.value == id)
|
|
146
|
+
| None => true
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let matchesFilter = (r: record, f: filter): bool => {
|
|
151
|
+
let userOf = () =>
|
|
152
|
+
r.meta->JSON.Decode.object->Option.flatMap(d => d->Dict.get("user"))->Option.flatMap(JSON.Decode.string)
|
|
153
|
+
let timeOf = () =>
|
|
154
|
+
r.meta
|
|
155
|
+
->JSON.Decode.object
|
|
156
|
+
->Option.flatMap(d => d->Dict.get("time"))
|
|
157
|
+
->Option.flatMap(JSON.Decode.string)
|
|
158
|
+
->Option.getOr(r.recordedAt)
|
|
159
|
+
matchesTag(r, f) &&
|
|
160
|
+
f.eventTypes->Option.mapOr(true, types => types->Array.includes(r.eventType)) &&
|
|
161
|
+
f.user->Option.mapOr(true, u => userOf() == Some(u)) &&
|
|
162
|
+
f.timeFrom->Option.mapOr(true, from => timeOf() >= from) &&
|
|
163
|
+
f.timeTo->Option.mapOr(true, to_ => timeOf() <= to_)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Pagination ───────────────────────────────────────────────────────────────
|
|
167
|
+
// Keyset over `position`, ascending (oldest first) — the natural reading order
|
|
168
|
+
// for a history. Reuses QueryDbListQuery's connection builder so edges,
|
|
169
|
+
// cursors and pageInfo are byte-identical in shape to every other connection
|
|
170
|
+
// in the API, so existing client pagination applies unchanged.
|
|
171
|
+
|
|
172
|
+
let paginate = (~records: array<record>, ~args: JSON.t): JSON.t => {
|
|
173
|
+
let argsDict = args->JSON.Decode.object->Option.getOr(Dict.make())
|
|
174
|
+
let getInt = k =>
|
|
175
|
+
argsDict->Dict.get(k)->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)
|
|
176
|
+
let getStr = k => argsDict->Dict.get(k)->Option.flatMap(JSON.Decode.string)
|
|
177
|
+
|
|
178
|
+
let sorted = records->Array.toSorted((a, b) => comparePosition(a.position, b.position))
|
|
179
|
+
|
|
180
|
+
let first = getInt("first")
|
|
181
|
+
let last = getInt("last")
|
|
182
|
+
let after = getStr("after")
|
|
183
|
+
let before = getStr("before")
|
|
184
|
+
let isBackward = last->Option.isSome
|
|
185
|
+
|
|
186
|
+
let bounded = switch (after, before) {
|
|
187
|
+
| (Some(c), _) if !isBackward =>
|
|
188
|
+
let cv = QueryDbListQuery.decodeCursor(c)
|
|
189
|
+
sorted->Array.filter(r => positionGt(r.position, cv))
|
|
190
|
+
| (_, Some(c)) if isBackward =>
|
|
191
|
+
let cv = QueryDbListQuery.decodeCursor(c)
|
|
192
|
+
sorted->Array.filter(r => positionLt(r.position, cv))
|
|
193
|
+
| _ => sorted
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let pageSize = (isBackward ? last : first)->Option.getOr(QueryDbListQuery.defaultListPageSize)
|
|
197
|
+
let take = pageSize + 1
|
|
198
|
+
let (pageItems, hasMore) = if isBackward {
|
|
199
|
+
let len = bounded->Array.length
|
|
200
|
+
let startIdx = len > take ? len - take : 0
|
|
201
|
+
let arr = bounded->Array.slice(~start=startIdx, ~end=len)
|
|
202
|
+
let hasMore = arr->Array.length > pageSize
|
|
203
|
+
(hasMore ? arr->Array.slice(~start=1, ~end=arr->Array.length) : arr, hasMore)
|
|
204
|
+
} else {
|
|
205
|
+
let arr = bounded->Array.slice(~start=0, ~end=take)
|
|
206
|
+
let hasMore = arr->Array.length > pageSize
|
|
207
|
+
(arr->Array.slice(~start=0, ~end=pageSize), hasMore)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
QueryDbListQuery.buildConnection(
|
|
211
|
+
~pageItems=pageItems->Array.map(recordJson),
|
|
212
|
+
~hasNextPage=!isBackward && hasMore,
|
|
213
|
+
~hasPreviousPage=isBackward && hasMore,
|
|
214
|
+
~cursorValueOf=item =>
|
|
215
|
+
item
|
|
216
|
+
->JSON.Decode.object
|
|
217
|
+
->Option.flatMap(d => d->Dict.get("position"))
|
|
218
|
+
->Option.flatMap(JSON.Decode.string)
|
|
219
|
+
->Option.getOr(""),
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let emptyConnection = () =>
|
|
224
|
+
QueryDbListQuery.buildConnection(
|
|
225
|
+
~pageItems=[],
|
|
226
|
+
~hasNextPage=false,
|
|
227
|
+
~hasPreviousPage=false,
|
|
228
|
+
~cursorValueOf=_ => "",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
module Make = (Bus: LocalBus.T) => {
|
|
232
|
+
let log = Logger.fromEnv()
|
|
233
|
+
|
|
234
|
+
// A DCB read narrowed to the caller's tag whenever one was supplied. Pushing
|
|
235
|
+
// the tag into the query means only the unfiltered plugin-wide sweep — the
|
|
236
|
+
// rare case — ever reads broadly; the audit-trail case reads just its entity.
|
|
237
|
+
let dcbQueryFor = (f: filter): Reventless.DcbTag.query =>
|
|
238
|
+
switch (f.tagKey, f.tagValue, f.entityId) {
|
|
239
|
+
| (Some(k), Some(v), _) => [{tags: [{key: k, value: v}]}]
|
|
240
|
+
| _ => []
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let readDcb = async (
|
|
244
|
+
read: (
|
|
245
|
+
~query: Reventless.DcbTag.query,
|
|
246
|
+
~after: Reventless.DcbTag.sequencePosition=?,
|
|
247
|
+
) => promise<ReventlessCore.DcbEventLog_Adapter.rawReadResult>,
|
|
248
|
+
f: filter,
|
|
249
|
+
) => {
|
|
250
|
+
let result = await read(~query=dcbQueryFor(f), ~after=?None)
|
|
251
|
+
result.ReventlessCore.DcbEventLog_Adapter.events->Array.map(e => {
|
|
252
|
+
position: e.position,
|
|
253
|
+
eventType: e.eventType,
|
|
254
|
+
payload: e.data,
|
|
255
|
+
tags: e.tags,
|
|
256
|
+
meta: metaJson(e.meta),
|
|
257
|
+
recordedAt: e.recordedAt,
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Aggregate logs replay per entity and return the stored `{id, meta, event}`
|
|
262
|
+
// envelopes in seq order, so the array index IS the sequence position.
|
|
263
|
+
let readAggregate = async (~replay, ~entityId) => {
|
|
264
|
+
let envelopes = await replay(entityId)
|
|
265
|
+
envelopes->Array.mapWithIndex((json, i) => {
|
|
266
|
+
let obj = json->JSON.Decode.object->Option.getOr(Dict.make())
|
|
267
|
+
let event = obj->Dict.get("event")->Option.getOr(JSON.Encode.null)
|
|
268
|
+
let id =
|
|
269
|
+
obj->Dict.get("id")->Option.flatMap(JSON.Decode.string)->Option.getOr(entityId)
|
|
270
|
+
let meta = metaJsonFromEnvelope(obj)
|
|
271
|
+
let time =
|
|
272
|
+
meta
|
|
273
|
+
->JSON.Decode.object
|
|
274
|
+
->Option.flatMap(d => d->Dict.get("time"))
|
|
275
|
+
->Option.flatMap(JSON.Decode.string)
|
|
276
|
+
->Option.getOr("")
|
|
277
|
+
{
|
|
278
|
+
position: i->Int.toString,
|
|
279
|
+
eventType: Reventless.Message.variantNameOfJson(event),
|
|
280
|
+
payload: event,
|
|
281
|
+
tags: [{Reventless.DcbTag.key: "id", value: id}],
|
|
282
|
+
meta,
|
|
283
|
+
// An aggregate log stores no separate storage timestamp — producer
|
|
284
|
+
// time is the only one there is.
|
|
285
|
+
recordedAt: time,
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let register = (
|
|
291
|
+
~server: ReventlessGraphqlServer.GraphQL_ServerInstance.t,
|
|
292
|
+
params: Plugin_Helpers.eventQueryRegistrationParams,
|
|
293
|
+
) => {
|
|
294
|
+
let seen: Set.t<string> = Set.make()
|
|
295
|
+
|
|
296
|
+
params.eventLogEntries->Array.forEach(entry => {
|
|
297
|
+
let displayName = entry.displayName
|
|
298
|
+
if !(seen->Set.has(displayName)) {
|
|
299
|
+
seen->Set.add(displayName)
|
|
300
|
+
let fieldName = Plugin_EventQuerySchema.historyFieldName(
|
|
301
|
+
~plugin=params.pluginName,
|
|
302
|
+
~displayName,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (
|
|
306
|
+
_root,
|
|
307
|
+
args,
|
|
308
|
+
_ctx,
|
|
309
|
+
) => {
|
|
310
|
+
let f = readFilter(args)
|
|
311
|
+
let records = switch Bus.getEventLogReplay(entry.busKey) {
|
|
312
|
+
| Some(replay) =>
|
|
313
|
+
switch f.entityId {
|
|
314
|
+
| Some(entityId) => await readAggregate(~replay, ~entityId)
|
|
315
|
+
| None =>
|
|
316
|
+
// Not a silent empty page: an aggregate log genuinely cannot be
|
|
317
|
+
// read without an id, and a caller who omitted one asked a
|
|
318
|
+
// question this log cannot answer.
|
|
319
|
+
log.warn(
|
|
320
|
+
~comp="EventHistoryResolvers_GraphQL",
|
|
321
|
+
`${fieldName}: ${displayName} is an aggregate event log — it can only be read per entity. Supply filter.entityId.`,
|
|
322
|
+
)
|
|
323
|
+
[]
|
|
324
|
+
}
|
|
325
|
+
| None =>
|
|
326
|
+
switch Bus.getDcbEventLogRead(entry.busKey) {
|
|
327
|
+
| Some(read) => await readDcb(read, f)
|
|
328
|
+
| None => []
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
switch records {
|
|
332
|
+
| [] => emptyConnection()
|
|
333
|
+
| _ => paginate(~records=records->Array.filter(r => matchesFilter(r, f)), ~args)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The SDL comes from the same generator that produced the plugin
|
|
338
|
+
// fragment's field, so the local server's schema and the fragment can
|
|
339
|
+
// not disagree about arguments or return type. (The supporting types
|
|
340
|
+
// reach the server separately, via `schemaTypeRegistrationHook` over
|
|
341
|
+
// the fragment's `types`.)
|
|
342
|
+
let sdlFields =
|
|
343
|
+
Plugin_EventQuerySchema.generate(
|
|
344
|
+
~plugin=params.pluginName,
|
|
345
|
+
~eventLogEntries=[entry],
|
|
346
|
+
).queryFields
|
|
347
|
+
|
|
348
|
+
server.registerQueries(~sdlFields, ~resolvers=Dict.fromArray([(fieldName, resolver)]))
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
4
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
8
|
+
import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
|
|
9
|
+
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
10
|
+
import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
|
|
11
|
+
import * as Plugin_EventQuerySchema$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_EventQuerySchema.res.mjs";
|
|
12
|
+
|
|
13
|
+
function comparePosition(a, b) {
|
|
14
|
+
let match = Stdlib_Int.fromString(a, undefined);
|
|
15
|
+
let match$1 = Stdlib_Int.fromString(b, undefined);
|
|
16
|
+
if (match !== undefined && match$1 !== undefined) {
|
|
17
|
+
return match - match$1 | 0;
|
|
18
|
+
}
|
|
19
|
+
if (a < b) {
|
|
20
|
+
return -1;
|
|
21
|
+
} else if (a > b) {
|
|
22
|
+
return 1;
|
|
23
|
+
} else {
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function positionGt(a, b) {
|
|
29
|
+
return comparePosition(a, b) > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function positionLt(a, b) {
|
|
33
|
+
return comparePosition(a, b) < 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function str(prim) {
|
|
37
|
+
return prim;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function optStr(o) {
|
|
41
|
+
return Stdlib_Option.mapOr(o, null, str);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function metaJson(m) {
|
|
45
|
+
return Object.fromEntries([
|
|
46
|
+
[
|
|
47
|
+
"service",
|
|
48
|
+
m.service
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
"time",
|
|
52
|
+
m.time
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
"user",
|
|
56
|
+
Stdlib_Option.mapOr(m.user, null, str)
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
"msgId",
|
|
60
|
+
m.msgId
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
"correlationId",
|
|
64
|
+
m.correlationId
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
"causationId",
|
|
68
|
+
Stdlib_Option.mapOr(m.causationId, null, str)
|
|
69
|
+
]
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function metaJsonFromEnvelope(envelope) {
|
|
74
|
+
let m = Stdlib_Option.getOr(Stdlib_Option.flatMap(envelope["meta"], Stdlib_JSON.Decode.object), {});
|
|
75
|
+
let get = k => Stdlib_Option.flatMap(m[k], Stdlib_JSON.Decode.string);
|
|
76
|
+
return Object.fromEntries([
|
|
77
|
+
[
|
|
78
|
+
"service",
|
|
79
|
+
Stdlib_Option.getOr(get("service"), "")
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
"time",
|
|
83
|
+
Stdlib_Option.getOr(get("time"), "")
|
|
84
|
+
],
|
|
85
|
+
[
|
|
86
|
+
"user",
|
|
87
|
+
Stdlib_Option.mapOr(get("user"), null, str)
|
|
88
|
+
],
|
|
89
|
+
[
|
|
90
|
+
"msgId",
|
|
91
|
+
Stdlib_Option.getOr(get("msgId"), "")
|
|
92
|
+
],
|
|
93
|
+
[
|
|
94
|
+
"correlationId",
|
|
95
|
+
Stdlib_Option.getOr(get("correlationId"), "")
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
"causationId",
|
|
99
|
+
Stdlib_Option.mapOr(get("causationId"), null, str)
|
|
100
|
+
]
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function recordJson(r) {
|
|
105
|
+
return Object.fromEntries([
|
|
106
|
+
[
|
|
107
|
+
"position",
|
|
108
|
+
r.position
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
"eventType",
|
|
112
|
+
r.eventType
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
"payload",
|
|
116
|
+
r.payload
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
"tags",
|
|
120
|
+
r.tags.map(t => Object.fromEntries([
|
|
121
|
+
[
|
|
122
|
+
"key",
|
|
123
|
+
t.key
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
"value",
|
|
127
|
+
t.value
|
|
128
|
+
]
|
|
129
|
+
]))
|
|
130
|
+
],
|
|
131
|
+
[
|
|
132
|
+
"meta",
|
|
133
|
+
r.meta
|
|
134
|
+
],
|
|
135
|
+
[
|
|
136
|
+
"recordedAt",
|
|
137
|
+
r.recordedAt
|
|
138
|
+
]
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readFilter(args) {
|
|
143
|
+
let f = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d["filter"]), Stdlib_JSON.Decode.object), {});
|
|
144
|
+
let s = k => Stdlib_Option.flatMap(f[k], Stdlib_JSON.Decode.string);
|
|
145
|
+
return {
|
|
146
|
+
entityId: s("entityId"),
|
|
147
|
+
tagKey: s("tagKey"),
|
|
148
|
+
tagValue: s("tagValue"),
|
|
149
|
+
eventTypes: Stdlib_Option.map(Stdlib_Option.flatMap(f["eventTypes"], Stdlib_JSON.Decode.array), a => Stdlib_Array.filterMap(a, Stdlib_JSON.Decode.string)),
|
|
150
|
+
user: s("user"),
|
|
151
|
+
timeFrom: s("timeFrom"),
|
|
152
|
+
timeTo: s("timeTo")
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function matchesTag(r, f) {
|
|
157
|
+
let match = f.tagKey;
|
|
158
|
+
let match$1 = f.tagValue;
|
|
159
|
+
if (match !== undefined) {
|
|
160
|
+
if (match$1 !== undefined) {
|
|
161
|
+
return r.tags.some(t => {
|
|
162
|
+
if (t.key === match) {
|
|
163
|
+
return t.value === match$1;
|
|
164
|
+
} else {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
return r.tags.some(t => t.key === match);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (match$1 !== undefined) {
|
|
173
|
+
return r.tags.some(t => t.value === match$1);
|
|
174
|
+
}
|
|
175
|
+
let id = f.entityId;
|
|
176
|
+
if (id !== undefined) {
|
|
177
|
+
return r.tags.some(t => t.value === id);
|
|
178
|
+
} else {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function matchesFilter(r, f) {
|
|
184
|
+
let userOf = () => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(r.meta), d => d["user"]), Stdlib_JSON.Decode.string);
|
|
185
|
+
let timeOf = () => Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(r.meta), d => d["time"]), Stdlib_JSON.Decode.string), r.recordedAt);
|
|
186
|
+
if (matchesTag(r, f) && Stdlib_Option.mapOr(f.eventTypes, true, types => types.includes(r.eventType)) && Stdlib_Option.mapOr(f.user, true, u => Primitive_object.equal(userOf(), u)) && Stdlib_Option.mapOr(f.timeFrom, true, from => timeOf() >= from)) {
|
|
187
|
+
return Stdlib_Option.mapOr(f.timeTo, true, to_ => timeOf() <= to_);
|
|
188
|
+
} else {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function paginate(records, args) {
|
|
194
|
+
let argsDict = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(args), {});
|
|
195
|
+
let getInt = k => Stdlib_Option.map(Stdlib_Option.flatMap(argsDict[k], Stdlib_JSON.Decode.float), prim => prim | 0);
|
|
196
|
+
let getStr = k => Stdlib_Option.flatMap(argsDict[k], Stdlib_JSON.Decode.string);
|
|
197
|
+
let sorted = records.toSorted((a, b) => comparePosition(a.position, b.position));
|
|
198
|
+
let first = getInt("first");
|
|
199
|
+
let last = getInt("last");
|
|
200
|
+
let after = getStr("after");
|
|
201
|
+
let before = getStr("before");
|
|
202
|
+
let isBackward = Stdlib_Option.isSome(last);
|
|
203
|
+
let bounded;
|
|
204
|
+
let exit = 0;
|
|
205
|
+
if (after !== undefined && !isBackward) {
|
|
206
|
+
let cv = QueryDbListQuery$ReventlessCore.decodeCursor(after);
|
|
207
|
+
bounded = sorted.filter(r => positionGt(r.position, cv));
|
|
208
|
+
} else {
|
|
209
|
+
exit = 1;
|
|
210
|
+
}
|
|
211
|
+
if (exit === 1) {
|
|
212
|
+
if (before !== undefined && isBackward) {
|
|
213
|
+
let cv$1 = QueryDbListQuery$ReventlessCore.decodeCursor(before);
|
|
214
|
+
bounded = sorted.filter(r => positionLt(r.position, cv$1));
|
|
215
|
+
} else {
|
|
216
|
+
bounded = sorted;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
let pageSize = Stdlib_Option.getOr(isBackward ? last : first, QueryDbListQuery$ReventlessCore.defaultListPageSize);
|
|
220
|
+
let take = pageSize + 1 | 0;
|
|
221
|
+
let match;
|
|
222
|
+
if (isBackward) {
|
|
223
|
+
let len = bounded.length;
|
|
224
|
+
let startIdx = len > take ? len - take | 0 : 0;
|
|
225
|
+
let arr = bounded.slice(startIdx, len);
|
|
226
|
+
let hasMore = arr.length > pageSize;
|
|
227
|
+
match = [
|
|
228
|
+
hasMore ? arr.slice(1, arr.length) : arr,
|
|
229
|
+
hasMore
|
|
230
|
+
];
|
|
231
|
+
} else {
|
|
232
|
+
let arr$1 = bounded.slice(0, take);
|
|
233
|
+
let hasMore$1 = arr$1.length > pageSize;
|
|
234
|
+
match = [
|
|
235
|
+
arr$1.slice(0, pageSize),
|
|
236
|
+
hasMore$1
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
let hasMore$2 = match[1];
|
|
240
|
+
return QueryDbListQuery$ReventlessCore.buildConnection(match[0].map(recordJson), !isBackward && hasMore$2, isBackward && hasMore$2, item => Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d["position"]), Stdlib_JSON.Decode.string), ""));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function emptyConnection() {
|
|
244
|
+
return QueryDbListQuery$ReventlessCore.buildConnection([], false, false, param => "");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function Make(Bus) {
|
|
248
|
+
let log = Logger$ReventlessCore.fromEnv();
|
|
249
|
+
let dcbQueryFor = f => {
|
|
250
|
+
let match = f.tagKey;
|
|
251
|
+
let match$1 = f.tagValue;
|
|
252
|
+
if (match !== undefined) {
|
|
253
|
+
if (match$1 !== undefined) {
|
|
254
|
+
return [{
|
|
255
|
+
tags: [{
|
|
256
|
+
key: match,
|
|
257
|
+
value: match$1
|
|
258
|
+
}]
|
|
259
|
+
}];
|
|
260
|
+
} else {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
let readDcb = async (read, f) => {
|
|
268
|
+
let result = await read(dcbQueryFor(f), undefined);
|
|
269
|
+
return result.events.map(e => ({
|
|
270
|
+
position: e.position,
|
|
271
|
+
eventType: e.eventType,
|
|
272
|
+
payload: e.data,
|
|
273
|
+
tags: e.tags,
|
|
274
|
+
meta: metaJson(e.meta),
|
|
275
|
+
recordedAt: e.recordedAt
|
|
276
|
+
}));
|
|
277
|
+
};
|
|
278
|
+
let readAggregate = async (replay, entityId) => {
|
|
279
|
+
let envelopes = await replay(entityId);
|
|
280
|
+
return envelopes.map((json, i) => {
|
|
281
|
+
let obj = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(json), {});
|
|
282
|
+
let event = Stdlib_Option.getOr(obj["event"], null);
|
|
283
|
+
let id = Stdlib_Option.getOr(Stdlib_Option.flatMap(obj["id"], Stdlib_JSON.Decode.string), entityId);
|
|
284
|
+
let meta = metaJsonFromEnvelope(obj);
|
|
285
|
+
let time = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(meta), d => d["time"]), Stdlib_JSON.Decode.string), "");
|
|
286
|
+
return {
|
|
287
|
+
position: i.toString(),
|
|
288
|
+
eventType: Message$Reventless.variantNameOfJson(event),
|
|
289
|
+
payload: event,
|
|
290
|
+
tags: [{
|
|
291
|
+
key: "id",
|
|
292
|
+
value: id
|
|
293
|
+
}],
|
|
294
|
+
meta: meta,
|
|
295
|
+
recordedAt: time
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
let register = (server, params) => {
|
|
300
|
+
let seen = new Set();
|
|
301
|
+
params.eventLogEntries.forEach(entry => {
|
|
302
|
+
let displayName = entry.displayName;
|
|
303
|
+
if (seen.has(displayName)) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
seen.add(displayName);
|
|
307
|
+
let fieldName = Plugin_EventQuerySchema$ReventlessCore.historyFieldName(params.pluginName, displayName);
|
|
308
|
+
let resolver = async (_root, args, _ctx) => {
|
|
309
|
+
let f = readFilter(args);
|
|
310
|
+
let replay = Bus.getEventLogReplay(entry.busKey);
|
|
311
|
+
let records;
|
|
312
|
+
if (replay !== undefined) {
|
|
313
|
+
let entityId = f.entityId;
|
|
314
|
+
if (entityId !== undefined) {
|
|
315
|
+
records = await readAggregate(replay, entityId);
|
|
316
|
+
} else {
|
|
317
|
+
log.warn("EventHistoryResolvers_GraphQL", undefined, fieldName + `: ` + displayName + ` is an aggregate event log — it can only be read per entity. Supply filter.entityId.`);
|
|
318
|
+
records = [];
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
let read = Bus.getDcbEventLogRead(entry.busKey);
|
|
322
|
+
records = read !== undefined ? await readDcb(read, f) : [];
|
|
323
|
+
}
|
|
324
|
+
if (records.length !== 0) {
|
|
325
|
+
return paginate(records.filter(r => matchesFilter(r, f)), args);
|
|
326
|
+
} else {
|
|
327
|
+
return emptyConnection();
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
let sdlFields = Plugin_EventQuerySchema$ReventlessCore.generate(params.pluginName, [entry]).queryFields;
|
|
331
|
+
server.registerQueries(sdlFields, Object.fromEntries([[
|
|
332
|
+
fieldName,
|
|
333
|
+
resolver
|
|
334
|
+
]]));
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
return {
|
|
338
|
+
log: log,
|
|
339
|
+
dcbQueryFor: dcbQueryFor,
|
|
340
|
+
readDcb: readDcb,
|
|
341
|
+
readAggregate: readAggregate,
|
|
342
|
+
register: register
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export {
|
|
347
|
+
comparePosition,
|
|
348
|
+
positionGt,
|
|
349
|
+
positionLt,
|
|
350
|
+
str,
|
|
351
|
+
optStr,
|
|
352
|
+
metaJson,
|
|
353
|
+
metaJsonFromEnvelope,
|
|
354
|
+
recordJson,
|
|
355
|
+
readFilter,
|
|
356
|
+
matchesTag,
|
|
357
|
+
matchesFilter,
|
|
358
|
+
paginate,
|
|
359
|
+
emptyConnection,
|
|
360
|
+
Make,
|
|
361
|
+
}
|
|
362
|
+
/* Message-Reventless Not a pure module */
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Behavioural tests for the event-history query resolver's pure core —
|
|
2
|
+
// position ordering, filtering, and keyset pagination. That is where the
|
|
3
|
+
// behaviour lives; the Bus fork around them is two registry lookups.
|
|
4
|
+
|
|
5
|
+
@@warning("-44")
|
|
6
|
+
|
|
7
|
+
open JestGlobals
|
|
8
|
+
|
|
9
|
+
module EH = EventHistoryResolvers_GraphQL
|
|
10
|
+
|
|
11
|
+
let metaOf = (~user=?, ~time="2026-01-01T00:00:00Z", ()) =>
|
|
12
|
+
Dict.fromArray([
|
|
13
|
+
("service", JSON.Encode.string("OrderingService")),
|
|
14
|
+
("time", JSON.Encode.string(time)),
|
|
15
|
+
("user", user->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
16
|
+
("msgId", JSON.Encode.string("msg-1")),
|
|
17
|
+
("correlationId", JSON.Encode.string("corr-1")),
|
|
18
|
+
("causationId", JSON.Encode.null),
|
|
19
|
+
])->JSON.Encode.object
|
|
20
|
+
|
|
21
|
+
let rec_ = (~position, ~eventType="OrderPlaced", ~tags=[], ~user=?, ~time="2026-01-01T00:00:00Z", ()): EH.record => {
|
|
22
|
+
position,
|
|
23
|
+
eventType,
|
|
24
|
+
payload: JSON.Encode.object(Dict.make()),
|
|
25
|
+
tags,
|
|
26
|
+
meta: metaOf(~user?, ~time, ()),
|
|
27
|
+
recordedAt: time,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let tag = (key, value): Reventless.DcbTag.tag => {key, value}
|
|
31
|
+
|
|
32
|
+
let args = (~first=?, ~after=?, ~last=?, ~before=?, ()) => {
|
|
33
|
+
let d = Dict.make()
|
|
34
|
+
first->Option.forEach(v => d->Dict.set("first", JSON.Encode.float(v->Int.toFloat)))
|
|
35
|
+
last->Option.forEach(v => d->Dict.set("last", JSON.Encode.float(v->Int.toFloat)))
|
|
36
|
+
after->Option.forEach(v => d->Dict.set("after", JSON.Encode.string(v)))
|
|
37
|
+
before->Option.forEach(v => d->Dict.set("before", JSON.Encode.string(v)))
|
|
38
|
+
JSON.Encode.object(d)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let positionsOf = (connection: JSON.t): array<string> =>
|
|
42
|
+
connection
|
|
43
|
+
->JSON.Decode.object
|
|
44
|
+
->Option.flatMap(d => d->Dict.get("edges"))
|
|
45
|
+
->Option.flatMap(JSON.Decode.array)
|
|
46
|
+
->Option.getOr([])
|
|
47
|
+
->Array.filterMap(edge =>
|
|
48
|
+
edge
|
|
49
|
+
->JSON.Decode.object
|
|
50
|
+
->Option.flatMap(d => d->Dict.get("node"))
|
|
51
|
+
->Option.flatMap(JSON.Decode.object)
|
|
52
|
+
->Option.flatMap(d => d->Dict.get("position"))
|
|
53
|
+
->Option.flatMap(JSON.Decode.string)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
let endCursorOf = (connection: JSON.t): option<string> =>
|
|
57
|
+
connection
|
|
58
|
+
->JSON.Decode.object
|
|
59
|
+
->Option.flatMap(d => d->Dict.get("pageInfo"))
|
|
60
|
+
->Option.flatMap(JSON.Decode.object)
|
|
61
|
+
->Option.flatMap(d => d->Dict.get("endCursor"))
|
|
62
|
+
->Option.flatMap(JSON.Decode.string)
|
|
63
|
+
|
|
64
|
+
let hasNextPageOf = (connection: JSON.t): bool =>
|
|
65
|
+
connection
|
|
66
|
+
->JSON.Decode.object
|
|
67
|
+
->Option.flatMap(d => d->Dict.get("pageInfo"))
|
|
68
|
+
->Option.flatMap(JSON.Decode.object)
|
|
69
|
+
->Option.flatMap(d => d->Dict.get("hasNextPage"))
|
|
70
|
+
->Option.flatMap(JSON.Decode.bool)
|
|
71
|
+
->Option.getOr(false)
|
|
72
|
+
|
|
73
|
+
describe("EventHistoryResolvers_GraphQL — position ordering", () => {
|
|
74
|
+
testSync("orders positions numerically, not lexically", () => {
|
|
75
|
+
// The regression this test exists for: as strings, "10" < "9", so a
|
|
76
|
+
// lexical sort silently reorders every log that passes nine events and
|
|
77
|
+
// cursor pages then skip or duplicate.
|
|
78
|
+
let records = [
|
|
79
|
+
rec_(~position="10", ()),
|
|
80
|
+
rec_(~position="9", ()),
|
|
81
|
+
rec_(~position="2", ()),
|
|
82
|
+
rec_(~position="11", ()),
|
|
83
|
+
]
|
|
84
|
+
let page = EH.paginate(~records, ~args=args())
|
|
85
|
+
expect(page->positionsOf)->toEqual(["2", "9", "10", "11"])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
testSync("a cursor bound crossing a digit boundary skips nothing", () => {
|
|
89
|
+
let records = Array.make(~length=12, 0)->Array.mapWithIndex((_, i) =>
|
|
90
|
+
rec_(~position=i->Int.toString, ())
|
|
91
|
+
)
|
|
92
|
+
// Page 1 ends at position 9; page 2 must start at 10, not at 1.
|
|
93
|
+
let page1 = EH.paginate(~records, ~args=args(~first=10, ()))
|
|
94
|
+
expect(page1->positionsOf->Array.length)->toBe(10)
|
|
95
|
+
expect(page1->hasNextPageOf)->toBe(true)
|
|
96
|
+
let cursor = page1->endCursorOf->Option.getOr("")
|
|
97
|
+
let page2 = EH.paginate(~records, ~args=args(~first=10, ~after=cursor, ()))
|
|
98
|
+
expect(page2->positionsOf)->toEqual(["10", "11"])
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe("EventHistoryResolvers_GraphQL — filtering", () => {
|
|
103
|
+
let records = [
|
|
104
|
+
rec_(~position="1", ~eventType="OrderPlaced", ~tags=[tag("orderId", "o-1")], ~user="alice", ~time="2026-01-01T00:00:00Z", ()),
|
|
105
|
+
rec_(~position="2", ~eventType="OrderShipped", ~tags=[tag("orderId", "o-1")], ~user="bob", ~time="2026-01-05T00:00:00Z", ()),
|
|
106
|
+
rec_(~position="3", ~eventType="OrderPlaced", ~tags=[tag("orderId", "o-2")], ~user="alice", ~time="2026-01-09T00:00:00Z", ()),
|
|
107
|
+
]
|
|
108
|
+
let filtered = f => records->Array.filter(r => EH.matchesFilter(r, f))->Array.map(r => r.position)
|
|
109
|
+
|
|
110
|
+
testSync("entityId matches any tag value", () => {
|
|
111
|
+
expect(filtered({entityId: "o-1"}))->toEqual(["1", "2"])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
testSync("tagKey + tagValue is the precise form", () => {
|
|
115
|
+
expect(filtered({tagKey: "orderId", tagValue: "o-2"}))->toEqual(["3"])
|
|
116
|
+
// A tag key that no event carries matches nothing, rather than falling
|
|
117
|
+
// back to "everything".
|
|
118
|
+
expect(filtered({tagKey: "customerId", tagValue: "o-1"}))->toEqual([])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
testSync("eventTypes narrows to the listed constructors", () => {
|
|
122
|
+
expect(filtered({eventTypes: ["OrderShipped"]}))->toEqual(["2"])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
testSync("user filters on the envelope actor", () => {
|
|
126
|
+
expect(filtered({user: "alice"}))->toEqual(["1", "3"])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
testSync("time range is inclusive on both bounds", () => {
|
|
130
|
+
expect(filtered({timeFrom: "2026-01-05T00:00:00Z"}))->toEqual(["2", "3"])
|
|
131
|
+
expect(filtered({timeTo: "2026-01-05T00:00:00Z"}))->toEqual(["1", "2"])
|
|
132
|
+
expect(
|
|
133
|
+
filtered({timeFrom: "2026-01-05T00:00:00Z", timeTo: "2026-01-05T00:00:00Z"}),
|
|
134
|
+
)->toEqual(["2"])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
testSync("an empty filter keeps everything", () => {
|
|
138
|
+
expect(filtered({}))->toEqual(["1", "2", "3"])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
testSync("filters compose (AND, not OR)", () => {
|
|
142
|
+
expect(filtered({entityId: "o-1", user: "alice"}))->toEqual(["1"])
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe("EventHistoryResolvers_GraphQL — argument decoding", () => {
|
|
147
|
+
testSync("reads every filter field off the GraphQL args", () => {
|
|
148
|
+
let raw = JSON.Encode.object(
|
|
149
|
+
Dict.fromArray([
|
|
150
|
+
(
|
|
151
|
+
"filter",
|
|
152
|
+
JSON.Encode.object(
|
|
153
|
+
Dict.fromArray([
|
|
154
|
+
("entityId", JSON.Encode.string("o-1")),
|
|
155
|
+
("tagKey", JSON.Encode.string("orderId")),
|
|
156
|
+
("tagValue", JSON.Encode.string("o-1")),
|
|
157
|
+
(
|
|
158
|
+
"eventTypes",
|
|
159
|
+
[JSON.Encode.string("OrderPlaced")]->JSON.Encode.array,
|
|
160
|
+
),
|
|
161
|
+
("user", JSON.Encode.string("alice")),
|
|
162
|
+
("timeFrom", JSON.Encode.string("2026-01-01")),
|
|
163
|
+
("timeTo", JSON.Encode.string("2026-02-01")),
|
|
164
|
+
]),
|
|
165
|
+
),
|
|
166
|
+
),
|
|
167
|
+
]),
|
|
168
|
+
)
|
|
169
|
+
let f = EH.readFilter(raw)
|
|
170
|
+
expect(f.entityId)->toEqual(Some("o-1"))
|
|
171
|
+
expect(f.tagKey)->toEqual(Some("orderId"))
|
|
172
|
+
expect(f.eventTypes)->toEqual(Some(["OrderPlaced"]))
|
|
173
|
+
expect(f.user)->toEqual(Some("alice"))
|
|
174
|
+
expect(f.timeFrom)->toEqual(Some("2026-01-01"))
|
|
175
|
+
expect(f.timeTo)->toEqual(Some("2026-02-01"))
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
testSync("absent filter decodes to the everything filter", () => {
|
|
179
|
+
let f = EH.readFilter(JSON.Encode.object(Dict.make()))
|
|
180
|
+
expect(f.entityId)->toEqual(None)
|
|
181
|
+
expect(f.eventTypes)->toEqual(None)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe("EventHistoryResolvers_GraphQL — pagination", () => {
|
|
186
|
+
let records = Array.make(~length=5, 0)->Array.mapWithIndex((_, i) =>
|
|
187
|
+
rec_(~position=(i + 1)->Int.toString, ())
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
testSync("forward paging reports hasNextPage until exhausted", () => {
|
|
191
|
+
let page = EH.paginate(~records, ~args=args(~first=2, ()))
|
|
192
|
+
expect(page->positionsOf)->toEqual(["1", "2"])
|
|
193
|
+
expect(page->hasNextPageOf)->toBe(true)
|
|
194
|
+
let last = EH.paginate(~records, ~args=args(~first=10, ()))
|
|
195
|
+
expect(last->hasNextPageOf)->toBe(false)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
testSync("backward paging takes the tail", () => {
|
|
199
|
+
let page = EH.paginate(~records, ~args=args(~last=2, ()))
|
|
200
|
+
expect(page->positionsOf)->toEqual(["4", "5"])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
testSync("no records yields an empty connection, not a crash", () => {
|
|
204
|
+
let page = EH.paginate(~records=[], ~args=args())
|
|
205
|
+
expect(page->positionsOf)->toEqual([])
|
|
206
|
+
expect(page->hasNextPageOf)->toBe(false)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,286 @@
|
|
|
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 EventHistoryResolvers_GraphQL$ReventlessLocal from "../../src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs";
|
|
7
|
+
|
|
8
|
+
function metaOf(user, timeOpt, param) {
|
|
9
|
+
let time = timeOpt !== undefined ? timeOpt : "2026-01-01T00:00:00Z";
|
|
10
|
+
return Object.fromEntries([
|
|
11
|
+
[
|
|
12
|
+
"service",
|
|
13
|
+
"OrderingService"
|
|
14
|
+
],
|
|
15
|
+
[
|
|
16
|
+
"time",
|
|
17
|
+
time
|
|
18
|
+
],
|
|
19
|
+
[
|
|
20
|
+
"user",
|
|
21
|
+
Stdlib_Option.mapOr(user, null, prim => prim)
|
|
22
|
+
],
|
|
23
|
+
[
|
|
24
|
+
"msgId",
|
|
25
|
+
"msg-1"
|
|
26
|
+
],
|
|
27
|
+
[
|
|
28
|
+
"correlationId",
|
|
29
|
+
"corr-1"
|
|
30
|
+
],
|
|
31
|
+
[
|
|
32
|
+
"causationId",
|
|
33
|
+
null
|
|
34
|
+
]
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rec_(position, eventTypeOpt, tagsOpt, user, timeOpt, param) {
|
|
39
|
+
let eventType = eventTypeOpt !== undefined ? eventTypeOpt : "OrderPlaced";
|
|
40
|
+
let tags = tagsOpt !== undefined ? tagsOpt : [];
|
|
41
|
+
let time = timeOpt !== undefined ? timeOpt : "2026-01-01T00:00:00Z";
|
|
42
|
+
return {
|
|
43
|
+
position: position,
|
|
44
|
+
eventType: eventType,
|
|
45
|
+
payload: {},
|
|
46
|
+
tags: tags,
|
|
47
|
+
meta: metaOf(user, time, undefined),
|
|
48
|
+
recordedAt: time
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tag(key, value) {
|
|
53
|
+
return {
|
|
54
|
+
key: key,
|
|
55
|
+
value: value
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function args(first, after, last, before, param) {
|
|
60
|
+
let d = {};
|
|
61
|
+
Stdlib_Option.forEach(first, v => {
|
|
62
|
+
d["first"] = v;
|
|
63
|
+
});
|
|
64
|
+
Stdlib_Option.forEach(last, v => {
|
|
65
|
+
d["last"] = v;
|
|
66
|
+
});
|
|
67
|
+
Stdlib_Option.forEach(after, v => {
|
|
68
|
+
d["after"] = v;
|
|
69
|
+
});
|
|
70
|
+
Stdlib_Option.forEach(before, v => {
|
|
71
|
+
d["before"] = v;
|
|
72
|
+
});
|
|
73
|
+
return d;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function positionsOf(connection) {
|
|
77
|
+
return Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(connection), d => d["edges"]), Stdlib_JSON.Decode.array), []), edge => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(edge), d => d["node"]), Stdlib_JSON.Decode.object), d => d["position"]), Stdlib_JSON.Decode.string));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function endCursorOf(connection) {
|
|
81
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(connection), d => d["pageInfo"]), Stdlib_JSON.Decode.object), d => d["endCursor"]), Stdlib_JSON.Decode.string);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasNextPageOf(connection) {
|
|
85
|
+
return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(connection), d => d["pageInfo"]), Stdlib_JSON.Decode.object), d => d["hasNextPage"]), Stdlib_JSON.Decode.bool), false);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
globalThis.describe("EventHistoryResolvers_GraphQL — position ordering", () => {
|
|
89
|
+
globalThis.test("orders positions numerically, not lexically", () => {
|
|
90
|
+
let records = [
|
|
91
|
+
rec_("10", undefined, undefined, undefined, undefined, undefined),
|
|
92
|
+
rec_("9", undefined, undefined, undefined, undefined, undefined),
|
|
93
|
+
rec_("2", undefined, undefined, undefined, undefined, undefined),
|
|
94
|
+
rec_("11", undefined, undefined, undefined, undefined, undefined)
|
|
95
|
+
];
|
|
96
|
+
let page = EventHistoryResolvers_GraphQL$ReventlessLocal.paginate(records, args(undefined, undefined, undefined, undefined, undefined));
|
|
97
|
+
globalThis.expect(positionsOf(page)).toEqual([
|
|
98
|
+
"2",
|
|
99
|
+
"9",
|
|
100
|
+
"10",
|
|
101
|
+
"11"
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
globalThis.test("a cursor bound crossing a digit boundary skips nothing", () => {
|
|
105
|
+
let records = Stdlib_Array.make(12, 0).map((param, i) => rec_(i.toString(), undefined, undefined, undefined, undefined, undefined));
|
|
106
|
+
let page1 = EventHistoryResolvers_GraphQL$ReventlessLocal.paginate(records, args(10, undefined, undefined, undefined, undefined));
|
|
107
|
+
globalThis.expect(positionsOf(page1).length).toBe(10);
|
|
108
|
+
globalThis.expect(hasNextPageOf(page1)).toBe(true);
|
|
109
|
+
let cursor = Stdlib_Option.getOr(endCursorOf(page1), "");
|
|
110
|
+
let page2 = EventHistoryResolvers_GraphQL$ReventlessLocal.paginate(records, args(10, cursor, undefined, undefined, undefined));
|
|
111
|
+
globalThis.expect(positionsOf(page2)).toEqual([
|
|
112
|
+
"10",
|
|
113
|
+
"11"
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
globalThis.describe("EventHistoryResolvers_GraphQL — filtering", () => {
|
|
119
|
+
let records = [
|
|
120
|
+
rec_("1", "OrderPlaced", [{
|
|
121
|
+
key: "orderId",
|
|
122
|
+
value: "o-1"
|
|
123
|
+
}], "alice", "2026-01-01T00:00:00Z", undefined),
|
|
124
|
+
rec_("2", "OrderShipped", [{
|
|
125
|
+
key: "orderId",
|
|
126
|
+
value: "o-1"
|
|
127
|
+
}], "bob", "2026-01-05T00:00:00Z", undefined),
|
|
128
|
+
rec_("3", "OrderPlaced", [{
|
|
129
|
+
key: "orderId",
|
|
130
|
+
value: "o-2"
|
|
131
|
+
}], "alice", "2026-01-09T00:00:00Z", undefined)
|
|
132
|
+
];
|
|
133
|
+
let filtered = f => records.filter(r => EventHistoryResolvers_GraphQL$ReventlessLocal.matchesFilter(r, f)).map(r => r.position);
|
|
134
|
+
globalThis.test("entityId matches any tag value", () => {
|
|
135
|
+
globalThis.expect(filtered({
|
|
136
|
+
entityId: "o-1"
|
|
137
|
+
})).toEqual([
|
|
138
|
+
"1",
|
|
139
|
+
"2"
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
globalThis.test("tagKey + tagValue is the precise form", () => {
|
|
143
|
+
globalThis.expect(filtered({
|
|
144
|
+
tagKey: "orderId",
|
|
145
|
+
tagValue: "o-2"
|
|
146
|
+
})).toEqual(["3"]);
|
|
147
|
+
globalThis.expect(filtered({
|
|
148
|
+
tagKey: "customerId",
|
|
149
|
+
tagValue: "o-1"
|
|
150
|
+
})).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
globalThis.test("eventTypes narrows to the listed constructors", () => {
|
|
153
|
+
globalThis.expect(filtered({
|
|
154
|
+
eventTypes: ["OrderShipped"]
|
|
155
|
+
})).toEqual(["2"]);
|
|
156
|
+
});
|
|
157
|
+
globalThis.test("user filters on the envelope actor", () => {
|
|
158
|
+
globalThis.expect(filtered({
|
|
159
|
+
user: "alice"
|
|
160
|
+
})).toEqual([
|
|
161
|
+
"1",
|
|
162
|
+
"3"
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
globalThis.test("time range is inclusive on both bounds", () => {
|
|
166
|
+
globalThis.expect(filtered({
|
|
167
|
+
timeFrom: "2026-01-05T00:00:00Z"
|
|
168
|
+
})).toEqual([
|
|
169
|
+
"2",
|
|
170
|
+
"3"
|
|
171
|
+
]);
|
|
172
|
+
globalThis.expect(filtered({
|
|
173
|
+
timeTo: "2026-01-05T00:00:00Z"
|
|
174
|
+
})).toEqual([
|
|
175
|
+
"1",
|
|
176
|
+
"2"
|
|
177
|
+
]);
|
|
178
|
+
globalThis.expect(filtered({
|
|
179
|
+
timeFrom: "2026-01-05T00:00:00Z",
|
|
180
|
+
timeTo: "2026-01-05T00:00:00Z"
|
|
181
|
+
})).toEqual(["2"]);
|
|
182
|
+
});
|
|
183
|
+
globalThis.test("an empty filter keeps everything", () => {
|
|
184
|
+
globalThis.expect(filtered({})).toEqual([
|
|
185
|
+
"1",
|
|
186
|
+
"2",
|
|
187
|
+
"3"
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
globalThis.test("filters compose (AND, not OR)", () => {
|
|
191
|
+
globalThis.expect(filtered({
|
|
192
|
+
entityId: "o-1",
|
|
193
|
+
user: "alice"
|
|
194
|
+
})).toEqual(["1"]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
globalThis.describe("EventHistoryResolvers_GraphQL — argument decoding", () => {
|
|
199
|
+
globalThis.test("reads every filter field off the GraphQL args", () => {
|
|
200
|
+
let raw = Object.fromEntries([[
|
|
201
|
+
"filter",
|
|
202
|
+
Object.fromEntries([
|
|
203
|
+
[
|
|
204
|
+
"entityId",
|
|
205
|
+
"o-1"
|
|
206
|
+
],
|
|
207
|
+
[
|
|
208
|
+
"tagKey",
|
|
209
|
+
"orderId"
|
|
210
|
+
],
|
|
211
|
+
[
|
|
212
|
+
"tagValue",
|
|
213
|
+
"o-1"
|
|
214
|
+
],
|
|
215
|
+
[
|
|
216
|
+
"eventTypes",
|
|
217
|
+
["OrderPlaced"]
|
|
218
|
+
],
|
|
219
|
+
[
|
|
220
|
+
"user",
|
|
221
|
+
"alice"
|
|
222
|
+
],
|
|
223
|
+
[
|
|
224
|
+
"timeFrom",
|
|
225
|
+
"2026-01-01"
|
|
226
|
+
],
|
|
227
|
+
[
|
|
228
|
+
"timeTo",
|
|
229
|
+
"2026-02-01"
|
|
230
|
+
]
|
|
231
|
+
])
|
|
232
|
+
]]);
|
|
233
|
+
let f = EventHistoryResolvers_GraphQL$ReventlessLocal.readFilter(raw);
|
|
234
|
+
globalThis.expect(f.entityId).toEqual("o-1");
|
|
235
|
+
globalThis.expect(f.tagKey).toEqual("orderId");
|
|
236
|
+
globalThis.expect(f.eventTypes).toEqual(["OrderPlaced"]);
|
|
237
|
+
globalThis.expect(f.user).toEqual("alice");
|
|
238
|
+
globalThis.expect(f.timeFrom).toEqual("2026-01-01");
|
|
239
|
+
globalThis.expect(f.timeTo).toEqual("2026-02-01");
|
|
240
|
+
});
|
|
241
|
+
globalThis.test("absent filter decodes to the everything filter", () => {
|
|
242
|
+
let f = EventHistoryResolvers_GraphQL$ReventlessLocal.readFilter({});
|
|
243
|
+
globalThis.expect(f.entityId).toEqual(undefined);
|
|
244
|
+
globalThis.expect(f.eventTypes).toEqual(undefined);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
globalThis.describe("EventHistoryResolvers_GraphQL — pagination", () => {
|
|
249
|
+
let records = Stdlib_Array.make(5, 0).map((param, i) => rec_((i + 1 | 0).toString(), undefined, undefined, undefined, undefined, undefined));
|
|
250
|
+
globalThis.test("forward paging reports hasNextPage until exhausted", () => {
|
|
251
|
+
let page = EventHistoryResolvers_GraphQL$ReventlessLocal.paginate(records, args(2, undefined, undefined, undefined, undefined));
|
|
252
|
+
globalThis.expect(positionsOf(page)).toEqual([
|
|
253
|
+
"1",
|
|
254
|
+
"2"
|
|
255
|
+
]);
|
|
256
|
+
globalThis.expect(hasNextPageOf(page)).toBe(true);
|
|
257
|
+
let last = EventHistoryResolvers_GraphQL$ReventlessLocal.paginate(records, args(10, undefined, undefined, undefined, undefined));
|
|
258
|
+
globalThis.expect(hasNextPageOf(last)).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
globalThis.test("backward paging takes the tail", () => {
|
|
261
|
+
let page = EventHistoryResolvers_GraphQL$ReventlessLocal.paginate(records, args(undefined, undefined, 2, undefined, undefined));
|
|
262
|
+
globalThis.expect(positionsOf(page)).toEqual([
|
|
263
|
+
"4",
|
|
264
|
+
"5"
|
|
265
|
+
]);
|
|
266
|
+
});
|
|
267
|
+
globalThis.test("no records yields an empty connection, not a crash", () => {
|
|
268
|
+
let page = EventHistoryResolvers_GraphQL$ReventlessLocal.paginate([], args(undefined, undefined, undefined, undefined, undefined));
|
|
269
|
+
globalThis.expect(positionsOf(page)).toEqual([]);
|
|
270
|
+
globalThis.expect(hasNextPageOf(page)).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
let EH;
|
|
275
|
+
|
|
276
|
+
export {
|
|
277
|
+
EH,
|
|
278
|
+
metaOf,
|
|
279
|
+
rec_,
|
|
280
|
+
tag,
|
|
281
|
+
args,
|
|
282
|
+
positionsOf,
|
|
283
|
+
endCursorOf,
|
|
284
|
+
hasNextPageOf,
|
|
285
|
+
}
|
|
286
|
+
/* Not a pure module */
|