@reventlessdev/reventless-local 3.0.0-alpha.173 → 3.0.0-alpha.175
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/package.json +9 -9
- package/src/Platform.res +19 -6
- package/src/Platform.res.mjs +10 -0
- package/src/adapter/Api/LocalEvents_Server.res +324 -0
- package/src/adapter/Api/LocalEvents_Server.res.mjs +341 -0
- package/src/adapter/DomainGraphQL_Server.res +23 -0
- package/src/adapter/DomainGraphQL_Server.res.mjs +36 -14
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +352 -0
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs +362 -0
- package/src/adapter/LocalBus.res +12 -1
- package/src/adapter/LocalBus.res.mjs +36 -0
- package/tests/adapter/EventHistoryResolverTest.res +208 -0
- package/tests/adapter/EventHistoryResolverTest.res.mjs +286 -0
- package/tests/adapter/LocalEvents_ServerTest.res +200 -0
- package/tests/adapter/LocalEvents_ServerTest.res.mjs +147 -0
|
@@ -0,0 +1,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 */
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// LocalEvents_Server protocol tests — socket-free.
|
|
2
|
+
//
|
|
3
|
+
// A `connection` is just a capturing `send` callback + subscription dict, so
|
|
4
|
+
// the AppSync Events frame handling, wildcard matching, LocalBus bridge and
|
|
5
|
+
// the publish route are all driven without a WebSocket. The ws attach glue is
|
|
6
|
+
// the only part not covered here (framework territory + a real socket).
|
|
7
|
+
|
|
8
|
+
open JestGlobals
|
|
9
|
+
|
|
10
|
+
let _ = TestRunner.setup()
|
|
11
|
+
|
|
12
|
+
let sentFrames: ref<array<string>> = ref([])
|
|
13
|
+
|
|
14
|
+
let makeConn = () => {
|
|
15
|
+
sentFrames := []
|
|
16
|
+
LocalEvents_Server.addConnection(~send=f => sentFrames.contents->Array.push(f))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let parseFrame = (s: string): dict<JSON.t> =>
|
|
20
|
+
s->JSON.parseOrThrow->JSON.Decode.object->Option.getOr(Dict.make())
|
|
21
|
+
|
|
22
|
+
let frameField = (s: string, field: string): option<string> =>
|
|
23
|
+
parseFrame(s)->Dict.get(field)->Option.flatMap(JSON.Decode.string)
|
|
24
|
+
|
|
25
|
+
let descriptor = (~id: string): JSON.t =>
|
|
26
|
+
Dict.fromArray([
|
|
27
|
+
("changeKind", JSON.Encode.string("Updated")),
|
|
28
|
+
("id", JSON.Encode.string(id)),
|
|
29
|
+
("sortKeyValue", JSON.Encode.string("2026-07-28T00:00:00Z")),
|
|
30
|
+
])->JSON.Encode.object
|
|
31
|
+
|
|
32
|
+
describe("LocalEvents_Server", () => {
|
|
33
|
+
beforeEach(() => LocalEvents_Server.resetConnections())
|
|
34
|
+
|
|
35
|
+
describe("channelMatches", () => {
|
|
36
|
+
testSync("exact match", () => {
|
|
37
|
+
expect(
|
|
38
|
+
LocalEvents_Server.channelMatches(
|
|
39
|
+
~subscription="/default/Product/p-1",
|
|
40
|
+
~channel="/default/Product/p-1",
|
|
41
|
+
),
|
|
42
|
+
)->toBe(true)
|
|
43
|
+
})
|
|
44
|
+
testSync("wildcard prefix matches any depth", () => {
|
|
45
|
+
expect(
|
|
46
|
+
LocalEvents_Server.channelMatches(
|
|
47
|
+
~subscription="/default/Product/*",
|
|
48
|
+
~channel="/default/Product/p-1",
|
|
49
|
+
),
|
|
50
|
+
)->toBe(true)
|
|
51
|
+
expect(
|
|
52
|
+
LocalEvents_Server.channelMatches(
|
|
53
|
+
~subscription="/default/*",
|
|
54
|
+
~channel="/default/Product/p-1/deep",
|
|
55
|
+
),
|
|
56
|
+
)->toBe(true)
|
|
57
|
+
})
|
|
58
|
+
testSync("wildcard does not match a sibling with the same stem", () => {
|
|
59
|
+
expect(
|
|
60
|
+
LocalEvents_Server.channelMatches(
|
|
61
|
+
~subscription="/default/Product/*",
|
|
62
|
+
~channel="/default/ProductArchive/p-1",
|
|
63
|
+
),
|
|
64
|
+
)->toBe(false)
|
|
65
|
+
})
|
|
66
|
+
testSync("non-wildcard mismatch", () => {
|
|
67
|
+
expect(
|
|
68
|
+
LocalEvents_Server.channelMatches(
|
|
69
|
+
~subscription="/default/Product/p-1",
|
|
70
|
+
~channel="/default/Product/p-2",
|
|
71
|
+
),
|
|
72
|
+
)->toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe("subscribe protocol", () => {
|
|
77
|
+
testSync("connection_init is answered with connection_ack", () => {
|
|
78
|
+
let conn = makeConn()
|
|
79
|
+
LocalEvents_Server.handleFrame(conn, `{"type":"connection_init"}`)
|
|
80
|
+
expect(sentFrames.contents->Array.length)->toBe(1)
|
|
81
|
+
expect(sentFrames.contents->Array.getUnsafe(0)->frameField("type"))->toEqual(
|
|
82
|
+
Some("connection_ack"),
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
testSync("subscribe → state change → data frame on the subscription id", () => {
|
|
87
|
+
let conn = makeConn()
|
|
88
|
+
LocalEvents_Server.handleFrame(
|
|
89
|
+
conn,
|
|
90
|
+
`{"type":"subscribe","id":"sub-1","channel":"/default/Product/*"}`,
|
|
91
|
+
)
|
|
92
|
+
expect(sentFrames.contents->Array.getUnsafe(0)->frameField("type"))->toEqual(
|
|
93
|
+
Some("subscribe_success"),
|
|
94
|
+
)
|
|
95
|
+
LocalEvents_Server.broadcastStateChange(~name="Product", ~descriptor=descriptor(~id="p-1"))
|
|
96
|
+
expect(sentFrames.contents->Array.length)->toBe(2)
|
|
97
|
+
let data = sentFrames.contents->Array.getUnsafe(1)
|
|
98
|
+
expect(data->frameField("type"))->toEqual(Some("data"))
|
|
99
|
+
expect(data->frameField("id"))->toEqual(Some("sub-1"))
|
|
100
|
+
// `event` is a stringified JSON payload — parse the string to verify.
|
|
101
|
+
let event =
|
|
102
|
+
data->frameField("event")->Option.getOr("")->JSON.parseOrThrow->JSON.Decode.object
|
|
103
|
+
expect(
|
|
104
|
+
event->Option.flatMap(o => o->Dict.get("id"))->Option.flatMap(JSON.Decode.string),
|
|
105
|
+
)->toEqual(Some("p-1"))
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
testSync("channel segments are normalized like the AWS publisher", () => {
|
|
109
|
+
let conn = makeConn()
|
|
110
|
+
LocalEvents_Server.handleFrame(
|
|
111
|
+
conn,
|
|
112
|
+
`{"type":"subscribe","id":"s","channel":"/default/My-Model/order-1-2026"}`,
|
|
113
|
+
)
|
|
114
|
+
// Read-model name `My.Model` and entity key `order#1@2026` normalize to
|
|
115
|
+
// the subscribed channel (`[^A-Za-z0-9-]` → `-`).
|
|
116
|
+
LocalEvents_Server.broadcastStateChange(
|
|
117
|
+
~name="My.Model",
|
|
118
|
+
~descriptor=descriptor(~id="order#1@2026"),
|
|
119
|
+
)
|
|
120
|
+
expect(sentFrames.contents->Array.length)->toBe(2)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
testSync("unsubscribe stops delivery", () => {
|
|
124
|
+
let conn = makeConn()
|
|
125
|
+
LocalEvents_Server.handleFrame(
|
|
126
|
+
conn,
|
|
127
|
+
`{"type":"subscribe","id":"sub-1","channel":"/default/Product/*"}`,
|
|
128
|
+
)
|
|
129
|
+
LocalEvents_Server.handleFrame(conn, `{"type":"unsubscribe","id":"sub-1"}`)
|
|
130
|
+
LocalEvents_Server.broadcastStateChange(~name="Product", ~descriptor=descriptor(~id="p-1"))
|
|
131
|
+
// subscribe_success + unsubscribe_success, but no data frame
|
|
132
|
+
expect(sentFrames.contents->Array.length)->toBe(2)
|
|
133
|
+
expect(sentFrames.contents->Array.getUnsafe(1)->frameField("type"))->toEqual(
|
|
134
|
+
Some("unsubscribe_success"),
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
testSync("malformed and unknown frames are ignored", () => {
|
|
139
|
+
let conn = makeConn()
|
|
140
|
+
LocalEvents_Server.handleFrame(conn, `not json`)
|
|
141
|
+
LocalEvents_Server.handleFrame(conn, `{"type":"mystery"}`)
|
|
142
|
+
LocalEvents_Server.handleFrame(conn, `{"type":"subscribe","id":"only-id"}`)
|
|
143
|
+
expect(sentFrames.contents->Array.length)->toBe(0)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe("handlePublish", () => {
|
|
148
|
+
testSync("rejects non-client channels with 403", () => {
|
|
149
|
+
let (status, _) = LocalEvents_Server.handlePublish(
|
|
150
|
+
~authorization=None,
|
|
151
|
+
~body=`{"channel":"/default/Product/p-1","events":["{}"]}`,
|
|
152
|
+
)
|
|
153
|
+
expect(status)->toBe(403)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
testSync("rejects an unverifiable token with 401", () => {
|
|
157
|
+
let (status, _) = LocalEvents_Server.handlePublish(
|
|
158
|
+
~authorization=Some("garbage-token"),
|
|
159
|
+
~body=`{"channel":"/client/x","events":["{}"]}`,
|
|
160
|
+
)
|
|
161
|
+
expect(status)->toBe(401)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
testSync("rejects malformed bodies with 400", () => {
|
|
165
|
+
let (s1, _) = LocalEvents_Server.handlePublish(~authorization=None, ~body=`nope`)
|
|
166
|
+
let (s2, _) = LocalEvents_Server.handlePublish(
|
|
167
|
+
~authorization=None,
|
|
168
|
+
~body=`{"channel":"/client/x","events":[]}`,
|
|
169
|
+
)
|
|
170
|
+
expect(s1)->toBe(400)
|
|
171
|
+
expect(s2)->toBe(400)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
testSync("fans out to a wildcard subscriber and accounts per event", () => {
|
|
175
|
+
let conn = makeConn()
|
|
176
|
+
LocalEvents_Server.handleFrame(
|
|
177
|
+
conn,
|
|
178
|
+
`{"type":"subscribe","id":"sub-p","channel":"/client/shop/presence/*"}`,
|
|
179
|
+
)
|
|
180
|
+
let (status, response) = LocalEvents_Server.handlePublish(
|
|
181
|
+
~authorization=None,
|
|
182
|
+
~body=`{"channel":"/client/shop/presence/room-1","events":["{\\"userId\\":\\"u1\\"}","not json",42]}`,
|
|
183
|
+
)
|
|
184
|
+
expect(status)->toBe(200)
|
|
185
|
+
let counts =
|
|
186
|
+
response
|
|
187
|
+
->JSON.Decode.object
|
|
188
|
+
->Option.map(o => (
|
|
189
|
+
o->Dict.get("successful")->Option.flatMap(JSON.Decode.array)->Option.getOr([])->Array.length,
|
|
190
|
+
o->Dict.get("failed")->Option.flatMap(JSON.Decode.array)->Option.getOr([])->Array.length,
|
|
191
|
+
))
|
|
192
|
+
expect(counts)->toEqual(Some((1, 2)))
|
|
193
|
+
// subscribe_success + one data frame for the one valid event
|
|
194
|
+
expect(sentFrames.contents->Array.length)->toBe(2)
|
|
195
|
+
let data = sentFrames.contents->Array.getUnsafe(1)
|
|
196
|
+
expect(data->frameField("id"))->toEqual(Some("sub-p"))
|
|
197
|
+
expect(data->frameField("event"))->toEqual(Some(`{"userId":"u1"}`))
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
})
|