@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
@@ -33,198 +33,253 @@ describe("LocalEvents_Server", () => {
33
33
  beforeEach(() => LocalEvents_Server.resetConnections())
34
34
 
35
35
  describe("channelMatches", () => {
36
- testSync("exact match", () => {
37
- expect(
38
- LocalEvents_Server.channelMatches(
39
- ~subscription="/default/Product/p-1",
40
- ~channel="/default/Product/p-1",
41
- ),
42
- )->toBe(true)
43
- })
44
- testSync("wildcard prefix matches any depth", () => {
45
- expect(
46
- LocalEvents_Server.channelMatches(
47
- ~subscription="/default/Product/*",
48
- ~channel="/default/Product/p-1",
49
- ),
50
- )->toBe(true)
51
- expect(
52
- LocalEvents_Server.channelMatches(
53
- ~subscription="/default/*",
54
- ~channel="/default/Product/p-1/deep",
55
- ),
56
- )->toBe(true)
57
- })
58
- testSync("wildcard does not match a sibling with the same stem", () => {
59
- expect(
60
- LocalEvents_Server.channelMatches(
61
- ~subscription="/default/Product/*",
62
- ~channel="/default/ProductArchive/p-1",
63
- ),
64
- )->toBe(false)
65
- })
66
- testSync("non-wildcard mismatch", () => {
67
- expect(
68
- LocalEvents_Server.channelMatches(
69
- ~subscription="/default/Product/p-1",
70
- ~channel="/default/Product/p-2",
71
- ),
72
- )->toBe(false)
73
- })
36
+ testSync(
37
+ "exact match",
38
+ () => {
39
+ expect(
40
+ LocalEvents_Server.channelMatches(
41
+ ~subscription="/default/Product/p-1",
42
+ ~channel="/default/Product/p-1",
43
+ ),
44
+ )->toBe(true)
45
+ },
46
+ )
47
+ testSync(
48
+ "wildcard prefix matches any depth",
49
+ () => {
50
+ expect(
51
+ LocalEvents_Server.channelMatches(
52
+ ~subscription="/default/Product/*",
53
+ ~channel="/default/Product/p-1",
54
+ ),
55
+ )->toBe(true)
56
+ expect(
57
+ LocalEvents_Server.channelMatches(
58
+ ~subscription="/default/*",
59
+ ~channel="/default/Product/p-1/deep",
60
+ ),
61
+ )->toBe(true)
62
+ },
63
+ )
64
+ testSync(
65
+ "wildcard does not match a sibling with the same stem",
66
+ () => {
67
+ expect(
68
+ LocalEvents_Server.channelMatches(
69
+ ~subscription="/default/Product/*",
70
+ ~channel="/default/ProductArchive/p-1",
71
+ ),
72
+ )->toBe(false)
73
+ },
74
+ )
75
+ testSync(
76
+ "non-wildcard mismatch",
77
+ () => {
78
+ expect(
79
+ LocalEvents_Server.channelMatches(
80
+ ~subscription="/default/Product/p-1",
81
+ ~channel="/default/Product/p-2",
82
+ ),
83
+ )->toBe(false)
84
+ },
85
+ )
74
86
  })
75
87
 
76
88
  describe("subscribe protocol", () => {
77
- testSync("connection_init is answered with connection_ack", () => {
78
- let conn = makeConn()
79
- LocalEvents_Server.handleFrame(conn, `{"type":"connection_init"}`)
80
- expect(sentFrames.contents->Array.length)->toBe(1)
81
- expect(sentFrames.contents->Array.getUnsafe(0)->frameField("type"))->toEqual(
82
- Some("connection_ack"),
83
- )
84
- })
85
-
86
- testSync("subscribe → state change → data frame on the subscription id", () => {
87
- let conn = makeConn()
88
- LocalEvents_Server.handleFrame(
89
- conn,
90
- `{"type":"subscribe","id":"sub-1","channel":"/default/Product/*"}`,
91
- )
92
- expect(sentFrames.contents->Array.getUnsafe(0)->frameField("type"))->toEqual(
93
- Some("subscribe_success"),
94
- )
95
- LocalEvents_Server.broadcastStateChange(~name="Product", ~descriptor=descriptor(~id="p-1"))
96
- expect(sentFrames.contents->Array.length)->toBe(2)
97
- let data = sentFrames.contents->Array.getUnsafe(1)
98
- expect(data->frameField("type"))->toEqual(Some("data"))
99
- expect(data->frameField("id"))->toEqual(Some("sub-1"))
100
- // `event` is a stringified JSON payload — parse the string to verify.
101
- let event =
102
- data->frameField("event")->Option.getOr("")->JSON.parseOrThrow->JSON.Decode.object
103
- expect(
104
- event->Option.flatMap(o => o->Dict.get("id"))->Option.flatMap(JSON.Decode.string),
105
- )->toEqual(Some("p-1"))
106
- })
107
-
108
- testSync("channel segments are normalized like the AWS publisher", () => {
109
- let conn = makeConn()
110
- LocalEvents_Server.handleFrame(
111
- conn,
112
- `{"type":"subscribe","id":"s","channel":"/default/My-Model/order-1-2026"}`,
113
- )
114
- // Read-model name `My.Model` and entity key `order#1@2026` normalize to
115
- // the subscribed channel (`[^A-Za-z0-9-]` `-`).
116
- LocalEvents_Server.broadcastStateChange(
117
- ~name="My.Model",
118
- ~descriptor=descriptor(~id="order#1@2026"),
119
- )
120
- expect(sentFrames.contents->Array.length)->toBe(2)
121
- })
122
-
123
- testSync("publishes on the plugin-qualified list field, not the store name", () => {
124
- // The channel root the client subscribes on is the read model's
125
- // `listFieldName` ("Catalog_Products"), not its store name ("Products").
126
- // The publish must translate through the same registry the AWS StateTopic
127
- // wiring uses — otherwise the descriptor lands on a channel no one listens
128
- // to and the socket stays Open while no view refreshes.
129
- let registry = ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry
130
- registry->Dict.set(
131
- "Products",
132
- {
133
- ReventlessCore.Api_Naming.singleFieldName: "Catalog_Product",
134
- listFieldName: "Catalog_Products",
135
- returnTypeName: "Catalog_Product",
136
- pluralTypeName: "Catalog_Products",
137
- includeIdParam: false,
138
- connectionSpec: true,
139
- },
140
- )
141
- let conn = makeConn()
142
- LocalEvents_Server.handleFrame(
143
- conn,
144
- `{"type":"subscribe","id":"s","channel":"/default/Catalog-Products/*"}`,
145
- )
146
- LocalEvents_Server.broadcastStateChange(~name="Products", ~descriptor=descriptor(~id="SKU-1"))
147
- // subscribe_success + a data frame ⇒ the descriptor reached the client's channel
148
- expect(sentFrames.contents->Array.length)->toBe(2)
149
- expect(sentFrames.contents->Array.getUnsafe(1)->frameField("type"))->toEqual(Some("data"))
150
- registry->Dict.delete("Products")
151
- })
152
-
153
- testSync("unsubscribe stops delivery", () => {
154
- let conn = makeConn()
155
- LocalEvents_Server.handleFrame(
156
- conn,
157
- `{"type":"subscribe","id":"sub-1","channel":"/default/Product/*"}`,
158
- )
159
- LocalEvents_Server.handleFrame(conn, `{"type":"unsubscribe","id":"sub-1"}`)
160
- LocalEvents_Server.broadcastStateChange(~name="Product", ~descriptor=descriptor(~id="p-1"))
161
- // subscribe_success + unsubscribe_success, but no data frame
162
- expect(sentFrames.contents->Array.length)->toBe(2)
163
- expect(sentFrames.contents->Array.getUnsafe(1)->frameField("type"))->toEqual(
164
- Some("unsubscribe_success"),
165
- )
166
- })
167
-
168
- testSync("malformed and unknown frames are ignored", () => {
169
- let conn = makeConn()
170
- LocalEvents_Server.handleFrame(conn, `not json`)
171
- LocalEvents_Server.handleFrame(conn, `{"type":"mystery"}`)
172
- LocalEvents_Server.handleFrame(conn, `{"type":"subscribe","id":"only-id"}`)
173
- expect(sentFrames.contents->Array.length)->toBe(0)
174
- })
89
+ testSync(
90
+ "connection_init is answered with connection_ack",
91
+ () => {
92
+ let conn = makeConn()
93
+ LocalEvents_Server.handleFrame(conn, `{"type":"connection_init"}`)
94
+ expect(sentFrames.contents->Array.length)->toBe(1)
95
+ expect(sentFrames.contents->Array.getUnsafe(0)->frameField("type"))->toEqual(
96
+ Some("connection_ack"),
97
+ )
98
+ },
99
+ )
100
+
101
+ testSync(
102
+ "subscribe → state change → data frame on the subscription id",
103
+ () => {
104
+ let conn = makeConn()
105
+ LocalEvents_Server.handleFrame(
106
+ conn,
107
+ `{"type":"subscribe","id":"sub-1","channel":"/default/Product/*"}`,
108
+ )
109
+ expect(sentFrames.contents->Array.getUnsafe(0)->frameField("type"))->toEqual(
110
+ Some("subscribe_success"),
111
+ )
112
+ LocalEvents_Server.broadcastStateChange(~name="Product", ~descriptor=descriptor(~id="p-1"))
113
+ expect(sentFrames.contents->Array.length)->toBe(2)
114
+ let data = sentFrames.contents->Array.getUnsafe(1)
115
+ expect(data->frameField("type"))->toEqual(Some("data"))
116
+ expect(data->frameField("id"))->toEqual(Some("sub-1"))
117
+ // `event` is a stringified JSON payload — parse the string to verify.
118
+ let event =
119
+ data->frameField("event")->Option.getOr("")->JSON.parseOrThrow->JSON.Decode.object
120
+ expect(
121
+ event->Option.flatMap(o => o->Dict.get("id"))->Option.flatMap(JSON.Decode.string),
122
+ )->toEqual(Some("p-1"))
123
+ },
124
+ )
125
+
126
+ testSync(
127
+ "channel segments are normalized like the AWS publisher",
128
+ () => {
129
+ let conn = makeConn()
130
+ LocalEvents_Server.handleFrame(
131
+ conn,
132
+ `{"type":"subscribe","id":"s","channel":"/default/My-Model/order-1-2026"}`,
133
+ )
134
+ // Read-model name `My.Model` and entity key `order#1@2026` normalize to
135
+ // the subscribed channel (`[^A-Za-z0-9-]` `-`).
136
+ LocalEvents_Server.broadcastStateChange(
137
+ ~name="My.Model",
138
+ ~descriptor=descriptor(~id="order#1@2026"),
139
+ )
140
+ expect(sentFrames.contents->Array.length)->toBe(2)
141
+ },
142
+ )
143
+
144
+ testSync(
145
+ "publishes on the plugin-qualified list field, not the store name",
146
+ () => {
147
+ // The channel root the client subscribes on is the read model's
148
+ // `listFieldName` ("Catalog_Products"), not its store name ("Products").
149
+ // The publish must translate through the same registry the AWS StateTopic
150
+ // wiring uses — otherwise the descriptor lands on a channel no one listens
151
+ // to and the socket stays Open while no view refreshes.
152
+ let registry = ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry
153
+ registry->Dict.set(
154
+ "Products",
155
+ {
156
+ ReventlessCore.Api_Naming.singleFieldName: "Catalog_Product",
157
+ listFieldName: "Catalog_Products",
158
+ returnTypeName: "Catalog_Product",
159
+ pluralTypeName: "Catalog_Products",
160
+ includeIdParam: false,
161
+ connectionSpec: true,
162
+ },
163
+ )
164
+ let conn = makeConn()
165
+ LocalEvents_Server.handleFrame(
166
+ conn,
167
+ `{"type":"subscribe","id":"s","channel":"/default/Catalog-Products/*"}`,
168
+ )
169
+ LocalEvents_Server.broadcastStateChange(
170
+ ~name="Products",
171
+ ~descriptor=descriptor(~id="SKU-1"),
172
+ )
173
+ // subscribe_success + a data frame the descriptor reached the client's channel
174
+ expect(sentFrames.contents->Array.length)->toBe(2)
175
+ expect(sentFrames.contents->Array.getUnsafe(1)->frameField("type"))->toEqual(Some("data"))
176
+ registry->Dict.delete("Products")
177
+ },
178
+ )
179
+
180
+ testSync(
181
+ "unsubscribe stops delivery",
182
+ () => {
183
+ let conn = makeConn()
184
+ LocalEvents_Server.handleFrame(
185
+ conn,
186
+ `{"type":"subscribe","id":"sub-1","channel":"/default/Product/*"}`,
187
+ )
188
+ LocalEvents_Server.handleFrame(conn, `{"type":"unsubscribe","id":"sub-1"}`)
189
+ LocalEvents_Server.broadcastStateChange(~name="Product", ~descriptor=descriptor(~id="p-1"))
190
+ // subscribe_success + unsubscribe_success, but no data frame
191
+ expect(sentFrames.contents->Array.length)->toBe(2)
192
+ expect(sentFrames.contents->Array.getUnsafe(1)->frameField("type"))->toEqual(
193
+ Some("unsubscribe_success"),
194
+ )
195
+ },
196
+ )
197
+
198
+ testSync(
199
+ "malformed and unknown frames are ignored",
200
+ () => {
201
+ let conn = makeConn()
202
+ LocalEvents_Server.handleFrame(conn, `not json`)
203
+ LocalEvents_Server.handleFrame(conn, `{"type":"mystery"}`)
204
+ LocalEvents_Server.handleFrame(conn, `{"type":"subscribe","id":"only-id"}`)
205
+ expect(sentFrames.contents->Array.length)->toBe(0)
206
+ },
207
+ )
175
208
  })
176
209
 
177
210
  describe("handlePublish", () => {
178
- testSync("rejects non-client channels with 403", () => {
179
- let (status, _) = LocalEvents_Server.handlePublish(
180
- ~authorization=None,
181
- ~body=`{"channel":"/default/Product/p-1","events":["{}"]}`,
182
- )
183
- expect(status)->toBe(403)
184
- })
185
-
186
- testSync("rejects an unverifiable token with 401", () => {
187
- let (status, _) = LocalEvents_Server.handlePublish(
188
- ~authorization=Some("garbage-token"),
189
- ~body=`{"channel":"/client/x","events":["{}"]}`,
190
- )
191
- expect(status)->toBe(401)
192
- })
193
-
194
- testSync("rejects malformed bodies with 400", () => {
195
- let (s1, _) = LocalEvents_Server.handlePublish(~authorization=None, ~body=`nope`)
196
- let (s2, _) = LocalEvents_Server.handlePublish(
197
- ~authorization=None,
198
- ~body=`{"channel":"/client/x","events":[]}`,
199
- )
200
- expect(s1)->toBe(400)
201
- expect(s2)->toBe(400)
202
- })
203
-
204
- testSync("fans out to a wildcard subscriber and accounts per event", () => {
205
- let conn = makeConn()
206
- LocalEvents_Server.handleFrame(
207
- conn,
208
- `{"type":"subscribe","id":"sub-p","channel":"/client/shop/presence/*"}`,
209
- )
210
- let (status, response) = LocalEvents_Server.handlePublish(
211
- ~authorization=None,
212
- ~body=`{"channel":"/client/shop/presence/room-1","events":["{\\"userId\\":\\"u1\\"}","not json",42]}`,
213
- )
214
- expect(status)->toBe(200)
215
- let counts =
216
- response
217
- ->JSON.Decode.object
218
- ->Option.map(o => (
219
- o->Dict.get("successful")->Option.flatMap(JSON.Decode.array)->Option.getOr([])->Array.length,
220
- o->Dict.get("failed")->Option.flatMap(JSON.Decode.array)->Option.getOr([])->Array.length,
221
- ))
222
- expect(counts)->toEqual(Some((1, 2)))
223
- // subscribe_success + one data frame for the one valid event
224
- expect(sentFrames.contents->Array.length)->toBe(2)
225
- let data = sentFrames.contents->Array.getUnsafe(1)
226
- expect(data->frameField("id"))->toEqual(Some("sub-p"))
227
- expect(data->frameField("event"))->toEqual(Some(`{"userId":"u1"}`))
228
- })
211
+ testSync(
212
+ "rejects non-client channels with 403",
213
+ () => {
214
+ let (status, _) = LocalEvents_Server.handlePublish(
215
+ ~authorization=None,
216
+ ~body=`{"channel":"/default/Product/p-1","events":["{}"]}`,
217
+ )
218
+ expect(status)->toBe(403)
219
+ },
220
+ )
221
+
222
+ testSync(
223
+ "rejects an unverifiable token with 401",
224
+ () => {
225
+ let (status, _) = LocalEvents_Server.handlePublish(
226
+ ~authorization=Some("garbage-token"),
227
+ ~body=`{"channel":"/client/x","events":["{}"]}`,
228
+ )
229
+ expect(status)->toBe(401)
230
+ },
231
+ )
232
+
233
+ testSync(
234
+ "rejects malformed bodies with 400",
235
+ () => {
236
+ let (s1, _) = LocalEvents_Server.handlePublish(~authorization=None, ~body=`nope`)
237
+ let (s2, _) = LocalEvents_Server.handlePublish(
238
+ ~authorization=None,
239
+ ~body=`{"channel":"/client/x","events":[]}`,
240
+ )
241
+ expect(s1)->toBe(400)
242
+ expect(s2)->toBe(400)
243
+ },
244
+ )
245
+
246
+ testSync(
247
+ "fans out to a wildcard subscriber and accounts per event",
248
+ () => {
249
+ let conn = makeConn()
250
+ LocalEvents_Server.handleFrame(
251
+ conn,
252
+ `{"type":"subscribe","id":"sub-p","channel":"/client/shop/presence/*"}`,
253
+ )
254
+ let (status, response) = LocalEvents_Server.handlePublish(
255
+ ~authorization=None,
256
+ ~body=`{"channel":"/client/shop/presence/room-1","events":["{\\"userId\\":\\"u1\\"}","not json",42]}`,
257
+ )
258
+ expect(status)->toBe(200)
259
+ let counts =
260
+ response
261
+ ->JSON.Decode.object
262
+ ->Option.map(
263
+ o => (
264
+ o
265
+ ->Dict.get("successful")
266
+ ->Option.flatMap(JSON.Decode.array)
267
+ ->Option.getOr([])
268
+ ->Array.length,
269
+ o
270
+ ->Dict.get("failed")
271
+ ->Option.flatMap(JSON.Decode.array)
272
+ ->Option.getOr([])
273
+ ->Array.length,
274
+ ),
275
+ )
276
+ expect(counts)->toEqual(Some((1, 2)))
277
+ // subscribe_success + one data frame for the one valid event
278
+ expect(sentFrames.contents->Array.length)->toBe(2)
279
+ let data = sentFrames.contents->Array.getUnsafe(1)
280
+ expect(data->frameField("id"))->toEqual(Some("sub-p"))
281
+ expect(data->frameField("event"))->toEqual(Some(`{"userId":"u1"}`))
282
+ },
283
+ )
229
284
  })
230
285
  })
