<?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>Triggers on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/triggers/</link><description>Recent content in Triggers 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, 18 Sep 2026 09:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/triggers/index.xml" rel="self" type="application/rss+xml"/><item><title>Triggers and Virtual Table Helpers</title><link>https://www.mongreldb.com/articles/2026/09/triggers-and-virtual-table-helpers/</link><pubDate>Fri, 18 Sep 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/09/triggers-and-virtual-table-helpers/</guid><description>MongrelDB triggers are engine-side and fire for every client that writes, and its virtual tables are app-registered modules instead of loadable native code, so audit behavior and foreign data both live inside the same WAL transaction as the rest of your rows.</description><content:encoded><![CDATA[<p>Every codebase that swears off triggers has the same convention standing in for them, a helper function everyone is supposed to call, a review checklist that catches the people who forgot, and the convention holds right up until the second writer arrives, the import job, the one-off script, the service that talks to the database directly, and updates the table without asking your helper for permission. That distance between &ldquo;the application usually remembers&rdquo; and &ldquo;the engine always enforces&rdquo; is the entire reason triggers exist, and a version of the same question sits underneath virtual tables, because a table that is not really a table is only worth having if the engine treats its rows with the same seriousness as the ones it stores itself, so the story of both features in MongrelDB is a story about where behavior lives and who owns the transaction boundary.</p>
<h2 id="triggers-fire-for-the-writers-you-forgot-about">Triggers fire for the writers you forgot about</h2>
<p>A MongrelDB trigger is a cataloged engine structure, not a client-side callback, and it is row-level across <code>INSERT</code>, <code>UPDATE</code>, and <code>DELETE</code>, with <code>BEFORE</code> and <code>AFTER</code> on ordinary tables and <code>INSTEAD OF</code> on session views, which covers the three places people actually use triggers: audit rows, lightweight denormalization, and validation that has no business being reimplemented in every client. The SQL surface looks the way you would expect if you have written a trigger in anything since the nineties:</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-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TRIGGER</span> audit_order
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AFTER</span> <span style="color:#66d9ef">UPDATE</span> <span style="color:#66d9ef">OF</span> status <span style="color:#66d9ef">ON</span> orders
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHEN</span> <span style="color:#66d9ef">OLD</span>.status <span style="color:#f92672">&lt;&gt;</span> <span style="color:#66d9ef">NEW</span>.status
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> order_audit(order_id, status)
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">VALUES</span> (<span style="color:#66d9ef">NEW</span>.id, <span style="color:#66d9ef">NEW</span>.status);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">END</span>;
</span></span></code></pre></div><p>The part worth reading twice is the commit behavior, because trigger expansion happens inside <code>commit_transaction</code>, which means the original write and every write the trigger produces land in one WAL transaction, and the unique, foreign-key, CHECK, and row-policy validation runs against the final expanded write set rather than against your write alone. A trigger that fails leaves no partial durable state behind, and that atomicity extends to the <code>RAISE</code> family in trigger bodies, where <code>FAIL</code> and <code>ROLLBACK</code> do not retain the SQLite-style partial row effects that have surprised a generation of developers, while <code>RAISE(IGNORE)</code> suppresses the current row operation and the later triggers for that row without throwing away the trigger work already staged. Recursive triggers are off by default, the way they should be, and enabling them is an explicit per-session <code>PRAGMA recursive_triggers = 1;</code>, with cycle and maximum-depth violations returning errors that name the trigger stack instead of hanging your commit.</p>
<h2 id="one-spec-every-client">One spec, every client</h2>
<p>Kit does not invent a trigger language of its own, it hands the engine a declarative JSON spec, and that choice is the whole point, because a trigger stored by the engine fires for writes that arrive through Kit, through raw SQL, through the daemon&rsquo;s HTTP surface, or through any of the native clients, and you do not end up with the usual SDK-shaped hole where the TypeScript path enforces the invariant and everything else hopes for the best:</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">import</span> { <span style="color:#a6e22e">trigger</span>, <span style="color:#a6e22e">newColumn</span>, <span style="color:#a6e22e">textValue</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#39;@visorcraft/mongreldb-kit&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">usersAudit</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">trigger</span>({
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;users_ai&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">target</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">kind</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;table&#39;</span>, <span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;users&#39;</span> },
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">timing</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;after&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">event</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;insert&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">program</span><span style="color:#f92672">:</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">steps</span><span style="color:#f92672">:</span> [{
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">kind</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;insert&#39;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">table</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;audit&#39;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">cells</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// the audit table is keyed on the user id it records
</span></span></span><span style="display:flex;"><span>        { <span style="color:#a6e22e">column_id</span>: <span style="color:#66d9ef">audit.id.id</span>, <span style="color:#a6e22e">value</span>: <span style="color:#66d9ef">newColumn</span>(<span style="color:#a6e22e">users</span>.<span style="color:#a6e22e">id</span>.<span style="color:#a6e22e">id</span>) },
</span></span><span style="display:flex;"><span>        { <span style="color:#a6e22e">column_id</span>: <span style="color:#66d9ef">audit.user_id.id</span>, <span style="color:#a6e22e">value</span>: <span style="color:#66d9ef">newColumn</span>(<span style="color:#a6e22e">users</span>.<span style="color:#a6e22e">id</span>.<span style="color:#a6e22e">id</span>) },
</span></span><span style="display:flex;"><span>        { <span style="color:#a6e22e">column_id</span>: <span style="color:#66d9ef">audit.note.id</span>, <span style="color:#a6e22e">value</span>: <span style="color:#66d9ef">textValue</span>(<span style="color:#e6db74">&#39;created&#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><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">db</span>.<span style="color:#a6e22e">createTriggerSync</span>(<span style="color:#a6e22e">usersAudit</span>);
</span></span></code></pre></div><p>The same spec round-trips through <code>createOrReplaceTriggerSync</code>, <code>dropTriggerSync</code>, and the remote client&rsquo;s <code>createTrigger</code> / <code>replaceTrigger</code> / <code>dropTrigger</code>, Rust keeps the spec as JSON so it tracks the engine schema exactly, Python accepts a dict or a JSON string, and migrations carry triggers as ordinary <code>createTrigger</code> ops so a drift check notices when the live database lost one. When a trigger rejects a write, every language surfaces the same stable <code>TRIGGER_VALIDATION</code> error category, so the catch block you wrote for the TypeScript path is the same shape you write for the Python path, which is the kind of boring uniformity that only shows up when the error comes from one engine instead of three SDKs.</p>
<h2 id="virtual-tables-are-modules-not-plugins">Virtual tables are modules, not plugins</h2>
<p>The virtual table side answers a different version of the same trust question, because the history of this feature is SQLite&rsquo;s <code>CREATE VIRTUAL TABLE</code> and its eponymous table-valued functions, a genuinely good idea that came with a genuinely bad habit of loading native code into your process from SQL, and MongrelDB keeps the idea while closing the loading door: SQL cannot load anything, modules are registered by the embedding application on the session or allowlisted by the daemon embedding, and an application that cataloged custom tables has to register its module implementations again when it reopens, which is a deliberate inconvenience that makes &ldquo;where did this code come from&rdquo; a question with a one-word answer. What SQL can do is ask for a module by name:</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-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> VIRTUAL <span style="color:#66d9ef">TABLE</span> numbers <span style="color:#66d9ef">USING</span> series(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">100</span>, <span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> value <span style="color:#66d9ef">FROM</span> numbers <span style="color:#66d9ef">WHERE</span> value <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">95</span>;
</span></span></code></pre></div><p>The contract between the planner and the module is where the seam actually sits, and it is a narrower seam than you might expect: the planner hands the module the projections, filters, ordering, limits, and estimates it has, the module reports which filters it accepts and which it wants left behind, and the engine applies the residual filters and the exact limits itself, so a module that understands nothing about pushdown is slow rather than wrong. Correctness never depends on how clever the module author felt that day, only performance does, and that is the right side of the line for a database to stand on.</p>
<p>The built-in modules cover the ground a real application asks for without anyone writing a module at all: <code>series</code> for integer ranges, <code>json_each</code> and <code>json_tree</code> plus their JSONB twins for traversal, <code>schema_tables</code> and <code>dbstat</code> for live metadata, <code>kv_store</code> as a durable writable key/value table, <code>fts_docs</code> as a writable full-text table with ranking, snippets, and highlights, and <code>rtree_rects</code> for rectangle overlap queries. The writable ones are the interesting half, because module writes stage through <code>ExternalTxn</code> and commit through the same shared WAL as your base tables, an explicit SQL transaction may mix base-table writes and module writes, trigger programs can target a module that declares itself writable and transaction-safe, and the module state lives under <code>_vtab/</code> where recovery restores it, backup copies it, and <code>check()</code> and <code>gc()</code> keep it honest, so a virtual table in MongrelDB is not a view over a foreign system that your transactions cannot reach, it is a participant in the same commit as everything else.</p>
<p>Kit&rsquo;s helper layer stays deliberately thin over that machinery, generating the SQL rather than hiding it:</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">import</span> { <span style="color:#a6e22e">createVirtualTableSql</span>, <span style="color:#a6e22e">virtualTable</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#39;@visorcraft/mongreldb-kit&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">docsFts</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">virtualTable</span>(<span style="color:#e6db74">&#39;docs_fts&#39;</span>, <span style="color:#e6db74">&#39;fts_docs&#39;</span>, [
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;content=docs&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;tokenize=porter&#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:#66d9ef">await</span> <span style="color:#a6e22e">db</span>.<span style="color:#a6e22e">createVirtualTable</span>(<span style="color:#a6e22e">docsFts</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#a6e22e">createVirtualTableSql</span>(<span style="color:#a6e22e">docsFts</span>));
</span></span><span style="display:flex;"><span><span style="color:#75715e">// CREATE VIRTUAL TABLE &#34;docs_fts&#34; USING &#34;fts_docs&#34;(content=docs, tokenize=porter)
</span></span></span></code></pre></div><p>Migrations carry <code>createVirtualTable</code> ops the same way they carry trigger ops, the Rust and Python runners execute them through their embedded SQL session, and introspection is just <code>PRAGMA module_list;</code> and <code>PRAGMA trigger_list;</code> when you need to know what is actually registered. The observant reader will have noticed the trigger example used <code>createTriggerSync</code> while this one awaits, and that difference is the API telling you the truth about itself rather than an inconsistency you should smooth over, because trigger registry changes in the embedded client are synchronous catalog operations while virtual table creation rides the SQL path, which is async in TypeScript and is the reason <code>migrateSync</code> refuses virtual table ops outright.</p>
<h2 id="where-the-seam-actually-sits">Where the seam actually sits</h2>
<p>The honest tradeoff is the same one it has always been, just with better defaults. A trigger is the right home for an invariant that must survive every writer, and it is the wrong home for anything that needs a network call or a side effect outside the database, because a trigger that calls a webhook is a distributed system wearing a costume. A virtual table is the right shape for data you cannot afford to copy or that lives somewhere else by nature, and it is the wrong shape for your hot path, because no matter how much pushdown the module accepts, you are still one indirection away from the storage the engine controls directly, and the Postgres foreign-data-wrapper crowd learned that lesson with two-phase commit as the tuition. What changed since the SQLite era is not the idea, it is who holds the keys: the modules come from your application instead of from the SQL text, the writes share your WAL instead of someone else&rsquo;s, and the planner keeps the residuals so the seam between a real table and a foreign one is a performance question, never a correctness one.</p>
]]></content:encoded></item></channel></rss>