Database migrations
TypeORM migrations manage the Postgres schema. The SQLite dev fallback does not use
migrations at all — it stays on synchronize, so entity changes reshape dev.sqlite
automatically.
| Mode | Schema management | synchronize |
|---|---|---|
Postgres (DATABASE_URL set) | Migrations, applied automatically on server start | off |
SQLite (no DATABASE_URL, or a sqlite: one) | Auto-DDL from entities, dev only | on |
All the relevant code lives in apps/server/src/db/:
typeorm-options.ts— the runtime TypeORM configuration used byAppModule;data-source.ts— a CLI-only DataSource for thetypeormcommands (requiresDATABASE_URL);migrations/— the shared migrations plusindex.ts, the explicit list of them;dataset-migrations/— the migrations of the dictionary tables, run in every dataset schema, with a list of their own (below);datasets.ts— runs both lists before the server's connection exists.
Commands
All commands run in the server workspace and need DATABASE_URL (they refuse to start
without it — migrations target Postgres only):
DATABASE_URL=postgres://user:pass@host:5432/db yarn workspace server migration:show # list applied/pending
DATABASE_URL=... yarn workspace server migration:run # apply pending
DATABASE_URL=... yarn workspace server migration:revert # roll back the last one
DATABASE_URL=... yarn workspace server migration:generate src/db/migrations/MyChange # diff entities vs DB
yarn workspace server migration:create src/db/migrations/MyDataFix # empty migration skeleton
DATABASE_URL=... yarn workspace server db:reset # DEV ONLY: wipe schema, re-run all migrations
Precaución
db:reset drops every table and type in the database and replays all migrations from
scratch — a factory reset for a broken or half-migrated development database. Never point
it at a database whose data you care about.
Consejo
DATABASE_URL may also come from the root .env — the CLI DataSource loads it the same way
the server does. A variable already set in the shell wins over the .env value.
Shared and dataset migrations
An instance holds several datasets, each in a Postgres schema of its own
(database.md). The dictionary tables exist once per
dataset, so their migrations run once per dataset:
| Shared | Dataset | |
|---|---|---|
| Folder | src/db/migrations/ | src/db/dataset-migrations/ |
| Tables | settings, datasets, audit_log; the enum types; extensions | en_entries, en_words, en_meanings, the translations, the links, en_changes, suggestions |
| Runs in | public, once | every registered schema — public for the default dataset, every ds_<name> |
| Journal | public.migrations | dataset_migrations of each schema |
| Names the schema | yes: "public"."settings" | never: "en_words" — the search_path of the connection decides where it lands |
| Runs | at server start, first | at server start, after the shared ones; and when a dataset is created |
Everything up to AddDatasets is history: those migrations built the dictionary tables in
public before datasets existed and stay in the shared list. DatasetBaseline builds the same
tables in a new schema and is recorded as applied in public by AddDatasets. From here on a
change to a dictionary table is a dataset migration; test:postgres compares the structure of
a new schema with public (columns, indexes, foreign keys) and fails when the two lists drift
apart.
The first dataset migrations after the baseline are the ones of the history of edits
(datasets.md): AddChanges creates
en_changes in every schema, empty; AddSuggestionAuthor adds the name a reader asked to be
credited by to suggestions; ChangesCarryValues makes the values of an edit mandatory and
removes the rows without them that the builds before it wrote. part_of_speech of en_changes is text, not the enum of en_words: an enum
type lives in public and is shared by every schema, a column of it would tie a dataset
migration to a shared one.
Sources and contribution licenses follow the same split. AddDatasetOrigins and
AddDatasetTermsUpdatedAt change the shared registry; AddWordOrigins, NormalizeWordOrigins
and AddChangeContribution run in every dictionary schema. They preserve known terms without
inventing earlier sources or edits. A new converter such as OpenGloss needs no separate schema
layout: installation creates the ordinary dictionary tables through the same migration list.
Reverting AddDatasets (migration:revert while it is the newest shared migration) takes the
registry away and leaves the journal of the dataset migrations of public with the rows of the
migrations that ran since: what they built in public is still there, and running the
migration again finds them applied.
Writing a dataset migration:
-
Edit the entity and generate the migration as usual — the generator diffs against
public:DATABASE_URL=... yarn workspace server migration:generate src/db/dataset-migrations/AddFrequencyRank -
Remove every
"public".in front of a table or an index in the generated SQL — the generator leaves the tables unqualified and writes the schema intoDROP INDEX "public"."IDX_…". A statement that namespublicwould change thedefaultdataset again and again and never the others. -
Enum types are shared. A new enum type or a new value of one goes into a shared migration (
CREATE TYPE "public"."…",ALTER TYPE "public"."…" ADD VALUE), which runs before the dataset migrations; the dataset migration only uses the type, by its qualified name. -
Register the class in
src/db/dataset-migrations/index.ts. -
Run
yarn workspace server test:postgres.
The migration:* commands work on the shared list and on public. The dataset migrations have
no command of their own: they are applied by the server at start (the log names every schema
and what ran there), and that is also the way to apply them by hand — start the server.
Precaución
db:reset drops public only. The schemas of the datasets stay behind without a registry
that knows them; drop them by hand (DROP SCHEMA ds_<name> CASCADE) before creating a dataset
of the same name.
Changing the schema: the workflow
-
Edit the entity (add a column, index, table, …).
-
Generate a migration against a Postgres database that has the current (pre-change) schema:
DATABASE_URL=... yarn workspace server migration:generate src/db/migrations/AddFrequencyRankTypeORM diffs the entities against that database and writes
src/db/migrations/<timestamp>-AddFrequencyRank.tswithup()/down(). -
Register the class in
src/db/migrations/index.ts— both the CLI and the running server read this explicit array (an explicit list resolves identically from ts-node and from the compileddist, unlike path globs). A generated migration that is not listed there will never run. -
Review the generated SQL, run
yarn format, and commit the migration file together with the entity change in the same PR.
Once a migration has run, add later schema changes in a new migration with a new name and timestamp. TypeORM records the migration's name, not a checksum of its SQL: editing an applied file will not update existing databases. Test both a fresh schema and an upgrade from the earlier schema; a passing fresh-database test alone does not cover that upgrade.
No local Postgres? Spin up a throwaway one, apply the already-committed migrations to bring it to the current schema, then generate:
docker run -d --rm --name vbh-pg -e POSTGRES_PASSWORD=pg -e POSTGRES_DB=vbh -p 55432:5432 postgres:17-alpine
export DATABASE_URL=postgres://postgres:pg@localhost:55432/vbh
yarn workspace server migration:run
# ...edit the entity...
yarn workspace server migration:generate src/db/migrations/MyChange
docker stop vbh-pg
Extensions
AddEntryWordTrigramIndex runs CREATE EXTENSION IF NOT EXISTS pg_trgm (trigram search).
pg_trgm ships with every Postgres distribution and is a trusted extension since Postgres 13, so the database owner can create it without superuser rights; managed
services (RDS, Cloud SQL, Supabase, Neon, …) allow it too.
Consejo
If the migration fails with a permission error, create the extension once as a superuser and
rerun migration:run.
Hand-written migrations
migration:generate only diffs the schema. Write the migration yourself (start from
migration:create) when you need:
- data backfills — e.g. adding a
NOT NULLcolumn: add it as nullable,UPDATEexisting rows, then setNOT NULL; - renames — the generator sees a rename as drop + create, which loses data; write
ALTER TABLE ... RENAME COLUMN ...manually; - any other data transformation.
Keep down() a real inverse of up() — migration:revert executes it.
How migrations run on deployment
On every start on Postgres, before its own connection is opened, the server
(prepareDatabase in src/db/datasets.ts):
- reads the
migrationstable to see what has already been applied; - executes every pending shared migration in order inside one transaction (TypeORM's
default
migrationsTransactionMode: "all") and records each; - does the same with the dataset migrations in every schema the registry names, a transaction per schema;
- opens its connection on the schema of the active dataset and only then lets Nest accept requests.
A failed migration rolls that transaction back — every migration of that start with it, so the
database stays where the previous version left it — and the server does not start: better a
service that is down than one running against a schema its code does not match. A start with no
pending migrations is effectively instant. The operator's side — what an upgrade does to the
database, backups, and why the rollback is the pre-upgrade backup rather than migration:revert
— is in database.md and
operations.md.
Adopting a pre-existing database
Databases created by the old synchronize mode already have all the tables, but no
migrations bookkeeping. Mark the baseline as applied without executing it, one time:
DATABASE_URL=... yarn workspace server migration:run --fake
After that, migration:show reports the baseline as applied and only future migrations will
actually execute.
Importante
Skipping this step would make the first migration:run (or server start) fail on
CREATE TABLE statements for tables that already exist.
Advertencia
--fake records every pending migration as applied, AddDatasets included — and the
registry of the datasets it would have created is then missing. The server says so at start
(The "datasets" table is missing although every shared migration is recorded as applied).
Take its row back and start the server, which runs the migration for real:
DELETE FROM "migrations" WHERE "name" = 'AddDatasets1789600000000';
Troubleshooting
DATABASE_URL must be set to run migration commands— the CLI DataSource refuses to run against SQLite. ExportDATABASE_URLor put it in the root.env.- Baseline fails with
type "..." already exists— the database holds orphaned enum types (e.g. tables were dropped manually but Postgres types survived). The baseline drops such orphans itself (DROP TYPE IF EXISTSbefore everyCREATE TYPE), so update to a version that includes it or rundb:resetto start from a clean schema. - Baseline fails with
cannot drop type ... because other objects depend on it— the database has a real schema created by the oldsynchronizebut no migrations bookkeeping; adopt it withmigration:run --fake(see above) instead of executing the baseline. migration:generateproduces a huge diff or wants to drop everything — the target database is not at the current schema. Runmigration:runfirst (or point at the right database), then generate.- A new migration never runs — check it is exported from
src/db/migrations/index.ts(orsrc/db/dataset-migrations/index.ts); the array is the single source of truth for both the CLI and the server. - A dataset migration changed
defaultand no other dataset, or fails in the second schema withalready exists— it namespublicin front of a table, or creates an enum type. See Shared and dataset migrations. The database connection is on schema "public", the active dataset lives in "ds_…"— a connection pooler dropped the startup option that carries thesearch_path(database.md).- Driver mismatch errors — entity column types are locked to a driver at import time
(
checkIsPostgres, see environment.md). The CLI DataSource loads.envbefore importing the entities for exactly this reason; always run migrations through the workspace scripts, not by invokingtypeormon ad-hoc files.