@reventlessdev/reventless-local 3.0.0-alpha.249 → 3.0.0-alpha.250

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 (170) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +13 -13
  3. package/src/BakedManifest.res +5 -6
  4. package/src/EmitCapabilities.res +3 -1
  5. package/src/LocalPlatformRegistry.res +3 -1
  6. package/src/LocalPlatformStart.res +7 -6
  7. package/src/LocalSeedTarget.res +3 -1
  8. package/src/Platform.res +291 -215
  9. package/src/ShellConfig.res +10 -17
  10. package/src/UiHints.res +8 -14
  11. package/src/UiSlots.res +10 -17
  12. package/src/adapter/Api/LocalEvents_Server.res +5 -3
  13. package/src/adapter/Api/LocalGraphQL_Adapter.res +4 -4
  14. package/src/adapter/Auth/LocalAuth.res +11 -15
  15. package/src/adapter/Auth/UserStore.res +5 -7
  16. package/src/adapter/Backend.res +1 -2
  17. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +42 -26
  18. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +13 -3
  19. package/src/adapter/CommandTopic/LocalCommandTopicChannel.res +15 -17
  20. package/src/adapter/CommandTopic/LocalCommandTopicRemoteChannel.res +18 -22
  21. package/src/adapter/Counter/LocalCounterHandler.res +1 -1
  22. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +33 -22
  23. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +57 -57
  24. package/src/adapter/DomainGraphQL_Server.res +35 -34
  25. package/src/adapter/DomainMCP_Server.res +45 -41
  26. package/src/adapter/EventCollector/LocalEventCollectorChannel.res +47 -36
  27. package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +15 -12
  28. package/src/adapter/EventLog/EventLogStorage_InMemory.res +9 -4
  29. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +36 -24
  30. package/src/adapter/EventTopic/LocalEventTopicPublisher.res +9 -3
  31. package/src/adapter/Heartbeat/LocalHeartbeatRunner.res +9 -14
  32. package/src/adapter/LocalBus.res +12 -12
  33. package/src/adapter/LocalCapabilities.res.mjs +1 -1
  34. package/src/adapter/LocalGeocodeResolvers.res +1 -4
  35. package/src/adapter/LocalSideEffectHandler.res +1 -1
  36. package/src/adapter/LocalStateChangeDescriptor.res +2 -1
  37. package/src/adapter/MCP_ServerInstance.res +51 -39
  38. package/src/adapter/ObjectStore/LocalObjectStore.res +5 -2
  39. package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res +1 -2
  40. package/src/adapter/PlatformGraphQL_Server.res +9 -5
  41. package/src/adapter/ProjectionCheckpoint.res +8 -1
  42. package/src/adapter/ProjectionPending.res +1 -3
  43. package/src/adapter/QueryDb/LocalQueryDbStorage.res +2 -1
  44. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +367 -269
  45. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +5 -3
  46. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +63 -40
  47. package/src/adapter/QueryDb/SortKey_Filter.res +14 -6
  48. package/src/adapter/Runtime/LocalAggregateRuntime_Builder.res +12 -10
  49. package/src/adapter/Runtime/LocalEventCollectorRuntime_Builder.res +8 -2
  50. package/src/adapter/Runtime/LocalPluginRuntime_Builder.res +1 -4
  51. package/src/adapter/Scheduler/LocalScheduledPublisher.res +12 -12
  52. package/src/adapter/SqliteDriver.res +16 -4
  53. package/src/adapter/Task/TaskBucket_Sqlite.res +5 -6
  54. package/src/components/Aggregate_Builder.res +6 -1
  55. package/src/components/Counter_Builder.res +1 -5
  56. package/src/components/DcbEventLog_Builder.res +5 -3
  57. package/src/components/ExtensionPoint_Builder.res +13 -15
  58. package/src/components/InboundTranslationSlice_Builder.res +3 -2
  59. package/src/components/OutboundTranslationSlice_Builder.res +3 -2
  60. package/src/components/ReadModel_Builder.res +18 -20
  61. package/src/components/StateViewSlice_Builder.res +3 -2
  62. package/src/components/Task_Builder.res +12 -13
  63. package/src/reset/LocalSeedReset.res +2 -5
  64. package/src/test/Mocks/MockDcbEventLogStorage.res +5 -1
  65. package/src/test/Mocks/MockEventLogStorage.res +8 -3
  66. package/src/test/Mocks/MockQueryDbStorage.res +3 -2
  67. package/src/test/TestRunner.res +1 -1
  68. package/tests/BakedManifestFilesTest.res +7 -10
  69. package/tests/LocalEventTapTest.res +26 -21
  70. package/tests/LocalPlatformStartTest.res +33 -28
  71. package/tests/PluginEventDecodeTest.res +3 -3
  72. package/tests/ShellConfigTest.res +30 -21
  73. package/tests/SplitApiFixtures.res +34 -10
  74. package/tests/SplitApiTest.res +92 -62
  75. package/tests/adapter/BackendParityTest.res +180 -130
  76. package/tests/adapter/CollectNEventsTest.res +6 -4
  77. package/tests/adapter/CommandAuthorizationTest.res +47 -41
  78. package/tests/adapter/CommandTopicChannelTest.res +163 -139
  79. package/tests/adapter/CounterHandlerTest.res +95 -77
  80. package/tests/adapter/DcbEventLogStorageSqliteTest.res +171 -168
  81. package/tests/adapter/DcbEventLogStorageTest.res +264 -207
  82. package/tests/adapter/DomainGraphQL_ServerTest.res +53 -43
  83. package/tests/adapter/EventCollectorChannelTest.res +153 -124
  84. package/tests/adapter/EventHistoryResolverTest.res +46 -18
  85. package/tests/adapter/EventLogSnapshotParityTest.res +57 -45
  86. package/tests/adapter/EventLogStorageSqliteTest.res +9 -10
  87. package/tests/adapter/EventTapTest.res +37 -20
  88. package/tests/adapter/EventTopicPublisherTest.res +74 -50
  89. package/tests/adapter/GraphQLSubscriptionBridgeTest.res +4 -3
  90. package/tests/adapter/GraphQL_SchemaInspectorTest.res +666 -568
  91. package/tests/adapter/GraphQL_SubscriptionResolversTest.res +105 -96
  92. package/tests/adapter/HeartbeatRunnerTest.res +99 -81
  93. package/tests/adapter/InboundTranslationMutationTest.res +4 -1
  94. package/tests/adapter/LocalAuthHttpTest.res +79 -85
  95. package/tests/adapter/LocalAuthLoginTest.res +42 -50
  96. package/tests/adapter/LocalAuthTest.res +8 -16
  97. package/tests/adapter/LocalAuthUserStoreTest.res +2 -7
  98. package/tests/adapter/LocalBusBoundedTest.res +187 -117
  99. package/tests/adapter/LocalBusPubSubTest.res +118 -76
  100. package/tests/adapter/LocalDispatchContextTest.res +1 -2
  101. package/tests/adapter/LocalEvents_ServerTest.res +242 -187
  102. package/tests/adapter/PgProjectionCatchupTest.res +44 -10
  103. package/tests/adapter/ProjectionCheckpointTest.res +261 -206
  104. package/tests/adapter/QueryDbGsiTtlTest.res +123 -83
  105. package/tests/adapter/QueryDbListPushdownParityTest.res +107 -67
  106. package/tests/adapter/QueryDbListResolverTest.res +225 -208
  107. package/tests/adapter/QueryDbResolvedFieldsTest.res +17 -14
  108. package/tests/adapter/QueryDbStorageSqliteTest.res +33 -49
  109. package/tests/adapter/QueryDbStorageTest.res +163 -117
  110. package/tests/adapter/QueryDbUnionRoundTripTest.res +8 -10
  111. package/tests/adapter/QueryEngineTest.res +179 -126
  112. package/tests/adapter/ScheduledPublisherTest.res +171 -135
  113. package/tests/adapter/ServedBucketHttpTest.res +65 -59
  114. package/tests/adapter/SortKey_FilterTest.res +19 -3
  115. package/tests/adapter/SqliteDriverTest.res +13 -10
  116. package/tests/adapter/TaskBucketTest.res +94 -80
  117. package/tests/components/aggregate/AggregateTest.res +31 -18
  118. package/tests/components/automationslice/AutomationSliceCallbackTest.res +259 -222
  119. package/tests/components/automationslice/AutomationSliceFixtures.res +4 -2
  120. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +38 -38
  121. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +12 -14
  122. package/tests/components/automationslice/MixedSourceAutomationSliceTest.res +8 -32
  123. package/tests/components/commandgenerator/CommandGeneratorFixtures.res +7 -5
  124. package/tests/components/commandgenerator/CommandGeneratorTest.res +40 -31
  125. package/tests/components/commandtopic/CommandTopicFixtures.res +3 -4
  126. package/tests/components/commandtopic/CommandTopicStreamFixtures.res +7 -5
  127. package/tests/components/commandtopic/CommandTopicTest.res +24 -15
  128. package/tests/components/counter/CounterFixtures.res +6 -8
  129. package/tests/components/dcb/DcbCrossPartitionFixtures.res +8 -2
  130. package/tests/components/dcb/DcbCrossPartitionReadTest.res +20 -13
  131. package/tests/components/dcb/DcbEventLogAppendStreamTest.res +12 -8
  132. package/tests/components/dcb/DcbEventLogStreamTest.res +88 -61
  133. package/tests/components/dcb/DcbFixtures.res +4 -1
  134. package/tests/components/dcb/DcbTest.res +9 -6
  135. package/tests/components/eventcollector/EventCollectorFixtures.res +0 -1
  136. package/tests/components/eventcollector/EventCollectorTest.res +6 -3
  137. package/tests/components/eventlog/EventLogAppendStreamTest.res +88 -68
  138. package/tests/components/eventlog/EventLogConflictTest.res +8 -4
  139. package/tests/components/eventlog/EventLogFixtures.res +3 -3
  140. package/tests/components/eventlog/EventLogProvisioningSeamTest.res +26 -13
  141. package/tests/components/eventlog/EventLogStreamTest.res +119 -56
  142. package/tests/components/eventlog/EventLogTest.res +20 -4
  143. package/tests/components/eventtopic/EventTopicFixtures.res +3 -3
  144. package/tests/components/eventtopic/EventTopicStreamFixtures.res +1 -2
  145. package/tests/components/eventtopic/EventTopicStreamTest.res +7 -1
  146. package/tests/components/extensionpoint/ExtensionPointFixtures.res +14 -9
  147. package/tests/components/extensionpoint/ExtensionPointTest.res +3 -2
  148. package/tests/components/heartbeat/HeartbeatFixtures.res +5 -6
  149. package/tests/components/heartbeat/HeartbeatTest.res +56 -47
  150. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +271 -249
  151. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +3 -2
  152. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +361 -276
  153. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res +8 -10
  154. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +37 -43
  155. package/tests/components/querydb/QueryDbFixtures.res +1 -5
  156. package/tests/components/querydb/QueryDbTest.res +2 -4
  157. package/tests/components/readmodel/DcbReadModelE2EFixtures.res +15 -18
  158. package/tests/components/readmodel/MixedSourceReadModelFixtures.res +5 -7
  159. package/tests/components/readmodel/ReadModelFixtures.res +8 -8
  160. package/tests/components/scheduler/SchedulerTest.res +76 -58
  161. package/tests/components/sideeffecthandler/SideEffectHandlerFixtures.res +4 -4
  162. package/tests/components/stateviewslice/StateViewSliceDisplayNameFixtures.res +5 -7
  163. package/tests/components/stateviewslice/StateViewSliceFixtures.res +8 -6
  164. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res +17 -11
  165. package/tests/components/stateviewslice/StateViewSliceSubIdTest.res +12 -2
  166. package/tests/components/task/TaskFixtures.res +5 -5
  167. package/tests/components/task/TaskTest.res +95 -80
  168. package/tests/plugin/PluginsProjection_GWT.res +3 -1
  169. package/tests/plugin/UiFragments_GWT.res +6 -1
  170. package/tests/reset/LocalSeedResetTest.res +1 -0
