Skip to content

v3.migrations #

V3 ORM migrations

v3.migrations provides ordered, reversible ORM migrations for the V3 compiler. It records applied versions in schema_migrations, uses transactions when the database supports transactional DDL, and supports migrate, rollback, redo, reset, target-version, and status workflows.

Mutating workflows hold a database-level lock from before the applied-version snapshot through all callbacks and history updates. PostgreSQL uses an advisory lock, MySQL uses a named lock, and SQLite uses an immediate transaction so concurrent runners cannot apply the same migration. MySQL lock names use a qualified history table's database, or otherwise the current database, so independent databases on one server do not contend, and follow the server's lower_case_table_names mode. PostgreSQL lock keys use the effective history schema and the full signed 64-bit advisory-lock space. A migrator retains its resolved database or schema across workflow calls, so callback namespace changes cannot redirect later locks or history access. Unqualified SQLite history tables are pinned to main, preventing TEMP tables from shadowing persistent history.

import db.sqlite
import v3.migrations

fn create_users(mut ctx migrations.Context) ! {
    ctx.create_table(migrations.Table{
        name: 'users'
        columns: [
            migrations.Column{
                name: 'email'
                kind: .varchar
                nullable: false
            },
        ]
    })!
}

fn drop_users(mut ctx migrations.Context) ! {
    ctx.drop_table('users')!
}

mut db := sqlite.connect('app.db')!
mut migrator := migrations.new(mut db, [
    migrations.Migration{
        version: 20260816143000
        name: 'create_users'
        up: create_users
        down: drop_users
    },
], migrations.Config{
    dialect: .sqlite
})!

migrator.migrate()!

Migration callbacks receive a context that implements orm.Connection. Existing ORM DDL and DML can therefore be mixed with migration helpers:

fn create_users(mut ctx migrations.Context) ! {
    sql ctx {
        create table User
    }!
}

The schema helpers include table and column creation/removal/renaming, indexes, inline or altered foreign keys, and trusted raw SQL via ctx.execute(). SQLite cannot directly change a column or add/remove a foreign key on an existing table; those helpers return an error so the migration can explicitly rebuild the table. SQLite add_column also rejects primary-key, unique, and auto-increment columns, non-nullable columns without a non-NULL default, and nonconstant defaults even when prohibited expressions have unary signs, SQL comments, or postfix clauses. Parenthesized and signed literal defaults remain allowed. Numeric defaults require a mantissa and a complete exponent; digit separators require SQLite 3.46.0 or newer. Constant CAST expressions, including signed casts, are also accepted, while casts resolving to NULL are rejected for NOT NULL columns. Casts of functions, column identifiers, or current-time values remain rejected. SQLite index tables and foreign-key targets must be unqualified; index removal derives the index schema from a qualified table or resolves an unqualified table using SQLite lookup order. Index creation resolves the table and qualifies an unqualified index name with the same schema; an explicitly qualified index name selects its attached database. PostgreSQL change_column supports type-related fields only and rejects explicitly supplied constraint options, including false or empty values, before executing SQL; use ctx.execute() for explicit constraint DDL. PostgreSQL serial columns reject explicit defaults, and index removal derives the index schema from a qualified table; PostgreSQL index names are unqualified when adding them. SQLite non-integer primary keys are explicitly non-nullable. Decimal scale requires a positive precision. MySQL change_column requires nullable, default, and auto-increment attributes; omitted key options, including those on auto-increment columns, are preserved, additions use true, and removals must use remove_index() or raw SQL. MySQL auto-increment columns must be primary keys or unique, MySQL index names must be unqualified when adding or removing them, and tables cannot contain more than one auto-increment column. MySQL foreign keys reject SET DEFAULT. Column-level identifiers must be unqualified. Generated PostgreSQL and MySQL index and foreign-key names are shortened deterministically to their dialect limits; every generated index name and every generated PostgreSQL or MySQL foreign-key identity receives a deterministic component-aware hash suffix, and overlong explicit names are rejected. Caller-supplied table, column, and history-table name components are also checked against those dialect limits, and qualified table, history, or index names may contain at most two components. SQLite and MySQL reject case-insensitive duplicate table columns. PostgreSQL and SQLite table rename targets must also be unqualified; MySQL keeps support for qualified table targets. MySQL migrations default to non-transactional execution because MySQL DDL implicitly commits. MySQL history strings use hex literals, while PostgreSQL uses explicit escape strings with doubled backslashes, avoiding session-mode-sensitive escaping. The migrator accepts orm.TransactionalConnection implementations, and Config.transaction_mode can override whether per-migration transaction methods are used for DDL. SQLite's immediate lock transaction still covers each mutating workflow; failed acquisition and commit paths roll back and remove their temporary transaction probes. Migration names containing NUL bytes are rejected before any database access. PostgreSQL migrations reject orm.DB decorators without probing their transactions; pass a direct session-pinned pg.Conn without an active transaction. Existing transactions, including pg.Tx, are rejected in every transaction mode so the session lock cannot be released before their work commits. Unqualified PostgreSQL history tables resolve an existing persistent relation before falling back to the normal creation schema for inspection, creation, and lock namespacing. Temporary relations are ignored unless explicitly qualified in Config.table. PostgreSQL transactions opened by callbacks in never mode are rolled back and rejected before the advisory migration lock is released, including when an aborted transaction makes the history write fail. In transactional modes, callbacks cannot end the migrator-owned PostgreSQL transaction before history is written. MySQL always mode verifies an owned savepoint before writing history. SQLite callbacks likewise cannot end or replace the original immediate lock transaction before the history write. MySQL migrations and inspections also reject connections with active transactions or disabled session autocommit, using a unique savepoint name for each transaction-state probe. An unqualified MySQL history table is resolved and retained on first use, so later database changes on the same connection cannot redirect history operations or lock namespacing. Callback-created MySQL transaction state is rolled back and rejected before the named migration lock is released.

