Skip to content

Migrations

Migrations are the rockstar way to evolve your database schema over time. AstrolaDB automatically generates migration files that track every change to your schema, making it easy to apply updates, roll back changes, and keep your team in sync.

Migrations are JavaScript files that live in your migrations/ directory. Each migration contains two functions:

  • up(m) - Applies the schema changes (create tables, add columns, etc.)
  • down(m) - Reverses those changes (for rollbacks)

Migrations are auto-generated by comparing your current schema files against the state captured in previous migrations. You never write migrations by hand — but you can edit them after generation for fine-tuning.

export default migration({
up(m) {
m.create_table("auth.user", (t) => {});
m.create_table("blog.post", (t) => {});
m.create_index("blog.post", ["author_id"]);
},
});
export default migration({
down(m) {
m.drop_table("auth.user");
m.drop_table("blog.post");
},
});

Use the alab new command to generate a migration:

Terminal window
# Generate a migration with a descriptive name
alab new add_user_auth
# Generate a migration for any schema changes
alab new update_blog_schema

AstrolaDB compares your current schema files against the last migration’s state, computes the diff, and generates a sequentially numbered migration file like 001_add_user_auth.js.

Here’s what a real migration looks like:

// Migration: start
export default migration({
up(m) {
// Table: auth.role
m.create_table("auth.role", (t) => {
t.id();
t.string("name", 100).unique();
t.timestamps();
});
// Table: auth.user
m.create_table("auth.user", (t) => {
t.id();
t.string("email", 255).unique();
t.boolean("is_active").default(true);
t.string("password", 255);
t.string("username", 50).unique();
t.timestamps();
});
// Table: auth_role_auth_user
m.create_table("auth_role_auth_user", (t) => {
t.belongs_to("auth.role");
t.belongs_to("auth.user");
});
// Index: auth_role_auth_user
m.create_index("auth_role_auth_user", ["role_id"], {
name: "idx_auth_role_auth_user_role_id",
});
// Index: auth_role_auth_user
m.create_index("auth_role_auth_user", ["user_id"], {
name: "idx_auth_role_auth_user_user_id",
});
// Index: auth_role_auth_user
m.create_index("auth_role_auth_user", ["role_id", "user_id"], {
unique: true,
name: "idx_auth_role_auth_user_unique",
});
// Table: blog.post
m.create_table("blog.post", (t) => {
t.id();
t.belongs_to("auth.user").as("author");
t.text("body");
t.datetime("published_at").optional();
t.string("slug", 255).unique();
t.enum("status", ["draft", "published", "archived"]).default("draft");
t.string("title", 200);
t.datetime("deleted_at").optional();
t.timestamps();
});
// Index: blog.post
m.create_index("blog.post", ["author_id"]);
},
down(m) {
m.drop_table("blog.post");
m.drop_table("auth_role_auth_user");
m.drop_table("auth.user");
m.drop_table("auth.role");
},
});

Key points:

  • The m parameter is the migration builder with methods for schema operations
  • The t parameter (inside create_table) is the table builder for defining columns
  • Comments help organize operations by table
  • Tables are created in dependency order (referenced tables first)
  • The down() function reverses everything in up()

These are the operations available in generated migrations. Use this reference when reviewing or editing migrations.

The migration builder m provides these operations:

MethodDescription
m.create_table(ref, fn)Create a new table. Pass a callback that receives the table builder.
m.drop_table(ref)Drop an existing table.
m.rename_table(oldRef, newName)Rename a table.
m.add_column(ref, fn)Add a column to an existing table.
m.drop_column(ref, name)Drop a column from a table.
m.rename_column(ref, oldName, newName)Rename a column.
m.alter_column(ref, name, fn)Alter a column’s type or constraints.
m.create_index(ref, columns, opts?)Create an index. Options: name, unique.
m.drop_index(name)Drop an index by name.
m.add_foreign_key(ref, cols, refTable, refCols, opts?)Add a foreign key constraint. Options: name, on_delete, on_update.
m.drop_foreign_key(ref, name)Drop a foreign key constraint.
m.add_check(ref, name, expression)Add a CHECK constraint.
m.drop_check(ref, name)Drop a CHECK constraint.
m.sql(statement)Execute raw SQL.

Example usage:

up(m) {
// Add a new column
m.add_column("auth.user", (t) => {
t.string("phone", 20).optional();
});
// Create an index
m.create_index("auth.user", ["email"], {
name: "idx_user_email"
});
// Add a CHECK constraint
m.add_check("auth.user", "chk_age", "age >= 18");
// Raw SQL for complex operations
m.sql("CREATE EXTENSION IF NOT EXISTS pgcrypto;");
}

Inside create_table, the table builder t provides:

MethodDescription
t.id()UUID primary key column (named id).
t.timestamps()Adds created_at and updated_at columns.
t.soft_delete()Adds a nullable deleted_at datetime column.
t.sortable()Adds a position integer column (default 0).
t.belongs_to(ref)Foreign key to another table. Chainable with .as(alias), .optional(), .on_delete(action), .on_update(action).
t.one_to_one(ref)Unique foreign key (same chainable API as belongs_to).
t.many_to_many(ref)Many-to-many relationship (generates a junction table automatically).
t.belongs_to_any(refs, opts?)Polymorphic relationship. Adds {as}_type and {as}_id columns. Options: { as: "name" }.
t.string(name, length)Variable-length string column.
t.text(name)Text column (unlimited length).
t.integer(name)Integer column.
t.float(name)Floating-point number.
t.decimal(name, precision, scale)Decimal column for precise numbers.
t.boolean(name)Boolean column.
t.date(name)Date column (no time).
t.time(name)Time column (no date).
t.datetime(name)Timestamp column.
t.uuid(name)UUID column.
t.json(name)JSON column.
t.base64(name)Base64-encoded binary column.
t.enum(name, values)Enum column with predefined values.

Column modifiers (chainable):

  • .optional() - Allow NULL values
  • .unique() - Add unique constraint
  • .default(value) - Set default value
  • .backfill(value) - Set backfill value for existing rows when adding a NOT NULL column

Example usage:

m.create_table("products.item", (t) => {
t.id();
t.string("sku", 50).unique();
t.string("name", 200);
t.text("description").optional();
t.decimal("price", 10, 2);
t.integer("stock").default(0);
t.boolean("available").default(true);
t.enum("category", ["electronics", "clothing", "food"]);
t.belongs_to("products.brand").optional().on_delete("SET NULL");
t.timestamps();
});

Once you’ve generated migrations, use these commands to apply or rollback changes:

Terminal window
# Apply all pending migrations
alab migrate
# Preview SQL without applying
alab migrate --dry
# Apply migrations and create a git commit
alab migrate --commit
# Rollback the last migration
alab rollback
# Check which migrations are pending
alab status

AstrolaDB’s migration generator is deterministic and smart:

  1. Diff calculation - Compares your current schema files against the state stored in the last migration
  2. Dependency ordering - Tables are created in dependency order (referenced tables first, referencing tables after)
  3. Column sorting - Columns appear in a predictable order: id first, then alphabetically, then timestamps last
  4. Automatic reversals - The down() function is auto-generated as the reverse of up()

Migrations are always generated — never written from scratch. However, after generation you can edit them to add custom logic like raw SQL, data migrations, or tweaks that the generator can’t infer.

Typical workflow:

  1. Update your schema files (in schemas/)
  2. Run alab new <name> to generate a new migration
  3. Review and optionally edit the generated migration
  4. Run alab migrate to apply it

This keeps your schema definitions as the single source of truth while giving you full control over the migration logic when needed.