@reventlessdev/reventless-core 3.0.0-alpha.284 → 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 +8 -0
- package/package.json +6 -6
- 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
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.285 (2026-10-01)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **spec, aws, core, local:** an app declares its environments, and every deployment records its commit ([c7cc925](https://github.com/ReventlessDev/reventless-core/commit/c7cc92573e97742b681a9000cea0bdfd6e9a33fc))
|
|
11
|
+
* **spec, core, gwt, ppx:** a command acts only on what its caller owns ([2636f0e](https://github.com/ReventlessDev/reventless-core/commit/2636f0ee44797ee2c0b0c40a7107cf306113173f))
|
|
12
|
+
|
|
13
|
+
|
|
6
14
|
# 3.0.0-alpha.284 (2026-09-29)
|
|
7
15
|
|
|
8
16
|
**Note:** Version bump only for package @reventlessdev/reventless-core
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.285",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.34",
|
|
32
31
|
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.20",
|
|
32
|
+
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
|
|
33
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.34",
|
|
33
34
|
"@reventlessdev/rescript-node": "2.0.0-alpha.20",
|
|
34
35
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.11",
|
|
35
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.22",
|
|
36
|
-
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.21",
|
|
38
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.182",
|
|
39
|
-
"@reventlessdev/reventless-interop": "3.0.0-alpha.38",
|
|
40
38
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
|
|
41
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
39
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.155",
|
|
40
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.38",
|
|
41
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.183"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.1",
|
package/src/Message.res.mjs
CHANGED
|
@@ -381,6 +381,8 @@ function combineMessage(typ, data) {
|
|
|
381
381
|
|
|
382
382
|
let serviceSchema = Message$Reventless.serviceSchema;
|
|
383
383
|
|
|
384
|
+
let CallerClaim = Message$Reventless.CallerClaim;
|
|
385
|
+
|
|
384
386
|
let metaSchema = Message$Reventless.metaSchema;
|
|
385
387
|
|
|
386
388
|
let contextSchema = Message$Reventless.contextSchema;
|
|
@@ -402,6 +404,7 @@ let InvalidEvent = Message$Reventless.InvalidEvent;
|
|
|
402
404
|
export {
|
|
403
405
|
logger,
|
|
404
406
|
serviceSchema,
|
|
407
|
+
CallerClaim,
|
|
405
408
|
metaSchema,
|
|
406
409
|
contextSchema,
|
|
407
410
|
statusChangeSchema,
|
|
@@ -57,10 +57,29 @@ module Make = (
|
|
|
57
57
|
let updateMeta = (command': Message.command'<'id, 'command>) =>
|
|
58
58
|
Message.deriveMeta(~parent=command'.meta)
|
|
59
59
|
|
|
60
|
+
// The events whose `@owner` field names who owns this aggregate. Empty for an
|
|
61
|
+
// aggregate that marks none, and then nothing is enforced.
|
|
62
|
+
let ownerFieldsByEventType = Reventless.Owner.fieldNamesByVariant(
|
|
63
|
+
Spec.eventSchema->S.castToUnknown,
|
|
64
|
+
)
|
|
65
|
+
let actsOnOwned = ownerFieldsByEventType->Dict.keysToArray->Array.length > 0
|
|
66
|
+
|
|
67
|
+
let recordEventOwners = (owners, event: Spec.event) =>
|
|
68
|
+
if actsOnOwned {
|
|
69
|
+
let (eventType, data) = event->Message.encode(Spec.eventSchema)->Message.splitMessage
|
|
70
|
+
switch ownerFieldsByEventType->Dict.get(eventType) {
|
|
71
|
+
| Some(fields) => owners->Reventless.OwnerScope.recordOwners(~fields, data)
|
|
72
|
+
| None => owners
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
owners
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
// Per-command outcome carried through the fold, paired with the originating reference and meta.
|
|
61
79
|
type cmdOutcome =
|
|
62
80
|
| CmdOk(array<Spec.event>)
|
|
63
81
|
| CmdRejected({errorCode: string, errorDetail: string})
|
|
82
|
+
| CmdRefused(CommandTopic_Helpers.ownershipRefusal)
|
|
64
83
|
|
|
65
84
|
// Folds a single command into the running accumulator: (currentState, perCommandOutcomes).
|
|
66
85
|
// A domain rejection from Behavior.decide is recorded as `CmdRejected` (no state change,
|
|
@@ -69,9 +88,16 @@ module Make = (
|
|
|
69
88
|
//
|
|
70
89
|
// `~seq` is the replayed sequence number the batch decides from, carried in so
|
|
71
90
|
// the line below can name the state rather than serialise it.
|
|
91
|
+
//
|
|
92
|
+
// `owners` are the distinct owners the stream records so far, including any a
|
|
93
|
+
// command earlier in the same batch just recorded.
|
|
72
94
|
let processCommand = (~seq: int) =>
|
|
73
95
|
(
|
|
74
|
-
(state, outcomes): (
|
|
96
|
+
(state, owners, outcomes): (
|
|
97
|
+
Behavior.state,
|
|
98
|
+
array<string>,
|
|
99
|
+
array<(string, cmdOutcome, Message.meta)>,
|
|
100
|
+
),
|
|
75
101
|
topicItem: CommandTopic.topicItem<Message.command'<Spec.Id.t, Spec.command>>,
|
|
76
102
|
) => {
|
|
77
103
|
let {reference, command: command'} = topicItem
|
|
@@ -81,33 +107,57 @@ module Make = (
|
|
|
81
107
|
// history, so serialising it here makes the line's size a function of how
|
|
82
108
|
// long the system has been in use. The state at decision time is a
|
|
83
109
|
// debugger's subject, not a log's.
|
|
84
|
-
let
|
|
85
|
-
command'.command
|
|
86
|
-
|
|
87
|
-
->Message.variantNameOfJson
|
|
88
|
-
->LogFormat.bold
|
|
110
|
+
let cmdVariant =
|
|
111
|
+
command'.command->Message.encode(Spec.commandSchema)->Message.variantNameOfJson
|
|
112
|
+
let cmdName = cmdVariant->LogFormat.bold
|
|
89
113
|
EffectLogger.logDebug(
|
|
90
114
|
~comp,
|
|
91
115
|
`deciding: id=${id} seq=${seq->Int.toString} cmd=${cmdName}`,
|
|
92
116
|
)->Effect.runSync
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
~comp,
|
|
108
|
-
`decide rejected: ${errorCode} ${errorDetail} id=${id}`,
|
|
117
|
+
// Before `decide`: an owner-marked aggregate refuses a caller who does not
|
|
118
|
+
// own the stream.
|
|
119
|
+
let refusal = actsOnOwned
|
|
120
|
+
? Reventless.OwnerScope.decideActing(
|
|
121
|
+
~claim=command'.meta.callerClaim,
|
|
122
|
+
~recordedOwners=Some(owners),
|
|
123
|
+
)->CommandTopic_Helpers.ownershipRefusal(~component=Spec.name, ~command=cmdVariant)
|
|
124
|
+
: None
|
|
125
|
+
switch refusal {
|
|
126
|
+
| Some(refused) =>
|
|
127
|
+
let line = `refused: ${refused.rejected.errorDetail} id=${id}`
|
|
128
|
+
|
|
129
|
+
(
|
|
130
|
+
refused.isDefect ? EffectLogger.logError(~comp, line) : EffectLogger.logWarn(~comp, line)
|
|
109
131
|
)->Effect.runSync
|
|
110
|
-
(state, Array.concat(outcomes, [(reference,
|
|
132
|
+
(state, owners, Array.concat(outcomes, [(reference, CmdRefused(refused), meta)]))
|
|
133
|
+
| None =>
|
|
134
|
+
switch Behavior.decide(state, command'.command) {
|
|
135
|
+
| Ok(generatedEvents) =>
|
|
136
|
+
let newState = generatedEvents->Array.reduce(state, Behavior.evolve)
|
|
137
|
+
(
|
|
138
|
+
newState,
|
|
139
|
+
generatedEvents->Array.reduce(owners, recordEventOwners),
|
|
140
|
+
Array.concat(outcomes, [(reference, CmdOk(generatedEvents), meta)]),
|
|
141
|
+
)
|
|
142
|
+
| Error(error) =>
|
|
143
|
+
let errorJson = error->Message.encode(Spec.errorSchema)
|
|
144
|
+
let errorCode = errorJson->Message.variantNameOfJson
|
|
145
|
+
// Strip the TAG so detail carries only the rejection payload (empty for unit errors).
|
|
146
|
+
let (_, payloadDict) = errorJson->Message.splitMessage
|
|
147
|
+
let errorDetail =
|
|
148
|
+
payloadDict->Dict.toArray->Array.length == 0
|
|
149
|
+
? ""
|
|
150
|
+
: payloadDict->JSON.Encode.object->JSON.stringify
|
|
151
|
+
EffectLogger.logError(
|
|
152
|
+
~comp,
|
|
153
|
+
`decide rejected: ${errorCode} ${errorDetail} id=${id}`,
|
|
154
|
+
)->Effect.runSync
|
|
155
|
+
(
|
|
156
|
+
state,
|
|
157
|
+
owners,
|
|
158
|
+
Array.concat(outcomes, [(reference, CmdRejected({errorCode, errorDetail}), meta)]),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
111
161
|
}
|
|
112
162
|
}
|
|
113
163
|
|
|
@@ -115,8 +165,8 @@ module Make = (
|
|
|
115
165
|
|
|
116
166
|
// In-process replay cache (per warm Lambda instance, per aggregate — the
|
|
117
167
|
// functor instance owns the cache, so the event log is implicit and the key
|
|
118
|
-
// is just the aggregate id). Holds the `(state, sequenceNr)` the
|
|
119
|
-
// command batch for that id left behind.
|
|
168
|
+
// is just the aggregate id). Holds the `(state, sequenceNr, owners)` the
|
|
169
|
+
// previous command batch for that id left behind.
|
|
120
170
|
//
|
|
121
171
|
// On a hit, `replayProcessAppend` skips the event-log replay entirely and
|
|
122
172
|
// decides on the cached state; after a successful append it stores the
|
|
@@ -129,7 +179,9 @@ module Make = (
|
|
|
129
179
|
// the fixed capacity (a per-aggregate knob is a future refinement — see
|
|
130
180
|
// docs/plans/done/aggregate-snapshotting.md).
|
|
131
181
|
let replayCacheCapacity = 100
|
|
132
|
-
let replayCache: Lru.t<string, (Behavior.state, int)> = Lru.make(
|
|
182
|
+
let replayCache: Lru.t<string, (Behavior.state, int, array<string>)> = Lru.make(
|
|
183
|
+
~capacity=replayCacheCapacity,
|
|
184
|
+
)
|
|
133
185
|
|
|
134
186
|
let resetCache = () => replayCache->Lru.clear
|
|
135
187
|
|
|
@@ -139,7 +191,17 @@ module Make = (
|
|
|
139
191
|
// one every `interval` events. Snapshots are a read optimization only — the
|
|
140
192
|
// OCC append remains the sole consistency primitive, so a missing, drifted,
|
|
141
193
|
// or corrupt snapshot degrades silently to full replay.
|
|
142
|
-
|
|
194
|
+
//
|
|
195
|
+
// Off for an owner-marked aggregate: a snapshot holds the state alone, so a
|
|
196
|
+
// replay seeded from one would never see the event that recorded the owner,
|
|
197
|
+
// and the ownership check would pass every caller.
|
|
198
|
+
let snapshotConfig = actsOnOwned ? None : Behavior.snapshot
|
|
199
|
+
if actsOnOwned && Behavior.snapshot->Option.isSome {
|
|
200
|
+
EffectLogger.logWarn(
|
|
201
|
+
~comp,
|
|
202
|
+
"snapshots disabled: this aggregate records an @owner, which a snapshot does not carry",
|
|
203
|
+
)->Effect.runSync
|
|
204
|
+
}
|
|
143
205
|
|
|
144
206
|
// Structural hash of the state schema, computed once. Stored on every written
|
|
145
207
|
// snapshot and compared on read: a snapshot whose hash differs from the
|
|
@@ -183,8 +245,8 @@ module Make = (
|
|
|
183
245
|
|
|
184
246
|
// Cold read (in-process cache miss): when snapshots are enabled, seed from the
|
|
185
247
|
// latest persisted snapshot (hash-gated) and replay only the events after it;
|
|
186
|
-
// otherwise replay full history from seq 0. Returns `(state, sequenceNr)`
|
|
187
|
-
// sequenceNr is the total event count (the OCC condition for the next append).
|
|
248
|
+
// otherwise replay full history from seq 0. Returns `(state, sequenceNr, owners)`
|
|
249
|
+
// where sequenceNr is the total event count (the OCC condition for the next append).
|
|
188
250
|
let coldReadState = id => {
|
|
189
251
|
let idStr = id->Spec.Id.toString
|
|
190
252
|
let seedEffect = switch snapshotConfig {
|
|
@@ -220,8 +282,12 @@ module Make = (
|
|
|
220
282
|
}
|
|
221
283
|
seedEffect->Effect.flatMap(((seedState, seedSeq)) =>
|
|
222
284
|
Ops.eventLog.replayStream(id, ~fromSeq=seedSeq)
|
|
223
|
-
->Stream.runFold((seedState, seedSeq), ((st, n), ev) => (
|
|
224
|
-
|
|
285
|
+
->Stream.runFold((seedState, seedSeq, []), ((st, n, owners), ev) => (
|
|
286
|
+
Behavior.evolve(st, ev),
|
|
287
|
+
n + 1,
|
|
288
|
+
owners->recordEventOwners(ev),
|
|
289
|
+
))
|
|
290
|
+
->Effect.tap(((_, n, _)) => {
|
|
225
291
|
let detail =
|
|
226
292
|
seedSeq > 0
|
|
227
293
|
? `(snapshot@${seedSeq->Int.toString}, ${(n - seedSeq)->Int.toString} delta event(s))`
|
|
@@ -305,6 +371,14 @@ module Make = (
|
|
|
305
371
|
{errorCode, errorDetail},
|
|
306
372
|
)
|
|
307
373
|
Ok(reference)
|
|
374
|
+
| CmdRefused({rejected}) =>
|
|
375
|
+
CommandTopic_Helpers.reportRejected(
|
|
376
|
+
~component=Spec.name,
|
|
377
|
+
~cause=AccessRefusal,
|
|
378
|
+
reference,
|
|
379
|
+
rejected,
|
|
380
|
+
)
|
|
381
|
+
Ok(reference)
|
|
308
382
|
| CmdOk(_) if appendSucceeded =>
|
|
309
383
|
CommandTopic_Helpers.reportAccepted(
|
|
310
384
|
~component=Spec.name,
|
|
@@ -339,24 +413,27 @@ module Make = (
|
|
|
339
413
|
// path (`coldReadState`): seed from the persisted snapshot when enabled, else
|
|
340
414
|
// full replay.
|
|
341
415
|
let readState = switch replayCache->Lru.get(idStr) {
|
|
342
|
-
| Some((state, seqNr)) =>
|
|
416
|
+
| Some((state, seqNr, owners)) =>
|
|
343
417
|
EffectLogger.logInfo(
|
|
344
418
|
~comp,
|
|
345
419
|
`replay skipped (cached): id=${idStr}, seq=${seqNr->Int.toString}`,
|
|
346
|
-
)->Effect.map(_ => (state, seqNr))
|
|
420
|
+
)->Effect.map(_ => (state, seqNr, owners))
|
|
347
421
|
| None => coldReadState(id)
|
|
348
422
|
}
|
|
349
423
|
|
|
350
|
-
readState->Effect.flatMap(((initialState, sequenceNr)) => {
|
|
351
|
-
let (finalState, outcomes) =
|
|
352
|
-
topicItemsForId->Array.reduce(
|
|
424
|
+
readState->Effect.flatMap(((initialState, sequenceNr, initialOwners)) => {
|
|
425
|
+
let (finalState, finalOwners, outcomes) =
|
|
426
|
+
topicItemsForId->Array.reduce(
|
|
427
|
+
(initialState, initialOwners, []),
|
|
428
|
+
processCommand(~seq=sequenceNr),
|
|
429
|
+
)
|
|
353
430
|
|
|
354
431
|
let eventsToAppend =
|
|
355
432
|
outcomes
|
|
356
433
|
->Array.map(((_reference, outcome, meta)) =>
|
|
357
434
|
switch outcome {
|
|
358
435
|
| CmdOk(events) => events->Array.map(event => {Message.id, meta, event})
|
|
359
|
-
| CmdRejected(_) => []
|
|
436
|
+
| CmdRejected(_) | CmdRefused(_) => []
|
|
360
437
|
}
|
|
361
438
|
)
|
|
362
439
|
->Array.flat
|
|
@@ -365,7 +442,7 @@ module Make = (
|
|
|
365
442
|
| [] =>
|
|
366
443
|
// Nothing appended, but the read snapshot is valid — keep it warm for
|
|
367
444
|
// the next command (also refreshes recency on a cache hit).
|
|
368
|
-
replayCache->Lru.put(idStr, (initialState, sequenceNr))
|
|
445
|
+
replayCache->Lru.put(idStr, (initialState, sequenceNr, initialOwners))
|
|
369
446
|
let perRef = reportFinalOutcomes(
|
|
370
447
|
outcomes,
|
|
371
448
|
~entityId=idStr,
|
|
@@ -392,7 +469,7 @@ module Make = (
|
|
|
392
469
|
// The post-decide fold state IS the post-append replay result;
|
|
393
470
|
// cache it so the next command for this id skips the replay.
|
|
394
471
|
let newSeq = sequenceNr + generatedEvents'->Array.length
|
|
395
|
-
replayCache->Lru.put(idStr, (finalState, newSeq))
|
|
472
|
+
replayCache->Lru.put(idStr, (finalState, newSeq, finalOwners))
|
|
396
473
|
// Persist a snapshot if this append crossed an interval boundary
|
|
397
474
|
// (fire-and-forget — never blocks or fails the command).
|
|
398
475
|
maybeWriteSnapshot(id, idStr, ~oldSeq=sequenceNr, ~newSeq, finalState)
|
|
@@ -8,9 +8,11 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
8
8
|
import * as Effect from "effect/Effect";
|
|
9
9
|
import * as Stream from "effect/Stream";
|
|
10
10
|
import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
|
|
11
|
+
import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
|
|
11
12
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
12
13
|
import * as Lru$ReventlessCore from "../../util/Lru.res.mjs";
|
|
13
14
|
import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
|
|
15
|
+
import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
|
|
14
16
|
import * as Message$ReventlessCore from "../../Message.res.mjs";
|
|
15
17
|
import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
|
|
16
18
|
import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
|
|
@@ -29,21 +31,55 @@ function Make(Spec) {
|
|
|
29
31
|
Spec.Id.makeFromString(param[0]),
|
|
30
32
|
param[1]
|
|
31
33
|
]));
|
|
34
|
+
let ownerFieldsByEventType = Owner$Reventless.fieldNamesByVariant(Spec.eventSchema);
|
|
35
|
+
let actsOnOwned = Object.keys(ownerFieldsByEventType).length !== 0;
|
|
36
|
+
let recordEventOwners = (owners, event) => {
|
|
37
|
+
if (!actsOnOwned) {
|
|
38
|
+
return owners;
|
|
39
|
+
}
|
|
40
|
+
let match = Message$ReventlessCore.splitMessage(Message$ReventlessCore.encode(event, Spec.eventSchema));
|
|
41
|
+
let fields = ownerFieldsByEventType[match[0]];
|
|
42
|
+
if (fields !== undefined) {
|
|
43
|
+
return OwnerScope$Reventless.recordOwners(owners, fields, match[1]);
|
|
44
|
+
} else {
|
|
45
|
+
return owners;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
32
48
|
let processCommand = seq => ((param, topicItem) => {
|
|
33
49
|
let reference = topicItem.reference;
|
|
34
50
|
let command$p = topicItem.command;
|
|
35
|
-
let outcomes = param[
|
|
51
|
+
let outcomes = param[2];
|
|
52
|
+
let owners = param[1];
|
|
36
53
|
let state = param[0];
|
|
37
54
|
let meta = Message$ReventlessCore.deriveMeta(command$p.meta, undefined);
|
|
38
55
|
let id = Spec.Id.toString(command$p.id);
|
|
39
|
-
let
|
|
56
|
+
let cmdVariant = Message$ReventlessCore.variantNameOfJson(Message$ReventlessCore.encode(command$p.command, Spec.commandSchema));
|
|
57
|
+
let cmdName = LogFormat$ReventlessCore.bold(cmdVariant);
|
|
40
58
|
Effect.runSync(EffectLogger$ReventlessCore.logDebug(comp, undefined, `deciding: id=` + id + ` seq=` + seq.toString() + ` cmd=` + cmdName));
|
|
59
|
+
let refusal = actsOnOwned ? CommandTopic_Helpers$ReventlessCore.ownershipRefusal(OwnerScope$Reventless.decideActing(command$p.meta.callerClaim, owners), Spec.name, cmdVariant) : undefined;
|
|
60
|
+
if (refusal !== undefined) {
|
|
61
|
+
let line = `refused: ` + refusal.rejected.errorDetail + ` id=` + id;
|
|
62
|
+
Effect.runSync(refusal.isDefect ? EffectLogger$ReventlessCore.logError(comp, undefined, line) : EffectLogger$ReventlessCore.logWarn(comp, undefined, line));
|
|
63
|
+
return [
|
|
64
|
+
state,
|
|
65
|
+
owners,
|
|
66
|
+
outcomes.concat([[
|
|
67
|
+
reference,
|
|
68
|
+
{
|
|
69
|
+
TAG: "CmdRefused",
|
|
70
|
+
_0: refusal
|
|
71
|
+
},
|
|
72
|
+
meta
|
|
73
|
+
]])
|
|
74
|
+
];
|
|
75
|
+
}
|
|
41
76
|
let generatedEvents = Behavior.decide(state, command$p.command);
|
|
42
77
|
if (generatedEvents.TAG === "Ok") {
|
|
43
78
|
let generatedEvents$1 = generatedEvents._0;
|
|
44
79
|
let newState = Stdlib_Array.reduce(generatedEvents$1, state, Behavior.evolve);
|
|
45
80
|
return [
|
|
46
81
|
newState,
|
|
82
|
+
Stdlib_Array.reduce(generatedEvents$1, owners, recordEventOwners),
|
|
47
83
|
outcomes.concat([[
|
|
48
84
|
reference,
|
|
49
85
|
{
|
|
@@ -62,6 +98,7 @@ function Make(Spec) {
|
|
|
62
98
|
Effect.runSync(EffectLogger$ReventlessCore.logError(comp, undefined, `decide rejected: ` + errorCode + ` ` + errorDetail + ` id=` + id));
|
|
63
99
|
return [
|
|
64
100
|
state,
|
|
101
|
+
owners,
|
|
65
102
|
outcomes.concat([[
|
|
66
103
|
reference,
|
|
67
104
|
{
|
|
@@ -75,7 +112,10 @@ function Make(Spec) {
|
|
|
75
112
|
});
|
|
76
113
|
let replayCache = Lru$ReventlessCore.make(100);
|
|
77
114
|
let resetCache = () => Lru$ReventlessCore.clear(replayCache);
|
|
78
|
-
let snapshotConfig = Behavior.snapshot;
|
|
115
|
+
let snapshotConfig = actsOnOwned ? undefined : Behavior.snapshot;
|
|
116
|
+
if (actsOnOwned && Stdlib_Option.isSome(Behavior.snapshot)) {
|
|
117
|
+
Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, "snapshots disabled: this aggregate records an @owner, which a snapshot does not carry"));
|
|
118
|
+
}
|
|
79
119
|
let stateSchemaHash = snapshotConfig !== undefined ? HashObject(Object.fromEntries([[
|
|
80
120
|
"state",
|
|
81
121
|
SchemaWalker$ReventlessCore.describeSchema(snapshotConfig.stateSchema)
|
|
@@ -151,10 +191,12 @@ function Make(Spec) {
|
|
|
151
191
|
let seedSeq = param[1];
|
|
152
192
|
return Effect.tap(Stream.runFold(Ops.eventLog.replayStream(id, seedSeq), [
|
|
153
193
|
param[0],
|
|
154
|
-
seedSeq
|
|
194
|
+
seedSeq,
|
|
195
|
+
[]
|
|
155
196
|
], (param, ev) => [
|
|
156
197
|
Behavior.evolve(param[0], ev),
|
|
157
|
-
param[1] + 1 | 0
|
|
198
|
+
param[1] + 1 | 0,
|
|
199
|
+
recordEventOwners(param[2], ev)
|
|
158
200
|
]), param => {
|
|
159
201
|
let n = param[1];
|
|
160
202
|
let detail = seedSeq > 0 ? `(snapshot@` + seedSeq.toString() + `, ` + (n - seedSeq | 0).toString() + ` delta event(s))` : n.toString() + ` event(s)`;
|
|
@@ -199,35 +241,43 @@ function Make(Spec) {
|
|
|
199
241
|
return outcomes.map(param => {
|
|
200
242
|
let outcome = param[1];
|
|
201
243
|
let reference = param[0];
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
244
|
+
switch (outcome.TAG) {
|
|
245
|
+
case "CmdOk" :
|
|
246
|
+
if (appendSucceeded) {
|
|
247
|
+
CommandTopic_Helpers$ReventlessCore.reportAccepted(Spec.name, reference, {
|
|
248
|
+
entityId: entityId,
|
|
249
|
+
eventCount: appendedEventCount
|
|
250
|
+
});
|
|
251
|
+
return {
|
|
252
|
+
TAG: "Ok",
|
|
253
|
+
_0: reference
|
|
254
|
+
};
|
|
255
|
+
} else {
|
|
256
|
+
CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "InfrastructureFailure", reference, {
|
|
257
|
+
errorCode: "AppendFailed",
|
|
258
|
+
errorDetail: appendErrorDetail
|
|
259
|
+
});
|
|
260
|
+
return {
|
|
261
|
+
TAG: "Error",
|
|
262
|
+
_0: reference
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
case "CmdRejected" :
|
|
266
|
+
CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "DomainRejection", reference, {
|
|
267
|
+
errorCode: outcome.errorCode,
|
|
268
|
+
errorDetail: outcome.errorDetail
|
|
207
269
|
});
|
|
208
270
|
return {
|
|
209
271
|
TAG: "Ok",
|
|
210
272
|
_0: reference
|
|
211
273
|
};
|
|
212
|
-
|
|
213
|
-
CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "
|
|
214
|
-
errorCode: "AppendFailed",
|
|
215
|
-
errorDetail: appendErrorDetail
|
|
216
|
-
});
|
|
274
|
+
case "CmdRefused" :
|
|
275
|
+
CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "AccessRefusal", reference, outcome._0.rejected);
|
|
217
276
|
return {
|
|
218
|
-
TAG: "
|
|
277
|
+
TAG: "Ok",
|
|
219
278
|
_0: reference
|
|
220
279
|
};
|
|
221
|
-
}
|
|
222
280
|
}
|
|
223
|
-
CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "DomainRejection", reference, {
|
|
224
|
-
errorCode: outcome.errorCode,
|
|
225
|
-
errorDetail: outcome.errorDetail
|
|
226
|
-
});
|
|
227
|
-
return {
|
|
228
|
-
TAG: "Ok",
|
|
229
|
-
_0: reference
|
|
230
|
-
};
|
|
231
281
|
});
|
|
232
282
|
};
|
|
233
283
|
let replayProcessAppend = (id, topicItemsForId) => {
|
|
@@ -235,35 +285,42 @@ function Make(Spec) {
|
|
|
235
285
|
let match = Lru$ReventlessCore.get(replayCache, idStr);
|
|
236
286
|
let readState;
|
|
237
287
|
if (match !== undefined) {
|
|
288
|
+
let owners = match[2];
|
|
238
289
|
let seqNr = match[1];
|
|
239
290
|
let state = match[0];
|
|
240
291
|
readState = Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay skipped (cached): id=` + idStr + `, seq=` + seqNr.toString()), () => [
|
|
241
292
|
state,
|
|
242
|
-
seqNr
|
|
293
|
+
seqNr,
|
|
294
|
+
owners
|
|
243
295
|
]);
|
|
244
296
|
} else {
|
|
245
297
|
readState = coldReadState(id);
|
|
246
298
|
}
|
|
247
299
|
return Effect.flatMap(readState, param => {
|
|
300
|
+
let initialOwners = param[2];
|
|
248
301
|
let sequenceNr = param[1];
|
|
249
302
|
let initialState = param[0];
|
|
250
303
|
let match = Stdlib_Array.reduce(topicItemsForId, [
|
|
251
304
|
initialState,
|
|
305
|
+
initialOwners,
|
|
252
306
|
[]
|
|
253
307
|
], processCommand(sequenceNr));
|
|
254
|
-
let outcomes = match[
|
|
308
|
+
let outcomes = match[2];
|
|
309
|
+
let finalOwners = match[1];
|
|
255
310
|
let finalState = match[0];
|
|
256
311
|
let eventsToAppend = outcomes.map(param => {
|
|
257
312
|
let meta = param[2];
|
|
258
313
|
let outcome = param[1];
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
314
|
+
switch (outcome.TAG) {
|
|
315
|
+
case "CmdOk" :
|
|
316
|
+
return outcome._0.map(event => ({
|
|
317
|
+
id: id,
|
|
318
|
+
meta: meta,
|
|
319
|
+
event: event
|
|
320
|
+
}));
|
|
321
|
+
case "CmdRejected" :
|
|
322
|
+
case "CmdRefused" :
|
|
323
|
+
return [];
|
|
267
324
|
}
|
|
268
325
|
}).flat();
|
|
269
326
|
if (eventsToAppend.length !== 0) {
|
|
@@ -280,7 +337,8 @@ function Make(Spec) {
|
|
|
280
337
|
let newSeq = sequenceNr + eventsToAppend.length | 0;
|
|
281
338
|
Lru$ReventlessCore.put(replayCache, idStr, [
|
|
282
339
|
finalState,
|
|
283
|
-
newSeq
|
|
340
|
+
newSeq,
|
|
341
|
+
finalOwners
|
|
284
342
|
]);
|
|
285
343
|
maybeWriteSnapshot(id, idStr, sequenceNr, newSeq, finalState);
|
|
286
344
|
let perRef = reportFinalOutcomes(outcomes, idStr, true, eventsToAppend.length, undefined);
|
|
@@ -308,7 +366,8 @@ function Make(Spec) {
|
|
|
308
366
|
}
|
|
309
367
|
Lru$ReventlessCore.put(replayCache, idStr, [
|
|
310
368
|
initialState,
|
|
311
|
-
sequenceNr
|
|
369
|
+
sequenceNr,
|
|
370
|
+
initialOwners
|
|
312
371
|
]);
|
|
313
372
|
let perRef = reportFinalOutcomes(outcomes, idStr, true, 0, undefined);
|
|
314
373
|
return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `no events produced: id=` + idStr), () => ({
|
|
@@ -55,13 +55,13 @@ let stampOwnerFields = (
|
|
|
55
55
|
obj: dict<JSON.t>,
|
|
56
56
|
~commandSchema: S.t<unknown>,
|
|
57
57
|
~command: string,
|
|
58
|
-
~
|
|
58
|
+
~scope: Reventless.OwnerScope.t,
|
|
59
59
|
~serviceName: string,
|
|
60
60
|
) =>
|
|
61
61
|
switch Reventless.Owner.variantFieldNames(commandSchema, ~variant=command) {
|
|
62
62
|
| [] => ()
|
|
63
63
|
| ownerFields =>
|
|
64
|
-
switch
|
|
64
|
+
switch scope {
|
|
65
65
|
| System | Elevated(_) => ()
|
|
66
66
|
| Owned({userId}) =>
|
|
67
67
|
ownerFields->Array.forEach(field => obj->Dict.set(field, JSON.Encode.string(userId)))
|
|
@@ -118,6 +118,11 @@ let makeGenerateCommand = (
|
|
|
118
118
|
(payload: CommandGenerator.payload) =>
|
|
119
119
|
Effect.sync(() => {
|
|
120
120
|
let msgId = Message.uuid()
|
|
121
|
+
// One classification for the stamp below and for the claim the handler
|
|
122
|
+
// checks ownership against, so the two cannot disagree about the caller.
|
|
123
|
+
// The claim is built here and nowhere from the payload, which is all a
|
|
124
|
+
// client controls.
|
|
125
|
+
let scope = payload.identity->Reventless.OwnerScope.resolve
|
|
121
126
|
let meta = {
|
|
122
127
|
{
|
|
123
128
|
Message.service: serviceName,
|
|
@@ -126,6 +131,7 @@ let makeGenerateCommand = (
|
|
|
126
131
|
time: Message.nowAsISOString(),
|
|
127
132
|
msgId,
|
|
128
133
|
correlationId: msgId,
|
|
134
|
+
callerClaim: scope->Reventless.OwnerScope.toClaim,
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
let params = switch payload.arguments
|
|
@@ -136,12 +142,7 @@ let makeGenerateCommand = (
|
|
|
136
142
|
obj->Dict.delete("id")
|
|
137
143
|
}
|
|
138
144
|
obj->dropNullArguments
|
|
139
|
-
obj->stampOwnerFields(
|
|
140
|
-
~commandSchema,
|
|
141
|
-
~command=payload.command,
|
|
142
|
-
~identity=payload.identity,
|
|
143
|
-
~serviceName,
|
|
144
|
-
)
|
|
145
|
+
obj->stampOwnerFields(~commandSchema, ~command=payload.command, ~scope, ~serviceName)
|
|
145
146
|
obj->Dict.toArray
|
|
146
147
|
}
|
|
147
148
|
| None =>
|
|
@@ -27,26 +27,25 @@ function clearCommandInterceptor() {
|
|
|
27
27
|
commandInterceptorHook.contents = undefined;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function stampOwnerFields(obj, commandSchema, command,
|
|
30
|
+
function stampOwnerFields(obj, commandSchema, command, scope, serviceName) {
|
|
31
31
|
let ownerFields = Owner$Reventless.variantFieldNames(commandSchema, command);
|
|
32
32
|
if (ownerFields.length === 0) {
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
if (typeof why !== "object") {
|
|
35
|
+
if (typeof scope !== "object") {
|
|
37
36
|
return;
|
|
38
37
|
}
|
|
39
|
-
switch (
|
|
38
|
+
switch (scope.TAG) {
|
|
40
39
|
case "Elevated" :
|
|
41
40
|
return;
|
|
42
41
|
case "Owned" :
|
|
43
|
-
let userId =
|
|
42
|
+
let userId = scope.userId;
|
|
44
43
|
ownerFields.forEach(field => {
|
|
45
44
|
obj[field] = userId;
|
|
46
45
|
});
|
|
47
46
|
return;
|
|
48
47
|
case "Unidentified" :
|
|
49
|
-
return Plugin_ResolverError$ReventlessCore.throwCallerFault(`Forbidden: ` + serviceName + `.` + command + ` records an owner, but the caller could not be identified (` +
|
|
48
|
+
return Plugin_ResolverError$ReventlessCore.throwCallerFault(`Forbidden: ` + serviceName + `.` + command + ` records an owner, but the caller could not be identified (` + scope._0 + `)`);
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
|
|
@@ -63,19 +62,22 @@ function makeGenerateCommand(publishJsons, publishJsonsAndWait, serviceName, com
|
|
|
63
62
|
let stripIdFromParams = stripIdFromParamsOpt !== undefined ? stripIdFromParamsOpt : true;
|
|
64
63
|
return payload => Effect.flatMap(Effect.tap(Effect.sync(() => {
|
|
65
64
|
let msgId = Message$ReventlessCore.uuid();
|
|
65
|
+
let scope = OwnerScope$Reventless.resolve(payload.identity, undefined);
|
|
66
66
|
let meta_time = Message$ReventlessCore.nowAsISOString();
|
|
67
67
|
let meta_ip = Stdlib_Option.getOr(payload.meta.ip.shift(), "");
|
|
68
68
|
let meta_user = payload.meta.user;
|
|
69
|
+
let meta_callerClaim = OwnerScope$Reventless.toClaim(scope);
|
|
69
70
|
let meta = {
|
|
70
71
|
service: serviceName,
|
|
71
72
|
time: meta_time,
|
|
72
73
|
ip: meta_ip,
|
|
73
74
|
user: meta_user,
|
|
74
75
|
msgId: msgId,
|
|
75
|
-
correlationId: msgId
|
|
76
|
+
correlationId: msgId,
|
|
77
|
+
callerClaim: meta_callerClaim
|
|
76
78
|
};
|
|
77
79
|
let obj = Stdlib_Option.flatMap(JSON.stringify(payload.arguments), jsonString => Stdlib_JSON.Decode.object(JSON.parse(jsonString)));
|
|
78
|
-
let params = obj !== undefined ? (stripIdFromParams ? Stdlib_Dict.$$delete(obj, "id") : undefined, dropNullArguments(obj), stampOwnerFields(obj, commandSchema, payload.command,
|
|
80
|
+
let params = obj !== undefined ? (stripIdFromParams ? Stdlib_Dict.$$delete(obj, "id") : undefined, dropNullArguments(obj), stampOwnerFields(obj, commandSchema, payload.command, scope, serviceName), Object.entries(obj)) : Stdlib_JsError.throwWithMessage("Couldn't decode:" + Stdlib_Option.getOr(JSON.stringify(payload.arguments), "<payload.arguments>"));
|
|
79
81
|
let commandStr = payload.command;
|
|
80
82
|
let match = params.length;
|
|
81
83
|
let commandJson = match !== 0 ? Object.fromEntries([[
|