Skip to content

Write a migration

Migrations are written in Kotlin Script, using an NPL specific DSL.

Migration files

Migrations are written in a Kotlin DSL. Its contents are stored in separate .kts-files for each individual migration.

File names follow a mandatory naming pattern, containing both the sequence number and a description that explains what the purpose or intent of the migration is. Its format is a S followed by an actual migration sequence number, followed by an underscore and the descriptive name. Examples of valid file names include S1_Name.kts, S1_Hello_world.kts, S1_v1-to-v2.kts, and S1_Smörgåsbord.kts.

Describing a migration

New migrations are instantiated using migration(descriptor, enableCompilerOptimisations), where the descriptor serves as a means of identifying this particular migration when applied to the system. The enableCompilerOptimisations parameter (false by default) enables automatic code removal for statements which don't impact code execution or produce side effects.

Subsequent operations may be added using its fluent API. These operations fall in one of two categories: read operations, and mutating operations. All operations pertain to type identifiers (typeIds), which represent a fully qualified identifier of the type to be targeted.

migration("description")
    .read(typeId = "/package/Contract") {
        <ProtocolReader>
    }
    .transformProtocol(
       currentTypeId = "/package/OldAgreement",
       targetTypeId = "/package/NewAgreement"
    ) {
        <ProtocolTransformer>
    }
    .transformStruct(
       currentTypeId = "/package/OldStruct",
       targetTypeId = "/package/NewStruct"
    ) {
        <StructTransformer>
    }
    .transformIdentifier(
       currentTypeId = "/package/OldId",
       targetTypeId = "/package/NewId"
    )
    .transformEnum(
       currentTypeId = "/package/OldEnum",
       targetTypeId = "/package/NewEnum",
       mappings = mapOf("OldVariant1" to "NewVariant1")
    )

Reads

Read operations are intended to accumulate existing protocol data, and are always executed before mutating operations. The migration() API does not allow the definition of a read operation after a mutating operation to help reflect this fact. You can use as many read operations as necessary.

Use case: Collecting data prior to transformations

Let's take the following protocol as an example.

protocol[p, q] Contract(var amount: Number) {};

Imagine that as part of our migration, the value amount must be read for every Contract instance.

val collectedData = mutableMapOf<ProtocolReferenceValue, NumberValue>()
migration("description")
    .read(typeId = MigrationTester.typeId("Contract")) {
        collectedData[reference] = get("amount")
    },

At the start of the transformation phase of the migration, the collectedData map will have been populated completely.

Operations

read

The only available read operation is read.

migration("read")
    .read(typeId = "/package/Contract") {
        <ProtocolReader>
    }

ProtocolReader

The ProtocolReader exposes various ways of collecting data from an existing protocol instance.

Method/property Explanation
id protocol instance identifier
reference protocol instance reference value
parties protocol parties (in provided order)
get<Value>(propertyName) obtain a property or argument value

Transformations

Transformations are types of changes to existing protocol instances. They are always executed after read operations and may safely assume that all read operations have been run against all known protocols at the time of execution.

Oftentimes the protocols you are migrating will themselves be part of other protocols, collections, or structs. Migrating these can become a tedious procedure, trying to traverse such a graph is error-prone, and ultimately cycles will emerge such that no clear dependency graph may be constructed. It is therefore possible to migrate protocols out of order, and recursively apply protocol, struct, and identifier migrations, even when they are within other protocols and collections or structs. They may for that reason be defined in an arbitrary order, and are applied as encountered: transformations cannot be assumed to run in a particular order.

For many operations you can therefore just leave it entirely to the migration to map an old instance to a new instance if no special rules are required to work out how to do so. This is the case for protocols that are structurally identical. In such cases you can use the retag() transformation (in combination with the convenience function mapPrototypes() that can generate a concise list of all your NPL prototypes).

So why are transformations required at all?

Use case 1: Changing a protocol definition

Take the following as an example. A year ago, the following NPL was uploaded, and various protocol instances exist in the system.

struct Data {
    amount: Number
};

enum CustomerClass { A, B };

protocol[p] Contract(var data: List<Data>, var class: CustomerClass) {};

An important feature is going to be added, requiring the storage of a discount on the Data struct. Furthermore, CustomerClass is now going to be ModifiedCustomerClass. The new NPL is now as follows.

struct ExtendedData {
    amount: Number,
    discount: Number
};

enum ModifiedCustomerClass { X, Y };

protocol[p] NewContract(
    var data: List<ExtendedData>,
    var class: ModifiedCustomerClass) {};

If existing protocols need to be moved from Contract to NewContract, this also means that Data needs to be changed to ExtendedData. This requires an explicit rule for how to set the discount field. Furthermore, values of CustomerClass need to be mapped to ModifiedCustomerClass.

