<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Andrea Debernardi</title>
    <description>The latest articles on DEV Community by Andrea Debernardi (@debba).</description>
    <link>https://dev.to/debba</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F23999%2F8993394e-b845-4933-a1d3-ebfa2e954992.jpeg</url>
      <title>DEV Community: Andrea Debernardi</title>
      <link>https://dev.to/debba</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/debba"/>
    <language>en</language>
    <item>
      <title>Database drivers as external processes</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Wed, 24 Jun 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/debba/database-drivers-as-external-processes-52af</link>
      <guid>https://dev.to/debba/database-drivers-as-external-processes-52af</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fia5eidmf9z4zagxtmnrh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fia5eidmf9z4zagxtmnrh.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;About three months ago I added external database drivers to Tabularis. Not dynamic libraries, not WebAssembly modules: ordinary processes that speak JSON-RPC over stdin/stdout.&lt;/p&gt;

&lt;p&gt;Enough time has passed for the design to be less theoretical. It has survived real drivers, timeouts, a headless server mode, and one security bug that was hiding in what looked like normal registry code. So this is not a launch post. It is a short retrospective on the shape of the implementation and the places where the shape mattered.&lt;/p&gt;

&lt;p&gt;Tabularis still has three database drivers compiled into the application: MySQL, PostgreSQL and SQLite. They use &lt;code&gt;sqlx&lt;/code&gt;, implement the same Rust trait, and ship with the binary. This is the comfortable case.&lt;/p&gt;

&lt;p&gt;The interesting case is the fourth database.&lt;/p&gt;

&lt;p&gt;There are many databases I will never run myself, and a few I have not heard of yet. Compiling a client library for each of them into the core binary is not realistic. It also feels wrong: a database GUI should not have every possible vendor SDK, Python runtime, OAuth stack, TLS oddity and transitive dependency in the same address space as the rest of the application.&lt;/p&gt;

&lt;p&gt;Still, "it does not support the database I use at work" is a valid reason to close a database GUI and never open it again. So Tabularis needed plugins.&lt;/p&gt;

&lt;p&gt;That sounds like a solved problem until you ask what a plugin is, exactly, for a Rust desktop application. In practice I saw three choices: load a dynamic library, run WebAssembly, or start another process. The implementation that shipped chose the least clever one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two options I didn't take
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Dynamic libraries.&lt;/strong&gt; Load a &lt;code&gt;.so&lt;/code&gt;, &lt;code&gt;.dylib&lt;/code&gt; or &lt;code&gt;.dll&lt;/code&gt;, find a symbol, call into it. This is the traditional native plugin model, and on paper it is the fastest one. A query method becomes a function call.&lt;/p&gt;

&lt;p&gt;There are two problems with it here.&lt;/p&gt;

&lt;p&gt;The first one is Rust. Rust does not have a stable ABI, so a plugin compiled with one toolchain cannot safely pass a &lt;code&gt;String&lt;/code&gt;, &lt;code&gt;Vec&lt;/code&gt; or trait object to a host compiled with another one. In practice a Rust plugin ABI becomes an &lt;code&gt;extern "C"&lt;/code&gt; interface: raw pointers, owned buffers, explicit frees, error codes, and a driver author thinking about FFI before thinking about the database.&lt;/p&gt;

&lt;p&gt;The second problem is more important. A dynamic library lives in the host process. If the driver segfaults, Tabularis segfaults. If it corrupts memory, the crash can surface later in unrelated code. If a panic crosses an FFI boundary incorrectly, the behavior is not something I want in user crash reports. The fastest plugin boundary is also the boundary with the worst failure mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebAssembly.&lt;/strong&gt; WASM is attractive for the opposite reason. It gives a real memory sandbox, and if the main problem was running hostile code, it would be the first thing to evaluate seriously.&lt;/p&gt;

&lt;p&gt;But a database driver is mostly I/O. It opens sockets, negotiates TLS, speaks a binary protocol, and often wants to reuse a vendor SDK or a mature client library that already exists in some language. For this use case WASM does not just sandbox the driver; it removes a lot of the ecosystem the driver author would naturally want to use.&lt;/p&gt;

&lt;p&gt;I am not saying WASM is a bad plugin technology. It is probably the right answer for many plugin systems. But for database drivers, the practical constraint is not CPU isolation. The practical constraint is: can somebody write a driver quickly, using the tools that already speak to that database? If the answer is no, the plugin system will be beautiful and mostly empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring option
&lt;/h2&gt;

&lt;p&gt;A Tabularis plugin driver is a separate program. It reads requests on stdin, writes responses on stdout, and exits when the host is done with it.&lt;/p&gt;

&lt;p&gt;This idea is old enough to be boring. Language servers work like this. Unix tools work like this. CGI worked like this. The reason this shape keeps coming back is that it gives a useful amount of isolation without inventing much:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The driver can be written in any language. Rust, Python, Go, Java, a shell script if that is really what the database deserves. If it can read a line and print a line, it can be a driver.&lt;/li&gt;
&lt;li&gt;If the driver crashes, the host sees EOF on a pipe. That is a normal error path, not memory corruption in the GUI process.&lt;/li&gt;
&lt;li&gt;The driver brings its own dependencies. The CSV driver is Python. The Google Sheets driver is Rust with an OAuth dependency I do not want in the core application. Both are fine because neither one is linked into Tabularis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost is serialization. Every call is encoded, written through a pipe, read on the other side, decoded, and then the response takes the same trip back.&lt;/p&gt;

&lt;p&gt;For a database client, this is a good trade. The thing behind the driver is usually a database server over the network, or at least disk. A bit of JSON framing is not where the time goes. When it does matter, the answer is usually to page or stream the result set, not to put a third-party driver into the main process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wire
&lt;/h2&gt;

&lt;p&gt;The protocol is JSON-RPC 2.0, one message per line. The whole definition lives in &lt;a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/src/plugins/rpc.rs" rel="noopener noreferrer"&gt;&lt;code&gt;src-tauri/src/plugins/rpc.rs&lt;/code&gt;&lt;/a&gt;, and it is small enough to show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Serialize,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize,&lt;/span&gt; &lt;span class="nd"&gt;Debug)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;JsonRpcRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;jsonrpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Serialize,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize,&lt;/span&gt; &lt;span class="nd"&gt;Debug)]&lt;/span&gt;
&lt;span class="nd"&gt;#[serde(untagged)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;JsonRpcResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Success&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;jsonrpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;jsonrpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;JsonRpcError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line-delimited JSON is intentionally unsophisticated. I could have used &lt;code&gt;Content-Length&lt;/code&gt; framing like LSP does. Instead a Python driver can do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and be done with framing. A JSON serializer escapes newlines inside strings, so a physical newline is a message boundary. This is not the most general protocol in the world. It is the one that makes the first driver take an afternoon instead of a week.&lt;/p&gt;

&lt;p&gt;One detail that paid for itself: the child's stderr is inherited, not piped. Driver logs and panics go to the same console as the app logs. I did not build a logging protocol because the operating system already had a useful one.&lt;/p&gt;

&lt;p&gt;The method surface is the database trait flattened into strings: &lt;code&gt;test_connection&lt;/code&gt;, &lt;code&gt;get_databases&lt;/code&gt;, &lt;code&gt;get_tables&lt;/code&gt;, &lt;code&gt;execute_query&lt;/code&gt;, &lt;code&gt;insert_record&lt;/code&gt;, &lt;code&gt;get_create_index_sql&lt;/code&gt;, and so on. There are a bit more than thirty methods. The scaffolder generates stubs for all of them, so a driver author fills in behavior rather than copying protocol boilerplate.&lt;/p&gt;

&lt;p&gt;At the boundary, &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;result&lt;/code&gt; are &lt;code&gt;serde_json::Value&lt;/code&gt;. Immediately above the boundary they become real types again. This is important: dynamic data at the process boundary is fine; dynamic data spread through the application would not be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is actually hard
&lt;/h2&gt;

&lt;p&gt;The phrase "JSON-RPC over stdin/stdout" hides the annoying part: there are two byte streams and many callers.&lt;/p&gt;

&lt;p&gt;When a connection opens, the sidebar may ask for schema information while the grid asks for the first page of rows and a background task pings the driver to see if the connection is still alive. Those calls are concurrent. The child process, however, has one stdin and one stdout.&lt;/p&gt;

&lt;p&gt;So two things must be true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Writes must be serialized. Even if small pipe writes often appear atomic, relying on that would make the protocol depend on an implementation detail.&lt;/li&gt;
&lt;li&gt;Responses must be routed by id. The fast schema request can finish after the slow row request or before it. Order is not a contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The way to get both properties is to stop letting callers touch the pipes. One Tokio task owns the child process. Everyone else sends that task a command and waits on a one-shot channel.&lt;/p&gt;

&lt;p&gt;The caller side looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;PluginCommand&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/// Dispatch a request; route the response back via the sender.&lt;/span&gt;
    &lt;span class="nf"&gt;Call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonRpcRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;oneshot&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Sender&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="cd"&gt;/// Drop the pending entry for `id` because the caller stopped waiting.&lt;/span&gt;
    &lt;span class="nf"&gt;Cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The owner holds the only &lt;code&gt;stdin&lt;/code&gt;, the only &lt;code&gt;stdout&lt;/code&gt;, and the map of outstanding requests. It waits for three things: a shutdown signal, the next command from the application, or the next line from the plugin.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;oneshot&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Sender&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;HashMap&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;loop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;select!&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;shutdown_rx&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="nf"&gt;.kill&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="nf"&gt;.recv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;PluginCommand&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_tx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// remember who's waiting&lt;/span&gt;
                &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="nf"&gt;.write_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="nf"&gt;.as_bytes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// serialized: only this task writes&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;PluginCommand&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="nf"&gt;.kill&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// all callers gone&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;

        &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="nf"&gt;.read_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// EOF: the plugin died&lt;/span&gt;
            &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;from_str&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JsonRpcResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;JsonRpcResponse&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Success&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;JsonRpcResponse&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="py"&gt;.message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;log&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bad response from plugin: {e}"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="nf"&gt;.clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;log&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read error: {e}"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the &lt;code&gt;PluginProcess&lt;/code&gt; management task in &lt;a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/src/plugins/driver.rs" rel="noopener noreferrer"&gt;&lt;code&gt;src-tauri/src/plugins/driver.rs&lt;/code&gt;&lt;/a&gt;. The request &lt;code&gt;id&lt;/code&gt; is a monotonic counter. Responses can arrive in any order, because each one finds its waiting caller through &lt;code&gt;pending&lt;/code&gt;. Writes are serialized because there is exactly one writer.&lt;/p&gt;

