Skip to content

Migrations

When you update your schema, you need to change your schema definition in your TypeScript files, but you also need to specify migrations that describe what changes you’ve made. This helps to double-check that you’ve made the changes you intend to, and also resolves some ambiguities - for example, did you mean to rename a field, or did you add a new field and delete an old field? This is part of what allows StatelyDB’s elastic schema to automatically convert between different schema versions and allow different versions of your clients to coexist.

Declaring Migrations

Whenever you update your schema, you need to declare a migration with the migrate function. Just like your schema types, the migration needs to be exported from your schema’s top level JavaScript module, and you can use modules to organize them into different files however you like.

The migrate function requires the schema version you’re migrating from (they start with 1), a human-readable description, and a function that applies one or more migration commands. You can look up the current version number of your schema on the console. Because these migration declarations have a version associated with them, you don’t need to delete them after they’ve been applied—you can keep them around as a record of changes to your schema, or you can clean them out of your schema files after they’re no longer needed.

schema.ts
import { migrate } from "@stately-cloud/schema";
// ... the rest of your schema, other migrations, etc.
// Declare a migration from version 1 to version 2 that describes a
// bunch of changes to the schema.
export const MyMigration = migrate(1, "An example migration", (m) => {
m.addType("NewType");
m.removeType("Url");
m.renameType("Account", "Profile");
m.changeType("Profile", (t) => {
// was previously named "Account"
t.addField("description");
t.removeField("note");
t.renameField("joinDate", "accountAge");
});
});

Migration Commands

Migration commands are the individual instructions in each migration declaration that tell StatelyDB how your schema’s shape has changed. When you call stately schema put, these commands help StatelyDB understand how to migrate items between versions when reading or writing them. The available commands are:

AddType

You can declare that you’ve added a new objectType, itemType, or enumType by passing the name of your new type to the addType migration. This type must exist in your new schema version and not the old version.

Clients using this new version can read and write using this new type, while older versions won’t see it. StatelyDB will filter this new type out of List/SyncList results for clients on older versions.

export const NewType = itemType("NewType", {
keyPath: "/newType-:name",
fields: {
name: { type: string },
},
});
export const AddType = migrate(2, "An add type example", (m) => {
m.addType("NewType");
});

RemoveType

When you remove a objectType, itemType or enumType, use the removeType command and pass the name of the type you have removed.

When you remove a type from your schema, newer schema versions will no longer see this type. The items or fields that reference the removed type are still accessible for older versions of the schema but newer versions of the schema cannot interact with them.

export const ExistingType = itemType("ExistingType", {
keyPath: "/et-:id",
fields: {
id: { type: string },
},
});
export const RemoveType = migrate(3, "A remove type example", (m) => {
m.removeType("ExistingType");
});

RenameType

When you rename a type in your schema, use the renameType command to clarify that it’s the same type, and not a new type plus a removed type. If you reference this type in subsequent migration actions, you must use the new name.

When you rename a type, the data stored in that type doesn’t change. Clients using older schema versions will use the old name, while clients on newer version will use the new name, but they’re all interacting with the same data.

export const OldType = itemType("OldType", {
export const NewType = itemType("NewType", {
keyPath: "/t-:name",
fields: {
name: { type: string },
},
});
export const RenameType = migrate(4, "A rename type example", (m) => {
m.renameType("OldType", "NewType");
});

AddField

When you add a new field to a type, use the addField command within a changeType command to declare the new field.

Clients using newer schema versions can read and write to this added field, while older versions won’t see this field at all. If a client using an older version updates an item that has added fields, those fields will be untouched. In other words, there is no way to replace the whole object—StatelyDB only updates the fields known to the caller’s schema version. If you want to completely replace an item, make sure to delete it first.

Adding required fields to an existing type must be accompanied by a readDefault to ensure newer clients can read items created by older clients that didn’t know about this field. The provided readDefault will be subject to any valid expression on the new field.

export const ExistingType = itemType("ExistingType", {
keyPath: "/et-:id",
fields: {
id: { type: string },
name: { type: string, readDefault: "John Doe" },
},
});
export const AddField = migrate(5, "An add field example", (m) => {
m.changeType("ExistingType", (i) => {
i.addField("name");
});
});

RemoveField

When you’ve removed a field from a type, use the removeField command within a changeType command.

Clients using older schema versions will still see the field, while newer clients cannot interact with the field but will not disturb any data already saved there. If you want to zero out this removed field for older clients, you will need to delete the item first.

Removing required fields from an existing type must be accompanied by a readDefault in the migration command to ensure older clients can read items created by newer clients that no longer know about this field. The provided readDefault will be subject to any valid expression on the removed field.

export const ExistingType = itemType("ExistingType", {
keyPath: "/et-:id",
fields: {
id: { type: string },
name: { type: string },
},
});
export const RemoveField = migrate(6, "A remove field example", (m) => {
m.changeType("ExistingType", (i) => {
i.removeField("name", "John Doe");
});
});

RenameField

If you’ve renamed a field in a type, use the renameField command in a changeType command, to distinguish a rename from an add and a remove. Renamed fields keep the same stored data and only change the name. This means that clients on different schema versions of the schema will read and write the same data, just with different field names.

export const ExistingType = itemType("ExistingType", {
keyPath: "/et-:id",
fields: {
id: { type: string },
oldName: { type: string },
newName: { type: string },
},
});
export const RenameField = migrate(7, "A rename field example", (m) => {
m.changeType("ExistingType", (i) => {
i.renameField("oldName", "newName");
});
});

ModifyFieldReadDefault

If you’ve changed the default value of a field, by either adding, removing or modifying the readDefault value, use the modifyFieldReadDefault command within a changeType command. The provided readDefault will be subject to any valid expression on the field.

Note: You can never add or remove a required field’s readDefault value, only modify the existing value. This is because required fields must always have a value.

export const ExistingType = itemType("ExistingType", {
keyPath: "/et-:id",
fields: {
id: { type: string },
name: { type: string, readDefault: "John Doe" },
name: { type: string, readDefault: "Jane Doe" },
},
});
export const ModifyFieldReadDefault = migrate(
8,
"A modify field readDefault example",
(m) => {
m.changeType("ExistingType", (i) => {
i.modifyFieldReadDefault("name");
});
},
);

MarkFieldAsRequired

If you’ve changed a field to be required use the markFieldAsRequired command within a changeType command. Additionally, you must provide a readDefault on the field.

Newer clients will see this readDefault when reading items created by older clients that may have set the zero value for this field. The provided readDefault will be subject to any valid expression on the field.

export const ExistingType = itemType("ExistingType", {
keyPath: "/et-:id",
fields: {
id: { type: string },
name: { type: string, required: false },
// required: true is implicit in schema
name: { type: string, readDefault: "John Doe" },
},
});
export const MarkFieldAsRequired = migrate(
9,
"A required example",
(m) => {
m.changeType("ExistingType", (i) => {
i.markFieldAsRequired("name");
});
},
);

MarkFieldAsNotRequired

If you’ve changed a field to not be required use the markFieldAsNotRequired command with a readDefault within a changeType command.

Older clients will see the readDefault when reading items modified by newer clients that set the zero value. This readDefault will be subject to any valid expression on the field.

export const ExistingType = itemType("ExistingType", {
keyPath: "/et-:id",
fields: {
id: { type: string },
// required: true is implicit in schema
name: { type: string },
name: { type: string, required: false },
},
});
export const MarkFieldAsNotRequired = migrate(
10,
"A not required example",
(m) => {
m.changeType("ExistingType", (i) => {
i.markFieldAsNotRequired("name", "John Doe");
});
},
);