@reventlessdev/reventless-core 3.0.0-alpha.283 → 3.0.0-alpha.285
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 +16 -0
- package/package.json +7 -7
- package/src/Message.res.mjs +3 -0
- package/src/components/Aggregate/Aggregate_Callback.res +116 -39
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +96 -37
- package/src/components/CommandGenerator/CommandGenerator_Callback.res +9 -8
- package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +10 -8
- package/src/components/CommandTopic/CommandTopic.res.mjs +3 -0
- package/src/components/CommandTopic/CommandTopic_Helpers.res +37 -1
- package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +29 -0
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +191 -141
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +66 -12
- package/src/plugin/component/Plugin_Helpers.res +33 -5
- package/src/plugin/component/Plugin_Helpers.res.mjs +45 -4
- package/tests/commandgenerator/OwnerActingTest.res +601 -0
- package/tests/commandgenerator/OwnerActingTest.res.mjs +1018 -0
- package/tests/commandgenerator/OwnerStampingTest.res +90 -0
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +87 -0
- package/tests/commandtopic/CommandOutcomeHookTest.res +1 -0
- package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +11 -1
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// A command acts only on what its caller owns. The stamp (OwnerStampingTest)
|
|
4
|
+
// decides who a new thing belongs to; this decides who may act on it afterwards.
|
|
5
|
+
//
|
|
6
|
+
// Driven end to end on purpose: the generator writes the claim, a transport
|
|
7
|
+
// encodes the envelope, and the handler reads it back before `decide`. The rule
|
|
8
|
+
// is a relationship between those three, and the AppSync stamping gap showed that
|
|
9
|
+
// each one can be correct on its own while the chain fails open. Every row of the
|
|
10
|
+
// table runs on every path below — two handlers, two envelope encodings.
|
|
11
|
+
|
|
12
|
+
// ─── An owner-marked DCB slice ──────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
module OrderSlice = {
|
|
15
|
+
let name = "OwnedOrders"
|
|
16
|
+
module Id = Reventless.Id.String
|
|
17
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
18
|
+
|
|
19
|
+
@schema
|
|
20
|
+
type event =
|
|
21
|
+
| OrderPlaced({orderId: @s.matches(Reventless.DcbTag.string) string, customerId: string})
|
|
22
|
+
| OrderCancelled({orderId: @s.matches(Reventless.DcbTag.string) string})
|
|
23
|
+
|
|
24
|
+
// `customerId` on the consumed `OrderPlaced` is the declaration under test: it
|
|
25
|
+
// names the owner of the order a command acts on. `ProductListed` is read
|
|
26
|
+
// across partitions and marks an owner of its own, which must not count.
|
|
27
|
+
@schema
|
|
28
|
+
type consumedEvent =
|
|
29
|
+
| OrderPlaced({
|
|
30
|
+
orderId: @s.matches(Reventless.DcbTag.string) string,
|
|
31
|
+
customerId: @s.matches(Reventless.Owner.string) string,
|
|
32
|
+
})
|
|
33
|
+
| OrderCancelled({orderId: @s.matches(Reventless.DcbTag.string) string})
|
|
34
|
+
| ProductListed({
|
|
35
|
+
productId: @s.matches(Reventless.DcbTag.string) string,
|
|
36
|
+
sellerId: @s.matches(Reventless.Owner.string) string,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
@schema
|
|
40
|
+
type command =
|
|
41
|
+
| PlaceOrder({
|
|
42
|
+
orderId: @s.matches(Reventless.DcbTag.string) string,
|
|
43
|
+
customerId: @s.matches(Reventless.Owner.string) string,
|
|
44
|
+
})
|
|
45
|
+
| CancelOrder({orderId: @s.matches(Reventless.DcbTag.string) string})
|
|
46
|
+
| ReviewOrder({
|
|
47
|
+
orderId: @s.matches(Reventless.DcbTag.string) string,
|
|
48
|
+
productId: @s.matches(Reventless.DcbTag.string) string,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
@schema
|
|
52
|
+
type error =
|
|
53
|
+
| OrderAlreadyPlaced
|
|
54
|
+
| OrderNotFound
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module OrderBehavior = {
|
|
58
|
+
type state = {placed: bool, cancelled: bool}
|
|
59
|
+
let initialState = {placed: false, cancelled: false}
|
|
60
|
+
let moduleUrl = OrderSlice.moduleUrl
|
|
61
|
+
|
|
62
|
+
let evolve = (state, event: OrderSlice.consumedEvent) =>
|
|
63
|
+
switch event {
|
|
64
|
+
| OrderPlaced(_) => {...state, placed: true}
|
|
65
|
+
| OrderCancelled(_) => {...state, cancelled: true}
|
|
66
|
+
| ProductListed(_) => state
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let decide = (state, command: OrderSlice.command): result<
|
|
70
|
+
array<OrderSlice.event>,
|
|
71
|
+
OrderSlice.error,
|
|
72
|
+
> =>
|
|
73
|
+
switch command {
|
|
74
|
+
| PlaceOrder({orderId, customerId}) =>
|
|
75
|
+
state.placed ? Error(OrderAlreadyPlaced) : Ok([OrderPlaced({orderId, customerId})])
|
|
76
|
+
| CancelOrder({orderId}) =>
|
|
77
|
+
switch (state.placed, state.cancelled) {
|
|
78
|
+
| (false, _) => Error(OrderNotFound)
|
|
79
|
+
| (true, true) => Ok([])
|
|
80
|
+
| (true, false) => Ok([OrderCancelled({orderId: orderId})])
|
|
81
|
+
}
|
|
82
|
+
| ReviewOrder(_) => state.placed ? Ok([]) : Error(OrderNotFound)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module OrderHandler = StateChangeSlice_Callback.Make(OrderSlice, OrderBehavior)
|
|
87
|
+
|
|
88
|
+
let dcbMock = DcbFixtures.makeMockStorage()
|
|
89
|
+
|
|
90
|
+
module DcbOps: DcbEventLog_Operations.Ops = {
|
|
91
|
+
let name = "OwnedOrdersEventLog"
|
|
92
|
+
let serviceName = "OwnedOrdersEventLog"
|
|
93
|
+
let storage = dcbMock.operations
|
|
94
|
+
let publishJson = dcbMock.mockPublishJson
|
|
95
|
+
}
|
|
96
|
+
module DcbLog = DcbEventLog_Operations.Make(DcbOps)
|
|
97
|
+
|
|
98
|
+
let dcbEventLog: DcbEventLog.operations = {
|
|
99
|
+
read: DcbLog.read,
|
|
100
|
+
append: DcbLog.append,
|
|
101
|
+
readStream: DcbLog.readStream,
|
|
102
|
+
appendStream: DcbLog.appendStream,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let orderPartition = Reventless.DcbTag.Simple({key: "orderId"})
|
|
106
|
+
|
|
107
|
+
// ─── An owner-marked aggregate ──────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
module AccountSpec = {
|
|
110
|
+
module Id = Reventless.Id.StringPure
|
|
111
|
+
let name = "OwnedAccount"
|
|
112
|
+
|
|
113
|
+
@schema
|
|
114
|
+
type command =
|
|
115
|
+
| Open({holderId: @s.matches(Reventless.Owner.string) string, name: string})
|
|
116
|
+
| Rename({newName: string})
|
|
117
|
+
|
|
118
|
+
@schema
|
|
119
|
+
type event =
|
|
120
|
+
| Opened({holderId: @s.matches(Reventless.Owner.string) string, name: string})
|
|
121
|
+
| Renamed({newName: string})
|
|
122
|
+
|
|
123
|
+
@schema
|
|
124
|
+
type error =
|
|
125
|
+
| AlreadyOpen
|
|
126
|
+
| NotFound
|
|
127
|
+
|
|
128
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module AccountBehavior = {
|
|
132
|
+
module Spec = AccountSpec
|
|
133
|
+
|
|
134
|
+
@schema
|
|
135
|
+
type state = NotOpen | Active({name: string})
|
|
136
|
+
|
|
137
|
+
let initialState = NotOpen
|
|
138
|
+
|
|
139
|
+
// Configured on purpose: an owner-marked aggregate must not seed from a
|
|
140
|
+
// snapshot, which holds the state but not the owner.
|
|
141
|
+
let snapshot = Some({Reventless.Snapshot.interval: 1, stateSchema})
|
|
142
|
+
|
|
143
|
+
let moduleUrl = AccountSpec.moduleUrl
|
|
144
|
+
|
|
145
|
+
let evolve = (_state, event: AccountSpec.event) =>
|
|
146
|
+
switch event {
|
|
147
|
+
| Opened({name}) => Active({name: name})
|
|
148
|
+
| Renamed({newName}) => Active({name: newName})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let decide = (state, command: AccountSpec.command): result<
|
|
152
|
+
array<AccountSpec.event>,
|
|
153
|
+
AccountSpec.error,
|
|
154
|
+
> =>
|
|
155
|
+
switch (state, command) {
|
|
156
|
+
| (NotOpen, Open({holderId, name})) => Ok([AccountSpec.Opened({holderId, name})])
|
|
157
|
+
| (NotOpen, Rename(_)) => Error(NotFound)
|
|
158
|
+
| (Active(_), Open(_)) => Error(AlreadyOpen)
|
|
159
|
+
| (Active(_), Rename({newName})) => Ok([AccountSpec.Renamed({newName: newName})])
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let accountEvents: ref<array<Message.event'<string, AccountSpec.event>>> = ref([])
|
|
164
|
+
let snapshotReads = ref(0)
|
|
165
|
+
|
|
166
|
+
module OuterEventLog = EventLog
|
|
167
|
+
|
|
168
|
+
module AccountOps = {
|
|
169
|
+
module Spec = AccountSpec
|
|
170
|
+
module EventLog = {
|
|
171
|
+
module Spec = {
|
|
172
|
+
module Id = AccountSpec.Id
|
|
173
|
+
let name = "OwnedAccountEventLog"
|
|
174
|
+
@schema
|
|
175
|
+
type event = AccountSpec.event
|
|
176
|
+
}
|
|
177
|
+
type operations = {
|
|
178
|
+
append: (
|
|
179
|
+
int,
|
|
180
|
+
string,
|
|
181
|
+
array<Message.event'<string, AccountSpec.event>>,
|
|
182
|
+
) => promise<result<unit, EventLog.appendError>>,
|
|
183
|
+
replay: string => promise<array<AccountSpec.event>>,
|
|
184
|
+
replayStream: (string, ~fromSeq: int=?) => Stream.t<AccountSpec.event, string, unit>,
|
|
185
|
+
appendStream: (
|
|
186
|
+
int,
|
|
187
|
+
string,
|
|
188
|
+
Stream.t<AccountSpec.event, string, unit>,
|
|
189
|
+
) => Effect.t<unit, string, unit>,
|
|
190
|
+
latestSnapshot: string => promise<result<option<EventLog.snapshot>, string>>,
|
|
191
|
+
writeSnapshot: (string, EventLog.snapshot) => promise<result<unit, string>>,
|
|
192
|
+
}
|
|
193
|
+
type component = Component.t<OuterEventLog.t, OuterEventLog.outputs, operations>
|
|
194
|
+
let make = (~name as _: string, ~owner as _=?, ~opts as _=?): component => Obj.magic(0)
|
|
195
|
+
}
|
|
196
|
+
let ofId = id => accountEvents.contents->Array.filter(e => e.id == id)->Array.map(e => e.event)
|
|
197
|
+
let eventLog: EventLog.operations = {
|
|
198
|
+
append: async (_seqNr, _id, newEvents) => {
|
|
199
|
+
accountEvents := accountEvents.contents->Array.concat(newEvents)
|
|
200
|
+
Ok()
|
|
201
|
+
},
|
|
202
|
+
replay: async id => ofId(id),
|
|
203
|
+
replayStream: (id, ~fromSeq as _=?) => ofId(id)->Stream.fromIterable,
|
|
204
|
+
appendStream: (_seqNr, _id, stream) => stream->Stream.runDrain,
|
|
205
|
+
latestSnapshot: async _ => {
|
|
206
|
+
snapshotReads := snapshotReads.contents + 1
|
|
207
|
+
Ok(None)
|
|
208
|
+
},
|
|
209
|
+
writeSnapshot: async (_, _) => Ok(),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module AccountHandler = Aggregate_Callback.Make(AccountSpec, AccountBehavior, AccountOps)
|
|
214
|
+
|
|
215
|
+
// ─── Callers ────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
external asIdentity: 'a => Reventless.Identity.t = "%identity"
|
|
218
|
+
|
|
219
|
+
let cognito = (~userId, ~groups): Reventless.Identity.t =>
|
|
220
|
+
asIdentity({"userId": userId, "username": userId, "groups": groups, "provider": "Cognito"})
|
|
221
|
+
|
|
222
|
+
let owner = cognito(~userId="cust-A", ~groups=["User"])
|
|
223
|
+
let stranger = cognito(~userId="cust-B", ~groups=["User"])
|
|
224
|
+
let operator = cognito(~userId="ops-1", ~groups=["Admin"])
|
|
225
|
+
let iam: Reventless.Identity.t = asIdentity({
|
|
226
|
+
"userArn": "arn:aws:sts::1:assumed-role/Ingester",
|
|
227
|
+
"accountId": "1",
|
|
228
|
+
"username": "Ingester",
|
|
229
|
+
"provider": "IAM",
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// ─── Generator → envelope → handler ─────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
// The two ways an envelope crosses from generator to handler: the inline body
|
|
235
|
+
// the local channel and the synchronous SQS path build, and the queued message
|
|
236
|
+
// body. Both encode `meta` through its schema, which is what carries the claim.
|
|
237
|
+
type hop = Inline | Queued
|
|
238
|
+
|
|
239
|
+
let cross = (hop, cmd: Message.commandJson): JSON.t =>
|
|
240
|
+
switch hop {
|
|
241
|
+
| Inline => CommandTopic_Helpers.encodeCommandJson(cmd)
|
|
242
|
+
| Queued => Message.toMessageBody(cmd)->JSON.parseOrThrow
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
type outcome = Accepted | Refused(string, CommandTopic_Helpers.refusalCause) | Lost
|
|
246
|
+
|
|
247
|
+
let observed: ref<array<CommandTopic_Helpers.commandOutcomeReport>> = ref([])
|
|
248
|
+
|
|
249
|
+
let outcomeOf = (reference: string) =>
|
|
250
|
+
switch observed.contents->Array.findLast(r => r.reference == reference) {
|
|
251
|
+
| Some({outcome: OutcomeAccepted(_)}) => Accepted
|
|
252
|
+
| Some({outcome: OutcomeRejected({errorCode, cause})}) => Refused(errorCode, cause)
|
|
253
|
+
| None => Lost
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let generated: ref<array<Message.commandJson>> = ref([])
|
|
257
|
+
|
|
258
|
+
let generator = (~serviceName, ~commandSchema, ~componentKind) =>
|
|
259
|
+
CommandGenerator_Callback.makeGenerateCommand(
|
|
260
|
+
~publishJsons=async cmds => generated := generated.contents->Array.concat(cmds),
|
|
261
|
+
~serviceName,
|
|
262
|
+
~commandSchema,
|
|
263
|
+
~componentKind,
|
|
264
|
+
~stripIdFromParams=componentKind == CommandGenerator_Callback.Aggregate,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
let generateOne = async (generate, ~command, ~args, ~identity) => {
|
|
268
|
+
generated := []
|
|
269
|
+
let p: CommandGenerator.payload = Obj.magic({
|
|
270
|
+
"command": command,
|
|
271
|
+
"arguments": Obj.magic(Dict.fromArray(args)),
|
|
272
|
+
"meta": {"ip": [], "user": "test", "info": ""},
|
|
273
|
+
"identity": identity,
|
|
274
|
+
})
|
|
275
|
+
let _ = await generate(p)->Effect.runPromise
|
|
276
|
+
generated.contents->Array.getUnsafe(0)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// What an internal route publishes: an envelope the generator did not write.
|
|
280
|
+
let withoutClaim = (cmd: Message.commandJson) => {...cmd, meta: {...cmd.meta, callerClaim: ?None}}
|
|
281
|
+
|
|
282
|
+
let str = JSON.Encode.string
|
|
283
|
+
|
|
284
|
+
module type Path = {
|
|
285
|
+
let label: string
|
|
286
|
+
let reset: unit => unit
|
|
287
|
+
// Creates the thing as `cust-A`, through the same path.
|
|
288
|
+
let seed: unit => promise<unit>
|
|
289
|
+
// Builds the command that acts on it, as a caller.
|
|
290
|
+
let act: Reventless.Identity.t => promise<Message.commandJson>
|
|
291
|
+
// Answers the msgId the handler received, which its outcome is keyed by.
|
|
292
|
+
let deliver: Message.commandJson => promise<string>
|
|
293
|
+
let historyLength: unit => int
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let dcbGenerate = generator(
|
|
297
|
+
~serviceName=OrderSlice.name,
|
|
298
|
+
~commandSchema=OrderSlice.commandSchema->S.castToUnknown,
|
|
299
|
+
~componentKind=CommandGenerator_Callback.StateChangeSlice,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
let deliverDcb = async (~crossPartitionTagKeys=[], ~partitionTag=orderPartition, hop, cmd) => {
|
|
303
|
+
let command' =
|
|
304
|
+
cross(hop, cmd)->Message.decodeCommand'(Reventless.Id.String.schema, OrderSlice.commandSchema)
|
|
305
|
+
let _ = await OrderHandler.handleCommands(
|
|
306
|
+
~crossPartitionTagKeys,
|
|
307
|
+
~partitionTag,
|
|
308
|
+
dcbEventLog,
|
|
309
|
+
Stream.fromIterable([
|
|
310
|
+
{ReventlessInfra.CommandTopic.command: command', reference: cmd.meta.msgId},
|
|
311
|
+
]),
|
|
312
|
+
)->Effect.runPromise
|
|
313
|
+
// A queued body is re-stamped with a fresh msgId; outcomes are keyed by the
|
|
314
|
+
// envelope the handler received.
|
|
315
|
+
command'.meta.msgId
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let makeDcbPath = (hop): module(Path) =>
|
|
319
|
+
module(
|
|
320
|
+
{
|
|
321
|
+
let label = `DCB slice, ${hop == Inline ? "inline" : "queued"} envelope`
|
|
322
|
+
let reset = () => {
|
|
323
|
+
dcbMock.reset()
|
|
324
|
+
OrderHandler.resetCache()
|
|
325
|
+
}
|
|
326
|
+
let deliver = cmd => deliverDcb(hop, cmd)
|
|
327
|
+
let seed = async () => {
|
|
328
|
+
let _ = await deliver(
|
|
329
|
+
await dcbGenerate->generateOne(
|
|
330
|
+
~command="PlaceOrder",
|
|
331
|
+
~args=[("orderId", str("o-1")), ("customerId", str("cust-A"))],
|
|
332
|
+
~identity=owner,
|
|
333
|
+
),
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
let act = identity =>
|
|
337
|
+
dcbGenerate->generateOne(~command="CancelOrder", ~args=[("orderId", str("o-1"))], ~identity)
|
|
338
|
+
let historyLength = () => dcbMock.getEvents()->Array.length
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
let aggregateGenerate = generator(
|
|
343
|
+
~serviceName=AccountSpec.name,
|
|
344
|
+
~commandSchema=AccountSpec.commandSchema->S.castToUnknown,
|
|
345
|
+
~componentKind=CommandGenerator_Callback.Aggregate,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
let deliverAggregate = async (hop, cmds: array<Message.commandJson>) => {
|
|
349
|
+
let items = cmds->Array.map(cmd => {
|
|
350
|
+
ReventlessInfra.CommandTopic.command: cross(hop, cmd)->Message.decodeCommand'(
|
|
351
|
+
AccountSpec.Id.schema,
|
|
352
|
+
AccountSpec.commandSchema,
|
|
353
|
+
),
|
|
354
|
+
reference: cmd.meta.msgId,
|
|
355
|
+
})
|
|
356
|
+
let _ = await AccountHandler.handleCommands(Stream.fromIterable(items))->Effect.runPromise
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let makeAggregatePath = (hop): module(Path) =>
|
|
360
|
+
module(
|
|
361
|
+
{
|
|
362
|
+
let label = `aggregate, ${hop == Inline ? "inline" : "queued"} envelope`
|
|
363
|
+
let reset = () => {
|
|
364
|
+
accountEvents := []
|
|
365
|
+
snapshotReads := 0
|
|
366
|
+
AccountHandler.resetCache()
|
|
367
|
+
}
|
|
368
|
+
let deliver = async cmd => {
|
|
369
|
+
await deliverAggregate(hop, [cmd])
|
|
370
|
+
cmd.meta.msgId
|
|
371
|
+
}
|
|
372
|
+
let seed = async () => {
|
|
373
|
+
let _ = await deliver(
|
|
374
|
+
await aggregateGenerate->generateOne(
|
|
375
|
+
~command="Open",
|
|
376
|
+
~args=[("id", str("acc-1")), ("holderId", str("cust-A")), ("name", str("Main"))],
|
|
377
|
+
~identity=owner,
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
let act = identity =>
|
|
382
|
+
aggregateGenerate->generateOne(
|
|
383
|
+
~command="Rename",
|
|
384
|
+
~args=[("id", str("acc-1")), ("newName", str("Renamed"))],
|
|
385
|
+
~identity,
|
|
386
|
+
)
|
|
387
|
+
let historyLength = () => accountEvents.contents->Array.length
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
let paths = [
|
|
392
|
+
makeDcbPath(Inline),
|
|
393
|
+
makeDcbPath(Queued),
|
|
394
|
+
makeAggregatePath(Inline),
|
|
395
|
+
makeAggregatePath(Queued),
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
let _ = beforeEach(() => {
|
|
399
|
+
Reventless.OwnerScope.setElevatedGroups(["Admin"])
|
|
400
|
+
observed := []
|
|
401
|
+
CommandTopic_Helpers.registerCommandOutcome(r => observed := observed.contents->Array.concat([r]))
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
let _ = afterEach(() => CommandTopic_Helpers.clearCommandOutcome())
|
|
405
|
+
|
|
406
|
+
let forbidden = Refused("Forbidden", CommandTopic_Helpers.AccessRefusal)
|
|
407
|
+
|
|
408
|
+
// (caller, how the envelope is produced, expected outcome, whether the history grows)
|
|
409
|
+
let table: array<(string, Reventless.Identity.t, bool, outcome)> = [
|
|
410
|
+
("the owner", owner, false, Accepted),
|
|
411
|
+
("another owner", stranger, false, forbidden),
|
|
412
|
+
("an operator, on the owner's behalf", operator, false, Accepted),
|
|
413
|
+
("the platform's own IAM traffic", iam, false, Accepted),
|
|
414
|
+
("an anonymous caller", Reventless.Identity.anonymous, false, forbidden),
|
|
415
|
+
// An envelope the generator did not write: an automation's follow-up. The same
|
|
416
|
+
// stranger, so the only difference from row two is where the command came from.
|
|
417
|
+
("an internal route, with no claim", stranger, true, Accepted),
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
paths->Array.forEach(path => {
|
|
421
|
+
module P = unpack(path)
|
|
422
|
+
describe(`acting on an owned thing — ${P.label}:`, () => {
|
|
423
|
+
let _ = beforeEach(() => P.reset())
|
|
424
|
+
|
|
425
|
+
table->Array.forEach(
|
|
426
|
+
((who, identity, internal, expected)) =>
|
|
427
|
+
testPromise(
|
|
428
|
+
`${who}: ${expected == Accepted ? "accepted" : "refused before decide"}`,
|
|
429
|
+
async () => {
|
|
430
|
+
await P.seed()
|
|
431
|
+
let before = P.historyLength()
|
|
432
|
+
let cmd = await P.act(identity)
|
|
433
|
+
let cmd = internal ? withoutClaim(cmd) : cmd
|
|
434
|
+
let received = await P.deliver(cmd)
|
|
435
|
+
expect((outcomeOf(received), P.historyLength() > before))->toEqual((
|
|
436
|
+
expected,
|
|
437
|
+
expected == Accepted,
|
|
438
|
+
))
|
|
439
|
+
},
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
// Creating is unaffected: an empty history has no owner to protect.
|
|
444
|
+
testPromise(
|
|
445
|
+
"the owner's own creating command is accepted on an empty history",
|
|
446
|
+
async () => {
|
|
447
|
+
await P.seed()
|
|
448
|
+
expect(P.historyLength())->toBe(1)
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
describe("acting on an owned thing — DCB specifics:", () => {
|
|
455
|
+
let _ = beforeEach(() => {
|
|
456
|
+
dcbMock.reset()
|
|
457
|
+
OrderHandler.resetCache()
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
let seedOrder = async () => {
|
|
461
|
+
let _ = await deliverDcb(
|
|
462
|
+
Inline,
|
|
463
|
+
await dcbGenerate->generateOne(
|
|
464
|
+
~command="PlaceOrder",
|
|
465
|
+
~args=[("orderId", str("o-1")), ("customerId", str("cust-A"))],
|
|
466
|
+
~identity=owner,
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
let rawEvent = (~eventType, ~fields, ~tags): DcbEventLog_Adapter.rawStoredEvent => {
|
|
472
|
+
eventType,
|
|
473
|
+
data: JSON.Encode.object(Dict.fromArray(fields)),
|
|
474
|
+
tags: tags->Array.map(((key, value)) => {Reventless.DcbTag.key, value}),
|
|
475
|
+
meta: DcbFixtures.testMeta,
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// The plan's catalog case: a product the command names is read across
|
|
479
|
+
// partitions and records a seller. Counting it would make every order two
|
|
480
|
+
// owners and refuse everyone; only the order's own partition says whose it is.
|
|
481
|
+
testPromise("an owner recorded in another partition does not count", async () => {
|
|
482
|
+
await seedOrder()
|
|
483
|
+
let _ = await dcbMock.operations.append([
|
|
484
|
+
rawEvent(
|
|
485
|
+
~eventType="ProductListed",
|
|
486
|
+
~fields=[("productId", str("p-1")), ("sellerId", str("seller-Z"))],
|
|
487
|
+
~tags=[("productId", "p-1")],
|
|
488
|
+
),
|
|
489
|
+
])
|
|
490
|
+
let review = await dcbGenerate->generateOne(
|
|
491
|
+
~command="ReviewOrder",
|
|
492
|
+
~args=[("orderId", str("o-1")), ("productId", str("p-1"))],
|
|
493
|
+
~identity=owner,
|
|
494
|
+
)
|
|
495
|
+
let _ = await deliverDcb(~crossPartitionTagKeys=["productId"], Inline, review)
|
|
496
|
+
expect(outcomeOf(review.meta.msgId))->toEqual(Accepted)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
// The owner rides in the projection cache with the state. A warm command reads
|
|
500
|
+
// only the events after the cached head, which never include the one that
|
|
501
|
+
// recorded the owner.
|
|
502
|
+
testPromise("a warm, delta-read command is still checked against the owner", async () => {
|
|
503
|
+
await seedOrder()
|
|
504
|
+
let mine = await dcbGenerate->generateOne(
|
|
505
|
+
~command="CancelOrder",
|
|
506
|
+
~args=[("orderId", str("o-1"))],
|
|
507
|
+
~identity=owner,
|
|
508
|
+
)
|
|
509
|
+
let _ = await deliverDcb(Inline, mine)
|
|
510
|
+
dcbMock.readAfters := []
|
|
511
|
+
let theirs = await dcbGenerate->generateOne(
|
|
512
|
+
~command="CancelOrder",
|
|
513
|
+
~args=[("orderId", str("o-1"))],
|
|
514
|
+
~identity=stranger,
|
|
515
|
+
)
|
|
516
|
+
let _ = await deliverDcb(Inline, theirs)
|
|
517
|
+
expect((
|
|
518
|
+
dcbMock.readAfters.contents->Array.some(Option.isSome),
|
|
519
|
+
outcomeOf(theirs.meta.msgId),
|
|
520
|
+
))->toEqual((true, forbidden))
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
testPromise("two owners in one partition refuse even an operator", async () => {
|
|
524
|
+
await seedOrder()
|
|
525
|
+
let _ = await dcbMock.operations.append([
|
|
526
|
+
rawEvent(
|
|
527
|
+
~eventType="OrderPlaced",
|
|
528
|
+
~fields=[("orderId", str("o-1")), ("customerId", str("cust-B"))],
|
|
529
|
+
~tags=[("orderId", "o-1")],
|
|
530
|
+
),
|
|
531
|
+
])
|
|
532
|
+
let cmd = await dcbGenerate->generateOne(
|
|
533
|
+
~command="CancelOrder",
|
|
534
|
+
~args=[("orderId", str("o-1"))],
|
|
535
|
+
~identity=operator,
|
|
536
|
+
)
|
|
537
|
+
let _ = await deliverDcb(Inline, cmd)
|
|
538
|
+
expect(outcomeOf(cmd.meta.msgId))->toEqual(forbidden)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
// A partition the handler cannot name is one whose owner it cannot read.
|
|
542
|
+
// Reading that as "no owner yet" would admit everybody.
|
|
543
|
+
testPromise("an unnameable partition refuses an owner and admits an operator", async () => {
|
|
544
|
+
await seedOrder()
|
|
545
|
+
let unnamed = Reventless.DcbTag.ByEventType(Dict.make())
|
|
546
|
+
let mine = await dcbGenerate->generateOne(
|
|
547
|
+
~command="CancelOrder",
|
|
548
|
+
~args=[("orderId", str("o-1"))],
|
|
549
|
+
~identity=owner,
|
|
550
|
+
)
|
|
551
|
+
let _ = await deliverDcb(~partitionTag=unnamed, Inline, mine)
|
|
552
|
+
let ops = await dcbGenerate->generateOne(
|
|
553
|
+
~command="CancelOrder",
|
|
554
|
+
~args=[("orderId", str("o-1"))],
|
|
555
|
+
~identity=operator,
|
|
556
|
+
)
|
|
557
|
+
let _ = await deliverDcb(~partitionTag=unnamed, Inline, ops)
|
|
558
|
+
expect((outcomeOf(mine.meta.msgId), outcomeOf(ops.meta.msgId)))->toEqual((forbidden, Accepted))
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
describe("acting on an owned thing — aggregate specifics:", () => {
|
|
563
|
+
let _ = beforeEach(() => {
|
|
564
|
+
accountEvents := []
|
|
565
|
+
snapshotReads := 0
|
|
566
|
+
AccountHandler.resetCache()
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
// One batch, one replay: the owner the first command records is the one the
|
|
570
|
+
// second is checked against.
|
|
571
|
+
testPromise(
|
|
572
|
+
"a command later in the same batch is checked against an owner just recorded",
|
|
573
|
+
async () => {
|
|
574
|
+
let opened = await aggregateGenerate->generateOne(
|
|
575
|
+
~command="Open",
|
|
576
|
+
~args=[("id", str("acc-1")), ("holderId", str("cust-A")), ("name", str("Main"))],
|
|
577
|
+
~identity=owner,
|
|
578
|
+
)
|
|
579
|
+
let renamed = await aggregateGenerate->generateOne(
|
|
580
|
+
~command="Rename",
|
|
581
|
+
~args=[("id", str("acc-1")), ("newName", str("Mine now"))],
|
|
582
|
+
~identity=stranger,
|
|
583
|
+
)
|
|
584
|
+
await deliverAggregate(Inline, [opened, renamed])
|
|
585
|
+
expect((outcomeOf(opened.meta.msgId), outcomeOf(renamed.meta.msgId)))->toEqual((
|
|
586
|
+
Accepted,
|
|
587
|
+
forbidden,
|
|
588
|
+
))
|
|
589
|
+
},
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
testPromise("an owner-marked aggregate never seeds from a snapshot", async () => {
|
|
593
|
+
let opened = await aggregateGenerate->generateOne(
|
|
594
|
+
~command="Open",
|
|
595
|
+
~args=[("id", str("acc-1")), ("holderId", str("cust-A")), ("name", str("Main"))],
|
|
596
|
+
~identity=owner,
|
|
597
|
+
)
|
|
598
|
+
await deliverAggregate(Inline, [opened])
|
|
599
|
+
expect(snapshotReads.contents)->toBe(0)
|
|
600
|
+
})
|
|
601
|
+
})
|