@reventlessdev/reventless-local 3.0.0-alpha.115 → 3.0.0-alpha.116

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 (59) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +6 -6
  3. package/src/Platform.res +91 -17
  4. package/src/Platform.res.mjs +89 -30
  5. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +102 -17
  6. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +105 -22
  7. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +113 -39
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +87 -23
  9. package/src/adapter/EventCollector/LocalEventCollectorChannel.res +33 -0
  10. package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +44 -0
  11. package/src/adapter/EventLog/EventLogStorage_InMemory.res +23 -2
  12. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +20 -3
  13. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +132 -24
  14. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +119 -37
  15. package/src/adapter/LocalBus.res +62 -0
  16. package/src/adapter/LocalBus.res.mjs +120 -0
  17. package/src/adapter/ProjectionCheckpoint.res +350 -0
  18. package/src/adapter/ProjectionCheckpoint.res.mjs +327 -0
  19. package/src/adapter/ProjectionPending.res +60 -0
  20. package/src/adapter/ProjectionPending.res.mjs +64 -0
  21. package/src/adapter/QueryDb/QueryDbListQuery.res +261 -0
  22. package/src/adapter/QueryDb/QueryDbListQuery.res.mjs +212 -0
  23. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +54 -286
  24. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +28 -190
  25. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +32 -6
  26. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +30 -14
  27. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +163 -11
  28. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +101 -3
  29. package/src/adapter/SqliteDriver.res +11 -1
  30. package/src/adapter/SqliteDriver.res.mjs +5 -1
  31. package/src/components/ReadModel_Builder.res +3 -1
  32. package/src/components/ReadModel_Builder.res.mjs +13 -4
  33. package/src/components/StateViewSlice_Builder.res +4 -1
  34. package/src/components/StateViewSlice_Builder.res.mjs +9 -3
  35. package/src/test/Mocks/MockEventLogStorage.res +19 -2
  36. package/src/test/Mocks/MockEventLogStorage.res.mjs +21 -2
  37. package/tests/PluginEventDecodeTest.res +51 -0
  38. package/tests/PluginEventDecodeTest.res.mjs +63 -0
  39. package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
  40. package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
  41. package/tests/adapter/DcbEventLogStorageTest.res +46 -0
  42. package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
  43. package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
  44. package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
  45. package/tests/adapter/EventLogStorageSqliteTest.res +128 -0
  46. package/tests/adapter/EventLogStorageSqliteTest.res.mjs +189 -0
  47. package/tests/adapter/ProjectionCheckpointTest.res +422 -0
  48. package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
  49. package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
  50. package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
  51. package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
  52. package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
  53. package/tests/adapter/QueryDbListResolverTest.res +76 -0
  54. package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
  55. package/tests/components/aggregate/AggregateFixtures.res +4 -0
  56. package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
  57. package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
  58. package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
  59. 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: string => Stream.t<JSON.t, string, unit> = id =>
31
- events.contents->Dict.get(id)->Option.getOr([])->Stream.fromIterable
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 = id => Stream.fromIterable(Stdlib_Option.getOr(events.contents[id], []));
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
+ })