Database and Migrations
Sparkfeed applies schema changes with Drizzle migrations. This page covers how that works, and what to do when it goes wrong.
How migrations run
Section titled “How migrations run”Migrations run before the server starts, not on boot from inside the app:
bun run db:migrate && node .output/server/index.mjsThat is the start command in railway.toml, and it is the shape to copy on any host. Two properties matter:
- It fails loudly. A failed migration exits non-zero, so the deploy stops instead of serving an app against a schema it does not match.
- It runs once per deploy, not once per replica, so multiple instances cannot race each other.
In demo mode db:migrate exits cleanly without doing anything, because demo builds its own SQLite schema at boot. The same start command works for both.
”relation already exists” on deploy
Section titled “”relation already exists” on deploy”The most common failure. The deploy log looks like this:
[migrate] Applying migrations from /app/drizzle[migrate] Migration failed: ...error: Failed query: CREATE TABLE "articles" (...)PostgresError: relation "articles" already exists code: "42P07"error: script "db:migrate" exited with code 1What it means: the tables exist, but the migration journal is empty, so Drizzle thinks nothing has been applied and tries to create tables that are already there.
Why it happens: the schema was originally created with drizzle-kit push. push diffs the schema and applies changes directly, and it writes nothing to drizzle.__drizzle_migrations. The migrator has no record that 0000 ever ran.
You have two ways out.
Option A: baseline (keeps your data)
Section titled “Option A: baseline (keeps your data)”Mark the already-applied migrations as done, without running their SQL:
# Dry run first. Shows exactly which migrations it would mark.bun run db:baseline
# Then write the rows.bun run db:baseline -- --applyBy default this baselines through 0000_small_sabretooth. Pass a different tag to baseline further:
bun run db:baseline 0001_last_tony_stark -- --applyDeploy again after baselining. Only migrations after the baselined tag will run.
Option B: reset (destroys your data)
Section titled “Option B: reset (destroys your data)”If the data is disposable, which is usually true before launch, a clean database is simpler and leaves no room for drift:
DROP SCHEMA public CASCADE;CREATE SCHEMA public;DROP SCHEMA IF EXISTS drizzle CASCADE;Then redeploy. Migrations run from 0000 against an empty database and record themselves properly.
Three things worth knowing about those statements:
CASCADEhandles the ordering. Sparkfeed has foreign keys betweenarticles,feedsandfolders, and between the auth tables anduser. Dropping tables one at a time means fighting that dependency graph.CREATE SCHEMA publicis not optional. Without it there is no schema to create tables in, and the next deploy fails differently.- The third line is the one people miss.
__drizzle_migrationslives in thedrizzleschema, notpublic, so droppingpublicalone leaves the empty journal behind and you get the same42P07again.
Resetting the database
Section titled “Resetting the database”-
Run the reset SQL
On Railway: open the Postgres service, go to Database → Data → Query, and run:
DROP SCHEMA public CASCADE;CREATE SCHEMA public;DROP SCHEMA IF EXISTS drizzle CASCADE;Anywhere else, the same statements through
psql:psql "$DATABASE_URL" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP SCHEMA IF EXISTS drizzle CASCADE;" -
Verify the state before deploying
SELECT(SELECT count(*) FROM information_schema.schemata WHERE schema_name='public') AS public_schema,(SELECT count(*) FROM information_schema.tables WHERE table_schema='public') AS leftover_tables,(SELECT count(*) FROM information_schema.schemata WHERE schema_name='drizzle') AS drizzle_schema;You want
1,0,0. Anything else and the next deploy will fail:Column Expected If it is wrong public_schema10means you dropped it without recreating. RunCREATE SCHEMA public;leftover_tables0Non-zero means the drop did not take, and you will hit 42P07againdrizzle_schema01means the stale journal is still there -
Redeploy
Watch for this line in the deploy log:
[migrate] Database is up to date.That means every migration applied and the journal is now populated.
-
Create the first account
After a reset the
usertable is empty, which opens registration for exactly one signup even whenALLOW_REGISTRATION=false. The first account becomes the workspace owner, and registration closes behind it. There is no need to toggle the flag.
Checking migration state
Section titled “Checking migration state”What the database thinks has been applied:
SELECT * FROM drizzle.__drizzle_migrations ORDER BY created_at;An empty result with tables present in public is the 42P07 situation above.
What the code expects, in order:
cat drizzle/meta/_journal.jsonAdding a migration
Section titled “Adding a migration”# 1. Edit src/db/schema.pg.ts# 2. Generate the SQLbun run db:generate# 3. Read the generated file in drizzle/ before committing it# 4. Apply locallybun run db:migrateSome things Drizzle cannot generate, such as GENERATED ALWAYS AS (...) STORED columns and certain index types, need a hand-written migration file. Add it to drizzle/ with the next number in sequence and register it in _journal.json.
Demo mode
Section titled “Demo mode”Demo mode ignores all of this. It runs a local SQLite file (rss-demo.db) whose schema is created at boot by ensureDemoSchema(), and it never touches Postgres. There is nothing to migrate and nothing to reset: delete the file and restart, and it rebuilds and reseeds itself.