orm-preflight

Preflight safety checks for TypeORM migrations: catch data loss and locking before you merge.
Documentation: https://sikandar100.github.io/orm-preflight/
Changing a column's length in TypeORM can delete all of its data. When a column's type or length
changes, TypeORM's migration generator drops the column and adds it again
(typeorm/typeorm#3357). The migration looks
harmless in review and passes every test on an empty database. orm-preflight catches that, and
other migrations that lose data, lock busy tables, or break a rolling deploy, before you merge.
src/migrations/1727100000000-WidenUserName.ts
7:30 error no-drop-and-recreate-column
"users"."name" is dropped and re-added in the same migration. Every existing value in this column will be lost.
Why: TypeORM drops and re-adds a column when its type or length changes, instead of changing it in place. The new column starts empty.
Safe: Change the type in place: ALTER TABLE "users" ALTER COLUMN "name" TYPE varchar(255). Widening a varchar, or changing varchar to text, does not rewrite the table. ...
Docs: https://github.com/sikandar100/orm-preflight/blob/main/docs/rules/no-drop-and-recreate-column.md
8:30 error no-add-not-null-without-default
"users"."name" is added as NOT NULL without a default. The migration fails as soon as "users" has any rows.
...
2 errors, 0 warnings in 1 migration
Install and run
npm install --save-dev orm-preflight
npx orm-preflight "src/migrations/*.ts"
Node.js 20 or newer. Without arguments, orm-preflight checks the migrations globs from its
config, or every migrations folder in the project.
To start on an existing project without fixing its history, run npx orm-preflight init first
(see Adopting on an existing project).
What it catches
Every finding says what happens, why, and how to make the change safely. Each rule's page cites
the PostgreSQL, MySQL, or TypeORM source behind its claim. Run npx orm-preflight explain <rule>
to read it in the terminal, or npx orm-preflight rules for the list.
Changes to a table created in the same migration are not reported: it has no rows yet.
Command line
orm-preflight [files or globs...] [options]
-c, --config <path> Config file (default: orm-preflight.config.json, then the
"ormPreflight" key in package.json)
--dialect <postgres|mysql> Database dialect (default: postgres)
--postgres-version <n> PostgreSQL major version the migrations run on (default: 16)
--orm <typeorm> ORM adapter (default: typeorm)
--changed-since <git-ref> Check only migrations added or changed since the merge base
with <git-ref>, such as origin/main
--format <format> pretty, json, github (annotations), or sarif. Default:
github in GitHub Actions, pretty elsewhere
--max-warnings <n> Fail when there are more than n warnings (default: no limit)
--execute Run each migration's up() to see SQL built at run time. This
runs your code: only use it on code you trust
--allow-untrusted-execute Allow --execute under GitHub's pull_request_target event
--no-color Print without colors
orm-preflight rules List every rule
orm-preflight explain <rule> Print a rule's documentation
orm-preflight init [files or globs...] Write a starter config
0 | No errors, and no more warnings than --max-warnings |
1 | Errors, or more warnings than --max-warnings |
2 | Usage, config, or internal error |
pretty | Findings grouped by file, for people. Suppressed findings are only counted. |
json | Every finding, including suppressed ones. The shape is public API, described in docs/json-output.md. |
github | GitHub Actions annotations on the exact lines of the pull request, then a summary line. |
sarif | SARIF 2.1.0, for GitHub code scanning and other SARIF viewers. Suppressed findings are kept with their reason. |
Configuration
Put the config in orm-preflight.config.json, or under the "ormPreflight" key of
package.json. Only JSON is supported, so a pull request cannot run code through the config.
{
"$schema": "./node_modules/orm-preflight/schema.json",
"orm": "typeorm",
"dialect": "postgres",
"postgresVersion": 16,
"migrations": ["src/migrations/*.ts"],
"startAfter": 1727000000000,
"typeorm": { "transactionMode": "each" },
"rules": { "no-rename-column": "warn" }
}
migrations | Globs of migration files. Default: every migrations folder. |
startAfter | A migration timestamp. Migrations at or before it are not checked. |
postgresVersion | The PostgreSQL major version you run, 12 or newer. Some safe alternatives depend on it. Default: 16. |
defaultSchema | The schema of unqualified table names. Default: public on PostgreSQL. |
typeorm.transactionMode | Must match migrationsTransactionMode in your DataSource: all (the TypeORM default), each, or none. |
rules | Severity per rule: error, warn, or off. |
Command-line flags override the config.
Adopting on an existing project
npx orm-preflight init
init writes orm-preflight.config.json with startAfter set to your newest migration, so only
migrations you add from now on are checked. It reads your migration files but never runs them.
On pull requests, you can also check only the migrations the branch adds or changes:
npx orm-preflight --changed-since origin/main
It compares against the merge base with origin/main and includes uncommitted and untracked
files. Tables created by any of the changed migrations count as new for all of them. Editing a
migration that already exists on origin/main is reported by no-edit-applied-migration. In
GitHub Actions, check out with fetch-depth: 0 so the merge base is available.
Continuous integration
orm-preflight needs no database and no secrets, so it is safe on pull requests from forks.
GitHub Action
Findings appear as annotations on the exact lines of the pull request:
name: Migrations
on: pull_request
permissions:
contents: read
jobs:
orm-preflight:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: sikandar100/orm-preflight@v1
with:
changed-since: origin/${{ github.base_ref }}
Which version to use:
@v1 follows every 1.x release, so you get fixes without changing anything.
@v1.0.0, or a commit SHA, stays exactly where it is, if your policy requires pinning.
The action runs the orm-preflight version of its tag with npx, so GitHub-hosted runners need
nothing else. On a self-hosted runner, set up Node.js 20 or newer first.
changed-since | Check only migrations added or changed since the merge base with this git ref. |
files | Files or globs to check, separated by spaces. Default: the config, or every migrations folder. |
config | Path of the config file. |
working-directory | Directory to run in, relative to the repository root. Default .. |
max-warnings | Fail when there are more than this many warnings. |
mysql | true when the migrations target MySQL, to install the MySQL parser. |
sarif | true to also upload the findings to GitHub code scanning. See below. |
version | The orm-preflight version to run. Default: the version of the action's tag. |
Use the pull_request trigger. Never use pull_request_target for this: it gives pull requests
from forks a token with write access and your secrets, and orm-preflight needs neither.
GitHub shows at most 10 annotations of each level per step. The last line of the log always
counts every finding.
To see findings in the repository's code scanning alerts as well, set sarif: true and grant
security-events: write. GitHub never grants that to pull requests from forks, so the action
skips the upload there and still annotates:
permissions:
contents: read
security-events: write
Other CI systems
Run the CLI. The exit code is 1 when there are errors:
npx orm-preflight --changed-since origin/main
Check out enough history for the merge base (in GitHub Actions, fetch-depth: 0).
Suppressing a finding
When a finding is expected, say why in a comment above the statement:
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "bio"`)
The reason is required, so suppressions form an audit trail. For a whole file, put
/* preflight safety-assured-file <rule> -- <reason> */ anywhere in it. A suppression with no reason or an unknown
rule is itself an error.
How it works
orm-preflight parses each migration file with Babel and reads the SQL strings passed to
queryRunner.query() and the builder calls such as addColumn() and createIndex(). It parses
the SQL with the PostgreSQL parser (libpg-query) or a MySQL parser, and runs its rules on the
result.
It never imports or runs your migrations, never connects to a database, and never loads
TypeORM. Anything it cannot read, such as SQL built at run time, is reported as
unanalyzable-statement instead of being skipped silently.
Running migrations with --execute
Some migrations build their SQL at run time, for example in a loop over table names, or in a
helper imported from another file. Static analysis cannot read that SQL. --execute can:
npx orm-preflight --execute
It loads each migration file and runs up() with a stand-in for TypeORM's query runner, which
records every statement instead of sending it to a database. So:
- No database is needed, and none is touched.
- Imports of
typeorm get a small stand-in; the real TypeORM is never loaded.
- If
up() reads the database, for example with getTable(), it gets an empty answer, and
orm-preflight reports that a real database may lead to different statements.
- If
up() fails or runs longer than 10 seconds, that is reported, and linting goes on.
--execute runs your code. Only use it on code you trust:
- Never use it on pull requests from forks with the
pull_request_target trigger, which gives
them your secrets. orm-preflight refuses to, unless you add --allow-untrusted-execute.
- The GitHub Action never uses
--execute. It stays static and safe for forks.
What a clean run means
A clean run means none of the documented hazards were found. It does not guarantee that a
migration is safe. orm-preflight does not know your table sizes or traffic, so it assumes every
existing table is large and busy.
Limitations:
- TypeORM only. The core is ORM-agnostic, so adapters for other ORMs can follow.
- PostgreSQL and MySQL. On MySQL, the data-loss, deploy-safety, and correctness rules apply.
Locking analysis for MySQL (
ALGORITHM=INSTANT, INPLACE, COPY) is planned after 1.0.
MySQL needs npm install --save-dev node-sql-parser.
- Only
up() is analyzed. down() is only checked for being empty (require-down).
- SQL built at run time is reported, not analyzed, unless you use
--execute.
Programmatic use
import { formatJson, lint } from 'orm-preflight'
const result = await lint({ cwd: process.cwd(), patterns: ['src/migrations/*.ts'] })
process.stdout.write(formatJson(result))
if (result.summary.errors > 0) process.exitCode = 1
Contributing
See CONTRIBUTING.md. To
report a security issue, see
SECURITY.md.
Acknowledgements
orm-preflight is inspired by strong_migrations
for Rails and squawk for PostgreSQL.
License
MIT