<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Migrations on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/migrations/</link><description>Recent content in Migrations on MongrelDB</description><image><title>MongrelDB</title><url>https://www.mongreldb.com/assets/og-mongreldb.png</url><link>https://www.mongreldb.com/assets/og-mongreldb.png</link></image><generator>Hugo</generator><language>en-US</language><lastBuildDate>Wed, 02 Sep 2026 08:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/migrations/index.xml" rel="self" type="application/rss+xml"/><item><title>Content-Addressed Migration Checksums</title><link>https://www.mongreldb.com/articles/2026/09/content-addressed-migration-checksums/</link><pubDate>Wed, 02 Sep 2026 08:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/09/content-addressed-migration-checksums/</guid><description>MongrelDB Kit stamps every applied migration with a content-aware SHA-256 checksum over its recorded intent and re-verifies it on every run, so editing a historical migration after it shipped fails loudly at the next deploy instead of silently re-shaping production.</description><content:encoded><![CDATA[<p>The most dangerous migration is not the one that fails; it is the one somebody edited after it already ran, because every migration framework worth using records applied versions in a table and then refuses to touch them again, which means a quiet fix-up commit to <code>003_add_sku.sql</code> changes nothing on any environment that already applied it and changes everything on the one environment that has not, and you find out about the divergence three months later when a fresh staging build behaves differently from production and nobody can say which one is right. I have watched this exact movie in the Rails world, where <code>schema_migrations</code> stores a bare version number and the file contents are free to rot, and in the Flyway world, which at least had the decency to checksum the file and complain, so when we built the migration runner for MongrelDB Kit the checksum was never an optional extra; it is the load-bearing piece, and the interesting design decisions are all in what the checksum covers, what it deliberately does not, and when it gets verified.</p>
<h2 id="what-the-checksum-actually-covers">What the checksum actually covers</h2>
<p>A Kit migration is a small object with a <code>version</code>, a <code>name</code>, an optional declarative <code>ops</code> list, and an imperative <code>up()</code> callback that does the work:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">await</span> <span style="color:#a6e22e">migrate</span>(<span style="color:#a6e22e">db</span>, <span style="color:#a6e22e">schema</span>, [
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">version</span>: <span style="color:#66d9ef">5</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;tighten_products&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ops</span><span style="color:#f92672">:</span> [{ <span style="color:#a6e22e">kind</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;addUnique&#39;</span>, <span style="color:#a6e22e">table</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;products&#39;</span>, <span style="color:#a6e22e">constraint</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;products_sku_uq&#39;</span> }],
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">async</span> <span style="color:#a6e22e">up</span>({ <span style="color:#a6e22e">kit</span> }) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">addUnique</span>(<span style="color:#a6e22e">kit</span>, <span style="color:#e6db74">&#39;products&#39;</span>, <span style="color:#66d9ef">unique</span>([<span style="color:#e6db74">&#39;sku&#39;</span>], { <span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;products_sku_uq&#39;</span> }));
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>]);
</span></span></code></pre></div><p>The checksum is a SHA-256 over one canonical serialization of the version, the name, and the ordered op list, with the key order fixed and standard JSON string escaping, so there is exactly one byte string that represents a given logical migration:</p>
<pre tabindex="0"><code>sha256(&#39;{&#34;version&#34;:&lt;n&gt;,&#34;name&#34;:&lt;json&gt;,&#34;ops&#34;:[&lt;op&gt;,...]}&#39;)
</code></pre><p>The canonical form matters more than the hash algorithm, because the same logical migration has to produce the identical checksum in TypeScript, in Rust, and in Python, and any serialization that leaves wiggle room, a space here, a reordered key there, breaks that property the moment two languages disagree about whitespace. There is a small two-layer wrinkle worth naming, since the discriminator in your TypeScript <code>ops</code> is <code>kind</code> while the canonical serialization calls it <code>op</code>, a renaming step in the canonicalizer (<code>kind: 'addUnique'</code> serializes as <code>{&quot;op&quot;:&quot;add_unique&quot;,...}</code>) that maps camelCase input onto the snake_case wire vocabulary every language shares, so the object you write and the bytes that get hashed are deliberately not identical. The conformance tests pin two known vectors so a drift in any language&rsquo;s canonicalizer fails a build instead of failing a deploy: <code>{&quot;version&quot;:1,&quot;name&quot;:&quot;init&quot;,&quot;ops&quot;:[{&quot;op&quot;:&quot;create_table&quot;,&quot;name&quot;:&quot;users&quot;}]}</code> hashes to <code>fe2f521793591207bd4d8645c2631e4b7ce43e30fe7ea5691a2846c74ea71cc3</code>, and the empty form <code>{&quot;version&quot;:1,&quot;name&quot;:&quot;init&quot;,&quot;ops&quot;:[]}</code> hashes to <code>6408373a4372a2c49859db2a4548ea43308e5ba7dd3609998ca376606cf09757</code>, and both are asserted byte-for-byte on both sides of the language boundary. When <code>ops</code> is omitted, as it often is in TypeScript where the list is metadata rather than an execution plan, the checksum covers the version and name against an empty op list, so even the smallest possible migration is content-addressed.</p>
<h2 id="when-the-checksum-gets-verified">When the checksum gets verified</h2>
<p>The runner records each applied migration in the internal <code>__kit_schema_migrations</code> table, one row carrying the version, the name, the checksum, the applied timestamp, the kit version that ran it, and a status, and that record is written in the same transaction discipline as everything else the kit does. The part that earns the word &ldquo;content-addressed&rdquo; is what happens on every subsequent run: before computing the pending set, the runner recomputes the checksum of every supplied migration that claims to correspond to an already-applied record and compares it, and the name, against what is stored, and any mismatch, or any applied version that has vanished from the supplied list entirely, raises a <code>KitSchemaDriftError</code> and stops the run:</p>
<pre tabindex="0"><code>migration 5 (tighten_products) checksum mismatch: stored a1b2..., expected c3d4...
</code></pre><p>That ordering is the whole point, because drift detection happens before any new migration is applied, so a tampered history aborts the deploy while the database is still untouched rather than halfway through a run of new DDL on top of a history nobody trusts. The pending set itself is computed from a high-water mark, meaning only versions above the maximum already applied are eligible to run, which is what makes re-running the runner a no-op and what makes the classic renumbering attack, slipping a new file in as <code>003b</code> and hoping it replays, a non-event: a renumbered migration either sits below the high-water mark and never runs, or it collides with an applied record whose checksum it cannot reproduce, and either way the runner says so. Records left in <code>failed</code> status are deliberately skipped by the drift check, and the high-water mark counts only versions that reached <code>applied</code>, which means a failed migration sits below the watermark and blocks itself and everything after it until you repair or remove the record; that is the intended posture, because a failed run leaves the history genuinely ambiguous and the runner refuses to guess its way forward.</p>
<h2 id="the-honest-tradeoff">The honest tradeoff</h2>
<p>The cost of this design is that the checksum is only as faithful as the <code>ops</code> you declare, and this is the seam I would point at before recommending it to anyone: in the TypeScript runner the <code>ops</code> array never drives the work, your <code>up()</code> does, so if you list an <code>addUnique</code> op and then quietly do something extra inside the callback, the checksum certifies the list and not the deed, and the discipline the system actually demands is that you keep the two in sync so the audited description stays content-aware. I consider that a fair price, and the comparison that settles it for me is the old PHP habit of a <code>migrations</code> folder full of numbered SQL files that everyone knew better than to edit but everyone edited anyway, where the only enforcement was social and the social contract lost to the first hotfix under deadline; the modern equivalent of doing it right is a checksum computed from a canonical form, stored next to the version, and re-verified on every run before anything else is allowed to happen, so the boring discipline that used to live in a code-review comment now lives in the runner, and the runner does not get tired on Fridays.</p>
]]></content:encoded></item></channel></rss>