@reventlessdev/reventless-local 3.0.0-alpha.173 → 3.0.0-alpha.175
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 +9 -9
- package/src/Platform.res +19 -6
- package/src/Platform.res.mjs +10 -0
- package/src/adapter/Api/LocalEvents_Server.res +324 -0
- package/src/adapter/Api/LocalEvents_Server.res.mjs +341 -0
- package/src/adapter/DomainGraphQL_Server.res +23 -0
- package/src/adapter/DomainGraphQL_Server.res.mjs +36 -14
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +352 -0
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs +362 -0
- package/src/adapter/LocalBus.res +12 -1
- package/src/adapter/LocalBus.res.mjs +36 -0
- package/tests/adapter/EventHistoryResolverTest.res +208 -0
- package/tests/adapter/EventHistoryResolverTest.res.mjs +286 -0
- package/tests/adapter/LocalEvents_ServerTest.res +200 -0
- package/tests/adapter/LocalEvents_ServerTest.res.mjs +147 -0
|
@@ -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
|
+
}
|