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 "@comet/blocks-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, typesafeMigrationPipe } from "@comet/blocks-api";

...

export const HeadlineBlock = createBlock(HeadlineBlockData, HeadlineBlockInput, {
name: "Headline",
migrate: {
version: 1,
migrations: typesafeMigrationPipe([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 "@comet/blocks-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: {},
},
},
};
}
}

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.