@reventlessdev/reventless-local 3.0.0-alpha.115 → 3.0.0-alpha.117
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 +27 -0
- package/package.json +8 -8
- package/src/Platform.res +91 -17
- package/src/Platform.res.mjs +89 -30
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +102 -17
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +105 -22
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +113 -39
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +87 -23
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res +33 -0
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +44 -0
- package/src/adapter/EventLog/EventLogStorage_InMemory.res +23 -2
- package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +20 -3
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +132 -24
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +119 -37
- package/src/adapter/LocalBus.res +62 -0
- package/src/adapter/LocalBus.res.mjs +120 -0
- package/src/adapter/ProjectionCheckpoint.res +350 -0
- package/src/adapter/ProjectionCheckpoint.res.mjs +327 -0
- package/src/adapter/ProjectionPending.res +60 -0
- package/src/adapter/ProjectionPending.res.mjs +64 -0
- package/src/adapter/QueryDb/QueryDbListQuery.res +261 -0
- package/src/adapter/QueryDb/QueryDbListQuery.res.mjs +212 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +54 -286
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +28 -190
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +32 -6
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +30 -14
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +163 -11
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +101 -3
- package/src/adapter/SqliteDriver.res +11 -1
- package/src/adapter/SqliteDriver.res.mjs +5 -1
- package/src/components/ReadModel_Builder.res +3 -1
- package/src/components/ReadModel_Builder.res.mjs +13 -4
- package/src/components/StateViewSlice_Builder.res +4 -1
- package/src/components/StateViewSlice_Builder.res.mjs +9 -3
- package/src/test/Mocks/MockEventLogStorage.res +19 -2
- package/src/test/Mocks/MockEventLogStorage.res.mjs +21 -2
- package/tests/PluginEventDecodeTest.res +51 -0
- package/tests/PluginEventDecodeTest.res.mjs +63 -0
- package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
- package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
- package/tests/adapter/DcbEventLogStorageTest.res +46 -0
- package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
- package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
- package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
- package/tests/adapter/EventLogStorageSqliteTest.res +128 -0
- package/tests/adapter/EventLogStorageSqliteTest.res.mjs +189 -0
- package/tests/adapter/ProjectionCheckpointTest.res +422 -0
- package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
- package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
- package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
- package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
- package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
- package/tests/adapter/QueryDbListResolverTest.res +76 -0
- package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
- package/tests/components/aggregate/AggregateFixtures.res +4 -0
- package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
- package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
- package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
- package/tests/plugin/PluginBehavior_GWT.res.mjs +1 -0
|
@@ -27,8 +27,22 @@ let make = (~name as _="mock-event-log", ~opts as _: Pulumi.CustomResourceOption
|
|
|
27
27
|
events.contents->Dict.get(id)->Option.getOr([])
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
let replayStream
|
|
31
|
-
events.contents
|
|
30
|
+
let replayStream = (id, ~fromSeq=0) =>
|
|
31
|
+
events.contents
|
|
32
|
+
->Dict.get(id)
|
|
33
|
+
->Option.getOr([])
|
|
34
|
+
->Array.filterWithIndex((_, i) => i >= fromSeq)
|
|
35
|
+
->Stream.fromIterable
|
|
36
|
+
|
|
37
|
+
let snapshots: dict<ReventlessCore.EventLog.snapshot> = Dict.make()
|
|
38
|
+
|
|
39
|
+
let latestSnapshot: ReventlessCore.EventLog.latestSnapshot<string> = async id =>
|
|
40
|
+
Ok(snapshots->Dict.get(id))
|
|
41
|
+
|
|
42
|
+
let writeSnapshot: ReventlessCore.EventLog.writeSnapshot<string> = async (id, snap) => {
|
|
43
|
+
snapshots->Dict.set(id, snap)
|
|
44
|
+
Ok()
|
|
45
|
+
}
|
|
32
46
|
|
|
33
47
|
let appendStream: ReventlessCore.EventLog.appendStream<string, JSON.t> = (_startingSeqNr, id, stream) =>
|
|
34
48
|
stream->Stream.runForEach(json =>
|
|
@@ -45,11 +59,14 @@ let make = (~name as _="mock-event-log", ~opts as _: Pulumi.CustomResourceOption
|
|
|
45
59
|
replay,
|
|
46
60
|
replayStream,
|
|
47
61
|
appendStream,
|
|
62
|
+
latestSnapshot,
|
|
63
|
+
writeSnapshot,
|
|
48
64
|
}),
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
let reset = () => {
|
|
52
68
|
events := Dict.make()
|
|
69
|
+
snapshots->Dict.forEachWithKey((_, k) => snapshots->Dict.delete(k))
|
|
53
70
|
failNextAppends := 0
|
|
54
71
|
}
|
|
55
72
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
3
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
5
|
import * as Effect from "effect/Effect";
|
|
5
6
|
import * as Stream from "effect/Stream";
|
|
@@ -35,7 +36,22 @@ function make($staropt$star, $staropt$star$1) {
|
|
|
35
36
|
};
|
|
36
37
|
};
|
|
37
38
|
let replay = async id => Stdlib_Option.getOr(events.contents[id], []);
|
|
38
|
-
let replayStream =
|
|
39
|
+
let replayStream = (id, fromSeqOpt) => {
|
|
40
|
+
let fromSeq = fromSeqOpt !== undefined ? fromSeqOpt : 0;
|
|
41
|
+
return Stream.fromIterable(Stdlib_Option.getOr(events.contents[id], []).filter((param, i) => i >= fromSeq));
|
|
42
|
+
};
|
|
43
|
+
let snapshots = {};
|
|
44
|
+
let latestSnapshot = async id => ({
|
|
45
|
+
TAG: "Ok",
|
|
46
|
+
_0: snapshots[id]
|
|
47
|
+
});
|
|
48
|
+
let writeSnapshot = async (id, snap) => {
|
|
49
|
+
snapshots[id] = snap;
|
|
50
|
+
return {
|
|
51
|
+
TAG: "Ok",
|
|
52
|
+
_0: undefined
|
|
53
|
+
};
|
|
54
|
+
};
|
|
39
55
|
let appendStream = (_startingSeqNr, id, stream) => Stream.runForEach(stream, json => Effect.sync(() => {
|
|
40
56
|
let existing = Stdlib_Option.getOr(events.contents[id], []);
|
|
41
57
|
events.contents[id] = existing.concat([json]);
|
|
@@ -45,7 +61,9 @@ function make($staropt$star, $staropt$star$1) {
|
|
|
45
61
|
append: append,
|
|
46
62
|
replay: replay,
|
|
47
63
|
replayStream: replayStream,
|
|
48
|
-
appendStream: appendStream
|
|
64
|
+
appendStream: appendStream,
|
|
65
|
+
latestSnapshot: latestSnapshot,
|
|
66
|
+
writeSnapshot: writeSnapshot
|
|
49
67
|
});
|
|
50
68
|
let storage = {
|
|
51
69
|
resources: storage_resources,
|
|
@@ -53,6 +71,7 @@ function make($staropt$star, $staropt$star$1) {
|
|
|
53
71
|
};
|
|
54
72
|
let reset = () => {
|
|
55
73
|
events.contents = {};
|
|
74
|
+
Stdlib_Dict.forEachWithKey(snapshots, (param, k) => Stdlib_Dict.$$delete(snapshots, k));
|
|
56
75
|
failNextAppends.contents = 0;
|
|
57
76
|
};
|
|
58
77
|
return {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Regression: subscribeToPluginEvents' Source-C emission decoded the WHOLE bus
|
|
2
|
+
// envelope {id, meta, event} with the bare Plugin event schema — convert mode
|
|
3
|
+
// "succeeded" and then threw a TypeError on every variant's payload access, so
|
|
4
|
+
// the onPluginStatusChange / onUIFragmentChange subscription emissions never
|
|
5
|
+
// fired on the local platform. decodePluginEventEnvelope must decode the
|
|
6
|
+
// payload under "event" and degrade to None on anything malformed.
|
|
7
|
+
|
|
8
|
+
open JestGlobals
|
|
9
|
+
|
|
10
|
+
let _ = TestRunner.setup()
|
|
11
|
+
|
|
12
|
+
let meta = ReventlessCore.Message.generateMeta(~service=ReventlessCore.PluginSpec.name)
|
|
13
|
+
|
|
14
|
+
let envelopeOf = (event: ReventlessCore.PluginSpec.event) =>
|
|
15
|
+
ReventlessCore.Message.composeEventJson'(
|
|
16
|
+
"Catalog",
|
|
17
|
+
meta,
|
|
18
|
+
event->S.reverseConvertToJsonOrThrow(ReventlessCore.PluginSpec.eventSchema),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
describe("Platform.decodePluginEventEnvelope", () => {
|
|
22
|
+
testSync("decodes a VersionConnected event from the published envelope", () => {
|
|
23
|
+
let envelope = envelopeOf(VersionConnected(Plugin_Fixtures.pluginDefinition))
|
|
24
|
+
switch Platform.decodePluginEventEnvelope(envelope) {
|
|
25
|
+
| Some(VersionConnected(def)) => expect(def.name)->toBe(Plugin_Fixtures.pluginDefinition.name)
|
|
26
|
+
| _ => expect("VersionConnected")->toBe("something else")
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
testSync("decodes a payload-light variant (VersionDetected)", () => {
|
|
31
|
+
let envelope = envelopeOf(VersionDetected("2.0.0"))
|
|
32
|
+
expect(Platform.decodePluginEventEnvelope(envelope))->toEqual(
|
|
33
|
+
Some(ReventlessCore.PluginSpec.VersionDetected("2.0.0")),
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
testSync("returns None for a bare event JSON (no envelope) and malformed input", () => {
|
|
38
|
+
// The old bug class: input that is not the {id, meta, event} envelope.
|
|
39
|
+
let bare =
|
|
40
|
+
ReventlessCore.PluginSpec.VersionDetected("2.0.0")->S.reverseConvertToJsonOrThrow(
|
|
41
|
+
ReventlessCore.PluginSpec.eventSchema,
|
|
42
|
+
)
|
|
43
|
+
expect(Platform.decodePluginEventEnvelope(bare))->toEqual(None)
|
|
44
|
+
expect(Platform.decodePluginEventEnvelope(JSON.Encode.string("junk")))->toEqual(None)
|
|
45
|
+
expect(
|
|
46
|
+
Platform.decodePluginEventEnvelope(
|
|
47
|
+
JSON.Encode.object(Dict.fromArray([("event", JSON.Encode.object(Dict.make()))])),
|
|
48
|
+
),
|
|
49
|
+
)->toEqual(None)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
|
|
5
|
+
import * as Platform$ReventlessLocal from "../src/Platform.res.mjs";
|
|
6
|
+
import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginSpec.res.mjs";
|
|
7
|
+
import * as TestRunner$ReventlessLocal from "../src/test/TestRunner.res.mjs";
|
|
8
|
+
import * as Plugin_Fixtures$ReventlessLocal from "./plugin/Plugin_Fixtures.res.mjs";
|
|
9
|
+
|
|
10
|
+
TestRunner$ReventlessLocal.setup();
|
|
11
|
+
|
|
12
|
+
let meta = Message$ReventlessCore.generateMeta(PluginSpec$ReventlessCore.name, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
13
|
+
|
|
14
|
+
function envelopeOf(event) {
|
|
15
|
+
return Message$ReventlessCore.composeEventJson$p("Catalog", meta, S.reverseConvertToJsonOrThrow(event, PluginSpec$ReventlessCore.eventSchema));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
globalThis.describe("Platform.decodePluginEventEnvelope", () => {
|
|
19
|
+
globalThis.test("decodes a VersionConnected event from the published envelope", () => {
|
|
20
|
+
let envelope = envelopeOf({
|
|
21
|
+
TAG: "VersionConnected",
|
|
22
|
+
_0: Plugin_Fixtures$ReventlessLocal.pluginDefinition
|
|
23
|
+
});
|
|
24
|
+
let match = Platform$ReventlessLocal.decodePluginEventEnvelope(envelope);
|
|
25
|
+
if (match !== undefined) {
|
|
26
|
+
if (match.TAG === "VersionConnected") {
|
|
27
|
+
globalThis.expect(match._0.name).toBe(Plugin_Fixtures$ReventlessLocal.pluginDefinition.name);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
globalThis.expect("VersionConnected").toBe("something else");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
globalThis.expect("VersionConnected").toBe("something else");
|
|
34
|
+
});
|
|
35
|
+
globalThis.test("decodes a payload-light variant (VersionDetected)", () => {
|
|
36
|
+
let envelope = envelopeOf({
|
|
37
|
+
TAG: "VersionDetected",
|
|
38
|
+
_0: "2.0.0"
|
|
39
|
+
});
|
|
40
|
+
globalThis.expect(Platform$ReventlessLocal.decodePluginEventEnvelope(envelope)).toEqual({
|
|
41
|
+
TAG: "VersionDetected",
|
|
42
|
+
_0: "2.0.0"
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
globalThis.test("returns None for a bare event JSON (no envelope) and malformed input", () => {
|
|
46
|
+
let bare = S.reverseConvertToJsonOrThrow({
|
|
47
|
+
TAG: "VersionDetected",
|
|
48
|
+
_0: "2.0.0"
|
|
49
|
+
}, PluginSpec$ReventlessCore.eventSchema);
|
|
50
|
+
globalThis.expect(Platform$ReventlessLocal.decodePluginEventEnvelope(bare)).toEqual(undefined);
|
|
51
|
+
globalThis.expect(Platform$ReventlessLocal.decodePluginEventEnvelope("junk")).toEqual(undefined);
|
|
52
|
+
globalThis.expect(Platform$ReventlessLocal.decodePluginEventEnvelope(Object.fromEntries([[
|
|
53
|
+
"event",
|
|
54
|
+
{}
|
|
55
|
+
]]))).toEqual(undefined);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
meta,
|
|
61
|
+
envelopeOf,
|
|
62
|
+
}
|
|
63
|
+
/* Not a pure module */
|
|
@@ -83,6 +83,79 @@ describe("DcbEventLogStorage_Sqlite", () => {
|
|
|
83
83
|
})
|
|
84
84
|
})
|
|
85
85
|
|
|
86
|
+
testPromise("batched tag hydration preserves per-event tags and order", async () => {
|
|
87
|
+
await runUnderSqlite(async () => {
|
|
88
|
+
module TestBus = LocalBus.Make()
|
|
89
|
+
module Storage = DcbEventLogStorage_InMemory.Make(TestBus)
|
|
90
|
+
let s = Storage.make(
|
|
91
|
+
~name="dcb-multitag",
|
|
92
|
+
~indexes=[],
|
|
93
|
+
~partitionTag=Reventless.DcbTag.Simple({key: "k"}),
|
|
94
|
+
~opts,
|
|
95
|
+
)
|
|
96
|
+
let ops = await s.operations->TestRunner.resolve
|
|
97
|
+
|
|
98
|
+
// e1 carries three tags (declaration order matters); e2 carries one; e3 none.
|
|
99
|
+
let e1 = stored(
|
|
100
|
+
"Multi",
|
|
101
|
+
[
|
|
102
|
+
{key: "a", value: "1"},
|
|
103
|
+
{key: "b", value: "2"},
|
|
104
|
+
{key: "c", value: "3"},
|
|
105
|
+
],
|
|
106
|
+
JSON.Encode.string("first"),
|
|
107
|
+
)
|
|
108
|
+
let e2 = stored("Single", [{key: "only", value: "z"}], JSON.Encode.string("second"))
|
|
109
|
+
let e3 = stored("None", [], JSON.Encode.string("third"))
|
|
110
|
+
let _ = await ops.append([e1])
|
|
111
|
+
let _ = await ops.append([e2])
|
|
112
|
+
let _ = await ops.append([e3])
|
|
113
|
+
|
|
114
|
+
let read = await ops.read(~query=[])
|
|
115
|
+
expect(read.events->Array.length)->toBe(3)
|
|
116
|
+
let first = read.events->Array.getUnsafe(0)
|
|
117
|
+
// Order and grouping survive the single batched IN query.
|
|
118
|
+
expect(first.tags->Array.map(t => t.key))->toEqual(["a", "b", "c"])
|
|
119
|
+
expect(first.tags->Array.map(t => t.value))->toEqual(["1", "2", "3"])
|
|
120
|
+
let second = read.events->Array.getUnsafe(1)
|
|
121
|
+
expect(second.tags->Array.length)->toBe(1)
|
|
122
|
+
expect((second.tags->Array.getUnsafe(0)).key)->toBe("only")
|
|
123
|
+
let third = read.events->Array.getUnsafe(2)
|
|
124
|
+
expect(third.tags->Array.length)->toBe(0)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
testPromise("append with a non-conflicting condition succeeds", async () => {
|
|
129
|
+
await runUnderSqlite(async () => {
|
|
130
|
+
module TestBus = LocalBus.Make()
|
|
131
|
+
module Storage = DcbEventLogStorage_InMemory.Make(TestBus)
|
|
132
|
+
let s = Storage.make(
|
|
133
|
+
~name="dcb-noconflict",
|
|
134
|
+
~indexes=[],
|
|
135
|
+
~partitionTag=Reventless.DcbTag.Simple({key: "k"}),
|
|
136
|
+
~opts,
|
|
137
|
+
)
|
|
138
|
+
let ops = await s.operations->TestRunner.resolve
|
|
139
|
+
|
|
140
|
+
let _ = await ops.append([stored("Created", [{key: "id", value: "x"}], JSON.Encode.string("a"))])
|
|
141
|
+
// Condition queries a tag value that does not exist → anyMatch is false →
|
|
142
|
+
// the append is allowed.
|
|
143
|
+
let cond: Reventless.DcbTag.appendCondition = {
|
|
144
|
+
query: [{tags: [{key: "id", value: "other"}]}],
|
|
145
|
+
}
|
|
146
|
+
let result = await ops.append(
|
|
147
|
+
[stored("Created", [{key: "id", value: "other"}], JSON.Encode.string("b"))],
|
|
148
|
+
~condition=cond,
|
|
149
|
+
)
|
|
150
|
+
switch result {
|
|
151
|
+
| Ok(_) => expect(true)->toBe(true)
|
|
152
|
+
| Error(m) => expect(m)->toBe("expected Ok")
|
|
153
|
+
}
|
|
154
|
+
let read = await ops.read(~query=[])
|
|
155
|
+
expect(read.events->Array.length)->toBe(2)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
86
159
|
testPromise("append with conflicting condition returns Error", async () => {
|
|
87
160
|
await runUnderSqlite(async () => {
|
|
88
161
|
module TestBus = LocalBus.Make()
|
|
@@ -88,6 +88,92 @@ globalThis.describe("DcbEventLogStorage_Sqlite", () => {
|
|
|
88
88
|
}], undefined);
|
|
89
89
|
globalThis.expect(read.events.length).toBe(2);
|
|
90
90
|
}));
|
|
91
|
+
globalThis.test("batched tag hydration preserves per-event tags and order", async () => await runUnderSqlite(async () => {
|
|
92
|
+
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
93
|
+
let Storage = DcbEventLogStorage_InMemory$ReventlessLocal.Make(TestBus);
|
|
94
|
+
let s = Storage.make("dcb-multitag", [], {
|
|
95
|
+
TAG: "Simple",
|
|
96
|
+
_0: {
|
|
97
|
+
key: "k"
|
|
98
|
+
}
|
|
99
|
+
}, undefined, opts);
|
|
100
|
+
let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
|
|
101
|
+
let e1 = stored("Multi", [
|
|
102
|
+
{
|
|
103
|
+
key: "a",
|
|
104
|
+
value: "1"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
key: "b",
|
|
108
|
+
value: "2"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
key: "c",
|
|
112
|
+
value: "3"
|
|
113
|
+
}
|
|
114
|
+
], "first");
|
|
115
|
+
let e2 = stored("Single", [{
|
|
116
|
+
key: "only",
|
|
117
|
+
value: "z"
|
|
118
|
+
}], "second");
|
|
119
|
+
let e3 = stored("None", [], "third");
|
|
120
|
+
await ops.append([e1], undefined);
|
|
121
|
+
await ops.append([e2], undefined);
|
|
122
|
+
await ops.append([e3], undefined);
|
|
123
|
+
let read = await ops.read([], undefined);
|
|
124
|
+
globalThis.expect(read.events.length).toBe(3);
|
|
125
|
+
let first = read.events[0];
|
|
126
|
+
globalThis.expect(first.tags.map(t => t.key)).toEqual([
|
|
127
|
+
"a",
|
|
128
|
+
"b",
|
|
129
|
+
"c"
|
|
130
|
+
]);
|
|
131
|
+
globalThis.expect(first.tags.map(t => t.value)).toEqual([
|
|
132
|
+
"1",
|
|
133
|
+
"2",
|
|
134
|
+
"3"
|
|
135
|
+
]);
|
|
136
|
+
let second = read.events[1];
|
|
137
|
+
globalThis.expect(second.tags.length).toBe(1);
|
|
138
|
+
globalThis.expect(second.tags[0].key).toBe("only");
|
|
139
|
+
let third = read.events[2];
|
|
140
|
+
globalThis.expect(third.tags.length).toBe(0);
|
|
141
|
+
}));
|
|
142
|
+
globalThis.test("append with a non-conflicting condition succeeds", async () => await runUnderSqlite(async () => {
|
|
143
|
+
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
144
|
+
let Storage = DcbEventLogStorage_InMemory$ReventlessLocal.Make(TestBus);
|
|
145
|
+
let s = Storage.make("dcb-noconflict", [], {
|
|
146
|
+
TAG: "Simple",
|
|
147
|
+
_0: {
|
|
148
|
+
key: "k"
|
|
149
|
+
}
|
|
150
|
+
}, undefined, opts);
|
|
151
|
+
let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
|
|
152
|
+
await ops.append([stored("Created", [{
|
|
153
|
+
key: "id",
|
|
154
|
+
value: "x"
|
|
155
|
+
}], "a")], undefined);
|
|
156
|
+
let cond_query = [{
|
|
157
|
+
tags: [{
|
|
158
|
+
key: "id",
|
|
159
|
+
value: "other"
|
|
160
|
+
}]
|
|
161
|
+
}];
|
|
162
|
+
let cond = {
|
|
163
|
+
query: cond_query
|
|
164
|
+
};
|
|
165
|
+
let result = await ops.append([stored("Created", [{
|
|
166
|
+
key: "id",
|
|
167
|
+
value: "other"
|
|
168
|
+
}], "b")], cond);
|
|
169
|
+
if (result.TAG === "Ok") {
|
|
170
|
+
globalThis.expect(true).toBe(true);
|
|
171
|
+
} else {
|
|
172
|
+
globalThis.expect(result._0).toBe("expected Ok");
|
|
173
|
+
}
|
|
174
|
+
let read = await ops.read([], undefined);
|
|
175
|
+
globalThis.expect(read.events.length).toBe(2);
|
|
176
|
+
}));
|
|
91
177
|
globalThis.test("append with conflicting condition returns Error", async () => await runUnderSqlite(async () => {
|
|
92
178
|
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
93
179
|
let Storage = DcbEventLogStorage_InMemory$ReventlessLocal.Make(TestBus);
|
|
@@ -96,6 +96,52 @@ describe("DcbEventLogStorage_InMemory", () => {
|
|
|
96
96
|
expect(firstEvent.tags->Array.length)->toBe(1)
|
|
97
97
|
})
|
|
98
98
|
|
|
99
|
+
testPromise("multi-tag clause matches only events carrying ALL tags", async () => {
|
|
100
|
+
let storage = makeStorage()
|
|
101
|
+
let ops = await storage.operations->TestRunner.resolve
|
|
102
|
+
let _ = await ops.append([
|
|
103
|
+
makeEvent(
|
|
104
|
+
~eventType="Evt",
|
|
105
|
+
~data=JSON.Null,
|
|
106
|
+
~tags=[{Reventless.DcbTag.key: "tenant", value: "acme"}, {key: "region", value: "eu"}],
|
|
107
|
+
),
|
|
108
|
+
makeEvent(~eventType="Evt", ~data=JSON.Null, ~tags=[{Reventless.DcbTag.key: "tenant", value: "acme"}]),
|
|
109
|
+
makeEvent(~eventType="Evt", ~data=JSON.Null, ~tags=[{Reventless.DcbTag.key: "region", value: "eu"}]),
|
|
110
|
+
])
|
|
111
|
+
// Intersection of the two tags' posting lists → only the first event.
|
|
112
|
+
let result = await ops.read(
|
|
113
|
+
~query=[
|
|
114
|
+
{
|
|
115
|
+
Reventless.DcbTag.tags: [
|
|
116
|
+
{Reventless.DcbTag.key: "tenant", value: "acme"},
|
|
117
|
+
{key: "region", value: "eu"},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
)
|
|
122
|
+
expect(result.events->Array.length)->toBe(1)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
testPromise("OR of clauses unions matches across clauses in position order", async () => {
|
|
126
|
+
let storage = makeStorage()
|
|
127
|
+
let ops = await storage.operations->TestRunner.resolve
|
|
128
|
+
let _ = await ops.append([
|
|
129
|
+
makeEvent(~eventType="A", ~data=JSON.Null),
|
|
130
|
+
makeEvent(~eventType="B", ~data=JSON.Null, ~tags=[{Reventless.DcbTag.key: "x", value: "1"}]),
|
|
131
|
+
makeEvent(~eventType="C", ~data=JSON.Null),
|
|
132
|
+
])
|
|
133
|
+
// Clause 1 (type A) ∪ clause 2 (tag x=1) → events at positions 1 and 2.
|
|
134
|
+
let result = await ops.read(
|
|
135
|
+
~query=[
|
|
136
|
+
{Reventless.DcbTag.eventTypes: ["A"]},
|
|
137
|
+
{Reventless.DcbTag.tags: [{Reventless.DcbTag.key: "x", value: "1"}]},
|
|
138
|
+
],
|
|
139
|
+
)
|
|
140
|
+
expect(result.events->Array.length)->toBe(2)
|
|
141
|
+
expect((result.events->Array.getUnsafe(0)).eventType)->toBe("A")
|
|
142
|
+
expect((result.events->Array.getUnsafe(1)).eventType)->toBe("B")
|
|
143
|
+
})
|
|
144
|
+
|
|
99
145
|
testPromise("after parameter skips events at or before that position", async () => {
|
|
100
146
|
let storage = makeStorage()
|
|
101
147
|
let ops = await storage.operations->TestRunner.resolve
|
|
@@ -102,6 +102,69 @@ globalThis.describe("DcbEventLogStorage_InMemory", () => {
|
|
|
102
102
|
let firstEvent = result.events[0];
|
|
103
103
|
globalThis.expect(firstEvent.tags.length).toBe(1);
|
|
104
104
|
});
|
|
105
|
+
globalThis.test("multi-tag clause matches only events carrying ALL tags", async () => {
|
|
106
|
+
let storage = makeStorage();
|
|
107
|
+
let ops = await TestRunner$ReventlessLocal.resolve(storage.operations);
|
|
108
|
+
await ops.append([
|
|
109
|
+
makeEvent("Evt", null, [
|
|
110
|
+
{
|
|
111
|
+
key: "tenant",
|
|
112
|
+
value: "acme"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
key: "region",
|
|
116
|
+
value: "eu"
|
|
117
|
+
}
|
|
118
|
+
]),
|
|
119
|
+
makeEvent("Evt", null, [{
|
|
120
|
+
key: "tenant",
|
|
121
|
+
value: "acme"
|
|
122
|
+
}]),
|
|
123
|
+
makeEvent("Evt", null, [{
|
|
124
|
+
key: "region",
|
|
125
|
+
value: "eu"
|
|
126
|
+
}])
|
|
127
|
+
], undefined);
|
|
128
|
+
let result = await ops.read([{
|
|
129
|
+
tags: [
|
|
130
|
+
{
|
|
131
|
+
key: "tenant",
|
|
132
|
+
value: "acme"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
key: "region",
|
|
136
|
+
value: "eu"
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
}], undefined);
|
|
140
|
+
globalThis.expect(result.events.length).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
globalThis.test("OR of clauses unions matches across clauses in position order", async () => {
|
|
143
|
+
let storage = makeStorage();
|
|
144
|
+
let ops = await TestRunner$ReventlessLocal.resolve(storage.operations);
|
|
145
|
+
await ops.append([
|
|
146
|
+
makeEvent("A", null, undefined),
|
|
147
|
+
makeEvent("B", null, [{
|
|
148
|
+
key: "x",
|
|
149
|
+
value: "1"
|
|
150
|
+
}]),
|
|
151
|
+
makeEvent("C", null, undefined)
|
|
152
|
+
], undefined);
|
|
153
|
+
let result = await ops.read([
|
|
154
|
+
{
|
|
155
|
+
eventTypes: ["A"]
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
tags: [{
|
|
159
|
+
key: "x",
|
|
160
|
+
value: "1"
|
|
161
|
+
}]
|
|
162
|
+
}
|
|
163
|
+
], undefined);
|
|
164
|
+
globalThis.expect(result.events.length).toBe(2);
|
|
165
|
+
globalThis.expect(result.events[0].eventType).toBe("A");
|
|
166
|
+
globalThis.expect(result.events[1].eventType).toBe("B");
|
|
167
|
+
});
|
|
105
168
|
globalThis.test("after parameter skips events at or before that position", async () => {
|
|
106
169
|
let storage = makeStorage();
|
|
107
170
|
let ops = await TestRunner$ReventlessLocal.resolve(storage.operations);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Backend-parity tests for the EventLog snapshot ops
|
|
2
|
+
// (docs/plans/aggregate-snapshotting.md): the in-memory and SQLite backends
|
|
3
|
+
// must behave identically for latestSnapshot / writeSnapshot (keep-one) and
|
|
4
|
+
// the replayStream ~fromSeq delta read, so GWT/local tests of
|
|
5
|
+
// snapshot-enabled aggregates give the same results under either backend.
|
|
6
|
+
|
|
7
|
+
open JestGlobals
|
|
8
|
+
|
|
9
|
+
let _ = TestRunner.setup()
|
|
10
|
+
let opts: Pulumi.CustomResourceOptions.t = {}
|
|
11
|
+
|
|
12
|
+
let makeMemoryOps = () => {
|
|
13
|
+
let s = EventLogStorage_InMemory.make(~name="parity", ~opts)
|
|
14
|
+
s.operations->TestRunner.resolve
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let makeSqliteOps = () => {
|
|
18
|
+
module TestBus = LocalBus.Make()
|
|
19
|
+
module DbProvider = {
|
|
20
|
+
let db = SqliteDriver.openDb(~path=":memory:")
|
|
21
|
+
}
|
|
22
|
+
module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
|
|
23
|
+
let s = Storage.make(~name="parity", ~opts)
|
|
24
|
+
s.operations->TestRunner.resolve
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let backends = [("memory", makeMemoryOps), ("sqlite", makeSqliteOps)]
|
|
28
|
+
|
|
29
|
+
describe("EventLog snapshot backend parity", () => {
|
|
30
|
+
backends->Array.forEach(((label, makeOps)) => {
|
|
31
|
+
testPromise(`${label}: latestSnapshot is None before any write`, async () => {
|
|
32
|
+
let ops = await makeOps()
|
|
33
|
+
expect(await ops.latestSnapshot("id-1"))->toEqual(Ok(None))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
testPromise(`${label}: snapshot round-trips and keep-one overwrites`, async () => {
|
|
37
|
+
let ops = await makeOps()
|
|
38
|
+
let state = JSON.Encode.object(Dict.fromArray([("n", JSON.Encode.int(1))]))
|
|
39
|
+
expect(
|
|
40
|
+
await ops.writeSnapshot(
|
|
41
|
+
"id-1",
|
|
42
|
+
{ReventlessCore.EventLog.seqNr: 2, state, schemaHash: "h1"},
|
|
43
|
+
),
|
|
44
|
+
)->toEqual(Ok())
|
|
45
|
+
expect(await ops.latestSnapshot("id-1"))->toEqual(
|
|
46
|
+
Ok(Some({ReventlessCore.EventLog.seqNr: 2, state, schemaHash: "h1"})),
|
|
47
|
+
)
|
|
48
|
+
let state2 = JSON.Encode.object(Dict.fromArray([("n", JSON.Encode.int(9))]))
|
|
49
|
+
let _ = await ops.writeSnapshot(
|
|
50
|
+
"id-1",
|
|
51
|
+
{ReventlessCore.EventLog.seqNr: 9, state: state2, schemaHash: "h2"},
|
|
52
|
+
)
|
|
53
|
+
expect(await ops.latestSnapshot("id-1"))->toEqual(
|
|
54
|
+
Ok(Some({ReventlessCore.EventLog.seqNr: 9, state: state2, schemaHash: "h2"})),
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
testPromise(`${label}: snapshots are per-aggregate-id`, async () => {
|
|
59
|
+
let ops = await makeOps()
|
|
60
|
+
let state = JSON.Encode.object(Dict.fromArray([("n", JSON.Encode.int(1))]))
|
|
61
|
+
let _ = await ops.writeSnapshot(
|
|
62
|
+
"id-a",
|
|
63
|
+
{ReventlessCore.EventLog.seqNr: 1, state, schemaHash: "h"},
|
|
64
|
+
)
|
|
65
|
+
expect(await ops.latestSnapshot("id-b"))->toEqual(Ok(None))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
testPromise(`${label}: replayStream ~fromSeq equals the tail of a full replay`, async () => {
|
|
69
|
+
let ops = await makeOps()
|
|
70
|
+
let ev = i => JSON.Encode.object(Dict.fromArray([("i", JSON.Encode.int(i))]))
|
|
71
|
+
let _ = await ops.append(0, "id-1", [ev(0), ev(1), ev(2), ev(3), ev(4)])
|
|
72
|
+
let delta = await ops.replayStream("id-1", ~fromSeq=3)->Stream.runCollect->Effect.runPromise
|
|
73
|
+
expect(delta)->toEqual([ev(3), ev(4)])
|
|
74
|
+
// fromSeq at the head yields nothing; omitted fromSeq yields everything.
|
|
75
|
+
let empty = await ops.replayStream("id-1", ~fromSeq=5)->Stream.runCollect->Effect.runPromise
|
|
76
|
+
expect(empty)->toEqual([])
|
|
77
|
+
let full = await ops.replayStream("id-1")->Stream.runCollect->Effect.runPromise
|
|
78
|
+
expect(full->Array.length)->toBe(5)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
})
|