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.
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.
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
.
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 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: {},
},
},
};
}
}
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
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 anOneOfBlock
- removing a supported block from a
BlocksBlock
or anOneOfBlock
How do I test a migration?
Follow these steps to verify that your migration works correctly
- Create a block instance in the old structure.
You may create a block in the Admin while being on the
main
branch. - 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
. - 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.