Skip to main content

Block migrations

The structure of a block may change over time. Consider the following example: The eyebrow field in a headline block should be changed from a basic string to a rich text block to support formatting.

headline.block.ts
class HeadlineBlockData extends BlockData {
@BlockField()
eyebrow: string;

@ChildBlock(RichTextBlock)
eyebrow: unknown;
}

This change in the block structure is incompatible with previously created block instances in the "old" block structure. Consequently, we need to add a block migration to migrate old blocks to the new format.

Block migrations are performed when a block instance is loaded from the database. As this operation happens just in time, we sometimes refer to them as live migrations.

note

Block instances will be migrated to the new block structure before being sent to a client. The clients do not need to support the old and new block structures.

A block may require multiple changes during its lifetime. Each change in the block structure is declared by increasing the block's version field. The block's version is stored alongside the block data in the database. Initially, a block's version is either undefined or 0.

headline.block.ts
export const HeadlineBlock = createBlock(HeadlineBlockData, HeadlineBlockInput, {
name: "Headline",
migrate: {
version: 1, // Current version in the code
},
});

Creating a migration​

A migration is described by a class that extends the BlockMigration class and implements the BlockMigrationInterface interface. Each migration performs the necessary steps to migrate the block structure from one version to the next. The version to which the migration migrates the block's structure is defined by the toVersion field.

import { BlockMigration, BlockMigrationInterface } from "@dextinity/cms-api";

export class ChangeEyebrowMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface {
public readonly toVersion = 1;

...
}

We recommend to put each migration class into a separate file. The file's name should start with the migration version, for instance 1-change-eyebrow.migration.ts.

In the migration file we start by defining two interfaces: The From interface describes the block structure before we changed it, and the To interface describes how the block structure should be.

interface From {
headline: any;
eyebrow: string;
level: any;
}

interface To {
headline: any;
eyebrow: unknown;
level: any;
}
note

Note how we are only specific for the parts of the block's structure we want to change in this migration. Doing so prevents the need to change old migration files when the block's structure changes again.

We then implement the migrate method, which performs the actual migration. This method receives the "old" block (From interface) and transforms the block into the new structure (To interface).

export class ChangeEyebrowMigration
extends BlockMigration<(from: From) => To>
implements BlockMigrationInterface
{
public readonly toVersion = 1;

protected migrate({ eyebrow, ...other }: From): To {
return {
...other,
eyebrow: {
draftContent: {
blocks: [
{
key: "12345",
// The "old" eyebrow string
text: eyebrow,
type: "unstyled",
depth: 0,
inlineStyleRanges: [],
entityRanges: [],
data: {},
},
],
entityMap: {},
},
},
};
}
}
caution

Avoid using application code in the migration. Doing so prevents the need to adapt old migrations if the code changes at a later time.

Finally, we add the migration to the block using the migrate option in createBlock.

import { createBlock, typeSafeBlockMigrationPipe } from "@dextinity/cms-api";

...

export const HeadlineBlock = createBlock(HeadlineBlockData, HeadlineBlockInput, {
name: "Headline",
migrate: {
version: 1,
migrations: typeSafeBlockMigrationPipe([ChangeEyebrowMigration]),
},
});

The next time the block is loaded from the database, the migration will be performed, migrating the block to the new structure.

Complete migration class
1-change-eyebrow.migration.ts
import { BlockDataInterface, BlockMigration, BlockMigrationInterface } from "@dextinity/cms-api";

interface From {
headline: any;
eyebrow: string;
level: any;
}

interface To {
headline: any;
eyebrow: unknown;
level: any;
}

export class ChangeEyebrowMigration
extends BlockMigration<(from: From) => To>
implements BlockMigrationInterface
{
public readonly toVersion = 1;

protected migrate({ eyebrow, ...other }: From): To {
return {
...other,
eyebrow: {
draftContent: {
blocks: [
{
key: "12345",
text: eyebrow,
type: "unstyled",
depth: 0,
inlineStyleRanges: [],
entityRanges: [],
data: {},
},
],
entityMap: {},
},
},
};
}
}

