@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
@@ -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
+ })