@reventlessdev/reventless-local 3.0.0-alpha.317 → 3.0.0-alpha.319

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +10 -10
  3. package/src/LocalPlatformRegistry.res +26 -1
  4. package/src/LocalPlatformRegistry.res.mjs +25 -5
  5. package/src/Platform.res +27 -2
  6. package/src/Platform.res.mjs +24 -4
  7. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +38 -0
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +29 -0
  9. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +87 -0
  10. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +76 -0
  11. package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res +8 -1
  12. package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +17 -4
  13. package/src/adapter/EventLog/EventLogStorage_InMemory.res +49 -2
  14. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +57 -21
  15. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +85 -0
  16. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +50 -0
  17. package/src/adapter/EventLog/LocalEventLogStorage.res +3 -0
  18. package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +6 -2
  19. package/src/adapter/LocalBus.res +32 -5
  20. package/src/adapter/LocalBus.res.mjs +27 -7
  21. package/src/adapter/LocalEventLogRead.res +477 -0
  22. package/src/adapter/LocalEventLogRead.res.mjs +507 -0
  23. package/src/adapter/LocalEventTap.res +18 -80
  24. package/src/adapter/LocalEventTap.res.mjs +19 -108
  25. package/src/adapter/LocalFeed.res +433 -0
  26. package/src/adapter/LocalFeed.res.mjs +419 -0
  27. package/src/adapter/LocalLogFeed.res +217 -0
  28. package/src/adapter/LocalLogFeed.res.mjs +295 -0
  29. package/src/adapter/LocalPgEventLogRead.res +85 -0
  30. package/src/adapter/LocalPgEventLogRead.res.mjs +91 -0
  31. package/src/adapter/LocalPlatformObserve.res +456 -0
  32. package/src/adapter/LocalPlatformObserve.res.mjs +505 -0
  33. package/src/adapter/LocalTapLines.res +149 -0
  34. package/src/adapter/LocalTapLines.res.mjs +232 -0
  35. package/src/adapter/PlatformGraphQL_Server.res +36 -3
  36. package/src/adapter/PlatformGraphQL_Server.res.mjs +15 -1
  37. package/src/adapter/ProjectionCheckpoint.res.mjs +3 -3
  38. package/src/components/AutomationSlice_Builder.res.mjs +1 -1
  39. package/src/components/LocalAutomationSchedules.res.mjs +1 -1
  40. package/src/components/LocalAutomationSweep.res.mjs +1 -1
  41. package/src/reexport/CommandTopic.res.mjs +5 -2
  42. package/tests/LocalPlatformRegistryTest.res +17 -0
  43. package/tests/LocalPlatformRegistryTest.res.mjs +14 -2
  44. package/tests/LocalPlatformStartTest.res.mjs +1 -1
  45. package/tests/adapter/GraphQLWsFixtures.mjs +46 -0
  46. package/tests/adapter/LocalEventLogReadTest.res +539 -0
  47. package/tests/adapter/LocalEventLogReadTest.res.mjs +466 -0
  48. package/tests/adapter/LocalFeedTest.res +314 -0
  49. package/tests/adapter/LocalFeedTest.res.mjs +329 -0
  50. package/tests/adapter/LocalPlatformObserveTest.res +165 -0
  51. package/tests/adapter/LocalPlatformObserveTest.res.mjs +183 -0
  52. package/tests/adapter/LocalTapLinesTest.res +207 -0
  53. package/tests/adapter/LocalTapLinesTest.res.mjs +274 -0
  54. package/tests/adapter/PlatformSubscriptionsTest.res +123 -0
  55. package/tests/adapter/PlatformSubscriptionsTest.res.mjs +132 -0
  56. package/tests/components/automationslice/AutomationSliceCallbackTest.res +80 -0
  57. package/tests/components/automationslice/AutomationSliceCallbackTest.res.mjs +81 -0
  58. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +17 -0
  59. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +24 -0
  60. package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res +30 -0
  61. package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res.mjs +28 -0
  62. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +58 -0
  63. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +71 -19
  64. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePiiTest.res.mjs +7 -7
@@ -11,7 +11,9 @@ import * as Pii$Reventless from "@reventlessdev/reventless-spec/src/components/P
11
11
  import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