fn create_orm_table #

fn create_orm_table[T](mut ctx Context) !

create_orm_table creates the table represented by T through V's ORM metadata.

fn drop_orm_table #

fn drop_orm_table[T](mut ctx Context) !

drop_orm_table drops the table represented by T through V's ORM metadata.

fn new #

fn new(mut conn orm.TransactionalConnection, registered []Migration, config Config) !Migrator

new creates and validates a migrator. It does not access the database until one of the migration or inspection methods is called.

fn ColumnType.from #

fn ColumnType.from[W](input W) !ColumnType

fn Dialect.from #

fn Dialect.from[W](input W) !Dialect

fn MigrationOperation.from #

fn MigrationOperation.from[W](input W) !MigrationOperation

fn MigrationState.from #

fn MigrationState.from[W](input W) !MigrationState

fn TransactionMode.from #

fn TransactionMode.from[W](input W) !TransactionMode

type MigrationFn #

type MigrationFn = fn (mut Context) !

MigrationFn changes the schema through a migration Context.

enum ColumnType #

enum ColumnType {
	boolean
	integer
	bigint
	real
	double_precision
	text
	varchar
	blob
	date
	datetime
	timestamp
	json
	jsonb
	uuid
	decimal
}

ColumnType is a portable subset of common SQL column types.

enum Dialect #

enum Dialect {
	sqlite
	pg
	mysql
}

Dialect selects the SQL emitted by schema helpers.

enum MigrationState #

enum MigrationState {
	applied
	pending
	missing
}

MigrationState describes the relationship between code and database history.

enum TransactionMode #

enum TransactionMode {
	automatic
	always
	never
}

TransactionMode controls whether each migration is wrapped in a transaction.

struct AppliedMigration #

struct AppliedMigration {
pub:
	version    i64
	name       string
	applied_at string
}

AppliedMigration is a migration recorded in the database history table.

struct Column #

struct Column {
pub:
	name           string
	kind           ColumnType
	limit          int
	precision      int
	scale          int
	nullable       ?bool
	default_sql    ?string
	primary_key    ?bool
	auto_increment ?bool
	unique         ?bool
}

Column describes a column used by create_table, add_column, or change_column. default_sql is inserted verbatim and should contain a trusted SQL expression. Constraint fields are optional so change_column can distinguish omitted options from explicit false or empty values.

struct Config #

struct Config {
pub:
	dialect          Dialect
	table            string          = 'schema_migrations'
	transaction_mode TransactionMode = .automatic
}

Config configures a Migrator.

struct Context #

struct Context {
mut:
	conn                   orm.Connection
	sqlite_runtime_version ?int
pub:
	dialect Dialect
}

Context is passed to migration callbacks. It implements orm.Connection, so normal V3 sql ctx { ... } ORM statements can be used alongside schema helpers.

fn (Context) execute #

fn (mut ctx Context) execute(query string) ![]orm.Row

execute runs trusted raw SQL through the migration connection.

fn (Context) create_table #

fn (mut ctx Context) create_table(table Table) !

create_table creates a table from a Rails-style table definition.

fn (Context) drop_table #

fn (mut ctx Context) drop_table(name string) !

drop_table drops a table by name.

fn (Context) rename_table #

fn (mut ctx Context) rename_table(from string, to string) !

rename_table renames a table. PostgreSQL and SQLite targets must be unqualified.

fn (Context) add_column #

fn (mut ctx Context) add_column(table string, column Column) !

add_column adds a column to an existing table.

fn (Context) remove_column #

fn (mut ctx Context) remove_column(table string, column string) !

remove_column removes a column from an existing table.

fn (Context) rename_column #

fn (mut ctx Context) rename_column(table string, from string, to string) !

rename_column renames a column.

fn (Context) change_column #

fn (mut ctx Context) change_column(table string, column Column) !

change_column changes a column definition. SQLite requires a table rebuild. PostgreSQL supports type-related fields only and rejects constraint changes before executing SQL; use execute for explicit PostgreSQL constraint DDL. MySQL requires nullable, default_sql, and auto_increment to be supplied because MODIFY COLUMN replaces those attributes. Omitted key options are preserved.

fn (Context) add_index #

fn (mut ctx Context) add_index(index Index) !

