<?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>Api on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/api/</link><description>Recent content in Api 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>Fri, 04 Sep 2026 09:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/api/index.xml" rel="self" type="application/rss+xml"/><item><title>Stored Procedures from PHP: Install, List, Drop, Call</title><link>https://www.mongreldb.com/articles/2026/09/stored-procedures-install-list-drop-call/</link><pubDate>Fri, 04 Sep 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/09/stored-procedures-install-list-drop-call/</guid><description>MongrelDB stored procedures are versioned, checksummed definitions installed over HTTP, and the pure PHP client exposes the whole lifecycle as five typed calls whose name-keyed argument arrays map directly onto the procedure&amp;#39;s declared params.</description><content:encoded><![CDATA[<p>Stored procedures carry a bad reputation they mostly earned, because the classic version of the idea was a pile of T-SQL or PL/pgSQL living in the catalog, edited in production by whoever had the password, versioned by nobody, and reviewed never; but the reason the idea existed in the first place is still sound, which is that some operations want to happen next to the data in one round trip with one commit, and shipping three queries over the wire so your application code can glue them together is the part that was always wrong, not the procedure. MongrelDB&rsquo;s take on it drops the string-of-SQL-in-the-catalog model entirely: a procedure is a structured definition with a name, a version, a mode, typed params, and a body made of ordered steps, the server checksums it when you install it, and the PHP client drives the whole lifecycle with five methods, <code>createProcedure</code>, <code>procedures</code>, <code>procedure</code>, <code>callProcedure</code>, and <code>dropProcedure</code>, which is the entire surface and is worth walking through because the shape of the definition is where the design decisions live.</p>
<h2 id="what-a-procedure-is-in-mongreldb">What a procedure is in MongrelDB</h2>
<p>The definition you install is an array that mirrors the engine&rsquo;s <code>StoredProcedure</code> struct field for field, and the only fields you do not own are the ones the server recomputes on install, so you send placeholders for those and it fills in the truth:</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">createProcedure</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;users_by_status&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;version&#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;mode&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;read_only&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;params&#39;</span> <span style="color:#f92672">=&gt;</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;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></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;body&#39;</span> <span style="color:#f92672">=&gt;</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;steps&#39;</span> <span style="color:#f92672">=&gt;</span> [
</span></span><span style="display:flex;"><span>            [
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;kind&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;native_query&#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:#e6db74">&#39;scan&#39;</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;table&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;users&#39;</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;conditions&#39;</span> <span style="color:#f92672">=&gt;</span> [
</span></span><span style="display:flex;"><span>                    [
</span></span><span style="display:flex;"><span>                        <span style="color:#e6db74">&#39;kind&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;bitmap_eq&#39;</span>,
</span></span><span style="display:flex;"><span>                        <span style="color:#e6db74">&#39;column_id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">3</span>,
</span></span><span style="display:flex;"><span>                        <span style="color:#e6db74">&#39;value&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;kind&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;param&#39;</span>, <span style="color:#e6db74">&#39;value&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;status&#39;</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;limit&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">100</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;return_value&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;kind&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;step_rows&#39;</span>, <span style="color:#e6db74">&#39;value&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;scan&#39;</span>],
</span></span><span style="display:flex;"><span>    ],
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Server-assigned; the install path recomputes all three.
</span></span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;checksum&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;created_epoch&#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;updated_epoch&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">0</span>,
</span></span><span style="display:flex;"><span>]);
</span></span></code></pre></div><p>Three things in there are doing real work. The <code>mode</code> is either <code>read_only</code> or <code>read_write</code>, and the engine enforces it, so a procedure that only reads can never quietly start writing underneath you. The <code>params</code> are typed against the same type vocabulary the columns use, each with a name, a <code>nullable</code> flag, and an optional default, which means the contract of the procedure is declared data and not a comment in a wiki. And the body is a list of steps whose values can reference each other, because a value in MongrelDB&rsquo;s procedure language is one of <code>literal</code>, <code>param</code>, <code>step_rows</code>, <code>step_row</code>, <code>step_scalar</code>, <code>object</code>, or <code>array</code>; the <code>param</code> kind dereferences a named call argument, and the <code>step_*</code> kinds consume the output of an earlier step, so a write procedure can read something in step one and feed it into a <code>put</code> or <code>upsert</code> in step two, all inside one committed call, with no client round trip in the middle.</p>
<h2 id="the-lifecycle-from-php">The lifecycle from PHP</h2>
<p>Once the definition exists, the rest of the lifecycle is four more calls and none of them involve SQL strings:</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:8090&#39;</span>, <span style="color:#a6e22e">token</span><span style="color:#f92672">:</span> $token);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Install or replace: POST /procedures with the definition from the section above.
</span></span></span><span style="display:flex;"><span>$created <span style="color:#f92672">=</span> $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createProcedure</span>($definition);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// List everything installed: GET /procedures.
</span></span></span><span style="display:flex;"><span>$names <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_column</span>($db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">procedures</span>(), <span style="color:#e6db74">&#39;name&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Fetch one definition back, checksum and all.
</span></span></span><span style="display:flex;"><span>$def <span style="color:#f92672">=</span> $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">procedure</span>(<span style="color:#e6db74">&#39;users_by_status&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Call it. Named arguments map onto the declared params.
</span></span></span><span style="display:flex;"><span>$rows <span style="color:#f92672">=</span> $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">callProcedure</span>(<span style="color:#e6db74">&#39;users_by_status&#39;</span>, <span style="color:#a6e22e">args</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;status&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;active&#39;</span>]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Retire it: DELETE /procedures/users_by_status.
</span></span></span><span style="display:flex;"><span>$db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dropProcedure</span>(<span style="color:#e6db74">&#39;users_by_status&#39;</span>);
</span></span></code></pre></div><p>The call path is the piece PHP fits better than any client language I have used for this, because the server expects the call arguments as a JSON object keyed by param name and PHP&rsquo;s associative arrays are exactly that shape, so <code>$db-&gt;callProcedure('users_by_status', args: ['status' =&gt; 'active'])</code> sends <code>{&quot;status&quot;:&quot;active&quot;}</code> and each key dereferences the <code>param</code> of the same name in the body; the <code>args:</code> label at the call site is ordinary PHP 8 named-argument syntax on the method itself, but the real mapping runs through the array keys, which is worth saying precisely because <code>callProcedure('users_by_status', status: 'active')</code> would not work, and the habit of naming arguments that PHP developers picked up in 8.0 still lines up nicely with a contract where every param is declared with a name and a type on the server side. One implementation detail in the client is worth knowing because it explains a whole class of confusing bugs you will now never have: <code>callProcedure</code> casts the args array to an object before encoding, since an empty PHP array serializes as <code>[]</code>, a JSON sequence, while the server requires <code>{}</code>, a map, and that one cast is the difference between a procedure with no arguments working and the server rejecting your payload shape.</p>
<h2 id="what-steps-can-do">What steps can do</h2>
<p>The step vocabulary covers the operations the engine can commit atomically: <code>native_query</code> reads a table through conditions (<code>pk</code>, <code>bitmap_eq</code>, <code>bitmap_in</code>, <code>range</code>, <code>range_f64</code>, <code>is_null</code>, <code>is_not_null</code>, and <code>fm_contains</code> for full-text substring matching) with an optional projection and limit, and <code>put</code>, <code>upsert</code>, <code>delete_by_pk</code>, and <code>delete_rows</code> mutate, with <code>returning</code> on the writes when you want the rows back. Because every value in a step can be a <code>param</code> reference or a <code>step_scalar</code> from earlier in the list, the read-then-write pattern that usually takes an application-side transaction, select the id, compute something, insert the child row, compresses into one call:</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></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;kind&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;put&#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:#e6db74">&#39;record_hit&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;table&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;counters&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;cells&#39;</span> <span style="color:#f92672">=&gt;</span> [
</span></span><span style="display:flex;"><span>        [<span style="color:#e6db74">&#39;column_id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">1</span>, <span style="color:#e6db74">&#39;value&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;kind&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;param&#39;</span>, <span style="color:#e6db74">&#39;value&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;key&#39;</span>]],
</span></span><span style="display:flex;"><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;value&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;kind&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;literal&#39;</span>, <span style="color:#e6db74">&#39;value&#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">1</span>]]],
</span></span><span style="display:flex;"><span>    ],
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;returning&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p>Two value shapes in that block deserve a sentence of their own, because they look asymmetric until you know why they differ: a <code>literal</code> wraps one of the engine&rsquo;s typed values in a one-key tag, <code>['Int64' =&gt; 1]</code>, since the wire format has to know whether your <code>1</code> is an integer, a float, or a decimal128 before it can check the value against the column type, while a <code>param</code> value is a bare string because it carries no data at all and merely names the call argument to substitute at execution time.</p>
<p>Managing the catalog of procedures is a DDL operation, so installing, listing, describing, and dropping all require a principal with DDL permission, while calling one runs under the normal transaction discipline with the caller&rsquo;s table grants checked the same way any other operation would be; the procedure is not a way to escape permissions, it is a way to pre-arrange work the caller was already allowed to do.</p>
<h2 id="the-honest-tradeoff">The honest tradeoff</h2>
<p>The constraint to design around is that procedures called over the HTTP endpoint execute in the core engine, which does not host the SQL query layer, so a <code>sql_query</code> step is rejected there and your read steps are built from the native conditions instead; that is a deliberate boundary, because arbitrary SQL at call time is what the <code>/sql</code> endpoint is for, and the procedure surface stays limited to operations the engine can validate, checksum, and commit deterministically. The old world gave you unlimited power inside the procedure and paid for it with unreviewable string soup drifting in the catalog, the modern equivalent is a definition that is data, with a version you bump when the body changes and a checksum the server owns, and if you genuinely need free-form SQL in the middle of a procedure the honest answer is that MongrelDB has decided you do not, at least not over this endpoint, which is a smaller toolbox and, on the Fridays when the old catalog procedures used to break, a noticeably quieter one.</p>
]]></content:encoded></item><item><title>A Typed Exception Hierarchy That Maps to HTTP</title><link>https://www.mongreldb.com/articles/2026/08/a-typed-exception-hierarchy-that-maps-to-http/</link><pubDate>Tue, 18 Aug 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/08/a-typed-exception-hierarchy-that-maps-to-http/</guid><description>The MongrelDB PHP client maps HTTP status codes to a small typed exception hierarchy, so a 401 is an AuthException, a 409 is a ConstraintException carrying the server&amp;#39;s error code and the failing operation index, and your catch blocks read like the failure modes they handle.</description><content:encoded><![CDATA[<p>Error handling is where a database client either respects your time or wastes it, because the moment something goes wrong in production you are not reading documentation, you are reading a stack trace, and the difference between &ldquo;the server said 409 because op 3 of your batch violated a unique key&rdquo; and &ldquo;DatabaseException: something failed&rdquo; is the difference between a two-minute fix and an hour of log spelunking. The MongrelDB PHP client speaks HTTP to the daemon, and HTTP already has a perfectly good vocabulary for failure, so rather than inventing a parallel taxonomy of database-flavored error codes, the client maps the status codes it receives onto a small set of typed exceptions, and your catch blocks end up reading like the failure modes they actually handle.</p>
<h2 id="one-base-class-five-shapes-of-failure">One base class, five shapes of failure</h2>
<p>Everything extends <code>MongrelDBException</code>, which extends <code>\Exception</code>, so a single catch at the boundary still works when you do not care about the specifics, and that is the entire hierarchy:</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:#a6e22e">MongrelDBException</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">├──</span> <span style="color:#a6e22e">ConnectionException</span>   <span style="color:#75715e">// daemon unreachable, network error
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">├──</span> <span style="color:#a6e22e">AuthException</span>         <span style="color:#75715e">// 401 Unauthorized, 403 Forbidden
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">├──</span> <span style="color:#a6e22e">NotFoundException</span>     <span style="color:#75715e">// 404 Not Found
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">├──</span> <span style="color:#a6e22e">ConstraintException</span>   <span style="color:#75715e">// 409 Conflict
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">└──</span> <span style="color:#a6e22e">QueryException</span>        <span style="color:#75715e">// 400 Bad Request, 500 Internal Server Error
</span></span></span></code></pre></div><p>The mapping lives in one place, a <code>match</code> on the response status inside the client, and it is deliberately boring; 401 and 403 both become <code>AuthException</code> even though the fixes differ, a 401 means your credentials were rejected while a 403 means you authenticated fine and simply lack the grant, because from the caller&rsquo;s side both are the same category of problem, an auth-layer failure you cannot retry past, and the message tells you which half of it you are looking at. Where the mapping gets opinionated is 404, which the client detects two ways: a 404 status maps to <code>NotFoundException</code> directly, and on top of that the client inspects the parsed error envelope for a <code>not found:</code> message prefix regardless of status, because &ldquo;that table does not exist&rdquo; deserves its own type even when the server phrases it inside a broader error envelope.</p>
<h2 id="the-one-that-carries-real-weight-constraintexception">The one that carries real weight: ConstraintException</h2>
<p>Most of the hierarchy is just names, but <code>ConstraintException</code> earns its existence by carrying two extra properties, <code>errorCode</code> and <code>opIndex</code>, and those two fields are what turn a batch failure from a mystery into a diff:</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\Exceptions\ConstraintException</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    $tx <span style="color:#f92672">=</span> $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">beginTransaction</span>();
</span></span><span style="display:flex;"><span>    $tx<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">insert</span>(<span style="color:#e6db74">&#39;users&#39;</span>, [<span style="color:#e6db74">&#39;email&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;ada@example.com&#39;</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;Ada&#39;</span>]);
</span></span><span style="display:flex;"><span>    $tx<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">insert</span>(<span style="color:#e6db74">&#39;users&#39;</span>, [<span style="color:#e6db74">&#39;email&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;grace@example.com&#39;</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;Grace&#39;</span>]);
</span></span><span style="display:flex;"><span>    $tx<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">insert</span>(<span style="color:#e6db74">&#39;users&#39;</span>, [<span style="color:#e6db74">&#39;email&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;ada@example.com&#39;</span>, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;Ada again&#39;</span>]);
</span></span><span style="display:flex;"><span>    $tx<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">commit</span>();
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">ConstraintException</span> $e) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// $e-&gt;errorCode === &#39;UNIQUE_VIOLATION&#39;
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">// $e-&gt;opIndex  === 2
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">// the whole batch rolled back atomically
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>errorCode</code> is the server&rsquo;s own string code, <code>UNIQUE_VIOLATION</code>, <code>FK_VIOLATION</code>, <code>CHECK_VIOLATION</code>, <code>TRIGGER_VALIDATION</code>, or the generic <code>CONFLICT</code>, and the <code>opIndex</code> tells you which operation inside the atomic batch tripped the constraint, zero-based, so when a forty-operation transaction fails you know exactly which insert to look at instead of bisecting your own code. Because the engine evaluates constraints at commit time and the batch is atomic, a <code>ConstraintException</code> also carries a guarantee you can build on: nothing partially applied, no cleanup pass, the database is exactly where it was before <code>commit()</code> was called, which is the property that makes retrying safe once you have fixed the offending row.</p>
<h2 id="why-status-shaped-exceptions-beat-a-single-databaseexception">Why status-shaped exceptions beat a single DatabaseException</h2>
<p>The old PHP database extensions trained a generation of us to check return values, <code>mysql_query</code> returned <code>false</code> and you called <code>mysql_error()</code> and hoped the string was parseable, and the PDO era improved that to a single exception class with a SQLSTATE code stuffed in <code>getCode()</code>, which meant every serious codebase grew its own switch statement mapping <code>23000</code> to &ldquo;probably a duplicate key.&rdquo; The shape of the failure was always there, it was just encoded in a place the type system could not see, so the compiler could not help you and your IDE could not autocomplete the recovery path. Putting the shape in the class name instead means <code>catch (AuthException)</code> is a complete sentence, it means a static analyzer with unchecked-exception tracking enabled can tell you that you handle constraint violations but not connection failures, and it means the retry policy for a transient <code>ConnectionException</code> never accidentally swallows a permanent <code>QueryException</code>, because those are different types and PHP&rsquo;s catch semantics keep them apart for free.</p>
<p>There is a tradeoff worth naming, and it is that the hierarchy is shallow on purpose; the client does not try to give every server error code its own class, because a deep hierarchy is its own kind of failure, the kind where you version your exceptions and break everyone&rsquo;s catch blocks on a minor release. Five leaves and one rich class is the line we picked, the wire stays the source of truth through <code>errorCode</code> for anything finer-grained, and if the daemon ever grows a genuinely new failure mode that callers must handle differently, that is a deliberate API change and not an accident of a new status string. That is the whole design: HTTP already classified the error, the client just refuses to throw the classification away on the way into PHP.</p>
]]></content:encoded></item><item><title>Daemon HTTP Endpoints: The Wire Contract</title><link>https://www.mongreldb.com/articles/2026/08/daemon-http-endpoints-the-wire-contract/</link><pubDate>Sat, 15 Aug 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/08/daemon-http-endpoints-the-wire-contract/</guid><description>A walk through the actual HTTP surface mongreldb-server exposes, from /sql and /txn to sessions, pagination cursors, and the /kit/* typed routes, and why plain HTTP was the right transport bet.</description><content:encoded><![CDATA[<p>A database wire protocol is the kind of decision you make once and then live inside for a decade, because every client you ever ship, every proxy you ever deploy behind, and every debugging session at 2am is shaped by it, and the industry default for thirty years has been to invent a binary protocol per database and then spend the next decade writing drivers for it. Postgres has its frontend/backend protocol, MySQL has its own packet format, Redis got away with RESP because it was simple enough to type by hand, and each of those choices created a whole ecosystem of client libraries that had to be ported, maintained, and debugged in every language somebody wanted to use. When we built the server mode for MongrelDB the tempting move was the same one, a compact binary framing over TCP with a Rust reference client, and we rejected it for a reason that has only gotten stronger since: plain HTTP with JSON bodies is already implemented, already proxied, already authenticated, and already debuggable with <code>curl</code> in every environment your code will ever run in, and the overhead it costs you is almost never where your latency budget actually goes.</p>
<p>That choice has a consequence worth stating up front, which is that the HTTP surface is not an accident or a shim, it is the contract, the same one the PHP client, the Kit SDKs, and your own shell scripts all speak, so it is worth walking through what <code>mongreldb-server</code> actually exposes and why the surface is shaped the way it is.</p>
<h2 id="the-shape-of-the-surface">The shape of the surface</h2>
<p>The daemon is an axum router, and the routes fall into a few deliberate groups rather than one flat pile. The operational group is what your monitoring talks to: <code>/health</code> for liveness, <code>/build-info</code> for the exact version and build metadata, <code>/capabilities</code> for what this build supports, <code>/metrics</code> for Prometheus-style scraping, <code>/audit</code> for the audit log, and <code>/history/retention</code> for reading and setting retention policy. Admin operations sit under <code>/admin/drain</code> and <code>/admin/reload</code>, with cluster membership workflows under <code>/admin/cluster/*</code> when the binary is built with the cluster feature, and that grouping exists so your firewall rules and your reverse proxy ACLs can treat &ldquo;is it alive&rdquo; traffic and &ldquo;reconfigure the server&rdquo; traffic as different trust levels, because they are.</p>
<p>The data group is the part a client touches on every request, and it starts with the table primitives: <code>GET</code> and <code>POST /tables</code> to list and create, <code>DELETE /tables/{name}</code> to drop, <code>POST /tables/{name}/put</code> to write a row, <code>GET /tables/{name}/count</code>, and <code>POST /tables/{name}/commit</code>. On top of that sits the SQL surface, which is where most real work happens, a single <code>POST /sql</code> that takes a JSON body and returns a JSON result, plus <code>POST /sql/continue</code> for paged results, <code>GET /queries/{query_id}</code> for status on a running query, and <code>POST /queries/{query_id}/cancel</code> when you want it dead.</p>
<h2 id="the-sql-request-is-the-contract">The /sql request is the contract</h2>
<p>The request body for <code>/sql</code> is where the interesting engineering lives, because a single POST carrying a SQL string is what every toy HTTP database does, and the difference between a toy and a transport is everything else in the envelope:</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;sql&#34;</span>: <span style="color:#e6db74">&#34;UPDATE products SET price = price * 0.9 WHERE category = &#39;clearance&#39;&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;format&#34;</span>: <span style="color:#e6db74">&#34;json&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;query_id&#34;</span>: <span style="color:#e6db74">&#34;q-7f3a2c&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;timeout_ms&#34;</span>: <span style="color:#ae81ff">5000</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;max_output_rows&#34;</span>: <span style="color:#ae81ff">10000</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;max_output_bytes&#34;</span>: <span style="color:#ae81ff">4194304</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;idempotency_key&#34;</span>: <span style="color:#e6db74">&#34;import-run-42-batch-7&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Every field except <code>sql</code> is optional, and each one exists because a real failure mode demanded it. <code>timeout_ms</code> and the output limits mean a runaway statement dies on the server&rsquo;s terms rather than streaming gigabytes into a client that stopped caring, the <code>query_id</code> ties the request to the <code>/queries/{query_id}/cancel</code> endpoint so an operator or a watchdog can kill it from a different connection entirely, and <code>idempotency_key</code> is the storage-layer dedupe we covered in an earlier post, so a retry after a network drop replays the receipt instead of re-executing the write, which is exactly the property a batch import like the one above needs. The <code>format</code> field is a different kind of knob, a per-request serialization choice rather than a guard: JSON is the default, but <code>&quot;arrow&quot;</code> returns Arrow IPC file bytes instead, which is the honest answer to &ldquo;JSON text is slow&rdquo; for the analytical case where you are moving real row volume, same endpoint, same auth, same query, different bytes on the wire.</p>
<p>Reads ride the same envelope, and a paged SELECT adds a pagination block to the request: <code>page_size_rows</code> bounds by count, <code>max_page_bytes</code> bounds by serialized size, and <code>max_page_tokens</code> bounds by an estimated token count the server computes as <code>ceil(projected_json_bytes / 4)</code>, which is a crude heuristic and deliberately documented as one in the response itself, but crude and server-side beats every client inventing its own truncation logic after the damage is done.</p>
<h2 id="cursors-that-tell-the-truth">Cursors that tell the truth</h2>
<p>A paged response comes back with the page and a continuation:</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;status&#34;</span>: <span style="color:#e6db74">&#34;completed&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;rows&#34;</span>: [ <span style="color:#960050;background-color:#1e0010">/*</span> <span style="color:#960050;background-color:#1e0010">up</span> <span style="color:#960050;background-color:#1e0010">to</span> <span style="color:#960050;background-color:#1e0010">page_size_rows</span> <span style="color:#960050;background-color:#1e0010">row</span> <span style="color:#960050;background-color:#1e0010">objects</span> <span style="color:#960050;background-color:#1e0010">*/</span> ],
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;next_cursor&#34;</span>: <span style="color:#e6db74">&#34;9f2c...&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;page&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;offset&#34;</span>: <span style="color:#ae81ff">0</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;row_count&#34;</span>: <span style="color:#ae81ff">500</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;total_rows&#34;</span>: <span style="color:#ae81ff">18342</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;byte_count&#34;</span>: <span style="color:#ae81ff">241118</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;estimated_tokens&#34;</span>: <span style="color:#ae81ff">60280</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;expires_at_ms&#34;</span>: <span style="color:#ae81ff">1786963200000</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;snapshot&#34;</span>: <span style="color:#e6db74">&#34;retained_result&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;token_estimate&#34;</span>: <span style="color:#e6db74">&#34;ceil(projected_json_bytes/4)&#34;</span>
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Yes, the server really ships the formula itself as a literal string in every paged response, and that is deliberate rather than redundant: any client reading <code>estimated_tokens</code> can see exactly how the number was produced without opening our docs, so nobody mistakes the estimate for a precise tokenizer count.</p>
<p>You pass <code>next_cursor</code> to <code>POST /sql/continue</code> and get the next page of the same retained result set, which matters because the snapshot is held server-side, so page two sees the same data page one saw even if writes landed in between. The cursor is HMAC-signed with a process-local key, and that detail is a deliberate tradeoff rather than an oversight: a tampered cursor is rejected instead of being treated as a query, and a server restart invalidates outstanding cursors, which means a client must be prepared to re-run the query after a failover. We could have made cursors durable across restarts, but that would mean persisting retained result sets or making cursors replayable queries with all the consistency questions that brings, and the honest contract is the simpler one: a cursor is a short-lived handle to a snapshot this process is holding for you, with an <code>expires_at_ms</code> that tells you exactly how short-lived, and when it is gone you start over.</p>
<h2 id="transactions-sessions-and-the-typed-routes">Transactions, sessions, and the typed routes</h2>
<p>Single-statement SQL is not the whole story, so <code>POST /txn</code> takes a batch of operations and applies them atomically, which is how the Kit layer implements its transaction commit and how any client can get all-or-nothing semantics over a transport that has no concept of a connection-held transaction. For callers that want the classic prepare-then-execute shape, the <code>/sessions</code> group provides it over HTTP: <code>POST /sessions</code> opens a session, <code>POST /sessions/{id}/prepare</code> registers a statement, <code>POST /sessions/{id}/execute</code> runs it with bound arguments, and <code>DELETE</code> on the statement or the session cleans up, so prepared-statement caching and parameter binding work the way you expect without the transport needing to keep a socket open per client.</p>
<p>The <code>/kit/*</code> group is the typed surface the SDKs prefer, routes like <code>/kit/schema</code>, <code>/kit/txn</code>, <code>/kit/query</code>, <code>/kit/search</code>, <code>/kit/retrieve</code>, <code>/kit/ann_rerank</code>, and <code>/kit/set_similarity</code>, and the distinction from raw <code>/sql</code> is that these carry structured, validated payloads rather than SQL text, so the engine can enforce constraints and column types at the boundary and the client never has to serialize a query string. Stored procedures and triggers get their own REST-shaped resources, <code>/procedures</code> and <code>/triggers</code> with list, create, describe, replace, drop, and call, which keeps the admin-y operations discoverable with nothing more exotic than an HTTP client. And for the replication story, <code>GET /wal/stream</code>, <code>GET /replication/snapshot</code>, and <code>GET /events</code> are the streaming edges, the places where HTTP&rsquo;s request-response shape bends into a long-lived stream because change capture genuinely is a stream.</p>
<h2 id="what-http-costs-and-what-it-buys">What HTTP costs, and what it buys</h2>
<p>The costs are real and worth naming. Every request carries headers and JSON parsing that a binary framing would shrink, there is no server push outside the streaming endpoints, and keep-alive and connection reuse are your responsibility through whatever HTTP client you bring, which is why the PHP client&rsquo;s persistent cURL sharing on 8.5+ matters as much as it does. The server meets you halfway on the abuse cases: a request-bytes bound rejects oversized bodies with a structured 413 rather than an OOM, and an optional concurrency limit sheds load instead of queueing until the wheels come off.</p>
<p>What it buys is everything you did not have to build. Auth is middleware with three modes, a bearer token, HTTP Basic verified against the Argon2id-hashed catalog users, or both, and TLS, rate limiting, and request logging come from whatever proxy you already run rather than from database-specific features somebody has to reimplement. Debugging is <code>curl</code> from any machine that can reach the port, load balancing is the same layer-7 infrastructure the rest of your stack uses, and a new language gets a working client the day somebody writes a hundred lines around its standard HTTP library, which is exactly how the long tail of MongrelDB language clients exists at all. The twenty-year view is that every database that invented a wire protocol also invented a driver maintenance burden it is still paying down, and the modern equivalent of the <code>mysql_*</code> extension problem is a proprietary protocol that only three of your twenty supported languages talk fluently; HTTP is not the fastest transport you can imagine, it is the fastest one you can already use from everywhere, and for an engine whose whole pitch is meeting you where your code runs, that is the right trade.</p>
]]></content:encoded></item></channel></rss>