A migration to move from Contract to NewContract looks as follows.

migration("description")
    .transformProtocol(
        currentTypeId = MigrationTester.typeId("Contract"),
        targetTypeId = MigrationTester.typeId("NewContract"),
    )
    .transformStruct(
        currentTypeId = MigrationTester.typeId("Data"),
        targetTypeId = MigrationTester.typeId("ExtendedData"),
    ) {
        put("discount") {
            NumberValue(0)
        }
    }
    .transformEnum(
        currentTypeId = MigrationTester.typeId("CustomerClass"),
        targetTypeId = MigrationTester.typeId("ModifiedCustomerClass"),
        mappings = mapOf("A" to "X", "B" to "Y"),
    ),

For the transformStruct step here, we have to explicitly use put to set a value for discount. So why do we not have to do something similar for data in the transformProtocol step that transforms Contract to NewContract? Because the field is left untouched: it is still a List, it is still called data, and therefore the transformStruct transformation is applied recursively. (When the value at hand is touched, withTransformations provides a similar mechanism to accomplish this manually.)

Use case 2: Modifying a protocol's values

Take the following protocol as an example.

protocol[p] Contract(var name: Text, var discount: Number) {};

The discount for a person named "Frank" has been set wrongly: Frank's discount should be 10 rather than 0. A migration to change this is as follows.

migration("frank_discount")
    .transformProtocol(
        currentTypeId = MigrationTester.typeId("Contract"),
        targetTypeId = MigrationTester.typeId("Contract"),
    ) {
        if (get<TextValue>("name") == TextValue("Frank")) {
            replace<NumberValue>("discount") {
                NumberValue(10)
            }
        }
    },

Because no mutating rule is defined for name, its transformation is handled automatically.

Operations

transformProtocol

Transform protocol applies a transformation to each protocol that matches the currentTypeId.

In all cases the new protocol takes the ID of the old one (meaning existing references from other protocols will point to the new protocol after any migration).

The framework will attempt to construct the state of the new protocol entirely from that of the old one. Constructors or init blocks are not called, and require conditions are not enforced.

The transformation logic does not try to be clever - it does not discard data that doesn't seem to be needed for the new protocol. If the result of applying the deletes, replaces and puts does not result in exactly the arguments and fields declared by the new protocol then it will fail the transformation.

migration("transformation")
    .transformProtocol(
       currentTypeId = "/package/OldAgreement",
       targetTypeId = "/package/NewAgreement"
    ) {
        <ProtocolTransformer>
    }

rebindTokenContext

In a Multinode context, it is possible for the migration of a protocol to affect the TokenContexts of existing@multinode-annotated permissions. There are three (3) scenarios under which TokenContext migrations can be performed:

  1. A protocol transformation that does not affect its @multinode-annotated permission(s): token contexts will be migrated automatically as long as the permissions' action name and party name don't change between the migration from protocol A to protocol B. Changing the underlying party (claims + access) of the party name is permitted.
  2. A protocol transformation that affects its @multinode-annotated permission(s): token contexts will have to be migrated manually by using the rebindTokenContext operation and specifying the target protocol type, the current protocol action name, the target protocol action name, and the party caller name.
  3. A retag migration: token contexts will be automatically migrated to the new protocol version.
// automatic migration of token contexts
migration("transformation")
    .transformProtocol(
       currentTypeId = "/package/OldAgreement",
       targetTypeId = "/package/NewAgreement"
    ) {}

// manual migration of token contexts
migration("transformation")
    .transformProtocol(
       currentTypeId = "/package/OldAgreement",
       targetTypeId = "/package/NewAgreement"
    ) {}
    .rebindTokenContext(
       "/package/NewAgreement",
       "oldPermission1",
       "newPermission1",
       "partyName"
    )
    .rebindTokenContext(
       "/package/NewAgreement",
       "oldPermission2",
       "newPermission2",
       "partyName"
    )

// automatic migration of token contexts
migration("transformation")
    .retag()

transformStruct

Transform struct applies a transformation to each struct that matches the currentTypeId.

migration("transformation")
    .transformStruct(
       currentTypeId = "/package/OldStruct",
       targetTypeId = "/package/NewStruct"
    ) {
        <StructTransformer>
    }

transformIdentifier

Transform identifier applies a transformation to each identifier that matches the currentTypeId.

migration("transformation")
    .transformIdentifier(
       currentTypeId = "/package/OldId",
       targetTypeId = "/package/NewId"
    ) // there is no transformer block

transformEnum

transformEnum(String, String, Map?)

Applies a transformation to each enum that matches the currentTypeId.

