@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.
@@ -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
+ }