&lt;p&gt;The external shape is concurrent. The internal shape is a single owner of a resource that should not be shared. That is the main trick.&lt;/p&gt;

&lt;p&gt;A call becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;oneshot&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.sender&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;PluginCommand&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PLUGIN_CALL_TIMEOUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"plugin did not respond"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* timed out */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point the design looks clean. In my experience, that is a good time to look for the state the code forgot to model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs that showed up
&lt;/h2&gt;

&lt;p&gt;The actor loop was the right abstraction, but two bugs fell directly out of its shape during the first rounds of testing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The leak.&lt;/strong&gt; A caller waits at most 120 seconds for a reply. If the plugin is slow or wedged, the caller gives up and returns an error.&lt;/p&gt;

&lt;p&gt;But the owner still has an entry in &lt;code&gt;pending&lt;/code&gt;. It contains a &lt;code&gt;oneshot::Sender&lt;/code&gt; whose receiver is gone. The entry is removed only when a response arrives, and in this case the response may never arrive. So every timeout leaks one map slot. A bad plugin can slowly grow that map for the lifetime of the application.&lt;/p&gt;

&lt;p&gt;That is what &lt;code&gt;Cancel(id)&lt;/code&gt; is for. When a call times out, the caller also tells the owner to forget the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.sender&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;PluginCommand&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"plugin call '{method}' timed out after {}s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="nf"&gt;.as_secs&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a small fix, but it is the kind of small fix that is easy to miss because the happy path never needs it. The actor owns the child process, so it must also own the cleanup for abandoned calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The zombies.&lt;/strong&gt; Tabularis can also run headless as an MCP server. In that mode a subprocess starts, registers plugins, serves requests, and exits when its stdin reaches EOF.&lt;/p&gt;

&lt;p&gt;The first time I tested that path, &lt;code&gt;ps&lt;/code&gt; still showed plugin processes after the parent had gone away. The owner task was the only place that called &lt;code&gt;child.kill()&lt;/code&gt;, but Tokio tasks are cancelled when the runtime is torn down. The task that was supposed to clean up the child could be dropped before it reached the shutdown branch.&lt;/p&gt;

&lt;p&gt;The fix is one line at spawn time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nf"&gt;.kill_on_drop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dropping the child handle is enough to terminate the process. This is a better invariant than "the async task will always get a chance to run one more branch before the runtime disappears", because that invariant is not true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The registry bug
&lt;/h2&gt;

&lt;p&gt;This is the bug that changed how I think about the registry.&lt;/p&gt;

&lt;p&gt;Registering a driver, built-in or plugin, eventually meant inserting it into a map keyed by driver id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The built-in drivers have the ids &lt;code&gt;"mysql"&lt;/code&gt;, &lt;code&gt;"postgres"&lt;/code&gt; and &lt;code&gt;"sqlite"&lt;/code&gt;. A plugin declares its id in &lt;code&gt;manifest.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing stopped a plugin from declaring this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mysql"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install that plugin and the map insert shadows the built-in MySQL driver. From that point, an existing MySQL connection can be routed to the third-party process that claimed to be MySQL. Tabularis resolves the password from the OS keychain and hands it to "the MySQL driver". The attacker does not need a memory corruption bug. The attack is a manifest entry.&lt;/p&gt;

&lt;p&gt;I caught this during the original implementation, before it shipped, but it was close enough to be uncomfortable. The fix in &lt;a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/src/plugins/manager.rs" rel="noopener noreferrer"&gt;&lt;code&gt;src-tauri/src/plugins/manager.rs&lt;/code&gt;&lt;/a&gt; is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;BUILTIN_DRIVER_IDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"mysql"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"postgres"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sqlite"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;BUILTIN_DRIVER_IDS&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Plugin id '{}' collides with a built-in driver and was refused"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plugin that claims a built-in id is refused at load time.&lt;/p&gt;

&lt;p&gt;The lesson is not that this particular denylist is clever. It is that an identity namespace shared by trusted and untrusted code is a security boundary. The &lt;code&gt;HashMap&lt;/code&gt; looked like plumbing. In practice it was deciding which process received credentials from the keychain.&lt;/p&gt;

&lt;p&gt;That is a useful class of bug to remember: the dangerous code is not always the code that parses packets or runs SQL. Sometimes it is the code that chooses who gets to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the word "sandboxed"
&lt;/h2&gt;

&lt;p&gt;It is tempting to call this sandboxing. I have used that word myself, but it needs qualification.&lt;/p&gt;

&lt;p&gt;Running a driver as a separate process gives Tabularis two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fault isolation.&lt;/strong&gt; A crash becomes EOF on a pipe, not a corrupted heap in the GUI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency isolation.&lt;/strong&gt; A plugin can bring its own runtime and packages without linking them into the application.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not make an untrusted plugin safe. The plugin process runs as the user. It can read files the user can read, open network connections the user can open, and do the normal things any program on the machine can do. A pipe is not a jail.&lt;/p&gt;

&lt;p&gt;Real containment against hostile code needs operating-system mechanisms: seccomp, pledge/unveil, the macOS sandbox, Windows job objects and AppContainer-style boundaries, or something equivalent. Tabularis does not have that layer yet.&lt;/p&gt;

&lt;p&gt;So the honest trust model is the same one you already use for packages, editor extensions and database client plugins: you are trusting the plugin author. The credential-shadowing fix above solves a narrower problem. It prevents Tabularis from automatically handing credentials to a plugin just because it chose a privileged name. That is worth doing even before a stronger sandbox exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing a plugin
&lt;/h2&gt;

&lt;p&gt;The registry is &lt;a href="https://github.com/TabularisDB/tabularis/blob/main/plugins/registry.json" rel="noopener noreferrer"&gt;a JSON file in the repo&lt;/a&gt; on GitHub, fetched by &lt;a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/src/plugins/registry.rs" rel="noopener noreferrer"&gt;&lt;code&gt;src-tauri/src/plugins/registry.rs&lt;/code&gt;&lt;/a&gt;. Installing a plugin, in &lt;a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/src/plugins/installer.rs" rel="noopener noreferrer"&gt;&lt;code&gt;src-tauri/src/plugins/installer.rs&lt;/code&gt;&lt;/a&gt;, fetches a ZIP over HTTPS, unpacks it into a temporary directory, checks that &lt;code&gt;manifest.json&lt;/code&gt; exists and parses, and then atomically renames the directory into place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nn"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tmp_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;final_dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// .tmp-&amp;lt;id&amp;gt; -&amp;gt; &amp;lt;id&amp;gt;, atomically&lt;/span&gt;
    &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to finalize plugin installation: {e}"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The atomic rename matters because the loader should never see half of a plugin. Either the install finished and the directory has a valid manifest, or there is no plugin.&lt;/p&gt;

&lt;p&gt;Three months later, this is still the weakest part of the install story: no signatures, no checksum pinning. The trust chain is "you trust the registry review, and you trust GitHub over HTTPS to deliver the ZIP". Signing is on the roadmap. Until then, it is better to describe the chain as it is than to imply more security than exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What held up
&lt;/h2&gt;

&lt;p&gt;The part that held up is the original reason for doing this: I do not need to know about your database for Tabularis to speak to it.&lt;/p&gt;

&lt;p&gt;Because a driver is just a process that reads a line and writes a line, the CSV driver can be Python, the Google Sheets driver can be Rust with OAuth dependencies, and a future driver can use whatever client library its database community already trusts. The Tabularis core does not need that code in its build, in its address space, or in its release cycle.&lt;/p&gt;

&lt;p&gt;This is not a sophisticated plugin architecture. That is the point. The sophistication is in the edges: one owner for the pipes, request ids for out-of-order responses, cancellation for abandoned calls, process cleanup that survives runtime teardown, and an explicit boundary between trusted built-in ids and plugin ids.&lt;/p&gt;

&lt;p&gt;The code is in &lt;a href="https://github.com/TabularisDB/tabularis" rel="noopener noreferrer"&gt;the Tabularis repo&lt;/a&gt;, mostly under &lt;a href="https://github.com/TabularisDB/tabularis/tree/main/src-tauri/src/plugins" rel="noopener noreferrer"&gt;&lt;code&gt;src-tauri/src/plugins/&lt;/code&gt;&lt;/a&gt;. If you want to read a real driver instead of the trait in the abstract, the &lt;a href="https://github.com/TabularisDB/tabularis-google-sheets-plugin" rel="noopener noreferrer"&gt;Google Sheets plugin&lt;/a&gt; is the one I would start with.&lt;/p&gt;

</description>
      <category>plugins</category>
      <category>rust</category>
      <category>architecture</category>
      <category>tokio</category>
    </item>
    <item>
      <title>The database has to be a defensive boundary again</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Wed, 13 May 2026 08:46:09 +0000</pubDate>
      <link>https://dev.to/debba/the-database-has-to-be-a-defensive-boundary-again-1o3e</link>
      <guid>https://dev.to/debba/the-database-has-to-be-a-defensive-boundary-again-1o3e</guid>
      <description>&lt;p&gt;For two decades the database has been able to outsource trust to the application layer. The app authenticated users, sanitized inputs, enforced business rules, and the DB just executed whatever came through the connection pool. That worked because the caller was almost always software written by someone, reviewed by someone, and shipped on a release train.&lt;/p&gt;

&lt;p&gt;Agents don't fit that picture.&lt;/p&gt;

