Naalya Handbook
Recipes

Add an Entity & Migration

Change the database schema the safe way — edit an entity, then generate, review, and run the migration yourself.

Changing the database schema on this backend is really two separate jobs, and keeping them separate is the whole point of this page. First you edit a TypeScript entity — the class that maps to a table. That part is just code, and it's easy. Then you turn that code change into a migration: a real SQL file, with an up() that applies the change and a down() that reverses it, that gets committed alongside your code and run against every database.

The reason it works this way is one setting: TypeORM's synchronize is false everywhere. TypeORM can auto-reshape your tables to match your entities on boot, but on this project that's switched off in DatabaseModule (libs/shared/src/database/database.module.ts). Nothing touches the schema until you generate a migration, read the SQL it produced, and run it. No silent drops, no surprise column renames in production — every schema change is a reviewed artifact in git.

Migrations are developer-driven — an agent never runs them

This is the rule that matters most. A coding agent (or any automation) edits the entity and then hands off. It must never run pnpm migration:generate or pnpm migration:run. Generating and applying migrations is a deliberate, human step — you review the SQL with your own eyes before it touches a database. If you're following along with an AI pair, expect it to stop after Step 1.


Step 1: Edit (or add) the entity

An entity is a class decorated with @Entity('table_name') that TypeORM maps to a database table — each property becomes a column. To add a field, you add a property. Every entity on this backend extends the shared DatabaseEntity base (which gives it a UUID id) and is decorated with @WithTimestamps() (which appends created_at / updated_at / deleted_at), so you don't write any of that boilerplate yourself.

Here we add a single nullable officePhone column to the staff-profile entity. Two conventions are doing quiet work: the DB column name is snake_case (office_phone, set explicitly via name:), and the field is nullable: true so it won't break the rows that already exist.

apps/api/src/app/staff-profile/staff-profile.entity.ts
@Entity('staff_profile')
@WithTimestamps()
export class StaffProfileEntity extends DatabaseEntity implements StaffProfile {
  @Column({ name: 'first_name' }) firstName: string;
  // ...existing columns...
  @Column({ name: 'office_phone', nullable: true }) officePhone?: string; // new
}

If you're adding a brand-new entity rather than a column, two things matter. The file must end in *.entity.ts — the migration CLI discovers entities by the glob apps/*/src/app/**/*.entity{.ts,.js}, so the suffix is what makes it visible. And its module must register it with TypeOrmModule.forFeature([...]) so the rest of the app can inject its repository.

You almost never need a 'deleted' flag

Every entity already has a deletedAt column via @WithTimestamps(), and the repository's softDelete* methods plus TypeORM's default filtering hide deleted rows from normal queries for free. Don't add an isDeleted boolean — soft delete is already built in. See The Database for how the base entity and repository fit together.

A schema field rarely lives alone. If the column should appear in API responses or request bodies, update the matching *.types.ts interface and any DTOs that expose it — that's covered in API Conventions.


Step 2: Generate the migration

Now the entity and the database disagree — the code wants an office_phone column the table doesn't have yet. The generator's job is to diff your entities against the database and write the SQL that closes the gap.

You run this yourself. With no name it auto-generates a friendly <adjective>_<noun>_<timestamp> filename; pass a name to make the file describe its purpose, which is what you usually want.

terminal
pnpm migration:generate                 # auto-named: swift_falcon_20260625…
pnpm migration:generate add-office-phone  # named — preferred

Under the hood that calls scripts/generate-migration.sh, which invokes the TypeORM CLI against typeorm.config.cli.ts and drops a new timestamped file into database/migrations/. That config is separate from the app's runtime config on purpose: it loads .env.local, scans the entity glob above, and reads or writes migration files — it's the only thing that ever generates schema SQL.

Generation reads your local database — so keep it current

The diff is computed against whatever database .env.local points at. If your local DB is behind (you skipped someone else's migrations), the generator can emit stray drops or renames trying to "fix" the difference. Run pnpm migration:run to catch up first, so the only diff left is your change.


Step 3: Review the generated SQL

This is the step the whole workflow exists to protect, so slow down and actually read the file. A generated migration is plain SQL wrapped in up() and down() — for our one-column change it's small and easy to verify by eye.

database/migrations/1776600000000-add-office-phone.ts
export class AddOfficePhone1776600000000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "staff_profile" ADD "office_phone" character varying`,
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "staff_profile" DROP COLUMN "office_phone"`,
    );
  }
}

Read it against a short checklist before you trust it:

  • Only your change is there — no drops, renames, or constraint churn you didn't ask for. Anything extra is the "stale local DB" smell from Step 2.
  • down() cleanly reverses up() — here, drop the column it added. This is your undo button.
  • Defaults and nullability are right for existing rows — a NOT NULL column with no default will fail on a table that already has data.

A migration is code — edit it if you need to

The generator gives you a draft, not gospel. If you need a backfill, a sensible default, or a data fix-up, hand-edit the up() / down() before running it. Just make sure down() still reverses whatever up() ends up doing.


Step 4: Run it locally

With the SQL reviewed, apply it to your local database. If something looks wrong after, revert rolls back the most recent migration using that down() you just checked.

terminal
pnpm migration:run       # apply pending migrations locally
pnpm migration:revert    # undo the last one (local)

Re-run the same change a few times if you're iterating: revert, tweak the entity, delete the old generated file, regenerate, run. Locally it's cheap and reversible — that's the time to get it right.

Staging and prod migrate themselves on merge

You only run migrations against your local database. Merging to staging (or main) triggers a CI workflow that reads that environment's DATABASE_URL and runs the migration for you, and the deploy is held until it succeeds — so code never goes live against an un-migrated database. The migration:run:staging / migration:run:prod scripts exist only for break-glass manual use; reach for them deliberately, not by habit.


Step 5: Commit the entity and migration together

The entity change and its migration file are one logical change — commit them in the same commit so they're never separated. A git add -A picks up both the edited *.entity.ts and the new file under database/migrations/.

terminal
git add -A
git commit -m "feat(staff): add office_phone to staff profile"

Two things round it out. CI runs migration checks on the PR, so the generated file must be in the diff or those checks fail. And the PR template has a Migration / Breaking Changes section — note the schema change there so reviewers know a table is moving.


The flow

Five steps, and only the middle three are yours to run by hand:

StepCommandWho runs it
Edit the entity(code change)you or an agent
Generatepnpm migration:generate <name>you
Review the SQL(read the file)you
Apply locallypnpm migration:runyou
Commitgit add -A + commityou or an agent

Where to go next

On this page