Variant mappings define how variants of currentTypeId map to those in targetTypeId. If no mappings are specified, an identity mapping is assumed (where each variant in currentTypeId is mapped to the same targetTypeId variant). If currentTypeId contains variants that have not been defined in targetTypeId and no explicit mapping has been provided for these variants, the migration will fail with an exception. Note that identity mappings are always added automatically.

Consider the enums OldEnum and NewEnum.

enum OldEnum { A, B, C };
enum NewEnum { B, X };

The following migration applies.

migration("transformation")
    .transformEnum(
        currentTypeId = MigrationTester.typeId("OldEnum"),
        targetTypeId = MigrationTester.typeId("NewEnum"),
        mappings = mapOf("A" to "X", "C" to "X"),
    )

Note that the variant B is present in both enums and therefore does not need to be handled explicitly (but may be overridden if it needs to map to something that is not B in NewEnum). Variants A and C are removed: any values of A or C are now values of X.

transformEnum(StatesEnumTypeId, StatesEnumTypeId, Map?)

Applies a transformation to the States derived enum of a given protocol typeId, while also automatically issuing a state call on the ProtocolTransformer of a transformProtocol.

Variant mappings define how variants of currentDerivedTypeId map to those in targetDerivedTypeId. If no mappings are specified, an identity mapping is assumed (where each variant in currentDerivedTypeId is mapped to the same targetDerivedTypeId variant). If currentTypeId contains variants that have not been defined in targetDerivedTypeId and no explicit mapping has been provided for these variants, the migration will fail with an exception. Note that identity mappings are always added automatically.

While this enum transform requires a transformProtocol to be meaningful, the operation will be a valid no-op if a transformProtocol is omitted.

You may override the automatic state call with your own custom state or state { } inside a transformProtocol context. When overriding this state call, calling transforEnum is still required – otherwise the migration will fail. The mappings parameter of the transformEnum call is also required.

Consider the following protocols and their States:

protocol[p] One() {
    initial state myFirstState;
    var a = One.States.myFirstState;
};
protocol[p] Two() {
    initial state mySecondState;
    var a = Two.States.mySecondState;
};

A typical migration to migrate the States of Protocol One to the States of Protocol Two would be:

migration("transformation")
    .transformProtocol(
        currentTypeId = MigrationTester.typeId("One"),
        targetTypeId = MigrationTester.typeId("Two"),
    )
    .transformEnum(
        currentDerivedTypeId = StatesEnumTypeId(
            protocolTypeId = MigrationTester.typeId("One"),
        ),
        targetDerivedTypeId = StatesEnumTypeId(
            protocolTypeId = MigrationTester.typeId("Two"),
        ),
        mappings = mapOf("myFirstState" to "mySecondState"),
    ),

A migration with a custom state instead of the automatic one issued by transformEnum could be:

migration("transformation")
    .transformProtocol(
        currentTypeId = MigrationTester.typeId("One"),
        targetTypeId = MigrationTester.typeId("Two"),
    ) {
        // `state` call override
        state { if (it == "myFirstState") "mySecondState" else "anotherState" }
    }
    .transformEnum(
        currentDerivedTypeId = StatesEnumTypeId(
            protocolTypeId = MigrationTester.typeId("One"),
        ),
        targetDerivedTypeId = StatesEnumTypeId(
            protocolTypeId = MigrationTester.typeId("Two"),
        ),
        mappings = mapOf("myFirstState" to "mySecondState"),
    ),

Finally, a valid no-op migration with an omitted transformProtocol would be:

migration("transformation")
    .transformEnum(
        currentDerivedTypeId = StatesEnumTypeId(
            protocolTypeId = MigrationTester.typeId("One"),
        ),
        targetDerivedTypeId = StatesEnumTypeId(
            protocolTypeId = MigrationTester.typeId("Two"),
        ),
        mappings = mapOf("myFirstState" to "mySecondState"),
    ),

createProtocols

Migration operation that allows for the creation of protocols via a lambda (ProtocolCreateOperation) -> Unit. This approach can be useful for a variety of situations without necessitating prior transforms. Protocols created via method cannot depend on protocols created by the ProtocolTransformer.createProtocol method.

migration("my migration without transform")
    .createProtocols { op ->
        for (i in 0..9) {
            op.createProtocol("protocolTypeId", listOf(PartyValue("p")), listOf(argumentValue))
        }
    }
migration("my migration with transform")
    .createProtocols { op ->
       for (i in 0..9) {
           op.createProtocol("protocolTypeId", listOf(PartyValue("p")), listOf(argumentValue)) {
               put("name") { TextValue("my name $i") }
           }
       }
    }

retag

Retag applies a remapping of prototypes based on provided lists of ID pairs. It is functionally equivalent to writing one of the above transforms specifying the IDs but omitting the transform block. The goal of retag is to make the intent of the migration easier to grasp by reducing the amount of error-prone boilerplate elements in a script. Retag must come last in a migration.

