@reventlessdev/reventless-local 3.0.0-alpha.301 → 3.0.0-alpha.303

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 (36) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/package.json +9 -9
  3. package/src/EmitCapabilities.res +12 -5
  4. package/src/EmitCapabilities.res.mjs +13 -1
  5. package/src/Platform.res +1 -2
  6. package/src/Platform.res.mjs +6 -10
  7. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +2 -2
  8. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +0 -2
  9. package/src/adapter/LocalCapabilities.res +1 -1
  10. package/src/adapter/LocalCapabilities.res.mjs +2 -1
  11. package/src/adapter/LocalFingerprintKey.res +66 -0
  12. package/src/adapter/LocalFingerprintKey.res.mjs +75 -0
  13. package/src/adapter/LocalUploadResolvers.res +7 -7
  14. package/src/adapter/LocalUploadResolvers.res.mjs +5 -4
  15. package/src/adapter/Scheduler/LocalScheduledPublisher.res +0 -1
  16. package/src/adapter/Scheduler/LocalScheduledPublisher.res.mjs +0 -1
  17. package/tests/EmitCapabilitiesTest.res +6 -0
  18. package/tests/EmitCapabilitiesTest.res.mjs +6 -0
  19. package/tests/PluginEventDecodeTest.res.mjs +2 -2
  20. package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +1 -1
  21. package/tests/adapter/DcbEventLogStorageTest.res.mjs +1 -1
  22. package/tests/adapter/ObjectStorePersistenceTest.res +6 -3
  23. package/tests/adapter/ObjectStorePersistenceTest.res.mjs +3 -3
  24. package/tests/adapter/ServedBucketHttpTest.res +3 -1
  25. package/tests/adapter/ServedBucketHttpTest.res.mjs +2 -1
  26. package/tests/components/dcb/DcbCrossPartitionFixtures.res.mjs +1 -1
  27. package/tests/components/dcb/DcbEventLogAppendStreamTest.res.mjs +1 -1
  28. package/tests/components/dcb/DcbRepeatTest.res +291 -0
  29. package/tests/components/dcb/DcbRepeatTest.res.mjs +420 -0
  30. package/tests/components/readmodel/DcbReadModelTypoFailFastTest.res +71 -15
  31. package/tests/components/readmodel/DcbReadModelTypoFailFastTest.res.mjs +119 -14
  32. package/tests/components/stateviewslice/StateViewSliceDisplayNameFixtures.res.mjs +1 -1
  33. package/tests/components/stateviewslice/StateViewSliceFixtures.res.mjs +1 -1
  34. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res.mjs +1 -1
  35. package/tests/plugin/Plugin_Fixtures.res +1 -3
  36. package/tests/plugin/Plugin_Fixtures.res.mjs +1 -2
@@ -175,7 +175,9 @@ testPromise("Upload_Presign returns matching uploadUrl + storageRef under the pr
175
175
  // Same same-origin ref serves as both the PUT target and the stored value.
176
176
  expect(uploadUrl)->toEqual(storageRef)
177
177
  expect(storageRef->String.startsWith("/uploads/"))->toEqual(true)
178
- expect(storageRef->String.endsWith("/logo.svg"))->toEqual(true)
178
+ // The name is opaque; the extension is the declared content type's.
179
+ expect(storageRef->String.endsWith(".svg"))->toEqual(true)
180
+ expect(storageRef->String.includes("logo"))->toEqual(false)
179
181
  })
180
182
 
