@reventlessdev/reventless-local 3.0.0-alpha.316 → 3.0.0-alpha.318
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 +16 -0
- package/package.json +11 -11
- package/src/LocalPlatformRegistry.res +19 -0
- package/src/LocalPlatformRegistry.res.mjs +19 -1
- package/src/Platform.res +24 -2
- package/src/Platform.res.mjs +22 -2
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +38 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +29 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +87 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +76 -0
- package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res +8 -1
- package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +17 -4
- package/src/adapter/EventLog/EventLogStorage_InMemory.res +49 -2
- package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +57 -21
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +85 -0
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +50 -0
- package/src/adapter/EventLog/LocalEventLogStorage.res +3 -0
- package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +6 -2
- package/src/adapter/LocalBus.res +32 -5
- package/src/adapter/LocalBus.res.mjs +27 -7
- package/src/adapter/LocalEventLogRead.res +477 -0
- package/src/adapter/LocalEventLogRead.res.mjs +507 -0
- package/src/adapter/LocalEventTap.res +18 -80
- package/src/adapter/LocalEventTap.res.mjs +19 -108
- package/src/adapter/LocalFeed.res +433 -0
- package/src/adapter/LocalFeed.res.mjs +419 -0
- package/src/adapter/LocalLogFeed.res +217 -0
- package/src/adapter/LocalLogFeed.res.mjs +295 -0
- package/src/adapter/LocalPgEventLogRead.res +85 -0
- package/src/adapter/LocalPgEventLogRead.res.mjs +91 -0
- package/src/adapter/LocalPlatformObserve.res +456 -0
- package/src/adapter/LocalPlatformObserve.res.mjs +505 -0
- package/src/adapter/LocalTapLines.res +147 -0
- package/src/adapter/LocalTapLines.res.mjs +229 -0
- package/src/adapter/PlatformGraphQL_Server.res +36 -3
- package/src/adapter/PlatformGraphQL_Server.res.mjs +15 -1
- package/src/reexport/CommandTopic.res.mjs +5 -2
- package/tests/adapter/GraphQLWsFixtures.mjs +46 -0
- package/tests/adapter/LocalEventLogReadTest.res +539 -0
- package/tests/adapter/LocalEventLogReadTest.res.mjs +466 -0
- package/tests/adapter/LocalFeedTest.res +314 -0
- package/tests/adapter/LocalFeedTest.res.mjs +329 -0
- package/tests/adapter/LocalPlatformObserveTest.res +165 -0
- package/tests/adapter/LocalPlatformObserveTest.res.mjs +183 -0
- package/tests/adapter/LocalTapLinesTest.res +182 -0
- package/tests/adapter/LocalTapLinesTest.res.mjs +242 -0
- package/tests/adapter/PlatformSubscriptionsTest.res +123 -0
- package/tests/adapter/PlatformSubscriptionsTest.res.mjs +132 -0
- package/tests/components/automationslice/AutomationSliceCallbackTest.res +80 -0
- package/tests/components/automationslice/AutomationSliceCallbackTest.res.mjs +81 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +17 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +24 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res +30 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res.mjs +28 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +58 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +71 -19
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePiiTest.res.mjs +7 -7
|
@@ -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
|
+
}
|