@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.
- package/CHANGELOG.md +46 -0
- package/package.json +9 -9
- package/src/EmitCapabilities.res +12 -5
- package/src/EmitCapabilities.res.mjs +13 -1
- package/src/Platform.res +1 -2
- package/src/Platform.res.mjs +6 -10
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +2 -2
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +0 -2
- package/src/adapter/LocalCapabilities.res +1 -1
- package/src/adapter/LocalCapabilities.res.mjs +2 -1
- package/src/adapter/LocalFingerprintKey.res +66 -0
- package/src/adapter/LocalFingerprintKey.res.mjs +75 -0
- package/src/adapter/LocalUploadResolvers.res +7 -7
- package/src/adapter/LocalUploadResolvers.res.mjs +5 -4
- package/src/adapter/Scheduler/LocalScheduledPublisher.res +0 -1
- package/src/adapter/Scheduler/LocalScheduledPublisher.res.mjs +0 -1
- package/tests/EmitCapabilitiesTest.res +6 -0
- package/tests/EmitCapabilitiesTest.res.mjs +6 -0
- package/tests/PluginEventDecodeTest.res.mjs +2 -2
- package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +1 -1
- package/tests/adapter/DcbEventLogStorageTest.res.mjs +1 -1
- package/tests/adapter/ObjectStorePersistenceTest.res +6 -3
- package/tests/adapter/ObjectStorePersistenceTest.res.mjs +3 -3
- package/tests/adapter/ServedBucketHttpTest.res +3 -1
- package/tests/adapter/ServedBucketHttpTest.res.mjs +2 -1
- package/tests/components/dcb/DcbCrossPartitionFixtures.res.mjs +1 -1
- package/tests/components/dcb/DcbEventLogAppendStreamTest.res.mjs +1 -1
- package/tests/components/dcb/DcbRepeatTest.res +291 -0
- package/tests/components/dcb/DcbRepeatTest.res.mjs +420 -0
- package/tests/components/readmodel/DcbReadModelTypoFailFastTest.res +71 -15
- package/tests/components/readmodel/DcbReadModelTypoFailFastTest.res.mjs +119 -14
- package/tests/components/stateviewslice/StateViewSliceDisplayNameFixtures.res.mjs +1 -1
- package/tests/components/stateviewslice/StateViewSliceFixtures.res.mjs +1 -1
- package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res.mjs +1 -1
- package/tests/plugin/Plugin_Fixtures.res +1 -3
- 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
|
-
|
|
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("
|
|
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
|
|
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
|
|
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
|
+
)
|