Recipes by change type
This page assumes that you have loaded the exact targetNet and are placing the code inside the migrationHelper.updateCasesCursor closure from the procedure for migrating existing cases. Use only the recipes for differences you actually found between the original and target process versions.
The examples use types that are automatically available in the worker's Groovy configuration. Do not add import statements to the Admin Executable field; if a custom type is unavailable, use its fully qualified name.
Case data
This covers fields stored in case.dataSet: their existence, business value, runtime type, and field metadata such as choices, options, allowedNets, validations, or the global immediate flag. It does not yet cover where or how a field is displayed in a task.
Adding new fields
Importing a new version adds fields to the model, not to the runtime useCase.dataSet of existing cases. The following helper adds a field, copies static metadata from the target model, and configures its form bindings.
def addFieldFromTarget = { useCase, targetNet, String fieldId, Object initialValue ->
def modelField = targetNet.dataSet[fieldId]
if (modelField == null) {
throw new IllegalArgumentException("Field ${fieldId} does not exist in the target PetriNet")
}
if (useCase.dataSet.containsKey(fieldId)) {
return
}
def runtimeField = new DataField(initialValue)
runtimeField.component = modelField.component
runtimeField.validations = modelField.validations?.collect { it.clone() } ?: []
if (modelField instanceof FieldWithAllowedNets) {
runtimeField.allowedNets = new ArrayList(modelField.allowedNets)
}
if (modelField instanceof ChoiceField && !modelField.dynamic) {
runtimeField.choices = modelField.choices
.collect { new I18nString(it) } as LinkedHashSet
}
if (modelField instanceof MapOptionsField && !modelField.dynamic) {
runtimeField.options = modelField.options.collectEntries { key, value ->
[(key): new I18nString(value)]
}
}
if (modelField instanceof FilterField) {
runtimeField.filterMetadata = new LinkedHashMap(modelField.filterMetadata)
}
targetNet.transitions.each { transitionId, transition ->
def dataRef = transition.dataSet[fieldId]
if (dataRef != null) {
runtimeField.addBehavior(
transitionId,
new LinkedHashSet(dataRef.behavior ?: [])
)
if (dataRef.component != null) {
runtimeField.addDataRefComponent(transitionId, dataRef.component)
}
}
}
useCase.dataSet[fieldId] = runtimeField
if (modelField.immediate) {
useCase.immediateDataFields.add(fieldId)
}
}Then add all new fields at once inside the case closure:
[
note : null,
approved : false,
amount : 0.0d,
related_cases : new ArrayList<String>(),
channels : new LinkedHashSet<String>()
].each { fieldId, initialValue ->
addFieldFromTarget(useCase, targetNet, fieldId, initialValue)
}The value must match the Petriflow type:
| Petriflow type | Runtime value type |
|---|---|
text | String or null |
boolean | Boolean |
number | Double |
date / dateTime | LocalDate / LocalDateTime |
i18n, enumeration | I18nString |
enumeration_map | String key |
multichoice | Set<I18nString> |
multichoice_map | Set<String> |
caseRef, taskRef, stringCollection | List<String> |
file, fileList | FileFieldValue / FileListFieldValue or null |
actor, actorList | ActorFieldValue / ActorListFieldValue or null |
button | Integer, typically 0 |
Dynamic values
The code above accepts initialValue deliberately. A dynamic <init>, choices, or options are not evaluated when the model is copied. The migration must calculate the resulting value for each case according to the business rule.
Changing a field type
The Engine provides ready-made conversions for the most common changes:
migrationHelper.changeDataFieldsValueFromNumberToText(
useCase,
["amount"] as Set
)
migrationHelper.changeDataFieldsValueFromTextToNumber(
useCase,
["score"] as Set
)
migrationHelper.changeDataFieldsValueFromEnumerationToMultichoice(
useCase,
["category"] as Set
)
migrationHelper.changeFileFieldToFileList(useCase, "attachment")text → numberconverts toDouble; it sets an invalid non-empty value tonulland records a migration error.number → textconverts throughdouble, so it may produce text such as10.0.enumeration → multichoicecreates aSet<I18nString>containing the original value.file → fileListchanges only the runtime value object. It does not move the physical file in storage.
Implement other type changes explicitly and test them with null, an empty value, a valid value, and an invalid historical value.
Converting a single value to a collection
When a field becomes a reference list, wrap the existing value instead of leaving it as a scalar. An empty collection, not null, is the correct empty value here:
def previous = useCase.dataSet["applicant_person_address_id"]?.value
useCase.dataSet["applicant_person_address_id"].value =
previous ? [previous as String] : new ArrayList<String>()Use the target type from the table above: caseRef, taskRef, and stringCollection are a List<String>, but multichoice is a Set<I18nString> and multichoice_map is a Set<String>. Wrapping a value into the wrong collection type is a frequent cause of a field that then fails to render in the form.
Renaming a field
Handle renaming as add new → convert value → remove old. Do not simply move the map entry, because the new field may have a different component, behavior, validation, or immediate flag.
def oldValue = useCase.dataSet["old_name"]?.value
addFieldFromTarget(useCase, targetNet, "new_name", oldValue?.toString())
migrationHelper.deleteDataFields(useCase, ["old_name"] as Set)
useCase.immediateDataFields.remove("old_name")Removing a field
def removed = ["old_field_1", "old_field_2"] as Set
migrationHelper.deleteDataFields(useCase, removed)
useCase.immediateDataFields.removeAll(removed)Before deletion
Check arcs, data events, task data references, filters, exports, scripts, and inter-process references.
Deleting a field may break action execution if it is referenced in data events, actions, or process logic!
Choices, options, and allowedNets
If the target PetriNet is the source of truth, the safest approach is to replace static metadata with the complete target value:
def fieldId = "status"
def modelField = targetNet.dataSet[fieldId]
def runtimeField = useCase.dataSet[fieldId]
if (modelField instanceof ChoiceField && !modelField.dynamic) {
runtimeField.choices = modelField.choices
.collect { new I18nString(it) } as LinkedHashSet
}
if (modelField instanceof MapOptionsField && !modelField.dynamic) {
runtimeField.options = modelField.options.collectEntries { key, value ->
[(key): new I18nString(value)]
}
}
if (modelField instanceof FieldWithAllowedNets) {
runtimeField.allowedNets = new ArrayList(modelField.allowedNets)
}After removing a choice or option, also decide what to do with the value that is already stored. Updating only the list of options is not enough.
Validations and the global immediate flag
These properties belong to the field definition and the case's runtime data, not to a specific task form:
useCase.dataSet.each { fieldId, runtimeField ->
def modelField = targetNet.dataSet[fieldId]
if (modelField == null) {
return
}
runtimeField.validations = modelField.validations?.collect { it.clone() } ?: []
if (modelField.immediate) {
useCase.immediateDataFields.add(fieldId)
} else {
useCase.immediateDataFields.remove(fieldId)
}
}Task forms
A task is not a form. A task is a runtime instance of a transition. A task form defines which fields the user sees in that task and how they behave. Its primary source is the transition's dataSet: data references, behavior, components, and layout.
The runtime case stores transition-specific behavior and components in its DataField objects. An existing task also stores its own TaskLayout. Therefore, importing a new process alone may not change the form of an existing task.
Field component and dataRef component
migrationHelper.updateCaseComponents(useCase, targetNet) adds or overwrites components that exist in the target. However, it does not remove an old component that is no longer in the target model. For general synchronization, therefore, rebuild both maps:
def fieldComponents = migrationHelper.createComponentsMap(targetNet)
def dataRefComponents = migrationHelper.createDataRefComponentsMap(targetNet)
useCase.dataSet.each { fieldId, runtimeField ->
runtimeField.component = fieldComponents[fieldId] // null removes the old component
runtimeField.dataRefComponents = new LinkedHashMap(
dataRefComponents[fieldId] ?: [:]
)
}Data references and behavior
updateCaseComponents does not change behavior. Rebuild it from the target transitions' data references:
useCase.dataSet.each { fieldId, runtimeField ->
def behaviorByTransition = [:]
targetNet.transitions.each { transitionId, transition ->
def dataRef = transition.dataSet[fieldId]
if (dataRef != null) {
behaviorByTransition[transitionId] =
new LinkedHashSet(dataRef.behavior ?: [])
}
}
runtimeField.behavior = behaviorByTransition
}If data references, FieldLayout, data groups, or TaskLayout changed, verify them separately. Component helpers do not synchronize layout. After the migration, open the form of every affected task.
Roles and permissions
New process role in the target version
After the new version is imported, the role belongs to the target PetriNet. For existing cases, handle the following separately:
- case permissions and enabled roles;
- permissions of existing tasks;
- actor/actorList references, if they changed;
- assignment of the process role to users or groups.
MigrationHelper does not create user assignments.
Changing case and task permissions
def changedTransitionIds = ["t_review", "t_approve"]
// Changes case permissions and the viewRoles and enabledRoles derived from them.
migrationHelper.updateCasePermissionsFromNet(useCase, targetNet, false)
// Changes roles, negativeViewRoles, and processId only on the listed existing tasks.
migrationHelper.updateTasksPermissions(
useCase,
targetNet,
changedTransitionIds
)Add only transitions that exist in the target model to changedTransitionIds. The case permissions helper does not synchronize actorRefs, negativeViewRoles, or user role assignments; if they changed, migrate them according to the specific model and business rule.
If transitions changed at the same time, use the workflow recipe below. A bulk updateCasePermissionsFromNet(useCase, targetNet, true) is not suitable if an existing task references a transition that no longer exists in the target model.
Workflow state and tasks
This section answers whether a task exists and can be executed, not which fields it displays. It covers places, arcs, transitions, tokens in activePlaces, enabled/executable tasks, assignment, and the lifecycle of existing tasks.
Changing places
IMPORTANT
Never replace useCase.activePlaces with targetNet.activePlaces. Doing so would reset an in-progress workflow to the initial tokens of the new net. Every renamed or removed place must have an explicit mapping.
def movedTokens = useCase.activePlaces.remove("p_wait") ?: 0
useCase.activePlaces["p_review"] =
(useCase.activePlaces["p_review"] ?: 0) + movedTokensWhen splitting one place into multiple places, a business rule must determine where the token belongs.
Moving a case to the target version and reloading tasks
// 1. First update the dataset, forms, roles, and activePlaces.
// 2. Changes only the stored reference to the process version.
migrationHelper.migratePetriNet(useCase, targetNet)
// 3. Sets the runtime targetNet internally and recalculates enabled tasks.
migrationHelper.reloadTasks(useCase, targetNet)
// 4. Reloading does not overwrite existing tasks. Synchronize those whose
// transition still exists in the target model.
def survivingTransitionIds = useCase.tasks
.collect { it.transition }
.findAll { targetNet.getTransition(it) != null }
.unique()
migrationHelper.updateTasksPermissions(
useCase,
targetNet,
survivingTransitionIds
)reloadTasks:
- does not require the case to already point to the target
petriNetObjectId; - creates a missing executable task;
- removes a disabled task only if it is unassigned;
- does not overwrite an existing executable task;
- may immediately start an auto-trigger.
IMPORTANT
An assigned task that is no longer executable or whose transition does not exist in the target model is retained. Before the migration, decide whether it should be completed, canceled, moved, or left on the old version.
If the workflow, tasks, and their permissions have not changed, do not call reloadTasks.
Files and Elasticsearch
FILE → FILELIST without moving storage content
migrationHelper.changeFileFieldToFileList(useCase, "attachment")This operation only wraps the existing FileFieldValue in a FileListFieldValue. It does not change storage content.
Saving a new file
An Admin FILE input is a java.io.File object. The current dataService file API expects a task ID:
def multipart = new com.netgrif.application.engine.adapter.spring.files.MultipartFileImpl(
inputFile.name,
inputFile.name,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
inputFile.bytes
)
dataService.deleteFile(taskId, "template")
dataService.saveFile(taskId, "template", multipart)dataService loads and saves the case separately and may trigger SET events. Do not call it inside a cursor closure that later bulk-replaces an older instance of the same case. Handle storage as a separate phase.
Reindexing after the MongoDB phase
Start reindexing only after the case migration succeeds:
migrationHelper.updateCasesCursor({ useCase ->
cancellable()
migrationHelper.elasticIndex(useCase)
useCase.tasks.each { taskPair ->
def task = taskService.findOne(taskPair.task)
migrationHelper.elasticTaskIndex(task)
}
}, targetNet.objectId, 100)Elasticsearch is not part of the MongoDB transaction. Reindexing must therefore be repeatable, and its result must be verified with a separate search query.
What to add to every recipe
- a precondition for the exact source version;
- an idempotent guard or an explicit refusal to run a second time;
cancellable()in every long-running loop;selected,changed,skipped, andfailedcounts;- values set according to the field type -
nulland an empty collection are not interchangeable; - both attributes set on fields that use
optionsorallowedNets, not justvalue; - per-case error handling that identifies the failing case;
- tests for
null, invalid historical data, and every active workflow state; - a separate verification step after the migration.
Identifying the failing case
A migration over thousands of cases is only debuggable if a failure names the case it happened on. Log the case identifier and rethrow, so the error still reaches MigrationErrorPolicy:
migrationHelper.updateCasesCursor({ useCase ->
cancellable()
try {
// the recipes for the identified differences
} catch (Exception exception) {
log.error("Migration of case {} failed: {}", useCase.stringId, exception.message)
throw exception
}
}, sourceNetId, 100)A catch block that only logs and does not rethrow hides the error from the error policy, and the script then reports success even though it skipped documents.
After the migration, verify in the UI at minimum that migrated cases open, that dialogs such as enumeration_dialog and multichoice_dialog work, that allowedNets references do not block selection, that FILE fields have real content in storage, and that stepping through tasks produces no errors.
For execution, error policies, and the exact helper limitations, see the Migration execution and Engine API reference.
