Skip to main content

Migrations & Seeds

Every time you need to change the database schema — add a table, add a column, modify a constraint — you write a migration. Every time you need consistent starting data (default roles, plans, admin users), you write a seed. Both use the same TypeScript file format and are run via Makefile commands inside Docker.

The file format

Both migrations and seeds export two functions: up (apply the change) and down (reverse it). They receive Sequelize’s queryInterface via the context object:
// apps/backend/src/tools/rds/sequelize/migrations/20250303045013-createUserTable.ts
import { Sequelize, DataTypes } from 'sequelize';

const TABLE_NAME = 'users';

export const up = async ({ context: { queryInterface } }) => {
  await queryInterface.createTable(TABLE_NAME, {
    id: {
      type: DataTypes.UUID,
      defaultValue: Sequelize.literal('uuid_generate_v4()'),
      primaryKey: true,
      allowNull: false,
    },
    email: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    created_at: {
      type: DataTypes.DATE,
      allowNull: false,
      defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
    },
    updated_at: {
      type: DataTypes.DATE,
      allowNull: false,
      defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
    },
  });
};

export const down = async ({ context: { queryInterface } }) => {
  await queryInterface.dropTable(TABLE_NAME);
};
A few conventions to follow:
  • Column names use snake_case — that’s what Sequelize maps to camelCase in models.
  • Always include created_at and updated_at on new tables.
  • Always write a down function, even if it just drops the table. You’ll thank yourself when you need to roll back.

Make commands

# Apply all pending migrations
make migrate-up

# Roll back the last migration
make migrate-down

# Apply all pending seeds
make seed-up

# Roll back seeds
make seed-down

# Generate a new migration file with the right timestamp prefix
make create-migration name=addStripeCustomerIdToOrganizations

# Generate a new seed file
make create-seed name=createAdminUser
These all run inside the Docker container — Sequelize needs a live Postgres connection to work, and the container has it.

Adding a new table (step by step)

  1. Generate the migration file
    make create-migration name=createWidgets
    
    This creates a timestamped file in apps/backend/src/tools/rds/sequelize/migrations/. Open it and fill in the up and down functions.
  2. Write the migration Use queryInterface.createTable in up and queryInterface.dropTable in down:
    export const up = async ({ context: { queryInterface } }) => {
      await queryInterface.createTable('widgets', {
        id: {
          type: DataTypes.UUID,
          defaultValue: Sequelize.literal('uuid_generate_v4()'),
          primaryKey: true,
          allowNull: false,
        },
        name: { type: DataTypes.STRING, allowNull: false },
        organization_id: {
          type: DataTypes.UUID,
          allowNull: false,
          references: { model: 'organizations', key: 'id' },
          onDelete: 'CASCADE',
        },
        created_at: {
          type: DataTypes.DATE,
          allowNull: false,
          defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
        },
        updated_at: {
          type: DataTypes.DATE,
          allowNull: false,
          defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
        },
      });
    };
    
    export const down = async ({ context: { queryInterface } }) => {
      await queryInterface.dropTable('widgets');
    };
    
  3. Run the migration
    make migrate-up
    
  4. Create a Sequelize model Add a new file in apps/backend/src/tools/rds/sequelize/models/widget.ts following the pattern of existing models.
  5. Register the model Add it to the models index in apps/backend/src/tools/rds/sequelize/models/index.ts.
  6. Verify
    make test module=backend
    
    The globalSetup in Jest runs migrate-up automatically before tests, so your new table will be present in the test database.

Adding a column to an existing table

For schema changes to existing tables, use addColumn / removeColumn:
export const up = async ({ context: { queryInterface } }) => {
  await queryInterface.addColumn('organizations', 'stripe_customer_id', {
    type: DataTypes.STRING,
    allowNull: true,
  });
};

export const down = async ({ context: { queryInterface } }) => {
  await queryInterface.removeColumn('organizations', 'stripe_customer_id');
};
⚠️ Watch out: if you’re adding a NOT NULL column to a table that already has rows, you need to either provide a defaultValue or do it in two steps: add the column as nullable, backfill data, then add a NOT NULL constraint. Doing it in one step on a table with existing data will fail.

Seeds

Seeds are for data that should always exist — default roles, product plans, an initial admin user. They follow the same up / down format:
// apps/backend/src/tools/rds/sequelize/seeds/20250503045559-createDefaultProducts.ts
export const up = async ({ context: { queryInterface } }) => {
  await queryInterface.bulkInsert('products', [
    { id: uuid(), name: 'Starter', type: 'plan', external_id: 'price_starter', ... },
    { id: uuid(), name: 'Pro', type: 'plan', external_id: 'price_pro', ... },
  ]);
};

export const down = async ({ context: { queryInterface } }) => {
  await queryInterface.bulkDelete('products', { type: 'plan' });
};
Seeds run in timestamp order, just like migrations. make fresh-start runs both migrate-up and seed-up in sequence.

What’s next?

  • Sequelize tooling — how the Sequelize model layer is set up.
  • Monorepo — workspace structure and how packages reference each other.
  • Testing — how Jest’s globalSetup automatically applies migrations before tests.