db-migrate rewritten in meta. A program written in meta ships with its
migrations compiled in, as one binary. During development, a launcher compiles
each migration on its own, so down, edit, up takes a fraction of a second.
The migrations table has the same columns and the same names as node
db-migrate's (/20261008120000-add-pets). A database migrated by one of them
can be carried on by the other.
#include <db_migrate.h>
static int up(migrator_t *db) {
db->createTable("pets", {
id: {type: "int", primaryKey: true, autoIncrement: true},
owner_id: {type: "int", notNull: true,
foreignKey: {table: "owners", mapping: "id",
rules: {onDelete: "CASCADE"}}},
name: {type: "string", length: 48, notNull: true},
traits: "jsonb",
});
db->addIndex("pets", "pets_name_idx", ["name"]);
json_t pets = db->all(SQL`select id, name from pets`);
defer pets.release();
for (int i = 0; i < pets.count(); ++i)
db->run(SQL`update pets set slug = ${slugify(pets[i].name)}
where id = ${(long)pets[i].id}`);
return 0;
}
static int down(migrator_t *db) {
return db->dropTable("pets");
}
DBM_MIGRATION(up, down)- The specs are documents, not structs. What a column can say belongs to
the driver, so
{...}and[...]passed where ajson_tis expected become JSON. A literal made only of constants is built once. One that contains variables is built per call and freed afterwards. - Errors stick. The first failing call records its reason, and every later
call does nothing. A migration therefore reads as a plain list of steps,
and the walker rolls back the whole migration together with its record.
A migration fails on its own with a template:
return db->fail(TEXT`no owner called ${name}`); - Data migrations are code.
db->allreturns rows as JSON, anddb->runtakes theSQLliteral, whose values never end up in the SQL text. - Driver-specific features need their header. With
#include <db_migrate/pg.h>,db->createSequence(...)becomes available. Without the header the compiler does not know the method. Run against a different database, the call fails with a clear reason.
A v2 migration (node's _meta: {version: 2}) has no down. It is undone from
what it learned while running up, recorded in node's format, so node can undo it
and vice versa:
static int migrate(schema_t *db) {
db->createTable("owners", {id: {type: "int", primaryKey: true}, name: "string"});
return db->addColumn("owners", "email", {type: "string", length: 120});
}
DBM_MIGRATION_V2(migrate)Like in node, it runs outside a transaction. Every step is recorded in
migrations_state before the next one starts, together with how far it got:
started, its undoing recorded (learned), sent to the database (done).
- A failure is undone by exactly the steps that reached the database. That includes the failed step itself if its table or column was made before a foreign key failed.
- A run that dies halfway is resumed by the next one, as in node since
1.0.0-beta.38. By default the steps that ran are skipped and the rest runs.
A migration that says
DBM_MIGRATION_V2_RECOVERY(migrate, "rollback")(node's_meta.recovery) is undone instead and run again. A run that was rolling back when it died is rolled back further. A file changed since is not skipped blind. This also works across the two tools: node can resume a run this one left, and the other way round.
Migrations can also be one SQL file with an up and a down section, as
db-migrate-plugin-sql writes them for node db-migrate 1.0
(create --sql): migrations/<stamp>-<name>.sql with a line -- up, its
SQL, and optionally a line -- down and its SQL. The same file works with
node and the plugin and with this, without a plugin.
Or plain SQL in two files, as node's create --sql-file creates them:
migrations/sqls/<stamp>-<name>-up.sql and -down.sql. No code file is
needed next to them. Text containing only comments (the placeholder) does
nothing.
What node's _meta says besides the version goes into the _WITH forms:
DBM_MIGRATION_WITH(up, down, {transactions: false}) /* v1, no transaction */
DBM_MIGRATION_V2_WITH(migrate, {release: "2.3.0", recovery: "rollback"})
DBM_MIGRATION_DML_WITH(migrate, {background: true, blocking: true})db->adopt() declares a table, column, index or foreign key that a v1
migration or a person made, without sending anything; v2 migrations then
treat it as their own, and undoing the migration only forgets it again.
Dropping something the schema does not know is refused with node's words,
unless it is asked for with {irreversible: true}
(db->dropTableWith("junk", {irreversible: true})): then down refuses
the migration, and a failure after it is not rolled back - the next run
continues after the steps that ran.
db->adopt()->createTable("legacy", {id: {type: "int", primaryKey: true}});
db->addColumn("legacy", "age", {type: "int"});A dml migration (node's _meta: {version: 2, type: 'dml'}) changes data
instead of the schema. Every step is recorded with what reverts it, in
node's records, before it runs:
static int migrate(dml_t *db) {
db->updateWith("pets", {kind: "hound"}, {kind: "dog"}, {batch: 500});
db->deleteWith("pets", ["kind = 'fish'"], {});
db->deleteWith("pets", {id: [7, 8]}, {mode: "soft", column: "deleted_at",
purge: true});
db->runSql("UPDATE pets SET age = age + 1", {revert: "UPDATE pets SET age = age - 1"});
return db->insert("kinds", [{name: "dog"}, {name: "cat"}]);
}
DBM_MIGRATION_DML(migrate)- insert flags its rows in
__dbmigrate__flag; reverting deletes them. - update and delete copy the rows into a backup table
__dbm_backup_<hash>first and restore them from there. They change the rows in batches by their key, each in a transaction; an interrupted run continues after the last batch. - delete in soft mode sets a column of yours and marks the rows;
db->purge("pets", "<migration>")deletes them for good later, or a later release does withpurge: {releases: 1, drop: "auto"}. - where is an object (
{status: "old", id: [1, 2], gone: null}), or a SQL text with its parameters as["kind = ?", ["fish"]]. A text alone is written["kind = 'fish'"]: a bare string is not a document in meta. {irreversible: true}runs a step without being able to revert it.db->all(SQL`...`)reads, and is no step.
A soft delete's mark #1 is found as itself, not as part of #10; on
MySQL, whose flag column keeps 255 characters, a soft delete that would make
a flag longer is refused instead of cut off - as node does since 1.8.1.
A dml migration with {background: true} is registered by up as a job in
__dbmigrate_jobs__ instead of being run, and the migrations after it go on.
migrate work runs the jobs:
migrate work --parallel 2 --pause 50 --batch 500 --interval 5000 --job-timeout 60000 --watch
Each slot is a process of its own. A job keeps its progress, stops after its
batch on SIGINT or SIGTERM and continues with the next run, is taken over
when its worker stops renewing it for --job-timeout, and is rolled back
when it fails - up queues it again. {blocking: true} keeps the jobs
after it waiting. While a process migrates, it pauses the jobs, so
migrations always go first, and down reverts the jobs before the
migrations. A program runs them with dbmWork and stops them with
dbmWorkStop.
A job keeps its scope and runs in it. migrate work runs the jobs kept with
the top level, those of the scopes sharing its database too; a scope with a
database of its own keeps its jobs there, and migrate work:<scope> runs
them. A job recovered by rolling back keeps its record, a job whose
migration was recorded already is forgotten instead of run twice, and
work --dry-run says what would run - as node does since 1.8.1.
A migration starts a release with release in its _meta; the ones after it
without a label belong to it. A v2 migration deprecates what the application
stops using:
db->deprecateTableWith("legacy_orders", {releases: 2, drop: "auto"});
db->deprecateColumn("users", "fax");With the next release it is renamed to __dbm_deprecated_<name>_<time>, so
whatever still uses it fails while the data is still there. After releases
of them (4) it is dropped - with drop: "auto" by itself, otherwise up
says it is due until a migration calls db->dropDeprecated(). What a
release does runs before its first migration and is reverted with it. Soft
deleted rows are purged and the backups of dml migrations dropped the same
way. The project's defaults go into .db-migraterc:
{"deprecation": {"releases": 2, "drop": "auto"}}.
migrate status says what is pending, the release, the lock, the jobs and
what is deprecated or due, in node's words.
Seeds in seeds/*.c insert rows into tables made by v2 migrations; their
rows are flagged, so seed removes them before it inserts them again,
seed down [name] removes them, seed reset removes all of them:
static int seed(seed_t *db) {
return db->insert("owners", [{id: 1, name: "Ann"}, {id: 2, name: "Bob"}]);
}
DBM_SEED(seed)up [name] run what has not run, up to and including name
down [name] undo the last one, or everything after name
sync name up or down, whichever reaches name
reset undo everything
check list what would run
status what is pending, the release, the lock, the jobs, what is due
fix rebuild node's state from the v2 migrations that ran
work run the background jobs
seed [name] insert the seeds again; seed down [name], seed reset
create name a new migration (--sql, --sql-file, --v2-file, --template NAME)
db:create name create a database, if it is not there yet
db:drop name drop a database, if it is there
command:scope the same in migrations/<scope>/ - up:billing, create:billing
Options as in node: -e/--env, --config, -m/--migrations-dir,
-c/--count, -t/--table, -s/--state-table, --lock-timeout,
--lock-interval, --backup-state, --dry-run, --check, -v/--verbose,
--non-transactional, --sql-file, --v2-file, --template,
--ignore-on-init, --ignore-completed-migrations, --log-level,
--seeds-dir, -h, -i, and for work --parallel, --pause,
--batch, --interval, --job-timeout, --watch. A destination can be
abbreviated: up 20261008 runs everything up to that day.
The options can also be kept in rc files, as node reads them since 1.1:
/etc/db-migraterc, ~/.db-migraterc, the first .db-migraterc from the
working directory upwards, the file $config names, and db-migrate_<key>
variables - JSON with comments, or INI. The command line wins over them.
In database.json, {"ENV": "SHOP_PASSWORD", "default": "dev"} reads a
variable and falls back to the default when it is not set, at any depth and
for defaultEnv too.
--ignore-on-initis for taking over a database that already has what the migrations make.create --sql-file --ignore-on-initwrites-- db-migrate: ignore-on-initas the first line of the up. Such an up is recorded without being run whenup --ignore-on-initis given. A migration in code checksdb->ignoreOnInititself, which the code template created with the flag already does.--log-level info|warn|error|sqlworks as in node: it selects which[INFO],[WARN],[ERROR]lines and statements are printed (-v, and a dry run).- A dry run reads which migrations have run and prints what would actually happen.
While it migrates, a process holds node's migration lock: the
__dbmigrate_state__ row in migrations_state, renewed every third of
--lock-timeout. A second process, node or this one, waits. It only takes the
lock over after the row has stayed unchanged for the whole timeout (60 s by
default), measured on its own monotonic clock. The state lives on a
connection of its own, so it survives a migration's rollback.
A failed migration is reported the way node reports it since 1.0.0-beta.38:
which migration failed, at which step for v2, the message, the statement
with a marker under the position the database names, and the driver's
diagnostic fields (code, detail, hint, ...):
[ERROR] Migration "20261009100000-broken" failed: relation "nope_table" does not exist
SQL: select *
from nope_table
^
code: 42P01
Scopes are subfolders of migrations/. Their migrations are recorded as
billing/<name> and those at the top level as /<name>, exactly as in node.
Each command only sees its own scope; up:all and the other commands with
all run the top level and every scope, nested ones too. A scope is a
folder with migrations in it - unlike node, which counts every folder, an
empty one is not entered. A scope's config.json is laid over the
environment: one that says only database or schema switches to it on
the same connection, one that says more connects on its own and keeps its
own lock and state there.
int main(int argc, char **argv) {
if (argc > 1 && strcmp(argv[1], "migrate") == 0)
return dbmCli(argc - 1, argv + 1); /* every command above */
...
}./build-app.sh <app> <output> [driver or plugin...] lowers the app, its
plugins/ and its migrations, including scopes, embeds the SQL migrations as
strings, and links
libdbmigrate.a with --whole-archive, together with only the drivers you
name (default: pg). A program on SQLite does not
need libpq installed. Drivers and migrations register
themselves through constructors, and nothing else references them, so a
linker that only pulls in referenced objects would drop them.
With --static, the client libraries go into the program too: libpq,
OpenSSL, SQLite and libyaml, as tools/deps.sh builds them. The program then
needs glibc on the host and nothing else. test/static.sh runs one on bare
Debian and Fedora.
- glibc stays dynamic. Name resolution goes through its NSS modules, which belong to the host, so DNS behaves like it does for every other program there.
- libpq is built from source, without GSSAPI and LDAP. The
distributions'
libpq.aneeds Kerberos, and no distribution ships that static. - mysql stays dynamic. libmysqlclient is GPL-2.0 with the FOSS exception, which is not something to bake into a program that is not free software.
./build-app.sh --static . ./app pg yamlThe libraries come with a .pc file each, and pkg-config works out what
they pull in, so the build host needs pkg-config as well as a C compiler.
Configuration works as in node db-migrate:
database.jsonwith environments (-e,NODE_ENV,defaultEnv,dev)database.yml(or.yaml) when there is nodatabase.json, or any--configfile whose ending a plugin reads{"ENV": "NAME"}for values taken from the environmentDATABASE_URLwhen there is no filetunnel: {...}in a connection, to reach it through ssh (see Plugins)
A program that brings meta's runtime itself - an nginx module built with
meta -module, which links lib/libmeta_runtime.a - links
libdbmigrate-core.a, the core without that runtime, and its drivers, and
migrates when it starts:
static void toLog(int level, const char *line) { /* the program's log */ }
dbmSetLogger(toLog); /* instead of stdout and stderr */
char why[512];
if (dbmMigrateUp(config, NULL, why, sizeof why) != 0) /* NULL: node's defaults */
fail(why);dbmMigrateUp does what up does: a connection, a second one for node's
state, the lock with its heartbeat, every migration the database has not
run (v2 included), and everything closed again. It blocks. config is one
connection as an environment of database.json says it; a dbm_options_t
names other tables or lock timings. test/embed.sh builds such a shared
object, loads it and migrates through it.
Everything in libdbmigrate is built with -fPIC, so all of it can go into
a shared object.
build/meta-migrate up
build/meta-migrate down
# edit the migration
build/meta-migrate up # compiles only the edited file, ~0.15 s- The launcher reads the migration files by name only and compiles exactly
those the database says need to run.
checkcompiles nothing. - Each one is kept as a
.sounder.meta-migrate/<hash>/. - It loads the driver the configuration names (
libdbmigrate-pg.so) only at that point, so libpq is only needed where PostgreSQL is used. - Apart from that it runs the same
dbmClias the shipped program.
What node's plugins were for is either built in or a plugin compiled into the program. A plugin registers from its constructor, the same way a driver does, so nothing else has to name it.
| node | here |
|---|---|
plugin-yaml (init:config:overwrite:require) |
shipped plugin yaml (libyaml), registered for .yml/.yaml |
plugin-tunnel-ssh (connection:tunnel:ssh) |
built in: tunnel in a connection |
connection:tunnel:<type> |
dbmRegisterTunnel("<type>", open, close) |
init:config:overwrite:require |
dbmRegisterConfigLoader(".ext", load) |
create:template |
dbmRegisterTemplate("name", write), used by create x --template name |
See include/db_migrate_plugin.h.
-
YAML reads like js-yaml's
safeLoad: unquoted5432is a number, quoted text stays text, and anchors, aliases and<<merges work. YAML is a plugin rather than part of the core, so only a program that reads YAML needs libyaml:./build-app.sh . ./app pg yaml. The launcher loadslibdbmigrate-yaml.sothe first time it meets a.yml. -
The ssh tunnel is the system's
ssh -N -L, so~/.ssh/config, the agent andknown_hostsapply. node's tunnel-ssh checked no host key at all. The keys are the ones plugin-tunnel-ssh took:prod: driver: pg host: db.internal # as the bastion sees it database: shop tunnel: host: bastion.example username: deploy privateKeyPath: /home/deploy/.ssh/id_ed25519 # port (22), privateKey, password, passphrase, localHost, localPort, # keepaliveInterval, readyTimeout (20000), options: {Key: value} -> -o
Passwords and passphrases reach ssh through
SSH_ASKPASSand the environment, never through the command line. Without one, ssh runs in batch mode and fails instead of waiting at a prompt.DBM_SSHnames a different ssh binary. -
A project's own plugins go in
plugins/*.c. The launcher compiles and opens them before it reads anything else, andbuild-app.shcompiles them in.
A driver is a driver_t that dbmDriverNew has filled with the generic
versions, the counterpart of db-migrate-base. The driver overrides only what
its database does differently. To call the generic version (_super), it
calls the generic function by name. The generic functions call each other
only through self, so overriding columnDef changes what createTable
writes. See include/db_migrate_driver.h.
| Driver | database.json | Special features (header) | Notes |
|---|---|---|---|
pg |
host, port, user, password, database, sslmode |
sequences, enums (db_migrate/pg.h) |
DDL is transactional |
cockroachdb |
as pg | additionally changePrimaryKey, uuid keys, computed columns, row TTL (db_migrate/cockroachdb.h) |
extends pg. A column added to an existing table cannot be used until its transaction commits, so adding a column and filling it are two migrations |
mysql |
host, port, user, password, database, socketPath |
table options (engine, charset), unsigned, after, comment |
MySQL commits before every DDL statement. A failed migration keeps the DDL it already ran, only its record is rolled back |
sqlite3 |
filename, busyTimeout (ms, 10000) |
– | foreign keys are written into the column (REFERENCES). Changing a column in place is not possible and is refused with a reason |
cockroachdb extends pg the way db-migrate-cockroachdb extends db-migrate-pg:
dbmPgConnect returns a fully configured pg driver, and Cockroach replaces
individual slots in it (src/drivers/pg_driver.h).
./build.sh # libdbmigrate.a, a driver library each, meta-migrate
test/run.sh # against all four databases
test/plugins.sh # yaml, plugins/, the ssh tunnel (own sshd)
test/options.sh # --ignore-on-init, --log-level, dry runs
test/static.sh # build-app --static: glibc only, on bare Debian/Fedora
test/embed.sh # dbmMigrateUp from a shared object with its own runtime
test/recovery.sh # v2 runs that die or fail halfway, resumed and rolled back
test/compat.sh # moving a project from node db-migrate and backtest/run.sh [driver...] runs the same cycle against every database: up,
down, reset, a migration that fails partway, a dry run, and the launcher.
test/migrations/common is shared, and each driver has its own folder. The
databases (test/database.json) are started by the commands at the top of
test/run.sh. SQLite only needs a file.
build.sh asks meta what meta knows: where its runtime is
(meta -print-config), and which libraries each driver needs
(meta -print-flags over all of its sources, as pkg-config names). Those
come from meta's own headers, or from the source itself where it includes a
library's header directly: the yaml plugin says
#pragma meta needs "yaml-0.1" beside #include <yaml.h>. build.sh
itself knows none of it. This needs meta ed6f263 or newer
(meta --version).
meta is not open source. The metalanguage repository publishes it, binary
and headers, as the image wxone/meta, with everything under /opt/meta.
This repository only uses it. meta bakes that path into itself, so it has to
stay at /opt/meta:
docker create --name meta wxone/meta:f4e60f8
sudo docker cp meta:/opt/meta /opt/meta && docker rm meta
META_ROOT=/opt/meta ./build.sh.github/workflows/ci.yml does exactly this (.github/actions/meta; the
image is tagged with the meta commit this is tested with, META_IMAGE picks
another) and runs every suite on Ubuntu
24.04, each in its own job: run.sh once per database (started with
docker run, as at the top of test/run.sh), plus options.sh,
plugins.sh, static.sh, embed.sh, recovery.sh, adopt.sh,
scopes.sh, seeds.sh, dml.sh, releases.sh, work.sh and
compat.sh. The last one moves a project from node
db-migrate to this and back, against node-db-migrate and its pg driver as
published (NODE_DB_MIGRATE_REF, NODE_PG_REF, default master).
A tag v* also builds a release with tools/release.sh. The release is a
tarball that unpacks at / (tar -xzf <file> -C /) and contains:
- meta in
/opt/meta, as the image has it: compiler, runtime headers andlibmeta_runtime.a(1.5 MB). It has to be at that path, and overwrites an/opt/metathat is already there. The host only needs a C compiler on top. The launcher's drivers carry their client libraries inside them, except mysql, which needs libmysqlclient installed. - in
/opt/meta-db-migrate:- the launcher and
bin/meta-migrate-build-app - the libraries, drivers and plugins, and the headers
- the static libraries for
--staticinlib/deps - the license,
THIRD_PARTY_NOTICESand a CycloneDX SBOM inshare/doc/meta-db-migrate
- the launcher and
The SBOM is also attached to the GitHub release as a separate file. It lists what the release brings and that no scanner can find in a binary: meta and its runtime, yyjson, and the static libraries, each with version, license and origin.
The name of the tarball says which glibc it needs: built on Ubuntu 24.04,
it is glibc2.39. A release against an older glibc needs a meta that runs
there too: today's needs glibc 2.34, and is bound to the compiler it was
built with. Once meta is built on glibc 2.28 and asks the compiler at run
time, a second release follows.
MIT, see LICENSE. A release also carries third-party code, which
THIRD_PARTY_NOTICES lists. meta and its runtime are proprietary for now
and are meant to become open source; the SBOM says so in an annotation next
to the license that applies today. yyjson, libpq, OpenSSL, SQLite and libyaml
are permissively licensed.
- PostgreSQL, CockroachDB, MySQL, SQLite: createTable, dropTable, renameTable, add/remove/rename/changeColumn, indexes, foreign keys, insert, run/all/runSql, plus each database's own features (see table)
- up/down/sync with destinations, reset, check, create (code, SQL, v2, templates), scopes, db:create/db:drop, node's options, development launcher
- v2 migrations, node's state and lock,
fix, in node's format; adopt and irreversible drops - what node 1.1 to 1.8 added: scopes with their own database, rc files,
defaults for variables, v1 migrations without a transaction, seeds, data
migrations, background migrations and
work, releases and deprecations,status - plugins: YAML, ssh tunnel, config loaders, tunnels and templates of your own
- Missing: MongoDB