<?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>Mongreldb-Kit on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/mongreldb-kit/</link><description>Recent content in Mongreldb-Kit 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/mongreldb-kit/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><item><title>The Schema DSL Versus Raw SQL</title><link>https://www.mongreldb.com/articles/2026/08/the-schema-dsl-versus-raw-sql/</link><pubDate>Fri, 28 Aug 2026 06:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/08/the-schema-dsl-versus-raw-sql/</guid><description>MongrelDB Kit&amp;#39;s typed schema DSL and its raw SQL surface are two front doors to the same engine rather than one compiling into the other, and knowing which door to use, and how to mix both inside a single migration, is most of the skill.</description><content:encoded><![CDATA[<p>Every typed database layer eventually grows a hole in the wall back to SQL, because the day always comes when you need a recursive CTE or a window function and the fluent builder simply does not have a verb for it, and the design question that actually matters is not whether the hole exists but whether the typed layer and the escape hatch are one system or two products glued together at the network boundary. I watched this go wrong for years in the PHP world, where the query builder in your framework and the <code>mysql_query</code> string you reached for under pressure were different planets with different quoting rules, different transaction scopes, and different ideas about what the schema even was, so a migration written half in the builder and half in raw strings was a coin flip. When we shaped MongrelDB Kit we wanted the typed DSL and raw SQL to be two front doors into the same engine, same catalog, same transaction machinery, and the honest way to describe the result is to admit what the DSL is not, because it is not a SQL generator.</p>
<h2 id="the-dsl-does-not-emit-sql">The DSL does not emit SQL</h2>
<p>The natural assumption, the one I made before reading the source, is that a declaration like this gets compiled down into <code>CREATE TABLE</code> text and that the query builder assembles <code>SELECT</code> strings under the hood:</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">export</span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">orders</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">table</span>(<span style="color:#e6db74">&#39;orders&#39;</span>, {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">columns</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">int</span>(<span style="color:#e6db74">&#39;id&#39;</span>, { <span style="color:#a6e22e">primaryKey</span>: <span style="color:#66d9ef">true</span>, <span style="color:#66d9ef">default</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">sequenceDefault</span>(<span style="color:#e6db74">&#39;orders_id_seq&#39;</span>) }),
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">int</span>(<span style="color:#e6db74">&#39;customer_id&#39;</span>, { <span style="color:#a6e22e">nullable</span>: <span style="color:#66d9ef">false</span> }),
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">text</span>(<span style="color:#e6db74">&#39;status&#39;</span>, {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">enumValues</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;pending&#39;</span>, <span style="color:#e6db74">&#39;paid&#39;</span>, <span style="color:#e6db74">&#39;shipped&#39;</span>, <span style="color:#e6db74">&#39;cancelled&#39;</span>],
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">default</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">staticDefault</span>(<span style="color:#e6db74">&#39;pending&#39;</span>),
</span></span><span style="display:flex;"><span>    }),
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">timestamp</span>(<span style="color:#e6db74">&#39;placed_at&#39;</span>, { <span style="color:#66d9ef">default</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">nowDefault</span>() }),
</span></span><span style="display:flex;"><span>  ],
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">primaryKey</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;id&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">indexes</span><span style="color:#f92672">:</span> [<span style="color:#a6e22e">index</span>([<span style="color:#e6db74">&#39;customer_id&#39;</span>])],
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">foreignKeys</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">foreignKey</span>([<span style="color:#e6db74">&#39;customer_id&#39;</span>], { <span style="color:#a6e22e">table</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;customers&#39;</span>, <span style="color:#a6e22e">columns</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;id&#39;</span>] }, { <span style="color:#a6e22e">onDelete</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;cascade&#39;</span> }),
</span></span><span style="display:flex;"><span>  ],
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>That assumption is wrong, and it is wrong in an interesting way. The <code>table()</code> call builds a <code>TableSpec</code>, a typed descriptor that Kit validates at construction time (duplicate column names, a primary key referencing a column that does not exist, an index over nothing), persists into the engine&rsquo;s schema catalog with stable table and column ids, and then uses directly: reads and writes through <code>selectFrom</code> / <code>insertInto</code> push the predicates they can down into the storage engine&rsquo;s native indexes and compute the rest, joins and grouping and ordering overflow, in memory, with no SQL text produced at any point. Even at the wire boundary the two surfaces stay separate; the remote client speaks typed endpoints, <code>POST /kit/query</code> for a native typed query and <code>POST /kit/txn</code> for an atomic write batch, while SQL statements go to <code>POST /sql</code>, so &ldquo;the DSL compiles to the same wire format as raw SQL&rdquo; is not the architecture, it is the thing we deliberately did not build. The payoff for skipping the SQL round trip is that one set of declarations drives type inference, validation, constraint enforcement, and migrations all at once, so renaming a column is a compile error in your TypeScript rather than a runtime surprise three deploys later, and the <code>int('id')</code> up there coming back as a <code>bigint</code>, with the compiler refusing <code>row.id === 1</code>, is the kind of pedantry that has saved me real money.</p>
<h2 id="where-raw-sql-is-the-right-door">Where raw SQL is the right door</h2>
<p>The DSL is honest about its ceiling, and the ceiling is exactly where the SQL frontend starts. Recursive CTEs, window functions, <code>CREATE TABLE AS SELECT</code>, materialized views, and multi-statement execution live on the raw surface, and the embedded TypeScript API is two calls wide:</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">const</span> <span style="color:#a6e22e">result</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">db</span>.<span style="color:#a6e22e">sql</span>(<span style="color:#e6db74">&#39;SELECT count(*) AS n FROM users&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">rows</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">db</span>.<span style="color:#a6e22e">sqlRows</span>(<span style="color:#e6db74">&#39;SELECT id, email FROM users ORDER BY id&#39;</span>);
</span></span></code></pre></div><p><code>db.sql(...)</code> hands back an Apache Arrow table and <code>db.sqlRows(...)</code> decodes it to plain objects, while the remote client exposes the same names synchronously because the native binding performs the HTTP call internally, so the shape of your code does not change when you move from embedded to daemon. On top of that surface Kit ships small expression helpers for the extended function catalog, things like <code>percentileCont(events.latency_ms, 0.95)</code> and <code>jsonExtract(events.payload, '$.city')</code>, each returning a <code>{ sql: string }</code> fragment you splice into a statement yourself, plus <code>mongreldbFtsRank(text, query)</code> for BM25-style relevance ordering, and virtual tables follow the same pattern, with <code>virtualTable(...)</code> describing a module-backed table that generates its own <code>CREATE VIRTUAL TABLE ... USING ...</code> statement. The rule of thumb I use is that the builder owns the hot paths, the typed CRUD and the filtered scans that run a thousand times a minute, and SQL owns the analytical long tail, the reports and one-off probes where nobody wants type inference anyway; the builder is not an ORM trying to swallow SQL, it is a typed fast lane with a clearly marked exit.</p>
<h2 id="mixing-both-inside-one-migration">Mixing both inside one migration</h2>
<p>The place the two surfaces genuinely meet is the migration runner, and this is where the &ldquo;one system, two doors&rdquo; claim either holds or falls apart. A TypeScript migration&rsquo;s <code>up(ctx)</code> is imperative, and the context object carries both doors: helpers like <code>ctx.ensureTable(table)</code> and <code>ctx.addColumn(...)</code> work from the typed <code>TableSpec</code>, and <code>ctx.sql(sql)</code> runs raw SQL inside the same migration transaction, so a single versioned change can create a table from its spec and then install a view over it without leaving the runner:</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">7</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;orders_reporting_view&#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">ctx</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">ctx</span>.<span style="color:#a6e22e">ensureTable</span>(<span style="color:#a6e22e">orders</span>);
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">ctx</span>.<span style="color:#a6e22e">sql</span>(<span style="color:#e6db74">`
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        CREATE VIEW paid_orders AS
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        SELECT id, customer_id, placed_at FROM orders WHERE status = &#39;paid&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      `</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>There are two catches worth knowing before you reach for this, and both are load-bearing rather than incidental. First, <code>ctx.sql()</code> is available in async migrations only and throws in <code>migrateSync</code>, because the sync runner has no suspension point for the SQL frontend, so the moment a migration needs raw SQL you commit to the async <code>migrate(db, schema, migrations)</code> helper for the whole run. Second, the optional <code>ops</code> array on a migration is metadata in TypeScript, not an execution plan; the runner folds it into the content-addressed checksum so that an after-the-fact edit to an applied migration is caught as drift, but your <code>up()</code> is what actually does the work, which is exactly the split you want, since the thing that runs and the thing that is audited are allowed to be expressed differently. Either way both doors land in the same <code>__kit_schema_migrations</code> history under the same advisory lock, which is the entire point: there is no shadow schema state that only the SQL side knows about.</p>
<h2 id="the-tradeoff-you-actually-sign">The tradeoff you actually sign</h2>
<p>The seam I watch in production is the remote-mode authority boundary, because it is the one place the two doors are not symmetric. The daemon enforces engine-level constraints, unique, foreign-key actions, checks, atomically and server-side for both surfaces, but the Kit-specific field validations, defaults, enums, min/max bounds, regex patterns, live in the client, so a remote caller who bypasses the typed layer and hand-writes <code>INSERT</code> statements against <code>/sql</code> is opting out of that richer validation and accepting the engine&rsquo;s floor as the whole contract, and that is a deliberate boundary, not an accident we plan to paper over. I will take that trade every time, and the comparison I keep reaching for is the old LAMP habit of treating the query builder as a toy and raw SQL as the real thing, which gave you one honest surface and one decorative one; the modern equivalent of doing it right is two surfaces that share a catalog, a transaction, and a migration history, where choosing between them is a per-query judgment about types and ergonomics rather than a per-project bet about which subsystem will still be maintained in three years.</p>
]]></content:encoded></item><item><title>One Schema, Three SDKs, One Rust Core</title><link>https://www.mongreldb.com/articles/2026/08/one-schema-three-sdks-one-rust-core/</link><pubDate>Wed, 26 Aug 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/08/one-schema-three-sdks-one-rust-core/</guid><description>MongrelDB Kit ships TypeScript, Rust, and Python SDKs over a single Rust core, and a shared JSON conformance suite proves all three produce identical results and identical error codes on top of one shared query compiler.</description><content:encoded><![CDATA[<p>Every project that ships the same SDK in three languages eventually ships three different SDKs, because the implementations drift the moment two humans edit two files on two different days, and the drift never announces itself; it shows up as a Python job that silently accepts a row the TypeScript service would have rejected, or a filter that returns rows in a different order depending on which side of the stack asked. I lived through this in the jQuery years with server frameworks that promised identical behavior across their PHP and Ruby ports, and the promise was always true for about one release, after which the changelog diverged and the docs quietly stopped mentioning the smaller port. When we built MongrelDB Kit, the persistence layer over the MongrelDB engine, the design constraint was that &ldquo;same schema, three SDKs&rdquo; had to be a mechanical fact rather than a marketing sentence, and the way you make it mechanical is you give the three languages one core and one test corpus, then let CI embarrass whichever SDK drifts first.</p>
<h2 id="one-core-three-thin-surfaces">One core, three thin surfaces</h2>
<p>The first decision was to refuse to reimplement anything per language. The Rust crate <code>mongreldb-kit</code> is the actual implementation: schema catalog, query builder, migrations, constraint enforcement, all of it. The Python package is a PyO3 binding built with maturin over that same crate, and the TypeScript package talks to the same compiled Rust core through native Node bindings, so the three &ldquo;SDKs&rdquo; are really three grammars over one semantics. A filter you build in TypeScript compiles to the same internal plan as the identical filter in Python, because there is only one compiler, and the schema catalog lives in the database itself with stable table and column ids, so all three languages are reading and writing the same descriptors rather than their own interpretation of them.</p>
<p>That architecture is what makes the interesting question askable at all, because once the semantics live in one place, &ldquo;do all three SDKs behave the same&rdquo; stops being a philosophy and becomes a diffable output.</p>
<h2 id="the-conformance-suite-is-the-contract">The conformance suite is the contract</h2>
<p>The answer to that question lives in <code>tests/conformance/</code>, which is a set of language-neutral JSON fixtures plus three small runners, one per SDK, that execute the same scenarios and compare against the same expected output. The fixtures are deliberately boring to read, which is the point; here is a slice of <code>queries.json</code>:</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-json" data-lang="json"><span style="display:flex;"><span>[
</span></span><span style="display:flex;"><span>  { <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;query_all_users&#34;</span>, <span style="color:#f92672">&#34;table&#34;</span>: <span style="color:#e6db74">&#34;users&#34;</span>, <span style="color:#f92672">&#34;order&#34;</span>: <span style="color:#e6db74">&#34;+id&#34;</span> },
</span></span><span style="display:flex;"><span>  { <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;query_users_by_role&#34;</span>, <span style="color:#f92672">&#34;table&#34;</span>: <span style="color:#e6db74">&#34;users&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;filter&#34;</span>: { <span style="color:#f92672">&#34;role&#34;</span>: <span style="color:#e6db74">&#34;admin&#34;</span> } },
</span></span><span style="display:flex;"><span>  { <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;query_posts_by_user&#34;</span>, <span style="color:#f92672">&#34;table&#34;</span>: <span style="color:#e6db74">&#34;posts&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;filter&#34;</span>: { <span style="color:#f92672">&#34;user_id&#34;</span>: { <span style="color:#f92672">&#34;eq&#34;</span>: <span style="color:#ae81ff">1</span> } } },
</span></span><span style="display:flex;"><span>  { <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;query_users_limit_offset&#34;</span>, <span style="color:#f92672">&#34;table&#34;</span>: <span style="color:#e6db74">&#34;users&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;order&#34;</span>: <span style="color:#e6db74">&#34;+id&#34;</span>, <span style="color:#f92672">&#34;limit&#34;</span>: <span style="color:#ae81ff">1</span>, <span style="color:#f92672">&#34;offset&#34;</span>: <span style="color:#ae81ff">0</span> }
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p>Every scenario is named, and for every name there is an expected result under <code>fixtures/expected/</code>, so a runner&rsquo;s job is just: load the fixture, apply it through the public SDK surface, serialize what came back, compare. The Rust side is a workspace member called <code>conformance-runner</code> that you can also run as a standalone binary, the TypeScript side is a vitest file that picks the suite up automatically in <code>npm test</code>, and the Python side is a pytest module that loads the same JSON files from the same directory. Nobody wrote the same test three times, which matters, because the moment you write the same test three times you have written three different tests and you are back to drift with extra steps.</p>
<h2 id="the-fixtures-test-the-failures-not-just-the-happy-path">The fixtures test the failures, not just the happy path</h2>
<p>The part I care most about is that the corpus spends as much energy on what must not work as on what must. <code>schema.json</code> defines <code>users</code>, <code>posts</code>, and <code>comments</code> with unique constraints, check constraints, and foreign-key actions across cascade, set-null, and restrict, and then <code>inserts.json</code> and <code>deletes.json</code> include rows that are supposed to fail, with the expected error recorded by code rather than by message text. The Python runner, for instance, maps its exception types onto the shared vocabulary and asserts <code>DUPLICATE</code> where the Rust runner asserts <code>DuplicateError</code>; what is being tested is that all three languages reject the same row for the same reason, not that they phrase it identically. There are fixtures for aggregates, CTEs, joins, learned-range indexes, migration failure handling, byte-prefix filters, and encrypted tables, and each one lands in all three runners at once, so a new engine behavior is not &ldquo;supported in the SDKs&rdquo; until the fixture exists and all three suites pass it in the same CI run.</p>
<p>That is the real product feature hiding under the word &ldquo;conformance&rdquo;: when a team mixes the TypeScript service, a Python analytics job, and a Rust batch worker against the same database, the behavioral differences between those processes are held at zero by the suite rather than by hope; the bindings still own their own serialization and error mapping, which is exactly what the fixtures exist to police, and when a bug report says &ldquo;the Python client let this through,&rdquo; the first thing CI tells you is whether that is even possible.</p>
<h2 id="what-this-does-not-buy-you">What this does not buy you</h2>
<p>Honesty about the seam: conformance proves the SDKs agree with each other and with the fixture corpus, and it does not prove the corpus is complete, so the failure mode moves from &ldquo;the SDKs drifted&rdquo; to &ldquo;the fixtures have a blind spot,&rdquo; which is a better failure mode because it has exactly one place to fix. It also costs something; every new engine feature owes three green runners before it ships, which slows the merge queue compared to a single-language library, and the error-code normalization layer (mapping Python exception types onto the shared vocabulary) is the kind of unglamorous plumbing that has to be maintained forever. I still think it is the right trade, and the comparison I keep coming back to is the old LAMP-era habit of testing only the primary driver and treating the other bindings as community goodwill: that worked until it didn&rsquo;t, usually on a Friday, and the modern equivalent of doing it right is not more discipline in three repos, it is one corpus that all three repos are forced to agree with.</p>
]]></content:encoded></item></channel></rss>