add_index adds an index. When name is empty, a deterministic Rails-style index_<table>_on_<columns> name is used. SQLite tables and PostgreSQL/MySQL index names must be unqualified.

fn (Context) remove_index #

fn (mut ctx Context) remove_index(table string, name string) !

remove_index removes an index by name. PostgreSQL and SQLite derive the index schema from a qualified table unless name is already qualified. MySQL index names must be unqualified.

fn (Context) add_foreign_key #

fn (mut ctx Context) add_foreign_key(key ForeignKey) !

add_foreign_key adds a named foreign-key constraint. SQLite cannot add one to an existing table without rebuilding the table.

fn (Context) remove_foreign_key #

fn (mut ctx Context) remove_foreign_key(table string, name string) !

remove_foreign_key removes a named foreign-key constraint.

fn (Context) select #

fn (mut ctx Context) select(config orm.SelectConfig, data orm.QueryData, where orm.QueryData) ![][]orm.Primitive

select forwards ORM queries through the migration connection.

fn (Context) insert #

fn (mut ctx Context) insert(table orm.Table, data orm.QueryData) !

insert forwards ORM inserts through the migration connection.

fn (Context) update #

fn (mut ctx Context) update(table orm.Table, data orm.QueryData, where orm.QueryData) !

update forwards ORM updates through the migration connection.

fn (Context) delete #

fn (mut ctx Context) delete(table orm.Table, where orm.QueryData) !

delete forwards ORM deletes through the migration connection.

fn (Context) create #

fn (mut ctx Context) create(table orm.Table, fields []orm.TableField) !

create forwards ORM table creation through the migration connection.

fn (Context) drop #

fn (mut ctx Context) drop(table orm.Table) !

drop forwards ORM table removal through the migration connection.

fn (Context) last_id #

fn (mut ctx Context) last_id() int

last_id forwards the last inserted id from the migration connection.

struct ForeignKey #

struct ForeignKey {
pub:
	from_table  string
	column      string
	to_table    string
	primary_key string = 'id'
	name        string
	on_delete   string
	on_update   string
}

ForeignKey describes a foreign-key constraint.

struct Index #

struct Index {
pub:
	table   string
	columns []string
	name    string
	unique  bool
}

Index describes an index for Context.add_index.

struct Migration #

struct Migration {
pub:
	version i64
	name    string
	up      MigrationFn @[required]
	down    MigrationFn @[required]
}

Migration is one reversible, versioned database schema change.

Versions are positive integers. Timestamp-shaped versions such as 20260816143000 make migrations naturally sortable, like Rails migrations.

struct Migrator #

struct Migrator {
mut:
	conn                       orm.TransactionalConnection
	migrations                 []Migration
	config                     Config
	pg_lock_key                ?i64
	mysql_lock_name            string
	sqlite_lock_active         bool
	sqlite_transaction_probe   string
	resolved_history_namespace string
	resolved_history_table_sql string
}

Migrator applies an ordered set of migrations and records their versions.

fn (Migrator) migrate #

fn (mut m Migrator) migrate() ![]AppliedMigration

migrate applies every pending migration in ascending version order.

fn (Migrator) migrate_to #

fn (mut m Migrator) migrate_to(target_version i64) ![]AppliedMigration

migrate_to moves the schema to target_version. Pending migrations at or below the target are applied; applied migrations above it are rolled back.

fn (Migrator) rollback #

fn (mut m Migrator) rollback(steps int) ![]AppliedMigration

rollback reverts the newest steps applied migrations.

fn (Migrator) rollback_last #

fn (mut m Migrator) rollback_last() ![]AppliedMigration

rollback_last reverts the newest applied migration.

fn (Migrator) redo #

fn (mut m Migrator) redo(steps int) ![]AppliedMigration

redo rolls back the newest steps migrations and applies them again.

fn (Migrator) redo_last #

fn (mut m Migrator) redo_last() ![]AppliedMigration

redo_last rolls back and reapplies the newest migration.

fn (Migrator) reset #

fn (mut m Migrator) reset() ![]AppliedMigration

reset rolls back every applied migration while preserving the history table.

fn (Migrator) applied #

fn (mut m Migrator) applied() ![]AppliedMigration

applied returns the migrations recorded in the database, oldest first.

fn (Migrator) pending #

fn (mut m Migrator) pending() ![]Migration

pending returns registered migrations that have not been applied yet.

fn (Migrator) current_version #

fn (mut m Migrator) current_version() !i64

current_version returns the greatest applied version, or zero for an empty schema.

fn (Migrator) status #

fn (mut m Migrator) status() ![]Status

status reports applied and pending migrations, plus applied versions whose migration definitions are missing from the program.

struct Status #

struct Status {
pub:
	version    i64
	name       string
	state      MigrationState
	applied_at string
}

Status is one row returned by Migrator.status. A missing row was applied to the database but is no longer registered in code.

struct Table #

struct Table {
pub:
	name          string
	columns       []Column
	foreign_keys  []ForeignKey
	id            bool   = true
	id_name       string = 'id'
	if_not_exists bool
}

Table describes a table for Context.create_table. An auto-incrementing bigint id primary key is added unless id is false.