&lt;p&gt;Once an LLM with tool access holds a live connection to your production database, the assumptions behind the application-as-perimeter model stop being true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Connections aren't short-lived anymore. A tool-using agent can keep a session open across a long reasoning loop, with the SQL emerging one token at a time.&lt;/li&gt;
&lt;li&gt;The caller isn't deterministic. Two runs of the same prompt can produce different queries. Sometimes very different ones.&lt;/li&gt;
&lt;li&gt;Writes aren't intentional in the way a human commit is. An agent will issue an &lt;code&gt;UPDATE&lt;/code&gt; without a &lt;code&gt;WHERE&lt;/code&gt; clause if its plan says so.&lt;/li&gt;
&lt;li&gt;Failures don't surface loudly. An exception that would have woken up a developer can be absorbed by the model and rationalized into the next step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Short version: the application layer used to be the boundary. With agents in the loop, it isn't. The database has to defend itself again.&lt;/p&gt;

&lt;p&gt;That's most of the reason the MCP safety work in &lt;a href="https://github.com/TabularisDB/tabularis" rel="noopener noreferrer"&gt;Tabularis&lt;/a&gt; looks the way it does. The MCP server is the actual surface where an agent and a real database meet, and that surface needs guarantees the model can't talk its way around.&lt;/p&gt;

&lt;p&gt;A few of the pieces we shipped:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only connections.&lt;/strong&gt; Not "the agent promises not to write" — the connection itself rejects writes. If the agent's plan calls for an &lt;code&gt;UPDATE&lt;/code&gt; on a read-only connection, it fails at the boundary, before the row is gone. The classifier strips strings, comments and quoted identifiers before scanning the keyword, and treats anything ambiguous as a write. Fail-closed is the safer default when the alternative is a corrupted production table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approval gates with pre-flight &lt;code&gt;EXPLAIN&lt;/code&gt;.&lt;/strong&gt; Before a write (or a heavy read) actually runs, we surface the statement together with the planner's view of it for human approval. &lt;code&gt;EXPLAIN&lt;/code&gt; turns out to be the right unit here: it shows the model's intent translated into what the database will really do, and that's often where the divergence between "what the agent said" and "what would have happened" shows up. You can fix the WHERE clause inside the modal, then approve. Both the original and the edited query are kept, linked by the same approval id.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query audit logs.&lt;/strong&gt; Every statement an agent issues is stored locally — one line of JSON per call — with its prompt context, the connection it used, the rows it touched, and the outcome. When something goes wrong (and with agents, something goes wrong) the audit log is how you reconstruct what actually happened, not what the model claims it did.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxrgzom8552l1hf9s14xc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxrgzom8552l1hf9s14xc.png" alt="Tabularis MCP Activity panel grouped into sessions, with an Export as Notebook button on each session" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full MCP activity tracing.&lt;/strong&gt; Tool calls, results, errors, timing: the whole exchange between the agent and Tabularis is observable. Events can be flat-filtered or auto-grouped into sessions by inactivity gaps, and any session can be exported as a SQL notebook you can replay, diff against another run, or attach to a PR. When a model starts improvising, you can usually pinpoint the exact tool call where it happened.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuh10slwa2d7om4yppyxu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuh10slwa2d7om4yppyxu.png" alt="Detail view of a single MCP audit event, showing the query, classifier kind, connection, status and the row of context surrounding it" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of these ideas are new. DBAs have wanted half of them for years. We could get away without them because the application layer was a decent proxy for "someone reasoned about this before it ran."&lt;/p&gt;

&lt;p&gt;That proxy is gone. Putting the guarantees back inside the database itself is cheap compared to finding out, after the fact, that an agent dropped a column at 3am because its context window was full of stale documentation.&lt;/p&gt;

&lt;p&gt;"Trust the application layer" was a fine default. With agents in the loop, it stops being one.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>opensource</category>
      <category>database</category>
      <category>mcp</category>
    </item>
    <item>
      <title>I Built an Open Source GitHub Dashboard Because My Repositories Were Becoming Unmanageable</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Mon, 04 May 2026 13:19:08 +0000</pubDate>
      <link>https://dev.to/debba/i-built-an-open-source-github-dashboard-because-my-repositories-were-becoming-unmanageable-1hdk</link>
      <guid>https://dev.to/debba/i-built-an-open-source-github-dashboard-because-my-repositories-were-becoming-unmanageable-1hdk</guid>
      <description>&lt;p&gt;&lt;em&gt;A few weeks ago I started building something called &lt;a href="https://github.com/debba/gh-dashboard" rel="noopener noreferrer"&gt;gh-dashboard&lt;/a&gt;, a local-first dashboard for developers juggling too many GitHub repos at once.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What began as a tiny weekend tool to reduce tab overload quietly turned into something I now open every single morning. This post is about why I built it, the workflow problem behind it, and why I think a lot more developers are dealing with the same thing than we admit.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We're all learning how to ship more side projects. If you're "in the bubble" it can feel like everyone is repo-maxxing. Shipping weekly. Spinning up agents to scaffold full apps overnight. New OSS dropped every Friday. The reality I see with most developers is much more normal:&lt;/p&gt;

&lt;p&gt;They have six or seven repos sitting in various states of half-attention.&lt;/p&gt;

&lt;p&gt;A side project from last year that still gets an issue every couple of months. A library someone mentioned in a thread once. A fork they meant to upstream. The repo they pinned on their profile that hasn't seen a commit since November. Most people I know don't have a "repo factory" problem. They have an &lt;em&gt;I have no idea what's going on across my own GitHub anymore&lt;/em&gt; problem.&lt;/p&gt;

&lt;p&gt;I've roasted plenty of developers for letting their projects rot. This year I realized I had been doing the same thing, just with more discipline pretending otherwise. Six GitHub tabs open on a Sunday morning, clicking between them, trying to figure out what actually needed me that week.&lt;/p&gt;

&lt;p&gt;My maintenance routine suddenly felt... less of a routine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Web Version Of Copy/Paste
&lt;/h2&gt;

&lt;p&gt;The data was always there. The interface just made me click for it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Anything new since yesterday?&lt;/em&gt; &lt;em&gt;Which repo is going stale?&lt;/em&gt; &lt;em&gt;Whose PR have I been ignoring?&lt;/em&gt; &lt;em&gt;Is anyone actually using the latest release?&lt;/em&gt; Each one lived behind a different tab, a different filter, a different page. Cheap individually, expensive in aggregate.&lt;/p&gt;

&lt;p&gt;I use a normal-ish setup. VS Code, the &lt;code&gt;gh&lt;/code&gt; CLI, a couple of PATs, a folder with too many cloned repos. Nothing fancy. And every morning I'd ferry context between tabs the same way junior devs ferry errors between VS Code and ChatGPT — manually, slowly, badly.&lt;/p&gt;

&lt;p&gt;The obvious fix is: use GitHub Projects. The less obvious problem is: Projects is built for working &lt;em&gt;inside&lt;/em&gt; a repo, not across all of them at once. It shows you what a single project thinks you should care about, not what you actually need to triage across your whole footprint.&lt;/p&gt;

&lt;p&gt;I didn't want more from GitHub. I wanted less of it, arranged differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Weekend Hack I Kept Opening
&lt;/h2&gt;

&lt;p&gt;The first version was rough. A single React page hitting the API directly, no caching, no backend, the token sitting in &lt;code&gt;localStorage&lt;/code&gt; where it absolutely shouldn't have been. Built on a weekend.&lt;/p&gt;

&lt;p&gt;But I kept opening it every morning. That is the only signal that ever matters with an internal tool — whether you reach for it without being reminded.&lt;/p&gt;

&lt;p&gt;So I rewrote it properly, then posted about the idea on Reddit, mostly to check if I was alone with this.&lt;/p&gt;

&lt;p&gt;I was not. The replies were almost all variations of &lt;em&gt;yes, this, exactly.&lt;/em&gt; Maintainers of small libraries. Indie devs with a graveyard of side projects. Tiny teams who didn't want a full Projects setup but needed an overview. People quietly assuming this was a personal failing.&lt;/p&gt;

&lt;p&gt;That thread is the reason this is open source instead of a script in &lt;code&gt;~/dev/tools/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local Means Local
&lt;/h2&gt;

&lt;p&gt;The naive version of my project was easy: spin it up as a SaaS, ask people for a GitHub token, host it somewhere, slap a Pro tier on it. And call it a day.&lt;/p&gt;

&lt;p&gt;Except... tokens and security are a thing. I already play it too fast and loose with my own credentials. I'm trying to be better about that. I've also been on the other side of that ask too many times to put someone else through it. I needed local-first visibility, not "give a stranger read access to every repo you touch."&lt;/p&gt;

&lt;p&gt;That became the design constraint for &lt;code&gt;gh-dashboard&lt;/code&gt;: it runs on your machine, the OAuth app belongs to you, and the token never reaches the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub As The Data, Your Machine As The Trust Surface
&lt;/h2&gt;

&lt;p&gt;The project is a small Node backend plus a React frontend.&lt;/p&gt;

&lt;p&gt;The backend is the GitHub-facing part. It handles OAuth Device Flow, talks to REST and GraphQL, caches responses, and exposes JSON to the frontend.&lt;/p&gt;

&lt;p&gt;The frontend is the human-facing part. Repository overview ranked by triage value, cross-repo issues and PRs in one list, a Kanban board for triage sessions, per-repo deep dives, and a daily digest of what changed — with optional AI-generated narrative if you plug in an OpenAI key.&lt;/p&gt;

&lt;p&gt;I did not want consent hidden inside a hosted dashboard somewhere. I wanted separate surfaces. GitHub is the data layer. Your machine is where the token lives and where you actually look.&lt;/p&gt;

&lt;p&gt;That feels like the right boundary.&lt;/p&gt;

&lt;p&gt;The v1 surface is intentionally boring. List repos, list issues, list PRs, summarize what changed, flag stale stuff. That is enough to stop me from clicking through six tabs every morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not GitHub Itself
&lt;/h2&gt;

&lt;p&gt;I'm not naive to the fact that this overlaps with things GitHub already does. Notifications exist. Projects exist. The mobile app exists.&lt;/p&gt;

&lt;p&gt;I am not trying to replace any of them. I am trying to make my normal triage routine less sloppy.&lt;/p&gt;

&lt;p&gt;The goal is for the easy path to be the focused path: one place, no push notifications, attention-first, quiet when there is nothing actually demanding me. That already feels better than tab-juggling chaos.&lt;/p&gt;