12
12
  import * as Deferred from "effect/Deferred";
13
13
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
14
+ import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
14
15
  import * as LocalEventTap$ReventlessLocal from "./LocalEventTap.res.mjs";
16
+ import * as LocalEventLogRead$ReventlessLocal from "./LocalEventLogRead.res.mjs";
15
17
 
16
18
  let eventTapSeq = {
17
19
  contents: 0
@@ -21,12 +23,23 @@ function seedEventTapSeq(n) {
21
23
  eventTapSeq.contents = n;
22
24
  }
23
25
 
24
- function emitEventTap(topic, service, payload) {
26
+ function emitEventTap(topic, service, meta, payload) {
25
27
  eventTapSeq.contents = eventTapSeq.contents + 1 | 0;
28
+ let stored = LocalEventLogRead$ReventlessLocal.take(meta.msgId);
26
29
  let toStdout = LocalEventTap$ReventlessLocal.stdoutEnabled(undefined);
27
30
  if (!(toStdout || LocalEventTap$ReventlessLocal.hasReaders())) {
28
31
  return;
29
32
  }
33
+ let where = stored !== undefined ? [
34
+ [
35
+ "log",
36
+ stored[0]
37
+ ],
38
+ [
39
+ "position",
40
+ LocalEventLogRead$ReventlessLocal.positionToString(stored[1])
41
+ ]
42
+ ] : [];
30
43
  let line = Object.fromEntries([
31
44
  [
32
45
  "event",
@@ -52,12 +65,19 @@ function emitEventTap(topic, service, payload) {
52
65
  "ts",
53
66
  new Date().toISOString()
54
67
  ]
55
- ]);
68
+ ].concat(where));
56
69
  let tapLine = "@@RVLESS_EVT@@ " + JSON.stringify(line);
57
70
  if (toStdout) {
58
71
  console.log(tapLine);
59
72
  }
60
- LocalEventTap$ReventlessLocal.broadcast(tapLine);
73
+ LocalEventTap$ReventlessLocal.publish({
74
+ line: tapLine,
75
+ facets: {
76
+ plugin: Stdlib_Option.flatMap(stored, param => LocalEventLogRead$ReventlessLocal.pluginOf(param[0])),
77
+ eventType: Message$ReventlessCore.eventNameOfEvent$pJson(payload),
78
+ correlationId: meta.correlationId
79
+ }
80
+ });
61
81
  }
62
82
 
63
83
  function Impl(C) {
@@ -141,7 +161,7 @@ function Impl(C) {
141
161
  if (Pii$Reventless.needsPrepare([json])) {
142
162
  await Pii$Reventless.prepareOrThrow([json]);
143
163
  }
144
- emitEventTap(topicName, service, json);
164
+ emitEventTap(topicName, service, meta, json);
145
165
  let hub = eventHubs.contents[topicName];
146
166
  if (hub === undefined) {
147
167
  return;
@@ -417,7 +437,7 @@ function Make($star) {
417
437
  if (Pii$Reventless.needsPrepare([json])) {
418
438
  await Pii$Reventless.prepareOrThrow([json]);
419
439
  }
420
- emitEventTap(topicName, service, json);
440
+ emitEventTap(topicName, service, meta, json);
421
441
  let hub = eventHubs.contents[topicName];
422
442
  if (hub === undefined) {
423
443
  return;
@@ -693,7 +713,7 @@ function MakeSilent($star) {
693
713
  if (Pii$Reventless.needsPrepare([json])) {
694
714
  await Pii$Reventless.prepareOrThrow([json]);
695
715
  }
696
- emitEventTap(topicName, service, json);
716
+ emitEventTap(topicName, service, meta, json);
697
717
  let hub = eventHubs.contents[topicName];
698
718
  if (hub === undefined) {
699
719
  return;
@@ -970,7 +990,7 @@ function MakeBounded(C) {
970
990
  if (Pii$Reventless.needsPrepare([json])) {
971
991
  await Pii$Reventless.prepareOrThrow([json]);
972
992
  }
973
- emitEventTap(topicName, service, json);
993
+ emitEventTap(topicName, service, meta, json);
974
994
  let hub = eventHubs.contents[topicName];
975
995
  if (hub === undefined) {
976
996
  return;
@@ -0,0 +1,477 @@
1
+ // One read across every event log of the local platform, newest first.
2
+ //
3
+ // Each storage registers how to read its log backwards from a position; this
4
+ // module merges the logs by the time each event was recorded, ties broken by
5
+ // plugin, log and position, without a new column in any store. The cursor holds
6
+ // every log's own position, so a page continues exactly where the last one
7
+ // stopped while events are appended: what is appended after the first page is
8
+ // newer than all of it, and is seen on the event tap, joined by log and position.
9
+ //
10
+ // The same positions go on the tap's lines: a storage notes each event's
11
+ // position as it appends it, and the bus takes the note when it publishes the
12
+ // event (`note` / `take`).
13
+
14
+ type logKind =
15
+ | Aggregate
16
+ | Dcb
17
+
18
+ /** Where a backwards read of one log starts. */
19
+ type bound =
20
+ | Below(float)
21
+ | AtMost(float)
22
+
23
+ /** One stored event, as a log's reader returns it. `position` is the log's own:
24
+ an aggregate log's row id (or its insertion number in memory), a DCB log's
25
+ position. */
26
+ type stored = {
27
+ log: string,
28
+ position: float,
29
+ eventType: string,
30
+ entityId?: string,
31
+ tags: array<Reventless.DcbTag.tag>,
32
+ payload: JSON.t,
33
+ meta: JSON.t,
34
+ recordedAt: string,
35
+ }
36
+
37
+ // The stored event is flat (`Message.storedEventToFlatJson`): meta's fields sit
38
+ // beside `event`, `data` and `recordedAt`. The meta is put back together for a
39
+ // reader that wants it whole.
40
+ let metaOfFlat = (flat: dict<JSON.t>): JSON.t =>
41
+ ReventlessCore.Message.metaKeys
42
+ ->Array.filterMap(k => flat->Dict.get(k)->Option.map(v => (k, v)))
43
+ ->Dict.fromArray
44
+ ->JSON.Encode.object
45
+
46
+ /** One stored aggregate event as the cross-log read takes it. */
47
+ let storedOfFlat = (~log: string, ~position: float, ~entityId: string, payload: JSON.t): stored => {
48
+ let flat = payload->JSON.Decode.object->Option.getOr(Dict.make())
49
+ let str = k => flat->Dict.get(k)->Option.flatMap(JSON.Decode.string)
50
+ let meta = metaOfFlat(flat)
51
+ {
52
+ log,
53
+ position,
54
+ eventType: str("event")->Option.getOr("unknown"),
55
+ entityId,
56
+ tags: [],
57
+ payload: flat->Dict.get("data")->Option.getOr(JSON.Encode.null),
58
+ meta,
59
+ // Every stored event has its own; producer time is the fallback for one
60
+ // written before it did.
61
+ recordedAt: switch str("recordedAt") {
62
+ | Some(t) => t
63
+ | None => str("time")->Option.getOr("")
64
+ },
65
+ }
66
+ }
67
+
68
+ let numberOf = (v: option<JSON.t>): float =>
69
+ switch v {
70
+ | Some(JSON.Number(n)) => n
71
+ | Some(JSON.String(s)) => s->Float.fromString->Option.getOr(0.)
72
+ | _ => 0.
73
+ }
74
+
75
+ /** Reads at most `limit` events of one log, newest first, from `bound` (from its
76
+ newest event when `None`), narrowed to one correlation id when given. */
77
+ type readBack = (
78
+ ~bound: option<bound>,
79
+ ~limit: int,
80
+ ~correlationId: option<string>,
81
+ ) => promise<array<stored>>
82
+
83
+ type registered = {kind: logKind, readBack: readBack}
84
+
85
+ let readers: dict<registered> = Dict.make()
86
+ let pluginOfLog: dict<string> = Dict.make()
87
+
88
+ let register = (~log: string, ~kind: logKind, readBack: readBack) =>
89
+ readers->Dict.set(log, {kind, readBack})
90
+
91
+ /** Names the plugin a log belongs to. Logs of the platform itself have none. */
92
+ let notePlugin = (~log: string, ~plugin: string) => pluginOfLog->Dict.set(log, plugin)
93
+
94
+ let pluginOf = (log: string): option<string> => pluginOfLog->Dict.get(log)
95
+
96
+ let logs = (): array<string> => readers->Dict.keysToArray->Array.toSorted(String.compare)
97
+
98
+ let positionToString = (p: float): string => p->Float.toString
99
+
100
+ // ─── Positions of events on their way to the tap ─────────────────────────────
101
+
102
+ // Bounded: a note nobody takes (an event appended and never published) must not
103
+ // stay forever.
104
+ let maxNotes = 10_000
105
+ let notes: Map.t<string, (string, float)> = Map.make()
106
+
107
+ let note = (~log: string, ~msgId: string, ~position: float) => {
108
+ notes->Map.set(msgId, (log, position))
109
+ if notes->Map.size > maxNotes {
110
+ switch notes->Map.keys->Iterator.next {
111
+ | {done: false, value: Some(oldest)} =>
112
+ let _ = notes->Map.delete(oldest)
113
+ | _ => ()
114
+ }
115
+ }
116
+ }
117
+
118
+ /** The log and position noted for this message, removed as it is read. */
119
+ let take = (msgId: string): option<(string, float)> =>
120
+ switch notes->Map.get(msgId) {
121
+ | Some(_) as found =>
122
+ let _ = notes->Map.delete(msgId)
123
+ found
124
+ | None => None
125
+ }
126
+
127
+ /** Test seam: forgets every reader, plugin and note. */
128
+ let resetForTests = () => {
129
+ readers->Dict.keysToArray->Array.forEach(k => readers->Dict.delete(k))
130
+ pluginOfLog->Dict.keysToArray->Array.forEach(k => pluginOfLog->Dict.delete(k))
131
+ notes->Map.clear
132
+ }
133
+
134
+ // ─── Records ─────────────────────────────────────────────────────────────────
135
+
136
+ type record = {
137
+ plugin?: string,
138
+ log: string,
139
+ position: string,
140
+ eventType: string,
141
+ entityId?: string,
142
+ tags: array<Reventless.DcbTag.tag>,
143
+ payload: JSON.t,
144
+ meta: JSON.t,
145
+ recordedAt: string,
146
+ }
147
+
148
+ let recordToJson = (r: record): JSON.t =>
149
+ [
150
+ ("log", JSON.Encode.string(r.log)),
151
+ ("position", JSON.Encode.string(r.position)),
152
+ ("eventType", JSON.Encode.string(r.eventType)),
153
+ (
154
+ "tags",
155
+ r.tags
156
+ ->Array.map(t =>
157
+ Dict.fromArray([
158
+ ("key", JSON.Encode.string(t.key)),
159
+ ("value", JSON.Encode.string(t.value)),
160
+ ])->JSON.Encode.object
161
+ )
162
+ ->JSON.Encode.array,
163
+ ),
164
+ ("payload", r.payload),
165
+ ("meta", r.meta),
166
+ ("recordedAt", JSON.Encode.string(r.recordedAt)),
167
+ ]
168
+ ->Array.concat(r.plugin->Option.mapOr([], p => [("plugin", JSON.Encode.string(p))]))
169
+ ->Array.concat(r.entityId->Option.mapOr([], e => [("entityId", JSON.Encode.string(e))]))
170
+ ->Dict.fromArray
171
+ ->JSON.Encode.object
172
+
173
+ // ─── Filter ──────────────────────────────────────────────────────────────────
174
+
175
+ type filter = {
176
+ plugins?: array<string>,
177
+ logs?: array<string>,
178
+ eventTypes?: array<string>,
179
+ entityId?: string,
180
+ tagKey?: string,
181
+ tagValue?: string,
182
+ msgId?: string,
183
+ correlationId?: string,
184
+ causationId?: string,
185
+ user?: string,
186
+ timeFrom?: string,
187
+ timeTo?: string,
188
+ }
189
+
190
+ /** Reads the filter argument of a query. Absent and null fields do not narrow. */
191
+ let filterOfJson = (json: option<JSON.t>): filter => {
192
+ let d = json->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
193
+ let s = k => d->Dict.get(k)->Option.flatMap(JSON.Decode.string)
194
+ let a = k =>
195
+ d
196
+ ->Dict.get(k)
197
+ ->Option.flatMap(JSON.Decode.array)
198
+ ->Option.map(items => items->Array.filterMap(JSON.Decode.string))
199
+ {
200
+ plugins: ?a("plugins"),
201
+ logs: ?a("logs"),
202
+ eventTypes: ?a("eventTypes"),
203
+ entityId: ?s("entityId"),
204
+ tagKey: ?s("tagKey"),
205
+ tagValue: ?s("tagValue"),
206
+ msgId: ?s("msgId"),
207
+ correlationId: ?s("correlationId"),
208
+ causationId: ?s("causationId"),
209
+ user: ?s("user"),
210
+ timeFrom: ?s("timeFrom"),
211
+ timeTo: ?s("timeTo"),
212
+ }
213
+ }
214
+
215
+ let metaString = (meta: JSON.t, key: string): option<string> =>
216
+ meta
217
+ ->JSON.Decode.object
218
+ ->Option.flatMap(d => d->Dict.get(key))
219
+ ->Option.flatMap(JSON.Decode.string)
220
+
221
+ let selectsLog = (f: filter, ~log: string, ~plugin: option<string>): bool =>
222
+ f.logs->Option.mapOr(true, logs => logs->Array.includes(log)) &&
223
+ f.plugins->Option.mapOr(true, plugins =>
224
+ plugin->Option.mapOr(false, p => plugins->Array.includes(p))
225
+ )
226
+
227
+ let matches = (f: filter, r: stored): bool => {
228
+ let equalsMeta = (wanted: option<string>, key) =>
229
+ wanted->Option.mapOr(true, w => metaString(r.meta, key) == Some(w))
230
+ let tagMatch = switch (f.tagKey, f.tagValue) {
231
+ | (Some(k), Some(v)) => r.tags->Array.some(t => t.key == k && t.value == v)
232
+ | (Some(k), None) => r.tags->Array.some(t => t.key == k)
233
+ | (None, Some(v)) => r.tags->Array.some(t => t.value == v)
234
+ | (None, None) => true
235
+ }
236
+ let entityMatch = switch f.entityId {
237
+ | Some(id) => r.entityId == Some(id) || r.tags->Array.some(t => t.value == id)
238
+ | None => true
239
+ }
240
+ tagMatch &&
241
+ entityMatch &&
242
+ f.eventTypes->Option.mapOr(true, types => types->Array.includes(r.eventType)) &&
243
+ equalsMeta(f.msgId, "msgId") &&
244
+ equalsMeta(f.correlationId, "correlationId") &&
245
+ equalsMeta(f.causationId, "causationId") &&
246
+ equalsMeta(f.user, "user") &&
247
+ f.timeFrom->Option.mapOr(true, from => r.recordedAt >= from) &&
248
+ f.timeTo->Option.mapOr(true, to_ => r.recordedAt <= to_)
249
+ }
250
+
251
+ // ─── Cursor ──────────────────────────────────────────────────────────────────
252
+
253
+ /** Where one log stands in a paged read. */
254
+ type logState =
255
+ // Read the log from here on the next page.
256
+ | Until(bound)
257
+ // Nothing left in this log for this read.
258
+ | Done
259
+
260
+ @val external btoa: string => string = "btoa"
261
+ @val external atob: string => string = "atob"
262
+
263
+ // URL-safe, so a cursor can travel in a query string as it is.
264
+ let toBase64Url = (s: string) =>
265
+ s->btoa->String.replaceAll("+", "-")->String.replaceAll("/", "_")->String.replaceAll("=", "")
266
+
267
+ let fromBase64Url = (s: string) => {
268
+ let b = s->String.replaceAll("-", "+")->String.replaceAll("_", "/")
269
+ atob(b ++ String.repeat("=", mod(4 - mod(b->String.length, 4), 4)))
270
+ }
271
+
272
+ let encodeCursor = (states: array<(string, logState)>): string =>
273
+ states
274
+ ->Array.map(((log, state)) => (
275
+ log,
276
+ switch state {
277
+ | Until(Below(p)) => Dict.fromArray([("below", JSON.Encode.float(p))])->JSON.Encode.object
278
+ | Until(AtMost(p)) => Dict.fromArray([("atMost", JSON.Encode.float(p))])->JSON.Encode.object
279
+ | Done => JSON.Encode.string("done")
280
+ },
281
+ ))
282
+ ->Dict.fromArray
283
+ ->JSON.Encode.object
284
+ ->JSON.stringify
285
+ ->toBase64Url
286
+
287
+ /** `None` for a cursor that is not one of ours. */
288
+ let decodeCursor = (cursor: string): option<dict<logState>> =>
289
+ switch cursor->fromBase64Url->JSON.parseOrThrow->JSON.Decode.object {
290
+ | Some(d) =>
291
+ let out = Dict.make()
292
+ d
293
+ ->Dict.toArray
294
+ ->Array.forEach(((log, v)) =>
295
+ switch v {
296
+ | JSON.String("done") => out->Dict.set(log, Done)
297
+ | JSON.Object(o) =>
298
+ switch (
299
+ o->Dict.get("below")->Option.flatMap(JSON.Decode.float),
300
+ o->Dict.get("atMost")->Option.flatMap(JSON.Decode.float),
301
+ ) {
302
+ | (Some(p), _) => out->Dict.set(log, Until(Below(p)))
303
+ | (None, Some(p)) => out->Dict.set(log, Until(AtMost(p)))
304
+ | (None, None) => ()
305
+ }
306
+ | _ => ()
307
+ }
308
+ )
309
+ Some(out)
310
+ | None => None
311
+ | exception _ => None
312
+ }
313
+
314
+ // ─── The merged read ─────────────────────────────────────────────────────────
315
+
316
+ type source = {
317
+ log: string,
318
+ plugin?: string,
319
+ reader: registered,
320
+ // Where the next fetch starts; `None` before the first.
321
+ mutable scan: option<bound>,
322
+ mutable fetched: bool,
323
+ mutable exhausted: bool,
324
+ // Matching records fetched and not yet taken, newest first.
325
+ mutable buffer: array<stored>,
326
+ // What the cursor says for this log after what was taken.
327
+ mutable state: logState,
328
+ }
329
+
330
+ type page = {
331
+ edges: array<(record, string)>,
332
+ hasNextPage: bool,
333
+ endCursor?: string,
334
+ }
335
+
336
+ let defaultPageSize = 50
337
+ let maxPageSize = 1000
338
+
339
+ let toRecord = (s: source, r: stored): record => {
340
+ plugin: ?s.plugin,
341
+ log: r.log,
342
+ position: r.position->positionToString,
343
+ eventType: r.eventType,
344
+ entityId: ?r.entityId,
345
+ tags: r.tags,
346
+ payload: r.payload,
347
+ meta: r.meta,
348
+ recordedAt: r.recordedAt,
349
+ }
350
+
351
+ /** Newer first; at the same time, by plugin, then log, then the higher position. */
352
+ let newerFirst = ((sa: source, a: stored), (sb: source, b: stored)): float =>
353
+ if a.recordedAt != b.recordedAt {
354
+ a.recordedAt > b.recordedAt ? -1. : 1.
355
+ } else {
356
+ let pa = sa.plugin->Option.getOr("")
357
+ let pb = sb.plugin->Option.getOr("")
358
+ if pa != pb {
359
+ String.compare(pa, pb)
360
+ } else if sa.log != sb.log {
361
+ String.compare(sa.log, sb.log)
362
+ } else {
363
+ b.position -. a.position
364
+ }
365
+ }
366
+
367
+ /** One page of every selected log's events, newest first. `after` is the
368
+ `endCursor` (or an edge's cursor) of the page before. */
369
+ let read = async (~filter: filter, ~first: option<int>=?, ~after: option<string>=?): page => {
370
+ let pageSize = first->Option.getOr(defaultPageSize)->Math.Int.max(0)->Math.Int.min(maxPageSize)
371
+ let incoming = after->Option.flatMap(decodeCursor)
372
+ let sources = logs()->Array.filterMap(log => {
373
+ let plugin = pluginOf(log)
374
+ switch (readers->Dict.get(log), selectsLog(filter, ~log, ~plugin)) {
375
+ | (Some(reader), true) =>
376
+ let start = switch incoming {
377
+ // A log the first page did not know was created since: its events are
378
+ // all newer than that page.
379
+ | Some(states) => states->Dict.get(log)->Option.getOr(Done)
380
+ | None => Until(AtMost(infinity))
381
+ }
382
+ switch start {
383
+ | Done => None
384
+ | Until(bound) =>
385
+ Some({
386
+ log,
387
+ ?plugin,
388
+ reader,
389
+ scan: incoming->Option.isSome ? Some(bound) : None,
390
+ fetched: false,
391
+ exhausted: false,
392
+ buffer: [],
393
+ state: incoming->Option.isSome ? start : Done,
394
+ })
395
+ }
396
+ | _ => None
397
+ }
398
+ })
399
+ let batch = Math.Int.max(pageSize + 1, 50)
400
+
401
+ let fetch = async (s: source) => {
402
+ let rows = await s.reader.readBack(
403
+ ~bound=s.scan,
404
+ ~limit=batch,
405
+ ~correlationId=filter.correlationId,
406
+ )
407
+
408
+ // The first page pins each log at its newest event, so events appended while
409
+ // a reader pages are left to the tap rather than shifting the pages.
410
+ if !s.fetched {
411
+ s.fetched = true
412
+ if incoming->Option.isNone {
413
+ s.state = switch rows->Array.get(0) {
414
+ | Some(newest) => Until(AtMost(newest.position))
415
+ | None => Done
416
+ }
417
+ }
418
+ }
419
+ switch rows->Array.at(-1) {
420
+ | Some(oldest) => s.scan = Some(Below(oldest.position))
421
+ | None => ()
422
+ }
423
+ if rows->Array.length < batch {
424
+ s.exhausted = true
425
+ }
426
+ s.buffer = s.buffer->Array.concat(rows->Array.filter(r => matches(filter, r)))
427
+ }
428
+
429
+ let rec headOf = async (s: source): option<stored> =>
430
+ switch s.buffer->Array.get(0) {
431
+ | Some(_) as head => head
432
+ | None if s.exhausted => None
433
+ | None =>
434
+ await fetch(s)
435
+ await headOf(s)
436
+ }
437
+
438
+ let newest = async (): option<(source, stored)> => {
439
+ let heads = []
440
+ for i in 0 to sources->Array.length - 1 {
441
+ let s = sources->Array.getUnsafe(i)
442
+ switch await headOf(s) {
443
+ | Some(r) => heads->Array.push((s, r))
444
+ | None => ()
445
+ }
446
+ }
447
+ heads->Array.toSorted(newerFirst)->Array.get(0)
448
+ }
449
+
450
+ let stateNow = () => sources->Array.map(s => (s.log, s.state))
451
+ // A log the cursor does not name is finished: the next page skips it.
452
+ let cursorNow = () => encodeCursor(stateNow())
453
+
454
+ let edges = []
455
+ let stop = ref(false)
456
+ while !stop.contents && edges->Array.length < pageSize {
457
+ switch await newest() {
458
+ | None => stop := true
459
+ | Some((s, r)) =>
460
+ s.buffer = s.buffer->Array.slice(~start=1, ~end=s.buffer->Array.length)
461
+ s.state = s.exhausted && s.buffer->Array.length == 0 ? Done : Until(Below(r.position))
462
+ edges->Array.push((toRecord(s, r), cursorNow()))
463
+ }
464
+ }
465
+ let hasNextPage = (await newest())->Option.isSome
466
+ // Logs that ran out without giving this page anything are finished too.
467
+ sources->Array.forEach(s =>
468
+ if s.exhausted && s.buffer->Array.length == 0 {
469
+ s.state = Done
470
+ }
471
+ )
472
+ {
473
+ edges,
474
+ hasNextPage,
475
+ endCursor: ?(edges->Array.length > 0 || incoming->Option.isSome ? Some(cursorNow()) : None),
476
+ }
477
+ }