Conversation
…eguards Core-Foundry#848 The migration runner declared a required down() on every migration but exposed no way to invoke it, so an incompatible schema change had no way back out. Add MigrationRunner.rollback(), which unwinds applied migrations newest first, each in its own transaction, validating every target before writing so a refusal cannot leave the database half-reverted. Reverts stamp migrations.reverted_at instead of deleting the row, preserving the audit trail, and re-applying a reverted migration revives its row. Migrations whose down() cannot reconstruct their data with up() are marked destructive and refused unless the caller opts in, so a routine rollback cannot silently drop production rows. check-migration-safety enforces that flag at review time by failing any migration whose down() uses DROP TABLE, DROP COLUMN or TRUNCATE without it. Fix three latent defects that made migrations unreliable: - sqlite3 only resolves when given a callback, so every await on a raw handle was a no-op. Migration DDL was fire-and-forget and could still be in flight when the transaction committed. Statements are now awaited through a promisified proxy. - db.serialize() invokes its callback synchronously and does not await an async one, so the runner resolved before work finished and callers could close the database mid-transaction. - 001 split its schema on ';', which cut CREATE TRIGGER ... BEGIN ... END bodies in half and left every trigger unparseable. The errors were swallowed by the two defects above. The script is now passed to exec(). Verified: 13 rollback tests, safety audit, scoped typecheck, prettier. Full suite matches the pre-change baseline exactly (136 pre-existing failures, unchanged) with 13 new tests passing.
|
@big6isaac Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
The listener's migration runner required every migration to export a
down()function, but exposed no way to invoke one. A schema change that turned out to be incompatible therefore had no way back out. This PR implements a real rollback path, guards destructive reverts so they cannot happen by accident, and documents the operational procedure.Related Issue
Closes #848
Changes
Rollback engine
listener/src/database/migration-system.ts—MigrationRunner.rollback()unwinds applied migrations newest first, each in its own transaction. Every target is validated before any write, so a refusal partway through a multi-step rollback cannot leave the database half-reverted.listener/src/database/migration-system.ts— reverts stamp a newmigrations.reverted_atcolumn instead of deleting the row, preserving the audit trail of what was applied and when it was reverted. The column is added defensively so databases that predate it keep working.listener/src/database/migration-system.ts— re-applying a reverted migration now upserts rather than inserts, becauseidis the primary key and the revert is recorded in place.getRollbackCandidates()reports what is currently in effect, newest first, with a reversible/destructive marker.Destructive-migration safeguards
Migrationinterface — new optionaldestructiveflag declaring thatdown()cannot reconstruct its data withup().DestructiveMigrationError—rollback()refuses a destructive migration unless the caller passesallowDestructive, so a routinenpm run migrate:rollbackcan never silently drop production rows.001-initial-schema.ts— flaggeddestructive: true; itsdown()drops all ten listener tables.002-query-performance-indexes.ts— flaggeddestructive: false; it only drops indexes, whichup()recreates without touching rows.CI gate
listener/src/scripts/check-migration-safety.ts— audits every migration and exits non-zero when one has nodown(), or when adown()usingDROP TABLE/DROP COLUMN/TRUNCATEis not flagged destructive. The second rule matters because an unflagged destructive migration is invisible to the runtime guard and would be reverted like any other..github/workflows/migration-safety.yml— runs the audit, a typecheck scoped to the migration surface, and the rollback tests.Operator tooling and docs
listener/src/scripts/rollback-db.ts— CLI with--status,--steps N, and--allow-destructive. Exits3on a destructive refusal so the guard is scriptable.listener/package.json— addsmigrate:rollbackandcheck-migration-safety.MIGRATION_ROLLBACK.md— rollback procedures, safeguard behaviour, an authoring guide for new migrations, and an operational runbook.Latent defects fixed along the way
Building the tests surfaced three bugs that made migrations unreliable, all of which had to be fixed for a rollback to be trustworthy:
listener/src/database/migration-system.ts—sqlite3only resolves when given a callback, soawait db.run(...)on a raw handle was a no-op. Migration DDL was fire-and-forget and could still be in flight when the surrounding transaction committed, so a migration could half-apply. Statements are now awaited through a promisified proxy.listener/src/database/migration-system.ts—db.serialize()invokes its callback synchronously and does not await an async one, so the runner resolved before work finished and callers could close the database mid-transaction (SQLITE_MISUSE: Database handle is closed).listener/src/migrations/001-initial-schema.ts—up()split its schema on;, which cutCREATE TRIGGER ... BEGIN ... ENDbodies in half and left every trigger unparseable. The resultingSQLITE_ERROR: incomplete inputwas swallowed by the two defects above and only logged. The script is now passed toexec(), which understands statement boundaries.Verification Results
Audit fails closed, confirmed by temporarily unflagging migration 001:
End-to-end run against a scratch database, as an operator would experience it:
No regressions: the full suite matches the pre-change baseline exactly, with 13 new tests passing.
The 136 pre-existing failures are unrelated to this change (for example
SyntaxError: Unexpected token '*'inrequest-id.ts) and are present onmain. A repo-widetsc --noEmitlikewise reports the same 32 pre-existing errors on both, so the workflow's typecheck is scoped to the migration surface rather than gating on files it does not touch.MIGRATION_ROLLBACK.mdcovers CLI usage, revert semantics, an authoring guide for new migrations, and an operational runbook for failed deploys and bad migrations.destructiveflag plus runtime refusal unlessallowDestructiveis passed (CLI--allow-destructive, exit code 3). Validated fail-closed: a refusal leaves the database untouched.check-migration-safetyadditionally fails the build on any destructivedown()missing the flag, so the safeguard cannot be forgotten..github/workflows/migration-safety.ymlruns the safety audit, a scoped typecheck, and 13 rollback tests covering reverse-order unwinding, schema restoration on re-apply, destructive refusal, revert audit trail, and atomicity when a revert fails. Scope is limited to the migration paths to keep the gate fast.Notes for reviewers
reverted_atis stamped in place becauseidis the primary key. This keeps an audit trail and means a reverted-then-reapplied migration revives its row instead of failing on a uniqueness collision. Worth a look, since the alternative (deleting the row) is simpler but loses history.tsc --noEmitwould fail on a workflow that never touches them. I did not fix those, as they are outside this issue.001trigger bug is pre-existing and unrelated to rollback. I fixed it because the rollback tests could not pass otherwise, and because a schema whose triggers never actually created is a serious latent problem in its own right. Happy to split it into its own PR if you would prefer to review it separately.package-lock.jsonis intentionally untouched.npm installregenerated it with 197 added and 164 removed transitive entries, which is out of scope here and a needless conflict surface.