@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.
- package/CHANGELOG.md +19 -0
- package/package.json +6 -6
- 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
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// Projection checkpoints + startup catch-up (plan B5).
|
|
2
|
+
//
|
|
3
|
+
// Simulates the crash window the checkpoint exists for: events persisted in the
|
|
4
|
+
// SQLite event_log whose projections never ran (no checkpoint advance). At the
|
|
5
|
+
// next startup runCatchup must redeliver exactly the missed range — in rowid
|
|
6
|
+
// order, reconstructed into the live {id, meta, event} envelope — and stamp the
|
|
7
|
+
// checkpoints so a second startup delivers nothing.
|
|
8
|
+
|
|
9
|
+
open JestGlobals
|
|
10
|
+
|
|
11
|
+
let _ = TestRunner.setup()
|
|
12
|
+
let opts: Pulumi.CustomResourceOptions.t = {}
|
|
13
|
+
|
|
14
|
+
let makeFreshDb = () => SqliteDriver.openDb(~path=":memory:")
|
|
15
|
+
|
|
16
|
+
// A flat stored-event payload in the exact shape EventLog_Operations.encodeEvent'
|
|
17
|
+
// persists: envelope fields + the meta fields flattened to the top level.
|
|
18
|
+
let flatEvent = (~id, ~seq, ~eventType, ~data=[], ~msgId, ~service="Products") =>
|
|
19
|
+
JSON.Encode.object(
|
|
20
|
+
Dict.fromArray([
|
|
21
|
+
("id", JSON.Encode.string(id)),
|
|
22
|
+
("position", JSON.Encode.string(seq->Int.toString->String.padStart(9, "0"))),
|
|
23
|
+
("event", JSON.Encode.string(eventType)),
|
|
24
|
+
("data", JSON.Encode.object(Dict.fromArray(data))),
|
|
25
|
+
("recordedAt", JSON.Encode.string("2026-07-03T00:00:00.000Z")),
|
|
26
|
+
("service", JSON.Encode.string(service)),
|
|
27
|
+
("time", JSON.Encode.string("2026-07-03T00:00:00.000Z")),
|
|
28
|
+
("msgId", JSON.Encode.string(msgId)),
|
|
29
|
+
("correlationId", JSON.Encode.string(msgId)),
|
|
30
|
+
]),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
let msgIdOfEnvelope = (json: JSON.t) =>
|
|
34
|
+
json
|
|
35
|
+
->JSON.Decode.object
|
|
36
|
+
->Option.flatMap(d => d->Dict.get("meta"))
|
|
37
|
+
->Option.flatMap(JSON.Decode.object)
|
|
38
|
+
->Option.flatMap(m => m->Dict.get("msgId"))
|
|
39
|
+
->Option.flatMap(JSON.Decode.string)
|
|
40
|
+
->Option.getOr("<none>")
|
|
41
|
+
|
|
42
|
+
// Recording catch-up handler.
|
|
43
|
+
let makeHandler = () => {
|
|
44
|
+
let received: array<JSON.t> = []
|
|
45
|
+
let handler = (json: JSON.t, _ctx: unit) => {
|
|
46
|
+
received->Array.push(json)
|
|
47
|
+
Promise.resolve()
|
|
48
|
+
}
|
|
49
|
+
(received, handler)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A DCB raw stored event with a deterministic msgId.
|
|
53
|
+
let dcbEvent = (~eventType, ~tags, ~data=[], ~msgId): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
|
|
54
|
+
eventType,
|
|
55
|
+
data: JSON.Encode.object(Dict.fromArray(data)),
|
|
56
|
+
tags,
|
|
57
|
+
meta: {
|
|
58
|
+
service: "OrderingDcbEventLog",
|
|
59
|
+
time: "2026-07-03T00:00:00.000Z",
|
|
60
|
+
msgId,
|
|
61
|
+
correlationId: msgId,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let makeDcbStorage = async (db, ~name) => {
|
|
66
|
+
let (_, _, storage) = DcbEventLogStorage_Sqlite.makeStorage(
|
|
67
|
+
~db,
|
|
68
|
+
~name,
|
|
69
|
+
~indexes=[],
|
|
70
|
+
~partitionTag=Reventless.DcbTag.Simple({key: "k"}),
|
|
71
|
+
~opts,
|
|
72
|
+
)
|
|
73
|
+
await storage.operations->TestRunner.resolve
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let idOfEnvelope = (json: JSON.t) =>
|
|
77
|
+
json
|
|
78
|
+
->JSON.Decode.object
|
|
79
|
+
->Option.flatMap(d => d->Dict.get("id"))
|
|
80
|
+
->Option.flatMap(JSON.Decode.string)
|
|
81
|
+
->Option.getOr("<none>")
|
|
82
|
+
|
|
83
|
+
describe("ProjectionCheckpoint", () => {
|
|
84
|
+
describe("pending low-watermark", () => {
|
|
85
|
+
testPromise(
|
|
86
|
+
"out-of-order publish completion cannot advance past a pending earlier append",
|
|
87
|
+
async () => {
|
|
88
|
+
ProjectionPending.reset()
|
|
89
|
+
ProjectionPending.enableTracking()
|
|
90
|
+
module TestBus = LocalBus.Make()
|
|
91
|
+
module DbProvider = {
|
|
92
|
+
let db = makeFreshDb()
|
|
93
|
+
}
|
|
94
|
+
module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
|
|
95
|
+
let s = Storage.make(~name="agg", ~opts)
|
|
96
|
+
let ops = await s.operations->TestRunner.resolve
|
|
97
|
+
|
|
98
|
+
// Batch A (rowids 1-2) appended before batch B (rowid 3).
|
|
99
|
+
let _ = await ops.append(
|
|
100
|
+
0,
|
|
101
|
+
"id-1",
|
|
102
|
+
[
|
|
103
|
+
flatEvent(~id="id-1", ~seq=0, ~eventType="Created", ~msgId="a1"),
|
|
104
|
+
flatEvent(~id="id-1", ~seq=1, ~eventType="Renamed", ~msgId="a2"),
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
let _ = await ops.append(
|
|
108
|
+
0,
|
|
109
|
+
"id-2",
|
|
110
|
+
[flatEvent(~id="id-2", ~seq=0, ~eventType="Created", ~msgId="b1")],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
let db = DbProvider.db
|
|
114
|
+
ProjectionCheckpoint.setPosition(db, "RM", 0)
|
|
115
|
+
|
|
116
|
+
// B's publish completes first — the watermark must stay below A.
|
|
117
|
+
ProjectionCheckpoint.completePublished(db, ["b1"])
|
|
118
|
+
expect(ProjectionCheckpoint.getPosition(db, "RM"))->toBe(0)
|
|
119
|
+
|
|
120
|
+
// A completes — nothing pending, watermark = MAX(rowid).
|
|
121
|
+
ProjectionCheckpoint.completePublished(db, ["a1", "a2"])
|
|
122
|
+
expect(ProjectionCheckpoint.getPosition(db, "RM"))->toBe(3)
|
|
123
|
+
ProjectionPending.reset()
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
testPromise("appendStream batches never enter the pending set", async () => {
|
|
128
|
+
ProjectionPending.reset()
|
|
129
|
+
ProjectionPending.enableTracking()
|
|
130
|
+
module TestBus = LocalBus.Make()
|
|
131
|
+
module DbProvider = {
|
|
132
|
+
let db = makeFreshDb()
|
|
133
|
+
}
|
|
134
|
+
module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
|
|
135
|
+
let s = Storage.make(~name="agg", ~opts)
|
|
136
|
+
let ops = await s.operations->TestRunner.resolve
|
|
137
|
+
|
|
138
|
+
// Bulk replay path: no publish cycle will ever resolve these, so they
|
|
139
|
+
// must not pin the low-watermark.
|
|
140
|
+
let _ =
|
|
141
|
+
await ops.appendStream(
|
|
142
|
+
0,
|
|
143
|
+
"id-bulk",
|
|
144
|
+
Stream.fromIterable([
|
|
145
|
+
flatEvent(~id="id-bulk", ~seq=0, ~eventType="Created", ~msgId="s1"),
|
|
146
|
+
flatEvent(~id="id-bulk", ~seq=1, ~eventType="Renamed", ~msgId="s2"),
|
|
147
|
+
]),
|
|
148
|
+
)->Effect.runPromise
|
|
149
|
+
|
|
150
|
+
expect(ProjectionPending.minPending(ProjectionPending.Aggregate))->toEqual(None)
|
|
151
|
+
|
|
152
|
+
// With nothing pending the watermark covers the bulk rows.
|
|
153
|
+
let db = DbProvider.db
|
|
154
|
+
ProjectionCheckpoint.setPosition(db, "RM", 0)
|
|
155
|
+
ProjectionCheckpoint.completePublished(db, [])
|
|
156
|
+
expect(ProjectionCheckpoint.getPosition(db, "RM"))->toBe(2)
|
|
157
|
+
ProjectionPending.reset()
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe("catchupEnvelope", () => {
|
|
162
|
+
testSync("rebuilds the live {id, meta, event} envelope from a stored row", () => {
|
|
163
|
+
let flat = flatEvent(
|
|
164
|
+
~id="p-1",
|
|
165
|
+
~seq=4,
|
|
166
|
+
~eventType="Created",
|
|
167
|
+
~data=[("name", JSON.Encode.string("Widget"))],
|
|
168
|
+
~msgId="m-1",
|
|
169
|
+
)
|
|
170
|
+
switch ProjectionCheckpoint.catchupEnvelope(flat) {
|
|
171
|
+
| None => expect("envelope")->toBe("None")
|
|
172
|
+
| Some(envelope) =>
|
|
173
|
+
let dict = envelope->JSON.Decode.object->Option.getOrThrow
|
|
174
|
+
expect(dict->Dict.get("id"))->toEqual(Some(JSON.Encode.string("p-1")))
|
|
175
|
+
expect(msgIdOfEnvelope(envelope))->toBe("m-1")
|
|
176
|
+
// Payload-bearing variant: TAG + payload fields (splitMessage reversed).
|
|
177
|
+
expect(dict->Dict.get("event"))->toEqual(
|
|
178
|
+
Some(
|
|
179
|
+
JSON.Encode.object(
|
|
180
|
+
Dict.fromArray([
|
|
181
|
+
("TAG", JSON.Encode.string("Created")),
|
|
182
|
+
("name", JSON.Encode.string("Widget")),
|
|
183
|
+
]),
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
testSync("payload-less variants come back as a bare JSON string", () => {
|
|
191
|
+
let flat = flatEvent(~id="p-1", ~seq=0, ~eventType="Deleted", ~msgId="m-2")
|
|
192
|
+
let event =
|
|
193
|
+
ProjectionCheckpoint.catchupEnvelope(flat)
|
|
194
|
+
->Option.flatMap(JSON.Decode.object)
|
|
195
|
+
->Option.flatMap(d => d->Dict.get("event"))
|
|
196
|
+
expect(event)->toEqual(Some(JSON.Encode.string("Deleted")))
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
testSync("malformed stored rows yield None instead of throwing", () => {
|
|
200
|
+
// Missing the required meta fields (composeMeta would throw).
|
|
201
|
+
let flat = JSON.Encode.object(
|
|
202
|
+
Dict.fromArray([
|
|
203
|
+
("id", JSON.Encode.string("p-1")),
|
|
204
|
+
("event", JSON.Encode.string("Created")),
|
|
205
|
+
]),
|
|
206
|
+
)
|
|
207
|
+
expect(ProjectionCheckpoint.catchupEnvelope(flat))->toEqual(None)
|
|
208
|
+
expect(ProjectionCheckpoint.catchupEnvelope(JSON.Encode.string("junk")))->toEqual(None)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe("runCatchup", () => {
|
|
213
|
+
testPromise("delivers the missed range in rowid order, exactly once", async () => {
|
|
214
|
+
ProjectionPending.reset()
|
|
215
|
+
module TestBus = LocalBus.Make()
|
|
216
|
+
module DbProvider = {
|
|
217
|
+
let db = makeFreshDb()
|
|
218
|
+
}
|
|
219
|
+
module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
|
|
220
|
+
let s = Storage.make(~name="agg", ~opts)
|
|
221
|
+
let ops = await s.operations->TestRunner.resolve
|
|
222
|
+
let db = DbProvider.db
|
|
223
|
+
|
|
224
|
+
// Interleaved appends across two aggregates — global order is rowid
|
|
225
|
+
// order (m1, m2, m3), not per-aggregate grouping.
|
|
226
|
+
let _ = await ops.append(0, "id-1", [flatEvent(~id="id-1", ~seq=0, ~eventType="Created", ~msgId="m1")])
|
|
227
|
+
let _ = await ops.append(0, "id-2", [flatEvent(~id="id-2", ~seq=0, ~eventType="Created", ~msgId="m2")])
|
|
228
|
+
let _ = await ops.append(1, "id-1", [flatEvent(~id="id-1", ~seq=1, ~eventType="Renamed", ~msgId="m3")])
|
|
229
|
+
|
|
230
|
+
let (receivedA, handlerA) = makeHandler()
|
|
231
|
+
let (receivedB, handlerB) = makeHandler()
|
|
232
|
+
let upperBound = ProjectionCheckpoint.maxPosition(db, ProjectionPending.Aggregate)
|
|
233
|
+
expect(upperBound)->toBe(3)
|
|
234
|
+
|
|
235
|
+
await ProjectionCheckpoint.runCatchup(
|
|
236
|
+
~db,
|
|
237
|
+
~upperBound,
|
|
238
|
+
~dcbUpperBound=0,
|
|
239
|
+
~handlers=[("RM-A", handlerA), ("RM-B", handlerB)],
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
expect(receivedA->Array.map(msgIdOfEnvelope))->toEqual(["m1", "m2", "m3"])
|
|
243
|
+
expect(receivedB->Array.map(msgIdOfEnvelope))->toEqual(["m1", "m2", "m3"])
|
|
244
|
+
expect(ProjectionCheckpoint.getPosition(db, "RM-A"))->toBe(3)
|
|
245
|
+
expect(ProjectionCheckpoint.getPosition(db, "RM-B"))->toBe(3)
|
|
246
|
+
|
|
247
|
+
// Second startup: checkpoints are current — nothing is redelivered.
|
|
248
|
+
await ProjectionCheckpoint.runCatchup(
|
|
249
|
+
~db,
|
|
250
|
+
~upperBound=ProjectionCheckpoint.maxPosition(db, ProjectionPending.Aggregate),
|
|
251
|
+
~dcbUpperBound=0,
|
|
252
|
+
~handlers=[("RM-A", handlerA), ("RM-B", handlerB)],
|
|
253
|
+
)
|
|
254
|
+
expect(receivedA->Array.length)->toBe(3)
|
|
255
|
+
expect(receivedB->Array.length)->toBe(3)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
testPromise("a fresh database still stamps checkpoint rows at 0", async () => {
|
|
259
|
+
// Regression (caught by the platform smoke run): with upperBound = 0 and
|
|
260
|
+
// no events, runCatchup must still CREATE the rows — the runtime
|
|
261
|
+
// advanceAll only lifts existing rows, so a missing row would make every
|
|
262
|
+
// subsequent startup redeliver the whole history.
|
|
263
|
+
ProjectionPending.reset()
|
|
264
|
+
let db = makeFreshDb()
|
|
265
|
+
let (_, handler) = makeHandler()
|
|
266
|
+
await ProjectionCheckpoint.runCatchup(~db, ~upperBound=0, ~dcbUpperBound=0, ~handlers=[("RM-Fresh", handler)])
|
|
267
|
+
let stamped =
|
|
268
|
+
db
|
|
269
|
+
->SqliteDriver.prepare(
|
|
270
|
+
"SELECT COUNT(*) AS c FROM projection_checkpoint WHERE read_model = ?",
|
|
271
|
+
)
|
|
272
|
+
->SqliteDriver.get([JSON.Encode.string("RM-Fresh")])
|
|
273
|
+
expect(stamped->Option.flatMap(r => r->Dict.get("c")))->toEqual(
|
|
274
|
+
Some(JSON.Encode.float(1.0)),
|
|
275
|
+
)
|
|
276
|
+
expect(ProjectionCheckpoint.getPosition(db, "RM-Fresh"))->toBe(0)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
testPromise("a read model without a checkpoint row is seeded from history", async () => {
|
|
280
|
+
ProjectionPending.reset()
|
|
281
|
+
module TestBus = LocalBus.Make()
|
|
282
|
+
module DbProvider = {
|
|
283
|
+
let db = makeFreshDb()
|
|
284
|
+
}
|
|
285
|
+
module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
|
|
286
|
+
let s = Storage.make(~name="agg", ~opts)
|
|
287
|
+
let ops = await s.operations->TestRunner.resolve
|
|
288
|
+
let db = DbProvider.db
|
|
289
|
+
|
|
290
|
+
let _ = await ops.append(0, "id-1", [flatEvent(~id="id-1", ~seq=0, ~eventType="Created", ~msgId="m1")])
|
|
291
|
+
|
|
292
|
+
// First session knows only RM-A.
|
|
293
|
+
let (receivedA, handlerA) = makeHandler()
|
|
294
|
+
await ProjectionCheckpoint.runCatchup(
|
|
295
|
+
~db,
|
|
296
|
+
~upperBound=ProjectionCheckpoint.maxPosition(db, ProjectionPending.Aggregate),
|
|
297
|
+
~dcbUpperBound=0,
|
|
298
|
+
~handlers=[("RM-A", handlerA)],
|
|
299
|
+
)
|
|
300
|
+
expect(receivedA->Array.length)->toBe(1)
|
|
301
|
+
|
|
302
|
+
// A read model added later starts at 0 and receives the full history;
|
|
303
|
+
// the existing one stays quiet.
|
|
304
|
+
let (receivedNew, handlerNew) = makeHandler()
|
|
305
|
+
await ProjectionCheckpoint.runCatchup(
|
|
306
|
+
~db,
|
|
307
|
+
~upperBound=ProjectionCheckpoint.maxPosition(db, ProjectionPending.Aggregate),
|
|
308
|
+
~dcbUpperBound=0,
|
|
309
|
+
~handlers=[("RM-A", handlerA), ("RM-New", handlerNew)],
|
|
310
|
+
)
|
|
311
|
+
expect(receivedA->Array.length)->toBe(1)
|
|
312
|
+
expect(receivedNew->Array.map(msgIdOfEnvelope))->toEqual(["m1"])
|
|
313
|
+
expect(ProjectionCheckpoint.getPosition(db, "RM-New"))->toBe(1)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
describe("DCB axis", () => {
|
|
318
|
+
testPromise(
|
|
319
|
+
"catch-up delivers missed DCB events with the tag-derived entityId, exactly once",
|
|
320
|
+
async () => {
|
|
321
|
+
ProjectionPending.reset()
|
|
322
|
+
let db = makeFreshDb()
|
|
323
|
+
let dcbOps = await makeDcbStorage(db, ~name="OrderingDcbEventLog")
|
|
324
|
+
|
|
325
|
+
// First tag supplies the envelope id (publish-path parity); a tagless
|
|
326
|
+
// event falls back to the log name.
|
|
327
|
+
let _ = await dcbOps.append(
|
|
328
|
+
[
|
|
329
|
+
dcbEvent(
|
|
330
|
+
~eventType="OrderPlaced",
|
|
331
|
+
~tags=[{key: "orderId", value: "o-1"}, {key: "productId", value: "p-1"}],
|
|
332
|
+
~data=[("qty", JSON.Encode.int(2))],
|
|
333
|
+
~msgId="d1",
|
|
334
|
+
),
|
|
335
|
+
],
|
|
336
|
+
~condition=?None,
|
|
337
|
+
)
|
|
338
|
+
let _ = await dcbOps.append(
|
|
339
|
+
[dcbEvent(~eventType="MaintenanceRan", ~tags=[], ~msgId="d2")],
|
|
340
|
+
~condition=?None,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
let (received, handler) = makeHandler()
|
|
344
|
+
await ProjectionCheckpoint.runCatchup(
|
|
345
|
+
~db,
|
|
346
|
+
~upperBound=0,
|
|
347
|
+
~dcbUpperBound=ProjectionCheckpoint.maxPosition(db, ProjectionPending.Dcb),
|
|
348
|
+
~handlers=[("SV", handler)],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
expect(received->Array.map(msgIdOfEnvelope))->toEqual(["d1", "d2"])
|
|
352
|
+
expect(received->Array.map(idOfEnvelope))->toEqual(["o-1", "OrderingDcbEventLog"])
|
|
353
|
+
// The event payload is the same combineMessage form the live publish uses.
|
|
354
|
+
expect(
|
|
355
|
+
received
|
|
356
|
+
->Array.get(0)
|
|
357
|
+
->Option.flatMap(JSON.Decode.object)
|
|
358
|
+
->Option.flatMap(d => d->Dict.get("event")),
|
|
359
|
+
)->toEqual(
|
|
360
|
+
Some(
|
|
361
|
+
JSON.Encode.object(
|
|
362
|
+
Dict.fromArray([
|
|
363
|
+
("TAG", JSON.Encode.string("OrderPlaced")),
|
|
364
|
+
("qty", JSON.Encode.int(2)),
|
|
365
|
+
]),
|
|
366
|
+
),
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
// Axis rows are independent: the DCB row advanced, the aggregate row
|
|
370
|
+
// stamped at its own (empty) bound.
|
|
371
|
+
expect(ProjectionCheckpoint.getPosition(db, "dcb:SV"))->toBe(2)
|
|
372
|
+
expect(ProjectionCheckpoint.getPosition(db, "SV"))->toBe(0)
|
|
373
|
+
|
|
374
|
+
// Second startup: nothing redelivered.
|
|
375
|
+
await ProjectionCheckpoint.runCatchup(
|
|
376
|
+
~db,
|
|
377
|
+
~upperBound=0,
|
|
378
|
+
~dcbUpperBound=ProjectionCheckpoint.maxPosition(db, ProjectionPending.Dcb),
|
|
379
|
+
~handlers=[("SV", handler)],
|
|
380
|
+
)
|
|
381
|
+
expect(received->Array.length)->toBe(2)
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
testPromise("the two axes' watermarks are independent", async () => {
|
|
386
|
+
ProjectionPending.reset()
|
|
387
|
+
ProjectionPending.enableTracking()
|
|
388
|
+
module TestBus = LocalBus.Make()
|
|
389
|
+
module DbProvider = {
|
|
390
|
+
let db = makeFreshDb()
|
|
391
|
+
}
|
|
392
|
+
module AggStorage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
|
|
393
|
+
let aggS = AggStorage.make(~name="agg", ~opts)
|
|
394
|
+
let aggOps = await aggS.operations->TestRunner.resolve
|
|
395
|
+
let db = DbProvider.db
|
|
396
|
+
let dcbOps = await makeDcbStorage(db, ~name="dcb-log")
|
|
397
|
+
|
|
398
|
+
// One pending append on each axis.
|
|
399
|
+
let _ = await aggOps.append(
|
|
400
|
+
0,
|
|
401
|
+
"id-1",
|
|
402
|
+
[flatEvent(~id="id-1", ~seq=0, ~eventType="Created", ~msgId="agg1")],
|
|
403
|
+
)
|
|
404
|
+
let _ = await dcbOps.append(
|
|
405
|
+
[dcbEvent(~eventType="OrderPlaced", ~tags=[{key: "orderId", value: "o-1"}], ~msgId="dcb1")],
|
|
406
|
+
~condition=?None,
|
|
407
|
+
)
|
|
408
|
+
ProjectionCheckpoint.setPosition(db, "RM", 0)
|
|
409
|
+
ProjectionCheckpoint.setPosition(db, "dcb:RM", 0)
|
|
410
|
+
|
|
411
|
+
// Only the DCB batch completes: its axis advances, the aggregate axis
|
|
412
|
+
// stays capped by its own pending append.
|
|
413
|
+
ProjectionCheckpoint.completePublished(db, ["dcb1"])
|
|
414
|
+
expect(ProjectionCheckpoint.getPosition(db, "dcb:RM"))->toBe(1)
|
|
415
|
+
expect(ProjectionCheckpoint.getPosition(db, "RM"))->toBe(0)
|
|
416
|
+
|
|
417
|
+
ProjectionCheckpoint.completePublished(db, ["agg1"])
|
|
418
|
+
expect(ProjectionCheckpoint.getPosition(db, "RM"))->toBe(1)
|
|
419
|
+
ProjectionPending.reset()
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
})
|