Database migrations
At a glance
- Migrations run automatically when
templates-apistarts, before it listens. - Each one is recorded in the
_migrationscollection byidand never runs twice. - Registration is manual: a migration that isn't in the
migrationsarray does nothing. - There is no rollback.
downexists on every migration and is never called.
MongoDB has no schema to migrate, but collections, indexes and seed data still have to exist before the API can serve a request. That is what these do.
How they run
server.ts awaits runMigrations() before app.listen, so the API never accepts traffic against an unmigrated database:
const startServer = async () => {
await connectToMongoDB();
await runMigrations();
app.listen(PORT, ...);
};The runner reads every _id already in the _migrations collection, then walks the migrations array in order and runs anything it hasn't seen. After each successful up it writes a record:
await migration.up(db);
await migrationsCollection.insertOne({
_id: migration.id,
appliedAt: new Date(),
});That record is what makes the run idempotent. The whole thing is memoised in a module-level promise, so concurrent callers in one process wait on the same run rather than racing. On failure the promise is cleared, so a later call retries rather than resolving against a half-finished run.
Writing one
A migration is three fields:
export type Migration = {
id: string;
up: (db: Db) => Promise<void>;
down: (db: Db) => Promise<void>;
};Convention is a file named NNN-what-it-does.ts exporting <name>Migration, with id matching the filename. 001-schemas-collection.ts is the smallest complete example — it creates a collection and its indexes.
Then register it. This is the step that's easy to miss:
// db/migrations/index.ts
import { myNewMigration } from './003-my-new-migration';
const migrations: Migration[] = [
schemasCollectionMigration,
seedDefaultSchemasMigration,
myNewMigration, // ← without this, the file is dead code
];Order is array order, not filename order. The numeric prefix is a reading aid; it has no effect on execution.
When a migration carries a large payload, keep the data in a sibling .data.ts and import it, as 002-seed-default-schemas.ts does with 002-default-schemas.data.ts. It keeps the logic readable.
What this system does not do
There is no rollback
Every migration implements down, and nothing ever calls it. The runner only applies up. Treat down as documentation of intent — a note about what undoing this would mean — not as a mechanism you can reach for.
A migration that has run will not run again, even if you change it. Only the id is recorded, not a hash of the contents. Editing a shipped migration changes nothing on any database that already applied it, including every developer's local one. To change something already applied, add a new migration.
A failure mid-migration is not undone. The _migrations record is only written after up resolves, so a migration that throws halfway leaves its partial work in place and will be retried from the start on the next boot. Write up so running it twice is safe — create indexes by name, upsert rather than insert, check before you create.
Related
Schema payloads are a different matter and have no migration path at all: deserializeSchema either parses a payload against the current shape or fails. See Schema serialization.