181
183
  testPromise("presign → PUT → GET → release → GET(404) is the full loop", async () => {
@@ -158,7 +158,8 @@ globalThis.test("Upload_Presign returns matching uploadUrl + storageRef under th
158
158
  let storageRef = getString(r, "storageRef");
159
159
  globalThis.expect(uploadUrl).toEqual(storageRef);
160
160
  globalThis.expect(storageRef.startsWith("/uploads/")).toEqual(true);
161
- globalThis.expect(storageRef.endsWith("/logo.svg")).toEqual(true);
161
+ globalThis.expect(storageRef.endsWith(".svg")).toEqual(true);
162
+ globalThis.expect(storageRef.includes("logo")).toEqual(false);
162
163
  });
163
164
 
164
165
  globalThis.test("presign → PUT → GET → release → GET(404) is the full loop", async () => {
@@ -248,7 +248,7 @@ function encodeEvent(event) {
248
248
  let json = Util_Sury$Reventless.toJson(event, eventSchema);
249
249
  let match = Message$ReventlessCore.splitMessage(json);
250
250
  let tags = DcbTag$Reventless.extractTags(eventSchema, event);
251
- let meta = Message$ReventlessCore.generateMeta("test", undefined, undefined, undefined, undefined, undefined, undefined, undefined);
251
+ let meta = Message$ReventlessCore.generateMeta("test", undefined, undefined, undefined, undefined, undefined, undefined);
252
252
  return {
253
253
  eventType: match[0],
254
254
  data: match[1],
@@ -13,7 +13,7 @@ function encodeEvent(event) {
13
13
  let json = Util_Sury$Reventless.toJson(event, DcbFixtures$ReventlessLocal.ItemEventLog.eventSchema);
14
14
  let match = Message$ReventlessCore.splitMessage(json);
15
15
  let tags = DcbTag$Reventless.extractTags(DcbFixtures$ReventlessLocal.ItemEventLog.eventSchema, event);
16
- let meta = Message$ReventlessCore.generateMeta("test", undefined, undefined, undefined, undefined, undefined, undefined, undefined);
16
+ let meta = Message$ReventlessCore.generateMeta("test", undefined, undefined, undefined, undefined, undefined, undefined);
17
17
  return {
18
18
  eventType: match[0],
19
19
  data: match[1],
@@ -0,0 +1,291 @@
1
+ // Another plugin's fact arrives through an Extension and a slice, and delivery is
2
+ // at-least-once. The slice here has no check of its own — every RecordDemand
3
+ // appends — so what keeps a redelivered ItemOrdered from counting twice is the
4
+ // framework: the Extension's command carries a stable id, and the handler drops a
5
+ // command the partition already holds events for. On both local backends, and
6
+ // for two concurrent copies.
7
+
8
+ open JestGlobals
9
+
10
+ let _ = TestRunner.setup()
11
+ let opts: Pulumi.CustomResourceOptions.t = {}
12
+
13
+ module Message = ReventlessCore.Message
14
+
15
+ // ─── The provider's extension point and the consumer's slice ────────────────
16
+
17
+ module Port = {
18
+ let name = "Ordering.Orders"
19
+
20
+ @schema
21
+ type command = Noop
22
+
23
+ @schema
24
+ type event = ItemOrdered({productId: string, orderId: string})
25
+
26
+ @schema
27
+ type directive = NoDirective
28
+
29
+ let moduleUrl: string = %raw(`import.meta.url`)
30
+ }
31
+
32
+ module DemandSlice = {
33
+ let name = "RecordDemandNoCheck"
34
+ module Id = Reventless.Id.String
35
+ let moduleUrl: string = %raw(`import.meta.url`)
36
+
37
+ // The produced event joins a product and an order, so the partition is
38
+ // declared, as the shop's RecordProductDemand does.
39
+ @schema
40
+ type event =
41
+ DemandRecorded({productId: @s.matches(Reventless.DcbTag.partition) string, orderId: string})
42
+
43
+ @schema
44
+ type consumedEvent =
45
+ DemandRecorded({productId: @s.matches(Reventless.DcbTag.string) string, orderId: string})
46
+
47
+ @schema
48
+ type command =
49
+ RecordDemand({productId: @s.matches(Reventless.DcbTag.string) string, orderId: string})
50
+
51
+ @schema
52
+ type error = Never
53
+
54
+ type role = Reventless.Role.name
55
+ let authorizationOf = (_: string): Reventless.Authorization.rule<role> => AllowAuthenticated
56
+ type lifecycleState = unit
57
+ let commandTransition = (_: command): Reventless.Transition.t<lifecycleState> => Unrestricted
58
+ let traits: array<Reventless.Trait.t> = []
59
+ }
60
+
61
+ module DemandDecision = {
62
+ type state = {orders: array<string>}
63
+ let initialState = {orders: []}
64
+ let moduleUrl = DemandSlice.moduleUrl
65
+
66
+ let evolve = (state, event: DemandSlice.consumedEvent) =>
67
+ switch event {
68
+ | DemandRecorded({orderId}) => {orders: state.orders->Array.concat([orderId])}
69
+ }
70
+
71
+ // Deliberately no "already counted" rule.
72
+ let decide = (_state, command: DemandSlice.command): result<
73
+ array<DemandSlice.event>,
74
+ DemandSlice.error,
75
+ > =>
76
+ switch command {
77
+ | RecordDemand({productId, orderId}) => Ok([DemandRecorded({productId, orderId})])
78
+ }
79
+ }
80
+
81
+ module DemandHandler = ReventlessCore.StateChangeSlice_Callback.Make(DemandSlice, DemandDecision)
82
+
83
+ let partitionTag = Reventless.DcbTag.Simple({key: "productId"})
84
+
85
+ // The consumer's Extension, as the catalog's Orders_Extension is written.
86
+ module DemandExtension = ReventlessInfra.ExtensionMapping.Make({
87
+ module ExtensionPoint = Port
88
+ module Delegate = DemandSlice
89
+ let moduleUrl: string = %raw(`import.meta.url`)
90
+ let delegateModuleUrl = DemandSlice.moduleUrl
91
+ let mapIncomingEvent = (_id, event: Port.event, _meta, _pluginDef, _queryEngine) =>
92
+ switch event {
93
+ | ItemOrdered({productId, orderId}) => [
94
+ ReventlessInfra.ExtensionMapping.PublishStateChangeSliceCommand(
95
+ DemandSlice.RecordDemand({productId, orderId}),
96
+ ),
97
+ ]
98
+ }
99
+ let mapOutgoingEvent = None
100
+ let handledEvents = []
101
+ let issuedCommands = []
102
+ })
103
+
104
+ let queryEngine: Reventless.QueryEngine.operations = {
105
+ scan: async (~readModelName as _, ~filterConfigs as _, ~limit as _) => [],
106
+ query: async (
107
+ ~readModelName as _,
108
+ ~key as _=?,
109
+ ~id as _,
110
+ ~subIdConfig as _=?,
111
+ ~filterConfigs as _=?,
112
+ ~ascending as _=?,
113
+ ~limit as _=?,
114
+ ) => [],
115
+ }
116
+
117
+ let pluginDefinition: Reventless.Plugin.pluginDefinition = {
118
+ id: "Catalog@1",
119
+ name: "Catalog",
120
+ version: "1",
121
+ extensionPoints: [],
122
+ extensions: [{name: "Catalog", extensionPointName: Port.name}],
123
+ eventCollector: "eventCollector",
124
+ extensionProtocols: [],
125
+ apiSchemaFragment: None,
126
+ structure: None,
127
+ kind: Domain,
128
+ }
129
+
130
+ // One published ItemOrdered, as the extension point stored and published it;
131
+ // every delivery of it carries this same envelope.
132
+ let published: Message.event'<string, Port.event> = {
133
+ id: "order-1",
134
+ meta: {
135
+ service: Port.name,
136
+ time: "2024-01-01T00:00:00Z",
137
+ msgId: "item-ordered-msg",
138
+ correlationId: "place-order-cmd",
139
+ causationId: "order-placed-event",
140
+ },
141
+ event: ItemOrdered({productId: "product-1", orderId: "order-1"}),
142
+ }
143
+
144
+ // What the Extension sends for one delivery of `published`.
145
+ let commandOf = (event': Message.event'<string, Port.event>) =>
146
+ switch DemandExtension.mapIncomingEvent(event', pluginDefinition, queryEngine) {
147
+ | [ReventlessInfra.ExtensionMapping.AbstractPublishAggregateCommand(_, cmdJson)] => cmdJson
148
+ | _ => JsError.throwWithMessage("expected one slice command")
149
+ }
150
+
151
+ // ─── Backends ───────────────────────────────────────────────────────────────
152
+
153
+ let eventLogOn = async (~name) => {
154
+ module Bus = LocalBus.Make()
155
+ module Storage = LocalDcbEventLogStorage.Make(Bus)
156
+ let s = Storage.make(~name, ~indexes=[], ~partitionTag, ~opts)
157
+ let storage = await s.operations->TestRunner.resolve
158
+ module Log = ReventlessCore.DcbEventLog_Operations.Make({
159
+ let name = name
160
+ let serviceName = name
161
+ let storage = storage
162
+ let publishJson = async (_, _, _) => ()
163
+ })
164
+ let eventLog: ReventlessCore.DcbEventLog.operations = {
165
+ read: Log.read,
166
+ append: Log.append,
167
+ readStream: Log.readStream,
168
+ appendStream: Log.appendStream,
169
+ }
170
+ eventLog
171
+ }
172
+
173
+ let runUnderMemory = async fn => {
174
+ BackendState.setMemory()
175
+ await fn()
176
+ }
177
+
178
+ let runUnderSqlite = async fn => {
179
+ let db = SqliteDriver.openDb(~path=":memory:")
180
+ BackendState.setSqlite(~db, ~path=":memory:")
181
+ await fn()
182
+ BackendState.setMemory()
183
+ db->SqliteDriver.close
184
+ }
185
+
186
+ let deliver = (eventLog, cmd: Message.commandJson) =>
187
+ DemandHandler.handleCommands(
188
+ ~partitionTag,
189
+ eventLog,
190
+ Stream.fromIterable([
191
+ {
192
+ ReventlessInfra.CommandTopic.command: cmd
193
+ ->ReventlessCore.CommandTopic_Helpers.encodeCommandJson
194
+ ->Message.decodeCommand'(Reventless.Id.String.schema, DemandSlice.commandSchema),
195
+ reference: cmd.meta.msgId,
196
+ },
197
+ ]),
198
+ )->Effect.runPromise
199
+
200
+ let recordedDemands = async (eventLog: ReventlessCore.DcbEventLog.operations) =>
201
+ (await eventLog.read(~query=[{eventTypes: ["DemandRecorded"]}])).events
202
+
203
+ let counter = ref(0)
204
+ let fresh = prefix => {
205
+ counter := counter.contents + 1
206
+ `${prefix}-${counter.contents->Int.toString}`
207
+ }
208
+
209
+ let _ = beforeEach(() => DemandHandler.resetCache())
210
+
211
+ let backends = [("in-memory", runUnderMemory), ("SQLite", runUnderSqlite)]
212
+
213
+ backends->Array.forEach(((label, runUnder)) =>
214
+ describe(`a redelivered fact is recorded once (${label} backend)`, () => {
215
+ testPromise(
216
+ "the same published event delivered twice appends one event",
217
+ async () =>
218
+ await runUnder(
219
+ async () => {
220
+ let log = await eventLogOn(~name=fresh("RepeatOnce"))
221
+ let first = await deliver(log, commandOf(published))
222
+ let again = await deliver(log, commandOf(published))
223
+ expect(first->Array.length)->toBe(1)
224
+ expect(again)->toEqual(first) // accepted both times
225
+ let events = await recordedDemands(log)
226
+ expect(events->Array.length)->toBe(1)
227
+ // The command is the stored event's cause, and the published event the command's.
228
+ let cmd = commandOf(published)
229
+ expect((events->Array.getUnsafe(0)).meta.causationId)->toEqual(Some(cmd.meta.msgId))
230
+ expect(cmd.meta.causationId)->toEqual(Some(published.meta.msgId))
231
+ },
232
+ ),
233
+ )
234
+
235
+ testPromise(
236
+ "a different order for the same product still counts",
237
+ async () =>
238
+ await runUnder(
239
+ async () => {
240
+ let log = await eventLogOn(~name=fresh("RepeatOther"))
241
+ let _ = await deliver(log, commandOf(published))
242
+ let second: Message.event'<string, Port.event> = {
243
+ id: "order-2",
244
+ meta: {...published.meta, msgId: "item-ordered-msg-2"},
245
+ event: Port.ItemOrdered({productId: "product-1", orderId: "order-2"}),
246
+ }
247
+ let _ = await deliver(log, commandOf(second))
248
+ let _ = await deliver(log, commandOf(published))
249
+ let _ = await deliver(log, commandOf(second))
250
+ expect((await recordedDemands(log))->Array.length)->toBe(2)
251
+ },
252
+ ),
253
+ )
254
+
255
+ testPromise(
256
+ "a command with a random id is decided every time",
257
+ async () =>
258
+ await runUnder(
259
+ async () => {
260
+ let log = await eventLogOn(~name=fresh("RepeatRandom"))
261
+ let cmd = commandOf(published)
262
+ // The envelope of a command nobody derived: fresh id, no mark.
263
+ let plain = {...cmd, meta: Message.deriveMeta(~parent=cmd.meta)}
264
+ let _ = await deliver(log, plain)
265
+ let _ = await deliver(log, plain)
266
+ expect((await recordedDemands(log))->Array.length)->toBe(2)
267
+ },
268
+ ),
269
+ )
270
+
271
+ testPromise(
272
+ "two concurrent copies append one event",
273
+ async () =>
274
+ await runUnder(
275
+ async () => {
276
+ let log = await eventLogOn(~name=fresh("RepeatConcurrent"))
277
+ let cmd = commandOf(published)
278
+ // Both read an empty partition; the second append conflicts, re-reads,
279
+ // and finds the first one's event.
280
+ let (a, b) = await Promise.all2((deliver(log, cmd), deliver(log, cmd)))
281
+ expect(a->Array.length)->toBe(1)
282
+ expect(b->Array.length)->toBe(1)
283
+ expect(a->Array.getUnsafe(0)->Result.isOk && b->Array.getUnsafe(0)->Result.isOk)->toBe(
284
+ true,
285
+ )
286
+ expect((await recordedDemands(log))->Array.length)->toBe(1)
287
+ },
288
+ ),
289
+ )
290
+ })
291
+ )