@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.
@@ -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
+ })