@@ -50,7 +50,12 @@ let makeHandler = () => {
50
50
  }
51
51
 
52
52
  // A DCB raw stored event with a deterministic msgId.
53
- let dcbEvent = (~eventType, ~tags, ~data=[], ~msgId): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
53
+ let dcbEvent = (
54
+ ~eventType,
55
+ ~tags,
56
+ ~data=[],
57
+ ~msgId,
58
+ ): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
54
59
  eventType,
55
60
  data: JSON.Encode.object(Dict.fromArray(data)),
56
61
  tags,
@@ -124,21 +129,22 @@ describe("ProjectionCheckpoint", () => {
124
129
  },
125
130
  )
126
131
 
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", ~owner=None, ~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(
132
+ testPromise(
133
+ "appendStream batches never enter the pending set",
134
+ async () => {
135
+ ProjectionPending.reset()
136
+ ProjectionPending.enableTracking()
137
+ module TestBus = LocalBus.Make()
138
+ module DbProvider = {
139
+ let db = makeFreshDb()
140
+ }
141
+ module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
142
+ let s = Storage.make(~name="agg", ~owner=None, ~opts)
143
+ let ops = await s.operations->TestRunner.resolve
144
+
145
+ // Bulk replay path: no publish cycle will ever resolve these, so they
146
+ // must not pin the low-watermark.
147
+ let _ = await ops.appendStream(
142
148
  0,
143
149
  "id-bulk",
144
150
  Stream.fromIterable([
@@ -147,171 +153,211 @@ describe("ProjectionCheckpoint", () => {
147
153
  ]),
148
154
  )->Effect.runPromise
149
155
 
150
- expect(ProjectionPending.minPending(ProjectionPending.Aggregate))->toEqual(None)
156
+ expect(ProjectionPending.minPending(ProjectionPending.Aggregate))->toEqual(None)
151
157
 
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
- })
158
+ // With nothing pending the watermark covers the bulk rows.
159
+ let db = DbProvider.db
160
+ ProjectionCheckpoint.setPosition(db, "RM", 0)
161
+ ProjectionCheckpoint.completePublished(db, [])
162
+ expect(ProjectionCheckpoint.getPosition(db, "RM"))->toBe(2)
163
+ ProjectionPending.reset()
164
+ },
165
+ )
159
166
  })
