@reventlessdev/reventless-core 3.0.0-alpha.284 → 3.0.0-alpha.286
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 +23 -0
- package/package.json +7 -7
- package/src/Message.res.mjs +3 -0
- package/src/Roles.res +3 -0
- package/src/Roles.res.mjs +2 -0
- package/src/admin/Platform_AdminGroup.res +12 -0
- package/src/admin/Platform_AdminGroup.res.mjs +23 -0
- package/src/admin/Platform_Admin_Structure.res +14 -10
- package/src/admin/Platform_Admin_Structure.res.mjs +22 -33
- package/src/components/Aggregate/Aggregate_Callback.res +116 -39
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +96 -37
- package/src/components/AutomationSlice/AutomationSlice_Builder.res +2 -1
- 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/Counter/Counter_Builder.res +4 -2
- package/src/components/Dcb/Dcb_Builder.res +9 -6
- package/src/components/Dcb/Dcb_Builder.res.mjs +6 -6
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res +2 -1
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Builder.res +2 -1
- package/src/components/QueryDb/QueryDb_Builder.res +1 -1
- package/src/components/QueryDb/QueryDb_Builder.res.mjs +3 -2
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +191 -141
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +66 -12
- package/src/components/StateViewSlice/StateViewSlice_Builder.res +1 -0
- package/src/plugin/api/PluginBaseFragment.res +1 -1
- package/src/plugin/api/PluginBaseFragment.res.mjs +2 -1
- package/src/plugin/component/Plugin_Builder.res +7 -3
- package/src/plugin/component/Plugin_Builder.res.mjs +6 -3
- package/src/plugin/component/Plugin_Helpers.res +35 -6
- package/src/plugin/component/Plugin_Helpers.res.mjs +47 -5
- package/src/plugin/component/Plugin_Structure.res +52 -19
- package/src/plugin/component/Plugin_Structure.res.mjs +40 -15
- package/src/plugin/lifecycle/PluginsReadModelSpec.res +1 -1
- package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +1 -1
- package/tests/admin/AdminApiSchemaDriftTest.res +9 -3
- package/tests/admin/AdminApiSchemaDriftTest.res.mjs +3 -3
- package/tests/admin/Platform_Admin_StructureTest.res +17 -14
- package/tests/admin/Platform_Admin_StructureTest.res.mjs +31 -19
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +8 -72
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +9 -39
- package/tests/api/GraphQL_FragmentGeneratorTest.res +1 -1
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +1 -1
- 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/tests/plugin/PluginStructureAccessTest.res +30 -1
- package/tests/plugin/PluginStructureAccessTest.res.mjs +28 -1
- package/tests/plugin/StateChange/PsGatedCommands.res +9 -1
- package/tests/plugin/StateChange/PsGatedCommands.res.mjs +4 -1
- package/tests/plugin/StateView/PsGatedView.res +1 -1
- package/tests/plugin/StateView/PsGatedView.res.mjs +1 -1
- package/tests/plugin/pluginDefinitionRequiredScalars.txt +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
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.286 (2026-10-01)
|
|
7
|
+
|
|
8
|
+
* feat!: roles a plugin declares, groups a platform provides ([43daace](https://github.com/ReventlessDev/reventless-core/commit/43daace6ca588afe22562d079af1e6f1da81942e))
|
|
9
|
+
|
|
10
|
+
### BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
* AllowGroups and AdminGroup are removed — write AllowRoles over
|
|
13
|
+
the plugin's Roles.t, and use Role.admin / Platform.adminGroup(). Spec module
|
|
14
|
+
types declare `type role`, and their rules are Authorization.rule<role>; a spec
|
|
15
|
+
that writes its own rule declares `type role` (Reventless.Role.name for a rule
|
|
16
|
+
over Authorization.permission). Role.name is a private string: make one from a
|
|
17
|
+
string with Role.make.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# 3.0.0-alpha.285 (2026-10-01)
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
* **spec, aws, core, local:** an app declares its environments, and every deployment records its commit ([c7cc925](https://github.com/ReventlessDev/reventless-core/commit/c7cc92573e97742b681a9000cea0bdfd6e9a33fc))
|
|
26
|
+
* **spec, core, gwt, ppx:** a command acts only on what its caller owns ([2636f0e](https://github.com/ReventlessDev/reventless-core/commit/2636f0ee44797ee2c0b0c40a7107cf306113173f))
|
|
27
|
+
|
|
28
|
+
|
|
6
29
|
# 3.0.0-alpha.284 (2026-09-29)
|
|
7
30
|
|
|
8
31
|
**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.286",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -30,20 +30,20 @@
|
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.34",
|
|
32
32
|
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.20",
|
|
33
|
-
"@reventlessdev/rescript-
|
|
33
|
+
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
|
|
34
34
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.11",
|
|
35
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.20",
|
|
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-
|
|
39
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.38",
|
|
40
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.156",
|
|
41
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.184"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.1",
|
|
45
45
|
"sury-ppx": "11.0.0",
|
|
46
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
46
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.96"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"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,
|
package/src/Roles.res
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The administrator group as the host shell reads it from `config.json`: the
|
|
2
|
+
// group the platform gates its admin API on, from the same role-to-group
|
|
3
|
+
// resolution as the server, so the two cannot disagree. Written only where the
|
|
4
|
+
// deployment maps the administrator role away from its own name; the shell
|
|
5
|
+
// reads an absent key as `Admin`, so a default deployment's file is unchanged.
|
|
6
|
+
|
|
7
|
+
let configKey = "adminGroup"
|
|
8
|
+
|
|
9
|
+
let configEntry = (): option<(string, JSON.t)> => {
|
|
10
|
+
let group = Reventless.Role.adminGroup()
|
|
11
|
+
group == (Reventless.Role.admin :> string) ? None : Some((configKey, JSON.Encode.string(group)))
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
|
|
4
|
+
|
|
5
|
+
let configKey = "adminGroup";
|
|
6
|
+
|
|
7
|
+
function configEntry() {
|
|
8
|
+
let group = Role$Reventless.adminGroup();
|
|
9
|
+
if (group === Role$Reventless.admin) {
|
|
10
|
+
return;
|
|
11
|
+
} else {
|
|
12
|
+
return [
|
|
13
|
+
configKey,
|
|
14
|
+
group
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
configKey,
|
|
21
|
+
configEntry,
|
|
22
|
+
}
|
|
23
|
+
/* Role-Reventless Not a pure module */
|
|
@@ -37,7 +37,8 @@ let pluginCommands: array<commandDef> = Plugin_Structure.extractCommandDefs(
|
|
|
37
37
|
~isAggregate=true,
|
|
38
38
|
~mutationFieldFor=variantName =>
|
|
39
39
|
Api_Naming.adminField(~name=PluginSpec.name ++ "_" ++ variantName),
|
|
40
|
-
~commandAuthorization=
|
|
40
|
+
~commandAuthorization=command =>
|
|
41
|
+
PluginSpec.commandAuthorization(command->Obj.magic)->Reventless.Authorization.named,
|
|
41
42
|
~commandTransition=PluginSpec.commandTransition->Obj.magic,
|
|
42
43
|
commandSchema,
|
|
43
44
|
)
|
|
@@ -57,16 +58,19 @@ let pluginAggregate: writableDef = {
|
|
|
57
58
|
chapter: None,
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
// Functions, not values: the view's access keys are the group the platform maps
|
|
62
|
+
// the administrator role to, and this module is evaluated before a root maps it.
|
|
63
|
+
let pluginReadModel = (): queryableDef =>
|
|
64
|
+
Plugin_Structure.queryableDefFromSpec(
|
|
65
|
+
~plugin=pluginId,
|
|
66
|
+
~name=PluginsReadModelSpec.name,
|
|
67
|
+
~stateSchema=PluginsReadModelSpec.stateSchema->S.castToUnknown,
|
|
68
|
+
~authorization=PluginsReadModelSpec.authorization->Reventless.Authorization.named,
|
|
69
|
+
~linkedWriteSide=["Plugin"],
|
|
70
|
+
)
|
|
67
71
|
|
|
68
|
-
let structure: pluginStructure
|
|
69
|
-
readModels: [pluginReadModel],
|
|
72
|
+
let structure = (): pluginStructure => {
|
|
73
|
+
readModels: [pluginReadModel()],
|
|
70
74
|
stateViewSlices: [],
|
|
71
75
|
stateChangeSlices: [],
|
|
72
76
|
aggregates: [pluginAggregate],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
|
|
3
4
|
import * as Api_Naming$ReventlessCore from "../components/Api/Api_Naming.res.mjs";
|
|
4
5
|
import * as PluginSpec$ReventlessCore from "../plugin/lifecycle/PluginSpec.res.mjs";
|
|
5
6
|
import * as Plugin_Structure$ReventlessCore from "../plugin/component/Plugin_Structure.res.mjs";
|
|
@@ -7,7 +8,7 @@ import * as PluginsReadModelSpec$ReventlessCore from "../plugin/lifecycle/Plugin
|
|
|
7
8
|
|
|
8
9
|
let pluginId = "Platform";
|
|
9
10
|
|
|
10
|
-
let pluginCommands = Plugin_Structure$ReventlessCore.extractCommandDefs(undefined, true, undefined, variantName => Api_Naming$ReventlessCore.adminField(PluginSpec$ReventlessCore.name + "_" + variantName), PluginSpec$ReventlessCore.commandAuthorization, PluginSpec$ReventlessCore.commandTransition, undefined, PluginSpec$ReventlessCore.commandSchema);
|
|
11
|
+
let pluginCommands = Plugin_Structure$ReventlessCore.extractCommandDefs(undefined, true, undefined, variantName => Api_Naming$ReventlessCore.adminField(PluginSpec$ReventlessCore.name + "_" + variantName), command => Authorization$Reventless.named(PluginSpec$ReventlessCore.commandAuthorization(command)), PluginSpec$ReventlessCore.commandTransition, undefined, PluginSpec$ReventlessCore.commandSchema);
|
|
11
12
|
|
|
12
13
|
let pluginAggregate_producedEventTypes = [];
|
|
13
14
|
|
|
@@ -31,39 +32,27 @@ let pluginAggregate = {
|
|
|
31
32
|
chapter: undefined
|
|
32
33
|
};
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
let structure_stateViewSlices = [];
|
|
39
|
-
|
|
40
|
-
let structure_stateChangeSlices = [];
|
|
41
|
-
|
|
42
|
-
let structure_aggregates = [pluginAggregate];
|
|
43
|
-
|
|
44
|
-
let structure_automationSlices = [];
|
|
45
|
-
|
|
46
|
-
let structure_outboundTranslationSlices = [];
|
|
47
|
-
|
|
48
|
-
let structure_inboundTranslationSlices = [];
|
|
49
|
-
|
|
50
|
-
let structure_extensions = [];
|
|
35
|
+
function pluginReadModel() {
|
|
36
|
+
return Plugin_Structure$ReventlessCore.queryableDefFromSpec(pluginId, PluginsReadModelSpec$ReventlessCore.name, PluginsReadModelSpec$ReventlessCore.stateSchema, Authorization$Reventless.named(PluginsReadModelSpec$ReventlessCore.authorization), undefined, undefined, ["Plugin"], undefined);
|
|
37
|
+
}
|
|
51
38
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
39
|
+
function structure() {
|
|
40
|
+
return {
|
|
41
|
+
readModels: [pluginReadModel()],
|
|
42
|
+
stateViewSlices: [],
|
|
43
|
+
stateChangeSlices: [],
|
|
44
|
+
aggregates: [pluginAggregate],
|
|
45
|
+
automationSlices: [],
|
|
46
|
+
outboundTranslationSlices: [],
|
|
47
|
+
inboundTranslationSlices: [],
|
|
48
|
+
extensions: [],
|
|
49
|
+
extensionPoints: undefined,
|
|
50
|
+
requiredStores: undefined,
|
|
51
|
+
requiredStoreDeclarations: undefined,
|
|
52
|
+
requiredCapabilities: undefined,
|
|
53
|
+
traitDeclarations: undefined
|
|
54
|
+
};
|
|
55
|
+
}
|
|
67
56
|
|
|
68
57
|
let internalPluginDefinition = {
|
|
69
58
|
id: pluginId + "@INTERNAL",
|
|
@@ -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)
|