&lt;h2&gt;
  
  
  More To Do
&lt;/h2&gt;

&lt;p&gt;I still have plenty to do on this project. It's a rough first pass. Smarter health scoring. Daily digests that adapt to what you actually read. Saved filters. Lightweight team workflows that don't bloat into a project tracker. Smoother first-run.&lt;/p&gt;

&lt;p&gt;But the direction feels right and I think it solves a real problem that I imagine other folks have.&lt;/p&gt;

&lt;p&gt;I'm gonna keep using it and iterating on it for my workflow. Feel free to check it out!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/debba/gh-dashboard" rel="noopener noreferrer"&gt;https://github.com/debba/gh-dashboard&lt;/a&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>opensource</category>
      <category>react</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I built a Claude Code skill that turns negative competitor reviews into a roadmap</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Sun, 26 Apr 2026 08:37:49 +0000</pubDate>
      <link>https://dev.to/debba/i-built-a-claude-code-skill-that-turns-negative-competitor-reviews-into-a-roadmap-2c1m</link>
      <guid>https://dev.to/debba/i-built-a-claude-code-skill-that-turns-negative-competitor-reviews-into-a-roadmap-2c1m</guid>
      <description>&lt;p&gt;Picking what to build next is the part of running a side project I'm worst at. Open the issue tracker, stare at it, close the issue tracker. Repeat next weekend.&lt;/p&gt;

&lt;p&gt;A few weeks ago I tried something different: instead of asking myself, I asked the people who already left a competitor. There's a specific kind of useful buried in a 1-star G2 review — someone took the time to write down what they wanted and didn't get. That's a roadmap. The problem is reading hundreds of them.&lt;/p&gt;

&lt;p&gt;So I built GapHunter, a Claude Code skill that does it for me.&lt;/p&gt;

&lt;p&gt;You run it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/gaphunter DBeaver TablePlus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It searches G2, Capterra, TrustRadius, Reddit, GitHub Issues and Hacker News in parallel, clusters near-duplicate complaints (so "no dark mode" and "lacks dark theme" collapse into one finding), then reads your own repo : package.json, Cargo.toml, the src/ tree — and cross-references the gaps against what you've already shipped.&lt;/p&gt;

&lt;p&gt;The output is two paired files in docs/: a JSON sidecar and a self-contained HTML report you just double-click. Tabs for Summary, Quick Wins (high priority × small effort, the obvious place to start), a Comparison matrix if you ran it against multiple competitors, and a Plan tab that lists implementation steps and the files in your repo to touch for each finding.&lt;/p&gt;

&lt;p&gt;It's not perfect. Sources occasionally 403, the semantic clustering still groups things it shouldn't sometimes, and it can't see private repos of the competitors (oviously). But the first time I ran it I had a one-page list of things people are actively complaining about not having in tools I compete with — annotated with which ones I'd already implemented. A couple of hours of manual research, one command.&lt;/p&gt;

&lt;p&gt;The skill itself, the HTML viewer, the CSS: all of it was written inside Claude Code. MIT licensed if you want to fork it or steal the template.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/debba/gaphunter-skill" rel="noopener noreferrer"&gt;https://github.com/debba/gaphunter-skill&lt;/a&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Databases Are Not Becoming Chatbots</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Fri, 17 Apr 2026 11:30:38 +0000</pubDate>
      <link>https://dev.to/debba/databases-are-not-becoming-chatbots-15gg</link>
      <guid>https://dev.to/debba/databases-are-not-becoming-chatbots-15gg</guid>
      <description>&lt;p&gt;Over the last two months, while building a database tool and adding AI features to it, I started realizing that I was not just shipping product work. I was bumping into a larger question: what happens to databases when software no longer just reads and writes records, but also interprets schema, suggests queries, explains plans, preserves context, and collaborates with language models?&lt;/p&gt;

&lt;p&gt;My current view is simple: databases are not becoming chatbots.&lt;/p&gt;

&lt;p&gt;The real change is happening in the layer around the database.&lt;/p&gt;

&lt;p&gt;Databases still matter because structured truth still matters. LLMs do not replace the need for schemas, constraints, transactions, and systems of record. If anything, they make those things more valuable.&lt;/p&gt;

&lt;p&gt;What AI changes is interpretation, navigation, and action around the database.&lt;/p&gt;

&lt;p&gt;That became very concrete to me while building &lt;code&gt;Tabularis&lt;/code&gt;, especially once AI, notebooks, visual explainability, MCP, and plugins started living in the same workflow.&lt;/p&gt;

&lt;p&gt;Here are the main bets I find myself making:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI should not become the source of truth&lt;/li&gt;
&lt;li&gt;AI should stay optional&lt;/li&gt;
&lt;li&gt;preserved context matters more than one-shot generation&lt;/li&gt;
&lt;li&gt;the future is probably composable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here are the mistakes I think I may be making:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;using AI where better UX would be enough&lt;/li&gt;
&lt;li&gt;overestimating generation and underestimating control&lt;/li&gt;
&lt;li&gt;underestimating auditability and reproducibility&lt;/li&gt;
&lt;li&gt;designing too much for the future&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote the full version here, with more detail on the technical decisions and the mistakes behind them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://tabularis.dev/blog/building-tabularis-future-of-databases-ai" rel="noopener noreferrer"&gt;Read the full article on tabularis.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>ai</category>
      <category>programming</category>
      <category>opensource</category>
    </item>
    <item>
      <title>From 0 to 1,000 GitHub Stars in 10 Weeks: What Actually Worked</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Tue, 14 Apr 2026 07:24:09 +0000</pubDate>
      <link>https://dev.to/debba/from-0-to-1000-github-stars-in-10-weeks-what-actually-worked-59a6</link>
      <guid>https://dev.to/debba/from-0-to-1000-github-stars-in-10-weeks-what-actually-worked-59a6</guid>
      <description>&lt;p&gt;Ten weeks ago I pushed the first binary of &lt;a href="https://github.com/debba/tabularis" rel="noopener noreferrer"&gt;Tabularis&lt;/a&gt; to GitHub. A cross-platform database client built with Tauri (Rust + React/TypeScript). One person, a late-night frustration, and a SQL editor.&lt;/p&gt;

&lt;p&gt;Last week the repo crossed &lt;strong&gt;1,000 stars&lt;/strong&gt;. Today there are 1,086 stars, 70 forks, 15 contributors, 41 releases, and a plugin ecosystem that didn't exist eight weeks ago.&lt;/p&gt;

&lt;p&gt;No marketing budget. No growth hacks.&lt;/p&gt;

&lt;p&gt;I wrote a detailed post about what actually worked, what didn't, and what I'd do differently. Here are the highlights.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship fast, ship broken
&lt;/h2&gt;

&lt;p&gt;The first week produced &lt;strong&gt;fifteen releases&lt;/strong&gt;. Some were embarrassing. I shipped anyway. Nobody remembers your v0.3.0. People remember whether the tool was useful when they tried it, and whether it got better when they came back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build an extension point early
&lt;/h2&gt;

&lt;p&gt;The decision that changed the trajectory: shipping a &lt;strong&gt;plugin system&lt;/strong&gt; (language-agnostic JSON-RPC protocol) at the one-month mark. Within two weeks the community had added Redis, ClickHouse, CSV, and a Hacker News plugin. It turned users into builders.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI factor
&lt;/h2&gt;

&lt;p&gt;I need to be honest: Tabularis would not exist without AI-assisted development. Specifically, &lt;a href="https://claude.ai/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;41 releases in eleven weeks, one person. That math doesn't work without a force multiplier. But AI doesn't replace experience. It amplifies it. Claude Code didn't design the plugin architecture or decide that JSON-RPC was the right protocol. Those decisions came from years of building software.&lt;/p&gt;

&lt;p&gt;What AI did was collapse the distance between a decision and its implementation. Once I knew &lt;em&gt;what&lt;/em&gt; to build, I could build it in hours instead of days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers at week 11
&lt;/h2&gt;

&lt;p&gt;The 1,000-star milestone landed at week ten. Here's where things stand today, one week later:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Week 4&lt;/th&gt;
&lt;th&gt;Week 10&lt;/th&gt;
&lt;th&gt;Week 11 (today)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stars&lt;/td&gt;
&lt;td&gt;~270&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,086&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contributors&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Releases&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forks&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;70&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugins&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Languages&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issues&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;~70&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;81&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pull Requests&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;~35&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downloads&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;~6,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7,100+&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  72 countries and counting
&lt;/h2&gt;

&lt;p&gt;One thing that surprised me: where those stars come from. About half of our stargazers have a public location on their GitHub profile, and they span &lt;strong&gt;72 countries&lt;/strong&gt; across every continent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F71g0p1fy1y375y8xasnx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F71g0p1fy1y375y8xasnx.png" alt="Stargazers by country" width="792" height="494"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The United States and China lead, but what stands out is the long tail: South Korea, Germany, France, Brazil, Indonesia, Vietnam. Tabularis isn't a tool for one market. It's a tool for developers, and developers are everywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa2p9qxdgpw04ymt25r6h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa2p9qxdgpw04ymt25r6h.png" alt="RepoStars" width="789" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the full story
&lt;/h2&gt;

&lt;p&gt;The full post covers every phase week by week, what didn't work, and advice for anyone starting an open source project today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://tabularis.dev/blog/from-zero-to-1000-github-stars" rel="noopener noreferrer"&gt;Read the full post on tabularis.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/debba/tabularis" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://discord.gg/YrZPHAwMSG" rel="noopener noreferrer"&gt;Join the Discord&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're building something in the open, I'd love to hear your story. Drop a comment or find me on &lt;a href="https://x.com/debba_92" rel="noopener noreferrer"&gt;X&lt;/a&gt; or &lt;a href="https://discord.gg/YrZPHAwMSG" rel="noopener noreferrer"&gt;Discord&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>github</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I am building a Notebook Environment for SQL Inside a Database Client</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Fri, 03 Apr 2026 06:42:43 +0000</pubDate>
      <link>https://dev.to/debba/i-am-building-a-notebook-environment-for-sql-inside-a-database-client-22j7</link>
      <guid>https://dev.to/debba/i-am-building-a-notebook-environment-for-sql-inside-a-database-client-22j7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This post is also available on &lt;a href="https://tabularis.dev/blog/notebooks-sql-analysis-reimagined" rel="noopener noreferrer"&gt;tabularis.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You know the drill. Write a query, get a table. Need to build on that result? Copy-paste into the next query. Need a chart? Export CSV, open a spreadsheet. Want to document the analysis? Paste SQL into a doc and pray nothing drifts.&lt;/p&gt;