160
167
 
161
168
  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
- ]),
169
+ testSync(
170
+ "rebuilds the live {id, meta, event} envelope from a stored row",
171
+ () => {
172
+ let flat = flatEvent(
173
+ ~id="p-1",
174
+ ~seq=4,
175
+ ~eventType="Created",
176
+ ~data=[("name", JSON.Encode.string("Widget"))],
177
+ ~msgId="m-1",
178
+ )
179
+ switch ProjectionCheckpoint.catchupEnvelope(flat) {
180
+ | None => expect("envelope")->toBe("None")
181
+ | Some(envelope) =>
182
+ let dict = envelope->JSON.Decode.object->Option.getOrThrow
183
+ expect(dict->Dict.get("id"))->toEqual(Some(JSON.Encode.string("p-1")))
184
+ expect(msgIdOfEnvelope(envelope))->toBe("m-1")
185
+ // Payload-bearing variant: TAG + payload fields (splitMessage reversed).
186
+ expect(dict->Dict.get("event"))->toEqual(
187
+ Some(
188
+ JSON.Encode.object(
189
+ Dict.fromArray([
190
+ ("TAG", JSON.Encode.string("Created")),
191
+ ("name", JSON.Encode.string("Widget")),
192
+ ]),
193
+ ),
184
194
  ),
185
- ),
195
+ )
196
+ }
197
+ },
198
+ )
199
+
200
+ testSync(
201
+ "payload-less variants come back as a bare JSON string",
202
+ () => {
203
+ let flat = flatEvent(~id="p-1", ~seq=0, ~eventType="Deleted", ~msgId="m-2")
204
+ let event =
205
+ ProjectionCheckpoint.catchupEnvelope(flat)
206
+ ->Option.flatMap(JSON.Decode.object)
207
+ ->Option.flatMap(d => d->Dict.get("event"))
208
+ expect(event)->toEqual(Some(JSON.Encode.string("Deleted")))
209
+ },
210
+ )
211
+
212
+ testSync(
213
+ "malformed stored rows yield None instead of throwing",
214
+ () => {
215
+ // Missing the required meta fields (composeMeta would throw).
216
+ let flat = JSON.Encode.object(
217
+ Dict.fromArray([
218
+ ("id", JSON.Encode.string("p-1")),
219
+ ("event", JSON.Encode.string("Created")),
220
+ ]),
186
221
  )
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
- })
222
+ expect(ProjectionCheckpoint.catchupEnvelope(flat))->toEqual(None)
223
+ expect(ProjectionCheckpoint.catchupEnvelope(JSON.Encode.string("junk")))->toEqual(None)
224
+ },
225
+ )
210
226
  })
