# Migrations

> Ship a plugin's schema as an ordered gormigrate set through pact.HasMigrations, and run, inspect and roll back migrations per plugin.

WinterCMS plugins keep their schema in the `updates/` directory and list the steps in `version.yaml`. A SummerCMS plugin keeps the same `updates/` directory, but each step is a Go migration in a [gormigrate](https://github.com/go-gormigrate/gormigrate) set that the plugin returns from `pact.HasMigrations`. [lagoon](/docs/api/lagoon.md) runs the sets and records each plugin's history in its own table.

## Writing migrations

`summer make:model` writes a create-table migration with the model, and `summer make:migration` writes an empty one to fill in:

```sh
summer make:migration acme.blog AddPublishedAt
```

The file is `updates/<timestamp>_add_published_at.go`, with a migration ID that starts with the same timestamp. `summer build` generates the plugin's list of migrations in file name order, so the timestamp is also the order in which they run. The scaffolded plugin returns that generated list from its `Migrations` method.

A migration has an ID, a `Migrate` function and a `Rollback` function, both given the transaction to run in. Write DDL as SQL with `tx.Exec`: the migration then says exactly what the database gets, and it keeps working when the model struct changes later. This is the whole set of an example plugin, written out by hand:

```go
// Migrations returns the plugin's schema as an ordered gormigrate set. In a
// scaffolded plugin each migration is a file in updates/ and this list is
// generated in file name order.
func (p *BlogPlugin) Migrations() []*gormigrate.Migration {
	return []*gormigrate.Migration{
		{
			ID: "20260101000100_create_posts",
			Migrate: func(tx *gorm.DB) error {
				return tx.Exec(`CREATE TABLE acme_blog_posts (
	id SERIAL PRIMARY KEY,
	title TEXT NOT NULL,
	slug TEXT NOT NULL,
	views INTEGER NOT NULL DEFAULT 0,
	tags TEXT,
	api_token TEXT,
	deleted_at TIMESTAMPTZ
)`).Error
			},
			Rollback: func(tx *gorm.DB) error {
				return tx.Exec(`DROP TABLE IF EXISTS acme_blog_posts`).Error
			},
		},
		{
			ID: "20260101000200_create_comments_and_categories",
			Migrate: func(tx *gorm.DB) error {
				for _, stmt := range []string{
					`CREATE TABLE acme_blog_comments (id SERIAL PRIMARY KEY, post_id INTEGER NOT NULL, body TEXT NOT NULL, deleted_at TIMESTAMPTZ)`,
					`CREATE TABLE acme_blog_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`,
					`CREATE TABLE acme_blog_post_categories (post_id INTEGER NOT NULL, category_id INTEGER NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (post_id, category_id))`,
				} {
					if err := tx.Exec(stmt).Error; err != nil {
						return err
					}
				}
				return nil
			},
			Rollback: func(tx *gorm.DB) error {
				return tx.Exec(`DROP TABLE IF EXISTS acme_blog_post_categories, acme_blog_categories, acme_blog_comments`).Error
			},
		},
	}
}
```

Treat an ID as permanent once the migration has run anywhere. gormigrate records the IDs it has applied, so renaming one makes it run again.

## Running migrations

The application binary has the migration commands:

```sh
./bin/acme migrate
./bin/acme migrate:status
./bin/acme migrate:rollback --plugin acme.blog
```

`migrate` runs the framework's own sets first (file attachments, the admin users and roles, and the job queue), then each plugin's set in plugin activation order, so a plugin's migrations run after those of the plugins it requires. Each plugin has its own history table, `summer_migrations_<plugin id>` with dots replaced by underscores, as `lagoon.HistoryTableName` returns.

`migrate:rollback` rolls back the last applied migration of one plugin. Without `--plugin` it picks the last activated plugin that has migrations. To fix a migration you just wrote, roll it back, edit it and run `migrate` again.

`migrate:status` prints each plugin's history table and applied IDs.

The same operations are Go functions, which is how tests migrate a fresh database:

```go
plugins := []party.Plugin{&BlogPlugin{}}
if err := lagoon.Migrate(db, plugins); err != nil {
	return nil, err
}
rows, err := lagoon.Status(db, plugins)
if err != nil {
	return nil, err
}
for _, row := range rows {
	lines = append(lines, fmt.Sprintf("%s %s %v", row.Plugin, row.Table, row.IDs))
}
if err := lagoon.RollbackLast(db, plugins, "acme.blog"); err != nil {
	return nil, err
}
```

`lagoon.Migrate`, `lagoon.Status` and `lagoon.RollbackLast` take the activated plugins; a plugin that does not implement `pact.HasMigrations` is skipped.

## Porting version.yaml

WinterCMS runs a plugin's update scripts by version number and seeds data from the same files. When you port a plugin:

- Fold the existing tables into one create migration per table, matching the final PHP schema column for column, so data copied from the PHP database fits.
- Keep data changes (backfills, seeds) as their own migrations, written in SQL.
- Give every `Rollback` a real inverse. It is what makes `migrate:rollback` safe while you develop.

The migration commands open the database through `database.dsn` and load `app.key`; see [Configuration](/docs/setup/configuration.md).