@@ -29,7 +29,12 @@ let flatEvent = (~id, ~seq, ~eventType, ~data=[], ~msgId, ~service="Products") =
29
29
  ]),
30
30
  )
31
31
 
32
- let dcbEvent = (~eventType, ~tags, ~data=[], ~msgId): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
32
+ let dcbEvent = (
33
+ ~eventType,
34
+ ~tags,
35
+ ~data=[],
36
+ ~msgId,
37
+ ): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
33
38
  eventType,
34
39
  data: JSON.Encode.object(Dict.fromArray(data)),
35
40
  tags,
@@ -58,7 +63,8 @@ let recorder = () => {
58
63
  }
59
64
 
60
65
  switch NodeProcess.env->Dict.get("PG_URL") {
61
- | None => testSync("Postgres projection catch-up (skipped — set PG_URL)", () => expect(true)->toBe(true))
66
+ | None =>
67
+ testSync("Postgres projection catch-up (skipped — set PG_URL)", () => expect(true)->toBe(true))
62
68
  | Some(url) =>
63
69
  let pool = ReventlessPostgres.PgDriver.makePool({connectionString: url})
64
70
 
@@ -91,12 +97,24 @@ switch NodeProcess.env->Dict.get("PG_URL") {
91
97
  testPromise("replays every persisted classic + DCB event as an envelope", async () => {
92
98
  await reset()
93
99
  let agg = classicOps()
94
- let _ = await agg.append(0, "p1", [flatEvent(~id="p1", ~seq=0, ~eventType="Added", ~msgId="a0")])
95
- let _ = await agg.append(1, "p1", [flatEvent(~id="p1", ~seq=1, ~eventType="Renamed", ~msgId="a1")])
100
+ let _ = await agg.append(
101
+ 0,
102
+ "p1",
103
+ [flatEvent(~id="p1", ~seq=0, ~eventType="Added", ~msgId="a0")],
104
+ )
105
+ let _ = await agg.append(
106
+ 1,
107
+ "p1",
108
+ [flatEvent(~id="p1", ~seq=1, ~eventType="Renamed", ~msgId="a1")],
109
+ )
96
110
 
97
111
  let dcb = dcbOps()
98
- let _ = await dcb.append([dcbEvent(~eventType="Placed", ~tags=[{key: "orderId", value: "o1"}], ~msgId="d0")])
99
- let _ = await dcb.append([dcbEvent(~eventType="Paid", ~tags=[{key: "orderId", value: "o2"}], ~msgId="d1")])
112
+ let _ = await dcb.append([
113
+ dcbEvent(~eventType="Placed", ~tags=[{key: "orderId", value: "o1"}], ~msgId="d0"),
114
+ ])
115
+ let _ = await dcb.append([
116
+ dcbEvent(~eventType="Paid", ~tags=[{key: "orderId", value: "o2"}], ~msgId="d1"),
117
+ ])
100
118
 
101
119
  let bounds = await PgProjectionCatchup.captureBounds(pool)
102
120
  let (received, handler) = recorder()
@@ -115,15 +133,27 @@ switch NodeProcess.env->Dict.get("PG_URL") {
115
133
  testPromise("the pre-session bound excludes events appended after capture", async () => {
116
134
  await reset()
117
135
  let agg = classicOps()
118
- let _ = await agg.append(0, "p1", [flatEvent(~id="p1", ~seq=0, ~eventType="Added", ~msgId="a0")])
119
- let _ = await agg.append(1, "p1", [flatEvent(~id="p1", ~seq=1, ~eventType="Renamed", ~msgId="a1")])
136
+ let _ = await agg.append(
137
+ 0,
138
+ "p1",
139
+ [flatEvent(~id="p1", ~seq=0, ~eventType="Added", ~msgId="a0")],
140
+ )
141
+ let _ = await agg.append(
142
+ 1,
143
+ "p1",
144
+ [flatEvent(~id="p1", ~seq=1, ~eventType="Renamed", ~msgId="a1")],
145
+ )
120
146
 
121
147
  // Snapshot the head BEFORE the "this-session" append below.
122
148
  let bounds = await PgProjectionCatchup.captureBounds(pool)
123
149
 
124
150
  // This event is > the captured bound — it stands in for a live-delivered
125
151
  // this-session append and must NOT be redelivered by catch-up.
126
- let _ = await agg.append(2, "p1", [flatEvent(~id="p1", ~seq=2, ~eventType="Archived", ~msgId="a2")])
152
+ let _ = await agg.append(
153
+ 2,
154
+ "p1",
155
+ [flatEvent(~id="p1", ~seq=2, ~eventType="Archived", ~msgId="a2")],
156
+ )
127
157
 
128
158
  let (received, handler) = recorder()
129
159
  await PgProjectionCatchup.runCatchup(~pool, ~bounds, ~handlers=[("rm", handler)])
@@ -134,7 +164,11 @@ switch NodeProcess.env->Dict.get("PG_URL") {
134
164
  testPromise("delivers each event to every handler", async () => {
135
165
  await reset()
136
166
  let agg = classicOps()
137
- let _ = await agg.append(0, "p1", [flatEvent(~id="p1", ~seq=0, ~eventType="Added", ~msgId="a0")])
167
+ let _ = await agg.append(
168
+ 0,
169
+ "p1",
170
+ [flatEvent(~id="p1", ~seq=0, ~eventType="Added", ~msgId="a0")],
171
+ )
138
172
 
139
173
  let bounds = await PgProjectionCatchup.captureBounds(pool)
140
174
  let (r1, h1) = recorder()