211
227
 
212
228
  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", ~owner=None, ~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 = ?",
229
+ testPromise(
230
+ "delivers the missed range in rowid order, exactly once",
231
+ async () => {
232
+ ProjectionPending.reset()
233
+ module TestBus = LocalBus.Make()
234
+ module DbProvider = {
235
+ let db = makeFreshDb()
236
+ }
237
+ module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
238
+ let s = Storage.make(~name="agg", ~owner=None, ~opts)
239
+ let ops = await s.operations->TestRunner.resolve
240
+ let db = DbProvider.db
241
+
242
+ // Interleaved appends across two aggregates global order is rowid
243
+ // order (m1, m2, m3), not per-aggregate grouping.
244
+ let _ = await ops.append(
245
+ 0,
246
+ "id-1",
247
+ [flatEvent(~id="id-1", ~seq=0, ~eventType="Created", ~msgId="m1")],
248
+ )
249
+ let _ = await ops.append(
250
+ 0,
251
+ "id-2",
252
+ [flatEvent(~id="id-2", ~seq=0, ~eventType="Created", ~msgId="m2")],
271
253
  )
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 = {
254
+ let _ = await ops.append(
255
+ 1,
256
+ "id-1",
257
+ [flatEvent(~id="id-1", ~seq=1, ~eventType="Renamed", ~msgId="m3")],
258
+ )
259
+
260
+ let (receivedA, handlerA) = makeHandler()
261
+ let (receivedB, handlerB) = makeHandler()
262
+ let upperBound = ProjectionCheckpoint.maxPosition(db, ProjectionPending.Aggregate)
263
+ expect(upperBound)->toBe(3)
264
+
265
+ await ProjectionCheckpoint.runCatchup(
266
+ ~db,
267
+ ~upperBound,
268
+ ~dcbUpperBound=0,
269
+ ~handlers=[("RM-A", handlerA), ("RM-B", handlerB)],
270
+ )
271
+
272
+ expect(receivedA->Array.map(msgIdOfEnvelope))->toEqual(["m1", "m2", "m3"])
273
+ expect(receivedB->Array.map(msgIdOfEnvelope))->toEqual(["m1", "m2", "m3"])
274
+ expect(ProjectionCheckpoint.getPosition(db, "RM-A"))->toBe(3)
275
+ expect(ProjectionCheckpoint.getPosition(db, "RM-B"))->toBe(3)
276
+
277
+ // Second startup: checkpoints are current — nothing is redelivered.
278
+ await ProjectionCheckpoint.runCatchup(
279
+ ~db,
280
+ ~upperBound=ProjectionCheckpoint.maxPosition(db, ProjectionPending.Aggregate),
281
+ ~dcbUpperBound=0,
282
+ ~handlers=[("RM-A", handlerA), ("RM-B", handlerB)],
283
+ )
284
+ expect(receivedA->Array.length)->toBe(3)
285
+ expect(receivedB->Array.length)->toBe(3)
286
+ },
287
+ )
288
+
289
+ testPromise(
290
+ "a fresh database still stamps checkpoint rows at 0",
291
+ async () => {
292
+ // Regression (caught by the platform smoke run): with upperBound = 0 and
293
+ // no events, runCatchup must still CREATE the rows — the runtime
294
+ // advanceAll only lifts existing rows, so a missing row would make every
295
+ // subsequent startup redeliver the whole history.
296
+ ProjectionPending.reset()
283
297
  let db = makeFreshDb()
284
- }
285
- module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
286
- let s = Storage.make(~name="agg", ~owner=None, ~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
- })
298
+ let (_, handler) = makeHandler()
299
+ await ProjectionCheckpoint.runCatchup(
300
+ ~db,
301
+ ~upperBound=0,
302
+ ~dcbUpperBound=0,
303
+ ~handlers=[("RM-Fresh", handler)],
304
+ )
305
+ let stamped =
306
+ db
307
+ ->SqliteDriver.prepare(
308
+ "SELECT COUNT(*) AS c FROM projection_checkpoint WHERE read_model = ?",
309
+ )
310
+ ->SqliteDriver.get([JSON.Encode.string("RM-Fresh")])
311
+ expect(stamped->Option.flatMap(r => r->Dict.get("c")))->toEqual(
312
+ Some(JSON.Encode.float(1.0)),
313
+ )
314
+ expect(ProjectionCheckpoint.getPosition(db, "RM-Fresh"))->toBe(0)
315
+ },
316
+ )
317
+
318
+ testPromise(
319
+ "a read model without a checkpoint row is seeded from history",
320
+ async () => {
321
+ ProjectionPending.reset()
322
+ module TestBus = LocalBus.Make()
323
+ module DbProvider = {
324
+ let db = makeFreshDb()
325
+ }
326
+ module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
327
+ let s = Storage.make(~name="agg", ~owner=None, ~opts)
328
+ let ops = await s.operations->TestRunner.resolve
329
+ let db = DbProvider.db
330
+
331
+ let _ = await ops.append(
332
+ 0,
333
+ "id-1",
334
+ [flatEvent(~id="id-1", ~seq=0, ~eventType="Created", ~msgId="m1")],
335
+ )
336
+
337
+ // First session knows only RM-A.
338
+ let (receivedA, handlerA) = makeHandler()
339
+ await ProjectionCheckpoint.runCatchup(
340
+ ~db,
341
+ ~upperBound=ProjectionCheckpoint.maxPosition(db, ProjectionPending.Aggregate),
342
+ ~dcbUpperBound=0,
343
+ ~handlers=[("RM-A", handlerA)],
344
+ )
345
+ expect(receivedA->Array.length)->toBe(1)
346
+
347
+ // A read model added later starts at 0 and receives the full history;
348
+ // the existing one stays quiet.
349
+ let (receivedNew, handlerNew) = makeHandler()
350
+ await ProjectionCheckpoint.runCatchup(
351
+ ~db,
352
+ ~upperBound=ProjectionCheckpoint.maxPosition(db, ProjectionPending.Aggregate),
353
+ ~dcbUpperBound=0,
354
+ ~handlers=[("RM-A", handlerA), ("RM-New", handlerNew)],
355
+ )
356
+ expect(receivedA->Array.length)->toBe(1)
357
+ expect(receivedNew->Array.map(msgIdOfEnvelope))->toEqual(["m1"])
358
+ expect(ProjectionCheckpoint.getPosition(db, "RM-New"))->toBe(1)
359
+ },
360
+ )
315
361
  })
