Migrations — forge push, forge diff, forge rollback, drift, and CI
Companion to the README's Creating tables and migrations chapter. The README is the one-screen tour: the CLI names, the schema-resolution cascade, the headline flags. This file is the deep reference: every command's exact behaviour, what counts as drift, what gets emitted per dialect, how to wire it into CI, and where the runtime browser path (db.$migrate(), since 2.5.1) takes over.
The browser-side equivalents are covered in BROWSER.md. Don't read this file looking for browser drift-apply — that's the runtime path, and its semantics are different from the CLI.
Contents
- The migration model — why there are no migration files
forge pushforge diffforge diff applyforge rollbackforge doctor- Drift detection rules — what counts, what doesn't
--ignorepatterns- Per-dialect emit table
- CI workflows
- Blue/green schema rollouts
- Migration in monorepos
- Data migration vs DDL migration
- Per-dialect rollback fidelity
- Five worked workflows
- Runtime
$migrate()+$diff()— when to use which forge migrate status
The migration model — why there are no migration files
forge is a declare-and-push ORM. You write models in TypeScript, and forge push reads the schema, introspects the live database, and emits the DDL needed to bring the database forward. There is no migrations/ folder you hand-edit, no prisma migrate dev step, no down-files you wire up by hand. The artefact of "schema at commit X" is the schema file at commit X — nothing else.
The trade-off is symmetric:
- What you get — round-trip time on a schema change is seconds, not minutes. Add a field, save, re-run
npx forge push, the column is there. No file naming conventions, no merge conflicts on20260601_add_users.sql, no "I forgot to runmigrate dev" rebases. New developers clone, setDATABASE_URL, runforge push, and have a populated schema. - What you give up — there is no out-of-band record of "the schema at this point in time, separate from the code". If you
git reverta feature branch that added three columns, the columns stay in the database until you runforge diff apply(which generates the destructive DDL) or drop them manually. You can't pin a schema state to a build artefact the way a Prisma migrations folder pins it.
The reconciliation path — forge diff apply + _forge_migrations ledger — gives you the migration-file shape when you need it. It's not the default flow because, in practice, additive changes (the 90% case) don't need it.
When push-style is the right call
- Single-team service with one database, additive changes most of the time.
- Mongo-backed services — index management is idempotent on the server side; there is no DDL to version.
- Browser / mobile / desktop SQLite (Tauri, Capacitor, sqlite-wasm) — the database lives on the user's device, ships fresh on first run, and gets reconciled at app boot.
db.$migrate()is the runtime equivalent offorge pushfor this case; see BROWSER.md. - Read-replica fan-out or DB-per-tenant setups where the same schema needs to land on N URLs — a
forloop overforge pushis the entire deploy script.
When a migration-file ORM is the right call
- You need to ship a complex multi-step rollout (add column, backfill, switch read source, drop old) where each step lands in a separate deploy and needs to be reviewed at a specific Git SHA.
- Compliance requires a signed audit trail of "the schema applied to prod on date X" — a migrations folder under version control gives you that artefact directly.
- A team larger than 4-5 backend engineers committing to the same schema, with code review on the SQL itself, not the schema declaration.
- You need transformational data migrations baked into the same atomic unit as the DDL — a migration file with a
BEGIN; ALTER TABLE …; UPDATE …; COMMIT;body is the natural shape.
In those cases, reach for Prisma Migrate, Drizzle Kit migrate, or a hand-rolled SQL migrations folder. forge's compile.<op>() API still gives you typed query objects you can paste into a SQL migration; the two models compose.
forge push
npx forge push # idempotent sync, schema → live DB
npx forge push --enable-extensions # additionally emit CREATE EXTENSION when needed
npx forge push --schema=./src/schema.ts # explicit schema path (overrides cascade)DATABASE_URL is read from .env or the environment. There is no --url flag — point a different DATABASE_URL at the binary if you need to target another database (DATABASE_URL=postgres://staging:… npx forge push). The schema-resolution cascade is documented in the README; this section is about what push does after the schema is loaded.
What forge push does, in order
- Detect adapter from the URL prefix (
postgres:,mysql:,sqlite:,duckdb:,mssql:,mongodb:). - Lock, per dialect:
- Postgres —
pg_advisory_xact_lock(0x6f6f7267, 0x65000001)insideBEGINso two concurrent pushes serialise instead of racing on DDL. - MySQL —
SELECT GET_LOCK('forge_migrate', 60)(DDL implicitly commits open txns on MySQL, so the lock is the only protection). - SQLite — single-writer connection; no extra lock needed.
- Mongo — no lock;
createIndexis idempotent on the server.
- Postgres —
- Plan — introspect existing tables / constraints / indexes, diff against the schema's generated DDL, produce
{ toApply, toSkip, summary }. A statement is "to skip" if the database already has an object with that name and shape. - Apply, statement by statement:
- Postgres — each statement runs inside a
SAVEPOINTso one failure rolls back that statement without aborting the whole batch. - MySQL — no savepoint for DDL; mid-batch failure leaves prior successes applied (same semantic as
prisma db push). - SQLite — straight execute.
- Mongo —
createIndexper index spec; on Mongo error 85 / 86 (spec drift), drop and recreate.
- Postgres — each statement runs inside a
- Report —
applied: N, skipped: M, failures: [...]to stdout. Exit code 0 on a clean run; 2 if any statement failed.
push is additive-only by design
forge push will:
- Create tables, columns, indexes, constraints, foreign keys, views, FTS shadow tables and their triggers.
- Drop and recreate Mongo indexes when their spec drifted (error 85 / 86).
- Auto-install extensions when
--enable-extensionsis set.
forge push will not:
- Drop columns the schema removed.
- Change a column's type (
ALTER TABLE … ALTER COLUMN …). - Drop tables the schema removed.
- Rename columns or tables (renames look like drop + add to the differ).
This is intentional. push is meant to be safe to run on production at app start. The destructive operations live in forge diff apply, where you see them in the preview before they run.
Flags
| Flag | Effect |
|---|---|
--enable-extensions | Before the DDL apply, emit CREATE EXTENSION IF NOT EXISTS postgis; (or the dialect equivalent) for whatever the schema declares — f.geoPoint(), f.vector(), .searchable(). Without this flag the push proceeds; it'll fail with a clear DB error if the extension's missing. With it, the role must have CREATE EXTENSION permission. |
--schema=<path> | Override the schema-resolution cascade. Useful in monorepos when the cwd isn't the package containing the schema. FORGE_SCHEMA_PATH=<path> env var works the same way. |
push deliberately does not ship --dry-run, --verbose, --accept-data-loss, --ignore, or --exclude. The patterns for what those flags would do are covered elsewhere in this doc — dry-run is forge diff (read-only), data-loss is the explicit forge diff apply workflow, ignoring is on forge diff, and exclude in monorepos is the partial-schema pattern.
Per-dialect quirks
- Postgres —
forge pushis transactional. If you're running it as part of a deploy hook and want a hard guarantee that "either every statement applies or none do", check thefailures.length === 0exit condition; otherwise some statements stuck. Thepg_advisory_xact_lockmeans two concurrent pushes (e.g. blue + green pods restarting at the same time) serialise correctly. - MySQL — DDL implicitly commits. There is no all-or-nothing semantic. Split DDL-heavy migrations into smaller pushes if you need that property.
- SQLite — single-writer; if another connection holds a write transaction the push will hang on
SQLITE_BUSY. For a deploy-time push, setPRAGMA busy_timeout = 60000. - DuckDB — DDL is transactional. The spatial and vss extensions auto-load on connect;
--enable-extensionsis a no-op for DuckDB. - MSSQL —
GEOGRAPHYandVECTOR(N)are built-in; no extension to install. - Mongo — there's no DDL;
forge pushdoes idempotentcreateIndexper declared index. Re-running against an in-sync DB does ~N round trips (one per collection). A spec change (e.g. you flipped a partial filter expression) triggers a drop-and-recreate; data that violates a newly-tightened unique constraint is logged, not crashed.
forge diff
npx forge diff # human-readable report
npx forge diff --json # machine-readable
npx forge diff --check # exit 3 if drift found (CI gate)
npx forge diff --ignore=logs,/^_atlas_/i # skip noisy meta-tablesdiff reads the live database (via the same introspect path the adapter uses for compile-shape queries), compares it to the schema's expected shape, and prints what's different. It never writes.
Output shape (--json)
{
"dialect": "postgres",
"items": [
{ "kind": "column", "direction": "missing", "table": "users",
"detail": "column 'email_verified_at'" },
{ "kind": "index", "direction": "extra",
"table": "users", "detail": "unique index u:legacy_email in DB but not in schema" }
],
"inSync": false,
"ignored": ["logs", "_atlas_meta"]
}dialect—postgres/mysql/sqlite/duckdb/mssql/mongo.items[].kind— one oftable/column/index/foreignKey/view/columnType.items[].direction—missing(in schema, not in DB),extra(in DB, not in schema),mismatch(shape drift, e.g. wrong index method).inSync—trueonly whenitemsis empty.ignored— tables that matched an--ignorepattern; surfaced so silent filtering can't hide real drift.
--check exit codes
| Exit | Meaning |
|---|---|
| 0 | No drift. inSync: true. |
| 3 | Drift detected. Only emitted when --check is set. |
| 1 | Something went wrong before the diff ran (no DATABASE_URL, unreachable DB, schema didn't load). |
The 3-vs-1 split lets a CI job distinguish "actual drift" from "couldn't even reach the DB" — pin a job's failure semantics on exit_code == 3 to catch the former without false-positive on the latter.
CI gate snippet
# .github/workflows/schema-drift.yml
name: Schema drift gate
on: [pull_request]
jobs:
diff:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env: { POSTGRES_PASSWORD: postgres }
ports: ['5432:5432']
options: --health-cmd pg_isready
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Seed staging schema from main
run: |
git checkout origin/main -- src/schema.ts
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres \
npx forge push
- name: Restore PR schema
run: git checkout HEAD -- src/schema.ts
- name: Diff against main-shaped DB
run: |
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres \
npx forge diff --check --json | tee diff.jsonA non-zero exit (specifically 3) on the final step means the PR adds drift that forge push would resolve — the reviewer sees diff.json to know what.
forge diff apply
npx forge diff apply # generate reconciliation migration + apply
npx forge diff apply --dry # print the SQL only; don't apply, don't write the filediff apply is the read-write sibling of forge diff. It generates a SQL migration file with up (bring DB forward) and down (inverse) sections, applies it, and records the row in _forge_migrations. Files land in ./migrations/<timestamp>_drift.sql.
This is the path you take when you'd rather review the SQL before running it, or when you need a record of "the change applied on this date" outside the schema file.
The safe slice
For every drift item, diff apply decides what to emit:
| Drift item | Safe slice (always emitted) | Destructive (emitted, but visible in preview) |
|---|---|---|
| Missing table | DEFER to forge push | — |
| Missing column | ALTER TABLE … ADD COLUMN (nullable, or with default) | — |
| Missing index | CREATE INDEX with full 2.2+ shape (method, where, include, expression) | — |
| Missing FK | ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY | — |
| Extra column | — | ALTER TABLE … DROP COLUMN |
| Extra table | — | DROP TABLE IF EXISTS |
| Column type mismatch | — (not yet generated) | — |
| Index method mismatch on an existing index | — (drop & recreate via forge push) | — |
There is no --accept-data-loss gate. diff apply shows you the change list before running it:
[forge:diff:apply] 3 change(s):
• add users.email_verified_at
• drop sessions.legacy_token
• drop table audit_oldIf anything in that list isn't what you want, Ctrl-C and use --dry instead — --dry prints the full SQL without touching the DB or writing the file.
What ends up in migrations/
-- forge migration: 20260624T143052_drift
-- generated: 2026-06-24T14:30:52.413Z
-- up
ALTER TABLE "users" ADD COLUMN "email_verified_at" TIMESTAMPTZ;
DROP TABLE IF EXISTS "audit_old";
-- down
DROP TABLE IF EXISTS "audit_old"; -- cannot auto-restore dropped table
ALTER TABLE "users" DROP COLUMN "email_verified_at";Notice the down for "extra table" is a comment, not real SQL. forge diff apply never invents a recreate plan for a dropped table because the data is gone — the down side is best-effort, not lossless. The Per-dialect rollback fidelity section spells out exactly what's recoverable.
Per-dialect ALTER repertoire
| Operation | Postgres | MySQL | SQLite |
|---|---|---|---|
| Add column (nullable) | ALTER TABLE … ADD COLUMN | ALTER TABLE … ADD COLUMN | ALTER TABLE … ADD COLUMN |
| Add column (NOT NULL + default) | ALTER TABLE … ADD COLUMN … DEFAULT … | ALTER TABLE … ADD COLUMN … DEFAULT … | ALTER TABLE … ADD COLUMN … DEFAULT … |
| Add unique index | CREATE UNIQUE INDEX | CREATE UNIQUE INDEX | CREATE UNIQUE INDEX IF NOT EXISTS |
| Add partial index | CREATE INDEX … WHERE … | translated to expression-index form | CREATE INDEX … WHERE … |
| Add covering index | CREATE INDEX … INCLUDE (…) | — (MySQL has no INCLUDE) | — |
| Add expression index | CREATE INDEX … ((expr)) | CREATE INDEX … ((expr)) | CREATE INDEX … (expr) |
| Add FTS shadow | (forge push only) | (forge push only) | (forge push only) |
| Add FK | ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY | ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY | comment-only ("SQLite cannot ALTER ADD FK") |
| Drop column | ALTER TABLE … DROP COLUMN | ALTER TABLE … DROP COLUMN | ALTER TABLE … DROP COLUMN (SQLite 3.35+) |
| Drop table | DROP TABLE IF EXISTS | DROP TABLE IF EXISTS | DROP TABLE IF EXISTS |
| Rename column | not emitted | not emitted | not emitted |
| Rename table | not emitted | not emitted | not emitted |
FTS shadow tables and their triggers come from forge push, not diff apply, because they're created together with the parent table's searchable() columns rather than as a separate diff item.
Renames are not detected. A schema change that renames email → email_address looks like "drop email, add email_address" to the differ. If you need a rename to preserve data, the pattern is the two-deploy plan in Worked workflow (d): add new, copy data via db.$executeRaw, switch reads, drop old.
forge rollback
npx forge rollback # roll back the most-recent applied migrationrollback reads _forge_migrations, picks the most-recently-applied row, reads the matching migrations/<name>.sql file, runs its -- down block, and deletes the row from the ledger.
What's recoverable
Operation in up | down does | Lossless? |
|---|---|---|
| ADD COLUMN (no data yet) | DROP COLUMN | Yes |
| ADD COLUMN (data backfilled separately) | DROP COLUMN | No — backfilled data is dropped with the column |
| CREATE INDEX | DROP INDEX | Yes |
| ADD CONSTRAINT | DROP CONSTRAINT | Yes |
| ADD FK | DROP FK | Yes |
| DROP COLUMN | ADD COLUMN with the type the introspect saw at apply time | No — column shape preserved, data is gone |
| DROP TABLE | comment ("cannot auto-restore") | No — best-effort note only |
The pattern: rollback is reliable for "I just added a thing and want to take it back". It is not a time machine for destructive operations. If you dropped a column on Monday and want it back on Wednesday, the rollback emits the right ALTER but the data was deleted on Monday — restore from a backup, not a rollback.
The _forge_migrations ledger
A two-column table forge creates the first time diff apply or rollback runs:
CREATE TABLE _forge_migrations (
name VARCHAR(255) PRIMARY KEY,
applied_at VARCHAR(64)
);- Portable across Postgres, MySQL, SQLite, DuckDB, MSSQL.
nameis the timestamped slug (20260624T143052_drift).applied_atis an ISO-8601 string.
This table is auto-ignored by forge diff so it never shows up as drift. You can read it directly with db.$queryRaw if you need to expose "what schema state are we on" to ops dashboards.
rollback only goes back one
There is no forge rollback --to <slug>. The CLI applies exactly the most-recent down block. If you need to walk back N migrations, run forge rollback N times.
forge doctor
npx forge doctordoctor is the pre-flight check. It does four things in order:
- Driver inventory — for every adapter, prints whether its npm driver is installed (
pg,mysql2,better-sqlite3,@duckdb/node-api,mssql,mongodb) and the installed version. DATABASE_URLshape check — redacts the password, detects the adapter from the prefix, and confirms the matching driver is present. If the URL prefix is unknown, gives the user the workaround (passtypeexplicitly tocreateDb).- Schema lint — if a schema is reachable via the cascade, walks every model's indexes and flags impossible combinations: Mongo-only fields (
collation,wildcardProjection,'2dsphere'keys) on a SQL adapter; SQL-only fields (method,include,expression) on Mongo; method mismatches likemethod: 'spatial'on Postgres;parserset on a non-FULLTEXT index; unnamed indexes that'll get a generated name. - Live capability probe — connects to the database (best-effort; failures are reported, not raised) and reads:
- Postgres —
server_version, presence of PostGIS, pg_trgm, btree_gin, btree_gist. - MySQL —
SELECT VERSION(), warns on < 8.0 (no SRID-aware spatial). - SQLite — version, attempts
load_extension('mod_spatialite'). - DuckDB —
INSTALL spatial; LOAD spatial; SELECT extension_name FROM duckdb_extensions(). - MSSQL —
SELECT @@VERSION, notesGEOGRAPHYis built-in. - Mongo —
buildInfo, notes2dsphereis built-in.
- Postgres —
The output ends with a copy-pasteable Action items section: which drivers to npm install, which extensions to CREATE EXTENSION, which database upgrades to plan.
The runtime equivalent for the browser is browserDoctor().
When to run doctor
- Once after
npm install forge-ormto confirm your driver is in. - Before the first
forge pushon a new environment to know if extensions need installing. - When a query fails with a "function doesn't exist" or "unknown index method" error to confirm the live DB has the capability the schema expects.
- In CI, as a pre-step before
forge diff --check. Saves you debugging "why does the diff job fail" — the doctor output points at the missing extension.
Drift detection rules — what counts, what doesn't
The differ is in src/scripts/diff-core.ts and is dialect-aware. The rules below match what it actually does — not what it could do.
What forge considers drift
| Kind | Direction | Detection |
|---|---|---|
table | missing | Model declared in schema but no table with that collection name in DB. |
table | extra | Table in DB whose name isn't any model's collection or matview backing. |
column | missing | Field in schema but no column with that name on the matching table. |
column | extra | Column in DB but no field with that name on the matching model. |
columnType | mismatch | Field's category (string / int / bigint / float / decimal / bool / datetime / json) differs from the DB column's category. Skipped on SQLite (dynamic typing) and Mongo (schemaless). |
index | missing | Index column-set + uniqueness signature in the schema but not in the DB. |
index | extra | Unique-only — extra non-unique indexes don't drift (engines create their own). Extra unique indexes do. |
index | mismatch | Named index in both, but method, where, include, expression, partialFilterExpression, collation, wildcardProjection, or per-key direction tokens differ. |
foreignKey | missing | Relation declared in schema with a non-id on column, but no FK with matching column → refTable.refColumn in DB. |
view | missing | model('…', { … }, { view: { … } }) in schema but neither a view nor a table with that name in DB. |
What forge does NOT consider drift
- Column ordering — schemas declare order in the IR for DDL emit, but introspect just reads back columns. Reordering a model doesn't generate drift.
- Default expressions that normalise differently per dialect — Postgres echoes
gen_random_uuid()back differently from how it was declared, MySQL storesCURRENT_TIMESTAMPin a normalised form. The differ doesn't compare default expressions. _forge_migrations— the migration ledger is always filtered.*_ftsshadow tables — engine-managed by the FTS5 / GIN tsvector / Mongotextmachinery. Always filtered.- Matview backing tables — a PG materialised view that's surfaced as a table is matched against the schema view list before being flagged extra.
- Non-unique extra indexes — DBs add their own (constraint-backing, auto-stat). Only extra unique indexes drift.
- Type comparison on SQLite + Mongo — SQLite types are dynamic; Mongo has no DDL. Column-existence and index drift still apply.
Category-based type comparison
The differ doesn't compare types as strings — it maps both sides to coarse categories and compares those. This is deliberate: PG text vs varchar(255) shouldn't drift, MySQL tinyint(1) vs bool shouldn't drift, MSSQL nvarchar(MAX) vs text shouldn't drift.
| Schema kind | Category | Matches DB types (normalised) |
|---|---|---|
id, objectId, string, text, uuid, enum | string | text, varchar*, char*, uuid, citext |
int | int | smallint, integer, int, int4, int2, mediumint |
bigint | bigint | bigint, int8 |
float | float | real, double*, float* |
decimal | decimal | numeric*, decimal* |
bool | bool | bool*, tinyint(1) |
dateTime | datetime | timestamp*, datetime*, date, time* |
json, embed, embedMany | json | json, jsonb |
| arrays, custom types | — (skipped) | — |
A category-vs-category mismatch fires; anything undefined-categorised is skipped. This biases toward "don't cry wolf" — false negatives (rare type drift the differ misses) are easier to live with than false positives that erode trust in the report.
Deep-field index drift (2.2+)
Forge tracks the full 2.2+ IndexDef surface. When an index is declared with an explicit name and the DB has an index with that name, the differ deep-compares:
method—btree/gin/gist/brin/hashon PG;spatial/fulltexton MySQL.where— partial index predicate, normalised (whitespace-collapsed, lowercased, paren-trimmed) before compare to tolerate PG's echo-back form.include— covering columns (PG).expression— expression index body, normalised the same way aswhere.partialFilterExpression— Mongo's partial-filter, compared via stable-key JSON to tolerate key-order differences.collation— Mongo only, projected to user-declared keys before compare so the DB's echoed defaults don't false-positive.wildcardProjection— Mongo only, stable-JSON compared.keySpec— Mongo only, per-key direction tokens (1/-1/'text'/'2dsphere'/'2d'/'hashed').
Each mismatch becomes its own drift entry scoped by index name, so the report shows you exactly which property drifted instead of just "something changed":
≠ [index] users: index 'idx_email_active' method: schema=gin db=btree
≠ [index] users: index 'idx_email_active' where: schema=active = true db=∅Unnamed indexes don't participate in the deep pass — the column-set signature catches "missing entirely", but the differ won't try to align a generated name across runs.
--ignore patterns
npx forge diff --ignore=logs,sessions,/^_atlas_/i,events
export FORGE_DIFF_IGNORE='/^_/i,external_events'
npx forge diff # CLI flag and env var stackTwo forms:
- Exact-match string —
logsmatches the tablelogsand nothing else. /regex/flags— anything wrapped in slashes is parsed as a regex with the given flags./^_atlas_/imatches any table starting with_atlas_case-insensitively.
Items are comma-separated. The CLI flag and FORGE_DIFF_IGNORE env var both feed the same list — the CLI flag stacks on top of the env var (additive), so a fleet-wide default can be extended for a single run without overwriting it.
Reading parseIgnoreList
import { parseIgnoreList } from 'forge-orm/scripts/diff-core';
parseIgnoreList('logs,/^_atlas_/i,events');
// → ['logs', /^_atlas_/i, 'events']You can import this directly when building tooling around the differ — e.g. a custom CI step that calls diffIntrospection programmatically.
Always-ignored
Regardless of flags:
_forge_migrations— the ledger.- Any name matching
/_fts/i— FTS5 shadow tables and triggers. - PG matview backing tables that match a declared view name.
These are engine-managed; surfacing them as drift would be permanent noise.
Common ignore patterns by environment
| Environment | Pattern | Why |
|---|---|---|
| MongoDB Atlas | /^_atlas_/i,/^system\./i | Atlas inserts metadata collections (_atlas_system_query_planner_caches, system.profile). |
| PostgREST | pgrst_watch,/^pgrst_/i | PostgREST creates trigger-backing tables. |
| Hasura | hdb_catalog,/^hdb_/i | Hasura keeps its metadata in a sibling schema, but the catalog leaks through. |
| Supabase | /^auth\./i,/^storage\./i,/^realtime\./i | Supabase's managed schemas — your service doesn't own them. |
| TimescaleDB | /^_timescaledb_/i,/_hyper_/i | Chunk metadata + hypertable shards. |
| Litestream replica | /^_litestream_/i | Replica state. |
The right answer for any of these is to bake the pattern into FORGE_DIFF_IGNORE in the CI env so every diff job picks it up automatically.
Ignored tables are reported
Forge surfaces what was filtered:
✓ no drift — live postgres schema matches forge schema
(ignored 3 tables: hdb_catalog, _atlas_meta, system.profile)This is deliberate — silent filtering would let a real-drift table slip through if someone accidentally wrote a too-broad regex. The trailing summary lets you verify the ignore list is doing what you meant.
Per-dialect emit table
What forge push (and forge diff apply for new objects) actually sends to each adapter:
| Operation | Postgres | MySQL | SQLite | DuckDB | MSSQL | Mongo |
|---|---|---|---|---|---|---|
| Add table | CREATE TABLE | CREATE TABLE | CREATE TABLE | CREATE TABLE | CREATE TABLE | implicit (first insert creates the collection) |
| Add column | ALTER TABLE … ADD COLUMN | ALTER TABLE … ADD COLUMN | ALTER TABLE … ADD COLUMN | ALTER TABLE … ADD COLUMN | ALTER TABLE … ADD | no-op (schemaless) |
| Add unique | CREATE UNIQUE INDEX | CREATE UNIQUE INDEX | CREATE UNIQUE INDEX IF NOT EXISTS | CREATE UNIQUE INDEX | CREATE UNIQUE INDEX | createIndex({…}, { unique: true }) |
| Add partial-filter index | CREATE INDEX … WHERE … | translated to expression-index form (CREATE INDEX ((CASE WHEN … THEN col END))) | CREATE INDEX … WHERE … | CREATE INDEX … WHERE … | CREATE INDEX … WHERE … (filtered index) | createIndex({…}, { partialFilterExpression }) |
| Add expression index | CREATE INDEX … ((expr)) | CREATE INDEX … ((expr)) | CREATE INDEX … (expr) | CREATE INDEX … (expr) | not emitted (use computed column) | not applicable |
| Add covering index | CREATE INDEX … INCLUDE (…) | not emitted | not emitted | not emitted | CREATE INDEX … INCLUDE (…) | not applicable |
| Add FTS shadow | tsvector column + CREATE INDEX … USING gin | FULLTEXT INDEX (…) inside table DDL | CREATE VIRTUAL TABLE … USING fts5 + after-insert / after-update / after-delete triggers | PRAGMA fts_main_* (DuckDB FTS) | manual (out-of-band FULLTEXT CATALOG) | createIndex({ field: 'text' }) |
| Add geo column with extension | geography(Point, 4326) | POINT NOT NULL SRID 4326 | SELECT AddGeometryColumn(…) (SpatiaLite) | GEOMETRY (spatial) | GEOGRAPHY | createIndex({ field: '2dsphere' }) |
| Add geo column without extension (fallback) | TEXT (JSON-stored, app-side Haversine) | TEXT | TEXT | (always available, no fallback) | (always available) | (always available) |
| Drop column | ALTER TABLE … DROP COLUMN (diff apply only) | ALTER TABLE … DROP COLUMN (diff apply only) | ALTER TABLE … DROP COLUMN on SQLite 3.35+ (diff apply only); older versions need full table rebuild | ALTER TABLE … DROP COLUMN | ALTER TABLE … DROP COLUMN | no-op |
| Drop table | DROP TABLE IF EXISTS (diff apply only) | DROP TABLE IF EXISTS | DROP TABLE IF EXISTS | DROP TABLE IF EXISTS | DROP TABLE IF EXISTS | db.collection.drop() (manual; not auto-emitted) |
| Rename column | not emitted | not emitted | not emitted | not emitted | not emitted | not emitted |
| Rename table | not emitted | not emitted | not emitted | not emitted | not emitted | not emitted |
| Add FK | ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY | ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY | comment only ("SQLite cannot ALTER ADD FK") | ALTER TABLE … ADD FOREIGN KEY | ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY | not applicable |
SQLite < 3.35: column drop requires rebuild
forge diff apply emits a plain ALTER TABLE … DROP COLUMN. On a SQLite older than 3.35 (released 2021-03), that statement isn't supported and the migration will fail. The workaround is the SQLite-recommended rebuild:
BEGIN;
CREATE TABLE _users_new (id TEXT PRIMARY KEY, email TEXT NOT NULL);
INSERT INTO _users_new (id, email) SELECT id, email FROM users;
DROP TABLE users;
ALTER TABLE _users_new RENAME TO users;
COMMIT;Run that as a one-shot db.$executeRaw before re-running forge diff apply. forge doesn't generate the rebuild because picking which columns to preserve is a judgment call — the schema-side intent is DROP COLUMN, the safe execution is rebuild, and the user owns the data.
Renames are not detected
The differ has no heuristic for "this column was renamed". Renaming email → email_address in the schema looks like "drop email, add email_address". forge diff apply will happily emit both. The pattern for preserving data through a rename is the two-deploy plan in Worked workflow (d): add the new column, copy data, switch reads, drop the old.
CI workflows
(a) GitHub Actions PR check — drift gate
# .github/workflows/schema.yml
name: Schema drift gate
on:
pull_request:
paths: ['src/schema.ts', 'src/schema/**', 'package.json']
jobs:
drift:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env: { POSTGRES_PASSWORD: postgres }
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 5s
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
FORGE_DIFF_IGNORE: /^_atlas_/i,/^hdb_/i
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Apply main-branch schema to staging DB
run: |
git worktree add /tmp/main origin/main
( cd /tmp/main && npm ci --omit=dev && npx forge push )
- name: Diff PR schema against main-shaped DB
id: diff
run: npx forge diff --check --json | tee diff.json
continue-on-error: true
- name: Comment diff on PR
if: steps.diff.outcome == 'failure'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = '```json\n' + fs.readFileSync('diff.json', 'utf8') + '\n```';
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: 'Schema drift detected — `forge push` would emit:\n' + body,
});
- name: Fail job if drift
if: steps.diff.outcome == 'failure'
run: exit 1Two pieces matter: the staging DB is shaped from origin/main first, then the PR's schema is diffed against it. Drift means "the PR adds DDL that needs to land before app code goes live". The PR comment surfaces the JSON so the reviewer doesn't have to dig into job logs.
(b) Pre-deploy gate — dry-run against prod via diff
There's no forge push --dry-run. The equivalent is forge diff against prod (read-only), which tells you what forge push would do without doing it:
# .github/workflows/predeploy-schema-check.yml
name: Pre-deploy schema check
on:
pull_request:
branches: [main]
paths: ['src/schema.ts', 'src/schema/**']
jobs:
prod-drift-preview:
runs-on: ubuntu-latest
environment: prod-readonly # protected env, read-only DATABASE_URL
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Diff PR schema against prod
run: npx forge diff --json | tee prod-diff.json
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('prod-diff.json', 'utf8'));
const sigil = report.inSync ? 'in sync' : `${report.items.length} drift item(s)`;
const body = [
`**Prod schema preview** — ${sigil}`, '',
'```json', JSON.stringify(report, null, 2), '```',
].join('\n');
github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.payload.pull_request.number, body,
});The prod-readonly GitHub environment holds a DATABASE_URL with a role that has SELECT on system catalogs (PG: pg_class, pg_index, pg_constraint) but no INSERT/UPDATE/DELETE/DDL — diff only needs introspection, never writes, so a read-only role is sufficient and gives you a "would this push do anything destructive?" preview without giving CI write access to prod.
(c) Post-deploy verify — diff JSON as a metric
// scripts/post-deploy-verify.ts
import { execSync } from 'child_process';
const out = execSync('npx forge diff --json', { env: process.env }).toString();
const report = JSON.parse(out);
const datadog = await fetch('https://api.datadoghq.com/api/v1/series', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'DD-API-KEY': process.env.DATADOG_API_KEY!,
},
body: JSON.stringify({
series: [
{
metric: 'forge.drift.items',
type: 'gauge',
points: [[Math.floor(Date.now() / 1000), report.items.length]],
tags: [`dialect:${report.dialect}`, `service:${process.env.SERVICE_NAME}`],
},
{
metric: 'forge.drift.in_sync',
type: 'gauge',
points: [[Math.floor(Date.now() / 1000), report.inSync ? 1 : 0]],
tags: [`dialect:${report.dialect}`, `service:${process.env.SERVICE_NAME}`],
},
],
}),
});
if (!report.inSync) {
console.error('Post-deploy drift:', report.items);
process.exit(1);
}Run this as a post-deploy hook in your CD pipeline. The metric goes to Datadog (or replace with Prometheus pushgateway / CloudWatch / your stack) so you can alert on "drift > 0 for more than 5 minutes after a deploy" — that means forge push either didn't run or partially failed.
Blue/green schema rollouts
For schema changes that aren't backward-compatible (renaming a column, narrowing a type, splitting a table), the safe pattern is a multi-step rollout where each step is backward-compatible on its own.
The four-step pattern
Goal: rename users.username → users.handle.
Step 1 — add the new column (one PR, one deploy):
// src/schema.ts
const Users = model('users', {
id: f.id(),
username: f.string().unique(),
handle: f.string().optional(), // nullable so existing rows pass
});Run forge push. App still reads/writes username; handle is nullable and ignored. This is safe to ship to prod — old app instances don't know about handle, new ones do.
Step 2 — backfill (data migration, no schema change):
// scripts/migrate/20260624-backfill-handle.ts
import { createDb, raw } from 'forge-orm';
import { schema } from '../../src/schema';
const db = await createDb({ url: process.env.DATABASE_URL!, schema });
await db.$executeRaw(raw`UPDATE users SET handle = username WHERE handle IS NULL`);
await db.$disconnect();Run once. No code deploy needed.
Step 3 — switch reads to the new column (one PR, one deploy):
const Users = model('users', {
id: f.id(),
username: f.string(), // still here, drop the unique
handle: f.string().unique(), // now required + unique
});App code now reads handle everywhere; writes still set both. forge push adds the unique on handle. After this deploy, no code reads username.
Step 4 — drop the old column (one PR, then forge diff apply):
const Users = model('users', {
id: f.id(),
handle: f.string().unique(),
});This adds the destructive item to the diff. Run npx forge diff apply (which shows the preview: drop users.username), confirm, apply. Or generate the migration file with --dry, commit it, and apply during the next deploy window.
Why four steps and not one
A single-step rename (username → handle) means there's a window between "DB has both columns" and "app reads new column" where:
- Old app pods still write
username, which doesn't exist anymore → 500s. - New app pods read
handle, which is empty for un-backfilled rows → wrong data.
The four-step pattern guarantees that at every deploy boundary, both the old and new app code work against the current DB shape. This is the same pattern as Stripe's column-additive migrations and GitHub's gh-ost workflow.
When you can skip steps
- Add-only changes — adding a column with a default, adding a new model. Single step is fine.
- Index changes — adding or dropping an index is online on all dialects (CONCURRENTLY on PG, INPLACE on MySQL 8, single-statement on SQLite). Single step is fine.
- Type widenings —
int→bigint,varchar(50)→varchar(255). Single step is fine on PG / MySQL, requires rebuild on SQLite.
The four-step pattern is specifically for renaming and splitting — operations where the old and new shapes can't coexist without code change.
Migration in monorepos
Two patterns, depending on whether services share a database.
Pattern A — service-per-DB (recommended)
Each service owns its own database. The monorepo holds N schema files, N DATABASE_URLs, N deploy steps. forge push runs once per service per deploy.
packages/
users-service/ src/schema.ts → DATABASE_URL_USERS
billing-service/ src/schema.ts → DATABASE_URL_BILLING
orders-service/ src/schema.ts → DATABASE_URL_ORDERSDeploy script:
( cd packages/users-service && DATABASE_URL=$DATABASE_URL_USERS npx forge push )
( cd packages/billing-service && DATABASE_URL=$DATABASE_URL_BILLING npx forge push )
( cd packages/orders-service && DATABASE_URL=$DATABASE_URL_ORDERS npx forge push )Schema-resolution finds the right schema per-service via the cascade — each package.json has "forge": { "schema": "./src/schema.ts" } and cd into the package directory takes care of the rest.
Pattern B — shared DB, partial-schema files
When multiple services share a database, each service should declare only the models it owns. Cross-service tables (read-only references) live in a @org/db-shared package that all services depend on.
// packages/shared/src/schema.ts — read-only model refs
export const Users = model('users', { id: f.id(), email: f.string() }, { readonly: true });// packages/billing-service/src/schema.ts — own tables + import the shared refs
import { Users } from '@org/db-shared';
export const Invoice = model('invoices', {
id: f.id(),
userId: f.string().refs(() => Users),
total: f.decimal(),
});
export const schema = { Invoice } as const; // does NOT include UsersWhen billing-service runs forge push, the differ only sees Invoice. It won't try to create or drop users. The shared Users model still gives you typed FKs and join queries; it just doesn't participate in push.
There is no forge push --exclude flag — the way to exclude tables is to not include them in the schema you pass. This is intentional: an exclude flag on the CLI would let a deploy accidentally drop tables it doesn't own. The schema being the source of truth means the boundary is in code review.
forge diff against the shared DB
Run forge diff --ignore= with the names of tables owned by sibling services:
export FORGE_DIFF_IGNORE='users,user_sessions,plans,plan_features'
npx forge diff --checkBake the ignore list into the per-service CI env. Each service's CI sees only its own tables as drift candidates.
Data migration vs DDL migration
forge push and forge diff apply handle DDL — table shape, indexes, constraints. Data migrations — backfilling a new column, transforming a JSON blob, splitting one column into two — are app code.
The pattern: a scripts/migrate/<date>-<name>.ts file that runs once, idempotently, and records itself in a one-off ledger.
// scripts/migrate/20260624-backfill-handle.ts
import { createDb, raw } from 'forge-orm';
import { schema } from '../../src/schema';
const MIGRATION_NAME = '20260624-backfill-handle';
const db = await createDb({ url: process.env.DATABASE_URL!, schema });
// Idempotency: skip if already applied. Reuse the forge ledger table.
await db.$executeRaw(raw`
CREATE TABLE IF NOT EXISTS _forge_migrations (
name VARCHAR(255) PRIMARY KEY, applied_at VARCHAR(64)
)
`);
const [{ count }] = await db.$queryRaw<{ count: number }>(
raw`SELECT COUNT(*)::int AS count FROM _forge_migrations WHERE name = ${MIGRATION_NAME}`,
);
if (count > 0) {
console.log(`[migrate] ${MIGRATION_NAME} already applied`);
process.exit(0);
}
await db.$transaction(async (tx) => {
await tx.$executeRaw(raw`UPDATE users SET handle = username WHERE handle IS NULL`);
await tx.$executeRaw(raw`
INSERT INTO _forge_migrations (name, applied_at)
VALUES (${MIGRATION_NAME}, ${new Date().toISOString()})
`);
});
console.log(`[migrate] ${MIGRATION_NAME} applied`);
await db.$disconnect();Three properties matter:
- Idempotent — the ledger check at the top means re-running is a no-op. Safe to run on every deploy.
- Transactional where it matters — the data UPDATE and the ledger insert are in the same
$transactionso a crash mid-update doesn't half-record success. (MySQL DDL is implicitly committing — DML inside a tx is still safe.) - Stored next to the schema —
scripts/migrate/files live in version control, get reviewed in the PR that adds the column, get deleted (or moved toscripts/migrate/archive/) once every environment has run them.
Why not just forge diff apply
forge diff apply is DDL-only. It writes ALTER TABLE … ADD COLUMN, never UPDATE …. Mixing data migration into _forge_migrations files would mean those files contain logic the differ can't generate or reason about — they'd no longer be roundtrippable.
The split is: DDL through forge (generated, reversible-where-possible), data through hand-written scripts/migrate/*.ts (idempotent, your responsibility).
Per-dialect rollback fidelity
Not every dialect can roll back DDL the same way. forge rollback does what it can; this table is the honest accounting.
| Dialect | DDL inside a transaction? | forge rollback recoverable for ADD | forge rollback recoverable for DROP |
|---|---|---|---|
| Postgres | Yes — full DDL in a tx, BEGIN/COMMIT brackets the whole migration | Yes (DROP COLUMN / DROP TABLE) | Yes for the shape; data is gone |
| MySQL | No — DDL implicitly commits any open tx | Yes (DROP COLUMN / DROP TABLE) | Yes for the shape; data is gone |
| SQLite | No — DDL is committed even inside BEGIN | Yes for ADD COLUMN; DROP COLUMN needs 3.35+ | Same as MySQL |
| DuckDB | Yes — DDL is transactional | Yes | Yes for shape |
| MSSQL | Yes — DDL is transactional | Yes | Yes for shape |
| Mongo | N/A — no DDL | Drop the index | Drop the collection (no rollback) |
The practical impact:
- Postgres + DuckDB + MSSQL —
forge rollbackis reliable. If the up was applied atomically, the down can be applied atomically. A crash mid-rollback leaves you at the post-up state, not half-rolled-back. - MySQL + SQLite — each DDL statement is its own commit boundary. A crash mid-rollback leaves you at "some statements rolled back, some not".
forge rollbackdoesn't detect this; you have to inspect the table state and re-run the rollback (which is idempotent for the DROP-then-ADD case) or fix forward. - Mongo —
forge rollbackexits with an error: "Mongo uses forge:push, not SQL migrations". Roll back a Mongo schema by editing the schema and re-runningforge push.
What "data is gone" means for DROP rollback
When forge rollback runs the down of a DROP COLUMN migration:
-- up (the rollback ran this when diff apply originally applied)
ALTER TABLE users DROP COLUMN legacy_token;
-- down (the rollback runs this now)
ALTER TABLE users ADD COLUMN legacy_token TEXT;You get the column back, with the type the introspect saw at apply time. You do not get the data back — the DROP deleted it. The same caveat applies to DROP TABLE, where the down is a comment ("cannot auto-restore"); there's no useful down statement at all.
If you need a destructive operation to be reversible, take a backup first and use db.$executeRaw to restore. forge does not snapshot data on DROP.
Five worked workflows
(a) New column added in a PR
You added users.email_verified_at to the schema. The PR is approved. The deploy steps:
# 1. Preview against staging
DATABASE_URL=$STAGING_URL npx forge diff --json | jq '.items'
# → [{ "kind": "column", "direction": "missing", "table": "users", … }]
# 2. Apply on staging
DATABASE_URL=$STAGING_URL npx forge push
# → applied 1, skipped 47, failures 0
# 3. Run smoke tests
npm run test:smoke -- --base-url=$STAGING_APP_URL
# 4. Apply on prod (typically wired into the CD pipeline; app start runs forge push)
DATABASE_URL=$PROD_URL npx forge push
# → applied 1, skipped 47, failures 0If you put npx forge push in the app's start command, steps 2 and 4 are automatic — forge push is idempotent and additive, so it's safe to run on every container start.
(b) Drift detected in production
A scheduled forge diff --check --json job in CD failed at 3 AM. Triage:
# 1. Capture the report
DATABASE_URL=$PROD_URL npx forge diff --json > drift.json
jq '.items' drift.json
# [
# { "kind": "index", "direction": "extra",
# "table": "events", "detail": "unique index u:idempotency_key in DB but not in schema" },
# { "kind": "column", "direction": "missing",
# "table": "users", "detail": "column 'mfa_secret'" }
# ]
# 2. Decide: did someone hand-add the unique? (Audit log says yes, ops did it during incident.)
# Fold it into the schema:
# (in schema.ts)
# Events.uniques: [['idempotency_key']]
git commit -am "Add unique on events.idempotency_key (matches prod)"
# 3. The missing column is genuine drift — the new column was deployed
# but forge push didn't run. Apply it:
DATABASE_URL=$PROD_URL npx forge push
# 4. Verify back in sync
DATABASE_URL=$PROD_URL npx forge diff --check
# → exit 0A small triage script that auto-applies the safe slice and escalates the rest:
// scripts/drift-triage.ts
import { execSync } from 'child_process';
const report = JSON.parse(execSync('npx forge diff --json').toString());
const safe = report.items.filter((i: any) => i.direction === 'missing');
const unsafe = report.items.filter((i: any) => i.direction !== 'missing');
if (safe.length > 0) {
console.log(`Auto-applying ${safe.length} additive drift item(s) via forge push`);
execSync('npx forge push', { stdio: 'inherit' });
}
if (unsafe.length > 0) {
console.error(`Manual review needed for ${unsafe.length} item(s):`);
console.error(JSON.stringify(unsafe, null, 2));
process.exit(2);
}(c) Engine-managed Atlas collections
MongoDB Atlas keeps metadata in _atlas_* collections. Bake the ignore list into the CI env so they never show up as drift:
# .github/workflows/schema.yml
env:
FORGE_DIFF_IGNORE: '/^_atlas_/i,/^system\./i,/^enterpriseAdvancedAuth\./i'Locally:
echo 'FORGE_DIFF_IGNORE=/^_atlas_/i,/^system\./i' >> .env
npx forge diff # Atlas system collections filtered, surfaced in the trailing summaryThe trailing ignored 4 collections: … line in the report lets you confirm the regex matched what you expected.
(d) Renaming a column
Rename users.username → users.handle. Four deploys, each backward-compatible. See Blue/green schema rollouts for the full pattern. The compressed version:
| Deploy | Schema | DDL emitted | App reads | App writes |
|---|---|---|---|---|
| 1 | both, handle nullable | ADD COLUMN handle | username | username |
| 2 (data) | — | — (data migration via db.$executeRaw) | username | username |
| 3 | both, handle unique | ADD UNIQUE on handle | handle | username + handle |
| 4 | only handle | (via diff apply) DROP COLUMN username | handle | handle |
Between deploys 3 and 4 the app instances all have to be on the new code. Forcing a graceful rollout finish before deploy 4 is the operator's job — forge doesn't enforce it.
(e) Multi-tenant DB-per-tenant
A SaaS that gives each customer their own database. The deploy step loops over tenant URLs and runs forge push against each:
// scripts/push-all-tenants.ts
import { execSync } from 'child_process';
const tenants = await fetch(`${process.env.CONTROL_API}/tenants`, {
headers: { Authorization: `Bearer ${process.env.CONTROL_TOKEN}` },
}).then(r => r.json());
const failures: string[] = [];
for (const t of tenants) {
try {
execSync('npx forge push', {
env: { ...process.env, DATABASE_URL: t.database_url },
stdio: 'inherit',
});
console.log(`✓ ${t.slug}`);
} catch (err) {
failures.push(t.slug);
console.error(`✗ ${t.slug}: ${(err as Error).message}`);
}
}
if (failures.length) {
console.error(`\n${failures.length} tenant(s) failed: ${failures.join(', ')}`);
process.exit(2);
}Run as a deploy step (or a scheduled job that runs daily, after every release). Because forge push is idempotent, re-running against tenants that succeeded is a no-op (applied 0, skipped 47).
For 100+ tenants, parallelise with a worker pool — each forge push opens its own connection pool, so 8-way concurrency is comfortable on a CI runner. Don't run more than the prod database fleet can handle.
Runtime $migrate() + $diff() — when to use which
forge has two migration paths: the CLI (forge push, forge diff) for server-side, and the runtime (db.$migrate(), db.$diff()) for the browser and other constrained environments where shelling out to Node isn't possible.
CLI — server-side, full power
- All adapters (Postgres, MySQL, SQLite, DuckDB, MSSQL, Mongo).
- Full DDL emit including FTS5 shadows, extension installs, foreign keys, views.
forge diff applywrites migration files to disk.forge rollbackreads them back and applies down blocks.- Schema-resolution cascade, ts-node loading, all the developer-experience niceties.
This is what you run in CI, in deploy pipelines, and at server start.
Runtime — browser, mobile, Tauri
db.$migrate()anddb.$diff()only on the sqlite-wasm browser adapter.- No CLI, no file I/O — everything happens in a Web Worker against an OPFS database.
- Since 2.5.1,
$migrate()runs a drift-apply pass too: introspect → diff → safely-additive ALTER COLUMN inside the same transaction. Destructive drift (DROP COLUMN, type changes, extra tables) surfaces underreport.pendinginstead of being applied. See BROWSER.md for the full shape. db.$diff()returns aDriftReportdirectly when you want the diff without the apply.
const report = await db.$migrate();
// {
// applied: ['items', 'forge_items_unique_name'],
// skipped: [],
// failures: [],
// alteredColumns: ['items.email'],
// pending: [
// { kind: 'column', direction: 'extra', table: 'items',
// detail: "column 'legacy_blob' in DB but not in schema" },
// ],
// }
if (report.pending.length > 0) {
// Decide app-side: wipe the local DB, prompt for export, or just live with it.
}Which to use
| Environment | Use |
|---|---|
| Production server / CI | CLI (forge push, forge diff --check) |
| Local dev | CLI (forge push at app start) |
| Browser / Electron / Tauri | Runtime (db.$migrate() at app boot) |
Ephemeral demo (:memory: SQLite) | Runtime is fine; CLI doesn't have a path to :memory: |
| Mobile (React Native, Capacitor) | Runtime when SQLite is on-device |
The two paths share the differ (diff-core.ts) and the introspect adapters — drift detection is the same logic in both places. What changes is how the result is applied: the CLI generates SQL files and runs them via the driver pool; the runtime generates SQL strings and runs them inside the Worker's prepared-statement API.
For browsers, that's the whole story — see BROWSER.md for the chunked rollout details, the pending report shape, and the Safari ITP eviction handling that affects how often $migrate() ends up running on a fresh DB.
Snapshots — generating without a database (2.9.0)
npx forge generate --name add-org-slugNo DATABASE_URL connection. Nothing is introspected. The command diffs your schema against the last committed snapshot and writes two files:
migrations/
meta/
_journal.json ordering, and what each file was called
0001_snapshot.json the schema's shape after 0001
0002_snapshot.json
0001_initial.sql
0002_add-org-slug.sqlWhy this exists
forge diff apply generates by introspecting the live database. That is the right tool for adopting a database somebody else created, and the wrong one for everyday work:
- CI cannot check anything. There is no database in the pipeline, so nothing can verify that a schema change arrived with its migration.
- Two branches generate against two worlds. Alice adds a column against her local database on Monday; Bob adds a different one against his on Tuesday. Each file is correct relative to a state that stops existing at the merge.
- The same schema does not reliably produce the same SQL, so a reviewer cannot regenerate the migration to check it matches the schema change in the same pull request.
A snapshot is simply what introspect() would return if this schema were applied. Once that is a file, the differ does not care that it did not come from a socket.
The commands
| reads | writes | needs a DB | |
|---|---|---|---|
forge generate | last snapshot | .sql + snapshot | no |
forge diff apply | the live database | .sql, and applies it | yes |
forge push | the live database | indexes only | yes |
forge rollback | _forge_migrations | reverses the last file | yes |
npx forge generate --name add-orgs # the usual case
npx forge generate --dialect postgres # no DATABASE_URL at all
npx forge generate --check # CI: exit 3 if a migration is missing
npx forge generate --custom --name backfill-slugs # empty up/down to fill in--dialect is only needed when DATABASE_URL is absent. When it is set, the command reads its scheme to know the dialect and connects to nothing.
The CI gate
- run: npx forge generate --checkExit 3 when the schema contains changes that no migration covers, and it names them:
[forge:generate] 1 change(s) are in the schema but not in any migration.
- add orgs.regionThis is the check that a database-backed generator cannot perform, and it is the main reason to adopt snapshots.
Custom and data migrations
npx forge generate --custom --name backfill-org-slugsWrites an empty up/down and takes its place in the ordered history. Use it for a backfill, or for the first half of a two-step change — clean the NULLs, then add NOT NULL.
The snapshot it writes is unchanged from the previous one, on purpose: forge cannot know what hand-written SQL does, and guessing would corrupt every later diff.
Reviewing the pair
Commit the .sql and its snapshot together. The .sql says what will run; the snapshot says what the schema will then be. A reviewer who sees one without the other is looking at half of the change.
Snapshots are sorted — tables, columns, indexes, keys — so reordering two models in your schema file produces no diff at all, and the diff you do see is the change you made.
What a snapshot is not
It is a projection, not a recording. It describes what the schema says, never what a database contains. It cannot see a column somebody added by hand at 3am.
For that, forge diff against the live database is still the tool, and still the honest answer to "is production actually what we think it is?" — the two are complementary, and neither replaces the other.
Adopting an existing database
Snapshots start from empty, so a first generate against a database that already has tables would produce a create-everything migration you must not run.
npx forge diff apply # reconcile against what is really there, once
npx forge generate # from here onOne dialect per folder
A snapshot describes one dialect — column types and index shapes differ. Pointing generate at a folder created for another one is refused rather than diffed:
[forge] this migrations folder was generated for 'mysql', but DATABASE_URL
points at 'postgres'. Use a separate folder per dialect.Mongo
forge generate refuses on Mongo, and should. There is no DDL to migrate: indexes are reconciled by forge push, which is idempotent and needs no history.
Column changes (2.10.0)
A column whose type or nullability changed used to be silently absent from a generated migration. The schema said varchar(255), the database kept varchar(64), the file applied cleanly, and nothing said a word.
Silently omitting a change is the worst of the three options. forge now picks one of the other two.
Widening is emitted
Where every existing row still fits, the statement cannot fail on data:
| from | to | |
|---|---|---|
varchar(64) | varchar(255) | longer |
varchar(255) | text | unbounded |
int | bigint | the one safe cross-category change |
numeric(10,2) | numeric(12,2) | more precision |
NOT NULL | NULL | dropping a constraint always succeeds |
-- up
ALTER TABLE `orgs` MODIFY COLUMN `hits` BIGINT NOT NULL;
-- down
-- narrowing hits back to int can fail on rows added since;
-- review before rolling back.
ALTER TABLE `orgs` MODIFY COLUMN `hits` int;The down carries that warning because the reverse of a widening is a narrowing, and it can fail on rows written since the migration ran. A file that says "rollback" without saying so is lying to whoever runs it at 3am.
Everything else is refused
[forge:generate] refusing to generate 1 change(s).
Each needs a decision forge cannot make for you.
✖ orgs.name: text → int
orgs.name changes from text to int, which is not a widening —
existing rows may not fit, or may not convert at all.
→ forge will not guess at this. Write it with `forge generate
--custom`: add the new column, backfill it with whatever conversion
is correct for YOUR data, verify, then drop the old one and rename.
Doing it in one ALTER locks the table and fails on the first row
that will not cast.Exit code 2. No file is written, no journal entry is made — including for the safe changes in the same diff, because a migration that applies cleanly while leaving the schema and the database disagreeing is the failure this exists to remove, not a smaller version of it.
The refusal is the feature. A tool that emits ALTER COLUMN … TYPE int against a column holding text is more dangerous than one that emits nothing, because the migration looks reviewed.
NULL → NOT NULL
Refused, always, and the message says why the obvious fix does not work:
A
DEFAULTdoes not help — a default applies to new rows, not to the ones already there.
The two-step migration:
npx forge generate --custom --name backfill-org-note
# UPDATE orgs SET note = '' WHERE note IS NULL;Ship it. Confirm SELECT count(*) FROM orgs WHERE note IS NULL is 0. Then make the column required in the schema and generate again — the refusal turns into the ALTER once the data is clean.
SQLite
Refused for any type or nullability change, because SQLite has no ALTER COLUMN. The real answer is its documented twelve-step rebuild: create x_new, INSERT … SELECT, drop, rename, recreate indexes and triggers.
forge will not generate that blind. Copying the column is the easy part; the indexes, triggers and views pointing at the old table are the part a generator cannot see, and getting that wrong drops them silently.
What is not compared
A type forge cannot categorise — tsvector, a domain type, an extension type — is left alone rather than rewritten. Better to say nothing about a shape we do not understand than to emit an ALTER for it.
Mongo is skipped entirely: introspection reports no column types, so there is nothing to compare.
Renames (2.11.0)
Comparing two schema states shows only that one column name is gone and another has appeared. A rename and a drop plus an add look identical from there — and they do opposite things to the data. One keeps every row; the other deletes a column's worth of it.
Guessing "drop and add" loses data on a column somebody meant to keep. Guessing "rename" is worse: it keeps a column somebody meant to delete, and quietly moves its data under a new name.
forge does not guess. It takes the answer from the schema.
renamedFrom
export const Org = model('orgs', {
id: f.id(),
name: f.string().renamedFrom('full_name'),
});-- up
ALTER TABLE "orgs" RENAME COLUMN "full_name" TO "name";
-- down
ALTER TABLE "orgs" RENAME COLUMN "name" TO "full_name";Every row survives, and the intent is in the schema, in the diff, and in the pull request.
Delete the annotation once the migration has shipped everywhere it needs to. It affects generation only — nothing reads it at runtime.
Without it, forge asks
✖ orgs.full_name dropped, name added
orgs.full_name is gone and a new column of the same type has appeared
(name). forge cannot tell a rename from a drop and an add, and the two
do opposite things to the data in that column.
→ If it is a rename, say so in the schema:
`name: f.…().renamedFrom('full_name')` — the migration becomes a
RENAME COLUMN and every row survives. If full_name really is being
deleted, re-run with --allow-drop to confirm you mean to lose it.Exit 2, nothing written.
Only same-typed pairs are suspected. A dropped text column beside a new integer one is not a rename candidate — flagging that would make every genuine drop noisy, and a check people learn to skip past protects nothing.
A drop with nothing added is never a rename question. It is just a drop.
--allow-drop
npx forge generate --allow-drop --name remove-legacy-columnsHow you say "it really is a drop, I mean to lose it". Deliberately a flag and not a prompt: it appears in the shell history and in whatever ran the command.
Why an annotation rather than a prompt
drizzle-kit asks the same question interactively — "is full_name a rename of name, or a new column?" That is the right question in the wrong medium:
- a prompt answered once at 2am is recorded nowhere
- it cannot run in CI, so the check is absent exactly where an automated pipeline would apply the migration
- the answer is invisible in review — a reader sees the SQL, not the reasoning
- and it must be answered again by the next person who regenerates
An annotation is in the schema. It is part of the diff, part of the pull request, and part of the answer to "why did this column change name?" a year later.
Renaming and changing a type
Both statements are emitted, in order:
-- up
ALTER TABLE `orgs` RENAME COLUMN `full_name` TO `name`;
ALTER TABLE `orgs` MODIFY COLUMN `name` TEXT NOT NULL;
-- down
-- narrowing name back to varchar(255) can fail on rows added since;
-- review before rolling back.
ALTER TABLE `orgs` MODIFY COLUMN `name` varchar(255);
ALTER TABLE `orgs` RENAME COLUMN `name` TO `full_name`;This is a known drizzle-kit bug (#5499, #3826): renaming and changing a column in one step emits only the rename, and the type change is silently lost. Here the rename runs first and the ordinary ALTER follows it — so either both appear, or the run is refused because the type change itself is unsafe.
Ordering is not cosmetic. A rename after the ADD would find the new column already there; one after the DROP would have nothing left to rename. Renames run before every other column pass.
forge migrate status (2.12.0)
npx forge migrate status
npx forge migrate status --check # CI: exit 4 if anything needs attentionEvery other command in this file compares intent — your schema against a snapshot, or against what a database reports. This one compares reality: the .sql files in migrations/ against the rows in _forge_migrations. It is the only command here that genuinely needs DATABASE_URL, because a database is the only thing that knows what it has actually run.
migrations
✓ 0001_initial.sql 2026-08-11T09:14:02Z
✓ 0002_add-org-slug.sql 2026-08-19T16:40:55Z
! 0003_alice-adds-note.sql OUT OF ORDER — sorts before
0004_bob-adds-tier.sql, which is already applied
✓ 0004_bob-adds-tier.sql 2026-09-01T11:02:10Z
· 0005_add-index.sql pending
? 0006_from-a-branch.sql NOT IN THIS CHECKOUT applied 2026-09-02T08:30:00Z
3 applied · 1 pending · 1 out of order · 1 not in this checkoutThe four states
| meaning | |
|---|---|
✓ applied | in the ledger and in this checkout |
· pending | a file here that has not run |
! OUT OF ORDER | pending, but sorting behind one already applied |
? NOT IN THIS CHECKOUT | the database applied something this checkout does not have |
The first two every migration tool shows. The other two are where production goes wrong, and no tool — drizzle-kit included — reports either.
NOT IN THIS CHECKOUT
The database has applied a migration that is not in your migrations/ folder. Somebody ran a branch against it.
This matters more than it looks. The schema in front of you is not the schema that database has, so every migration you generate from here is built on a state you cannot see. The next forge generate produces a file that is correct against your snapshot and wrong against that database.
The fix is to find the branch that produced them, or — if the database is disposable — reset it. Do not generate against it until it matches a branch that exists.
OUT OF ORDER
Alice generates 0007 on Monday. Bob generates 0008 on Tuesday. Bob's merges first and ships. When Alice's is merged, a migrator that walks forward from the highest applied entry skips 0007 in silence — it is never applied at all, and nothing ever says so.
drizzle-kit has this exact failure with journal timestamps. forge would too; the difference is that forge tells you.
The fix is to regenerate on top of the current state: delete the file and its snapshot, pull, then forge generate again so it is numbered after what is already there. That also re-diffs it against the schema as it now is, which is the part that actually matters — 0007 was written against a world where 0008 had not happened.
How the two interact
An applied migration that is not in this checkout never marks the others out of order. It would otherwise set the high-water mark and flag every ordinary pending file — with guidance that does not address the real cause, because the cause is the branch migration.
So OUT OF ORDER means behind a migration this checkout also has, and the ordering risk from an unknown one is stated under the unknown one:
? 0006_from-a-branch.sql NOT IN THIS CHECKOUT applied 2026-09-02
It also blocks the 1 pending migration above: a migrator walking
forward from the newest ledger entry will step over anything
numbered below it. Resolve this before applying them.Both are still reported, and --check still fails. What changes is which line you are asked to act on.
The CI gate
- run: npx forge migrate status --check
env:
DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}Exit 4 when anything is out of order, unknown, or unapplied. It is a distinct code from generate --check's 2 and 3 so a pipeline can tell which gate refused it:
| exit | command | meaning |
|---|---|---|
| 2 | generate --check | the schema has unsafe changes |
| 3 | generate --check | a schema change has no migration |
| 4 | migrate status --check | the database and the folder disagree |
Point it at staging, not at the pull request's throwaway database — an empty database has nothing to disagree about, and this check only earns its place against one that has history.