<?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>Schema on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/schema/</link><description>Recent content in Schema 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/schema/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>Schema Management from PHP</title><link>https://www.mongreldb.com/articles/2026/08/schema-management-from-php/</link><pubDate>Fri, 21 Aug 2026 08:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/08/schema-management-from-php/</guid><description>The MongrelDB PHP client treats schema as typed data instead of SQL strings, so the same call that creates a table can be paired with a catalog read that proves the server stored what you think it stored.</description><content:encoded><![CDATA[<p>Every PHP codebase that talks to a database long enough develops the same quiet bug: the migration that created the table says one thing, the ORM&rsquo;s annotated entity says another, and the actual catalog on the server says a third, and nobody notices until a nullable column meets a not-null assumption in production at 2 AM. Raw DDL over a <code>sql()</code> call makes this worse rather than better, because a <code>CREATE TABLE</code> string is fire-and-forget; you can send it, but you cannot parse it back, diff it, or assert against it without writing a SQL parser of your own, which is a project nobody finishes. The MongrelDB PHP client takes the other route: schema is typed data in both directions, so the array you pass to <code>createTable()</code> has the same shape as the descriptor you read back from <code>schemaFor()</code>, and the gap between &ldquo;what I deployed&rdquo; and &ldquo;what the server has&rdquo; becomes a comparison you can run in a test instead of a hope.</p>
<h2 id="creating-a-table-as-data-not-as-a-string">Creating a table as data, not as a string</h2>
<p><code>createTable()</code> takes a name, a list of column definitions, and optional constraint and index lists, and it returns the table ID the engine assigned; each column is a plain PHP array with an <code>id</code>, a <code>name</code>, a type under <code>ty</code>, and the flags you would expect, so the definition reads like a config file rather than a dialect you have to quote and escape:</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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\Database</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$db <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Database</span>(<span style="color:#e6db74">&#39;http://127.0.0.1:8453&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$tableId <span style="color:#f92672">=</span> $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createTable</span>(<span style="color:#e6db74">&#39;orders&#39;</span>, [
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">1</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;id&#39;</span>,     <span style="color:#e6db74">&#39;ty&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;int64&#39;</span>,   <span style="color:#e6db74">&#39;primary_key&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">true</span>,  <span style="color:#e6db74">&#39;nullable&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">false</span>],
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">2</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;status&#39;</span>, <span style="color:#e6db74">&#39;ty&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;enum&#39;</span>,    <span style="color:#e6db74">&#39;primary_key&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">false</span>, <span style="color:#e6db74">&#39;nullable&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;enum_variants&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;new&#39;</span>, <span style="color:#e6db74">&#39;paid&#39;</span>, <span style="color:#e6db74">&#39;cancelled&#39;</span>]],
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">3</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;amount&#39;</span>, <span style="color:#e6db74">&#39;ty&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;float64&#39;</span>, <span style="color:#e6db74">&#39;nullable&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">false</span>],
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">4</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;tag&#39;</span>,    <span style="color:#e6db74">&#39;ty&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;varchar&#39;</span>, <span style="color:#e6db74">&#39;default_value&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;standard&#39;</span>],
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">5</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;ref&#39;</span>,    <span style="color:#e6db74">&#39;ty&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;varchar&#39;</span>, <span style="color:#e6db74">&#39;default_expr&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;uuid&#39;</span>],
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">6</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;placed&#39;</span>, <span style="color:#e6db74">&#39;ty&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;timestamp&#39;</span>, <span style="color:#e6db74">&#39;default_expr&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;now&#39;</span>],
</span></span><span style="display:flex;"><span>]);
</span></span></code></pre></div><p>The distinction between <code>default_value</code> and <code>default_expr</code> is worth a beat, because it is the kind of thing a DDL string blurs: <code>default_value</code> is a literal the engine stores as-is, so the <code>tag</code> column above really does get the four characters <code>standard</code> on every insert that omits it, while <code>default_expr</code> names an expression the engine evaluates at insert time, which is how <code>uuid</code> and <code>now</code> end up generating a fresh identifier and a server-side timestamp instead of a string that merely says &ldquo;uuid&rdquo;. The client is also careful about the wire shape it emits, and this is the unglamorous detail that saves you later: optional keys you did not set are simply absent from the JSON body, so a column without an enum does not send an empty <code>enum_variants</code> array for the server to misread, and the conformance tests in the repo assert exactly that by capturing the literal POST to <code>/kit/create_table</code>.</p>
<h2 id="indexes-and-constraints-ride-in-the-same-call">Indexes and constraints ride in the same call</h2>
<p>Because the schema is a payload rather than a statement, the indexes and check constraints are just more arrays on the same request, which means a table and its access paths are created atomically instead of in three migrations you hope ran in order:</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-php" data-lang="php"><span style="display:flex;"><span>$db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createTable</span>(<span style="color:#e6db74">&#39;docs&#39;</span>, [
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">1</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;id&#39;</span>,     <span style="color:#e6db74">&#39;ty&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;int64&#39;</span>,   <span style="color:#e6db74">&#39;primary_key&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">true</span>],
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">2</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;status&#39;</span>, <span style="color:#e6db74">&#39;ty&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;varchar&#39;</span>, <span style="color:#e6db74">&#39;nullable&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">false</span>],
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">3</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;body&#39;</span>,   <span style="color:#e6db74">&#39;ty&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;varchar&#39;</span>, <span style="color:#e6db74">&#39;nullable&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">false</span>],
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">4</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;hits&#39;</span>,   <span style="color:#e6db74">&#39;ty&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;int64&#39;</span>,   <span style="color:#e6db74">&#39;default_value&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">0</span>],
</span></span><span style="display:flex;"><span>], [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;checks&#39;</span> <span style="color:#f92672">=&gt;</span> [[
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;id&#39;</span>   <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;ck_hits_range&#39;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;expr&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;And&#39;</span> <span style="color:#f92672">=&gt;</span> [
</span></span><span style="display:flex;"><span>            [<span style="color:#e6db74">&#39;Ge&#39;</span> <span style="color:#f92672">=&gt;</span> [[<span style="color:#e6db74">&#39;Col&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">4</span>], [<span style="color:#e6db74">&#39;Lit&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;Int64&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">0</span>]]]],
</span></span><span style="display:flex;"><span>            [<span style="color:#e6db74">&#39;Le&#39;</span> <span style="color:#f92672">=&gt;</span> [[<span style="color:#e6db74">&#39;Col&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">4</span>], [<span style="color:#e6db74">&#39;Lit&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;Int64&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">1000000</span>]]]],
</span></span><span style="display:flex;"><span>        ]],
</span></span><span style="display:flex;"><span>    ]],
</span></span><span style="display:flex;"><span>], [
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;bm&#39;</span>,    <span style="color:#e6db74">&#39;column_id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">2</span>, <span style="color:#e6db74">&#39;kind&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;bitmap&#39;</span>],
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;fm&#39;</span>,    <span style="color:#e6db74">&#39;column_id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">3</span>, <span style="color:#e6db74">&#39;kind&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;fm_index&#39;</span>],
</span></span><span style="display:flex;"><span>    [<span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;range&#39;</span>, <span style="color:#e6db74">&#39;column_id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">4</span>, <span style="color:#e6db74">&#39;kind&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;learned_range&#39;</span>],
</span></span><span style="display:flex;"><span>]);
</span></span></code></pre></div><p>The check expression is a small typed tree rather than a SQL fragment, with <code>Col</code> naming a column by ID, <code>Lit</code> carrying a typed value, and the comparison and boolean nodes composing them, so <code>ck_hits_range</code> reads as &ldquo;0 &lt;= hits &lt;= 1000000&rdquo; and the engine evaluates it atomically at commit time against the whole transaction instead of row-by-row as a trigger would. The <code>kind</code> strings in the index list name the same index families the query builder targets, so the bitmap on <code>status</code>, the FM-index on <code>body</code>, and the learned-range index on <code>hits</code> you declare here are the ones your <code>where('bitmap_eq', ...)</code> and <code>where('fm_contains', ...)</code> conditions will hit later, and keeping both sides in one typed vocabulary is what makes the schema the contract rather than a suggestion.</p>
<h2 id="reading-the-catalog-back">Reading the catalog back</h2>
<p>The half of schema management most clients skip is the read path, and it is the half that pays for the typed approach; <code>tables()</code> lists names, <code>schema()</code> returns the full catalog as a map of table name to descriptor, and <code>schemaFor()</code> fetches one table, all over plain GETs against <code>/kit/schema</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-php" data-lang="php"><span style="display:flex;"><span>$names <span style="color:#f92672">=</span> $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">tables</span>();                 <span style="color:#75715e">// [&#39;orders&#39;, &#39;docs&#39;, ...]
</span></span></span><span style="display:flex;"><span>$catalog <span style="color:#f92672">=</span> $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">schema</span>();               <span style="color:#75715e">// every table descriptor, keyed by name
</span></span></span><span style="display:flex;"><span>$orders <span style="color:#f92672">=</span> $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">schemaFor</span>(<span style="color:#e6db74">&#39;orders&#39;</span>);     <span style="color:#75715e">// one table&#39;s descriptor
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// The drift check that used to require a parser:
</span></span></span><span style="display:flex;"><span>$amount <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_values</span>(<span style="color:#a6e22e">array_filter</span>(
</span></span><span style="display:flex;"><span>    $orders[<span style="color:#e6db74">&#39;columns&#39;</span>],
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">fn</span> (<span style="color:#66d9ef">array</span> $c) <span style="color:#f92672">=&gt;</span> $c[<span style="color:#e6db74">&#39;name&#39;</span>] <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;amount&#39;</span>,
</span></span><span style="display:flex;"><span>))[<span style="color:#ae81ff">0</span>] <span style="color:#f92672">??</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> ($amount <span style="color:#f92672">===</span> <span style="color:#66d9ef">null</span> <span style="color:#f92672">||</span> $amount[<span style="color:#e6db74">&#39;nullable&#39;</span>] <span style="color:#f92672">!==</span> <span style="color:#66d9ef">false</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">RuntimeException</span>(<span style="color:#e6db74">&#39;orders.amount drifted; aborting deploy&#39;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That last block is the whole argument in miniature, because the descriptor the server returns is the same shape as the definition you sent, so verifying a deploy is an <code>array_filter</code> and an equality check rather than a regex over <code>SHOW CREATE TABLE</code> output, and the check can live in a deploy script or a PHPUnit bootstrap where it fails loudly instead of silently corrupting a quarter of reports. When the table is genuinely done, <code>dropTable()</code> removes it by name, and the catalog read will confirm that too.</p>
<h2 id="when-raw-sql-is-still-the-right-tool">When raw <code>sql()</code> is still the right tool</h2>
<p>None of this makes the escape hatch wrong, and the client keeps <code>sql()</code> around for the things a typed surface should not pretend to own, like recursive CTEs over the org chart or a one-off analytical query that will never run twice; the honest split is that DDL and schema verification belong in typed calls where both directions are data, while exploratory SQL belongs in a string where flexibility beats structure. The rule I would hand a team is the one we would have wanted in the <code>mysql_*</code> era, when we edited schemas in phpMyAdmin and prayed: create and verify through the typed API, explore through SQL, and never let a string you cannot read back be the only record of what your database looks like.</p>
]]></content:encoded></item></channel></rss>