316
362
 
317
363
  describe("DCB axis", () => {
@@ -382,41 +428,50 @@ describe("ProjectionCheckpoint", () => {
382
428
  },
383
429
  )
384
430
 
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", ~owner=None, ~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
- })
431
+ testPromise(
432
+ "the two axes' watermarks are independent",
433
+ async () => {
434
+ ProjectionPending.reset()
435
+ ProjectionPending.enableTracking()
436
+ module TestBus = LocalBus.Make()
437
+ module DbProvider = {
438
+ let db = makeFreshDb()
439
+ }
440
+ module AggStorage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
441
+ let aggS = AggStorage.make(~name="agg", ~owner=None, ~opts)
442
+ let aggOps = await aggS.operations->TestRunner.resolve
443
+ let db = DbProvider.db
444
+ let dcbOps = await makeDcbStorage(db, ~name="dcb-log")
445
+
446
+ // One pending append on each axis.
447
+ let _ = await aggOps.append(
448
+ 0,
449
+ "id-1",
450
+ [flatEvent(~id="id-1", ~seq=0, ~eventType="Created", ~msgId="agg1")],
451
+ )
452
+ let _ = await dcbOps.append(
453
+ [
454
+ dcbEvent(
455
+ ~eventType="OrderPlaced",
456
+ ~tags=[{key: "orderId", value: "o-1"}],
457
+ ~msgId="dcb1",
458
+ ),
459
+ ],
460
+ ~condition=?None,
461
+ )
462
+ ProjectionCheckpoint.setPosition(db, "RM", 0)
463
+ ProjectionCheckpoint.setPosition(db, "dcb:RM", 0)
464
+
465
+ // Only the DCB batch completes: its axis advances, the aggregate axis
466
+ // stays capped by its own pending append.
467
+ ProjectionCheckpoint.completePublished(db, ["dcb1"])
468
+ expect(ProjectionCheckpoint.getPosition(db, "dcb:RM"))->toBe(1)
469
+ expect(ProjectionCheckpoint.getPosition(db, "RM"))->toBe(0)
470
+
471
+ ProjectionCheckpoint.completePublished(db, ["agg1"])
472
+ expect(ProjectionCheckpoint.getPosition(db, "RM"))->toBe(1)
473
+ ProjectionPending.reset()
474
+ },
475
+ )
421
476
  })
422
477
  })