The retag operation takes lists of ID pairs wrapped up into a PrototypeIdPairs instance. If any pair involves IDs for which a custom transformation has already been specified (with transformProtocol etc.) then retag silently ignores such pairs and the custom transformation takes precedence.

val protos = PrototypeIdPairs(
    enums = listOf<IdPair>(),
    structs = listOf<IdPair>(),
    identifiers = listOf<IdPair>(),
    protocols = listOf<IdPair>(IdPair("/system-1?/Car","/system-2?/Car"),
    currentTag = "system-1",
    targetTag = "system-2"
)

Although PrototypeIdPairs instances can be hand coded, it is expected that they be generated by helper functions provided by the Platform or associated tooling (see next section) or by project specific logic.

PrototypeIdPairs generation

The convenience function mapPrototypes() can be used to automatically generate a PrototypeIdPairs instance for you. It does this by discovering all prototypes in a source directory. If you also provide the desired current and target tags then it will automatically prefix the IDs with these tags.

val protos = mapPrototypes(path)

// When using tags to version the NPL.
val protos = mapPrototypes(path, "system-1.0", "system-1.1")

Note: Additionally the convenience function mapPrototypesInMigration() is provided which will construct the prototype ID pairs based on the migration definition file.

The list of prototypes returned by either of the above functions can also be used in transformations, to map the current and target prototype IDs, and to create type reference.

val protos = mapPrototypes(path,"system-1.0","system-1.1")

val color = protos.match("Color")
val features = protos.match("Features")
val car = protos.match("Car")

migration("${protos.current} to ${protos.target}")
    .transformEnum(color.current, color.target,
        mapOf("r" to "red", "g" to "green", "b" to "blue"))
    .transformStruct(features.current, features.target) {
        put("color") {
            createOptional(color.targetAsType())
        }
    }
    .transformProtocol(car.current, car.target) {
        put("features") {
            createOptional(features.targetAsType())
        }
    }
    .retag(protos)

StructTransformer and ProtocolTransformer

There are two transformers: StructTransformer exposes operations for performing struct-to-struct mappings, whereas ProtocolTransformer exposes operations for state-to-state mappings.

See Transformer operations for a list of fields and methods available in these contexts.

withTransformations

In the section on transformations it was explained that under most circumstances all specified transformations are applied automatically, rather than having to respecify these operations for each specific field. For example, in a migration in which all protocols of type OldAgreement are turned into NewAgreement any values of OldAgreement found on any other protocols are converted as well.

This feature can also be invoked explicitly whenever more involved transformations include not just such already specified migrations, but require additional transformative steps. For example, an additional protocol OldAgreementBundle may contain List<OldAgreement> that is now a Set<NewAgreement>. This transformation not only relies on OldAgreement being turned into NewAgreement (a transformation that is already specified), but also on an additional step of converting the List into a Set that cannot be performed automatically.

Rather than having to respecify the conversion from OldAgreement to NewAgreement when specifying the conversion of List to Set, there is withTransformations which applies all specified automatic transformations to the provided value. In our example above, the variable newAgreementListBundle in withTransformations(oldAgreementListBundle) { newAgreementListBundle -> ... } would be a List<NewAgreement>. The logic within the braces should then only concern itself with converting List<NewAgreement> into Set<NewAgreement>.

migration("withTransformations-example")
   // A transformation from `OldAgreement` to `NewAgreement`.
   .transformProtocol(
      currentTypeId = "/package/OldAgreement",
      targetTypeId = "/package/NewAgreement"
   ) {
      // Transformation logic.
   }
   // Another transformation wishing to make use of the above.
   .transformProtocol(
      currentTypeId = "/package/OldAgreementBundle",
      targetTypeId = "/package/NewAgreementBundle"
   ) {
      replace<ListValue>("bundle") { oldBundle ->
         withTransformations(oldBundle) { newBundle ->
            // Existing transformations applied, 'newBundle' is
            // now List<NewAgreement>, but not yet a Set<NewAgreement>.
            // All that needs to be done is convert it into a set.
            createSet(newBundle.value)
         }
      }
   }

Specifically, this allows additional transformations to be performed on top of the automatic transformations. Such function composition may be denoted as (withTransformations ∘ additionalTransformation)(oldValue).

type

Given a type ID for a user-defined type (union, struct, identifier, or enum), function type(typeId: String) returns the corresponding type reference.

For example, an empty list that would (eventually) hold protocols of type Example can be created like this:

createList(emptyList(), type("/examplepkg/Example"))

A more extensive example can be found here.

Note that while symbols are user-defined type, they do not currently have their own prototypes, and type will therefore not be able to resolve them. TypeRef.name(typeId) can be used instead for symbols.