&lt;p&gt;I got tired of this loop, so I'm building &lt;strong&gt;Notebooks&lt;/strong&gt; into &lt;a href="https://tabularis.dev" rel="noopener noreferrer"&gt;Tabularis&lt;/a&gt; — a cell-based SQL analysis environment that lives inside the database client. No Jupyter, no Python runtime, no context switching. Just SQL + markdown cells, inline charts, and a few features that make multi-query analysis way less painful.&lt;/p&gt;

&lt;p&gt;It's still in development, but the core works. Here's what it looks like and how it's shaping up.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;A notebook is a sequence of &lt;strong&gt;cells&lt;/strong&gt; — SQL or markdown. SQL cells run against your database and show results inline with the same data grid from the query editor (sorting, filtering, resizable panels). Markdown cells are for documentation between queries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpcc5x8ehq4qbbe84eui4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpcc5x8ehq4qbbe84eui4.png" alt="Tabularis notebook with SQL cell, data grid results, and inline pie chart" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Cell References via CTEs
&lt;/h2&gt;

&lt;p&gt;This is the part I'm most excited about.&lt;/p&gt;

&lt;p&gt;Any SQL cell can reference another cell's query with &lt;code&gt;{{cell_N}}&lt;/code&gt;. At execution time, it gets resolved as a CTE:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Cell 1: Base query&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;

&lt;span class="c1"&gt;-- Cell 3: References Cell 1&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;cell_1&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;cell_1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cell_1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No temp tables, no copy-paste. Change the base query, re-run downstream cells, everything stays in sync. You can chain across multiple cells and every intermediate result stays visible.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2xdegvdfdo5gxtcf5g59.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2xdegvdfdo5gxtcf5g59.png" alt="Two SQL cells with cell reference — Cell 11 filters results from Cell 10 using CTE syntax" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Inline Charts
&lt;/h2&gt;

&lt;p&gt;Any result with 2+ columns and at least one row can be charted — bar, line, or pie — directly in the cell. Pick a label column and value columns, done. Config is saved with the cell.&lt;/p&gt;

&lt;p&gt;Not meant to replace BI tools. It's for when you're exploring and want a quick visual check before writing the next query.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5txfz5f8214gzvxnkef5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5txfz5f8214gzvxnkef5.png" alt="SQL cell with bar chart and label column selector dropdown open showing chart configuration" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcy9on5xsv0ffhtuthiyp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcy9on5xsv0ffhtuthiyp.png" alt="Pie chart and line chart in separate notebook cells showing chart type variety" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Parameters
&lt;/h2&gt;

&lt;p&gt;Define once, use everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="vi"&gt;@start_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt;
&lt;span class="vi"&gt;@end_date&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2024-12-31'&lt;/span&gt;
&lt;span class="vi"&gt;@min_amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every SQL cell with &lt;code&gt;@start_date&lt;/code&gt; gets it substituted before execution. Change the value, re-run — all queries pick it up. Great for monthly reports, cohort comparisons, anything where the logic stays the same but inputs change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frq61kpp9sasm6oz2jalq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frq61kpp9sasm6oz2jalq.png" alt="Notebook parameters panel with productCategory and orderStatus variables defined" width="800" height="104"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Parallel Execution
&lt;/h2&gt;

&lt;p&gt;Not every cell depends on the previous one. Mark independent cells with the lightning bolt icon and they run &lt;strong&gt;concurrently&lt;/strong&gt; during "Run All" instead of waiting in sequence. For notebooks with heavy queries against different tables, this makes a real difference.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fox4o8psia8dvk4o5zuvd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fox4o8psia8dvk4o5zuvd.png" alt="Two SQL cells with parallel execution lightning bolt icons enabled for concurrent running" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Run All + Stop on Error
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ctrl+Shift+Enter&lt;/code&gt; runs every SQL cell top to bottom. &lt;strong&gt;Stop on Error&lt;/strong&gt; controls whether it halts at the first failure or keeps going. After execution, a summary card shows succeeded/failed/skipped counts — click a failed cell to jump straight to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Multi-Database in One Notebook
&lt;/h2&gt;

&lt;p&gt;Each SQL cell can target a different database connection. Pull from production PostgreSQL in one cell, compare with your analytics SQLite in the next. Works across MySQL, MariaDB, PostgreSQL, and SQLite.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp97yaeegksu7zaxpuqpr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp97yaeegksu7zaxpuqpr.png" alt="SQL cell with database selector dropdown showing multiple MySQL, PostgreSQL, and SQLite connections" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Execution History
&lt;/h2&gt;

&lt;p&gt;Every cell keeps its last 10 runs — timestamp, duration, row count. You can restore any previous query version. Useful when you've been iterating and need to go back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1227tdrkigchk05swslw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1227tdrkigchk05swslw.png" alt="Execution history panel showing timestamp, duration, and row count for previous query runs" width="800" height="291"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  AI Assist
&lt;/h2&gt;

&lt;p&gt;Each SQL cell has &lt;strong&gt;AI&lt;/strong&gt; and &lt;strong&gt;Explain&lt;/strong&gt; buttons — describe what you want, get SQL back, or break down an existing query. There's also an auto-naming feature: click the sparkles icon and AI generates a cell name based on the content. Named cells show up in a &lt;strong&gt;notebook outline&lt;/strong&gt; for navigation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjmzzqx94hfof1d0y2tse.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjmzzqx94hfof1d0y2tse.png" alt="SQL cell with AI and Explain buttons, execution history, and collapsed cells overview" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7qm9f3ko34m4llabfhqv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7qm9f3ko34m4llabfhqv.png" alt="Notebook outline panel with AI-generated cell names and markdown headings as table of contents" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Organization
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Collapse&lt;/strong&gt; cells to show just headers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drag and drop&lt;/strong&gt; to reorder&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cell names&lt;/strong&gt; (manual or AI-generated) for identity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Markdown cells&lt;/strong&gt; as section dividers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgy0u3arpwo9qlrsst2aj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgy0u3arpwo9qlrsst2aj.png" alt="Notebook with collapsed and expanded SQL and markdown cells showing organization" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Import / Export
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.tabularis-notebook&lt;/code&gt;&lt;/strong&gt; — JSON with cells, parameters, charts. No result data. Share it, import it, connect to a different DB, run it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTML export&lt;/strong&gt; — self-contained document with rendered markdown, syntax highlighting, embedded result tables. Dark-themed.&lt;/li&gt;
&lt;li&gt;Individual results export as &lt;strong&gt;CSV&lt;/strong&gt; or &lt;strong&gt;JSON&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's Not Done Yet
&lt;/h2&gt;

