Post-deploy verification
migrate --force exiting 0 means every migration ran. It does not mean every object those
migrations created is usable.
A CREATE INDEX CONCURRENTLY that failed leaves an INVALID index behind and does not fail the
migration. A constraint added NOT VALID whose VALIDATE never followed is a promise the database
is not keeping. An expand/contract rename that stopped halfway leaves users_old sitting there
forever. All three are silent in migrate's own output, all three survive indefinitely, and none of
them is visible to anything else in your pipeline.
php artisan sqlens:postdeploy
That is the whole command. It reads catalogs, it takes no lock, it writes nothing, and it ends.
What it checks
| Check | Engine | Answers |
|---|---|---|
DEPLOY.LEGACY.INVALID_INDEX | PostgreSQL | an index the catalog marks indisvalid = false — a concurrent build that failed |
DEPLOY.LEGACY.INVALID_INDEX | MySQL | not applicable, out loud. InnoDB has no invalid-index state, and silence would read as "nothing found" |
DEPLOY.LEGACY.CONSTRAINT_NOT_VALIDATED | PostgreSQL | a constraint added NOT VALID whose VALIDATE never ran |
DEPLOY.LEGACY.CONSTRAINT_NOT_ENFORCED | MySQL | a CHECK constraint with ENFORCED = NO — in the catalog, in SHOW CREATE TABLE, and admitting every row it claims to refuse |
DEPLOY.LEGACY.ORPHAN_TRANSITION_OBJECT | PostgreSQL | an object whose NAME says an unfinished migration left it |
DEPLOY.LEGACY.OSC_ARTIFACT | MySQL | what gh-ost, pt-online-schema-change or a copy rebuild left behind |
DEPLOY.RUN.TIME_BUDGET_EXCEEDED | both | the run's own cost, when it broke the budget you set |
Each links to its own page from the finding itself, so you never have to come back here to find out what one means.
Two of them are undetermined by design, and that is worth understanding before you read your
first report: a name is not evidence, and an online-schema-change run still in flight looks exactly
like one that died. See understanding undetermined, and
--expect-shadow below for the second reading that
settles them.
What it is NOT
It is not monitoring. Not a watcher, not a daemon, not an APM replacement. There is no --watch,
no scheduler entry and no history — and that is a property of the code rather than a promise in this
paragraph: PostdeployVerifier has no loop and keeps nothing between calls.
If you want to know how your application is behaving over time — queries getting slower, errors appearing, queues backing up — that is Laravel Pulse, Sentry, or whichever APM you already run. They watch a running system. This reads a schema, once, at the moment a deploy finished, and then it is gone.
The distinction matters because the two are easy to conflate and impossible to substitute. An APM
will never tell you an index is INVALID; this will never tell you a query got slow.
It is not a gate. The deploy has already happened. A non-zero exit here is a report about work
already done, not a refusal to start anything — the gate is sqlens:predeploy,
and it runs before the migration.
Exit codes
The same four codes every SQLens command uses, so a pipeline never has to learn a second contract:
| Code | Meaning here |
|---|---|
0 | every check ran and found nothing |
1 | something was found, or the run broke its time budget |
2 | misconfiguration — no connection, an unknown --format, an unreadable exclude file. Nothing was learned about the database |
3 | a check could not answer, and --allow-undetermined was not passed |
Code 3 is the one worth a decision. A check that could not run has not established that anything
is fine, so it is reported rather than swallowed — and whether that should stop your pipeline is
your call. --allow-undetermined says "record it and carry on"; when it opens, the report says so
in its own field, so a waived green and an earned one are never the same document.
Proving the schema matches your migrations
Everything above searches the database for wreckage. The opposite problem — something in the
database that no migration describes at all — is invisible to a catalog reading, because the catalog
holds it and it looks perfectly healthy. A hotfix applied straight to production is exactly that
shape, and the next migrate:fresh on a rebuilt environment silently loses it.
php artisan sqlens:postdeploy --expect-shadow
This replays your migrations into a throwaway shadow database and compares the result against the
live schema. It is the same comparison sqlens:drift makes, through the same code, so the
two commands cannot disagree — differences arrive as ordinary findings under DEPLOY.DRIFT.*, and
your sqlens-drift-excludes.json is honored here too.
It also settles the two undetermined checks: when the comparison confirms that no migration
describes an object whose name says leftover, the two readings agree from different directions and
the finding is raised to fail.
It is off by default, because building the expectation CREATES a database and therefore falls under the production guard. The plain aftercare stays guard-free and cheap enough to run every time.
And a run without it says so — on stderr and in the report's run.expectation block. A report
holding no drift findings would otherwise say the schema matches and nobody looked with exactly
the same silence.
Privileges
Least privilege is a promise this package keeps rather than advertises, so it is worth being exact about what each half needs.
On the database you deployed to — reading only.
CONNECTto the database andUSAGEon the schemas you want read.SELECTon the system catalogs (PostgreSQL:pg_catalog; MySQL:information_schema). On PostgreSQL that is public by default; on MySQLinformation_schemais filtered by privilege without a word, so an under-privileged role sees a shorter list rather than an error. That is why a failed read here is reported asundeterminedrather than as "nothing found".- No
SELECTon your tables. Row contents are never read and never appear in a finding. - No write privilege of any kind. Not
INSERT, notCREATE, notALTER. The debt ledger is a file in your repository, not a table.
On the shadow host — only with --expect-shadow. That half creates a database and drops it
again, so it needs CREATEDB (PostgreSQL) or CREATE/DROP on a database-name prefix it owns
(MySQL). Point it at a scratch server rather than granting that on production: the guard will refuse
a production connection anyway, and the guard is not the reason to keep it separate.
The full list, with the four things SQLens never asks for, is on deploy gate privileges.
Where it goes in a deploy
Immediately after migrate --force, in the same chain — the
deploy recipe is the runnable version with the shell options that make it real.
sqlens:drift does not belong in that chain. Its expectation side replays your whole migration
history, which is dominated by how much history you have rather than by anything this package does;
it belongs in a CI or cron-adjacent run outside the deploy window. If you want the comparison inside
a deploy, that is --expect-shadow above.
Its own cost is on the record
The run holds itself to sqlens.deploy.postdeploy.budget_ms — five seconds by default,
milliseconds like every other budget here. Breaking it produces
DEPLOY.RUN.TIME_BUDGET_EXCEEDED with the measured
duration and the per-check split, and the run still finishes.
That is not vanity. A step that visibly delays a deploy gets configured away in the first sprint, and a step nobody runs has helped nobody — so the number is held by a test rather than by an intention.