Lint
Replay a migration history on a throwaway database before it runs on a real one.
Postgres: native. Proof passes (proof:tooling.scratchReplay).
okm migrate check proves a migration history is sound. It is a CI command. It takes no prompt, and the result does not depend on colour. Exit zero prints ok N migrations, then one line that this command replays empty tables and does not catch a step that fails only on real data. A reprinted domain check or LANGUAGE sql body adds warning OKM1552 on the following lines. The warning does not fail the command. Any failure exits non-zero. One failure prints its code, the message, and the fix. Several failures print one greppable line per failure.
Point it at a throwaway Postgres. A protected target is OKM1850, and --allow-protected does not lift that refusal. The message and the JSON fields are on migrate check. The composite action is GitHub Action.
Run the check as a non-superuser. A superuser replay executes each step as written, and a step can reach COPY ... PROGRAM and lo_import. A NOSUPERUSER owner of the database cannot. CREATEROLE is not required.
The four checks
- History. Every migration is replayed, in order, into a scratch schema created inside one transaction. The schema name starts with
okm_check_. The transaction always ends withROLLBACK, and the schema is dropped at the end, including after a failure. The replay does not edit a system catalog or a real ACL. After each migration the scratch schema is introspected and planned back to that file's catalog. A remaining step is OKM1547 and names the migration and the steps. On PostgreSQL 18 aNOT NULL … NOT VALIDconstraint that the file never validates is such a step:VALIDATE CONSTRAINT. Before that plan, a domain check or aLANGUAGE sqlfunction body that reprints differently is warning OKM1552.-- okm-allow OKM1552: reasonsilences it. An empty reason, or an allow when the stored text matches, is OKM1510. A reprinted domain check, table check (including a picklist), or btree index expression is sealed before that plan, so it is not OKM1547. Ausingindex tail is not that seal. Two migrations generated from the same parent fail here.COMMIT,END,ABORT,ROLLBACK,BEGIN,START TRANSACTION,PREPARE TRANSACTION, andCOMMIT PREPAREDin a step are OKM1551 before any replay, even when-- okm-allow OKM1551silenced the lint. ABEGIN ATOMICbody ofCREATE FUNCTIONorCREATE PROCEDUREis one statement and is supported. Its closingENDis not OKM1551.BEGIN ATOMICoutside those statements is still refused. Apply honors that allow. The check still throws OKM1551. A passing run also lists every-- okm-allowline in the directory. A weak reason is warning OKM2300. The lint codes are Lint codes.VACUUM,CREATE DATABASE,DROP DATABASE, andALTER SYSTEMare not executed. The success line names them. A concurrent index, reindex, or refresh runs withoutCONCURRENTLY.ALTER TYPE … ADD VALUEruns inside the transaction and rolls back with it. Extension replay and auuidv7()default are on migrate check. - Previous catalog. For migration N, catalog N−1 is the previous release. Every table, column, constraint, and type in N−1 must still exist in N with the same type, and nullability must not be tightened without a default. A gap is allowed only when the recomputed class is
contract. The class comes from the SQL steps' kinds, or from planning N−1 to N again when a step has no kind. A hand-edited-- class:line is not trusted. A gap in an expand migration is OKM1548. This is schema-level. It does not prove application behaviour. - Head. The last migration's catalog must equal the current schema. Otherwise the command fails with OKM1549 and the fix is to run
okm generate. - Lint. The whole history is linted. An error fails. An override with a reason is respected. Apply is unchanged: it still lints only migrations that are pending on the target.
lintFromis the first migration idokm migrate checklints. Files before that id are not linted. The id itself is linted. Apply does not readlintFrom.
What the empty replay does not prove
The scratch schema starts empty. The replay runs the migration files, not a copy of the selected database's rows. A step that fails only when rows are already stored is not caught: NOT NULL on a populated column, including SET NOT NULL and a not-null check, a unique index over duplicate values, a check constraint over existing rows that violate it, a foreign key validation over rows that do not match, or a cast of values already stored.
A lock timeout while a step waits is not a bad migration. The fix says to run okm migrate check again when that lock is free, and that the step does not need a change.
A passing run prints that limit on the line after ok N migrations:
This check replays empty tables. A step that only fails on real data is not caught: NOT NULL on a populated column, a unique index over duplicates, a check over existing rows, a foreign key validation, or a cast.The first line stays ok N migrations. Skipped statements and OKM1552 warnings follow the limit. The command checks the selected target's history in one scratch schema.
Empty-target apply
An empty target installs the head snapshot. It does not replay migration files.
A target is empty when it has no okm_meta, okm_history, okm_backfill, or okm_steps, and its namespace has no other table, view, sequence, enum, domain, or extension. okm migrate apply on that target plans the last catalog from an empty catalog, inserts reference rows, and writes one history row, provisioned@⟨migration id⟩. The catalog hash is the head hash. No expand, backfill, or contract step runs.
A target that already has history is tracked. Apply continues with files that are still pending. It does not install the snapshot again.
A target that is not empty and has no history is OKM1851. The fix is to provision an empty schema or an empty database, or to okm migrate baseline after okm pull and okm generate.
If the install stops after it has created objects and before it writes the history row, those objects stay. The next apply does not replay over them and does not delete them. It is OKM1851, names the object, and says to drop the schema or database and run again. Managed tables with no history row and no other objects are still empty, and apply provisions them.
Provision on an empty protected target is allowed without --allow-protected. A protected target that is not empty is still OKM1851.
Each migration has a sibling catalog file. That file is the catalog after the migration. The latest catalog is the head snapshot. Installing it plans that catalog from empty, so grants and roles are included, and a table created in that plan keeps the plain statements.
provision(target) from okmodel/migrate provisions one configured target. default is the database target. It uses the same emptiness rule and the same policy call as okm migrate apply.
Provision comparison
okm migrate check --provision installs the head snapshot into one scratch schema and replays the history into another. Each side is its own transaction and ends with ROLLBACK. Both schemas start with okm_check_ and are dropped at the end, including after a failure. Type names are normalised before the comparison. A difference is OKM1521 and names the object. The default command does not do this. A protected target is still refused, and --allow-protected does not apply. okm migrate check without --provision still replays the history. It does not provision.
Override
Put the line on the step, directly above its statements. The reason is the text after the colon.
-- okm-allow OKM1511: the table is empty and nothing reads it
drop table "public"."notes";An override silences only the code it names, on every statement of that step. A missing or empty reason, or a code no statement triggered, is OKM1510 and does not silence the finding. okm check and okm migrate check list every override. A weak reason is warning OKM2300.
Where lint runs
| Command | What it does with findings |
|---|---|
okm generate | Writes the migration file and prints findings. An error finding exits 1 after the write. A warning does not change the exit code. |
okm migrate plan | Prints the plan, then the findings. An error finding exits non-zero. |
okm check | Prints findings. An error finding is OKM1510. Type preferences run here. |
okm migrate apply | Lints migrations that still have a step missing from okm_history on the selected target. An unresolved error is OKM1510 before any DDL or data statement. |
okm migrate check | Lints the whole history. An error is OKM1510. defineConfig({ lintFrom }) skips files before that migration id. Apply does not read lintFrom. |
The linter does not connect to the database for the rule table below. A finding names the step, the code, why it fired, and the fix. error fails the command. warning prints and the command still succeeds.
Destructive, backward-incompatible, and data-dependent rules are errors. OKM1534–OKM1537 are errors when the statement is not the safe form. OKM1538 stays a warning when the type change rewrites the heap. There is no safe form for that rewrite in this version; the recipe is Type change. Type preferences are warnings and run on the schema in okm check, not on a migration file.
On a table that already exists, the planner writes the safe form these locking rules accept: CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY; ADD CONSTRAINT … NOT VALID then VALIDATE CONSTRAINT in its own step; SET NOT NULL after a validated CHECK (column IS NOT NULL); a unique or primary key via CREATE UNIQUE INDEX CONCURRENTLY then ADD CONSTRAINT … USING INDEX. A new table keeps the plain statements. The linter recognises the form from the SQL, so a hand-edited file is judged the same way.
Lint codes
| Code | Category | Severity | What it flags |
| --- | --- | --- | --- | --- |
| OKM1510 | refusal | error | Unresolved lint error, or an override with an empty reason or a code no statement in the step triggered |
| OKM1511 | destructive | error | Drop table |
| OKM1512 | destructive | error | Drop column |
| OKM1513 | destructive | error | Drop enum |
| OKM1514 | destructive | error | Drop domain |
| OKM1515 | destructive | error | Drop function |
| OKM1516 | destructive | error | Drop view |
| OKM1517 | destructive | error | Drop extension. A dependent still in the catalog is OKM1814 before this finding |
| OKM1518 | destructive | error | Drop materialized view |
| OKM1519 | backward-incompatible | error | Rename column |
| OKM1523 | backward-incompatible | error | Rename table |
| OKM1524 | backward-incompatible | error | Column type change, including ALTER COLUMN … TYPE and SET DATA TYPE |
| OKM1525 | backward-incompatible | error | New NOT NULL column with no default on an existing table |
| OKM1526 | backward-incompatible | error | Remove a default |
| OKM1527 | backward-incompatible | error | Shrink a character length |
| OKM1528 | data-dependent | error | New unique or primary-key constraint on an existing table |
| OKM1529 | data-dependent | error | New unique index on an existing table |
| OKM1531 | data-dependent | error | New check that validates existing rows |
| OKM1532 | data-dependent | error | New foreign key that validates existing rows |
| OKM1533 | data-dependent | error | Narrowing type change |
| OKM1534 | locking | error | Non-concurrent index create on an existing table |
| OKM1535 | locking | error | Add check without NOT VALID |
| OKM1536 | locking | error | Add foreign key without NOT VALID |
| OKM1537 | locking | error | SET NOT NULL that is not preceded by a validated CHECK (col IS NOT NULL) |
| OKM1538 | locking | warning | Type change that rewrites the heap. No safe form in this version. See Type change |
| OKM1539 | type-preference | warning | timestamp without time zone |
| OKM1540 | type-preference | warning | varchar(n) where text would do |
| OKM1542 | data-dependent | error | insert, update, delete, merge, truncate, or a with that writes, outside a backfill step. DO and CALL too: the body is not analysed |
| OKM1543 | type-preference | warning | serial or a nextval default |
| OKM1544 | type-preference | warning | json where jsonb is available |
| OKM1545 | type-preference | warning | Identity that is not generated always |
| OKM1550 | destructive | error | DISABLE ROW LEVEL SECURITY, NO FORCE ROW LEVEL SECURITY, DROP POLICY, CREATE or ALTER POLICY on a tenant table that is not the emitted predicate, BYPASSRLS on a role, or OWNER TO on a tenant table |
| OKM1551 | destructive | error | Transaction control inside a step (COMMIT, END, ABORT, ROLLBACK, BEGIN, START TRANSACTION, PREPARE TRANSACTION, or COMMIT PREPARED), including a DO body. A function or procedure body, including BEGIN ATOMIC, is not this. ROLLBACK TO is not this. Apply may honor -- okm-allow; okm migrate check still refuses it |
| OKM1552 | reprint | warning | Domain check or LANGUAGE sql function body whose stored text differs. okm migrate check reports it |
| OKM1557 | locking | error | A committed step (not a backfill) with no built-in probe and no -- probe: |
| OKM1558 | locking | error | -- probe: is empty, is not one read-only SELECT, has more than one statement, or sits on a transactional or backfill step |
Reference rows are not migration steps, so OKM1542 does not see them. See Required rows.
In a workflow
The job starts Postgres, then runs the check. The URL belongs to that service. Do not point this command at a protected database. The checkout and the Bun setup are pinned to the commits this repository's workflows use.
name: migrate
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- run: bun install --frozen-lockfile
- run: bunx okm migrate check
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgresokmodel.config.ts reads DATABASE_URL for that target, and the target is not protected.
The package copy is docs/migrate-check.md on main.
| Next | You use it for |
|---|---|
| Backfill | A data step that runs outside the check transaction. |
| Failures | What a failed step leaves behind. |
| migrate check | Extensions, uuidv7(), and the JSON fields. |
| GitHub Action | The composite action this repository publishes. |