Bridge Watch uses Knex.js as its migration and query-builder layer on top of PostgreSQL 15 + TimescaleDB. This guide covers every aspect of working with the migration system — from daily development to production deployments.
- Overview
- Quick Reference
- Running Migrations
- Rolling Back
- Migration Status
- Dry Run Mode
- Creating Migration Files
- Validating Migrations
- Migration History
- Migration Locking
- Seed Data Management
- Environment Awareness
- Production Deployments
- CI/CD Integration
- Writing Good Migrations
- Troubleshooting
Each migration is a TypeScript file that exports two functions:
| Function | Purpose |
|---|---|
up(knex) |
Apply the schema change (forward migration) |
down(knex) |
Revert the schema change (rollback) |
Knex tracks which migrations have been applied in the knex_migrations table
and groups runs into numbered batches. Rolling back a batch undoes all
migrations that were applied together in that run.
Before any migration run Knex sets a lock row in the knex_migrations_lock
table. This prevents two processes from running migrations simultaneously (e.g.
two replicas racing at startup). The lock is released automatically when the
run completes. If a run crashes the lock may stay set; see
Migration Locking for how to recover.
Migration files use a timestamp prefix so new files always sort after existing ones without collisions:
YYYYMMDDHHmmss_<description>.ts
The npm run migrate:make command generates this prefix automatically.
# --- Migrations ---
npm run migrate # run all pending migrations
npm run migrate:up # same as above
npm run migrate:down # roll back the last batch
npm run migrate:rollback # same as migrate:down
npm run migrate:rollback:all # roll back EVERY migration
npm run migrate:status # show applied / pending table
npm run migrate:dry-run # preview without touching the DB
npm run migrate:make -- add_foo_table # generate a new migration file
npm run migrate:validate # validate all migration files
npm run migrate:history # show full history from DB
npm run migrate:unlock # release a stuck lock
# --- Seeds ---
npm run seed # run all seed files
npm run seed:specific -- 01_assets # run a single seed file
# --- Make targets ---
make migrate # run all pending migrations
make migrate-status # show status
make migrate-dry-run # dry run
make migrate-rollback # roll back last batch
make migrate-make NAME=add_foo_table # generate file
make migrate-validate # validate
make migrate-history # show history
make migrate-unlock # release lock
make seed # run all seeds
make seed-specific FILE=01_assets # run one seednpm run migrate
# or
npm run migrate:up
# or (inside dev Docker environment)
make migrateKnex applies every pending migration in alphabetical order and records them together in a single batch. The output shows which files were applied and the batch number assigned.
- Knex acquires the migration lock.
- It reads the
knex_migrationstable to find which files have already run. - It compares that list against all files in
src/database/migrations/. - It runs each pending file's
up()function inside a transaction (where possible — DDL statements in PostgreSQL are transactional). - Each applied file is recorded in
knex_migrations. - The lock is released.
npm run migrate:down
# or
npm run migrate:rollback
# or
make migrate-rollbackAll migrations that were applied in the most recent batch are reverted by
calling their down() functions in reverse order.
npm run migrate:rollback:all
# or
make migrate-rollback-allWarning: This drops all managed schema objects. Use only in development or testing — never in production without a full backup.
npm run migrate:status
# or
make migrate-statusPrints a formatted table showing every migration file, whether it has been applied, which batch it belongs to, and when it was applied:
Migration Status [env: development]
======================================================================================
Status Batch Name Applied At
--------------------------------------------------------------------------------------
✓ applied 1 20240101120000_initial_schema 2024-01-01T12:00:00.000Z
✓ applied 1 20240101120001_reserve_verification 2024-01-01T12:00:00.000Z
pending 20240601090000_add_governance_table -
======================================================================================
Applied: 2 Pending: 1
Preview which migrations would run without applying any changes:
npm run migrate:dry-run
# or
make migrate-dry-runUseful before deploying to staging or production to confirm the exact set of migrations that will be applied.
Generate a new, timestamped migration file with the correct boilerplate:
npm run migrate:make -- <name>
# Examples:
npm run migrate:make -- add_governance_table
npm run migrate:make -- alter_assets_add_decimals
npm run migrate:make -- drop_legacy_price_feed
# Via make:
make migrate-make NAME=add_governance_tableThe command creates a file like:
backend/src/database/migrations/20240601090000_add_governance_table.ts
With this template:
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// TODO: implement forward migration
}
export async function down(knex: Knex): Promise<void> {
// TODO: implement rollback (mirror of up)
}Fill in both functions before committing.
Check that every migration file exports the required up and down functions:
npm run migrate:validate
# or
make migrate-validateThe validator imports each file and reports any that:
- are missing an
upexport - are missing a
downexport (rollback will fail without it) - fail to import (syntax error, missing dependency, etc.)
This command is run automatically in CI before any migration step (see CI/CD Integration).
Query the full history of applied migrations stored in the database:
npm run migrate:history
# or
make migrate-historySample output:
Migration History:
--------------------------------------------------------------------------------------
ID Batch Name Applied At
--------------------------------------------------------------------------------------
1 1 001_initial_schema.ts 2024-01-01T12:00:00.000Z
2 1 002_reserve_verification.ts 2024-01-01T12:00:00.000Z
3 2 20240601090000_add_governance_table.ts 2024-06-01T09:00:00.000Z
--------------------------------------------------------------------------------------
Total: 3 migration(s) applied.
Knex uses a two-row knex_migrations_lock table to ensure only one migration
process runs at a time:
SELECT * FROM knex_migrations_lock;
-- index | is_locked
-- -------+-----------
-- 1 | 0 ← 0 = unlocked, 1 = lockedIf a migration process was killed mid-run the lock may remain set, causing all subsequent runs to fail with:
Migration table is already locked
To release it:
npm run migrate:unlock
# or
make migrate-unlockOnly run this if you are certain no other migration process is currently running. In production, verify this across all application replicas first.
Seeds populate the database with reference or test data. All seed files live in
src/database/seeds/.
npm run seed
# or
make seedSeed files use onConflict().ignore() so they are safe to re-run — duplicate
rows are silently skipped.
npm run seed:specific -- 01_assets_and_bridges
npm run seed:specific -- 01_assets_and_bridges.ts # extension optional
# Via make:
make seed-specific FILE=01_assets_and_bridgesSeed files run in alphabetical order. Use numeric prefixes to control sequencing:
01_assets_and_bridges.ts
02_circuit_breaker_configs.ts
03_asset_metadata.ts
Running seeds in production is blocked by default:
# This will exit with an error:
NODE_ENV=production npm run seed
# To override explicitly:
NODE_ENV=production npm run seed -- --forceThe migration system respects NODE_ENV:
NODE_ENV |
Behaviour |
|---|---|
development (default) |
Normal operation, no warnings |
test |
Normal operation, used in CI |
production |
Prints a prominent warning before running migrations; seeding is blocked unless --force is passed |
Connection credentials are read from environment variables (see .env.example):
POSTGRES_HOST
POSTGRES_PORT
POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD
For test environments set POSTGRES_DB=bridge_watch_test to isolate test data.
- Run
npm run migrate:validateto confirm all files are valid. - Run
npm run migrate:dry-runagainst a production clone to preview changes. - Verify you have a recent, tested database backup.
- Ensure no other migration process is running (check
knex_migrations_lock). - Review each pending migration's
down()function — rollback must be tested. - For large table changes (millions of rows), use
NOT VALIDconstraints and validate in a separate step to avoid long locks.
Additive changes (safe to deploy first):
- Adding a new nullable column
- Creating a new table
- Adding an index
CONCURRENTLY
Breaking changes (require coordination):
- Removing a column — deploy code that no longer references it before the migration
- Renaming a column — use a two-phase approach (add new column → backfill → remove old)
- Changing a column type — add a new column, backfill, swap at the application layer, then drop the old one
# 1. Check what is currently applied
npm run migrate:status
# 2. Roll back the last batch
NODE_ENV=production npm run migrate:rollback
# 3. Confirm the state
npm run migrate:statusIf an entire deployment needs to be undone, roll back multiple batches by
running migrate:rollback once per batch.
The CI pipeline (.github/workflows/ci.yml) runs the following migration steps
automatically on every push and pull request:
-
Validate migration files —
npm run migrate:validateEnsures no file is missingupordownexports before touching the database. -
Apply pending migrations —
npm run migrateRuns all pending migrations against the test database service. -
Verify migration status —
npm run migrate:statusConfirms the schema is fully up to date before tests run.
These steps run against a fresh timescale/timescaledb:latest-pg15 service
provisioned by GitHub Actions, so every CI run starts from a clean state.
To add migration checks to the deployment pipeline, add a dry-run step before applying changes:
- name: Preview migrations
run: npm run migrate:dry-run
env:
NODE_ENV: production
POSTGRES_HOST: ${{ secrets.PROD_DB_HOST }}
# ...
- name: Apply migrations
run: npm run migrate
env:
NODE_ENV: production
POSTGRES_HOST: ${{ secrets.PROD_DB_HOST }}
# ...Every migration must have a working down() function. A migration without
a rollback path blocks incident recovery.
// ✓ Good
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("governance_proposals");
}
// ✗ Bad — leaves team unable to roll back
export async function down(_knex: Knex): Promise<void> {
// not implemented
}One schema change per file. This makes rollbacks surgical and simplifies code review.
Use createTableIfNotExists, dropTableIfExists, and hasColumn checks to
make re-runs safe:
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable("governance_proposals"))) {
await knex.schema.createTable("governance_proposals", (t) => {
t.uuid("id").primary().defaultTo(knex.fn.uuid());
t.string("title").notNullable();
t.timestamps(true, true);
});
}
}Wrap data-only migrations (UPDATE / INSERT) in a transaction so they are atomic:
export async function up(knex: Knex): Promise<void> {
await knex.transaction(async (trx) => {
await trx("assets").where({ type: null }).update({ type: "credit_alphanum4" });
});
}Create the regular table first, then convert it:
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("my_timeseries", (t) => {
t.timestamp("recorded_at").notNullable();
t.string("symbol").notNullable();
t.decimal("value", 36, 18).notNullable();
});
// Convert to hypertable (time column must be the first argument)
await knex.raw(
"SELECT create_hypertable('my_timeseries', 'recorded_at', if_not_exists => TRUE)"
);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("my_timeseries");
}Once a migration has been applied in any shared environment (development, CI, staging, production), treat it as immutable. Create a new migration to make further changes.
Another process holds the lock, or a previous run crashed.
# Verify no migration process is running, then:
npm run migrate:unlockThe database has never been migrated. Run:
npm run migrateCheck the history to confirm which batch ran:
npm run migrate:historyThen roll back and re-apply:
npm run migrate:rollback
npm run migratenpm run migrate:validateThe command prints each failing file with the specific problem. Common causes:
- Forgot to export
downas a named export - TypeScript compilation error in the file
- Missing
import type { Knex } from "knex"at the top
Seeds use onConflict().ignore(), so rows that already exist are silently
skipped. If you need fresh data, either clear the table manually or add a
truncate step at the top of the seed file (development only).
The database must have the TimescaleDB extension enabled. Run:
make psql
# then inside psql:
CREATE EXTENSION IF NOT EXISTS timescaledb;Or re-run the init script:
psql -U bridge_watch bridge_watch < scripts/init-db.sql