&lt;p&gt;Being honest about rough edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Large notebooks&lt;/strong&gt; (30+ cells) need better virtualization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Circular reference detection&lt;/strong&gt; is missing — needs a dependency graph&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chart customization&lt;/strong&gt; is minimal (no axis labels, no color palettes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyboard navigation&lt;/strong&gt; between cells is partially implemented&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notebook-level undo/redo&lt;/strong&gt; doesn't exist yet (cell-level works via Monaco)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Build This?
&lt;/h2&gt;

&lt;p&gt;Database clients haven't really evolved beyond "connect, query, see table." Analysis tooling moved forward — Jupyter, Observable, dbt — but the DB client stayed behind.&lt;/p&gt;

&lt;p&gt;Notebooks in Tabularis bet that the database client is the right place for exploratory SQL analysis. You already have the connection, the schema, autocomplete, query history. Cells, charts, references and parameters on top of that means the whole workflow — first query to shareable report — happens without switching tools.&lt;/p&gt;

&lt;p&gt;It's not a Jupyter replacement. No Python, no R. It's purpose-built for SQL, and for the kind of work most people actually do with their database every day — ad-hoc exploration, report building, data validation, performance investigation — that focus is a feature.&lt;/p&gt;

&lt;p&gt;Landing soon. If you want to try it, check out &lt;a href="https://tabularis.dev" rel="noopener noreferrer"&gt;Tabularis&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>One Month Building in Public — Here's What I Shipped</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Thu, 26 Feb 2026 09:32:38 +0000</pubDate>
      <link>https://dev.to/debba/one-month-building-in-public-heres-what-i-shipped-10c3</link>
      <guid>https://dev.to/debba/one-month-building-in-public-heres-what-i-shipped-10c3</guid>
      <description>&lt;p&gt;Tabularis was born on the night of January 25th, 2026. A solo project — one person, a frustration turned into code, a binary pushed to GitHub.&lt;/p&gt;

&lt;p&gt;Today is exactly one month since that first release. And in one month, it stopped being a solo project.&lt;/p&gt;

&lt;p&gt;I believe deeply in what this can become. Not just as a database tool, but as proof that a small team — or even a single person — can build something genuinely useful, genuinely open, and genuinely worth growing. That belief hasn't wavered for a single day. If anything, watching people show up and contribute has made it stronger.&lt;/p&gt;

&lt;p&gt;A lot has happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk1xbr7y5kck8btrgptka.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk1xbr7y5kck8btrgptka.png" alt="Repostars" width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;~&lt;strong&gt;280 stars&lt;/strong&gt;. &lt;strong&gt;5 contributors&lt;/strong&gt;. Around &lt;strong&gt;1,000 downloads&lt;/strong&gt;. Around twenty issues opened by people who actually tried the product, pull requests reviewed, bugs squashed.&lt;/p&gt;

&lt;p&gt;For a one-month-old open source project, that's not nothing — that's a community. And communities don't happen by accident. They happen because people show up.&lt;/p&gt;

&lt;p&gt;So: thank you. Genuinely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;p&gt;A month ago, Tabularis could connect to PostgreSQL, MySQL, and SQLite. It had a SQL editor, a data grid, and not much else.&lt;/p&gt;

&lt;p&gt;Since then, we've shipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Plugin System&lt;/strong&gt; — the biggest release of the month. A language-agnostic JSON-RPC protocol that lets anyone build a new database driver without touching the core app. The first plugin, &lt;a href="https://github.com/debba/tabularis-duckdb-plugin" rel="noopener noreferrer"&gt;tabularis-duckdb-plugin&lt;/a&gt;, was ready on day one. This is the feature that changes what Tabularis can become: not a tool that supports three databases, but a platform that can support any database.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI Assistant&lt;/strong&gt; — generate SQL from natural language, get explanations for complex queries. Integrated with OpenAI, Anthropic, OpenRouter, and Ollama for those who want to keep everything local.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Visual Query Builder&lt;/strong&gt; — build JOINs and filters without writing a line of SQL.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SSH Tunneling&lt;/strong&gt; — connect to remote databases through SSH with key and password auth.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Split View&lt;/strong&gt; — work with two connections simultaneously, side by side.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Schema Management&lt;/strong&gt; — ER diagrams, inline column editing, table creation wizards.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;MCP Server&lt;/strong&gt; — expose your database to Claude and other AI agents via the Model Context Protocol.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;10+ Themes&lt;/strong&gt; — because your tools should feel good to use.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One month. That's the velocity the community made possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Wiki Is Open
&lt;/h3&gt;

&lt;p&gt;Starting today, the &lt;a href="https://tabularis.dev/wiki" rel="noopener noreferrer"&gt;Tabularis Wiki&lt;/a&gt; is live and open for contributions. Every page has an &lt;strong&gt;Edit on GitHub&lt;/strong&gt; link.&lt;/p&gt;

&lt;p&gt;This is intentional. Documentation written only by the maintainers reflects only the maintainers' perspective. If you've figured out a non-obvious workflow, found a gotcha during setup, or have a tip that saved you time — the wiki is the right place for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The plugin ecosystem is still young. We want drivers for &lt;strong&gt;ClickHouse, CockroachDB&lt;/strong&gt;, and more. We want the registry to grow. We want the community to build things we haven't thought of yet.&lt;/p&gt;

&lt;p&gt;On the core side: better query history, smarter autocomplete, a more complete ER diagram, and performance improvements are all in flight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Involved
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/debba/tabularis" rel="noopener noreferrer"&gt;github.com/debba/tabularis&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discord:&lt;/strong&gt; &lt;a href="https://discord.gg/YrZPHAwMSG" rel="noopener noreferrer"&gt;discord.gg/YrZPHAwMSG&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;brew install --cask tabularis&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Snap: &lt;code&gt;snap install tabularis&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;AUR: &lt;code&gt;yay -S tabularis-bin&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Tabularis has been useful to you — tell someone. Open source lives and dies by word of mouth.&lt;/p&gt;

&lt;p&gt;Here's to month two.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Building Tabularis: A Developer's Database Tool That Doesn't Suck</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Mon, 09 Feb 2026 09:56:35 +0000</pubDate>
      <link>https://dev.to/debba/building-tabularis-a-developers-database-tool-that-doesnt-suck-4k73</link>
      <guid>https://dev.to/debba/building-tabularis-a-developers-database-tool-that-doesnt-suck-4k73</guid>
      <description>&lt;p&gt;I've been managing databases for years, and I'm tired of the same two options: heavyweight enterprise tools that feel like flying a 747 to get groceries, or stripped-down CLI utilities that make even simple tasks a chore. There had to be a middle ground—something fast, clean, and actually enjoyable to use.&lt;/p&gt;

&lt;p&gt;So I built Tabularis, starting from a vibe-coding session with Claude Code, and then it evolved as I really put my mind into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Database Tools
&lt;/h2&gt;

&lt;p&gt;Like everyone else, I spent years bouncing between phpMyAdmin, MySQL Workbench, DBeaver, and whatever else was popular that quarter. They all had the same issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Too heavy&lt;/strong&gt;: Enterprise tools pack in features I'll never touch, eating RAM and taking forever to start&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poor UX&lt;/strong&gt;: Clunky interfaces designed by committee, not for actual daily use&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendor lock-in&lt;/strong&gt;: Tools optimized for one database, awkward for others&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing modern features&lt;/strong&gt;: No AI assistance, no visual query building, no MCP integration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real frustration came when I just wanted to quickly check production data or test a query—it shouldn't require launching a 500MB Electron app and waiting 30 seconds for it to connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes Tabularis Different
&lt;/h2&gt;

&lt;p&gt;Tabularis is what I actually wanted to use every day. It's built with Tauri (not Electron), so it's genuinely lightweight—the entire app is under 20MB. Starts in under a second. Feels native because it is native.&lt;/p&gt;

&lt;p&gt;But being lightweight doesn't mean being limited. Here's what actually matters:&lt;/p&gt;

&lt;h3&gt;
  
  
  Connection Management That Makes Sense
&lt;/h3&gt;

&lt;p&gt;SSH tunneling with automatic readiness detection. No more "is the tunnel up yet?" guessing games. Secure password storage in your system keychain, not some custom encrypted file. Clone connection profiles instead of manually recreating them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F786xkm0scmbm2w436rwq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F786xkm0scmbm2w436rwq.png" alt="Connection Manager" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Database Explorer I Always Wanted
&lt;/h3&gt;

&lt;p&gt;A proper tree view that shows everything—tables, views, stored procedures, functions, foreign keys, indexes. Not hidden in submenus or separate windows. Right-click context actions for common operations. Want to see the row count? It's one click, not three windows deep.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwtfay15t4kyscwcijupv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwtfay15t4kyscwcijupv.png" alt="SQL Editor" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ER diagram generator is particularly useful. It's not trying to be a full diagramming tool—it just shows your schema interactively. Pan, zoom, select specific tables. When you're debugging a complex query join, being able to visually trace the relationships saves real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Visual Query Builder (That Actually Works)
&lt;/h3&gt;

&lt;p&gt;Most visual query builders are toys. They work for &lt;code&gt;SELECT * FROM users&lt;/code&gt; and fall apart at the first JOIN. I needed something that could handle real queries—multiple joins, aggregates, WHERE clauses, HAVING filters.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwmuli16h2b155flpjd8o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwmuli16h2b155flpjd8o.png" alt="Visual Query Builder" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The builder uses ReactFlow for drag-and-drop table connections. Create visual relationships between tables, and it generates proper JOIN syntax. Add filters, aggregates, sorting—all visually—and watch the SQL update in real-time. When you need to switch to the SQL editor, you're not starting from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  AI Integration (Optional, But Useful)
&lt;/h3&gt;

&lt;p&gt;This is where it gets interesting. I didn't want to force cloud AI on anyone—privacy matters—so Tabularis supports multiple AI providers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenAI, Anthropic, OpenRouter for cloud options&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ollama for completely local inference&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;OpenAI-compatible APIs (Groq, Perplexity, LocalAI, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbsc77i0ivqulziqtaewz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbsc77i0ivqulziqtaewz.png" alt="AI Query Assistant" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The AI features are straightforward: text-to-SQL generation and query explanation. Type "show me users who signed up last week" and get working SQL. Or paste a complex query and get a plain-English explanation of what it's doing.&lt;/p&gt;

&lt;p&gt;But here's the part I'm most excited about: &lt;strong&gt;MCP Server support&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP Server: Exposing Databases to AI Agents
&lt;/h2&gt;

&lt;p&gt;Tabularis includes a built-in Model Context Protocol (MCP) server. Run &lt;code&gt;tabularis --mcp&lt;/code&gt; and your database connections become available to any MCP-compatible AI agent—Claude Desktop, Cursor, or custom tools.&lt;/p&gt;

&lt;p&gt;This means AI agents can query your databases directly. Not through screenshots or copy-paste, but actual programmatic access. I've been using it with Claude Desktop to analyze production data, generate reports, and debug queries—all without leaving my chat window.&lt;/p&gt;

&lt;p&gt;The architecture is simple: Tabularis runs as an MCP server, exposes your saved connections through a standard protocol, and handles authentication and query execution. The AI agent just sees a set of available databases and can query them like any other data source.&lt;/p&gt;

&lt;p&gt;It's surprisingly powerful. I recently had Claude help me optimize a slow query by letting it inspect the actual table structures, existing indexes, and query execution patterns—all through MCP. It generated three optimization strategies, I tested them, and cut query time by 60%. That workflow wasn't possible with traditional database tools.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building It All
&lt;/h3&gt;

&lt;p&gt;I started Tabularis about six months ago, mostly as a weekend project to scratch my own itch. The tech stack choices were deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tauri v2&lt;/strong&gt; instead of Electron (smaller bundle, better performance, native feel)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React 19&lt;/strong&gt; with TypeScript (familiar, fast development)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS v4&lt;/strong&gt; (rapid UI iteration without CSS bloat)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rust backend&lt;/strong&gt; with SQLx (type-safe queries, excellent performance)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdvk9eukzr9eph0is5aa5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdvk9eukzr9eph0is5aa5.png" alt="Settings and Customization" width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Rust backend handles all database operations through SQLx, which provides compile-time verification of SQL queries. No more runtime surprises about typos in column names. SSH tunneling is handled with native Rust libraries, not shelling out to external processes.&lt;/p&gt;

&lt;p&gt;One interesting challenge was supporting multiple database types (MySQL, PostgreSQL, SQLite) with a unified interface. SQLx helps here with its database-agnostic query builder, but each database has quirks—MySQL's information_schema differs from PostgreSQL's pg_catalog, and SQLite is its own thing entirely.&lt;/p&gt;

&lt;p&gt;The solution was a trait-based provider system in Rust. Each database implements a common &lt;code&gt;DatabaseProvider&lt;/code&gt; trait that defines operations like "fetch tables," "execute query," "get foreign keys." The frontend doesn't care which database it's talking to—it just calls methods on the trait.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzmxhnurighfwa0kldk3w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzmxhnurighfwa0kldk3w.png" alt="ER Diagram View" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Using Tabularis
&lt;/h2&gt;

&lt;p&gt;Getting started is intentionally simple:&lt;/p&gt;

&lt;h3&gt;
  
  
  macOS (Homebrew)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap debba/tabularis
brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; tabularis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Arch Linux
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yay &lt;span class="nt"&gt;-S&lt;/span&gt; tabularis-bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Everyone Else
&lt;/h3&gt;

&lt;p&gt;Download from &lt;a href="https://github.com/debba/tabularis/releases" rel="noopener noreferrer"&gt;releases&lt;/a&gt; for Windows, Linux (AppImage), or macOS (DMG).&lt;/p&gt;

&lt;p&gt;Connect to your database, start exploring. The interface is designed to be obvious—if you've used any database tool before, you'll figure it out in 30 seconds.&lt;/p&gt;

&lt;p&gt;All configuration lives in &lt;code&gt;~/.config/tabularis/&lt;/code&gt; (or your OS equivalent)—connections, saved queries, themes, settings. Plain JSON files, easy to back up or edit manually if needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbsc77i0ivqulziqtaewz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbsc77i0ivqulziqtaewz.png" alt="Data Grid with Inline Editing" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Tabularis is fully functional today, but there's more to build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Better PostgreSQL &amp;amp; SQLite support&lt;/strong&gt;: MySQL is solid, but Postgres and SQLite need more love&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query history&lt;/strong&gt;: Track and revisit past queries across sessions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance monitoring&lt;/strong&gt;: Built-in query profiling and execution plan visualization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is on GitHub, Apache 2.0 licensed. It's built by a developer, for developers. No telemetry, no accounts, no subscriptions. Just a tool that works.&lt;/p&gt;




&lt;p&gt;If you're tired of database tools that feel like they're fighting you instead of helping you, give Tabularis a try. It's what I wish I had years ago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download&lt;/strong&gt;: &lt;a href="https://tabularis.dev" rel="noopener noreferrer"&gt;tabularis.dev&lt;/a&gt; | &lt;strong&gt;Source&lt;/strong&gt;: &lt;a href="https://github.com/debba/tabularis" rel="noopener noreferrer"&gt;github.com/debba/tabularis&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;tabularis --mcp&lt;/code&gt; to enable MCP server mode and start connecting your databases to AI agents.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>database</category>
      <category>rust</category>
      <category>ai</category>
    </item>
    <item>
      <title>I was tired of database tools — so I started building my own</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Tue, 27 Jan 2026 12:04:22 +0000</pubDate>
      <link>https://dev.to/debba/i-was-tired-of-database-tools-so-i-started-building-my-own-3kia</link>
      <guid>https://dev.to/debba/i-was-tired-of-database-tools-so-i-started-building-my-own-3kia</guid>
      <description>&lt;p&gt;Repo url: &lt;a href="https://github.com/debba/debba.sql" rel="noopener noreferrer"&gt;https://github.com/debba/debba.sql&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At some point, I realized I was spending more energy &lt;strong&gt;fighting my database tools&lt;/strong&gt; than actually working with databases.&lt;/p&gt;

&lt;p&gt;Most of the popular DB managers are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;closed source
&lt;/li&gt;
&lt;li&gt;heavy as hell
&lt;/li&gt;
&lt;li&gt;UX-wise… questionable
&lt;/li&gt;
&lt;li&gt;clearly not built with Linux developers in mind
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And yes, some of them &lt;em&gt;look&lt;/em&gt; nice (👀 DataGrip), but they feel like bringing a tank to open a bottle of water.&lt;/p&gt;

&lt;p&gt;So one night, instead of complaining on Twitter or Discord, I did the most dangerous thing a developer can do:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I started vibe coding my own DB manager.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  It started as a hack (literally)
&lt;/h2&gt;

&lt;p&gt;This wasn’t a “vision”, or a “startup idea”.&lt;/p&gt;

&lt;p&gt;It was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;late at night
&lt;/li&gt;
&lt;li&gt;Rust open in my editor
&lt;/li&gt;
&lt;li&gt;Tauri + React on the side
&lt;/li&gt;
&lt;li&gt;a strong feeling of &lt;em&gt;“how hard can it be?”&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I just wanted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;something fast
&lt;/li&gt;
&lt;li&gt;something local
&lt;/li&gt;
&lt;li&gt;something that doesn’t feel like a bloated enterprise product
&lt;/li&gt;
&lt;li&gt;something I can actually understand end-to-end
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No roadmap.&lt;br&gt;&lt;br&gt;
No design doc.&lt;br&gt;&lt;br&gt;
Just hacking.&lt;/p&gt;




&lt;h2&gt;
  
  
  From vibe coding to “wait, this might actually work”
&lt;/h2&gt;

&lt;p&gt;After the first hacky prototype, something unexpected happened.&lt;/p&gt;

&lt;p&gt;I kept adding things.&lt;/p&gt;

&lt;p&gt;Slowly.&lt;br&gt;&lt;br&gt;
Incrementally.&lt;br&gt;&lt;br&gt;
Scratching my own itch.&lt;/p&gt;

&lt;p&gt;What started as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Let me just connect to a DB”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;became:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;multi-database support (SQLite, Postgres, MySQL…)
&lt;/li&gt;
&lt;li&gt;a cleaner mental model
&lt;/li&gt;
&lt;li&gt;better performance than I expected
&lt;/li&gt;
&lt;li&gt;an app that feels &lt;em&gt;mine&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At that point I thought:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ok, either I stop now… or I open-source it and see what happens.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  debba.sql (name pending 😅)
&lt;/h2&gt;

&lt;p&gt;The project is currently called &lt;strong&gt;debba.sql&lt;/strong&gt; — &lt;em&gt;debba&lt;/em&gt; is my nickname and, conveniently, contains &lt;code&gt;db&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Is the name final?&lt;br&gt;&lt;br&gt;
Probably not.&lt;/p&gt;

&lt;p&gt;Is the project done?&lt;br&gt;&lt;br&gt;
Absolutely not.&lt;/p&gt;

&lt;p&gt;Right now it’s:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🧩 a &lt;strong&gt;side project&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🦀 written in &lt;strong&gt;Rust&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🖥️ wrapped with &lt;strong&gt;Tauri&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;⚛️ frontend in &lt;strong&gt;React&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🐧 built with Linux devs in mind
&lt;/li&gt;
&lt;li&gt;🌍 &lt;strong&gt;open source&lt;/strong&gt;, repo public
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No company.&lt;br&gt;&lt;br&gt;
No monetization (for now).&lt;br&gt;&lt;br&gt;
Just a tool I actually want to use.&lt;/p&gt;




&lt;h2&gt;
  
  
  This is not a manifesto (yet)
&lt;/h2&gt;

&lt;p&gt;I’m not claiming this will “replace” anything.&lt;br&gt;&lt;br&gt;
I’m not claiming it’s better than your favorite tool.&lt;/p&gt;

&lt;p&gt;I &lt;em&gt;am&lt;/em&gt; saying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dev tools should feel empowering
&lt;/li&gt;
&lt;li&gt;open source matters
&lt;/li&gt;
&lt;li&gt;UX is not optional
&lt;/li&gt;
&lt;li&gt;Linux users deserve first-class experiences
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And sometimes, the best way to stop complaining…&lt;br&gt;&lt;br&gt;
is to build.&lt;/p&gt;




&lt;h2&gt;
  
  
  I want your feedback
&lt;/h2&gt;

&lt;p&gt;This is the part where I shut up and listen.&lt;/p&gt;

&lt;p&gt;If you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;have strong opinions about DB tools
&lt;/li&gt;
&lt;li&gt;build dev tools yourself
&lt;/li&gt;
&lt;li&gt;care about open source UX
&lt;/li&gt;
&lt;li&gt;are tired of bloated software
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’d love your feedback.&lt;br&gt;&lt;br&gt;
Issues, comments, brutal honesty — all welcome.&lt;/p&gt;

&lt;p&gt;This started as vibe coding at night.&lt;br&gt;&lt;br&gt;
Let’s see where it goes.&lt;/p&gt;

&lt;p&gt;🧪🔥&lt;/p&gt;

&lt;p&gt;Repo url: &lt;a href="https://github.com/debba/debba.sql" rel="noopener noreferrer"&gt;https://github.com/debba/debba.sql&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>rust</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>From VS Code to Zed: building a FreeMarker extension because I needed one</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Thu, 15 Jan 2026 12:37:57 +0000</pubDate>
      <link>https://dev.to/debba/from-vs-code-to-zed-building-a-freemarker-extension-because-i-needed-one-4hm9</link>
      <guid>https://dev.to/debba/from-vs-code-to-zed-building-a-freemarker-extension-because-i-needed-one-4hm9</guid>
      <description>&lt;h1&gt;
  
  
  From VS Code to Zed: building a FreeMarker extension because I needed one
&lt;/h1&gt;

&lt;p&gt;A few months ago, I switched from VS Code to &lt;strong&gt;Zed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not because VS Code is bad — it’s still an amazing editor — but because it slowly became… heavy.&lt;br&gt;&lt;br&gt;
My setup had turned into a Frankenstein monster of freemium extensions, background processes, upsells, and “just one more helper” that somehow made everything slower.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpy377wa75co87sk76hfo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpy377wa75co87sk76hfo.gif" alt="My VS Code setup: I feel thin, sort of stretched, like butter scraped over too much bread." width="360" height="202"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wanted something:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fast
&lt;/li&gt;
&lt;li&gt;open-source
&lt;/li&gt;
&lt;li&gt;opinionated
&lt;/li&gt;
&lt;li&gt;boring in the best possible way
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zed checked all the boxes.&lt;/p&gt;

&lt;p&gt;Until I had to start working with &lt;strong&gt;FreeMarker&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  FreeMarker is still very much alive
&lt;/h2&gt;

&lt;p&gt;If you work in Java or enterprise environments, you already know this.&lt;/p&gt;

&lt;p&gt;FreeMarker isn’t trendy.&lt;br&gt;&lt;br&gt;
It’s not on Hacker News every week.&lt;br&gt;&lt;br&gt;
But it’s &lt;strong&gt;everywhere&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In my case, I’m using it in a project that integrates &lt;strong&gt;Keycloak&lt;/strong&gt;, where FreeMarker templates are still a core part of the customization flow.&lt;/p&gt;

&lt;p&gt;So yes — FreeMarker is old.&lt;br&gt;&lt;br&gt;
And yes — it’s still critical.&lt;/p&gt;

&lt;p&gt;Which made this part a bit painful:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Zed had no FreeMarker support.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No syntax highlighting.&lt;br&gt;&lt;br&gt;
No understanding of directives.&lt;br&gt;&lt;br&gt;
Nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw65xsrry74fshtr9bb7p.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw65xsrry74fshtr9bb7p.jpg" alt="Fine, I’ll do it myself" width="648" height="385"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The “fine, I’ll do it myself” moment
&lt;/h2&gt;

&lt;p&gt;Zed is fast enough that once you get used to it, going back feels… wrong.&lt;/p&gt;

&lt;p&gt;So instead of switching editors again, I thought:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“How hard can it be to write a FreeMarker extension?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;(Last words before opening a tree-sitter grammar.)&lt;/p&gt;

&lt;p&gt;I already knew that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zed uses &lt;strong&gt;tree-sitter&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Syntax highlighting is grammar-driven&lt;/li&gt;
&lt;li&gt;Extensions are lean and explicit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also had a starting point:&lt;br&gt;&lt;br&gt;
a &lt;strong&gt;FreeMarker extension for VS Code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The idea was simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reuse what I could&lt;/li&gt;
&lt;li&gt;adapt what I had to&lt;/li&gt;
&lt;li&gt;learn the Zed extension model along the way&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With some help from &lt;strong&gt;vibe-coding&lt;/strong&gt; and a lot of trial &amp;amp; error, I started porting it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual journey (a.k.a. tree-sitter reality check)
&lt;/h2&gt;

&lt;p&gt;Some things were easier than expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zed’s extension model is refreshingly clean&lt;/li&gt;
&lt;li&gt;Tree-sitter forces you to think properly about structure&lt;/li&gt;
&lt;li&gt;No magic, no hidden layers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some things were… not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FreeMarker syntax is flexible in &lt;em&gt;annoying&lt;/em&gt; ways&lt;/li&gt;
&lt;li&gt;Directives, interpolations, nested expressions&lt;/li&gt;
&lt;li&gt;Edge cases you only notice after breaking everything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Porting from VS Code wasn’t a copy–paste job.&lt;br&gt;&lt;br&gt;
It was more like translating between two different mental models.&lt;/p&gt;

&lt;p&gt;But honestly?&lt;br&gt;&lt;br&gt;
That’s what made it fun.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs9voqy2s8etwue9cwelw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs9voqy2s8etwue9cwelw.jpg" alt="The actual journey" width="666" height="500"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The result: early, but usable
&lt;/h2&gt;

&lt;p&gt;The extension is live here:&lt;br&gt;&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://github.com/debba/zed-freemarker" rel="noopener noreferrer"&gt;https://github.com/debba/zed-freemarker&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What it supports today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FreeMarker syntax highlighting&lt;/li&gt;
&lt;li&gt;Directives&lt;/li&gt;
&lt;li&gt;Interpolations&lt;/li&gt;
&lt;li&gt;A solid base for further improvements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Is it perfect? No.&lt;br&gt;&lt;br&gt;
Is it production-grade? Not yet.&lt;br&gt;&lt;br&gt;
Is it &lt;strong&gt;good enough to work daily without hating your editor&lt;/strong&gt;? Absolutely.&lt;/p&gt;

&lt;p&gt;And for an editor like Zed, that already feels like a win.&lt;/p&gt;




&lt;h2&gt;
  
  
  Thoughts on Zed and niche tooling
&lt;/h2&gt;

&lt;p&gt;Zed feels like an editor for people who:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;enjoy understanding how their tools work&lt;/li&gt;
&lt;li&gt;prefer fewer abstractions&lt;/li&gt;
&lt;li&gt;don’t mind building missing pieces themselves&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Writing this extension reminded me of something important:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Open source doesn’t move forward only with big features —&lt;br&gt;&lt;br&gt;
it also grows through small, boring, niche tools that solve real problems.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;FreeMarker isn’t cool.&lt;br&gt;&lt;br&gt;
But someone still has to maintain it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdro8l7o289ws7rnjhv34.jpg" alt="The actual journey" width="612" height="408"&gt;
&lt;/h2&gt;

&lt;h2&gt;
  
  
  What’s next?
&lt;/h2&gt;

&lt;p&gt;Possible next steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;better grammar coverage&lt;/li&gt;
&lt;li&gt;error handling&lt;/li&gt;
&lt;li&gt;maybe an LSP in the future (no promises)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use FreeMarker
&lt;/li&gt;
&lt;li&gt;use Zed
&lt;/li&gt;
&lt;li&gt;enjoy hacking on developer tools
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback, issues, and PRs are more than welcome.&lt;/p&gt;

&lt;p&gt;Sometimes the best extensions start with a simple need:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I just want my editor to understand this file.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And that’s exactly how this one was born 🚀&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>software</category>
      <category>coding</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building AuthPress: From Simple Telegram 2FA to Extensible WordPress Security Platform</title>
      <dc:creator>Andrea Debernardi</dc:creator>
      <pubDate>Tue, 16 Sep 2025 12:50:53 +0000</pubDate>
      <link>https://dev.to/debba/building-authpress-from-simple-telegram-2fa-to-extensible-wordpress-security-platform-258h</link>
      <guid>https://dev.to/debba/building-authpress-from-simple-telegram-2fa-to-extensible-wordpress-security-platform-258h</guid>
      <description>&lt;p&gt;&lt;em&gt;How I evolved a single-provider WordPress 2FA plugin into a comprehensive, developer-friendly authentication system&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem 🔐
&lt;/h2&gt;

&lt;p&gt;WordPress powers 43% of the web, but most sites still rely on password-only authentication. While there are 2FA plugins available, they're often:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Limited to specific providers&lt;/li&gt;
&lt;li&gt;Hard to extend or customize&lt;/li&gt;
&lt;li&gt;Not developer-friendly&lt;/li&gt;
&lt;li&gt;Missing modern authentication methods&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Journey 🚀
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AuthPress&lt;/strong&gt; started as a simple idea: "What if I could get my WordPress 2FA codes via Telegram?" Three years and multiple iterations later, it's become a comprehensive authentication platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version 4.0: The Extensible Platform
&lt;/h3&gt;

&lt;p&gt;Now it supports multiple providers through a clean architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Modern extensible system&lt;/span&gt;
&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Abstract_Provider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;send_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verify_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render_user_settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Register custom providers&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'authpress_register_providers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$providers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$providers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'my_sms'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'MyPlugin\\SMS_Provider'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$providers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'push_notification'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'MyPlugin\\Push_Provider'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$providers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Technical Architecture 🏗️
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Core Features
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-Provider Support&lt;/strong&gt;: Telegram, Email, TOTP, Recovery Codes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extensible API&lt;/strong&gt;: Clean interfaces for custom providers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Professional Logging&lt;/strong&gt;: WordPress-native admin tables&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security First&lt;/strong&gt;: Rate limiting, encrypted storage, brute force protection&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Provider System
&lt;/h3&gt;

&lt;p&gt;Each authentication method is a provider class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Telegram_Provider&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Abstract_Provider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;send_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Telegram API integration&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send_telegram_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verify_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$submitted_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Validate against stored code&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate_stored_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$submitted_code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render_user_settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// User configuration interface&lt;/span&gt;
        &lt;span class="k"&gt;include&lt;/span&gt; &lt;span class="s1"&gt;'templates/telegram-settings.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Developer Experience 👨‍💻
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Creating Custom Providers
&lt;/h3&gt;

&lt;p&gt;Want SMS via Twilio? Here's how simple it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Twilio_SMS_Provider&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Abstract_Provider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nv"&gt;$twilio_client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;twilio_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Twilio\Rest\Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;send_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_user_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'phone_number'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;twilio_client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'from'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'+1234567890'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Your WordPress login code: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verify_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;verify_stored_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  WordPress Integration
&lt;/h3&gt;

&lt;p&gt;Follows WordPress best practices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Uses hooks and filters extensively&lt;/li&gt;
&lt;li&gt;Proper nonce verification&lt;/li&gt;
&lt;li&gt;WP_List_Table for admin interfaces&lt;/li&gt;
&lt;li&gt;Standard WordPress coding standards&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next? 🔮
&lt;/h2&gt;

&lt;p&gt;Working on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Passkey/WebAuthn integration&lt;/strong&gt; (already in beta)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardware token support&lt;/strong&gt; (YubiKey, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Open Source &amp;amp; Community 💝
&lt;/h2&gt;

&lt;p&gt;AuthPress is GPL-licensed and available on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WordPress.org&lt;/strong&gt;: &lt;a href="https://wordpress.org/plugins/two-factor-login-telegram/" rel="noopener noreferrer"&gt;Official plugin directory&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/dueclic/authpress" rel="noopener noreferrer"&gt;dueclic/authpress&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Contributing
&lt;/h3&gt;

&lt;p&gt;We welcome contributions! Especially:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New provider implementations&lt;/li&gt;
&lt;li&gt;Security audits&lt;/li&gt;
&lt;li&gt;Documentation improvements&lt;/li&gt;
&lt;li&gt;Translation updates&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons Learned 📚
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start simple, architect for growth&lt;/strong&gt; - The extensible design saved massive refactoring&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security can't be an afterthought&lt;/strong&gt; - Built-in from day one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer experience matters&lt;/strong&gt; - Clean APIs lead to better ecosystem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WordPress standards exist for a reason&lt;/strong&gt; - Following them made everything easier&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;strong&gt;Try AuthPress&lt;/strong&gt; on your WordPress site and let me know what custom providers you'd build! The extensibility system makes it possible to integrate with virtually any authentication service.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What 2FA method would you want to see next? Drop your ideas in the comments! 👇&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  WordPress #2FA #Security #PHP #OpenSource
&lt;/h1&gt;

</description>
      <category>php</category>
      <category>showdev</category>
      <category>wordpress</category>
      <category>security</category>
    </item>
  </channel>
</rss>