Vendor migrations​

A block can receive migrations from two sides: from the application that uses it, and from the library that provides it. createTipTapRichTextBlock with migrateFromDraftJs is such a library migration — it converts Draft.js content into TipTap content. Both sides need to add migrations without knowing about each other, which a single version counter can't support.

Therefore, the migrations that ship with a block are declared in migrateVendor, next to the migrate option the application fills:

createVideoBlock.ts (library)
export function createVideoBlock(nameOrOptions: BlockFactoryNameOrOptions = "Video"): Block {
const options = typeof nameOrOptions === "string" ? { name: nameOrOptions } : nameOrOptions;

return createBlock(VideoBlockData, VideoBlockInput, {
...options,
migrateVendor: {
version: 1,
migrations: typeSafeBlockMigrationPipe([ChangeAspectRatioMigration]),
},
});
}

Both options are separate on purpose: migrate belongs to the application, so the library must not merge its own migrations into it, and the application can't overwrite the library's. A block that is entirely owned by the library declares only migrateVendor.

migrateVendor counts its versions from 1, independently of the block's version, and its counter is stored per block instance in $$vendorVersion:

{ "$$version": 2, "$$vendorVersion": 1 }

The vendor migrations run first, then the block's own migrations. Existing block instances are unaffected by a new vendor chain, because $$vendorVersion is 0 for them.

note

migrateVendor is meant for libraries that provide blocks, for instance Dextinity itself or a package shared between your projects. For the blocks of your application, keep using migrate — its counter cannot collide with anything. The vendor migrations of a block can only come from one library: a block extending a block that has them can't declare its own, and puts its migrations in migrate.

caution

Because both chains advance on their own, a vendor migration may be added after the block's own migrations have run. Only type and touch the fields your migration changes, and spread the rest through, so a migration stays correct no matter what the other chain did to the data.

Extending a block that has vendor migrations​

A block that extends the data of a block with vendor migrations inherits them, and brings migrations of its own:

captioned-video.block.ts
class CaptionedVideoBlockData extends VideoBlockData {
@BlockField()
caption: string;
}

class CaptionedVideoBlockInput extends VideoBlockInput {
@BlockField()
caption: string;

transformToBlockData(): CaptionedVideoBlockData {
return blockInputToData(CaptionedVideoBlockData, this);
}
}

export const CaptionedVideoBlock = createBlock(CaptionedVideoBlockData, CaptionedVideoBlockInput, {
name: "CaptionedVideo",
migrate: {
version: 1,
migrations: typeSafeBlockMigrationPipe([AddCaptionMigration]),
},
});

The vendor migrations belong to the data, so they are inherited along with it: the extending block runs them before its own, without declaring them. Its own migrations start at 1, whatever the extended block's vendor chain is counting at. The data and input classes have to be exported by the library for this, which is why VideoBlockData above is imported from it rather than taken from VideoBlock.

FAQ​

When do I need a migration?​

You will need a migration when

  • adding a required field to the block (you will need to provide a default value for existing block instances)
  • changing an existing field of the block

You don't necessarily need a migration when

  • adding an optional field to the block
  • removing an existing field from the block
  • adding an additionally supported block to a BlocksBlock or an OneOfBlock
  • removing a supported block from a BlocksBlock or an OneOfBlock

How do I test a migration?​

Follow these steps to verify that your migration works correctly

  1. Create a block instance in the old structure. You may create a block in the Admin while being on the main branch.
  2. Switch to the new block structure. Usually, you would check out the branch where you changed the structure and added the migration, for instance, git checkout change-eyebrow-for-headline-block.
  3. Load the block instance to trigger the migration and verify it has been migrated correctly. You may either load it in the Admin or a frontend client.