Database Migrations in Rows ORM

Database Migrations in Rows ORM

posted Originally published at encommunity.alusus.org 6 min read

Rows, the ORM built for Alusus language, takes a fundamentally different approach to database migrations compared to the linear, file-based systems found in tools like Sequelize, Flyway, or Rails ActiveRecord. Instead of maintaining an ordered sequence of migration files that must all be run in the right order, Rows ties migrations directly to your model definitions and tracks schema evolution on a per-table basis.

The Core Idea: Model Versions

Every model in Rows declares its current version as part of its @model annotation:

@model["vehicles", 2]
class Vehicle {
    define_model_essentials[];

    @notNull @primaryKey @Integer @column
    def id: int;

    @VarChar["50"] @column
    def name: String;

    @Integer @column["year"]
    def year: Int;

    @migration[1, 2]
    function migrate1To2(db: ref[Db]): SrdRef[Error] {
        return db.exec("alter table vehicles add column year integer").error;
    }
}

The number 2 in @model["vehicles", 2] is the current version of this model's schema. The @migration[1, 2] annotation on a method declares a function that upgrades the vehicles table from version 1 to version 2.

The library maintains a table_versions table in the database that records the current version of every table. When you call migrate(), the engine compares what versions the DB currently has against what versions the models declare, and figures out exactly which migration functions need to run.

The Migration Lifecycle

Step by step

  1. Ensure tracking table exists. The engine creates the table_versions table if it is not already there.

  2. Read current state. It queries table_versions to get the current version number for each table that has been migrated before.

  3. Pick needed migrations. For each model, the engine walks the version chain starting from the current DB version up to the model's target version, collecting every migration function needed. If any step in the chain is missing a migration function, it returns an error immediately.

  4. Populate dependencies. Each migration function can declare that it depends on another table being at a certain version. The engine resolves these into direct migration-to-migration dependencies.

  5. Topological sort. The collected migrations are sorted using Kahn's algorithm so that every migration runs only after all of its dependencies have completed. If a dependency cycle is detected, an error is returned.

  6. Run and record. Each migration is executed in sorted order. After each one succeeds, table_versions is updated to record the new version for that table.

Fresh Databases: No Migrations Needed

When a table does not yet exist in the database (version 0), the engine does not require you to write a migration function. It automatically generates a CREATE TABLE statement directly from the model's field definitions and annotations.

This means for a brand-new development environment you just call migrate() and all tables are created from scratch — no migration functions required at all. Migration functions are only needed when you need to transform an existing table from one version to another.

Multi-Step Migrations

A model can hold multiple migration functions, each covering a different version step. The engine automatically chains them together:

@model["vehicles", 4]
class Vehicle {
    // ... current field definitions ...

    @migration[2, 3, { Price: 1 }]
    function migrate2To3(db: ref[Db]): SrdRef[Error] {
        return db.exec("insert into prices select id as price_id, the_price as price from vehicles").error;
    }

    @migration[3, 4]
    function migrate3To4(db: ref[Db]): SrdRef[Error] {
        return db.exec("alter table vehicles drop column the_price").error;
    }
}

If a database is currently at version 2, the engine will run migrate2To3 and then migrate3To4 in sequence, leaving the table at version 4. If it is already at version 3, only migrate3To4 runs. A jump from version 1 to version 4 would chain migrate1To2 (from the now-deleted or earlier version of the model) through each step.

Cross-Table Dependencies

The third argument to @migration declares which other tables — and at which version — must be ready before this migration can run. The engine uses these declarations to determine a safe execution order across all tables.

@migration[2, 3, { Price: 1 }]
function migrate2To3(db: ref[Db]): SrdRef[Error] {
    // This migration copies data from vehicles into the prices table.
    // It must only run after the prices table exists at version 1.
    return db.exec("insert into prices select id as price_id, the_price as price from vehicles").error;
}

The dependency { Price: 1 } tells the engine: "before running this function, make sure the prices table is at version 1." The engine resolves this into a direct ordering constraint between the two migrations and uses the topological sort to enforce it.

Bypassing Auto Table Generation

