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

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 (174) hide show
  1. package/CHANGELOG.md +30 -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 +12 -16
  15. package/src/adapter/Auth/LocalAuth.res.mjs +2 -1
  16. package/src/adapter/Auth/UserStore.res +13 -40
  17. package/src/adapter/Auth/UserStore.res.mjs +11 -67
  18. package/src/adapter/Backend.res +1 -2
  19. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +42 -26
  20. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +13 -3
  21. package/src/adapter/CommandTopic/LocalCommandTopicChannel.res +15 -17
  22. package/src/adapter/CommandTopic/LocalCommandTopicRemoteChannel.res +18 -22
  23. package/src/adapter/Counter/LocalCounterHandler.res +1 -1
  24. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +33 -22
  25. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +57 -57
  26. package/src/adapter/DomainGraphQL_Server.res +35 -34
  27. package/src/adapter/DomainMCP_Server.res +45 -41
  28. package/src/adapter/EventCollector/LocalEventCollectorChannel.res +47 -36
  29. package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +15 -12
  30. package/src/adapter/EventLog/EventLogStorage_InMemory.res +9 -4
  31. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +36 -24
  32. package/src/adapter/EventTopic/LocalEventTopicPublisher.res +9 -3
  33. package/src/adapter/Heartbeat/LocalHeartbeatRunner.res +9 -14
  34. package/src/adapter/LocalBus.res +12 -12
  35. package/src/adapter/LocalCapabilities.res +6 -0
  36. package/src/adapter/LocalCapabilities.res.mjs +5 -2
  37. package/src/adapter/LocalGeocodeResolvers.res +1 -4
  38. package/src/adapter/LocalSideEffectHandler.res +1 -1
  39. package/src/adapter/LocalStateChangeDescriptor.res +2 -1
  40. package/src/adapter/MCP_ServerInstance.res +51 -39
  41. package/src/adapter/ObjectStore/LocalObjectStore.res +5 -2
  42. package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res +1 -2
  43. package/src/adapter/PlatformGraphQL_Server.res +10 -6
  44. package/src/adapter/PlatformGraphQL_Server.res.mjs +2 -1
  45. package/src/adapter/ProjectionCheckpoint.res +8 -1
  46. package/src/adapter/ProjectionPending.res +1 -3
  47. package/src/adapter/QueryDb/LocalQueryDbStorage.res +2 -1
  48. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +367 -269
  49. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +5 -3
  50. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +63 -40
  51. package/src/adapter/QueryDb/SortKey_Filter.res +14 -6
  52. package/src/adapter/Runtime/LocalAggregateRuntime_Builder.res +12 -10
  53. package/src/adapter/Runtime/LocalEventCollectorRuntime_Builder.res +8 -2
  54. package/src/adapter/Runtime/LocalPluginRuntime_Builder.res +1 -4
  55. package/src/adapter/Scheduler/LocalScheduledPublisher.res +12 -12
  56. package/src/adapter/SqliteDriver.res +16 -4
  57. package/src/adapter/Task/TaskBucket_Sqlite.res +5 -6
  58. package/src/components/Aggregate_Builder.res +6 -1
  59. package/src/components/Counter_Builder.res +1 -5
  60. package/src/components/DcbEventLog_Builder.res +5 -3
  61. package/src/components/ExtensionPoint_Builder.res +13 -15
  62. package/src/components/InboundTranslationSlice_Builder.res +3 -2
  63. package/src/components/OutboundTranslationSlice_Builder.res +3 -2
  64. package/src/components/ReadModel_Builder.res +18 -20
  65. package/src/components/StateViewSlice_Builder.res +3 -2
  66. package/src/components/Task_Builder.res +12 -13
  67. package/src/reset/LocalSeedReset.res +2 -5
  68. package/src/test/Mocks/MockDcbEventLogStorage.res +5 -1
  69. package/src/test/Mocks/MockEventLogStorage.res +8 -3
  70. package/src/test/Mocks/MockQueryDbStorage.res +3 -2
  71. package/src/test/TestRunner.res +1 -1
  72. package/tests/BakedManifestFilesTest.res +7 -10
  73. package/tests/LocalEventTapTest.res +26 -21
  74. package/tests/LocalPlatformStartTest.res +33 -28
  75. package/tests/PluginEventDecodeTest.res +3 -3
  76. package/tests/ShellConfigTest.res +30 -21
  77. package/tests/SplitApiFixtures.res +34 -10
  78. package/tests/SplitApiTest.res +92 -62
  79. package/tests/adapter/BackendParityTest.res +180 -130
  80. package/tests/adapter/CollectNEventsTest.res +6 -4
  81. package/tests/adapter/CommandAuthorizationTest.res +47 -41
  82. package/tests/adapter/CommandTopicChannelTest.res +163 -139
  83. package/tests/adapter/CounterHandlerTest.res +95 -77
  84. package/tests/adapter/DcbEventLogStorageSqliteTest.res +171 -168
  85. package/tests/adapter/DcbEventLogStorageTest.res +264 -207
  86. package/tests/adapter/DomainGraphQL_ServerTest.res +53 -43
  87. package/tests/adapter/EventCollectorChannelTest.res +153 -124
  88. package/tests/adapter/EventHistoryResolverTest.res +46 -18
  89. package/tests/adapter/EventLogSnapshotParityTest.res +57 -45
  90. package/tests/adapter/EventLogStorageSqliteTest.res +9 -10
  91. package/tests/adapter/EventTapTest.res +37 -20
  92. package/tests/adapter/EventTopicPublisherTest.res +74 -50
  93. package/tests/adapter/GraphQLSubscriptionBridgeTest.res +4 -3
  94. package/tests/adapter/GraphQL_SchemaInspectorTest.res +666 -568
  95. package/tests/adapter/GraphQL_SubscriptionResolversTest.res +105 -96
  96. package/tests/adapter/HeartbeatRunnerTest.res +99 -81
  97. package/tests/adapter/InboundTranslationMutationTest.res +4 -1
  98. package/tests/adapter/LocalAuthHttpTest.res +79 -85
  99. package/tests/adapter/LocalAuthLoginTest.res +42 -50
  100. package/tests/adapter/LocalAuthTest.res +8 -16
  101. package/tests/adapter/LocalAuthUserStoreTest.res +2 -7
  102. package/tests/adapter/LocalBusBoundedTest.res +187 -117
  103. package/tests/adapter/LocalBusPubSubTest.res +118 -76
  104. package/tests/adapter/LocalDispatchContextTest.res +1 -2
  105. package/tests/adapter/LocalEvents_ServerTest.res +242 -187
  106. package/tests/adapter/PgProjectionCatchupTest.res +44 -10
  107. package/tests/adapter/ProjectionCheckpointTest.res +261 -206
  108. package/tests/adapter/QueryDbGsiTtlTest.res +123 -83
  109. package/tests/adapter/QueryDbListPushdownParityTest.res +107 -67
  110. package/tests/adapter/QueryDbListResolverTest.res +225 -208
  111. package/tests/adapter/QueryDbResolvedFieldsTest.res +17 -14
  112. package/tests/adapter/QueryDbStorageSqliteTest.res +33 -49
  113. package/tests/adapter/QueryDbStorageTest.res +163 -117
  114. package/tests/adapter/QueryDbUnionRoundTripTest.res +8 -10
  115. package/tests/adapter/QueryEngineTest.res +179 -126
  116. package/tests/adapter/ScheduledPublisherTest.res +171 -135
  117. package/tests/adapter/ServedBucketHttpTest.res +65 -59
  118. package/tests/adapter/SortKey_FilterTest.res +19 -3
  119. package/tests/adapter/SqliteDriverTest.res +13 -10
  120. package/tests/adapter/TaskBucketTest.res +94 -80
  121. package/tests/components/aggregate/AggregateTest.res +31 -18
  122. package/tests/components/automationslice/AutomationSliceCallbackTest.res +259 -222
  123. package/tests/components/automationslice/AutomationSliceFixtures.res +4 -2
  124. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +38 -38
  125. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +12 -14
  126. package/tests/components/automationslice/MixedSourceAutomationSliceTest.res +8 -32
  127. package/tests/components/commandgenerator/CommandGeneratorFixtures.res +7 -5
  128. package/tests/components/commandgenerator/CommandGeneratorTest.res +40 -31
  129. package/tests/components/commandtopic/CommandTopicFixtures.res +3 -4
  130. package/tests/components/commandtopic/CommandTopicStreamFixtures.res +7 -5
  131. package/tests/components/commandtopic/CommandTopicTest.res +24 -15
  132. package/tests/components/counter/CounterFixtures.res +6 -8
  133. package/tests/components/dcb/DcbCrossPartitionFixtures.res +8 -2
  134. package/tests/components/dcb/DcbCrossPartitionReadTest.res +20 -13
  135. package/tests/components/dcb/DcbEventLogAppendStreamTest.res +12 -8
  136. package/tests/components/dcb/DcbEventLogStreamTest.res +88 -61
  137. package/tests/components/dcb/DcbFixtures.res +4 -1
  138. package/tests/components/dcb/DcbTest.res +9 -6
  139. package/tests/components/eventcollector/EventCollectorFixtures.res +0 -1
  140. package/tests/components/eventcollector/EventCollectorTest.res +6 -3
  141. package/tests/components/eventlog/EventLogAppendStreamTest.res +88 -68
  142. package/tests/components/eventlog/EventLogConflictTest.res +8 -4
  143. package/tests/components/eventlog/EventLogFixtures.res +3 -3
  144. package/tests/components/eventlog/EventLogProvisioningSeamTest.res +26 -13
  145. package/tests/components/eventlog/EventLogStreamTest.res +119 -56
  146. package/tests/components/eventlog/EventLogTest.res +20 -4
  147. package/tests/components/eventtopic/EventTopicFixtures.res +3 -3
  148. package/tests/components/eventtopic/EventTopicStreamFixtures.res +1 -2
  149. package/tests/components/eventtopic/EventTopicStreamTest.res +7 -1
  150. package/tests/components/extensionpoint/ExtensionPointFixtures.res +14 -9
  151. package/tests/components/extensionpoint/ExtensionPointTest.res +3 -2
  152. package/tests/components/heartbeat/HeartbeatFixtures.res +5 -6
  153. package/tests/components/heartbeat/HeartbeatTest.res +56 -47
  154. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +271 -249
  155. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +3 -2
  156. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +361 -276
  157. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res +8 -10
  158. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +37 -43
  159. package/tests/components/querydb/QueryDbFixtures.res +1 -5
  160. package/tests/components/querydb/QueryDbTest.res +2 -4
  161. package/tests/components/readmodel/DcbReadModelE2EFixtures.res +15 -18
  162. package/tests/components/readmodel/MixedSourceReadModelFixtures.res +5 -7
  163. package/tests/components/readmodel/ReadModelFixtures.res +8 -8
  164. package/tests/components/scheduler/SchedulerTest.res +76 -58
  165. package/tests/components/sideeffecthandler/SideEffectHandlerFixtures.res +4 -4
  166. package/tests/components/stateviewslice/StateViewSliceDisplayNameFixtures.res +5 -7
  167. package/tests/components/stateviewslice/StateViewSliceFixtures.res +8 -6
  168. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res +17 -11
  169. package/tests/components/stateviewslice/StateViewSliceSubIdTest.res +12 -2
  170. package/tests/components/task/TaskFixtures.res +5 -5
  171. package/tests/components/task/TaskTest.res +95 -80
  172. package/tests/plugin/PluginsProjection_GWT.res +3 -1
  173. package/tests/plugin/UiFragments_GWT.res +6 -1
  174. 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
  })