@reventlessdev/reventless-local 3.0.0-alpha.173 → 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 CHANGED
@@ -3,6 +3,13 @@
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
+
6
13
  # 3.0.0-alpha.173 (2026-07-27)
7
14
 
8
15
  **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.173",
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": {
@@ -33,15 +33,15 @@
33
33
  "ws": "^8.18.0",
34
34
  "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
35
35
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.25",
36
- "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
37
36
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
38
37
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
39
- "@reventlessdev/reventless-core": "3.0.0-alpha.185",
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",
40
42
  "@reventlessdev/reventless-infra": "3.0.0-alpha.106",
41
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.132",
42
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.33",
43
- "@reventlessdev/reventless-spec": "3.0.0-alpha.81",
44
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.49"
43
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.50",
44
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.81"
45
45
  },
46
46
  "devDependencies": {
47
47
  "rescript": "12.3.0",
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}) => {
@@ -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 */