By default the engine creates a table automatically for any model whose table does not yet exist. If you need full control over the initial table creation for whatever reason you can define a migration from version 0 yourself. The presence of a @migration[0, N] function signals to the engine to skip the auto-generation entirely and use your function instead:

@model["vehicles", 1]
class Vehicle {
    // ... field definitions ...

    @migration[0, 1]
    function createTable(db: ref[Db]): SrdRef[Error] {
        return db.exec(
            "create table vehicles (id integer primary key, name varchar(50)) partition by range (id)"
        ).error;
    }
}

Arbitrary Migrations using Fake Models

Sometimes you need to run a one-off data migration that does not correspond to a structural change in any particular table. You can use a "fake" model — a class with no column definitions — purely as a container for a migration function:

@model["price_update", 1]
class PriceUpdate {
    @migration[0, 1, { Vehicle: 2 }]
    function migrate0To1(db: ref[Db]): SrdRef[Error] {
        return db.exec("update vehicles set the_price = the_price + 50").error;
    }
}

Because this model has a @migration[0, 1] function, no table is created for it. The engine just runs the function and records price_update at version 1 in table_versions. The dependency { Vehicle: 2 } ensures the vehicles table is at the expected state before the data update runs.

Running Migrations

You pass a schema — a single model or a grouped set of models — to schemaBuilder and call migrate():

// Single model
def schema: Vehicle;
db.schemaBuilder[schema].migrate();

// Multiple models
def schema: { Vehicle, Price, PriceUpdate };
db.schemaBuilder[schema].migrate();

The engine handles everything: determining what needs to run, ordering it correctly, and recording progress.

Cleaning Up Old Migrations

Once a migration function has been deployed and run in production, it can be deleted from the source code. The engine only needs migration functions that bridge the gap between the current DB version and the target version. Any migration that covers a version range already passed in production is simply never consulted again.

This is a significant ergonomic improvement: over time your model files stay lean rather than accumulating an ever-growing list of historical migration methods.


Comparison with Linear Migration Systems

Tools like Sequelize, Flyway, and Rails ActiveRecord manage migrations as a linearly ordered sequence of numbered files. Every migration ever written must be present and run in order, even on a fresh database.

Concern Linear system Rows
New table Migration needed Auto generated, no migration needed
Understanding current schema Must mentally replay all migrations Read the model definition directly
Fresh database Run every migration from the beginning Auto-generate tables from models
Branch conflicts Two people adding migrations get sequence number conflicts Each model is independent; different models never conflict
Cleaning up history Squashing migrations is risky and non-trivial Delete functions once they are deployed; no risk
Cross-table ordering Implicit via file numbering; easy to get wrong Explicit dependency declarations; engine enforces correct order
Arbitrary data migrations A migration file like any other Fake model with a 0→1 migration function
Schema as documentation Migration files are authoritative, model may drift Model is always authoritative and up to date

Branch conflicts

In a linear system, two developers working on different features each create the next migration file. One calls it 0042_add_tags.sql and the other calls it 0042_add_ratings.sql. When the branches merge, one of them must be renumbered and every developer must re-run migrations. In Rows, because migrations belong to their model and are identified by version pairs rather than global sequence numbers, two developers working on Article and Comment respectively can never conflict with each other.

Schema clarity

With a linear system, the only way to know the current shape of a table is to read every migration that has ever touched it. In Rows, the model class is always the authoritative description of the current schema. Columns, types, nullability, defaults, primary keys, and foreign keys are all declared there. The migration functions are only concerned with the delta between versions, not the full picture.

History hygiene

Linear systems accumulate migrations indefinitely. Squashing them into a single "initial" migration is possible but fragile and infrequently done. In Rows, migration functions for old version ranges can simply be deleted once they have been run in production. The model definition already captures the end state; there is nothing to preserve.

2 Comments

1 vote
1

More Posts

Meet Trysil: a lightweight ORM for Delphi

davidlastrucci - Apr 20

The Hidden Program Behind Every SQL Statement

lovestaco - Apr 11

Filtering, sorting, and pagination with the fluent query builder

davidlastrucci - Apr 20

Why learning to build your own ORM is worth it (even if you use EF Core or Dapper)

Spyros - Dec 19, 2025

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!