
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Tabularis Blog</title>
    <link>https://tabularis.dev/blog</link>
    <atom:link href="https://tabularis.dev/feed.xml" rel="self" type="application/rss+xml" />
    <description>Releases, guides and product notes from Tabularis — the open-source desktop database client.</description>
    <language>en</language>
    <lastBuildDate>Thu, 10 Sep 2026 10:30:00 GMT</lastBuildDate>
    <item>
      <title>v0.23.0: The PostgreSQL Plugin Takes Over From the Built-in Driver, SQL Files Open in Editor Tabs, and a Data Folder You Can Sync</title>
      <link>https://tabularis.dev/blog/v0230-postgres-plugin-migration-sql-files-storage-location</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0230-postgres-plugin-migration-sql-files-storage-location</guid>
      <pubDate>Thu, 10 Sep 2026 10:30:00 GMT</pubDate>
      <description>v0.23.0 lets plugins ship their own Visual EXPLAIN parsers, which is what the new SQL Server plugin (1.0.0-beta.1) needs, opens, edits and saves .sql files in editor tabs without executing them, moves the whole data folder to any location such as an iCloud Drive or Dropbox folder, deprecates the built-in PostgreSQL driver in favour of the PostgreSQL plugin with a reversible, per-connection or bulk migration flow, fixes two bugs that swallowed keystrokes in the SQL editor, and carries community fixes for dump escaping, connections.json field loss, the PostgreSQL pool size and shortcuts firing during IME composition.</description>
      <content:encoded><![CDATA[<h1>v0.23.0: The PostgreSQL Plugin Takes Over From the Built-in Driver, SQL Files Open in Editor Tabs, and a Data Folder You Can Sync</h1>
<p><strong>v0.23.0</strong> follows <a href="https://tabularis.dev/blog/v0220-follow-system-theme-grid-selection-shortcuts-tiling-decorations">v0.22.0</a> and is a release about where things live. Visual EXPLAIN gained a parser registry that plugins fill at runtime, which is the piece the <a href="https://github.com/TabularisDB/tabularis-sqlserver-plugin">SQL Server plugin</a> was waiting for: its 1.0.0-beta.1 is in the registry for macOS, Linux and Windows. The SQL editor learns to open <code>.sql</code> files from disk, edit them and save them back, without running them. The data folder that holds connections, settings, saved queries, themes and notebooks can be moved anywhere, an iCloud Drive or Dropbox folder included, so a second machine picks up the same connections. The built-in PostgreSQL driver is now deprecated: the <a href="https://github.com/TabularisDB/tabularis-postgresql-plugin">PostgreSQL plugin</a> installs itself when you have a PostgreSQL connection, every place the old driver shows up carries a <strong>Deprecated</strong> badge, and a migration flow moves connections over one at a time or in bulk, tests them, and offers <strong>Undo</strong> when the result is not what you expected. Two long-standing bugs that ate typed characters in the editor are gone. The tail is a set of community fixes: dump files that round-trip JSON columns, a <code>connections.json</code> that no longer loses fields it does not understand, a configurable PostgreSQL pool size, and shortcuts that stay out of the way while an input method is composing.</p>
<hr>
<h2>Plugins Ship Their Own EXPLAIN Parsers, and the SQL Server Plugin Lands</h2>
<p><a href="https://tabularis.dev/blog/how-plugins-can-now-inject-their-own-parsers-into-visual-explain">Last week&#39;s post</a> told the story of this change in full; this is the release it ships in. PR <a href="https://github.com/TabularisDB/tabularis/pull/688">#688</a> takes <code>@tabularis/explain</code> from 0.1.0 to 0.2.0 with a parser registry: <code>registerExplainParser</code>, <code>unregisterExplainParser</code>, <code>getExplainParser</code> and <code>listExplainParsers</code>, with the built-in PostgreSQL, MySQL and SQLite parsers registered through the same path, so detection and parsing dispatch by format instead of a hard-coded switch. Registering an existing format replaces it, which is what a plugin upgrade needs; unregistering restores detection order deterministically.</p>
<p><video class="video-borderless" src="https://tabularis.dev/videos/posts/explain-sqlserver.mp4" poster="/videos/posts/explain-sqlserver.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p>On the plugin side, a driver can now answer <code>explain_query</code> with a raw payload tagged with an <code>engine</code> and a <code>format</code>, and declare an <code>explain_parsers</code> array in its <code>.tabularium</code> manifest pointing at a TypeScript bundle for that format. The desktop reads each declared module once, evaluates it the way UI extension bundles are evaluated, matches the export by exact engine and format and registers it. Bundles that fail to read or evaluate are logged and skipped per plugin; a parser that throws during actual parsing surfaces through Visual EXPLAIN&#39;s normal error handling. Disabling and re-enabling a plugin unregisters and reloads its formats, so the cycle is deterministic. Plugins that keep returning the parsed plan shape are unaffected.</p>
<p>Two supporting pieces landed with it. The <strong>Raw</strong> tab of Visual EXPLAIN detects XML from the leading tag, switches Monaco to the XML language and indents a single-line document one node per line, keeping attribute values containing <code>&gt;</code> intact; SQL Server SHOWPLAN arrives as one line, so without this the raw view was a wrapped paragraph. And the host now enforces a plugin&#39;s <code>min_runtime_version</code> at install and load time: an older Tabularis refuses the plugin with a message naming both versions, at install even when the archive came from a URL or a local file, instead of failing later inside Visual EXPLAIN. Comparison follows semver precedence, so a prerelease host does not satisfy a <code>0.23.0</code> floor. Development builds load the plugin anyway and show the mismatch as a bottom-right warning toast, so a plugin declaring the next release as its floor stays testable.</p>
<p><img src="https://tabularis.dev/img/tabularis-explain-raw-xml.png" alt="Visual EXPLAIN on a SQL Server connection through the plugin, Raw Output tab: the SHOWPLAN XML returned as a single line is indented one node per line with XML highlighting in Monaco, below the overview bar with the two top issues"></p>
<p>The first plugin to use all of this is the <a href="https://github.com/TabularisDB/tabularis-sqlserver-plugin">SQL Server plugin</a>. Its <a href="https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/tag/v1.0.0-beta.1">1.0.0-beta.1</a> is in the registry with builds for macOS, Linux and Windows, declares <code>min_runtime_version</code> <code>0.23.0</code>, and ships a <code>sqlserver-showplan-xml</code> parser as its explain bundle. Its manifest declares schemas, views, routines and routine management, triggers, user management, table management, connection strings, TLS and EXPLAIN. It is a beta, and it needs this release: on v0.22.0 the catalogue filters it out, and a manual install is refused with the version message described above.</p>
<hr>
<h2>Open, Edit and Save SQL Files in Editor Tabs</h2>
<p>The editor has always worked on text that lives in the app. PR <a href="https://github.com/TabularisDB/tabularis/pull/706">#706</a> adds a folder icon to the tab strip that opens a native file picker filtered on <code>.sql</code>, <code>.psql</code> and <code>.pgsql</code>. The file becomes a new console tab titled with its name and bound to its path, and nothing is executed on open. On a multi-database connection the tab inherits the currently selected database.</p>
<p>Saving writes the editor text back to that path; <strong>Save As</strong> asks for a new one, rebinds the tab and renames it. <code>Ctrl/Cmd+S</code> on a file-backed console saves the file, and keeps its previous meaning, submitting pending grid changes, on every other tab. The toolbar gets a compact save split button in the style of the other controls: the main action saves, or opens Save As when the console is not bound to a file yet, and is disabled when there is nothing to save; a small amber dot next to the icon marks unsaved changes; the chevron opens a menu with <strong>Save</strong>, <strong>Save As</strong> and <strong>Add to Saved Queries</strong>, which opens the existing saved-query modal prefilled with the current SQL. The same Save and Save As entries sit at the top of the tab&#39;s right-click menu, the tab title gets a bullet while the file is dirty, and its tooltip shows the full path.</p>
<p><img src="https://tabularis.dev/img/tabularis-sql-file-save-split.png" alt="The editor toolbar's save split button expanded over a console tab: Save (greyed out, Not saved to a file yet), Save As… with the .sql hint, and Add to Saved Queries…, next to the folder icon in the tab strip that opens a .sql file"></p>
<p>Dirty tracking is a real comparison rather than a flag. The tab stores the content last read from or written to disk and compares the editor text against it, so typing a character and deleting it again leaves the file clean, and an edit made while a write is in flight keeps the tab dirty. That stored content is persisted with the tab, so a file tab restored after a restart can still be saved to the same path and still knows whether it changed. <strong>Close</strong>, <strong>Close Others</strong>, <strong>Close to the Right</strong>, <strong>Close to the Left</strong> and <strong>Close All</strong> all check whether any tab about to go has unsaved file changes and ask before discarding them.</p>
<p>The file IO goes through two small Tauri commands in Rust instead of the JS filesystem plugin. The earlier attempt failed with a <em>forbidden path</em> error for anything outside the app data directory, and the runtime scope the dialog plugin grants for a picked path does not survive a restart, so saving a restored tab could never have worked that way. The commands validate the path, cap reads at 50 MB and run the blocking IO off the main thread, the same approach result exports already use.</p>
<hr>
<h2>Move the Data Folder Anywhere, and Sync It</h2>
<p>Issue <a href="https://github.com/TabularisDB/tabularis/issues/702">#702</a> from <a href="https://github.com/jschuur">@jschuur</a> asked for connections that follow you across machines. PR <a href="https://github.com/TabularisDB/tabularis/pull/707">#707</a> answers with a <strong>Settings → Storage</strong> tab and one setting: the root folder where Tabularis keeps its data. Connections, <code>config.json</code>, saved queries, themes, notebooks, query history and custom connection icons all live under it. Installed plugins are the one exception and always stay in the platform data directory, since they are per-platform binaries. TablePlus does this with one path per file; a single root is simpler and covers the case people actually have, a folder in iCloud Drive or Dropbox.</p>
<p><img src="https://tabularis.dev/img/tabularis-settings-storage.png" alt="Settings → Storage: the Data folder card showing the default location, the Change folder… and Open folder buttons, the warning about running two instances on the same folder, and the TABULARIS_DATA_DIR environment variable note with a copy-ready example"></p>
<p>When you pick a new folder the backend inspects it first. If it already holds Tabularis data, synced from another machine say, the default is to use it as is. Otherwise you choose between copying your current data into it, with existing files never overwritten, or starting empty. The current folder, anything nested inside it or containing it, relative paths and plain files are rejected. The choice is written to <code>storage-location.json</code> in the default config directory, so the app finds it before loading anything else, and it is resolved once per process and cached; the GUI and the <code>tabularis --mcp</code> subprocess read the same pointer and always agree. That is also why a change needs a restart, and the tab shows a banner with a <strong>Restart now</strong> button until you do. The tab carries a warning worth repeating: Tabularis reads the folder at startup, so avoid running two instances on the same folder at the same time, for example on two machines while it is still syncing.</p>
<p><img src="https://tabularis.dev/img/tabularis-settings-storage-new-folder.png" alt="Settings → Storage after picking a new folder: the New folder card shows the chosen path, the Copy my current data into this folder option selected, the Start with an empty folder alternative, and Apply and Cancel buttons"></p>
<p>The <code>TABULARIS_DATA_DIR</code> environment variable overrides the pointer file. When it is set the tab shows the folder read-only and explains why, and a note documents the variable with a copy-ready example. It applies to the MCP server too and is the right tool for portable installs, scripts and development. Every remaining direct call to the platform config and data directories now goes through one path module, so nothing is left behind in the old folder, and the asset protocol scope is extended at startup so custom connection icons load from the new location.</p>
<hr>
<h2>The Built-in PostgreSQL Driver Is Deprecated, and the Plugin Migration Is Reversible</h2>
<p>The PostgreSQL plugin has been catching up with the built-in driver since spring, and it reached <a href="https://github.com/TabularisDB/tabularis-postgresql-plugin/releases/tag/v1.0.0-rc.1">1.0.0-rc.1</a> last week. From this release on it is the driver PostgreSQL connections are supposed to use, and the built-in <code>postgres</code> driver is deprecated with a tentative removal date of <strong>2026-10-05</strong>. The flow that gets you from one to the other is the work of <a href="https://github.com/aesslinger">@aesslinger</a> in PR <a href="https://github.com/TabularisDB/tabularis/pull/685">#685</a>, designed first in a planning document and then built in five reviewed chunks.</p>
<p>It starts before you do anything. At launch, if at least one saved connection uses the built-in <code>postgres</code> driver and a compatible plugin release exists in the registry, Tabularis installs and activates the <code>postgresql</code> plugin in the background. This re-triggers on every launch, so uninstalling the plugin while a built-in PostgreSQL connection still exists gets it reinstalled next time; there is no opt-out for this release, and the PR says so rather than leaving it to be discovered. Once the plugin is ready, three things appear:</p>
<p><img src="https://tabularis.dev/img/tabularis-deprecated-badge-catalogue.png" alt="The Choose a database catalogue with paradigm facets and Installed badges: the built-in PostgreSQL tile in the SQL group carries a Deprecated badge, while the PostgreSQL plugin tile sits in the Relational group next to the newly installed SQL Server plugin"></p>
<ul>
<li>A <strong>Deprecated</strong> badge, with a tooltip naming the replacement and the removal date, on the built-in entry in the connection catalogue, on its card under <strong>Settings → Plugins</strong>, and on every connection row that still uses it. The plugin&#39;s own catalogue entry sorts ahead of the built-in one.</li>
<li>A dismissible banner on the Connections page. It reads <em>Try the new PostgreSQL plugin</em> when the plugin is installed, and <em>couldn&#39;t be downloaded, we&#39;ll retry on the next launch</em> when the registry was unreachable. Dismissing it hides it for the connections that existed at that moment; a new built-in connection brings it back.</li>
<li>A <strong>Switch to plugin</strong> action on each built-in PostgreSQL connection, as a button on the card and in the context menu, plus a <strong>Review connections</strong> link on the banner that opens a bulk checklist.</li>
</ul>
<p><img src="https://tabularis.dev/img/tabularis-postgres-deprecation-banner.png" alt="Connections page with the dismissible Try the new PostgreSQL plugin banner announcing the built-in driver retirement (tentatively 2026-10-05) and its Review connections link, above the connection cards, one of which carries the Deprecated badge next to its driver chip"></p>
<p>Switching a single connection asks for confirmation, flips the driver to <code>postgresql</code>, reconnects it if it was open, and runs a connection test. The toast that follows reports success or the exact failure, and always carries <strong>Undo</strong>, which flips the driver back and reconnects again. A failure that is not the plugin&#39;s fault is labelled as such: if the connection also fails on the built-in driver, the toast says so and keeps the switch. If the plugin process itself did not start, the toast says no connection was attempted. Undo can fail too, for instance when the connection was deleted underneath it, and that surfaces as its own error toast instead of an unhandled rejection. Every one of these outcomes also has a <strong>Report an issue</strong> action that opens a pre-filled GitHub Issue Form in the plugin repository with the plugin and app versions, OS and error already in place, and it works even when the registry API is unreachable thanks to a static fallback URL for the first-party plugin.</p>
<p>The bulk checklist is where the capability check lives. Each connection is compared against the capabilities the installed plugin declares in its manifest. A connection that uses something the plugin does not declare, SSL or connection strings are the two the code knows about, is listed <strong>unchecked</strong> with the specific gap named inline and a <strong>Report this gap</strong> action; once you have filed it, the row shows <em>Reported</em> instead. A connection whose connection string is stored in the keychain is unchecked as well, with a note that the secret will not carry over and needs to be re-entered after migrating, because the plugin cannot read the built-in driver&#39;s keychain entry. Everything else defaults to checked. <strong>Migrate N selected</strong> then works through the rows sequentially with per-row status, one failure does not abort the rest, and migration continues if you close the modal. Every migration is recorded in a persisted history so the state survives a restart.</p>
<p><img src="https://tabularis.dev/img/tabularis-migration-checklist.png" alt="The Review connections modal opened from the banner: it lists the one connection still on the built-in driver as a checked row, with Close and Migrate 1 selected buttons"></p>
<p>Two details for later. The whole thing is parameterized by a <code>(builtinId, pluginId)</code> pair, with <code>(&quot;postgres&quot;, &quot;postgresql&quot;)</code> as the only entry today, so the MySQL and SQLite deprecations that are expected to follow are an additional pair rather than a redesign. And a <code>migrationModeByDriver</code> setting is staged for a future switch to forced migration; for v0.23.0 every driver is <code>opt-in</code>, and nothing moves unless you click.</p>
<hr>
<h2>Two Bugs That Ate Keystrokes in the SQL Editor</h2>
<p>Issue <a href="https://github.com/TabularisDB/tabularis/issues/731">#731</a>, reported by <a href="https://github.com/aesslinger">@aesslinger</a> against the PostgreSQL plugin before it was traced to the app, described a character disappearing right after a Shift+Home selection. PR <a href="https://github.com/TabularisDB/tabularis/pull/732">#732</a> found two independent bugs behind the symptom.</p>
<p>The first is WebKit-only, which is why it never showed on Windows. Monaco falls back to a hidden textarea for keyboard input wherever the EditContext API is missing, which is every WebKit build (WebKitGTK on Linux, WKWebView on macOS) and Firefox. For a backward selection, Shift+Home, Shift+Left or Shift+Up, it mirrors the selection into that textarea as a reversed range, browsers collapse such a range to the smaller offset, the next typed character lands before the mirrored text, and Monaco deduces a one-character composition over a non-empty selection and discards it. A small app-side patch collapses the reversed range at the anchor for Monaco&#39;s input textarea only; Monaco 0.56.0 and current VS Code main still carry the same code, so the fix stays in Tabularis for now.</p>
<p>The second affected every platform. The editor wrapper debounces <code>onChange</code> by 300 ms and separately re-syncs Monaco from the <code>initialValue</code> prop whenever the two differ. The flush re-renders the page asynchronously, so a keystroke typed between the flush and that effect was already in the editor but not yet in the prop, and the effect wrote the stale value back. Typing at roughly the debounce interval lost every third character: <code>abcd</code> typed at 0.30 to 0.32 s intervals came out as <code>abd</code>. The wrapper now remembers the values it handed to <code>onChange</code> until the consumer echoes them back and never re-applies an echo; a genuinely external change, a saved query loaded into the tab for instance, still wins and cancels any pending flush. Both fixes were verified in the real Tauri app on WebKitGTK 2.52.6, driving the console with xdotool and reading the editor state through the remote inspector.</p>
<p>The same PR stops <code>@monaco-editor/react</code> from fetching Monaco from the jsDelivr CDN at runtime while a full copy sat unused in the bundle. The loader now points at the bundled package and registers the editor and JSON workers itself, so the editor works offline and ships the version pinned in <code>package.json</code>.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>Tables first in the quick navigator</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/703">#703</a>): a PostgreSQL schema with about 300 tables showed 1.6k entries because PostGIS alone adds over a thousand functions to <code>public</code>, and typing a table name did not filter them away. Objects now carry a relevance weight (tables 20, views 10, routines and triggers 0), so tables come first on an empty query and a table containing the text ranks above functions, while typing the exact name of a function still puts that function first. The fuzzy threshold drops from 0.4 to 0.3, one typo every four characters, which also applies to the command palette since both share the same search. The palette renders at most 100 rows; the footer still shows the total match count.</li>
<li><strong>Dump files round-trip JSON columns and backslashes</strong> (<a href="https://github.com/soliloquist">@soliloquist</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/720">#720</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/717">#717</a>): the dumper applied two different escaping rules inside one file. Text columns doubled backslashes, correct for MySQL and wrong for PostgreSQL and SQLite, where the dump re-imported <code>a\b</code> as <code>a\\b</code>. JSON columns went through a fallback that only escaped quotes, so on MySQL <code>\&quot;</code> became <code>&quot;</code> (error 3140 on import) and <code>\uXXXX</code> became <code>uXXXX</code>, valid JSON with all non-ASCII text silently destroyed. One dialect-aware string escaper now handles every literal, JSON and JSONB columns are written as JSON literals with UTF-8 emitted as-is, and unit tests cover both dialects, NUL, nested values and JSON scalars. Behaviour change for PostgreSQL and SQLite text columns: backslashes are no longer doubled.</li>
<li><strong><code>connections.json</code> keeps the fields it does not understand</strong> (<a href="https://github.com/atirna">@atirna</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/708">#708</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/668">#668</a>): an older binary saving the file used to drop every field it did not recognize, which became a real problem once the MCP process started writing it alongside a possibly newer GUI. Unknown fields are now preserved on write.</li>
<li><strong>PostgreSQL pool size is a setting</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/681">#681</a>, refs <a href="https://github.com/TabularisDB/tabularis/issues/71">#71</a>): the built-in PostgreSQL driver gains a <strong>Pool Max Size</strong> setting under <strong>Settings → Plugins</strong>, used when the pool is created. The default stays at 10, oversized values are capped at 64, and invalid or zero values fall back to the default. Useful behind pgBouncer, where a pool of ten per connection is more than you want.</li>
<li><strong>Shortcuts stay quiet during text composition</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/656">#656</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/655">#655</a>): dead keys and IME composition produce keydown events that look like shortcuts before the final character is committed. A shared guard recognizes them (<code>isComposing</code>, <code>Dead</code>, <code>Process</code>, <code>Unidentified</code>, keycode 229) and both the global shortcut handler and the Monaco interception skip them, so the character reaches the editor instead of stealing focus.</li>
<li><strong>Storage globals bound to jsdom in the test suite</strong> (<a href="https://github.com/be-student">@be-student</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/719">#719</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/713">#713</a>): Node 26&#39;s native Web Storage globals kept Vitest from installing jsdom&#39;s, and 40 tests failed at <code>localStorage.clear()</code>. Shared setup now binds <code>localStorage</code> and <code>sessionStorage</code> to the worker&#39;s jsdom window before test modules load, with regression cases and a note in the testing rules.</li>
<li><strong>Preview builds for pull requests</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/705">#705</a>): a maintainer adds the <code>preview</code> label to a PR and CI builds it for macOS (arm64 and x64), Linux and Windows, uploads the installable bundles as run artifacts kept for 14 days, rebuilds on every push while the label stays, and maintains one sticky comment with a download link per platform. PRs from branches of the repository come out signed and notarized; PRs from forks come out unsigned, and the comment says which case applies. Release mode now fails explicitly when signing secrets are missing instead of publishing unsigned bundles. If you reported a bug and get pointed at one of these, it is the fastest way to confirm the fix before a nightly.</li>
<li><strong>CI dependencies</strong>: Dependabot bumped <code>actions/checkout</code> to 7.0.1, <code>actions/cache</code> to 6, <code>actions/upload-artifact</code> to 7 and <code>swatinem/rust-cache</code> to 2.9.2 (<a href="https://github.com/TabularisDB/tabularis/pull/661">#661</a>, <a href="https://github.com/TabularisDB/tabularis/pull/662">#662</a>, <a href="https://github.com/TabularisDB/tabularis/pull/663">#663</a>, <a href="https://github.com/TabularisDB/tabularis/pull/664">#664</a>).</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Five external contributors land in v0.23.0.</p>
<p><strong><a href="https://github.com/aesslinger">@aesslinger</a></strong> built the release&#39;s headline feature end to end, from the planning document to the on-demand install, the deprecation badges, the per-connection and bulk migration flows, the capability-gap detection and the reversible outcome toasts, and kept every review follow-up as its own commit (<a href="https://github.com/TabularisDB/tabularis/pull/685">#685</a>). The same author reported the lost-keystroke bug in <a href="https://github.com/TabularisDB/tabularis/issues/731">#731</a>, the <code>connections.json</code> field loss in <a href="https://github.com/TabularisDB/tabularis/issues/668">#668</a> and the flaky storage tests in <a href="https://github.com/TabularisDB/tabularis/issues/713">#713</a>, which is three fixes in this release that started as precise reports.</p>
<p><strong><a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a></strong> made the PostgreSQL pool size configurable (<a href="https://github.com/TabularisDB/tabularis/pull/681">#681</a>) and kept shortcuts from consuming IME composition events (<a href="https://github.com/TabularisDB/tabularis/pull/656">#656</a>). <strong><a href="https://github.com/soliloquist">@soliloquist</a></strong> found, reproduced and fixed the dump escaping bug, with a reproduction table and a verification against MySQL 8.0 that left nothing to guess (<a href="https://github.com/TabularisDB/tabularis/pull/720">#720</a>). <strong><a href="https://github.com/atirna">@atirna</a></strong> made <code>connections.json</code> writes preserve unknown fields (<a href="https://github.com/TabularisDB/tabularis/pull/708">#708</a>), and <strong><a href="https://github.com/be-student">@be-student</a></strong> fixed the test harness under Node 26 (<a href="https://github.com/TabularisDB/tabularis/pull/719">#719</a>). Thanks also to <strong><a href="https://github.com/jschuur">@jschuur</a></strong> for asking for synced connections in <a href="https://github.com/TabularisDB/tabularis/issues/702">#702</a>, to <strong><a href="https://github.com/mccuskk">@mccuskk</a></strong> for the focus-stealing report in <a href="https://github.com/TabularisDB/tabularis/issues/655">#655</a>, and to <strong><a href="https://github.com/ealvesss">@ealvesss</a></strong> for the pool-size report that PR #681 refers to.</p>
<p>If you have a dozen PostgreSQL connections and want the plugin without retyping them, keep your migration scripts in files the app could not open, or carry the same connections on two machines and have been exporting and importing them by hand, this is the upgrade.</p>
<hr>
<p><em>v0.23.0 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.23.0">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0230-postgres-plugin-migration-sql-files-storage-location/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>sqlite</category>
      <category>plugin</category>
      <category>ui</category>
      <category>ux</category>
      <category>community</category>
    </item>
    <item>
      <title>How plugins can now inject their own parsers into Visual EXPLAIN</title>
      <link>https://tabularis.dev/blog/how-plugins-can-now-inject-their-own-parsers-into-visual-explain</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/how-plugins-can-now-inject-their-own-parsers-into-visual-explain</guid>
      <pubDate>Fri, 04 Sep 2026 18:30:00 GMT</pubDate>
      <description>explain.tabularis.dev now reads SQL Server plans, and the parser does not live in the core. This is how @tabularis/explain got a parser registry, how a driver plugin ships its own parser as a bundle the app loads at runtime, and why the SQL Server plugin finally lands next week.</description>
      <content:encoded><![CDATA[<h1>How plugins can now inject their own parsers into Visual EXPLAIN</h1>
<p>This week <a href="https://explain.tabularis.dev" target="_blank" rel="noopener noreferrer">explain.tabularis.dev</a> understands SQL Server. You run your query with <code>SET STATISTICS XML ON</code>, or <code>SET SHOWPLAN_XML ON</code> if you only want the estimated plan, copy the XML that comes back and paste it into the page. The plan opens as the same interactive graph, diagram, table and stats views that PostgreSQL, MySQL and SQLite plans have had since July. Everything runs in the browser and nothing is uploaded.</p>
<p><video class="video-borderless" src="https://tabularis.dev/videos/posts/explain-sqlserver.mp4" poster="/videos/posts/explain-sqlserver.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p>The operator tree is what you would expect from a SHOWPLAN: Table Scan, Index Seek, Clustered Index Seek, Nested Loops, Hash Match, Sort, Top, Compute Scalar, Parallelism. For actual plans you also get estimated versus actual rows per operator, the number of executions on the inner side of a join, and the self time and cumulative cost that make the hot branch stand out. Pasted XML is pretty-printed and highlighted, since SQL Server hands it to you as a single very long line.</p>
<p>This is a small feature to describe and it took a surprisingly long path to ship. I want to write down that path, because the interesting part is not SQL Server. It is what had to change in Tabularis so that a plugin could bring its own EXPLAIN parser.</p>
<h2>Where the parser used to live</h2>
<p>When I <a href="https://tabularis.dev/blog/extracting-visual-explain">extracted Visual EXPLAIN</a> into the <code>@tabularis/explain</code> package this summer, the boundary I settled on was: drivers stop at raw EXPLAIN output, and everything after that, parsing, exclusive metrics, findings and views, belongs to the package. That boundary worked well for the three built-in engines, and it is what made the online visualizer possible in the first place.</p>
<p>It had a limit I did not think about enough at the time. The list of raw formats was a closed union of five string literals, dispatched by an exhaustive <code>switch</code>. Only a parser compiled into the package could handle a format. Plugins had a different contract altogether: a driver plugin&#39;s <code>explain_query</code> response was always a fully parsed plan, built inside the plugin process.</p>
<p>For SQL Server that meant the plugin carried a Rust SHOWPLAN parser. It worked, and it had good tests, but it had three problems. The parser could only evolve with a Rust binary release. Anyone wanting SQL Server plans in a browser would have to write a second parser, because a web page cannot spawn a plugin process. And every plan-model improvement in <code>@tabularis/explain</code> had to be mirrored by hand on the Rust side to stay in sync.</p>
<p>The fix I wanted was not &quot;add SQL Server to the core package&quot;. SQL Server support was already becoming a plugin, and I did not want the core to learn one more engine each time a driver plugin appears. I wanted the plugin to own its parser and the core to be able to load it.</p>
<h2>What changed in the core</h2>
<p>The work landed in two steps in the Tabularis repository.</p>
<p>The first is <a href="https://github.com/TabularisDB/tabularis/commit/f64db40e" target="_blank" rel="noopener noreferrer">a parser registry</a> in <code>@tabularis/explain</code>, released as 0.2.0. A parser is now a small descriptor: an engine id, a globally unique format tag, an optional label, a <code>parse</code> function and an optional <code>sniff</code> function for cheap source detection.</p>
<pre><code class="language-ts">export interface RegisteredExplainParser {
  readonly engine: string;
  readonly format: string;
  readonly label?: string;
  parse(payload: string): ExplainPlan;
  sniff?(payload: string): boolean;
}

export function registerExplainParser(parser: RegisteredExplainParser): void;
export function unregisterExplainParser(format: string): void;
</code></pre>
<p>The five built-in parsers became entries in the same registry, the exhaustive <code>switch</code> is gone, and the engine and format types are open while keeping literal autocomplete for the known values. This part is engine-neutral. The package knows nothing about SQL Server.</p>
<p>The second step is <a href="https://github.com/TabularisDB/tabularis/pull/688" target="_blank" rel="noopener noreferrer">PR #688</a>, which teaches the desktop app to use that registry for plugins:</p>
<ul>
<li>A driver plugin can now answer <code>explain_query</code> with raw output, the same <code>engine</code>, <code>format</code> and <code>payload</code> shape the built-in drivers already used, instead of a parsed plan.</li>
<li>The plugin manifest gains an optional <code>explain_parsers</code> array. Each entry names the engine, the format, a label, and the path of a JavaScript bundle inside the installed plugin directory.</li>
<li>When the set of enabled plugins changes, Tabularis reads each declared bundle, evaluates it with the host&#39;s <code>@tabularis/explain</code> API injected as <code>__TABULARIS_EXPLAIN__</code>, checks that the exported descriptor matches the manifest entry exactly, and registers it. Disabling the plugin unregisters its formats, and reloads happen in sorted plugin-id order so enable and disable cycles are deterministic.</li>
<li>A broken bundle is isolated to its own plugin. Parse errors inside a working parser flow into the normal Visual EXPLAIN error handling.</li>
<li><code>min_runtime_version</code> is now enforced at install and at load time, with a message that names both versions. Development builds load the plugin anyway and show the mismatch as a warning toast, which is how I tested the plugin against an unreleased host.</li>
<li>The raw view learned XML. It used to know JSON and plain text, and a SHOWPLAN document came out as one wrapped paragraph in Monaco.</li>
</ul>
<p>The <a href="https://github.com/TabularisDB/tabularis/blob/main/plugins/PLUGIN_GUIDE.md" target="_blank" rel="noopener noreferrer">plugin guide</a> has a new section on this, so any third-party driver can do the same thing without waiting for a core release.</p>
<h2>What changed in the plugin</h2>
<p>On the <a href="https://github.com/TabularisDB/tabularis-sqlserver-plugin" target="_blank" rel="noopener noreferrer">SQL Server plugin</a> side the Rust parser is gone. The Rust process now does the only thing that needs a database connection: it turns <code>SHOWPLAN_XML</code> or <code>STATISTICS XML</code> on, runs the statement, turns the option off again even if the statement failed, and returns the untouched XML with the format tag <code>sqlserver-showplan-xml</code>.</p>
<p>The parser is TypeScript and lives in the <a href="https://github.com/TabularisDB/tabularis-sqlserver-plugin/tree/main/explain" target="_blank" rel="noopener noreferrer"><code>explain/</code></a> directory of the plugin repository. It has no runtime dependencies and uses no Node built-ins, with its own small XML reader that validates nesting, attributes, entities, comments and CDATA and ignores namespace prefixes. That is deliberate: the same source has to run in a browser tab, in the desktop IIFE and in server-side JavaScript. The build produces both an IIFE that ships inside the plugin archive and an ESM package, <a href="https://www.npmjs.com/package/@tabularis/explain-sqlserver" target="_blank" rel="noopener noreferrer"><code>@tabularis/explain-sqlserver</code></a>, versioned and released with the plugin.</p>
<p>The migration was checked with golden fixtures captured from SQL Server 2022: a trivial scan, an index seek with key lookup, a parallel hash join, a <code>STATISTICS XML</code> actual plan, a missing-index recommendation and a multi-statement batch. Their expected output is what the old Rust parser produced. The Rust parser was removed only after the TypeScript one matched it on every fixture.</p>
<p>The online visualizer then needed very little. <a href="https://github.com/TabularisDB/explain-plan/pull/2" target="_blank" rel="noopener noreferrer">One PR</a> adds the two packages, imports <code>@tabularis/explain-sqlserver</code> for its registration side effect, and relies on the <code>ShowPlanXML</code> root element for auto-detection. There is no SQL Server code in the site itself.</p>
<h2>A note on how this was planned</h2>
<p>Before writing any of this I froze the contract in a <a href="https://github.com/TabularisDB/tabularis-sqlserver-plugin/blob/main/docs/explain-architecture.md" target="_blank" rel="noopener noreferrer">design document</a> in the plugin repository, with a table of verified claims about the code at specific commits. Doing that caught two mistakes in my own first draft: I believed the Rust parser subtracted child subtree costs and mapped average row size, and it did neither. The document says so explicitly rather than quietly correcting the design. After the <a href="https://tabularis.dev/blog/code-generation-was-only-the-beginning-software-development-is-becoming-a-loop">Tabularis Web migration</a> I have become quite attached to this way of working: spend the time on a plan that is checked against the repository, then let the implementation tasks be boring.</p>
<h2>Next week: SQL Server</h2>
<p>The reason all of this happened now is that the SQL Server plugin is about to ship. In April I <a href="https://tabularis.dev/blog/sql-server-looking-for-contributors">wrote</a> that SQL Server would be a built-in driver and not a plugin. I was wrong about that, and the <a href="https://tabularis.dev/roadmap/sql-server">roadmap page</a> explains why the direction changed. The short version is that the plugin protocol caught up with what SQL Server needs, and this parser work closed the last gap I knew of.</p>
<p>The plugin covers connection pooling, schema introspection, query execution with multiple result sets, full CRUD, DDL, triggers, stored routines, database users and privileges, BLOBs and visual execution plans. It requires Tabularis 0.23.0, the first release with raw plugin EXPLAIN output and parser bundle loading. Both are planned for next week.</p>
<p>Until then, if you have a SQL Server plan you have been squinting at in Management Studio, paste it into <a href="https://explain.tabularis.dev" target="_blank" rel="noopener noreferrer">explain.tabularis.dev</a> and tell me what looks wrong. The parser is new and real plans are the best test fixtures I can get.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/how-plugins-can-now-inject-their-own-parsers-into-visual-explain/opengraph-image.png" type="image/png" />
      <category>explain</category>
      <category>sql-server</category>
      <category>plugins</category>
      <category>architecture</category>
      <category>typescript</category>
      <category>rust</category>
      <category>extensibility</category>
    </item>
    <item>
      <title>v0.22.0: Themes That Follow Your OS, Spreadsheet Selection in the Grid, and a Titlebar That Gets Out of the Way on Tiling Window Managers</title>
      <link>https://tabularis.dev/blog/v0220-follow-system-theme-grid-selection-shortcuts-tiling-decorations</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0220-follow-system-theme-grid-selection-shortcuts-tiling-decorations</guid>
      <pubDate>Tue, 01 Sep 2026 21:40:00 GMT</pubDate>
      <description>v0.22.0 adds a Follow System theme mode with separate light and dark picks that switch live with the OS appearance, Google Sheets style keyboard selection in the data grid (Shift+Arrow ranges, Ctrl+Space columns, Shift+Space rows, header click to select a column), automatic hiding of native window decorations on Hyprland, Sway, i3 and other tiling window managers, a round of notebook fixes, and community patches for the production banner, PostgreSQL test connections with client certificates, SQLite text BLOBs and MySQL routines in non-default schemas.</description>
      <content:encoded><![CDATA[<h1>v0.22.0: Themes That Follow Your OS, Spreadsheet Selection in the Grid, and a Titlebar That Gets Out of the Way on Tiling Window Managers</h1>
<p><strong>v0.22.0</strong> follows <a href="https://tabularis.dev/blog/v0210-sql-folding-per-tab-page-size-postgres-mtls">v0.21.0</a> and is a release about how the app fits into the desktop around it. The theme can now follow the operating system&#39;s light/dark appearance, with a separate theme for each mode, and the native window chrome switches with it. On Linux, Tabularis detects tiling window managers and drops its native decorations so the window tiles like everything else on the screen. Inside the app, the data grid learns the selection shortcuts you already know from Google Sheets: Shift+Arrow grows a range, Ctrl/Cmd+Arrow jumps to the edge, Shift+Space and Ctrl/Cmd+Space select rows and columns, and a plain click on a header selects the column. Notebooks get a round of frontend fixes from the community, and the tail of the release is a set of precise bug fixes: the production banner no longer slices the last grid row in half, PostgreSQL <strong>Test Connection</strong> finally honors client certificates and custom CAs, SQLite BLOBs that are really text display as text, and MySQL routine definitions resolve in the schema you opened them from.</p>
<hr>
<h2>Follow System: One Theme for Light, One for Dark</h2>
<p>Tabularis has had a light/dark listener in the theme provider for a long time, but it was hardcoded to the two default themes and hidden behind a flag nothing could set. Issue <a href="https://github.com/TabularisDB/tabularis/issues/649">#649</a> asked for the real thing, and <a href="https://github.com/iamthenuggetman">@iamthenuggetman</a> built it in PR <a href="https://github.com/TabularisDB/tabularis/pull/650">#650</a>. <strong>Settings → Appearance</strong> gains a <strong>Theme Mode</strong> switch with two options:</p>
<ul>
<li><strong>Static</strong> keeps a single fixed theme. This is the previous behavior and still the default, so nothing changes for existing installs.</li>
<li><strong>Follow System</strong> shows two pickers, <strong>Light Theme</strong> and <strong>Dark Theme</strong>, each filtered to themes of that classification (custom themes included, classified by their Monaco base). The app watches <code>prefers-color-scheme</code> and applies the matching pick the moment the OS flips, and it also asks the native window to switch its chrome via Tauri&#39;s <code>setTheme</code>, so the titlebar follows too.</li>
</ul>
<p>The resolution is a pure helper, <code>resolveActiveThemeId(settings, systemIsDark)</code>, and the edge cases are handled rather than hoped away: toggling Follow System applies the current-mode theme immediately, a picked theme that no longer resolves (a deleted custom theme, say) falls back to the preset for the <em>current</em> OS mode instead of always landing on dark, and deleting a custom theme resets any per-mode pick that referenced it. The editor is untouched: <strong>Same as App</strong> follows the switch automatically, explicit editor overrides stay fixed. Three optional fields land in <code>config.json</code>, <code>followSystemTheme</code>, <code>lightThemeId</code> and <code>darkThemeId</code>; absent fields mean Static, so there is no migration. Strings shipped in all eleven locales, and the PR came with provider tests for hydration, persistence, bidirectional OS switching and the fallback paths, plus a follow-up from the Kilo review that fixed the delete-active-theme case in follow mode.</p>
<p><img src="https://tabularis.dev/img/tabularis-theme-mode-follow-system.png" alt="Settings → Appearance with the Theme Mode switch set to Follow System and separate Light Theme and Dark Theme pickers"></p>
<hr>
<h2>Spreadsheet-Style Selection in the Data Grid</h2>
<p>The grid got cell focus and arrow navigation in v0.18.0 and cell-range selection with Shift+click shortly after. What it lacked was the keyboard half of that story, and issue <a href="https://github.com/TabularisDB/tabularis/issues/673">#673</a> from <a href="https://github.com/manojvignesh">@manojvignesh</a> laid out exactly which half: rows, columns and ranges, from the keyboard, the way Google Sheets does it. PR <a href="https://github.com/TabularisDB/tabularis/pull/683">#683</a> implements that set. Every shortcut is relative to the focused cell:</p>
<table>
<thead>
<tr>
<th align="left">Shortcut</th>
<th align="left">Action</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><code>Shift + Arrow</code></td>
<td align="left">Extend a rectangular range by one step. The anchor stays fixed, the opposite corner moves.</td>
</tr>
<tr>
<td align="left"><code>Ctrl/Cmd + Arrow</code></td>
<td align="left">Jump the focused cell to the grid edge.</td>
</tr>
<tr>
<td align="left"><code>Ctrl/Cmd + Shift + Arrow</code></td>
<td align="left">Extend the range to the grid edge.</td>
</tr>
<tr>
<td align="left"><code>Ctrl/Cmd + Home</code> / <code>End</code></td>
<td align="left">First / last cell of the grid.</td>
</tr>
<tr>
<td align="left"><code>Shift + Space</code></td>
<td align="left">Select the row(s) of the focused cell or of the current range.</td>
</tr>
<tr>
<td align="left"><code>Ctrl/Cmd + Space</code>, or <code>Ctrl/Cmd + Shift + Space</code></td>
<td align="left">Select the column(s) of the focused cell or of the current range.</td>
</tr>
</tbody></table>
<p><img src="https://tabularis.dev/img/tabularis-grid-keyboard-range.png" alt="The customers grid with a four-by-three cell range selected from the keyboard with Shift+Arrow, the focused cell outlined at the top-left corner of the range"></p>
<p>The second column binding exists because plain Ctrl+Space is often consumed before it reaches the app: ibus and fcitx on Linux, Spotlight on macOS. Row, column and range selection remain mutually exclusive, so <code>Ctrl/Cmd + C</code> keeps unambiguous copy semantics, and the focused cell survives Shift/Ctrl+Space so shortcuts chain (Shift+Down three times, then Shift+Space, selects four rows).</p>
<p>Headers change with it. A <strong>plain click on a column header now selects that column</strong>, replacing the current selection; Ctrl/Cmd+click still toggles and Shift+click still range-selects. Sorting moves to the sort icon next to the column name, which is now a real button with an aria-label, still revealed on hover. One behavior to be aware of: with a focused cell, Ctrl+Left/Right now jumps to the grid edge instead of paginating. The <strong>Next page</strong> and <strong>Previous page</strong> shortcuts still fire when no cell is focused and from the pagination buttons, and both remain user-overridable in <strong>Settings → Keyboard Shortcuts</strong>, where the new bindings are listed too. The range logic lives in pure helpers in <code>utils/dataGrid.ts</code> with unit tests, and every shortcut has a component test.</p>
<hr>
<h2>Native Decorations Get Out of the Way on Tiling Window Managers</h2>
<p>On a tiling window manager the native titlebar is dead weight: the compositor already decides where the window goes and how big it is, and the extra strip just steals vertical space and looks wrong next to everything else. PR <a href="https://github.com/TabularisDB/tabularis/pull/680">#680</a> makes Tabularis notice. On Linux it looks for the session sockets and desktop names of common tilers, <code>HYPRLAND_INSTANCE_SIGNATURE</code>, <code>SWAYSOCK</code>, <code>I3SOCK</code>, <code>NIRI_SOCKET</code>, <code>RIVER_SOCKET</code>, <code>BSPWM_SOCKET</code>, and <code>XDG_CURRENT_DESKTOP</code> / <code>XDG_SESSION_DESKTOP</code> / <code>DESKTOP_SESSION</code> values such as awesome, bspwm, dwm, hyprland, i3, leftwm, niri, qtile, river, sway and xmonad, and hides native decorations when it recognizes one.</p>
<p>The behavior is a setting rather than a guess you can&#39;t override. <strong>Settings → General → Window Decorations</strong> offers <strong>Automatic</strong> (the default, described above), <strong>Always show</strong> and <strong>Always hide</strong>, persisted as <code>windowDecorations</code> in <code>config.json</code>. Changing it applies to every open window immediately, and secondary windows (connection windows, detached results) are created with the same mode. On macOS and Windows, Automatic keeps native decorations on; the setting is there if you want to force it either way. The detection is a pure function over environment variables and carries its own unit tests.</p>
<p><img src="https://tabularis.dev/img/tabularis-settings-general.png" alt="Settings → General with the Window Decorations button group: Automatic, Always show, Always hide"></p>
<hr>
<h2>Notebook Polish</h2>
<p>Two PRs from <a href="https://github.com/harshavardhankonisa">@harshavardhankonisa</a> went through the notebook frontend with a magnifying glass.</p>
<p>PR <a href="https://github.com/TabularisDB/tabularis/pull/687">#687</a> fixes three things you notice within a minute of using a notebook. The <strong>add-cell dropdown</strong> always opened downward, so near the bottom of the viewport it rendered off-screen and could not be clicked; it now measures its position and flips upward when there is no room below. <strong>New cells were not scrolled into view</strong>, because <code>scrollToCell</code> ran before React had rendered the cell, so the scroll was a silent no-op; the update is now flushed synchronously and scroll-plus-focus live inside <code>addCell</code> itself, which also removed six duplicated call sites. And the <strong>SQL cell editor</strong> was a fixed 150px box, cramped for any multi-line query; it now tracks Monaco&#39;s content height and grows to fit, with a 60px minimum for empty cells.</p>
<p><img src="https://tabularis.dev/img/tabularis-notebook-autofit-editor.png" alt="A notebook SQL cell whose editor has grown to show a ten-line query in full, with the result grid below it"></p>
<p>PR <a href="https://github.com/TabularisDB/tabularis/pull/700">#700</a> is a broader React audit that touches the notebook as well as a dozen other components. The visible change is that cells scroll to the <em>top</em> of the viewport with a small margin instead of the center, which reads better when you are adding cells in sequence. Under the surface, eleven copies of the copy-to-clipboard-then-reset-after-two-seconds pattern, none of which cleaned up its timer on unmount, are replaced by one <code>useCopyFeedback</code> hook with tests; the context menu uses stable keys so rebuilt menus no longer inherit stale hover state; <code>SqlHighlight</code> sanitizes its HTML through DOMPurify like the plugin README modal already did; the connection icon component stops calling <code>setState</code> during render; and the notebook outline&#39;s <code>role=&quot;button&quot;</code> rows answer to Space as well as Enter, as WCAG expects.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>The production banner no longer clips the last grid row</strong> (<a href="https://github.com/thomaswasle">@thomaswasle</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/684">#684</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/682">#682</a>): every routed page sizes itself with <code>h-full</code>, which resolved against the full height of <code>&lt;main&gt;</code> and ignored the banner rendered above it. With a production-flagged connection the page was about 18px taller than the space it had, and the bottom of the results grid, half of the last row, was cut off and unreachable. The routed content is now wrapped in a properly sized flex item, which fixes the editor and split-pane geometry under the banner as well.</li>
<li><strong>PostgreSQL Test Connection honors client certificates and custom CAs</strong> (<a href="https://github.com/adisusilayasa">@adisusilayasa</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/678">#678</a>): the driver never overrode <code>test_connection</code>, so the test path went through <code>sqlx::AnyConnection</code> and a URL that carried no client certificate, and on macOS used the system keychain instead of the supplied CA bundle. Servers requiring mTLS rejected the test with <code>connection requires a valid client certificate</code> even though real queries, fixed in v0.21.0, worked. The test now uses the same rustls connector as the query pool, so <code>ssl_ca</code>, <code>ssl_cert</code> and <code>ssl_key</code> behave identically in both.</li>
<li><strong>Monaco relayouts while you drag the editor resize handle</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/657">#657</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/113">#113</a>): the resize handler wrote panel heights to the DOM during the drag and committed React state on release, and Monaco&#39;s automatic layout lagged behind the manual writes, leaving stale measurements and visual glitches. The editor is now explicitly relayouted during the drag and once more after release.</li>
<li><strong>SQLite text BLOBs display as text</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/696">#696</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/695">#695</a>): the v0.20.0 hex preview treated every small generic BLOB as binary, so a BLOB column holding UTF-8 strings, file paths for instance, came back as a hexdump. A complete <code>application/octet-stream</code> value that decodes as valid UTF-8 with no binary control characters is now shown as text; invalid UTF-8 and real binary keep the hex preview.</li>
<li><strong>MySQL routine definitions in non-default schemas</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/701">#701</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/699">#699</a>): <code>SHOW CREATE PROCEDURE</code> was issued with the bare routine name, so MySQL resolved it against the session&#39;s default database and returned error 1305 for any routine opened from another database in the sidebar. Listing, parameters, drop and call scripts were already schema-aware; the definition and edit path was the only one that wasn&#39;t. The statement is now schema-qualified with proper backtick escaping, with unit tests for both routine types.</li>
<li><strong><code>brew install --cask tabularis</code>, no tap</strong> (<a href="https://github.com/justsrc">@justsrc</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/691">#691</a>): Tabularis is in the main Homebrew cask repository, so the <code>brew tap TabularisDB/tabularis</code> step is gone from all eleven README translations.</li>
<li><strong>Nightly build pinned</strong>: the CI install of <code>tauri-cli</code> is pinned to a revision and runs with <code>--locked</code>, after an unpinned branch install pulled a <code>value-bag</code> release incompatible with the <code>log</code> crate and broke the nightly.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Six external contributors land in v0.22.0.</p>
<p><strong><a href="https://github.com/iamthenuggetman">@iamthenuggetman</a></strong> shipped the release&#39;s headline feature, Follow System theme mode with per-mode themes, native chrome sync and the full fallback story (<a href="https://github.com/TabularisDB/tabularis/pull/650">#650</a>). <strong><a href="https://github.com/harshavardhankonisa">@harshavardhankonisa</a></strong> fixed the notebook&#39;s dropdown, scroll and editor-height issues (<a href="https://github.com/TabularisDB/tabularis/pull/687">#687</a>) and followed up with a React audit that removed a class of timer leaks and render-time state updates across the app (<a href="https://github.com/TabularisDB/tabularis/pull/700">#700</a>). <strong><a href="https://github.com/thomaswasle">@thomaswasle</a></strong> diagnosed and fixed the production-banner clipping with a writeup that made the review trivial (<a href="https://github.com/TabularisDB/tabularis/pull/684">#684</a>).</p>
<p><strong><a href="https://github.com/adisusilayasa">@adisusilayasa</a></strong> closed the loop on PostgreSQL client certificates by bringing Test Connection onto the same connector as the pool (<a href="https://github.com/TabularisDB/tabularis/pull/678">#678</a>). <strong><a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a></strong> put the editor resize glitch open since March to rest (<a href="https://github.com/TabularisDB/tabularis/pull/657">#657</a>), and <strong><a href="https://github.com/justsrc">@justsrc</a></strong> simplified the macOS install docs now that the cask lives upstream (<a href="https://github.com/TabularisDB/tabularis/pull/691">#691</a>). Thanks also to <strong><a href="https://github.com/manojvignesh">@manojvignesh</a></strong> for the selection-shortcuts spec in <a href="https://github.com/TabularisDB/tabularis/issues/673">#673</a>, to <strong><a href="https://github.com/agross">@agross</a></strong> for the SQLite BLOB report, and to <strong><a href="https://github.com/madiajijah11">@madiajijah11</a></strong> for the MySQL routine report.</p>
<p>If you switch your OS to dark at sunset and have been switching Tabularis by hand right after, run it under Hyprland or Sway with a titlebar that doesn&#39;t belong there, or have reached for Shift+Space in the grid out of spreadsheet habit and had nothing happen, this is the upgrade.</p>
<hr>
<p><em>v0.22.0 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.22.0">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0220-follow-system-theme-grid-selection-shortcuts-tiling-decorations/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>sqlite</category>
      <category>ui</category>
      <category>ux</category>
      <category>data-grid</category>
      <category>community</category>
    </item>
    <item>
      <title>v0.21.0: Fold Your SQL, Pick Your Page Size Per Tab, and Postgres Client Certificates That Actually Get Sent</title>
      <link>https://tabularis.dev/blog/v0210-sql-folding-per-tab-page-size-postgres-mtls</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0210-sql-folding-per-tab-page-size-postgres-mtls</guid>
      <pubDate>Tue, 25 Aug 2026 18:00:00 GMT</pubDate>
      <description>v0.21.0 adds dialect-aware folding with hover previews to the SQL editor, a per-tab rows-per-page selector that overrides the global limit (including an All option), PostgreSQL mTLS client-certificate authentication, cancellable plugin installs, a safety-confirmation countdown that is now opt-in and shared between destructive and production guards, an SSL-mode migration that finally runs in the MCP process, and a batch of grid, export and import fixes from the community.</description>
      <content:encoded><![CDATA[<h1>v0.21.0: Fold Your SQL, Pick Your Page Size Per Tab, and Postgres Client Certificates That Actually Get Sent</h1>
<p><strong>v0.21.0</strong> follows <a href="https://tabularis.dev/blog/v0200-command-palette-grid-paste-postgres-plugin">v0.20.0</a> and is a release about the two surfaces you spend the whole day in: the editor and the results bar. Long scripts can now be folded one statement at a time, and a collapsed statement shows you what it contains when you hover it. The pagination bar gets a rows-per-page selector that applies to <em>that tab only</em>, so raising the limit for one result no longer means changing a global setting and putting it back. Underneath, PostgreSQL connections with a client certificate and key finally present them to the server, plugin installs can be cancelled without leaving a half-extracted directory behind, and the two safety guards (destructive query and production write) are unified so you see one warning instead of two. The rest is a long tail of community fixes: multi-statement exports, hidden SQLite virtual-table columns, database-qualified tables in grid editing, a manual TablePlus import path, and a Snap that appears in the launcher.</p>
<hr>
<h2>Fold Statements, Preview Them on Hover</h2>
<p>Long migration scripts and seed files are mostly noise around the one statement you care about. PR <a href="https://github.com/TabularisDB/tabularis/pull/674">#674</a> teaches the Monaco editor to fold SQL <strong>per statement</strong>: every multiline statement gets its own folding range, computed with the same dialect-aware splitter that already powers run-at-cursor, so a <code>$$</code> body or a <code>DELIMITER</code> block folds as one unit rather than at the first stray semicolon. Fold controls stay visible in the gutter instead of appearing only on hover.</p>
<p>The part that makes folding usable rather than merely available is the preview. Hover a collapsed statement and a syntax-highlighted popup shows what&#39;s inside; you can move the pointer into it and scroll it without expanding the fold. Previews are scoped to the main query editor, so the small SQL fields elsewhere in the app don&#39;t grow tooltips they don&#39;t need.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-sql-folding.mp4" poster="/videos/posts/tabularis-sql-folding.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Rows Per Page, Per Tab</h2>
<p>The <strong>Result Page Size</strong> setting has always been global. Issue <a href="https://github.com/TabularisDB/tabularis/issues/672">#672</a> put its finger on the consequence: with a global page size of 50, a query ending in <code>LIMIT 51</code> came back as two pages, 50 and 1, and the only way around it was to go to Settings and change the value for every tab. PR <a href="https://github.com/TabularisDB/tabularis/pull/675">#675</a> adds a <strong>rows-per-page selector</strong> to the results pagination bar. Whatever you pick there overrides the global setting for the current tab only; other tabs and new tabs keep the default. This is the same model DataGrip uses: the setting is the default, the control on the grid wins for the view you&#39;re looking at.</p>
<p>The selector offers presets from 50 to 5000 (the global value is marked as <em>default</em> and slotted in if it isn&#39;t already a preset), a custom value input, and an <strong>All</strong> option that turns pagination off for the tab. All works because <code>execute_query</code> already accepted an optional limit and simply runs the statement unpaginated when none is sent. The override lives on the tab and is persisted with it, so it survives a restart. Changing the page size recomputes the page number so the first visible row stays in view instead of jumping. Editor runs, multi-statement batches and result-panel paging all resolve the effective size through one helper: tab value first, then the global setting, then the historical fallback of 100.</p>
<p><img src="https://tabularis.dev/img/tabularis-page-size-selector.png" alt="The rows-per-page selector open in the results pagination bar, with presets from 50 to 5000, the global value marked as default, an All option and a Custom input"></p>
<hr>
<h2>PostgreSQL Client Certificates (mTLS)</h2>
<p>The connection form has had <strong>Client Certificate</strong> and <strong>Client Key</strong> fields for a while. The TLS connector never used them: <code>build_postgres_tls_connector</code> called <code>.with_no_client_auth()</code> in every SSL mode, so a server that requires client authentication, Google Cloud SQL with mTLS enabled being the canonical case, rejected every connection with <code>connection requires a valid client certificate</code>. PR <a href="https://github.com/TabularisDB/tabularis/pull/666">#666</a>, from <a href="https://github.com/adisusilayasa">@adisusilayasa</a>, loads the certificate and private key from PEM via <code>rustls_pemfile</code> and attaches them with <code>.with_client_auth_cert(...)</code> whenever both <code>ssl_cert</code> and <code>ssl_key</code> are supplied. Client auth is skipped when <code>ssl_mode</code> is <code>disabled</code>, and the pool key now includes both paths, so editing the certificate on a connection can never reuse a pool built without it. Unit tests cover PEM loading, connector configuration and the pool-key change.</p>
<p><img src="https://tabularis.dev/img/tabularis-postgres-client-cert.png" alt="PostgreSQL connection editor, SSL tab: SSL mode Verify Full with CA Certificate, Client Certificate and Client Key path fields"></p>
<hr>
<h2>Cancellable Plugin Installs</h2>
<p>A plugin download that stalls used to be a wait-it-out situation. PR <a href="https://github.com/TabularisDB/tabularis/pull/665">#665</a> turns the install button into a <strong>Cancel</strong> button while a plugin is downloading. Cancelling aborts the active request and the extraction, then removes the staged files. The ordering matters for updates: an existing plugin stays active until its replacement is fully verified and ready, so cancelling an update leaves you with the version you had, not with nothing. The registry cards were reworked alongside to show the download, extraction and cancellation states clearly.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-plugin-install-cancel.mp4" poster="/videos/posts/tabularis-plugin-install-cancel.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>One Safety Guard Instead of Two</h2>
<p>v0.19.0 introduced the production write guard and it stacked on top of the older destructive-query guard, which meant a <code>DELETE</code> without a <code>WHERE</code> on a production connection produced two modals in a row, and the destructive one still made you sit through its five-second countdown. PR <a href="https://github.com/TabularisDB/tabularis/pull/667">#667</a> unifies them:</p>
<ul>
<li>The <strong>five-second countdown is now opt-in.</strong> It&#39;s off by default and lives under <strong>Settings → General</strong> as <em>Delay safety confirmations</em>, persisted in <code>config.json</code> as <code>safetyConfirmationDelayEnabled</code>. When enabled it applies to both destructive-query and production-write confirmations.</li>
<li>On a production connection, the production warning is the only one shown; the standard dangerous-query modal is suppressed, since the production one already covers it.</li>
<li>Production guards run <em>before</em> dangerous-query guards on every execution path: editor, batch, notebook cells and AI-generated queries. A dedicated query-guard pipeline test pins the ordering.</li>
</ul>
<p>The related banner fix in PR <a href="https://github.com/TabularisDB/tabularis/pull/677">#677</a> keeps the red production banner on the connection editor only, so it no longer follows you into Settings.</p>
<p><img src="https://tabularis.dev/img/tabularis-settings-safety-delay.png" alt="Settings → General with the new Delay safety confirmations toggle highlighted, off by default"></p>
<hr>
<h2>The SSL-Mode Migration Reaches the MCP Server</h2>
<p>v0.20.0 shipped a migration that rewrites stale MySQL-style <code>ssl_mode</code> spellings on PostgreSQL-dialect connections. It ran from the two GUI commands that load connections, and nowhere else. The standalone <code>tabularis --mcp</code> process reads the same <code>connections.json</code> and uses <code>ssl_mode</code> directly to open real database connections, so a connection saved with the stale value stayed silently cleartext over MCP for as long as nobody reopened it in the GUI. Issue <a href="https://github.com/TabularisDB/tabularis/issues/639">#639</a>, fixed by <a href="https://github.com/aesslinger">@aesslinger</a> in PR <a href="https://github.com/TabularisDB/tabularis/pull/643">#643</a>.</p>
<p>The fix is four small commits and the middle one is the interesting part. Running the migration from MCP makes that process a <em>writer</em> of <code>connections.json</code> for the first time, and MCP clients spawn it as an independent subprocess that can run alongside the GUI, with no file locking anywhere in the persistence layer. Rather than bolt locking onto ~20 write sites, the migration got a content-based compare-and-swap: it re-reads the file immediately before writing and skips its write for this run if anything changed since its initial read. That&#39;s safe because the migration is idempotent and self-terminating; the next process to load connections retries. Content rather than mtime, because coarse filesystem timestamps can&#39;t tell two writes in the same second apart. The migration logic moved into its own <code>connection_migrations</code> module with no <code>AppHandle</code> dependency along the way, and a redundant file read found in review was removed by teaching <code>persistence</code> to parse content it already has. The <code>migrate_ssh_connections</code> path has the same gap and is explicitly left as a follow-up: it touches a second file and the OS keychain.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>Multi-statement result exports</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/628">#628</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/627">#627</a>): the export button is enabled for the active tab of a multi-statement result, and it exports the loaded rows directly instead of re-running the script on a fresh connection, which is what made temp-table scripts impossible to save. After a batch run the first result-bearing statement is selected automatically. Because the export uses what&#39;s loaded, the progress modal now warns when only <em>N</em> of <em>M</em> total rows were exported and suggests paging or narrowing the query. CSV, JSON and Markdown formatters are covered by tests.</li>
<li><strong>Hidden SQLite virtual-table columns stay hidden</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/624">#624</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/622">#622</a>): the move to <code>PRAGMA table_xinfo</code> in v0.20.0 surfaced <code>hidden = 1</code> rows, FTS5 internals for instance, as ordinary writable columns in the New Row modal. Those are filtered out; <code>hidden = 2</code> and <code>3</code> still map to generated columns.</li>
<li><strong>Manual TablePlus import path</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/625">#625</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/620">#620</a>): if TablePlus keeps its data somewhere other than the default Application Support location, the importer used to report the source as unavailable. You can now point it at the TablePlus <code>Data</code> directory (or a plist inside it) when auto-discovery fails.</li>
<li><strong>Database-qualified tables in grid editing and autocomplete</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/660">#660</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/659">#659</a>): <code>SELECT * FROM Ops.Addresses</code> used to make the grid look up the primary key of a table called <code>Ops</code>. The extractor resolves <code>Addresses</code> when the qualifier matches the active database; a mismatched qualifier keeps the result read-only so a write can never target a same-named table in the wrong database. Autocomplete keeps each loaded table&#39;s database, so typing <code>Ops.</code> suggests tables from <code>Ops</code> while <code>Ops.Addresses.</code> and aliases still suggest columns.</li>
<li><strong>Query errors you can select and copy</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/613">#613</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/591">#591</a>, <a href="https://github.com/TabularisDB/tabularis/issues/608">#608</a>, <a href="https://github.com/TabularisDB/tabularis/issues/610">#610</a>): the error text in the result panel is selectable and gets an explicit <strong>Copy</strong> button. The same PR renders MySQL/MariaDB JSON updates as JSON text instead of <code>CAST(... AS JSON)</code>, which MariaDB rejects, and aligns the object palette&#39;s multi-database branch with the sidebar&#39;s live selection.</li>
<li><strong>Run-at-cursor for tabs that weren&#39;t active on first render</strong> (<a href="https://github.com/GabrielMalava">@GabrielMalava</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/603">#603</a>): editors in background tabs never registered themselves, so Execute fell back to running the whole script instead of the statement under the cursor.</li>
<li><strong>Single-database connections keep their controls</strong> (<a href="https://github.com/gcapellib">@gcapellib</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/635">#635</a>): selecting exactly one database switched the sidebar to a layout that hid <strong>Manage databases</strong> and <strong>Refresh</strong>. The multi-database layout now stays on from one database up, and the lone database is set active and expanded so its tables are one click closer, not one click further.</li>
<li><strong>The Snap shows up in your launcher</strong> (<a href="https://github.com/janpetto">@janpetto</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/670">#670</a>, refs <a href="https://github.com/TabularisDB/tabularis/issues/669">#669</a>): the published snap exported no desktop entry, so after <code>snap install tabularis</code> the app could only be started from a terminal. A hand-written <code>snap/gui/tabularis.desktop</code> fixes that, and fixes what the <code>.deb</code>&#39;s copy got wrong on the way: a lowercase name, empty categories, an icon that doesn&#39;t resolve under confinement, and a <code>tabularis://</code> scheme handler that was invoked without the URL.</li>
<li><strong>Release notes link to the announcement post</strong>: the release workflow now reads <code>src/data/changelog.ts</code> on the tagged commit and prepends the blog post link to the GitHub release body, above the auto-generated &quot;What&#39;s Changed&quot; list. <code>tauri-action</code> was bumped to 1.0.0 via Dependabot (<a href="https://github.com/TabularisDB/tabularis/pull/601">#601</a>).</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Six external contributors land in v0.21.0.</p>
<p><strong><a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a></strong> is everywhere in this one: multi-statement exports with the truncation warning (<a href="https://github.com/TabularisDB/tabularis/pull/628">#628</a>), hidden SQLite virtual-table columns (<a href="https://github.com/TabularisDB/tabularis/pull/624">#624</a>), the manual TablePlus import path (<a href="https://github.com/TabularisDB/tabularis/pull/625">#625</a>), and copyable query errors plus the MariaDB JSON fix (<a href="https://github.com/TabularisDB/tabularis/pull/613">#613</a>). <strong><a href="https://github.com/adisusilayasa">@adisusilayasa</a></strong> made PostgreSQL client-certificate authentication work (<a href="https://github.com/TabularisDB/tabularis/pull/666">#666</a>). <strong><a href="https://github.com/aesslinger">@aesslinger</a></strong> closed the SSL-mode migration gap in the MCP process with a race guard that&#39;s worth reading (<a href="https://github.com/TabularisDB/tabularis/pull/643">#643</a>).</p>
<p><strong><a href="https://github.com/GabrielMalava">@GabrielMalava</a></strong> fixed run-at-cursor for background tabs (<a href="https://github.com/TabularisDB/tabularis/pull/603">#603</a>), <strong><a href="https://github.com/gcapellib">@gcapellib</a></strong> kept the database controls visible for single-database selections (<a href="https://github.com/TabularisDB/tabularis/pull/635">#635</a>), and <strong><a href="https://github.com/janpetto">@janpetto</a></strong> gave the Snap a launcher entry, with a verification writeup precise enough to be a template (<a href="https://github.com/TabularisDB/tabularis/pull/670">#670</a>).</p>
<p>If you&#39;ve scrolled past two hundred lines of seed data to find the one <code>UPDATE</code> you meant to edit, changed the global page size just to see 51 rows on one screen, or watched Cloud SQL reject a connection whose certificate fields you had filled in correctly, this is the upgrade.</p>
<hr>
<p><em>v0.21.0 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.21.0">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0210-sql-folding-per-tab-page-size-postgres-mtls/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>sqlite</category>
      <category>ui</category>
      <category>ux</category>
      <category>data-grid</category>
      <category>plugin</category>
      <category>community</category>
    </item>
    <item>
      <title>Code generation was only the beginning. Software development is becoming a loop</title>
      <link>https://tabularis.dev/blog/code-generation-was-only-the-beginning-software-development-is-becoming-a-loop</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/code-generation-was-only-the-beginning-software-development-is-becoming-a-loop</guid>
      <pubDate>Sun, 23 Aug 2026 13:32:00 GMT</pubDate>
      <description>Tabularis Web touched 1,040 files and moved a desktop database client behind a second transport. This is how I managed it as 48 verified tasks, and why AI development is moving from prompts toward loops.</description>
      <content:encoded><![CDATA[<h1>Code generation was only the beginning. Software development is becoming a loop</h1>
<p>Yesterday I opened <a href="https://github.com/TabularisDB/tabularis/pull/676" target="_blank" rel="noopener noreferrer">a pull request</a> to add a browser-hosted mode to Tabularis. The idea was to reuse the React UI and the Rust application services of the desktop client, but without starting a Tauri window. You run <code>tabularis web</code>, a local server starts, and you use the application from a browser.</p>
<p>The PR opened with 49 commits and a diff of 1,040 files, 50,159 additions and 8,351 deletions. A good part of the file count comes from moving the frontend into a workspace package, so the numbers look a bit more dramatic than the actual change, but it was still a very large migration. There were 227 frontend command names across 366 call sites, and more than one hundred files imported Tauri APIs. Connections, tunnels, plugins, credentials, queries, events, file dialogs and secondary windows all assumed that the UI was running inside the desktop application.</p>
<p>The first commit was made at 16:35 on August 21. The last commit of the planned work was made at 17:15 the next day. I used GPT-5.6-sol to do most of this work, but I did not give it a single prompt asking it to build the web version. I used a Ralph loop.</p>
<p>The name comes from the Ralph Wiggum technique popularized by <a href="https://ghuntley.com/loop/" target="_blank" rel="noopener noreferrer">Geoffrey Huntley</a>. The basic idea is almost disappointingly simple. You prepare a plan and a list of small tasks, start a coding agent with a fresh context, ask it to complete one task, make it record what happened, and start again. It is mostly a shell loop around an agent.</p>
<p>I think this simple pattern is important because it continues a clear progression. Autocomplete suggested the next few lines and the programmer accepted or rejected them. Chat let us paste code into a conversation and discuss a bug. Coding agents added tools, and suddenly the model could inspect the repository, edit files, run the compiler and look at the test output on its own. Each step handed the model a bigger piece of the feedback loop a programmer works inside, and the outer loop is the next step in the same direction. I will come back to this progression at the end of the post; first, how the loop actually ran.</p>
<p>An agent is already a loop internally. The model asks to read a file, receives the file, asks to change it, runs a test, sees the failure and tries something else. OpenAI has a good technical explanation of this in its article about <a href="https://openai.com/index/unrolling-the-codex-agent-loop/" target="_blank" rel="noopener noreferrer">the Codex agent loop</a>. However, this inner loop still lives inside one task and one context window. Ralph adds another loop around complete sessions.</p>
<p>In my case the outer loop was more or less this:</p>
<pre><code class="language-text">plan -&gt; fresh agent -&gt; change -&gt; local tests -&gt; commit -&gt; CI
          ^                                      |
          |--------------- repair ---------------|
</code></pre>
<p>The two loops answer different questions. The agent decides what to do next while working on a task. The runner decides if the task is actually complete and whether the next one is allowed to start. I don&#39;t want the model itself to be the only authority on both questions, since models are quite capable of saying that everything is done when the interesting broken part is just outside what they tested.</p>
<p>Longer context windows help, but I don&#39;t think they remove this need. A long session accumulates useful information together with old test output, failed approaches, changed assumptions and a lot of tool calls that no longer matter. Compaction makes the conversation smaller by summarizing it, but the summary is still an interpretation. After enough hours, it can become hard to know if the model is reasoning from the current repository or from the story it has built about the repository.</p>
<p>Starting again with a clean context has the opposite problem: the new agent knows nothing. So the important information has to live elsewhere. Anthropic described a very similar approach in its work on <a href="https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents" target="_blank" rel="noopener noreferrer">long-running agent harnesses</a>, where each new session reads a progress file and the Git history, implements one feature, tests it and leaves a clean handoff for the next session.</p>
<p>For this project the durable state was deliberately boring:</p>
<ul>
<li>one <a href="https://github.com/TabularisDB/tabularis/blob/64472f08534b73e5323d28ad921ff7f0b4473372/web-ui-project/docs/WEB_UI_PLAN.md" target="_blank" rel="noopener noreferrer">architecture document</a> describing goals, constraints and the target design;</li>
<li><a href="https://github.com/TabularisDB/tabularis/tree/64472f08534b73e5323d28ad921ff7f0b4473372/web-ui-project/tasks" target="_blank" rel="noopener noreferrer">48 task files</a> with narrow acceptance criteria;</li>
<li>a tracked <a href="https://github.com/TabularisDB/tabularis/blob/64472f08534b73e5323d28ad921ff7f0b4473372/web-ui-project/tasks/PROGRESS.md" target="_blank" rel="noopener noreferrer"><code>PROGRESS.md</code></a> with status and verification evidence;</li>
<li>Git commits as checkpoints and rollback points.</li>
</ul>
<p>All of it is committed in the PR under <code>web-ui-project/</code>, together with the runner itself, so you can read the actual artifacts instead of trusting my description of them.</p>
<p>The architecture document described the intended boundary: one shared React UI, a typed client, a Tauri transport for desktop, an HTTP and WebSocket transport for browsers, and shared Rust services below both. It also listed things that were not acceptable, such as duplicating business logic, sending credentials to the browser or accepting server file paths supplied by a remote client.</p>
<p>The task files were intentionally much smaller. Their IDs are grouped by area, which is why the numbering runs from <code>WEB-000</code> to <code>WEB-103</code> across only 48 files. One task created the command inventory. Another added the typed client. Another extracted the shared runtime. Later tasks handled query execution, notebooks, backups, plugins, AI, remote authentication, packaging and browser E2E. The query task did not need to solve the whole application. It needed to make queries behave the same through Tauri and HTTP, including cancellation and bounded results, then prove this with the relevant tests.</p>
<p>This is the part that required the most thought before starting. In wall-clock terms, preparing the architecture document and the task list took me around two to three hours, working through the decomposition in conversation with Claude Fable 5 before the loop ever ran. If the task decomposition is wrong, the loop just repeats the wrong abstraction very efficiently. A model can usually compensate for a task that is a little too big or a specification that is missing a detail, but it is much harder for it to notice that the entire sequence of tasks will produce two competing architectures thirty iterations later.</p>
<p>I ran the tasks with <a href="https://pi.dev/" target="_blank" rel="noopener noreferrer">Pi CLI</a>. I have been using Pi a lot lately and I like it because it is small, open source and easy to automate. It has a non-interactive mode, accepts files directly as context and does not impose much structure of its own. The relevant part of my runner looked like this (the <a href="https://github.com/TabularisDB/tabularis/blob/64472f08534b73e5323d28ad921ff7f0b4473372/web-ui-project/scripts/run-web-ui-tasks.sh" target="_blank" rel="noopener noreferrer">full script</a>, with all its checks and the exact prompts, is in the PR):</p>
<pre><code class="language-bash">for task in &quot;${TASKS[@]}&quot;; do
  pi --print --approve \
    --name &quot;web-ui-${task}&quot; \
    --thinking high \
    --model openai/gpt-5.6-sol \
    @web-ui-project/docs/WEB_UI_PLAN.md \
    @&quot;web-ui-project/tasks/${task}.md&quot; \
    @web-ui-project/tasks/PROGRESS.md \
    &quot;$TASK_PROMPT&quot;

  validate_progress_entry &quot;$task&quot;
  watch_task_ci &quot;$task&quot;
done
</code></pre>
<p>There was more code around this, mostly checks. In particular, the runner required that:</p>
<ul>
<li>every task ran in a brand-new Pi session, with Pi&#39;s session environment variables explicitly unset, so no conversational state could leak from one task into the next;</li>
<li>only one copy could modify the branch at a time;</li>
<li>the task updated the ledger with a summary, evidence and a date;</li>
<li>the ledger update was included in the task commit;</li>
<li>local checks passed before pushing;</li>
<li>CI was green before the next task started.</li>
</ul>
<p>Every task was a conventional commit, so every new session inherited a branch that was supposed to be both understandable and recoverable.</p>
<p>Each session was told to work only on its assigned task, inspect the worktree first, preserve unrelated changes, run focused tests before editing, review the final diff and keep desktop compatibility. It was not allowed to start the next task or spawn more agents. In theory I could have run many tasks in parallel, but on one branch this would have made the process harder to inspect. Serial execution also created useful pressure: task 31 could assume that task 30 had already passed CI.</p>
<p>The progress file was not just a checklist. For every completed task it contained what changed and the exact commands used to verify it. This turned out to be more valuable than session summaries. A new agent could read the same state that I could read, and the runner could validate parts of it without trusting the prose returned by the model.</p>
<p>The other important part was testing. The branch started with 3,838 frontend tests and 1,157 Rust tests. It ended with 4,023 frontend tests across 270 files and 1,292 passing Rust tests, plus contract tests, resilience checks, packaging checks and E2E tests against real databases in Chromium, Firefox and WebKit.</p>
<p>For the transport work, the same contract was executed against a Tauri mock and a real authenticated HTTP server. This caught a class of bugs that normal unit tests would probably miss, especially differences in serialization and errors. The packaging tests unpacked the artifacts to check that the web assets were really there. There was also a manual parity document with 29 rows, because not everything involving dialogs, windows and browser permissions can be reduced to a unit test in a useful way.</p>
<p>After each push the runner waited for GitHub Actions. If CI failed, it started a new repair session for that same task, with the failed logs included in the work. The repair had to amend the existing commit and watch CI again. There was a limit of three attempts, after which the whole loop stopped.</p>
<p>It also stopped during the real run. <code>WEB-091</code>, the task adapting plugin install links for the browser, failed on its first attempt. The next task did not start and the ledger remained unchanged. I fixed the runner, resumed it, and it continued from the first task that was not completed. That repair is also why the branch ended up with 49 commits for 48 tasks: the extra one is the commit hardening the runner itself. This sounds like a minor detail, but a loop that can only continue is not something I would leave running on a large codebase.</p>
<p>The 49 commits were produced in 24 hours and 40 minutes, around one every 31 minutes on average. Most of that time was not the model typing code. It was Rust tests, frontend tests, builds and CI. Some early commits took ten or fifteen minutes, while tasks involving queries, tunnels, remote authentication or cross-browser testing took much longer.</p>
<p>What came out of the loop is a shared application rather than a separate web rewrite. Running <code>tabularis</code> still starts the desktop client. Running <code>tabularis web</code> starts the same application services without a native window and serves the React UI. On desktop the typed client uses Tauri IPC. In the browser it uses HTTP and WebSocket. Both transports reach the same Rust services for connections, queries, plugins, settings, backups and AI.</p>
<p>The browser differences are handled as capabilities. A desktop file dialog may become an upload, a download or a server-side picker. A secondary window becomes a route. The updater becomes information about the server version. Local mode binds to <code>127.0.0.1</code> and exchanges a short-lived bootstrap token for an <code>HttpOnly</code> session. Remote mode is rejected unless authentication, HTTPS origin and allowed origins are configured.</p>
<p>A disclosure is due here. I use GPT-5.6-sol thanks to OpenAI supporting Tabularis through the <a href="https://openai.com/form/codex-for-oss/" target="_blank" rel="noopener noreferrer">Codex for Open Source program</a>, which gives selected maintainers Codex access and API credits. It is worth mentioning because this was not a token-efficient experiment. Forty-eight high-reasoning tasks, full test suites and repair sessions consume real resources: in a single day, the run went through roughly 25% of the weekly usage limit of a ChatGPT Pro plan. The sponsorship allowed me to optimize for verification instead of trying to save every model call. With that said, the model did a very good job.</p>
<p>At the same time, I don&#39;t think the result is explained only by the model becoming stronger. GPT-5.6-sol was more capable than the models I used a few months ago, but it also found a repository prepared with examples, tests, rules and a very explicit sequence of changes. The same model with a single “build a web version” prompt would probably have produced something impressive in a few hours and unpleasant to maintain for years.</p>
<p>Looking back at the whole run, the clearest way I have found to describe the progression I mentioned at the beginning is where the durable state lives and who decides that the work is complete:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Unit of work</th>
<th>Durable state</th>
<th>Completion</th>
</tr>
</thead>
<tbody><tr>
<td>Autocomplete</td>
<td>The next tokens</td>
<td>The open file</td>
<td>The developer accepts the suggestion</td>
</tr>
<tr>
<td>Chat</td>
<td>One answer</td>
<td>The conversation</td>
<td>The developer asks another question</td>
</tr>
<tr>
<td>Coding agent</td>
<td>One delegated task</td>
<td>The context window and worktree</td>
<td>The agent returns control</td>
</tr>
<tr>
<td>Agent loop</td>
<td>One verified task</td>
<td>The repository, tests and Git</td>
<td>External checks pass</td>
</tr>
</tbody></table>
<p>This is why I expect more AI development to move toward loops and what OpenAI calls <a href="https://openai.com/index/harness-engineering/" target="_blank" rel="noopener noreferrer">harness engineering</a>. As agents become able to work for longer, the interesting problem moves from getting them to write code to giving them an environment where wrong work is visible. The compiler, tests, Git history, browser, CI and code review all become feedback channels. The model tries something, the environment pushes back, and the next iteration starts from that result.</p>
<p>There is also a limit to this approach. The loop completed all 48 tasks, but the <a href="https://github.com/TabularisDB/tabularis/pull/676" target="_blank" rel="noopener noreferrer">pull request is still open</a> while I write this. After the planned run I changed the original <code>--web</code> flag into a <code>web</code> subcommand, added connection-specific editor routes and a secure server-side file picker, fixed CI details and resolved conflicts with <code>main</code>. External review found real security issues. The clearest one: user-uploaded SVG connection icons were served inline without any content security policy, a stored XSS, while the neighboring plugin asset endpoint applied every one of the missing protections. The loop knew the right pattern and used it where a task asked for it, but no task owned the invariant. Security is a property of the whole system, and a plan made of locally verified tasks does not automatically verify global properties. Four thousand tests are useful evidence, but they do not make a network listener correct by definition.</p>
<p>I still have to review the architecture, the threat model and the final diff, and I still decide if the feature is ready to merge. The loop changes where my time goes. I spend more time preparing tasks, making the codebase legible, deciding what evidence is sufficient and reviewing the result. I spend much less time writing each implementation line myself.</p>
<p>I would use this approach again for work that can be decomposed and verified. I would be more careful for a product whose behavior is still unclear, because a loop is not particularly good at deciding what people want. It is good at moving through a plan once the plan has enough structure.</p>
<p>I don&#39;t know what the right size of a task will be as models improve. What seems clear is that the useful unit is becoming less “give the model a prompt” and more “build a process where the model can try, observe and continue.” For me, this project was the first time that difference became very concrete.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/code-generation-was-only-the-beginning-software-development-is-becoming-a-loop/opengraph-image.png" type="image/png" />
      <category>ai</category>
      <category>engineering</category>
      <category>open-source</category>
      <category>ralph-loop</category>
      <category>deep-dive</category>
    </item>
    <item>
      <title>v0.20.0: A Command Palette That Knows Which Pane You&apos;re In, Paste That Works Like a Spreadsheet, and PostgreSQL Moves Out of the Core</title>
      <link>https://tabularis.dev/blog/v0200-command-palette-grid-paste-postgres-plugin</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0200-command-palette-grid-paste-postgres-plugin</guid>
      <pubDate>Mon, 17 Aug 2026 18:00:00 GMT</pubDate>
      <description>v0.20.0 replaces the quick navigator with a scope-aware command palette, teaches the data grid to paste spreadsheet ranges as staged edits, moves PostgreSQL out of the core and into a standalone plugin at full parity with the built-in driver (the first step in migrating every built-in driver to the plugin pipeline), adds inline hex preview and editing for BLOBs, and makes rows with binary primary keys deletable on MySQL.</description>
      <content:encoded><![CDATA[<h1>v0.20.0: A Command Palette That Knows Which Pane You&#39;re In, Paste That Works Like a Spreadsheet, and PostgreSQL Moves Out of the Core</h1>
<p><strong>v0.20.0</strong> follows <a href="https://tabularis.dev/blog/v0190-connection-tags-column-masking-keyless-editing">v0.19.0</a>. The quick navigator grows into a real command palette that understands split view, and the data grid learns to paste: tab-separated, CSV, or a single value fanned across a range, always as staged edits you can still roll back. Underneath, the core learns to let go of its drivers. PostgreSQL, the flagship, becomes an independent plugin, developed and released in its own repository at full parity with the built-in driver, which it will most likely replace in an upcoming release. It&#39;s the first step of a migration we intend to repeat for every built-in driver. And binary data stops being a second-class citizen: small BLOBs render and edit as hex, and MySQL rows keyed by <code>BINARY</code> columns can finally be deleted.</p>
<hr>
<h2>The Quick Navigator Grows Into a Command Palette</h2>
<p>The quick navigator could find a table. It could not run a command, and in split view it quietly searched whichever connection happened to be <em>active</em> rather than the pane you were actually looking at. PR <a href="https://github.com/TabularisDB/tabularis/pull/545">#545</a>, from <a href="https://github.com/verbaux">@verbaux</a>, replaces it with a Spotlight-style palette with two modes: the familiar object search (tables, views, routines, triggers), and a new <strong>action palette</strong> on <strong>Cmd/Ctrl+Shift+A</strong>, with a visible mode label in the header so you always know which one you&#39;re in.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-command-palette.mp4" poster="/videos/posts/tabularis-command-palette.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p>The interesting work is under the surface. Both palettes and the explorer sidebar each used to build their own &quot;open this in the editor&quot; logic: assembling SQL, guessing the tab type, reading the active connection regardless of where you clicked. They now share one navigation contract, a single definition of what &quot;open&quot;, &quot;count rows&quot; and &quot;show definition&quot; mean per object type, used by the sidebar&#39;s sixteen call sites and both palette modes alike. Each editor pane registers a <strong>command scope</strong>, and the palette resolves against the scope that owns the focus. In split view with two connections, the action palette from each pane targets <em>that pane&#39;s</em> connection and table. The same discipline reached the schema and generate-SQL modals, which used to inspect the active connection even when opened from a non-active pane; they now receive an explicit target. Palette strings shipped translated, including a contributed Brazilian Portuguese pass.</p>
<p>The action palette deliberately starts small (open settings, open the current table in the SQL console), because the point of this release is the registry, the scoping and the navigation contract; commands are now one item plus an i18n key to add. And the palette&#39;s one known data-freshness gap didn&#39;t survive the cycle: <a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a> fixed the multi-database object list to read the provider&#39;s live database selection instead of the stale saved params, so a database dropped on the server stops haunting the palette (PR <a href="https://github.com/TabularisDB/tabularis/pull/597">#597</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/591">#591</a>).</p>
<hr>
<h2>Paste Lands in the Data Grid</h2>
<p>Copy has been getting steadily smarter for two releases; paste didn&#39;t exist. PR <a href="https://github.com/TabularisDB/tabularis/pull/612">#612</a>, from <a href="https://github.com/ymadd">@ymadd</a> (closes <a href="https://github.com/TabularisDB/tabularis/issues/611">#611</a>), adds spreadsheet-style <strong>Cmd/Ctrl+V</strong>, and every pasted value goes through the existing pending-changes flow as a staged edit, never straight to the database. You still review, apply or roll back.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-grid-paste.mp4" poster="/videos/posts/tabularis-grid-paste.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p>The parsing rules are chosen to round-trip real workflows. Tab-separated cells win, because that&#39;s what every spreadsheet puts on the clipboard. Multi-line text without tabs is parsed as CSV with double-quote escaping, preferring your configured CSV delimiter, so the grid&#39;s own copy formats round-trip cleanly. A single line without tabs is always one value, so <code>hello, world</code> lands in one cell instead of two. A leading header row is dropped only when it matches the grid&#39;s column names positionally from the paste anchor: the &quot;export column names&quot; option round-trips without swallowing external data that merely mentions a column name. A single copied value fills the whole selected range or row selection, the pasted matrix clips at the grid edges, and alias and computed columns are skipped under the same guard as inline editing. Pasting a cell&#39;s original value back clears its pending change, exactly like typing it would.</p>
<p>The prerequisite fix matters beyond paste: staging N cells in one tick hit a React batching bug where only the last cell survived, because the pending-changes handlers computed the next state from a snapshot. They now use functional updates, which hardens every rapid-succession staging path, not just this one.</p>
<hr>
<h2>PostgreSQL Moves Out of the Core</h2>
<p>Tabularis has been moving toward drivers-as-plugins for several releases (<a href="https://github.com/TabularisDB/tabularis/issues/16">#16</a> is the tracking issue), and this cycle the flagship makes the jump. PostgreSQL now lives as an independent plugin in its own repository, <a href="https://github.com/TabularisDB/tabularis-postgresql-plugin">tabularis-postgresql-plugin</a>, no longer developed inside the core. The in-tree copy is deleted; the plugin repo is the single source of truth, with its own security audit, its own CI, and its own 5-platform release builds. PR <a href="https://github.com/TabularisDB/tabularis/pull/577">#577</a>, from <a href="https://github.com/aesslinger">@aesslinger</a>, lands the migration.</p>
<p>This is the model we intend to apply to <strong>every built-in driver</strong>, and it&#39;s worth spelling out why:</p>
<ul>
<li><strong>Drivers ship on their own schedule.</strong> A PostgreSQL fix no longer waits for a Tabularis release. It ships as a plugin release, with its own version, its own changelog, its own cadence.</li>
<li><strong>The core gets smaller.</strong> Every driver that moves out is code the app no longer has to carry, build, and audit in-tree.</li>
<li><strong>Community drivers stop being second-class.</strong> If PostgreSQL itself can run through the plugin API, the API is proven complete. An external plugin has access to exactly the same machinery the flagship uses, because the flagship uses nothing else.</li>
<li><strong>Isolation.</strong> Plugin drivers run as separate processes behind JSON-RPC; a driver crash is a driver crash, not an app crash.</li>
<li><strong>Focused security surface.</strong> A driver in its own repo gets its own audit and its own hardened release pipeline, instead of inheriting whatever the monorepo does.</li>
<li><strong>It keeps the host honest.</strong> Migrating PostgreSQL flushed out every place the core was quietly special-casing itself, and those fixes benefit all drivers (more below).</li>
</ul>
<p>The plugin is <strong>ready</strong>, and &quot;ready&quot; here is not a vibe, it&#39;s a test suite. The migration built a harness that spawns the real plugin binary and runs the same operations through both drivers against the same live PostgreSQL 16, comparing results: 83 parity tests, on top of a 181-test integration suite with golden-file snapshots for schema metadata. The harness is as paranoid as the thing it tests. A <code>POSTGRES_PLUGIN_BIN</code> pointing at a missing binary now panics instead of silently degrading to builtin-only, a missing golden file fails the test instead of skipping it with a warning, and a CI job that regenerated golden files before comparing against them (an assertion that could never fail) was caught and made opt-in. The manual sign-off ran a 24-item smoke test against the real plugin binary in the actual desktop UI, cross-checking every result directly via <code>psql</code>. It even shook out a real plugin bug along the way (<code>execute_query</code> returned <code>null</code> for enum values instead of the label), fixed upstream and pinned with a new parity test.</p>
<p>The honesty part is the piece built-in-driver users feel too. Several core checks were hardcoded on <code>driver === &quot;postgres&quot;</code>, so the plugin&#39;s <code>&quot;postgresql&quot;</code> driver id silently fell through to wrong behavior, and issue <a href="https://github.com/TabularisDB/tabularis/issues/614">#614</a> catalogs the damage: broken identifier quoting, wrong MCP schema defaults, a connection form that lost its host/port layout, and an SSL-mode dropdown that could leave a plugin connection in cleartext without saying so. All of these now key off the driver&#39;s declared <strong>SQL dialect and capabilities</strong> instead of its id, with a migration for already-persisted stale <code>ssl_mode</code> values, and <code>sql_dialect</code> made a true <code>Option</code> so an absent value is no longer silently treated as PostgreSQL. The host stops special-casing its own driver, which is the real architectural milestone: every future driver migration inherits these fixes for free.</p>
<p>Where this goes: the plugin will most likely <strong>replace the built-in PostgreSQL driver in an upcoming release</strong>. The removal is tracked in <a href="https://github.com/TabularisDB/tabularis/issues/631">#631</a> and will happen deliberately, not by surprise. In v0.20.0 nothing changes for existing connections: the built-in driver is untouched and remains the default. But you can already try the plugin today: grab a release build from the <a href="https://github.com/TabularisDB/tabularis-postgresql-plugin/releases">plugin repository</a> (Linux, macOS, and Windows builds are published per release); one-click install from the Plugin Center is the follow-up. Feedback from real databases now is what makes the eventual switch a non-event.</p>
<hr>
<h2>Binary Data: Hex You Can Read, Keys You Can Delete</h2>
<p>Three threads landed on the same theme: binary and keyless data used to fail in opaque ways, and now doesn&#39;t.</p>
<p><strong>BLOBs get an inline hex preview and editor</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/648">#648</a>). Small generic binary values, think <code>BINARY(16)</code> identifiers, now render in the grid as a compact <code>0x…</code> hex string (up to 64 bytes, then an ellipsis) instead of opaque Base64 transport metadata. In the row-editor sidebar, any complete BLOB up to 10 KiB opens in a monospaced hex editor: uppercase space-separated pairs, whitespace and an optional <code>0x</code> prefix accepted, odd-length or non-hex input rejected, invalid edits reverted on blur, and the original MIME type preserved on write-back. Larger or truncated BLOBs keep the existing download/file editor, and the backend preview boundary moves from 4 KiB to 10 KiB so everything in the editable range arrives complete, with the truncation check corrected to account for Base64 padding so a truncated value can never sneak into the hex editor.</p>
<p><img src="https://tabularis.dev/img/tabularis-blob-hex-editor.png" alt="A table with BINARY(16) primary keys rendered as compact 0x hex strings in the grid, and the Edit Row sidebar showing the binary id and varbinary token columns open in monospaced hex editors"></p>
<p><strong>Rows with binary primary keys can be deleted on MySQL</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/647">#647</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/646">#646</a>). MySQL <code>BINARY</code> values reach the frontend in Tabularis&#39; BLOB wire format, and row deletion passed that representation straight back into the primary-key predicate as a string, which matched nothing. The driver now decodes the wire value first, binds raw bytes on prepared statements, renders hex literals on the text protocol, and carries a Docker-backed regression test against MySQL 8.4.</p>
<p><strong>Keyless-table edits stop tripping over PostgreSQL&#39;s type strictness</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/618">#618</a>). The keyless editing shipped in v0.19.0 identifies rows by all their columns, so the WHERE predicate can target numeric and temporal columns, whose values arrive as JSON strings and were bound as plain TEXT, which PostgreSQL rejects with <code>operator does not exist: numeric = text</code>. The predicate builder now routes known column types through the same numeric and temporal coercions the SET path already used. The same PR adds a small mercy to every failure after this one: the error modal gains a <strong>Copy</strong> button.</p>
<hr>
<h2>Package-Managed Builds Stop Fighting Their Package Manager</h2>
<p>If a distribution packages Tabularis, the in-app updater is at best noise and at worst a fight over who owns the binary. PR <a href="https://github.com/TabularisDB/tabularis/pull/621">#621</a> (closes <a href="https://github.com/TabularisDB/tabularis/issues/617">#617</a>) lets package maintainers set <code>PACKAGE_MANAGER_SRC</code> and <code>PACKAGE_MANAGER_NAME</code> at build time: the build then skips automatic update checks, disables the built-in installer, and names the managing package manager in the Info settings page, joining the existing Snap and Flatpak detection.</p>
<p>The concrete occasion is a happy one: this was built for <strong><a href="https://getsol.us/">Solus</a></strong>, which plans to integrate Tabularis into its own repositories. Solus is an independent, curated rolling-release Linux distribution (&quot;The Personal OS for Personal Computers&quot;) with its own package manager, <code>eopkg</code>, and a philosophy of shipping software that works out of the box with sensible defaults. A distribution with that level of curation choosing to package Tabularis natively is exactly the kind of adoption we hoped for, and we&#39;re glad to meet them halfway. The feature was tested end-to-end with a real Solus <code>eopkg</code> build, and any other distribution can use the same two variables to ship a build that behaves like a proper citizen of its package manager.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>Query results survive switching connections</strong> (<a href="https://github.com/iamthenuggetman">@iamthenuggetman</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/586">#586</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/292">#292</a>): switching to another connection and back replaced live tabs with their persisted copies, which are saved result-stripped; the grid came back empty. The storage reload is now skipped when a connection&#39;s tabs are already live in memory, which also kills the loading flash on the way back.</li>
<li><strong>macOS stops autocorrecting your SQL</strong> (<a href="https://github.com/iamthenuggetman">@iamthenuggetman</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/634">#634</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/633">#633</a>): WKWebView inherits Safari&#39;s autofill pill, predictive text, autocorrect and spellcheck, and 102 technical fields across 57 files were still exposed. All of them now opt out; the three natural-language prose fields (AI prompts, markdown cells) deliberately keep their spellcheck.</li>
<li><strong>One failing metadata query no longer takes down a database&#39;s tree</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/638">#638</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/637">#637</a>): when a server rejects the <code>information_schema.routines</code> lookup, the explorer used to treat the whole expand as failed and retry it forever in a loop. Routine metadata is now optional: tables, views and triggers load, <strong>Routines</strong> gets an error indicator with the full copyable server error, and failed loads stop auto-retrying unboundedly.
<img src="https://tabularis.dev/img/tabularis-routines-error-indicator.png" alt="The explorer sidebar with tables, views and triggers loaded normally while the Routines section shows a red error indicator, reproduced against a proxy that rejects only the routines metadata query"></li>
<li><strong>SQLite generated columns appear, and stay read-only</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/581">#581</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/162">#162</a>): column metadata moves to <code>table_xinfo</code>, so generated columns show up in table grids, flagged as generated and excluded from insert and update paths.</li>
<li><strong>PostgreSQL Visual Query Builder follow-ups</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/588">#588</a>): the table list routes through the same reference formatter as <code>FROM</code> generation, <code>HAVING</code> aggregate references are formatted correctly, <code>SELECT</code> aliases are quoted when they need it, and all of it applies to both the <code>postgres</code> and <code>postgresql</code> driver ids.</li>
<li><strong>Notebook parameters accept <code>${name}</code></strong> (<a href="https://github.com/fuleinist">@fuleinist</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/602">#602</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/550">#550</a>): alongside the existing <code>@name</code>, and both syntaxes can mix in one query.</li>
<li><strong>URI-first connection forms, and FK creation params for plugins</strong> (<a href="https://github.com/jonaspm">@jonaspm</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/619">#619</a>): the connection modal optimizes its layout when a driver declares raw-URI connections, and <code>get_create_foreign_key_sql</code> now receives <code>ConnectionParams</code> through the driver trait and the RPC layer, unblocking foreign-key creation in plugins like libSQL.</li>
<li><strong>Add Row survives an empty table</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/642">#642</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/641">#641</a>): an empty result without column metadata used to drop the tab out of data mode, taking the manipulation toolbar and <strong>Add Row</strong> with it, which made an empty table impossible to fill from the grid.</li>
<li><strong>Plugin icons finally render</strong> (<a href="https://github.com/aesslinger">@aesslinger</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/645">#645</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/632">#632</a>): <code>getDriverIcon</code> never checked for a URL or <code>data:</code> manifest icon, so every external plugin fell back to the generic plug glyph in 7 of the 8 places icons render. The check now runs first, scheme-matched case-insensitively, and the documented priority holds: connection custom icon, then manifest icon, then built-in fallback.</li>
<li><strong>Plugins live in the same data directory as everything else</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/258">#258</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/257">#257</a>): on macOS and Windows, plugins were stored under a legacy <code>com.debba.tabularis</code> path that contradicted the documented location, so a plugin placed where the docs said was never discovered. Storage is unified under <code>tabularis</code> with a one-time, non-clobbering migration at startup.</li>
<li><strong>CI stops recompiling the world</strong> (<a href="https://github.com/aesslinger">@aesslinger</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/644">#644</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/640">#640</a>): the test job spent ~6.5 of its ~10 minutes cold-compiling the Rust dependency graph to run 19 seconds of tests; it now reuses the same pinned rust-cache step the release build already trusted.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Seven external contributors land in v0.20.0.</p>
<p><strong><a href="https://github.com/aesslinger">@aesslinger</a></strong> carried the biggest change: the PostgreSQL driver&#39;s move out of the core, at full parity, plus the host-side dialect and capability fixes (<a href="https://github.com/TabularisDB/tabularis/pull/577">#577</a>), the plugin manifest icon fix (<a href="https://github.com/TabularisDB/tabularis/pull/645">#645</a>), and a CI test job that stopped recompiling everything (<a href="https://github.com/TabularisDB/tabularis/pull/644">#644</a>). <strong><a href="https://github.com/verbaux">@verbaux</a></strong> built the command palette and the unified navigation contract underneath it (<a href="https://github.com/TabularisDB/tabularis/pull/545">#545</a>). <strong><a href="https://github.com/ymadd">@ymadd</a></strong> gave the data grid paste, with the pending-changes batching fix it needed (<a href="https://github.com/TabularisDB/tabularis/pull/612">#612</a>).</p>
<p><strong><a href="https://github.com/iamthenuggetman">@iamthenuggetman</a></strong> kept query results alive across connection switches (<a href="https://github.com/TabularisDB/tabularis/pull/586">#586</a>) and silenced macOS text assistance on every technical field (<a href="https://github.com/TabularisDB/tabularis/pull/634">#634</a>). <strong><a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a></strong> landed three fixes: SQLite generated-column metadata (<a href="https://github.com/TabularisDB/tabularis/pull/581">#581</a>), PostgreSQL Visual Query Builder follow-ups (<a href="https://github.com/TabularisDB/tabularis/pull/588">#588</a>), and the palette&#39;s live database selection (<a href="https://github.com/TabularisDB/tabularis/pull/597">#597</a>). <strong><a href="https://github.com/fuleinist">@fuleinist</a></strong> added <code>${name}</code> notebook parameters (<a href="https://github.com/TabularisDB/tabularis/pull/602">#602</a>), and <strong><a href="https://github.com/jonaspm">@jonaspm</a></strong> threaded connection params through foreign-key SQL generation for plugin drivers and made URI-first connection forms possible (<a href="https://github.com/TabularisDB/tabularis/pull/619">#619</a>).</p>
<p>If you&#39;ve ever hit Cmd+V on a grid out of spreadsheet habit and watched nothing happen, worked in split view and had the palette search the wrong connection, or stared at a Base64 blob that you knew was just sixteen bytes, this is the upgrade.</p>
<hr>
<p><em>v0.20.0 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.20.0">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0200-command-palette-grid-paste-postgres-plugin/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>sqlite</category>
      <category>ui</category>
      <category>ux</category>
      <category>data-grid</category>
      <category>plugin</category>
      <category>community</category>
    </item>
    <item>
      <title>We Accidentally Ran One SQL Script Across Multiple Database Connections</title>
      <link>https://tabularis.dev/blog/one-sql-script-multiple-database-connections</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/one-sql-script-multiple-database-connections</guid>
      <pubDate>Wed, 12 Aug 2026 10:00:00 GMT</pubDate>
      <description>Run All looked like a UI feature. Underneath, it fanned one SQL script into parallel pool checkouts. The statements succeeded, the session state disappeared, and a parent-child import silently produced missing and duplicated rows.</description>
      <content:encoded><![CDATA[<h1>We Accidentally Ran One SQL Script Across Multiple Database Connections</h1>
<p>The import was supposed to create 144 child rows, eight for each of 18 parents. It created 83.</p>
<p>Two parents had no children. One parent&#39;s first child appeared five times. Two others got 14 and 10 rows instead of eight. Auto-increment IDs from unrelated parents were interleaved. The broken child IDs also fed a schedule table that was supposed to receive 1,008 rows.</p>
<p>There was no useful error. Every SQL statement looked successful in the UI.</p>
<p>This happened in Tabularis v0.10.2 against MySQL. The execution path was open source, which made the failure straightforward to reconstruct once we stopped looking for a mistake in the SQL.</p>
<p>The same file produced the correct data outside the application&#39;s multi-statement path. The SQL was not the problem. The transaction was not the problem either, at least not in the way we first suspected.</p>
<p>The application had taken one SQL script and executed it as a collection of unrelated, concurrent database sessions.</p>
<h2>The script depended on a session</h2>
<p>The original report involved a longer MySQL import, but its essential dependency fits in four statements:</p>
<pre><code class="language-sql">INSERT INTO parent (name) VALUES (&#39;A&#39;);
SET @parent_id = LAST_INSERT_ID();
INSERT INTO child (parent_id, name) VALUES (@parent_id, &#39;A-1&#39;);
INSERT INTO child (parent_id, name) VALUES (@parent_id, &#39;A-2&#39;);
</code></pre>
<p>This is valid MySQL. <code>LAST_INSERT_ID()</code> returns the auto-increment value generated by the most recent insert on the current connection. User-defined variables such as <code>@parent_id</code> are also scoped to the current session. The MySQL documentation is explicit about both properties: <a href="https://dev.mysql.com/doc/refman/8.0/en/user-variables.html">user variables are session-specific</a>, and the server maintains <a href="https://dev.mysql.com/doc/refman/8.0/en/information-functions.html"><code>LAST_INSERT_ID()</code> on a per-connection basis</a>.</p>
<p>The word &quot;connection&quot; is doing all the work there.</p>
<p>In the editor, the user had selected one saved connection and clicked Run All. At the UI level, it looked like one destination. At the driver level, that saved connection referred to a pool containing up to ten physical MySQL connections. Those are ten independent server sessions.</p>
<p>There is nothing wrong with that. A pool is supposed to let concurrent work borrow different connections. The bug was treating a sequence that required one session as concurrent work.</p>
<h2><code>Promise.allSettled</code> was the transaction coordinator</h2>
<p>The <a href="https://github.com/TabularisDB/tabularis/blob/5ead7a7dcaa11956800d3bc6a02a5f71d9c4b078/src/pages/Editor.tsx#L823-L835">old execution path</a> split the script into statements and mapped each one to a separate Tauri command:</p>
<pre><code class="language-typescript">await Promise.allSettled(
  statements.map((statement) =&gt;
    invoke(&quot;execute_query&quot;, {
      connectionId,
      query: statement,
    }),
  ),
);
</code></pre>
<p>That code was originally concerned with result rendering. Each query got its own result tab, execution time and error state. <code>Promise.allSettled</code> was convenient because one failed statement did not prevent the UI from collecting the others.</p>
<p>It also meant every statement started independently.</p>
<p>On the Rust side, every <code>execute_query</code> call did what a normal single-query path should do: resolve the pool and <a href="https://github.com/TabularisDB/tabularis/blob/5ead7a7dcaa11956800d3bc6a02a5f71d9c4b078/src-tauri/src/drivers/mysql/mod.rs#L854-L870">acquire a connection from it</a>.</p>
<pre><code class="language-rust">let pool = get_mysql_pool(params).await?;
let mut conn = pool.acquire().await?;
execute_on(&amp;mut conn, query).await
</code></pre>
<p>SQLx returns the connection to the pool when the checkout is dropped. Another statement may later get the same physical connection, but that is an implementation detail, not a guarantee. With several calls in flight concurrently, multiple physical connections are exactly what the pool is there to provide.</p>
<p>The four-statement script could therefore become this:</p>
<pre><code class="language-text">INSERT INTO parent ...       connection 17
SET @parent_id = ...         connection 24
INSERT INTO child ...        connection 31
INSERT INTO child ...        connection 42
</code></pre>
<p>Connection 17 owned the new auto-increment value. Connection 24 could not see it. Connections 31 and 42 could not see the variable set on connection 24.</p>
<p>An uninitialized MySQL user variable evaluates to <code>NULL</code>. Because <code>parent_id</code> in the affected import accepted it, the child insert was still valid SQL. Other pooled connections could carry stale session state from earlier work. Concurrency also removed statement ordering, so a dependent statement could reach the server before the statement it depended on.</p>
<p>The database was doing exactly what each client asked. We had accidentally created several clients.</p>
<h2>Why the failure looked healthy</h2>
<p>Most database failures are kind enough to produce an error code. This one often produced plausible data.</p>
<p>The statements were individually valid. A child row with a nullable or incorrect foreign key can still be inserted. <code>Promise.allSettled</code> waited for every invocation and let the interface render successes alongside failures. Worse, the MySQL, PostgreSQL and SQLite execution paths were returning <code>affected_rows: 0</code> for every non-result statement, regardless of what the server reported.</p>
<p>So a successful insert and an insert that did nothing had the same summary: zero rows affected.</p>
<p>That hardcoded value did not cause the session bug, but it removed one of the few signals that might have exposed it. It was fixed in the same change.</p>
<p>This is a particularly unpleasant class of corruption because the output retains structure. Eight missing rows are easy to notice. Sixty-one missing rows spread across several parents, mixed with duplicates and valid inserts, look like an error in the source data or in the import logic. By the time somebody counts the records, the script is no longer running and the pool has long since returned every connection.</p>
<h2>Serial execution is necessary, but not sufficient</h2>
<p>The obvious first fix is to replace <code>Promise.allSettled</code> with a loop:</p>
<pre><code class="language-typescript">for (const statement of statements) {
  await invoke(&quot;execute_query&quot;, { connectionId, query: statement });
}
</code></pre>
<p>That restores ordering. It does not restore session identity.</p>
<p>Each invocation still calls <code>pool.acquire()</code>. The pool is free to return connection 17 for the first statement and connection 24 for the second. Under light load it may happen to return the same idle connection repeatedly, which is worse than a deterministic failure because the broken implementation appears correct in development.</p>
<p>There are two separate invariants:</p>
<ol>
<li>Statement B must start after statement A finishes.</li>
<li>Statement B must execute in the same database session as statement A.</li>
</ol>
<p>Awaiting each query gives you the first. Holding one connection for the lifetime of the script gives you both.</p>
<p>This distinction matters far beyond SQL editors. Any abstraction that accepts a pool and exposes a sequence of calls can get it wrong: migration runners, background jobs, import tools, home-grown transaction helpers and repository layers. If the API does not make connection affinity visible, a loop can look safe while still changing sessions between iterations.</p>
<h2>The fix was one checkout, then a loop</h2>
<p>We replaced the per-statement command fan-out with one <code>execute_query_batch</code> call. The built-in drivers acquire one physical connection before the first statement and keep it until the last result has been collected.</p>
<p>The core of the fix is deliberately boring:</p>
<pre><code class="language-rust">let mut conn = pool.acquire().await?;
let mut results = Vec::with_capacity(statements.len());

for statement in statements {
    let result = execute_on(&amp;mut conn, statement).await;
    results.push(result);
}

Ok(results)
</code></pre>
<p>The important part is not the loop. It is where <code>acquire()</code> sits.</p>
<p>Previously it was inside <code>execute_query</code>, so it ran once per statement. In the batch path it runs before the loop, so all statements share the same checked-out connection. The UI still receives one result per statement and can show partial failures, but execution order and session state now agree with what Run All means to a person reading the script.</p>
<p>MySQL, PostgreSQL and SQLite each implement the batch operation against their native connection type. External plugin drivers get a sequential default implementation for compatibility, but must override it if they promise connection-local continuity. That limitation is documented in the driver trait rather than hidden behind a method named <code>execute_batch</code>.</p>
<p>The full change is in <a href="https://github.com/TabularisDB/tabularis/commit/8eed14b20135a7e6a091f3ce404d484d16635a6e">commit <code>8eed14b2</code></a>. The original report, including the damaged row counts and minimal reproduction, is <a href="https://github.com/TabularisDB/tabularis/issues/199">issue #199</a>.</p>
<h2>Fixing the connection exposed a protocol problem</h2>
<p>Keeping one connection alive made transactions structurally possible, but MySQL added another constraint.</p>
<p>SQLx normally sends <code>sqlx::query()</code> through MySQL&#39;s prepared-statement protocol, using <code>COM_STMT_PREPARE</code> and <code>COM_STMT_EXECUTE</code>. In our path, preparing transaction control still surfaced MySQL error 1295 on commands outside the protocol&#39;s supported set. MySQL <a href="https://dev.mysql.com/doc/refman/8.0/en/sql-prepared-statements.html">documents <code>COMMIT</code> as preparable</a>, but does not list the whole family, including <code>BEGIN</code>, <code>START TRANSACTION</code> and savepoint operations.</p>
<p>The driver now routes the complete transaction-control family through the text protocol with <code>sqlx::raw_sql()</code>, which uses <code>COM_QUERY</code>. Regular statements continue through the prepared path. Sharing a connection was the semantic fix; selecting the correct wire protocol was required to make explicit MySQL transactions actually use it.</p>
<p>This was not visible while every statement had its own connection. A transaction could not work correctly in that model anyway. Removing one broken assumption exposed the next one.</p>
<h2>Testing session identity instead of query success</h2>
<p>The regression tests do not merely assert that each statement returns <code>Ok</code>. That was already happening when the data was wrong.</p>
<p>The MySQL test creates a parent, stores <code>LAST_INSERT_ID()</code> in <code>@pid</code>, inserts two children and then verifies that both children reference the parent that was just created. A second test wraps two inserts in <code>BEGIN</code> and <code>COMMIT</code> and checks the committed rows.</p>
<p>The PostgreSQL test uses a temporary table:</p>
<pre><code class="language-sql">BEGIN;
CREATE TEMP TABLE batch_tmp (id serial primary key, value text);
INSERT INTO batch_tmp (value) VALUES (&#39;a&#39;), (&#39;b&#39;), (&#39;c&#39;);
SELECT count(*) FROM batch_tmp;
COMMIT;
</code></pre>
<p><a href="https://www.postgresql.org/docs/current/sql-createtable.html">PostgreSQL requires each session to create its own temporary table</a>. A later statement on another connection cannot see it. That makes the table a useful assertion about session identity, not just query ordering. PostgreSQL also exposes <a href="https://www.postgresql.org/docs/current/functions-info.html"><code>pg_backend_pid()</code></a>, while MySQL exposes <a href="https://dev.mysql.com/doc/refman/8.0/en/information-functions.html"><code>CONNECTION_ID()</code></a>, if you want a minimal diagnostic that prints the server session used by each statement.</p>
<p>These are integration tests against live MySQL and PostgreSQL instances, so they are ignored in the ordinary unit test run. That is worth stating because a test existing in a repository is not evidence that somebody ran it against a server. The issue reproducer and the fixed execution path are the primary evidence; the tests preserve the invariant for future driver changes.</p>
<h2>A script is a conversation</h2>
<p>Connection pools encourage a useful mental model: take a connection, perform one independent unit of work, return it quickly. Problems start when &quot;independent&quot; is inferred from an API boundary instead of from database semantics.</p>
<p>These operations all depend on session identity:</p>
<ul>
<li><code>BEGIN</code>, <code>COMMIT</code>, savepoints and transaction-scoped locks</li>
<li>MySQL user variables and <code>LAST_INSERT_ID()</code></li>
<li>PostgreSQL <code>currval()</code> and prepared statements</li>
<li>temporary tables</li>
<li>session settings such as <code>search_path</code>, <code>sql_mode</code> or <code>FOREIGN_KEY_CHECKS</code></li>
<li>advisory locks and other connection-owned resources</li>
</ul>
<p>A pool may choose any suitable connection for the next operation. It cannot infer that two strings originated from the same editor buffer, migration file or job. If continuity matters, the application has to express it by holding a connection or transaction handle across the whole unit of work.</p>
<p>That is the general lesson from this bug:</p>
<p><strong>A SQL script is not a <code>Vec&lt;String&gt;</code>. It is a conversation with one database session.</strong></p>
<p>We had preserved the strings and discarded the conversation. The database accepted every sentence. It was listening on four different calls.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/one-sql-script-multiple-database-connections/opengraph-image.png" type="image/png" />
      <category>databases</category>
      <category>sql</category>
      <category>mysql</category>
      <category>postgresql</category>
      <category>connection-pooling</category>
      <category>engineering</category>
      <category>deep-dive</category>
    </item>
    <item>
      <title>v0.19.0: Connections That Know They&apos;re Production, Columns That Keep Secrets, and Tables Without a Primary Key You Can Finally Edit</title>
      <link>https://tabularis.dev/blog/v0190-connection-tags-column-masking-keyless-editing</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0190-connection-tags-column-masking-keyless-editing</guid>
      <pubDate>Mon, 10 Aug 2026 11:00:00 GMT</pubDate>
      <description>v0.19.0 adds colored tags and environment classification with a production write guard, masks sensitive columns in the results grid, makes tables without a primary key editable, closes the plugin driver feature gap with BLOB and materialized-view forwarding plus plugin-owned connection fields, validates plugin archives before they can touch an existing install, and stops bundling libwayland in the Linux AppImage.</description>
      <content:encoded><![CDATA[<h1>v0.19.0: Connections That Know They&#39;re Production, Columns That Keep Secrets, and Tables Without a Primary Key You Can Finally Edit</h1>
<p><strong>v0.19.0</strong> follows <a href="https://tabularis.dev/blog/v0180-user-privileges-connection-diagnostics-grid-selection">v0.18.0</a> and is a release about guardrails. The headline teaches connections which environment they belong to — with a permanent red banner and a confirmation before any write while you&#39;re on production — and adds free-form colored tags for organizing everything else. The results grid learns to render password, token and email columns as <code>••••••</code> until you deliberately reveal them. The oldest editing limitation in the data grid falls: tables without a primary key accept cell edits, NULLs and row deletions instead of silently ignoring you. Underneath, the plugin pipeline closes most of its feature gap with the built-in drivers, plugin installs stop being able to clobber a working installation with a corrupt archive, and the Linux AppImage stops shipping the stale libwayland that produced black windows on up-to-date distros.</p>
<hr>
<h2>Tags, Environments, and a Warning Before You Write to Production</h2>
<p>Every connection list eventually turns into a minefield: the local scratch database and the customer-facing one sit two rows apart, distinguished by nothing but a name. PR <a href="https://github.com/TabularisDB/tabularis/pull/473">#473</a>, from <a href="https://github.com/pokertour">@pokertour</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/472">#472</a> with two complementary features, following the DBeaver model.</p>
<p><strong>Free-form colored tags.</strong> Tags live in <code>connections.json</code> alongside groups; connections carry a list of tag ids. The connection modal&#39;s appearance tab gets a tag picker with inline creation on the shared accent palette and a manage mode to rename, recolor or delete. Tags render as colored chips on connection cards and list rows, and tag names participate in the connection search filter. They also ride along in export, import and backups — selective exports only include the tags actually used, and imports merge by id first, then by name, remapping <code>tag_ids</code> onto the existing tag. Re-creating &quot;prod&quot; on another machine never duplicates it.</p>
<p><strong>Environment classification.</strong> Each connection can optionally declare itself <code>development</code>, <code>staging</code> or <code>production</code>, picked in the modal title bar and preserved across duplicate and import. Production identity is loud on purpose: a <strong>PROD</strong> badge on cards and rows, a red ring on open sidebar entries, and a permanent red banner while the active connection is production.</p>
<p><img src="https://tabularis.dev/img/tabularis-connection-tags-environments.png" alt="The Connections page with colored tag chips and environment badges on the cards: a red "work" tag next to STAGING and DEV badges, two connections marked PROD in red, and connections with no environment set"></p>
<p><strong>The write guard.</strong> Any statement that isn&#39;t provably read-only prompts for confirmation on a production connection, with a SQL preview and a per-connection &quot;don&#39;t ask again&quot; that lasts for the session. Detection is deliberately conservative — only <code>SELECT</code>, <code>SHOW</code>, <code>DESCRIBE</code>, <code>PRAGMA</code> and <code>EXPLAIN</code> of a <code>SELECT</code> count as read-only. Data-modifying CTEs, <code>EXPLAIN ANALYZE &lt;write&gt;</code> (which executes the write on PostgreSQL) and unknown statement types like <code>CALL</code> or <code>SET</code> all prompt. The guard covers editor runs, staged grid-edit commits, immediate cell edits, row insertion and notebook cells, and it stacks with the <a href="https://tabularis.dev/blog/v0140-stored-routines-connection-windows-destructive-query-guard">destructive-query guard from v0.14.0</a> rather than replacing it.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-production-guard.mp4" poster="/videos/posts/tabularis-production-guard.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Sensitive Columns Come Up Masked</h2>
<p>Screen-sharing a results grid used to mean hoping nobody could read fast. PR <a href="https://github.com/TabularisDB/tabularis/pull/587">#587</a>, from <a href="https://github.com/iamthenuggetman">@iamthenuggetman</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/485">#485</a>: columns whose name matches a sensitive pattern — password, email, token, ssn and friends — now render as a <code>••••••</code> placeholder in the results grid.</p>
<ul>
<li><strong>Per-cell reveal.</strong> A masked cell shows an eye button that reveals just that cell; revealed cells get an eye-off to re-mask. Column headers carry the same toggle for the whole column. Reveal state is grid-local and resets when the result data changes.</li>
<li><strong>The mask actually holds.</strong> Masked cells can&#39;t be edited — double-click, Enter and F2 are guarded until you reveal — and the hover tooltip is suppressed so it can&#39;t leak the value.</li>
<li><strong>Display-only, by design.</strong> Copy and export keep the real values, as the issue requested; write-path anonymization for exports is tracked separately in <a href="https://github.com/TabularisDB/tabularis/issues/483">#483</a>.</li>
</ul>
<p><video src="https://tabularis.dev/videos/posts/tabularis-column-masking.mp4" poster="/videos/posts/tabularis-column-masking.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p>Configuration lives in a new <strong>Settings → Privacy</strong> tab: an on/off toggle (default on), the column-name patterns as an editable list of case-insensitive substring matches, and per-connection overrides as <code>table.column</code> entries — <strong>Always mask</strong> and <strong>Never mask</strong> lists per connection, where never-mask wins over always-mask, which wins over the name patterns. A review round moved the per-connection overrides into the connection modal as well, so a saved connection can manage its own exceptions from a Privacy tab in edit mode. Strings are translated across all eleven locales.</p>
<p><img src="https://tabularis.dev/img/tabularis-privacy-settings.png" alt="The Settings Privacy tab: the Mask sensitive columns toggle, the sensitive column name patterns list, and per-connection Always mask / Never mask overrides with a connection picker"></p>
<hr>
<h2>Tables Without a Primary Key Are Now Editable</h2>
<p>A table with no primary key refused every edit and never said why: double-click did nothing, the row-editor sidebar accepted input but never showed a submit button, and Set NULL, Set EMPTY and Delete Row were silent no-ops. This wasn&#39;t a regression — row addressing has always required primary-key columns, and every path that needed them bailed out silently. PR <a href="https://github.com/TabularisDB/tabularis/pull/600">#600</a> (closes <a href="https://github.com/TabularisDB/tabularis/issues/598">#598</a>) makes keyless tables editable, carefully.</p>
<p>Rows of a keyless table are now identified by the values of <strong>all their comparable columns</strong>. Binary, geometric, json and hstore columns are excluded — their grid representations wouldn&#39;t survive an equality comparison — and so are approximate numerics (<code>FLOAT</code>, <code>DOUBLE</code>, <code>REAL</code>), whose stored values may not match the grid&#39;s decimal rendering. Excluded columns stay editable; they just don&#39;t take part in addressing. The fallback only activates when the result set exposes every physical column of the table: a partial <code>SELECT a, b FROM t</code> stays non-editable, because it couldn&#39;t distinguish rows that differ only in the omitted columns — and the grid now says so in an explanatory alert instead of ignoring the double-click.</p>
<p>The mechanics are fussy and worth getting right. Without a key, each UPDATE invalidates the value its own WHERE clause will need next time, so updates to the same row run sequentially with already-applied values threaded into each step. Columns set to DEFAULT — whose stored result the client can&#39;t know — are ordered last and dropped from later WHERE maps, so applying DEFAULT to two columns of one row works instead of failing halfway. Deletions repeat until every duplicate the grid marked is gone: MySQL and MariaDB delete one copy per statement with <code>LIMIT 1</code>, PostgreSQL and SQLite sweep identical rows in one statement, and every driver converges on the state the grid displayed. And when the row no longer matches — someone changed the data underneath you — a zero affected-rows result raises a clear error instead of pretending it worked.</p>
<p>On the driver side, the pk map can now legitimately carry a whole row: NULL entries render as <code>IS NULL</code> instead of being rejected, booleans bind natively across MySQL, PostgreSQL and SQLite, and MySQL updates and deletes by pk map carry <code>LIMIT 1</code> so duplicate rows are never swept by a single statement. The changes were verified live against MySQL 8.4, PostgreSQL 16, SQLite, and a MariaDB 11 container loaded with the reporter&#39;s actual dump.</p>
<hr>
<h2>The Plugin Pipeline Closes the Gap</h2>
<p>Five threads landed this cycle that together move plugin drivers from &quot;supported&quot; toward &quot;indistinguishable from built-in&quot;.</p>
<p><strong>BLOB, materialized views and type mappings reach plugin drivers</strong> (<a href="https://github.com/aesslinger">@aesslinger</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/576">#576</a>). The RPC adapter now forwards <code>save_blob_to_file</code> and <code>fetch_blob_as_data_url</code> — the plugin writes the file directly, since it runs on the host machine — plus all four materialized-view methods. A plugin that doesn&#39;t implement a method returns JSON-RPC <code>-32601</code> and the host falls back to the existing &quot;not supported&quot; error, so nothing changes for current plugins. A new optional <code>type_mappings</code> manifest field lets a plugin resolve <code>map_inferred_type()</code> locally, which matters because that method is synchronous and can&#39;t issue an RPC call. These are the prerequisites for migrating the PostgreSQL driver itself to a plugin (<a href="https://github.com/TabularisDB/tabularis/issues/16">#16</a>).</p>
<p><strong>Plugins get their own connection fields</strong> (<a href="https://github.com/fuleinist">@fuleinist</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/596">#596</a>). <code>ConnectionParams</code> gains an opaque <code>extra</code> string map — persisted verbatim, forwarded to the driver, absent from JSON when empty — and a new <code>connection-modal.extra_fields</code> slot renders plugin UI below the host/port section, with <code>setExtraField(key, value)</code> to edit the map. The concrete beneficiary is the DynamoDB plugin, which needs an AWS region without asking core for a schema change. The host API version moves to 0.2.0.</p>
<p><img src="https://tabularis.dev/img/tabularis-dynamodb-plugin-settings.png" alt="The DynamoDB plugin's page in Settings → Plugins, with a Default AWS region setting that acts as the fallback when a connection doesn't specify its own region through the new per-connection extra fields"></p>
<p><strong>Plugin READMEs open in the app</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/615">#615</a>). Deciding whether to install a plugin used to mean a detour to GitHub. A WordPress-style details modal now shows the plugin&#39;s README — from the connection install gate and from every Plugin Center card the registry knows. The registry serves the README locale-aware, relative image and link paths are resolved against the plugin&#39;s repository, the HTML is sanitized with DOMPurify, and links open in the OS browser. The install gate also declutters: while the driver isn&#39;t installed yet, the connection-name input and environment selector stay out of the way.</p>
<p><img src="https://tabularis.dev/img/tabularis-plugin-readme-modal.png" alt="The plugin README modal open over the install gate, showing the ClickHouse plugin's README with release, downloads and CI badges, description and table of contents"></p>
<p><strong>A bad archive can no longer eat a good install</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/609">#609</a>). The manifest id and version are now verified against what the registry advertised <em>while the bundle is still in the temp directory</em> — previously the check ran after the archive had already replaced the plugin folder, so a corrupt download could wipe out a working installation. Uninstall resolves plugins by manifest id even when the folder name differs, and the install-error modal gains &quot;Open plugins folder&quot; and &quot;Reload plugins&quot; buttons, so a broken half-install is recoverable from the UI instead of requiring a restart and a file manager safari.</p>
<p><strong><code>.tabularium</code> is the canonical manifest, and the scaffold finally validates</strong> (<a href="https://github.com/NewtTheWolf">@NewtTheWolf</a>, PRs <a href="https://github.com/TabularisDB/tabularis/pull/594">#594</a> and <a href="https://github.com/TabularisDB/tabularis/pull/595">#595</a>). The manifest <code>create-plugin</code> scaffolded failed live registry validation on four counts — missing <code>engine</code> and <code>paradigms</code>, a forbidden <code>id</code> field, and a display name where the slug belongs. It now renders a manifest the registry accepts, and the in-repo plugin docs stop describing the old <code>manifest.json</code> world: slug <code>name</code>, optional legacy <code>id</code>, publishing pointed at <code>registry.tabularis.dev/submit</code>. Relatedly, the twelve plugins already migrated to the hosted Tabularium registry were removed from the legacy <code>registry.json</code>, which keeps only the five not yet migrated — and the MongoDB Atlas entry now points at the official TabularisDB repository and its published v0.1.0 release (<a href="https://github.com/Robbyfuu">@Robbyfuu</a>, <a href="https://github.com/TabularisDB/tabularis/pull/496">#496</a>).</p>
<hr>
<h2>The AppImage Stops Bundling a Time Bomb</h2>
<p>If Tabularis gave you a black window on Arch or Solus since v0.13.3, this is the fix. The AppImage bundled the build container&#39;s libwayland (Ubuntu 22.04, ~1.20); on distros with a recent Mesa, the host libEGL bound to that stale <code>libwayland-client</code>, protocol marshalling mismatched, and WebKitWebProcess aborted with <code>EGL_BAD_PARAMETER</code>. This is a known-enough failure class that the upstream AppImage excludelist added <code>libwayland-client.so.0</code> for exactly this reason — but the linuxdeploy binary pinned by tauri-cli predates that entry, and upstream has declared the linuxdeploy bundler unmaintained.</p>
<p>PR <a href="https://github.com/TabularisDB/tabularis/pull/599">#599</a> (fixes <a href="https://github.com/TabularisDB/tabularis/issues/423">#423</a>) attacks the root cause and the process failure behind it. The stable build path pre-seeds a patched <code>linuxdeploy-plugin-gtk.sh</code> that strips <code>libwayland-*.so*</code> from the AppDir before the squashfs is packed — the produced AppImage contains zero libwayland files where v0.18.0 shipped four. And <code>release.yml</code> is rewritten as a thin dispatcher over the same reusable <code>build.yml</code> matrix the nightlies use, because the regression existed at all only because the two workflows had drifted apart: nightlies kept a working configuration for months while releases shipped the broken one. Two channels, one build definition, no more drift.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>The MySQL Visual Query Builder qualifies its tables</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/582">#582</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/480">#480</a>) — tables dragged from the sidebar keep their schema, and generated <code>FROM</code> and <code>JOIN</code> clauses are schema-qualified, so a query built against a non-default database runs instead of erroring on the wrong one.</li>
<li><strong>Visual EXPLAIN understands parameterized queries</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/580">#580</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/565">#565</a>) — explaining SQL with <code>@params</code> now opens the same parameter modal the editor uses, reuses saved tab values when complete, and saves submitted values back to the tab. A follow-up fix gave the modal&#39;s submit button a label that exists in the locale files instead of a raw i18n key.</li>
<li><strong>SQLite paths starting with <code>~</code> work</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/579">#579</a>) — <code>~/</code> and <code>~\</code> are expanded before file validation, shared between pool creation and new-database creation, so a home-relative path no longer fails as &quot;file not found&quot;.</li>
<li><strong>Closing your last connection sticks</strong> (<a href="https://github.com/fuleinist">@fuleinist</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/578">#578</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/548">#548</a>) — the persistence effect skipped empty states, so disconnecting everything and quitting brought the connections back on next launch. Empty is now a state worth saving too.</li>
<li><strong>Engine cards in the connection catalogue got a cleanup</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/605">#605</a>) — cards are titled after the engine with proper spellings (PostgreSQL, MongoDB Atlas, SQL Server) instead of whichever plugin sorts first, the layout stops truncating names and clipping the meta line mid-word, the unavailable badge shrinks to one word with the full sentence in a tooltip, and a registry icon that fails to load falls back to the generic database icon.</li>
<li><strong>The DynamoDB plugin reached v0.1.5</strong> (<a href="https://github.com/fuleinist">@fuleinist</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/607">#607</a>) — two registry bumps this cycle, the latest adding the official DynamoDB brand icon to the manifest.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Seven external contributors land in v0.19.0.</p>
<p><strong><a href="https://github.com/pokertour">@pokertour</a></strong> built the headline feature for the second release running: colored tags, environment classification and the production write guard (<a href="https://github.com/TabularisDB/tabularis/pull/473">#473</a>). <strong><a href="https://github.com/iamthenuggetman">@iamthenuggetman</a></strong> gave the results grid column masking with per-cell reveal and a Privacy settings tab (<a href="https://github.com/TabularisDB/tabularis/pull/587">#587</a>). <strong><a href="https://github.com/fuleinist">@fuleinist</a></strong> added plugin-owned connection fields to <code>ConnectionParams</code> (<a href="https://github.com/TabularisDB/tabularis/pull/596">#596</a>), fixed disconnects that didn&#39;t persist (<a href="https://github.com/TabularisDB/tabularis/pull/578">#578</a>), and kept the DynamoDB plugin moving (<a href="https://github.com/TabularisDB/tabularis/pull/607">#607</a>).</p>
<p><strong><a href="https://github.com/aesslinger">@aesslinger</a></strong> closed the plugin driver feature gap with BLOB, materialized-view and type-mapping forwarding (<a href="https://github.com/TabularisDB/tabularis/pull/576">#576</a>). <strong><a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a></strong> landed three fixes: schema-qualified MySQL visual queries (<a href="https://github.com/TabularisDB/tabularis/pull/582">#582</a>), parameters in Visual EXPLAIN (<a href="https://github.com/TabularisDB/tabularis/pull/580">#580</a>), and SQLite home-path expansion (<a href="https://github.com/TabularisDB/tabularis/pull/579">#579</a>). <strong><a href="https://github.com/NewtTheWolf">@NewtTheWolf</a></strong> made the plugin scaffold pass registry validation and rewrote the manifest docs around <code>.tabularium</code> (<a href="https://github.com/TabularisDB/tabularis/pull/594">#594</a>, <a href="https://github.com/TabularisDB/tabularis/pull/595">#595</a>). <strong><a href="https://github.com/Robbyfuu">@Robbyfuu</a></strong> pointed the MongoDB Atlas registry entry at the official release (<a href="https://github.com/TabularisDB/tabularis/pull/496">#496</a>).</p>
<p>If you&#39;ve ever run a script against production that you meant for staging, screen-shared a grid full of email addresses, or double-clicked a cell in a keyless table and watched nothing happen — this is the upgrade.</p>
<hr>
<p><em>v0.19.0 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.19.0">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0190-connection-tags-column-masking-keyless-editing/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>mysql</category>
      <category>postgres</category>
      <category>sqlite</category>
      <category>ui</category>
      <category>ux</category>
      <category>data-grid</category>
      <category>plugin</category>
      <category>community</category>
    </item>
    <item>
      <title>Installing Tabularis from the AUR, the Careful Way</title>
      <link>https://tabularis.dev/blog/installing-tabularis-from-aur-without-a-helper</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/installing-tabularis-from-aur-without-a-helper</guid>
      <pubDate>Mon, 03 Aug 2026 19:00:00 GMT</pubDate>
      <description>After the malware wave that hit 200+ AUR packages, the Arch team temporarily disabled package adoption. Our tabularis-bin package is unaffected and still on the AUR, but a few of you asked how to install without going through an AUR helper. Here&apos;s how to build straight from our GitHub repo — and what to check in any PKGBUILD before you run it.</description>
      <content:encoded><![CDATA[<h1>Installing Tabularis from the AUR, the Careful Way</h1>
<p>Heads up for Arch users: after the malware wave that hit 200+ AUR packages, the Arch team temporarily disabled package adoption. Our <code>tabularis-bin</code> package is unaffected and still on the <a href="https://aur.archlinux.org/packages/tabularis-bin">AUR</a>, but a few of you asked how to install without going through an AUR helper. Fair enough, and honestly it&#39;s the safer habit anyway since you get to read the PKGBUILD before it builds anything.</p>
<h2>Building straight from our repo</h2>
<p>You can build straight from our GitHub repo, the PKGBUILD lives in the <code>aur/</code> folder:</p>
<pre><code class="language-bash">curl -O https://raw.githubusercontent.com/TabularisDB/tabularis/main/aur/PKGBUILD
sed -i &#39;s/^pkgver=.*/pkgver={{APP_VERSION}}/&#39; PKGBUILD
updpkgsums          # fills in the real sha256, needs pacman-contrib
less PKGBUILD       # read it, that&#39;s the whole point
makepkg -si
</code></pre>
<p>The version in the repo is a placeholder (<code>x.y.z</code>) because our release workflow substitutes it, so set it to whatever the current release is (<code>{{APP_VERSION}}</code> at the time you&#39;re reading this). All it does is pull our official <code>.deb</code> from the GitHub release and unpack it, nothing exotic.</p>
<p>If you&#39;d rather skip <code>makepkg</code> entirely, the <code>.deb</code> and the AppImage are right there on the <a href="https://github.com/TabularisDB/tabularis/releases">releases page</a>, both signed.</p>
<h2>Read the PKGBUILD — every time</h2>
<p>Whatever you install from the AUR these days, take thirty seconds to read the PKGBUILD:</p>
<ul>
<li>check that <code>source=</code> points at the real upstream,</li>
<li>be suspicious of any <code>curl</code> or <code>wget</code> inside <code>prepare()</code>/<code>build()</code>,</li>
<li>and of <code>post_install</code> hooks.</li>
</ul>
<p>If you did install one of the compromised packages, assume the box is dirty and rotate your SSH keys and API tokens.</p>
<p>Article with the details: <a href="https://www.bleepingcomputer.com/news/security/arch-linux-disables-aur-package-adoption-to-stop-malware-flood/">Arch Linux disables AUR package adoption to stop malware flood</a> (BleepingComputer).</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/installing-tabularis-from-aur-without-a-helper/opengraph-image.png" type="image/png" />
      <category>security</category>
      <category>linux</category>
      <category>arch</category>
      <category>aur</category>
      <category>open-source</category>
      <category>community</category>
    </item>
    <item>
      <title>v0.18.0: Users and Privileges, a Connection Test That Tells You Where It Failed, and a Data Grid You Can Actually Select</title>
      <link>https://tabularis.dev/blog/v0180-user-privileges-connection-diagnostics-grid-selection</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0180-user-privileges-connection-diagnostics-grid-selection</guid>
      <pubDate>Mon, 03 Aug 2026 18:00:00 GMT</pubDate>
      <description>v0.18.0 adds MySQL/MariaDB user and privilege management, rebuilds the connection modal around a real SSH test with classified errors and a step-by-step diagnostics log, lets a connection browse every database without picking any, gives the data grid keyboard navigation and honest copy scopes, exports the ER diagram as Mermaid or DBML, and teaches PostgreSQL to edit hstore and render expression indexes.</description>
      <content:encoded><![CDATA[<h1>v0.18.0: Users and Privileges, a Connection Test That Tells You Where It Failed, and a Data Grid You Can Actually Select</h1>
<p><strong>v0.18.0</strong> follows <a href="https://tabularis.dev/blog/v0170-visual-explain-diagnostics-row-editor-sidebar-sql-formatting">v0.17.0</a> and moves the attention from <em>reading</em> a database to <em>administering</em> and <em>reaching</em> one. The headline is a full Users &amp; Privileges view for MySQL and MariaDB — list accounts, create them, edit grants scope by scope — contributed from outside the core team. Around it, the connection modal stops failing with one truncated red line: it tests the SSH tunnel on its own, classifies what went wrong, streams the steps as they run, and hands you a copyable diagnostics report. A connection can now be saved with no database selected at all and browse whatever the server has. The data grid gains arrow-key navigation, discoverable select-all, and copy actions that are explicit about whether they cover the page or the whole result. The ER diagram exports to Mermaid and DBML and stops stacking wide tables on top of each other. And PostgreSQL learns to write <code>hstore</code> back.</p>
<hr>
<h2>Users &amp; Privileges for MySQL and MariaDB</h2>
<p>Tabularis could read your schema in detail and tell you nothing about who was allowed to read it. PR <a href="https://github.com/TabularisDB/tabularis/pull/478">#478</a>, from <a href="https://github.com/pokertour">@pokertour</a>, adds a <strong>Users &amp; Privileges</strong> tab, opened from the Explorer sidebar on any connection whose driver declares the <code>user_management</code> capability — today MySQL and MariaDB.</p>
<ul>
<li><strong>The account list</strong> shows every server account with its locked flag, and degrades gracefully: when <code>mysql.user</code> isn&#39;t readable, it falls back to <code>CURRENT_USER</code> rather than showing an empty pane.</li>
<li><strong>Create, change password, drop</strong> — creation can grant an initial set of privileges on a chosen scope in the same step, and dropping asks for confirmation first.</li>
<li><strong>The privilege editor</strong> renders one card per scope — global, database, table — with checkboxes reflecting the parsed output of <code>SHOW GRANTS</code>. Checking grants, unchecking revokes, and the editor revokes before granting so narrowing <code>ALL PRIVILEGES</code> down to a subset actually works.</li>
<li><strong>Nothing is hidden.</strong> Grants the editor can&#39;t model — roles, column-level privileges, proxy grants — are kept and shown as the raw <code>SHOW GRANTS</code> output, so the UI never implies a privilege set it isn&#39;t representing.</li>
</ul>
<p><video src="https://tabularis.dev/videos/posts/tabularis-user-management-grant.mp4" poster="/videos/posts/tabularis-user-management-grant.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p>The SQL builders validate every privilege against a per-scope allow-list and escape literals according to the server&#39;s <code>sql_mode</code>; builders and the grant parser are unit-tested. Just as importantly, the design is extensible rather than MySQL-shaped: the privilege catalog comes from the driver, and all seven trait methods are forwarded over JSON-RPC, so an external plugin can opt in via <code>capabilities.userManagement</code>. PostgreSQL support is the planned follow-up.</p>
<hr>
<h2>The Connection Test Tells You Where It Failed</h2>
<p>Connecting through a tunnel used to fail in the least useful way possible: one truncated red line in the modal footer, no indication whether the SSH hop, the port-forward or the database handshake was the thing that broke, and a 3-second auto-reset that raced you while you were still reading it. PR <a href="https://github.com/TabularisDB/tabularis/pull/570">#570</a> rebuilds that whole path.</p>
<ul>
<li><strong>Test SSH, on its own.</strong> A dedicated button in the SSH tab verifies host, credentials and tunnel without touching the database. The success state invalidates the moment you edit any SSH field, and a <strong>Stop</strong> button abandons a test that hangs. For a saved connection whose SSH secrets live in the keychain under the <em>database</em> connection id, the test resolves them correctly — unless you&#39;ve edited the password field, in which case it tests exactly what you typed.</li>
<li><strong>Errors get a category.</strong> A new classifier maps raw backend strings onto <code>ssh-auth</code>, <code>ssh-unreachable</code>, <code>ssh</code>, <code>db-auth</code>, <code>network</code> and <code>db-not-found</code>, each with a translated summary and an actionable recovery hint. Credentials embedded in raw error text are redacted. With a tunnel active, &quot;connection refused&quot; is attributed to the tunnel rather than blamed on the database host — which is the single most common misdiagnosis in this whole flow.</li>
<li><strong>The steps stream live.</strong> <code>test_connection</code> now emits progress events — <code>sshTunnel</code> → <code>k8sForward</code> → <code>dbConnect</code>, with start/ok/error per step and a per-run id so a superseded run&#39;s late events are discarded. The footer shows the current step, so a hanging test tells you <em>where</em> it hangs instead of just spinning.</li>
<li><strong>A diagnostics modal</strong> opens on failure or on Stop: classified summary, recovery hint, timestamped step log, sanitized raw error, and a copy-to-clipboard report worth pasting into an issue. A &quot;Show log&quot; link reopens it.</li>
</ul>
<p><video src="https://tabularis.dev/videos/posts/tabularis-connection-diagnostics.mp4" poster="/videos/posts/tabularis-connection-diagnostics.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p>All new strings are translated across the eleven supported locales.</p>
<hr>
<h2>All-Databases Mode: Leave It Empty to Browse Everything</h2>
<p>Until now a multi-database connection had to name its databases up front, and every new database on the server meant editing the connection. PR <a href="https://github.com/TabularisDB/tabularis/pull/572">#572</a> makes an empty selection meaningful: the Databases tab gains a mode switch between <strong>All databases</strong> — the default for new connections — and <strong>Choose databases</strong>, and the &quot;select at least one database&quot; save block only applies to the explicit mode.</p>
<p><img src="https://tabularis.dev/img/tabularis-all-databases-mode.png" alt="The Databases tab with the All databases / Choose databases mode switch set to All databases, and the hint explaining that every database on the server is loaded at connect time"></p>
<p>An all-databases connection persists an empty <code>database</code> param and resolves the real list at connect time through <code>get_available_databases</code>. New databases appear on their own, dropped ones disappear, and the sidebar refresh button re-syncs without persisting anything, toasting what was added and removed. Narrowing to a subset from the sidebar&#39;s manage popover persists that choice and exits the mode. Pasting a connection URI with no database in it switches to all-databases mode automatically.</p>
<p>Two smaller consequences worth naming. Editing an all-databases connection no longer auto-fetches the database list when the dialog opens — which could silently spawn an SSH or Kubernetes tunnel just because you clicked <em>Edit</em>. And the seven scattered <code>selectedDatabases.length &gt; 1</code> checks that decided whether to show the multi-database layout are replaced by one shared helper keyed on &quot;is there a runtime selection at all&quot;, so a connection with a single database still issues database-qualified queries — it has no default schema — and a multi-database connection narrowed to one database keeps its tree and manage button instead of losing them.</p>
<hr>
<h2>The Data Grid Gets a Real Selection Model</h2>
<p>Three pieces landed here, and together they change how the grid feels under the hands.</p>
<p><strong>Select All becomes discoverable, and copying becomes explicit</strong> (<a href="https://github.com/iamthenuggetman">@iamthenuggetman</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/549">#549</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/546">#546</a>). Select-all already existed behind a click on the <code>#</code> header cell — undiscoverable, and it silently wrote to the clipboard. Now <strong>Cmd/Ctrl+A</strong> selects all loaded rows (guarded to the last-interacted grid, since a notebook mounts one grid per cell, and never inside a text input or an open cell editor), the row context menu carries a <strong>Select All / Deselect All</strong> entry, and selection is cleanly separated from copying. <strong>Copy Selected (N)</strong> and <strong>Copy All (M)</strong> sit next to each other as a pair, and <code>Copy All</code> re-runs the query unpaginated with the tab&#39;s total-limit clause stripped, preserving the on-screen sort order — so &quot;all&quot; means all. Every copy path toasts its row count, and a page-only copy of a larger result says &quot;Copied N of M&quot;, so a partial copy is never silent.</p>
<p><strong>Selection extends past whole rows.</strong> The same PR adds <a href="https://tabularis.dev/compare/dbeaver-alternative">DBeaver-style</a> <strong>multi-column selection</strong> — Cmd/Ctrl+click a header to toggle, Shift+click to range-select, plain click still sorts — and <strong>cell range selection</strong> via Shift+click, which highlights a rectangle and offers a <strong>Copy Range (R×C)</strong> entry. Row, column and cell-range selections are mutually exclusive, so what Cmd/Ctrl+C copies is never ambiguous.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-grid-selection.mp4" poster="/videos/posts/tabularis-grid-selection.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p><strong>Arrow keys move the focused cell</strong> (<a href="https://github.com/ymadd">@ymadd</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/552">#552</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/551">#551</a>). The grid has tracked a focused cell for a while, but only a click could ever move it. Now the arrow keys move one cell clamped at the edges, <code>Home</code>/<code>End</code> jump to the first and last column of the row, <code>PageUp</code>/<code>PageDown</code> move a viewport of rows, and <code>Enter</code>/<code>F2</code> open the focused cell for editing through the same path as a double-click. The first keypress in a grid with no focused cell enters at the top-left. The handler is bound to the scroll container rather than to <code>document</code> — otherwise every mounted grid in a notebook would move at once — and keys are left alone for anything that handles them itself: text inputs, the FK and BLOB buttons inside cells, and the sortable column headers.</p>
<hr>
<h2>The Run Button Says What It Will Run</h2>
<p>With no selection, Run and Cmd/Ctrl+Enter execute only the statement under the cursor. The button said &quot;Run&quot; regardless, so pasting a multi-statement script and pressing Run executed one statement and skipped the rest — no error, no warning, nothing in the UI admitting it. <a href="https://github.com/ymadd">@ymadd</a> hit this against a production database: a 21-statement script where only the statement the caret landed in ran, surfacing later as a foreign key violation because the parent rows the rest of the script depended on were never inserted.</p>
<p>PR <a href="https://github.com/TabularisDB/tabularis/pull/532">#532</a> labels the button with its actual target: <strong>Run Selection</strong> when text is selected, <strong>Run Statement</strong> when the buffer holds several statements and nothing is selected, plain <strong>Run</strong> otherwise. Behaviour is untouched — this only makes it visible before you commit to it. And when the button would run one statement out of several, the tooltip surfaces <code>Run All (Cmd/Ctrl+Shift+Enter)</code>, a shortcut that was already bound and mentioned nowhere. The decision lives in one pure function, <code>resolveRunTarget</code>, so the label can&#39;t drift from the behaviour it describes.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-run-target-label.mp4" poster="/videos/posts/tabularis-run-target-label.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Editor Tabs Reorder by Drag</h2>
<p>PR <a href="https://github.com/TabularisDB/tabularis/pull/517">#517</a>, from <a href="https://github.com/maximumbreak">@maximumbreak</a>, makes the editor tab bar draggable. Console, table, query-builder and notebook tabs all share one flat tabs array, so any tab type can be dragged — no console-only restriction — and reordering is scoped to the active connection: dragging reshuffles only that connection&#39;s tabs and leaves everyone else&#39;s slots alone. It reuses the native HTML5 drag-and-drop pattern already behind notebook cell reordering, down to the edge auto-scroll and the insertion-line indicator, and needs no new persistence code, because the existing tab-save effect already writes to <code>preferences.json</code> whenever the tabs array changes.</p>
<p>A follow-up fix renders editor panes in a stable order, so a reorder can&#39;t shuffle which pane is which.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-reorder-tabs.mp4" poster="/videos/posts/tabularis-reorder-tabs.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>The ER Diagram Exports, and Stops Overlapping</h2>
<p><strong>Export to Mermaid or DBML</strong> (<a href="https://github.com/gcapellib">@gcapellib</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/521">#521</a>) — the ER diagram had no export at all. An <strong>Export</strong> button now generates two text formats from the schema data already in memory, no new dependency. Mermaid (<code>erDiagram</code>) renders natively on GitHub, GitLab and most docs tools, so the output pastes straight into a README, at the cost of entity-level relationships where the FK column is only a label. DBML keeps relationships at column level (<code>Ref: orders.client_id &gt; clients.id</code>) and round-trips through dbdiagram.io and <code>dbml-to-sql</code>, with composite primary keys expressed via an <code>Indexes</code> block. The same PR fixes a permissions bug that made <em>every</em> permission-gated call fail in the ER diagram window: the capability allow-list named the literal window <code>er-diagram</code>, while the window is actually created as <code>er-diagram:{connectionId}:{database}:{schema}</code>.</p>
<p><img src="https://tabularis.dev/img/tabularis-er-export-menu.png" alt="The ER diagram with the Export menu open on Mermaid and DBML, over a laid-out schema where the orders table's wide enum column no longer overlaps its neighbours"></p>
<p><strong>Nodes stop landing on top of each other</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/558">#558</a>) — the dagre layout was fed a fixed 240px width for every node, but the table node only has a <em>minimum</em> width and grows with its content, so a table with a wide <code>enum(...)</code> column overlapped its neighbours. Node width and height are now estimated from the actual content, the real width is used when centering, and node separation gets a little more room. The same PR adds a lock toggle for pinning a node where you dragged it.</p>
<hr>
<h2>PostgreSQL: Editable hstore, Expression Indexes, Catalog-Based Keys</h2>
<p>Four PostgreSQL fixes land this cycle, three of them from outside the core team.</p>
<p><strong><code>hstore</code> columns are editable</strong> (<a href="https://github.com/arturbent0">@arturbent0</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/427">#427</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/395">#395</a>) — reading worked, writing failed with <em>&quot;Cannot bind a JSON object to a non-JSON column&quot;</em>, because <code>hstore</code> has no fixed OID and <code>information_schema</code> reports it as a generic <code>USER-DEFINED</code>. The driver now resolves the real per-column <code>hstore</code> OID through <code>pg_type</code> before binding and binds JSON objects as a string map, which the client encodes natively. <code>udt_name</code> is exposed on the column metadata so the frontend can identify <code>hstore</code> precisely — <code>data_type</code> can&#39;t, since every extension type shares it — and <code>hstore</code> columns route through the JSON editor in the row-editor sidebar. As a bonus, the inline cell editor stops showing <code>[object Object]</code> for any object-valued cell.</p>
<p><img src="https://tabularis.dev/img/tabularis-hstore-editor.png" alt="An hstore column open in the row-editor sidebar's JSON editor, labelled hstore, with its key/value pairs formatted and validated"></p>
<p><strong>Functional and expression indexes render</strong> (<a href="https://github.com/Davydhh">@Davydhh</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/499">#499</a>) — an index like <code>CREATE INDEX ON users (lower(email))</code> was invisible. Every driver dropped expression columns for its own reason: Postgres joined <code>pg_attribute</code> on an <code>attnum</code> that expression columns don&#39;t have, MySQL and SQLite only read plain column names. Mixed indexes lost their expression columns and all-expression indexes vanished from the sidebar entirely. The expression text is now recovered per driver — <code>pg_get_indexdef</code>, <code>information_schema.STATISTICS.EXPRESSION</code>, and SQLite&#39;s <code>CREATE INDEX</code> DDL — with an <code>is_expression</code> flag so DDL export emits the expression raw instead of identifier-quoting it. MySQL&#39;s <code>EXPRESSION</code> column only exists from 8.0.13, so the driver probes for it and falls back on MariaDB and older MySQL.</p>
<p><strong>Key metadata survives a read-only user</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/543">#543</a>) — primary-key detection read <code>information_schema.table_constraints</code>, which returns nothing for users who can nonetheless see <code>pg_constraint</code>. Detection moves to a <code>pg_catalog</code> query, reused across single-table, batch-table and view column loading.</p>
<p><strong>The Visual Query Builder quotes reserved identifiers</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/553">#553</a>) — generated SQL emitted raw table and column references, so a perfectly legal table named <code>user</code> or <code>order</code> produced a query PostgreSQL rejected. The generator now takes the active driver and quotes identifiers when the dialect requires it.</p>
<hr>
<h2>Create a SQLite Database from Inside the App</h2>
<p>PR <a href="https://github.com/TabularisDB/tabularis/pull/523">#523</a>, from <a href="https://github.com/Jishnu-Prasad888">@Jishnu-Prasad888</a>, addresses <a href="https://github.com/TabularisDB/tabularis/issues/131">#131</a>: you can now create a SQLite database without leaving Tabularis. <strong>New SQLite Database…</strong> appears in the Connections menu and empty state, and the SQLite file picker in the connection modal gains a <strong>+ New</strong> button. The quick-create flow auto-names the file and opens it; the modal flow just fills in the path and leaves the usual Test/Save alone.</p>
<p><img src="https://tabularis.dev/img/tabularis-new-sqlite-database.png" alt="The SQLite connection form with the + New button next to the file path field, and the New SQLite Database save dialog open on top of it"></p>
<p>The safety rules are the interesting part: existing files are never overwritten, <code>create_if_missing</code> stays disabled so a typo in a path can&#39;t silently conjure an empty database, failed quick-creates clean up after themselves, and duplicate connections are prevented.</p>
<hr>
<h2>Notebook SQL Generation, Hardened</h2>
<p>PR <a href="https://github.com/TabularisDB/tabularis/pull/559">#559</a> started as a README correction and turned into a security pass on how notebooks generate SQL.</p>
<ul>
<li><strong>Parameter values are inserted literally.</strong> They were passed as the replacement string to <code>String.replace</code>, so a value containing <code>$&amp;</code>, <code>$&#39;</code> or <code>$$</code> was expanded as a replacement pattern — quietly producing SQL that differed from what you were shown.</li>
<li><strong>Cell-reference CTEs escape their identifiers.</strong> Column names were interpolated into quoted identifiers without doubling embedded quotes, so a crafted column alias in a referenced cell could break out of the identifier and inject arbitrary SQL into the generated CTE.</li>
<li><strong>MySQL and MariaDB string values escape backslashes</strong>, not just single quotes.</li>
</ul>
<p>The README also described a notebook syntax that never existed. The real syntax is <code>{{cell_N}}</code>, expanded to a CTE at run time, and <code>@paramName</code> — documented correctly now, closing the documentation half of <a href="https://github.com/TabularisDB/tabularis/issues/550">#550</a>.</p>
<hr>
<h2>DynamoDB in the Plugin Registry</h2>
<p><a href="https://github.com/fuleinist">@fuleinist</a> registered <a href="https://github.com/TabularisDB/tabularis-dynamodb-plugin">tabularis-dynamodb-plugin</a> in the official registry (PR <a href="https://github.com/TabularisDB/tabularis/pull/574">#574</a>) and shipped it to v0.1.3 within the same cycle (PR <a href="https://github.com/TabularisDB/tabularis/pull/589">#589</a>). The two fixes in between are the kind you only find against a real account: <code>get_tables</code> issued its <code>DescribeTable</code> calls serially, so an account with hundreds of tables took over a minute and blew the GUI&#39;s connection timeout — describes now run with bounded concurrency and results are re-sorted to preserve <code>ListTables</code> ordering. And <code>execute_query</code> responses now carry a complete pagination object instead of only a <code>next_token</code>, which the app was rejecting outright.</p>
<p>Install it from the connection catalogue when creating a new connection; it requires Tabularis 0.15.0 or newer and ships builds for Linux and macOS on both x64 and arm64, plus Windows x64.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>MariaDB temporal tables appear in the tree</strong> (<a href="https://github.com/gustavomelo-dotgroup">@gustavomelo-dotgroup</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/568">#568</a>) — the MySQL driver filtered <code>information_schema.tables</code> on <code>table_type = &#39;BASE TABLE&#39;</code>, but MariaDB reports tables created <code>WITH SYSTEM VERSIONING</code> as <code>SYSTEM VERSIONED</code>. They were silently excluded from the explorer with no error anywhere, making it look like the tables didn&#39;t exist. Both types are now accepted.</li>
<li><strong>Raw connection URIs reach plugin drivers</strong> (<a href="https://github.com/Robbyfuu">@Robbyfuu</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/495">#495</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/494">#494</a>) — pasting a real MongoDB Atlas connection string failed on the import path, starting with the protocol being rejected outright because the protocol registry only derived protocols from two per-driver sources. Plugin drivers now receive the URI as given, <code>mongodb+srv://</code> included.</li>
<li><strong>Array cells open the JSON editor</strong> (<a href="https://github.com/Davydhh">@Davydhh</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/489">#489</a>) — double-clicking a <code>text[]</code> or <code>uuid[]</code> cell opened the inline textarea, which renders <code>String(array)</code> and crams the values into a comma-joined string in a 120px box. Array cells already <em>rendered</em> as JSON; now they edit that way too, in the same dedicated viewer window <code>json</code>/<code>jsonb</code> columns use.</li>
<li><strong>DML submits against the right schema</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/542">#542</a>) — inserts, updates and deletes now use the table tab&#39;s schema on schema-capable drivers, while multi-database drivers keep receiving the tab value as the database.</li>
<li><strong>The connection modal keeps a stable height</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/538">#538</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/462">#462</a>) — the dialog is bounded to the viewport and driver-specific form content scrolls inside it, so switching between MySQL, PostgreSQL and SQLite no longer resizes the whole modal under your cursor.</li>
<li><strong>Sidebar accordion actions stop crowding the scrollbar</strong> (<a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/539">#539</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/310">#310</a>) — section headers reserve a padded action lane, so long titles truncate instead of squeezing the refresh and add buttons.</li>
<li><strong>The MCP approval modal stops blanking the window</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/567">#567</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/566">#566</a>) — the preflight stored the driver&#39;s raw explain output verbatim, and the approval modal cast it straight to a parsed plan. The object is truthy, so the plan view rendered and then walked an undefined root: black window, approval impossible, MCP request timing out after 120s — and a crash loop on restart, since the pending approval was replayed. The payload now goes through the same client-side resolver everything else uses.</li>
<li><strong>MiniMax regional endpoints</strong> (<a href="https://github.com/octo-patch">@octo-patch</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/498">#498</a>) — the current OpenAI- and Anthropic-compatible endpoints are defined for both the global and <code>cn_zh</code> regions, and model listing tries both official regional endpoints so mainland China API keys work.</li>
<li><strong>JetBrains Mono ExtraBold, bundled</strong> (<a href="https://github.com/GabrielMalava">@GabrielMalava</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/547">#547</a>) — two more weights of a typeface already in the font picker, shipped locally rather than fetched. Selecting a freshly bundled font also forces Monaco to re-measure its glyph widths: it measures once and never re-measures when a webfont finishes loading, so the editor used to render the new font with stale fallback metrics.</li>
<li><strong>Third-party GitHub Actions pinned to commit SHAs</strong> (<a href="https://github.com/jeffersongoncalves">@jeffersongoncalves</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/540">#540</a>) — every third-party action reference is pinned to a full 40-character SHA with the resolved tag kept as a comment. A tag-pinned action can be silently re-pointed at malicious code by a compromised maintainer, which is exactly what happened to <code>tj-actions/changed-files</code> in March 2025. A <code>dependabot.yml</code> now keeps those pins moving, and the first four bumps (<a href="https://github.com/TabularisDB/tabularis/pull/554">#554</a>, <a href="https://github.com/TabularisDB/tabularis/pull/555">#555</a>, <a href="https://github.com/TabularisDB/tabularis/pull/556">#556</a>, <a href="https://github.com/TabularisDB/tabularis/pull/557">#557</a>) landed in this release.</li>
<li><strong>Nightly version numbers make sense again</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/585">#585</a>) — the counter after the dash was <code>github.run_number</code>, monotonic per workflow and never reset, so the first nightly after v0.17.0 was <code>0.17.1-18</code> and gaps appeared on runs where the gate decided no build was due. It&#39;s now derived from the highest suffix already published for the current version base.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Fifteen external contributors land in v0.18.0.</p>
<p><strong><a href="https://github.com/pokertour">@pokertour</a></strong> built the release&#39;s headline feature: user and privilege management for MySQL and MariaDB (<a href="https://github.com/TabularisDB/tabularis/pull/478">#478</a>), designed from the start so plugin drivers can opt in. <strong><a href="https://github.com/iamthenuggetman">@iamthenuggetman</a></strong> gave the data grid a real selection model — discoverable select-all, explicit copy scopes, multi-column and cell-range selection (<a href="https://github.com/TabularisDB/tabularis/pull/549">#549</a>). <strong><a href="https://github.com/ymadd">@ymadd</a></strong> contributed both keyboard navigation in the grid (<a href="https://github.com/TabularisDB/tabularis/pull/552">#552</a>) and the Run button that admits what it&#39;s about to run (<a href="https://github.com/TabularisDB/tabularis/pull/532">#532</a>), the latter written up from a production incident.</p>
<p><strong><a href="https://github.com/DhruvShah-Dev">@DhruvShah-Dev</a></strong> landed four fixes: PostgreSQL key metadata from <code>pg_catalog</code> (<a href="https://github.com/TabularisDB/tabularis/pull/543">#543</a>), Visual Query Builder identifier quoting (<a href="https://github.com/TabularisDB/tabularis/pull/553">#553</a>), DML schema selection (<a href="https://github.com/TabularisDB/tabularis/pull/542">#542</a>), and the connection modal and sidebar accordion layout fixes (<a href="https://github.com/TabularisDB/tabularis/pull/538">#538</a>, <a href="https://github.com/TabularisDB/tabularis/pull/539">#539</a>). <strong><a href="https://github.com/gcapellib">@gcapellib</a></strong> added ER diagram export to Mermaid and DBML and fixed the window permissions bug behind it (<a href="https://github.com/TabularisDB/tabularis/pull/521">#521</a>). <strong><a href="https://github.com/Davydhh">@Davydhh</a></strong> made functional and expression indexes visible across every driver (<a href="https://github.com/TabularisDB/tabularis/pull/499">#499</a>) and routed array cells to the JSON editor (<a href="https://github.com/TabularisDB/tabularis/pull/489">#489</a>).</p>
<p><strong><a href="https://github.com/arturbent0">@arturbent0</a></strong> made PostgreSQL <code>hstore</code> columns editable (<a href="https://github.com/TabularisDB/tabularis/pull/427">#427</a>). <strong><a href="https://github.com/maximumbreak">@maximumbreak</a></strong> made editor tabs draggable (<a href="https://github.com/TabularisDB/tabularis/pull/517">#517</a>). <strong><a href="https://github.com/Jishnu-Prasad888">@Jishnu-Prasad888</a></strong> added SQLite database creation (<a href="https://github.com/TabularisDB/tabularis/pull/523">#523</a>). <strong><a href="https://github.com/gustavomelo-dotgroup">@gustavomelo-dotgroup</a></strong> found the <code>table_type</code> filter hiding MariaDB temporal tables (<a href="https://github.com/TabularisDB/tabularis/pull/568">#568</a>). <strong><a href="https://github.com/Robbyfuu">@Robbyfuu</a></strong> got raw connection URIs through to plugin drivers (<a href="https://github.com/TabularisDB/tabularis/pull/495">#495</a>). <strong><a href="https://github.com/jeffersongoncalves">@jeffersongoncalves</a></strong> pinned every third-party action to a commit SHA (<a href="https://github.com/TabularisDB/tabularis/pull/540">#540</a>), <strong><a href="https://github.com/octo-patch">@octo-patch</a></strong> fixed MiniMax regional endpoints (<a href="https://github.com/TabularisDB/tabularis/pull/498">#498</a>), <strong><a href="https://github.com/GabrielMalava">@GabrielMalava</a></strong> bundled two more JetBrains Mono weights (<a href="https://github.com/TabularisDB/tabularis/pull/547">#547</a>), and <strong><a href="https://github.com/fuleinist">@fuleinist</a></strong> registered and hardened the DynamoDB plugin (<a href="https://github.com/TabularisDB/tabularis/pull/574">#574</a>, <a href="https://github.com/TabularisDB/tabularis/pull/589">#589</a>).</p>
<p>If you&#39;ve ever had a connection through a tunnel fail with one unreadable red line, wanted to grant a colleague <code>SELECT</code> without opening a terminal, or pressed Run on a 20-statement script and watched one statement execute — this is the upgrade.</p>
<hr>
<p><em>v0.18.0 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.18.0">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0180-user-privileges-connection-diagnostics-grid-selection/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>mysql</category>
      <category>postgres</category>
      <category>sqlite</category>
      <category>ui</category>
      <category>ux</category>
      <category>data-grid</category>
      <category>plugin</category>
      <category>community</category>
    </item>
    <item>
      <title>The Rust stack inside a ~15 MB installer</title>
      <link>https://tabularis.dev/blog/rust-stack-inside-a-15mb-database-client</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/rust-stack-inside-a-15mb-database-client</guid>
      <pubDate>Thu, 30 Jul 2026 08:52:00 GMT</pubDate>
      <description>Two database stacks, several TLS verification paths, two SSH clients, a hand-written float16 decoder, and the vendored OpenSSL that refuses to leave: a guided tour of the fifty-odd top-level Rust dependencies behind Tabularis&apos;s compact installers — the other half of last week&apos;s Tauri story.</description>
      <content:encoded><![CDATA[<h1>The Rust stack inside a ~15 MB installer</h1>
<p>Last week I wrote about <a href="https://tabularis.dev/blog/why-tabularis-runs-on-tauri">why Tabularis runs on Tauri</a>. That post was about the frame: a Rust backend, a web frontend, and the seams between them. It said almost nothing about what the Rust side is actually made of.</p>
<p>This post is the guided tour. The compressed <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.17.0">0.17.0 release</a> artifacts range from 12 MB (the Windows NSIS installer) to 17 on Linux and 18 on macOS; the Windows MSI sits at 15.8. Roughly fifteen is useful shorthand for the download, not a claim about the larger installed footprint.</p>
<p>The main <code>[dependencies]</code> table in <a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/Cargo.toml"><code>Cargo.toml</code></a> has 53 entries, before platform-specific and build dependencies. Most looked free the day they entered the tree. None stayed free, because dependencies are also boundaries — to protocols, platforms, formats and other people&#39;s decisions.</p>
<p><strong>Every abstraction boundary is a decision, and every decision eventually sends an invoice.</strong></p>
<p>Here are the invoices I found worth reading twice.</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-rust-stack-dependencies.svg" alt="Horizontal bar chart of the 53 entries in Tabularis's main Rust dependencies table, grouped by job. Everything else: 14. Formats and encoding: 11. Tauri and its plugins: 9. Secrets and crypto: 6, including vendored OpenSSL. TLS and certificates: 4 rustls crates. Database clients and types: 4. Async plumbing: 3. SSH: 2."></p>
<h2>Two database stacks, on purpose</h2>
<p>The most common assumption about Tabularis&#39;s backend is that everything goes through <a href="https://github.com/launchbadge/sqlx">SQLx</a>. It does not. MySQL and SQLite do. PostgreSQL runs on <a href="https://crates.io/crates/tokio-postgres">tokio-postgres</a> with a deadpool pool, and the split is deliberate.</p>
<p>SQLx earns its place on two engines: shared pool APIs, query builders and a common route into the grid&#39;s row model. But a database <em>client</em> has a problem a typical CRUD application does not. The application usually knows the types in its own schema. A client must decode whatever the user&#39;s schema throws at it, including extension types its database library does not know yet.</p>
<p>With tokio-postgres we can implement <code>FromSql</code> by hand, byte by byte, at the boundary of Postgres&#39;s binary wire format. That is how Tabularis got <a href="https://github.com/TabularisDB/tabularis/pull/450">pgvector support</a>: <code>vector</code>, <code>halfvec</code> and <code>sparsevec</code> decoded straight from pgvector&#39;s <code>*_send</code> format. For <code>halfvec</code> that meant half-precision floats, and since no direct dependency in the tree decoded IEEE 754 binary16, the driver now contains this:</p>
<pre><code class="language-rust">/// Decode an IEEE 754 half-precision (`binary16`) value into `f32`.
fn f16_bits_to_f32(bits: u16) -&gt; f32 {
    let sign = if (bits &gt;&gt; 15) &amp; 1 == 1 { -1.0f32 } else { 1.0f32 };
    let exp = (bits &gt;&gt; 10) &amp; 0x1f;
    let mant = bits &amp; 0x3ff;
    match exp {
        0 =&gt; sign * (mant as f32) * 2f32.powi(-24), // zero / subnormal
        0x1f if mant == 0 =&gt; sign * f32::INFINITY,
        0x1f =&gt; f32::NAN,
        _ =&gt; sign * (1.0 + (mant as f32) / 1024.0) * 2f32.powi(exp as i32 - 15),
    }
}
</code></pre>
<p>A hand-rolled float16 decoder in a database GUI. We did not plan for this line item, and we are strangely fond of it.</p>
<p>The cost of owning those codecs arrives as pull requests: binding temporal and UUID values with <a href="https://github.com/TabularisDB/tabularis/pull/408">explicit wire types</a>, casting enums <a href="https://github.com/TabularisDB/tabularis/pull/471">back to their column type</a> so dropdown editing works, keeping routine introspection alive <a href="https://github.com/TabularisDB/tabularis/pull/377">on PostgreSQL older than 11</a>. SQLx can support <a href="https://docs.rs/sqlx/latest/sqlx/trait.Decode.html">custom <code>Decode</code> implementations</a> too; this was a choice of API surface, not a capability SQLx lacks. In fact, SQLx&#39;s <code>postgres</code> feature is still enabled in the manifest even though the runtime path no longer uses its PostgreSQL pool. That build-time baggage is an invoice still waiting to be cancelled.</p>
<h2>rustls, and the OpenSSL that stayed</h2>
<p>The TLS section of <code>Cargo.toml</code> has a fifteen-line comment, which is usually the sign of a scar.</p>
<p>The short version: <code>tls-native-tls</code> is deliberately not enabled. On macOS its path through Apple&#39;s deprecated Secure Transport APIs failed in Tabularis on real-world CA bundles — the AWS RDS regional bundle among them — with errors like <em>&quot;One or more parameters passed to a function were not valid.&quot;</em> The same bundle validated with <code>openssl s_client</code> and with <code>mysql --ssl-mode=VERIFY_IDENTITY</code>. Debugging that path through an opaque error string is not a hobby I recommend.</p>
<p>So every TLS path controlled by Tabularis&#39;s Rust code now speaks <a href="https://github.com/rustls/rustls">rustls</a>, using platform roots where appropriate. That second choice matters as much as the first: it keeps the operating system&#39;s trust store live, so a corporate CA sitting in the macOS keychain still works. A bundled web PKI root set would not include that private CA, and &quot;silently&quot; is the worst word in that sentence.</p>
<p>Then comes the part rustls does not give you for free. Users expect familiar <code>sslmode</code> choices — <code>prefer</code>, <code>require</code>, <code>verify-ca</code>, <code>verify-full</code> — while rustls provides verification mechanisms rather than database-client policy. Reproducing those choices took two custom <code>ServerCertVerifier</code> implementations plus the standard platform and WebPKI verifier paths. The custom verify-CA-but-not-hostname verifier calls <code>verify_server_cert_signed_by_trust_anchor</code> directly, so the &quot;skip hostname check&quot; intent is explicit instead of buried in error recovery.</p>
<p>One of those semantics is a confession. Before v0.10.3, Tabularis&#39;s <code>require</code> mode validated the certificate chain. It now means &quot;encrypt without authenticating the server&quot;, the common interpretation of <code>require</code>, which made the application <em>less</em> strict. libpq has a backward-compatibility wrinkle: when a root CA file exists, its <code>require</code> behaves like <code>verify-ca</code>, so Tabularis does not claim byte-for-byte compatibility with every libpq configuration.</p>
<p>Real-world deployments added their own complications: PlanetScale&#39;s Vitess rejects the <code>sql_mode</code> SQLx sets on every connection, so pool creation <a href="https://github.com/TabularisDB/tabularis/pull/387">detects it and retries</a>; AWS RDS wanted <a href="https://github.com/TabularisDB/tabularis/pull/404">IAM authentication</a>; Postgres needed <code>ssl_mode</code> <a href="https://github.com/TabularisDB/tabularis/pull/378">honored in one more code path</a> than we remembered existed.</p>
<p>And yet, after all this rustls conviction, there is still an <code>openssl = { version = &quot;0.10&quot;, features = [&quot;vendored&quot;] }</code> in the tree. Today the only place Tabularis&#39;s own code calls it is one module, and what that module does is the next section.</p>
<h2>The crypto shelf</h2>
<p>Secrets never touch Tabularis&#39;s config files. Passwords go to the OS keychain through the <a href="https://crates.io/crates/keyring">keyring</a> crate — Keychain on macOS, Credential Manager on Windows, the Secret Service on Linux.</p>
<p>But a keychain is a place, not a format, and connections need to <em>move</em>: exports, and <a href="https://github.com/TabularisDB/tabularis/pull/470">automatic encrypted backups</a> to a local folder or a WebDAV server. The envelope is <a href="https://crates.io/crates/argon2">argon2</a> plus <a href="https://crates.io/crates/aes-gcm">aes-gcm</a>: Argon2id at 64 MiB and three iterations derives the key, AES-256-GCM seals the payload, and the KDF parameters travel inside the envelope so future versions can raise them.</p>
<p>A self-describing envelope has a failure mode that took us longer to see than to fix: the parameters are attacker-supplied. A malicious file could ask the <em>decrypting</em> machine for 100 GiB of Argon2 memory, and the DoS would arrive dressed as your own backup. So the decrypt path rejects requests above 1 GiB of memory, 32 iterations or parallelism of 8. Those caps are bounds, not proof that decryption cannot hurt a smaller machine; keeping them low, and expensive derivation off the UI thread, is still worthwhile. Plaintext backups are not supported. There is no checkbox to turn encryption off, because someone would eventually tick it by accident.</p>
<p>The same shelf holds the strangest tools in the codebase. Tabularis <a href="https://github.com/TabularisDB/tabularis/pull/393">imports connections</a> from TablePlus, Sequel Ace, DBeaver, Beekeeper Studio and DataGrip, which meant learning each client&#39;s storage format: <a href="https://crates.io/crates/plist">plist</a> for the macOS clients, <a href="https://crates.io/crates/roxmltree">roxmltree</a> for DataGrip&#39;s XML, and OpenSSL&#39;s AES-CBC for the two encrypted stores.</p>
<p><a href="https://tabularis.dev/compare/dbeaver-alternative">DBeaver</a>&#39;s credentials file uses a fixed key; <a href="https://tabularis.dev/compare/beekeeper-studio-alternative">Beekeeper</a> uses Node&#39;s <code>simple-encryptor</code> format with a per-install key unwrapped by a fixed bootstrap key. These are local files on the user&#39;s own machine and the threat models differ. But it does mean the vendored OpenSSL&#39;s last remaining duty in a rustls application is reading other database clients&#39; secrets. Some dependencies retire; this one became a locksmith.</p>
<h2>russh and ssh. Both.</h2>
<p>SSH tunnels have two implementations, and the function that picks between them is three lines long: if the user typed a password, use <a href="https://github.com/Eugeny/russh">russh</a> in-process, because system <code>ssh</code> under <code>BatchMode=yes</code> cannot do interactive password auth. Otherwise, spawn the system <code>ssh</code> binary.</p>
<p>Shelling out sounds like the lazy option. It is actually the compatible one: the system client brings the user&#39;s <code>~/.ssh/config</code>, agent, jump hosts and FIDO2 support with it, subject to the explicit options and GUI environment Tabularis supplies. <code>russh</code> does not try to reproduce every corner of decades of OpenSSH behavior, and Tabularis does not need it to.</p>
<p>The invoice came anyway, in two currencies. On Windows, <code>ssh.exe</code> popped a visible console window with every tunnel until <a href="https://github.com/TabularisDB/tabularis/pull/418">the flags said otherwise</a>. And a GUI app spawning <code>ssh</code> has nowhere to type a key passphrase — so Tabularis registers <em>itself</em> as the <code>SSH_ASKPASS</code> helper, re-executing its own binary in a thin client mode that forwards the prompt to the running app over a private local socket. Our app&#39;s process tree occasionally contains our app, asking itself for a passphrase. That story deserves its own post.</p>
<h2>The 2⁵³ toll booth</h2>
<p>One dependency does not appear in <code>Cargo.toml</code>, because it is a language: every query result crosses Tauri&#39;s IPC boundary as JSON and lands in <code>JSON.parse</code>. Beyond 2⁵³ − 1, JavaScript numbers no longer guarantee that distinct integers remain distinct. A database client that rounds your BIGINT is not a client, it is a rumor.</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-rust-stack-bigint-roundtrip.svg" alt="Diagram of PostgreSQL's maximum BIGINT crossing JSON.parse. It leaves Rust as 9223372036854775807. The nearest JavaScript Number is 2 to the 63rd power and is commonly displayed as 9223372036854776000. Tabularis instead sends the JSON string "9223372036854775807", preserving every digit."></p>
<p>So the serializer checks the range: in-range integers cross as JSON numbers, out-of-range ones cross as strings. Write-back is harder. Column metadata decides the binding where it is available; some paths otherwise use a narrow heuristic that recognizes only integer strings outside the safe range. That reduces accidental coercion, but cannot eliminate the ambiguity: a large numeric value in a VARCHAR column is still text, and a leading zero still matters. A tagged wire value, or type-directed binding on every path, is the durable end state. The current range policy fits in roughly fifty lines of <code>safe_int.rs</code>; the boundary does not.</p>
<h2>The dependency we wrote ourselves: 31 lines of JSON-RPC</h2>
<p>Plugin drivers — DuckDB, Redis, MongoDB, Elasticsearch, Cloudflare D1 — are separate executables speaking JSON-RPC 2.0 over stdin and stdout. The entire wire-type module is 31 lines. I have spent more lines configuring loggers.</p>
<p>The driver and process-management machinery is much larger, but keeping the wire types small is the point: any language that can read a line and write a line can be a Tabularis driver, and the registry already has drivers in Go and in Rust. A protocol whose transport is stdout has one commandment — <em>nothing else may write to stdout</em> — and the plugin guide warns every third-party author about it in bold.</p>
<p>Then we shipped <a href="https://github.com/TabularisDB/tabularis/pull/488">an MCP server that logged to stdout</a> and corrupted its own transport. The rule we wrote for other people, broken in the same protocol and the same repo. The fix was one line. The same subsystem also taught us that &quot;read-only&quot; is a property of a SQL dialect, not of a protocol: <code>EXPLAIN ANALYZE</code> <em>executes</em> the statement it explains, and <a href="https://github.com/TabularisDB/tabularis/pull/456">bypassed the read-only gate</a> until the gate started classifying the wrapped statement instead of the <code>EXPLAIN</code> prefix. <code>EXPLAIN ANALYZE SELECT</code> remains a read; <code>EXPLAIN ANALYZE DELETE</code> does not.</p>
<h2>The supply-chain boundary</h2>
<p>The last set of decisions concerns code Tabularis downloads but does not write. The plugin registry never hosts binaries; it signs author-provided hashes of release assets with Ed25519. Tabularis verifies that registry signature with <a href="https://crates.io/crates/ed25519-dalek">ed25519-dalek</a> before installing anything, and it rejects a valid signature whose plugin, version or registry does not match the requested release. CI actions are <a href="https://github.com/TabularisDB/tabularis/pull/540">pinned to commit SHAs</a>, not mutable tags.</p>
<h2>Would I choose them again?</h2>
<p>Almost all of them, yes. Not because cost is virtuous, but because the expensive choices bought capabilities users actually need.</p>
<p>SQLx where uniformity helps, tokio-postgres where explicit type codecs help. rustls with platform trust, plus custom and standard verifier paths for familiar <code>sslmode</code> choices. Two SSH clients chosen by a three-line predicate. Argon2id that puts bounds on its own envelope. A 31-line wire module that let other people write database drivers in Go before we finished documenting it.</p>
<p>A dependency is not just code you do not have to write. It is a boundary you agree to operate, often before you have read the code on the other side. Choose the boundaries whose failure modes you are willing to understand, and budget for the invoices — they are not optional, only deferred.</p>
<p>The vendored OpenSSL, meanwhile, is still here. Nobody has had the heart to tell it.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/rust-stack-inside-a-15mb-database-client/opengraph-image.png" type="image/png" />
      <category>rust</category>
      <category>sqlx</category>
      <category>rustls</category>
      <category>engineering</category>
      <category>deep-dive</category>
    </item>
    <item>
      <title>v0.17.0: Visual EXPLAIN That Points at the Problem, a Row Editor That Follows You, and SQL That Formats Itself</title>
      <link>https://tabularis.dev/blog/v0170-visual-explain-diagnostics-row-editor-sidebar-sql-formatting</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0170-visual-explain-diagnostics-row-editor-sidebar-sql-formatting</guid>
      <pubDate>Mon, 27 Jul 2026 15:00:00 GMT</pubDate>
      <description>v0.17.0 rebuilds Visual EXPLAIN around exclusive metrics, per-node findings and two new views, replaces the row-editor overlay with a selection-following right sidebar, adds SQL formatting with configurable style, clause-aware autocomplete, an opt-in nightly update channel, pgvector support for PostgreSQL, live reaction to DROP DATABASE, and a Brazilian Portuguese translation.</description>
      <content:encoded><![CDATA[<h1>v0.17.0: Visual EXPLAIN That Points at the Problem, a Row Editor That Follows You, and SQL That Formats Itself</h1>
<p><strong>v0.17.0</strong> follows <a href="https://tabularis.dev/blog/v0160-hosted-plugin-registry-k8s-overrides-encrypted-backups">v0.16.0</a> and is the release where the tools around your SQL get smarter about what your SQL is actually doing. Visual EXPLAIN stops ranking plan nodes by figures that always point at the root and starts computing what each node <em>itself</em> costs — then tells you, in plain findings, which nodes deserve your attention. The editor learns which clause your cursor is in and suggests accordingly, formats your SQL on Shift+Alt+F with a style you configure, and stops mistaking <code>&#39;x:y&#39;</code> inside a string for a query parameter. Around that core: the row editor becomes a proper right sidebar that follows your selection, split view grows to four panes, updates gain an opt-in nightly channel, PostgreSQL learns pgvector, the sidebar reacts to <code>DROP DATABASE</code> the moment it happens, and Tabularis speaks Brazilian Portuguese.</p>
<hr>
<h2>Visual EXPLAIN: Exclusive Metrics, Findings, and Two New Views</h2>
<p>The plan views used to rank and colour nodes by the figures the database reports directly. Those figures are inclusive of children, and Postgres&#39; <code>Actual Total Time</code> is an average per loop — so the plan root was the &quot;slowest step&quot; in essentially every plan, the heat colour was a gradient by depth rather than by work done, and a node executed 50,000 times at 0.2 ms each looked cheap next to a node that ran once for 20 ms. PR <a href="https://github.com/TabularisDB/tabularis/pull/529">#529</a> rebuilds the whole thing.</p>
<ul>
<li><strong>Exclusive (self) metrics.</strong> Every node&#39;s figures are restated once per plan before any view renders: inclusive time becomes <code>Actual Total Time × Actual Loops</code> (a total, not a per-loop average), exclusive time subtracts the children&#39;s inclusive time, and the same treatment applies to cost, rows and buffers. Graph nodes, the table view and the overview bar now rank and colour on these values — by exclusive time when the plan ran with ANALYZE, by exclusive cost otherwise. InitPlan/SubPlan children are excluded from the subtraction, and exclusive values clamp at zero for the drivers and text dumps that don&#39;t satisfy <code>parent ≥ Σ children</code>.</li>
<li><strong>Per-node findings.</strong> Ten diagnostic checks run on every node — hotspot (≥ 25% of plan time), row estimates off by 4x/10x, sorts that spilled to disk, filters discarding ≥ 90% of rows, large sequential scans, heavy heap fetches, fewer parallel workers than planned, nodes executed thousands of times, block accesses missing shared buffers, and never-executed nodes. Findings render as labelled chips on the graph, as icons in the table and diagram rows, and with a one-line explanation in the node details panel.</li>
<li><strong>A diagram view</strong> — one row per node in plan order with a bar proportional to the selected metric (time, rows, cost or buffers; only metrics the plan actually carries are offered). Selection is shared with the graph, so a node picked in one view stays picked in the other.</li>
<li><strong>A stats view</strong> — plan-wide aggregates: node counts and depth, time by operation, relations accessed with rows and self time, and indexes used with scan counts.</li>
</ul>
<p><video src="https://tabularis.dev/videos/posts/tabularis-explain-findings.mp4" poster="/videos/posts/tabularis-explain-findings.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p>Underneath, the explain core moved out of the app into a standalone npm package (<a href="https://github.com/TabularisDB/tabularis/pull/531">#531</a>, <a href="https://github.com/TabularisDB/tabularis/pull/536">#536</a>) — the how and why of that extraction has its own write-up: <a href="https://tabularis.dev/blog/extracting-visual-explain">Visual EXPLAIN beyond the app</a>.</p>
<hr>
<h2>The Row Editor Becomes a Sidebar That Follows You</h2>
<p>The row editor used to open as a fixed overlay: it covered your results, stayed pinned to the row you opened it on, and had to be reopened from the context menu for every other row. PR <a href="https://github.com/TabularisDB/tabularis/pull/510">#510</a>, from <a href="https://github.com/aesslinger">@aesslinger</a>, replaces it with a <strong>right sidebar</strong> that behaves like the Explorer on the left — a first-class layout citizen that pushes content aside instead of covering it, resizes with a drag handle (width persisted), toggles with <strong>Cmd/Ctrl+Shift+B</strong>, and <em>follows your row selection</em> by default. A pin button locks it to a specific row when you want the old behaviour, and the underlying panel system is generic, so future panels get the same treatment for free.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-row-editor-sidebar.mp4" poster="/videos/posts/tabularis-row-editor-sidebar.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>A Polished Rail, and Split View Grows to Four</h2>
<p>The rail on the far left got a matching polish in PR <a href="https://github.com/TabularisDB/tabularis/pull/511">#511</a>: active items are marked by a pill indicator that follows the current view, connection badges grew so the driver logo is actually readable, and <strong>split view now holds up to four connections</strong> instead of two — join a group from the context menu or by dragging a connection onto the group badge, swap panes by dragging the icons, remove one with a right click.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-split-four-panes.mp4" poster="/videos/posts/tabularis-split-four-panes.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>SQL Formatting, Your Way</h2>
<p>A request as old as the repo — issue <a href="https://github.com/TabularisDB/tabularis/issues/23">#23</a> — lands in this release. PR <a href="https://github.com/TabularisDB/tabularis/pull/500">#500</a>, from <a href="https://github.com/aesslinger">@aesslinger</a>, adds <strong>Format SQL</strong> to the editor: <strong>Shift+Alt+F</strong> (Shift+Option+F on macOS), a toolbar button, and a right-click entry. Select text first to format only the selection, the dialect follows the active connection (PostgreSQL, MySQL, SQLite, T-SQL, PL/SQL), and formatting pushes to the undo stack so Cmd+Z reverses it.</p>
<p>The follow-up PR <a href="https://github.com/TabularisDB/tabularis/pull/504">#504</a> makes the style configurable in Settings: keyword and function case, indent style and width, tabs vs. spaces, blank lines between queries, dense operators. Settings apply on the next format action, no restart.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-sql-format.mp4" poster="/videos/posts/tabularis-sql-format.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>The Editor Knows Where Your Cursor Is</h2>
<p>Two changes teach the editor to actually parse what&#39;s around the cursor instead of pattern-matching the whole buffer.</p>
<p><strong>Clause-aware autocomplete</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/505">#505</a>) replaces the offer-everything-everywhere completion with a context analyzer that classifies the cursor into one of 29 clause contexts. After <code>FROM</code> or <code>JOIN</code> you get tables, after <code>WHERE</code> or <code>ON</code> or inside function arguments you get columns, inside an <code>INSERT INTO t (...)</code> column list you get columns only, and inside a string literal or comment you get nothing at all. The analyzer handles subqueries (clause scoped per parenthesis frame), CTEs, nested <code>CASE … END</code>, quoted identifiers and escape sequences — and degrades to the old behaviour on anything it doesn&#39;t recognize, so a miss can never hide valid suggestions.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-clause-autocomplete.mp4" poster="/videos/posts/tabularis-clause-autocomplete.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p><strong>Query parameters stop firing inside strings</strong> (<a href="https://github.com/ymadd">@ymadd</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/519">#519</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/458">#458</a>) — <code>WHERE value = &#39;x:y&#39;</code> no longer pops the parameter modal for <code>:y</code>, and filling in a value no longer rewrites the inside of your string literal. The detection now reuses the dialect-aware tokenizer that already powers the statement splitter, so URLs, timestamps and JSON-in-text stop being mistaken for parameters.</p>
<hr>
<h2>An Opt-In Nightly Channel in the Updater</h2>
<p>Signed nightly builds have existed since v0.16.0 — but installing one meant finding it on GitHub. PR <a href="https://github.com/TabularisDB/tabularis/pull/497">#497</a>, from <a href="https://github.com/NewtTheWolf">@NewtTheWolf</a>, wires them into the app: a <strong>release channel selector</strong> in Settings switches the updater between stable and nightly, and the update check resolves the newest nightly and installs it in place.</p>
<p>The versioning under it is carefully boring: a nightly is stamped as the <em>next</em> patch with a prerelease suffix, so it always supersedes the current stable, any real release supersedes the nightly, and comparisons run through semver so nightly users can always come back to stable when a release ships. If you want to see where Tabularis is going a few weeks early, this is the switch — and if you don&#39;t touch it, nothing changes.</p>
<p><img src="https://tabularis.dev/img/tabularis-release-channel.png" alt="The release channel selector in Settings → Info → Updates, switched to Nightly"></p>
<hr>
<h2>PostgreSQL Learns pgvector</h2>
<p>If your tables hold embeddings, they stopped rendering as <code>USER-DEFINED</code> columns full of nulls. PR <a href="https://github.com/TabularisDB/tabularis/pull/450">#450</a>, from <a href="https://github.com/jonatannietoa">@jonatannietoa</a>, teaches the PostgreSQL driver the three pgvector types — <code>vector</code>, <code>halfvec</code> and <code>sparsevec</code>: values decode from their binary send formats to canonical text, column metadata reports the real type name (in tables <em>and</em> views), and editing works — vector literals are inlined with a strict validation allow-list, since pgvector registers no text cast for bound parameters. In the grid, vector columns render as expandable long-text previews instead of a bare ellipsis.</p>
<hr>
<h2>The Sidebar Notices When a Database Disappears</h2>
<p>Three changes in this cycle close the same gap from different sides: a database dropped mid-session used to stay in the sidebar until you disconnected.</p>
<ul>
<li><strong>Dropped databases are pruned on connect</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/524">#524</a>) — reconnecting reconciles the saved selection against what the server actually has.</li>
<li><strong>A manual refresh button</strong> (<a href="https://github.com/gcapellib">@gcapellib</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/530">#530</a>) runs that same reconciliation on demand, next to &quot;Manage databases&quot; — with an in-flight lock and a cooldown so rapid clicks don&#39;t stack toasts.</li>
<li><strong><code>DROP DATABASE</code> inside the app is detected as it happens</strong> (<a href="https://github.com/gcapellib">@gcapellib</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/535">#535</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/525">#525</a>) — a small dedicated parser recognizes a successfully executed <code>DROP DATABASE</code>/<code>DROP SCHEMA</code> (failing closed on anything ambiguous), and the sidebar clears the database and notifies you the moment the statement succeeds. Dropping a database that wasn&#39;t selected stays silent.</li>
</ul>
<p><video src="https://tabularis.dev/videos/posts/tabularis-drop-database.mp4" poster="/videos/posts/tabularis-drop-database.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Tabularis Fala Português</h2>
<p><a href="https://github.com/jeffersongoncalves">@jeffersongoncalves</a> contributed a complete <strong>Brazilian Portuguese (pt-BR)</strong> locale in PR <a href="https://github.com/TabularisDB/tabularis/pull/537">#537</a>, plus a translated README linked from every language switcher. That makes eleven UI languages — and the same PR fixed a resolution bug where any region-coded locale (<code>pt-BR</code> → <code>pt</code>) was silently stripped before matching and fell back to English.</p>
<p>Fittingly, the language picker itself stopped scaling: a button group doesn&#39;t survive eleven entries, so it&#39;s now a searchable select showing each language&#39;s native name alongside its label in your current UI language.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>Copy column values as a list or an <code>IN</code> clause</strong> (<a href="https://github.com/pokertour">@pokertour</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/482">#482</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/459">#459</a>) — the grid&#39;s cell and column-header menus gain two entries: newline-separated values, or a ready-to-paste SQL list with numbers raw, strings quoted and escaped, and <code>NULL</code> for nulls.</li>
<li><strong>PostgreSQL <code>ssl_mode</code> honored everywhere</strong> (<a href="https://github.com/darkrideroffate">@darkrideroffate</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/378">#378</a>) — the connection-test path ignored the SSL mode and attempted TLS even when set to Disable, so Load Databases succeeded while connecting failed with &quot;bad protocol version&quot; against servers like CloudNativePG. Both paths now agree.</li>
<li><strong>Cut and Copy work in the editor&#39;s context menu again</strong> (<a href="https://github.com/gcapellib">@gcapellib</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/520">#520</a>) — Monaco&#39;s built-ins go through <code>document.execCommand</code>, which fails on WebKitGTK/Wayland; both actions now use the Tauri clipboard API, the same treatment Paste already had.</li>
<li><strong>SQL file import runs once, against the right database</strong> (<a href="https://github.com/gcapellib">@gcapellib</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/513">#513</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/512">#512</a>) — <em>Run SQL file</em> on a right-clicked database imported into the connection&#39;s primary database instead, and a re-firing effect executed the whole dump twice.</li>
<li><strong>Load Databases no longer requires a username</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/533">#533</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/528">#528</a>) — Redis authenticates with a password only, so the button could never enable and the connection could never be saved.</li>
<li><strong>Scrolling stays where it belongs</strong> (<a href="https://github.com/verbaux">@verbaux</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/493">#493</a>) — document-level scrolling and overscroll are disabled, nested lists stop propagating scroll gestures to their parents, and the phantom empty area at the right edge of data grids is gone.</li>
<li><strong>Modal borders are visible again</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/509">#509</a>) — modals get a visible border and properly clipped rounded corners.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Nine external contributors land in v0.17.0.</p>
<p><strong><a href="https://github.com/aesslinger">@aesslinger</a></strong> built two of the release&#39;s headline features: the row editor right sidebar (<a href="https://github.com/TabularisDB/tabularis/pull/510">#510</a>) and SQL formatting with configurable style (<a href="https://github.com/TabularisDB/tabularis/pull/500">#500</a>, <a href="https://github.com/TabularisDB/tabularis/pull/504">#504</a>). <strong><a href="https://github.com/gcapellib">@gcapellib</a></strong> closed the dropped-database gap with the manual refresh (<a href="https://github.com/TabularisDB/tabularis/pull/530">#530</a>) and live <code>DROP DATABASE</code> detection (<a href="https://github.com/TabularisDB/tabularis/pull/535">#535</a>), fixed editor Cut/Copy on WebKitGTK (<a href="https://github.com/TabularisDB/tabularis/pull/520">#520</a>) and the double-executing, wrong-target SQL import (<a href="https://github.com/TabularisDB/tabularis/pull/513">#513</a>).</p>
<p><strong><a href="https://github.com/NewtTheWolf">@NewtTheWolf</a></strong> wired the nightly channel into the updater (<a href="https://github.com/TabularisDB/tabularis/pull/497">#497</a>). <strong><a href="https://github.com/jonatannietoa">@jonatannietoa</a></strong> brought pgvector support to PostgreSQL (<a href="https://github.com/TabularisDB/tabularis/pull/450">#450</a>). <strong><a href="https://github.com/jeffersongoncalves">@jeffersongoncalves</a></strong> translated the entire app into Brazilian Portuguese (<a href="https://github.com/TabularisDB/tabularis/pull/537">#537</a>).</p>
<p><strong><a href="https://github.com/ymadd">@ymadd</a></strong> stopped query-parameter detection from reaching into string literals (<a href="https://github.com/TabularisDB/tabularis/pull/519">#519</a>), <strong><a href="https://github.com/pokertour">@pokertour</a></strong> added column-values copy as list and <code>IN</code> clause (<a href="https://github.com/TabularisDB/tabularis/pull/482">#482</a>), <strong><a href="https://github.com/verbaux">@verbaux</a></strong> contained scrolling and grid overflow (<a href="https://github.com/TabularisDB/tabularis/pull/493">#493</a>), and <strong><a href="https://github.com/darkrideroffate">@darkrideroffate</a></strong> made the PostgreSQL connection test honor <code>ssl_mode</code> (<a href="https://github.com/TabularisDB/tabularis/pull/378">#378</a>).</p>
<p>If you&#39;ve ever stared at an EXPLAIN graph wondering which node is actually the problem, wanted your SQL formatted the way <em>you</em> format it, or wished the row editor would just follow your selection — this is the upgrade.</p>
<hr>
<p><em>v0.17.0 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.17.0">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0170-visual-explain-diagnostics-row-editor-sidebar-sql-formatting/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>postgres</category>
      <category>ui</category>
      <category>ux</category>
      <category>data-grid</category>
      <category>community</category>
    </item>
    <item>
      <title>Visual EXPLAIN beyond the app: one engine, multiple hosts</title>
      <link>https://tabularis.dev/blog/extracting-visual-explain</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/extracting-visual-explain</guid>
      <pubDate>Mon, 27 Jul 2026 11:48:00 GMT</pubDate>
      <description>Visual EXPLAIN began inside the desktop app. Extracting it produced an npm package and an online visualiser, but also a public API, two release schedules, and a repository question I have not answered yet.</description>
      <content:encoded><![CDATA[<h1>Visual EXPLAIN beyond the app: one engine, multiple hosts</h1>
<p><a href="https://tabularis.dev/solutions/visual-explain">Visual EXPLAIN</a> is the Tabularis feature people screenshot. You run a query and it turns the database&#39;s EXPLAIN output into a graph, a diagram, a table and a statistics view. It also reports findings for individual nodes, which is usually where the useful work begins: finding the bottleneck in a large plan.</p>
<p>For a few months I had wanted the same views on the web. There are already websites that let you inspect an EXPLAIN plan online, but none of them worked quite the way I wanted. I wanted to copy the output from psql or the MySQL shell, paste it into a page and inspect the plan without installing anything. I also did not want the site to store anything people pasted. I see little practical value in collecting queries for what should be a temporary analysis session. For a persistent workflow, there is the Tabularis desktop app. Since parsing a plan does not need a server, the page could be static and keep all the data in the browser.</p>
<p><a href="https://www.anthropic.com/news/claude-opus-5">Claude Opus 5 had just been released</a>, and I wanted to give it a real task in this codebase. This was a useful test for an agent because I could state the architectural rule in one sentence and verify the mechanical work with 3,286 tests. The judgement calls were still mine. This was the rule:</p>
<p><strong>A plan visualiser needs a plan, not a database.</strong></p>
<p>The rest of the work was that sentence applied to one file after another.</p>
<p>That page now exists at <a href="https://explain.tabularis.dev">explain.tabularis.dev</a>. Its engine is a public npm package, <a href="https://www.npmjs.com/package/@tabularis/explain"><code>@tabularis/explain</code></a>, and the desktop app uses the same package.</p>
<h2>How the feature was tied to the app</h2>
<p>Before the extraction, Visual EXPLAIN could not leave the desktop app. Not because it needed a database connection. It never did. The problem was where the code lived.</p>
<p>On the Rust side, parsing was treated as part of the database driver. A driver did two different jobs: it ran the right EXPLAIN statement against the database, then translated the returned payload into a common plan model. The second job was pure data transformation, but placing it next to database access made the two responsibilities look inseparable.</p>
<p>The frontend had the same problem in a different form. Plan types, metrics, diagnostics and rendering logic had grown together. Analysis code depended on UI types, some statement logic lived beside plan logic, and two analysis modules imported each other at runtime. Diagnostics were also recomputed for every node on every render because the graph could not reuse work already done by its parent view.</p>
<p>None of this stopped the feature from working inside one application. The second host turned it into an architectural problem.</p>
<p>The first step was to make the dependencies point in one direction. Analysis could depend on the plan model, views could depend on analysis, and the host could depend on the package. Nothing inside the package should need to reach back into the host.</p>
<h2>The extraction test</h2>
<p>The rule I used for every file is now the first line of the package&#39;s description:</p>
<blockquote>
<p>Takes raw EXPLAIN output; never runs a query.</p>
</blockquote>
<p>Anything that passed this test moved to the package. Anything that failed stayed in the app. Statement building and the version fallback chains remained in the drivers. Tauri commands, the standalone window and file reading remained in the host. The AI plan explanation also stayed because it uses a provider configured by the host. The Monaco raw-output tab depended on the host theme, so it stayed too.</p>
<p>The decisions were not difficult once the rule existed. The problem was that these host features were threaded through otherwise portable code.</p>
<p>I moved the plan analysis and all the views into a <code>@tabularis/explain</code> workspace package. One important question remained: what should happen to the parsers?</p>
<h2>Where should the parsers live?</h2>
<p>The parsers were written in Rust, so my first implementation kept them there. I moved them into a standalone crate with no dependencies beyond <code>serde</code>, then compiled it to WASM for the browser. The desktop app and the web visualiser parsed plans with literally the same implementation. It looked like the clean architecture.</p>
<p>Once it was working, the bill became clearer. The plan model would still exist twice: as serde structs in Rust and as TypeScript types in the package. Analysis, metrics, diagnostics and all the views are written in TypeScript, so they need those types regardless of where parsing happens. We would have two definitions of the same model, in two languages, kept in sync by hand. A parser change or support for a new database engine could require changes on both sides. The browser would also need a WASM artifact for the only part of the package that was not TypeScript.</p>
<p>The same parser in two hosts was attractive. One plan model was more valuable.</p>
<p>The parsers did not need to run where Rust runs. They needed to run where the views run.</p>
<p>So I <a href="https://github.com/TabularisDB/tabularis/pull/536">moved the parsers into the package</a> behind a common interface. They cover Postgres JSON and text, MySQL and MariaDB <code>FORMAT=JSON</code> and <code>ANALYZE</code> trees, and SQLite <code>EXPLAIN QUERY PLAN</code>. The existing parser tests moved with them, so the change of language did not also become a change of behaviour.</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-explain-parser-decision.svg" alt="Comparison of the two parser architectures. The rejected design keeps parsers and a serde plan model in Rust, crosses a WASM boundary, and requires a second ExplainPlan model in TypeScript before analysis and rendering. The chosen design sends raw EXPLAIN output to a TypeScript parser, which produces the single ExplainPlan model used directly by metrics, diagnostics and views."></p>
<p>The Rust side kept the part that actually depends on the database. Drivers run the statement, handle the version fallbacks, and return a small envelope containing the engine, format and raw payload. Row-based formats are serialised without interpreting their meaning. From that boundary onward, the package owns the plan.</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-explain-boundary.svg" alt="Diagram of the boundary after PR #536. Two hosts are on the left: the Tabularis desktop app, whose drivers run EXPLAIN and return the engine, format and raw payload, and explain.tabularis.dev, where the user pastes EXPLAIN output. Both feed raw data into the @tabularis/explain package on the right. The package owns parsing, metrics, diagnostics, stats and views. Database statements, host integration, AI explanation and the raw-output editor remain in the desktop app."></p>
<p>In <a href="https://tabularis.dev/blog/why-tabularis-runs-on-tauri">the Tauri post</a> I wrote that every result page pays the IPC tax. Plans still pay it, but now they cross as a payload instead of a parsed tree. Parsing happens once, on the side that owns the types. Any host holding raw EXPLAIN output now takes the same path from text to view.</p>
<h2>What the extraction exposed</h2>
<p>Once the parsers stood alone, an assumption became obvious. The old API tried to detect the payload format from its contents, but this only worked for the two Postgres forms. A MySQL <code>EXPLAIN FORMAT=JSON</code> document also starts with <code>{</code>, so it was treated as Postgres and then rejected because it did not have the expected shape.</p>
<p>The fix was to let the caller provide the database engine when it is known, while keeping format detection as a fallback. The bug had been present in the app for some time, but separating the parsers made the weak assumption much easier to notice.</p>
<p>The tests revealed a different gap. We had unit tests for the parsers, but no test for the fallback chain that chooses which EXPLAIN statement to run. The driver uses <code>EXPLAIN ANALYZE</code> on MySQL 8.0.18 and newer, <code>ANALYZE FORMAT=JSON</code> on MariaDB, and plain <code>FORMAT=JSON</code> when neither is available. This logic can only be tested properly against a server. It now has live tests that exercise all three branches using MySQL and MariaDB containers.</p>
<p>Once these parts were in place, version 0.1.0 went to npm.</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-explain-extraction-timeline.svg" alt="The extraction as five ordered steps: make dependencies flow one way; separate analysis and views from the host; make drivers stop at raw EXPLAIN output; publish the package; use the same engine in the desktop app and the standalone visualiser."></p>
<h2>Three entry points, and the bill</h2>
<p>The package has three entry points. The split is the architecture:</p>
<ul>
<li><code>@tabularis/explain</code>: parsers, plan types, exclusive metrics, diagnostics, stats and formatters. It has <strong>no runtime dependencies</strong>, so plan analysis can run in a browser, a worker, a Node script or a test.</li>
<li><code>@tabularis/explain/react</code>: the graph, table, diagram, stats, node details and bars.</li>
<li><code>@tabularis/explain/flow</code>: the ReactFlow and dagre adapter, kept separate so the analysis core does not pull in a graph library.</li>
</ul>
<p>All peer dependencies are optional, so a consumer only needs the dependencies required by the parts it imports. Inside the monorepo, the app can use the TypeScript source directly. Published consumers receive the compiled package instead. This keeps local development immediate without changing the public package contract.</p>
<p>Now for the bill. <code>./react</code> is not a component kit you can drop into any application. The host must provide Tailwind with the colour tokens used by the desktop app, an initialised <code>react-i18next</code> instance with the <code>editor.visualExplain.*</code> namespace, and the ReactFlow stylesheet when rendering the graph. The package is portable, but its views are not independent of their host.</p>
<p>The desktop app provides the strings in eleven languages, while the standalone site currently provides only English. The two consumers also update differently: the app tracks <code>workspace:*</code>, while the site tracks releases on npm. This is a synchronisation cost that did not exist while everything lived in one codebase.</p>
<h2>One repository or two?</h2>
<p>For now, <code>@tabularis/explain</code> remains a workspace package inside the Tabularis monorepo. This is excellent during development. The desktop app resolves the package directly to its TypeScript source, so a change to a parser, a diagnostic and the view that displays it can all happen in one commit. The app tests the package as part of its normal test suite, and I can change both sides of the boundary without publishing an intermediate version to npm.</p>
<p>The monorepo becomes less convenient when the package is considered as a product of its own. It needs releases and a changelog that make sense to people who do not follow the desktop app. A change may be internal to Tabularis, public for package consumers, or relevant to both. The current application changelog is not a particularly good place to explain all three. Versioning and release notes also become easier to get wrong when the app and the package move in the same repository but publish on different schedules.</p>
<p>A separate repository would make that independence explicit. The package could have its own issues, documentation, changelog and release cadence. On the other hand, every change that crosses the driver and parser boundary would then cross repositories too. Testing an app change against an unpublished package version would require more work, and a refactor that is atomic today could become a pair of coordinated pull requests.</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-explain-repository-tradeoff.svg" alt="Comparison of keeping @tabularis/explain in the Tabularis monorepo and moving it to a dedicated repository. The monorepo allows one commit for cross-boundary changes, direct source imports and shared tests, but gives the package an awkward release history. A dedicated repository gives the package its own releases, changelog, issues and documentation, but requires coordinated pull requests and more work to test unpublished versions."></p>
<p>I do not yet know which cost matters more. Keeping the package in the monorepo gives the best development experience today. Moving it out may give it a clearer life as a public library. Now that there are two hosts and external consumers are possible, this is no longer just a question about where the files look tidier.</p>
<h2>The second host</h2>
<p>The <a href="https://github.com/TabularisDB/explain-plan">standalone visualiser</a> is mostly a page shell, examples and translations around the package. It runs entirely in the browser. It does not execute queries or upload plans to a server. The deployed site is just a static bundle.</p>
<p>The same extraction rule also guided a product decision for the site. AI plan analysis does not take only a plan: it depends on a provider configured by the host. For this reason, the online visualiser does not offer it. Instead, the corresponding tab explains that the feature is available in the desktop app.</p>
<p>The extracted analysis had also improved shortly before this work. In the same period, <a href="https://github.com/TabularisDB/tabularis/pull/529">#529</a> added exclusive metrics. Previously, views ranked nodes using the figures reported by the database. Those figures include the work of child nodes and are often averages per loop, so the plan root appeared to be the most expensive node in nearly every plan.</p>
<p>This was technically accurate and practically useless.</p>
<p>The package now recalculates metrics for each node before displaying them. It also reports hotspots, row estimates that are wrong by more than 10x, sorts spilling to disk and filters discarding nearly every row. In a tool where you paste a plan, these findings are a large part of the product.</p>
<p>The extraction was merged as <a href="https://github.com/TabularisDB/tabularis/pull/536">#536</a>, with the app&#39;s 3,286 tests passing.</p>
<h2>Would I draw the boundary again?</h2>
<p>Yes, and earlier.</p>
<p>I started the extraction because the online visualiser needed it. But most of the immediate value appeared in the desktop app before the site existed. The import cycle is gone. Diagnostics are computed once per plan instead of once per node on every render. The format detection bug is fixed, and the fallback chain finally has tests. The code that answers &quot;what does this plan mean?&quot; no longer depends on the code that draws it.</p>
<p>This does not mean that moving code into a package automatically improves it. A bad boundary in its own repository is still a bad boundary. What helped was having a rule that described ownership instead of directories.</p>
<p>That rule is still the most useful result for me: <em>takes raw EXPLAIN output; never runs a query</em>. It sorted the files, determined what the drivers should return, chose the language for the parsers, and guided the decision about the AI tab on the standalone site. When one sentence makes that many decisions, the boundary was already real. The code had simply failed to express it.</p>
<p>The plan visualiser needed a plan, not a database. Now it does not even need Tabularis. You can <a href="https://explain.tabularis.dev">paste a plan and see</a>, although I would still prefer you used Tabularis.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/extracting-visual-explain/opengraph-image.png" type="image/png" />
      <category>explain</category>
      <category>architecture</category>
      <category>typescript</category>
      <category>rust</category>
      <category>engineering</category>
      <category>deep-dive</category>
    </item>
    <item>
      <title>Why I chose Tauri, and what it cost me</title>
      <link>https://tabularis.dev/blog/why-tabularis-runs-on-tauri</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/why-tabularis-runs-on-tauri</guid>
      <pubDate>Wed, 22 Jul 2026 09:40:00 GMT</pubDate>
      <description>A database client is a systems program with a UI problem. That is why I chose Tauri over Electron and native. After six months and 64 releases, here is the part the framework comparison charts leave out.</description>
      <content:encoded><![CDATA[<h1>Why I chose Tauri, and what it cost me</h1>
<p>In January I had to choose how to build Tabularis. Electron was the obvious option. Three native applications were the romantic option. Tauri looked like the compromise.</p>
<p>Six months and 64 releases later, I still think it was the right choice. But not for the reason usually printed at the top of Tauri comparison pages.</p>
<p>Yes, the Windows installer is 12 MB. This is nice. It is also the least interesting part.</p>
<p>The sentence that decided the architecture was this:</p>
<p><strong>A database client is a systems program with a UI problem, not a web app with a database problem.</strong></p>
<p>That sentence explains why I chose Tauri. It does not explain the bill. This post is about both.</p>
<h2>The program behind the pixels</h2>
<p>Below the data grid, Tabularis speaks MySQL, PostgreSQL and SQLite through <a href="https://github.com/launchbadge/sqlx">SQLx</a>. It opens SSH tunnels, asks the OS keychain for credentials, invokes <code>kubectl</code> for port forwarding, encrypts connection backups, and starts plugin drivers as subprocesses speaking JSON-RPC over stdin and stdout.</p>
<p>You can build all of this in Electron. Of course you can. But then I would either implement the backend in JavaScript, maintain native Node modules for every target, or add a Rust sidecar and recreate the same architectural split while still shipping Chromium. Electron&#39;s own documentation says native modules <a href="https://www.electronjs.org/docs/latest/tutorial/using-native-node-modules">usually need to be rebuilt after an Electron upgrade</a>. I did not want the database engine of the application to depend on how well <code>node-gyp</code> felt that morning.</p>
<p>With Tauri, the backend is an ordinary Rust program. SQLx, Tokio, rustls, keyring, AES-GCM: they are normal Rust dependencies, with normal Rust types between them. The backend is not a helper attached to the product. It is the product.</p>
<p>Then the frontend gets the part web technology is genuinely good at: Monaco, a virtualized data grid, forms, diagrams, themes. This separation felt natural to me. More importantly, it was a separation I could maintain alone.</p>
<p>There is also a security argument, but it is easy to exaggerate it.</p>
<p>Code in a Tauri webview has normal web capabilities, including network requests. What it does <em>not</em> get by default is Node.js or arbitrary access to the operating system. Privileged work crosses a defined IPC boundary through Tauri commands and plugin capabilities. In Tabularis I can inspect the command registration and capability files and see what the frontend is allowed to ask for.</p>
<p>This does not make a Tauri application secure by magic. A bad command is still a bad command. A loose capability is still loose, XSS still matters, and the Rust and npm dependency trees are still part of the attack surface. But for an application holding production credentials, I prefer starting from an explicit privilege boundary instead of adding one later.</p>
<p>And then there is size.</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-tauri-installer-size.svg" alt="Grouped bar chart comparing installer sizes of Tabularis 0.16.0 (Tauri) and Beekeeper Studio 5.9.2 (Electron) across four package types. Linux .deb: 17 vs 207 MB. macOS .dmg: 18 vs 290 MB. Windows installer: 12 vs 201 MB. Linux AppImage: 94 vs 282 MB."></p>
<p>These are decimal megabytes from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.16.0">Tabularis 0.16.0</a> and <a href="https://github.com/beekeeper-studio/beekeeper-studio/releases/tag/v5.9.2">Beekeeper Studio 5.9.2</a> release assets. <a href="https://tabularis.dev/compare/beekeeper-studio-alternative">Beekeeper</a> is a good database client. Its package sizes are not a failure of Beekeeper; they are the price of shipping Chromium, and shipping Chromium also buys you consistency.</p>
<p>Still, a 12 MB Windows installer for a database IDE makes me smile.</p>
<h2>The harder comparison is native</h2>
<p>Electron is the easy comparison. Native applications are the ones that made me hesitate.</p>
<p>An AppKit application does not care which WebKitGTK version Arch happens to ship. Native controls can offer better input latency, scrolling, text rendering and platform integration. Tauri does not ship a browser engine on most targets, but it certainly <em>runs</em> one, and the webview&#39;s memory does not disappear because the installer is small.</p>
<p>Size is not even a native-versus-Tauri argument. Sequel Ace is small too. The small-package story is mostly an anti-bundled-Chromium story.</p>
<p>So why not native? Because native to what?</p>
<p>Sequel Ace gives the honest answer: macOS. It is a focused application and a very good one. <a href="https://tabularis.dev/compare/tableplus-alternative">TablePlus</a> began on macOS; when its small team built the Windows version, it <a href="https://tableplus.com/blog/2018/04/tableplus-windows-version-when-is-it-coming-out.html">rewrote the application in C# and C/C++</a>. That is not a criticism. It is what “native on two platforms” actually means.</p>
<p>For Tabularis, native meant three user interfaces: three grids, three editors, three settings screens, three sets of bugs. A cross-platform widget toolkit would move the compromise somewhere else, not remove it.</p>
<p>The web has a component economy that is hard to ignore. Tabularis uses Monaco, the editor inside VS Code. I get multi-cursor editing, folding, search and the keyboard behavior developers already know. I can render an ER diagram and a visual EXPLAIN plan with the DOM. Reproducing that experience with native widgets on three platforms would not be a side quest for me. It would become the project.</p>
<p>When Tabularis started, one person was working on it: me. Yet it had to run on Linux, macOS and Windows, and it now ships in ten languages. Native might win the benchmark while losing the more important test: whether I could actually ship the application and keep it moving.</p>
<p>This is the trade I eventually wrote down:</p>
<p><strong>Electron charges you for the runtime. Native charges you for the platforms. Tauri charges you for the seams.</strong></p>
<p>Now for the seams.</p>
<h2>You do not ship a browser. You inherit three.</h2>
<p>Tauri uses the webview already available on the platform: WKWebView on macOS, WebKitGTK on Linux and WebView2 on Windows. WebView2 is based on Chromium. The other two are WebKit, but that does not make them identical. Tauri maintains a useful <a href="https://v2.tauri.app/reference/webview-versions/">webview version guide</a>; the Linux table alone explains why “works in my browser” is not a test plan.</p>
<p>So I do not ship a rendering engine, but I answer for three of them.</p>
<p>WebKitGTK has its own graphics path and is distributed on the cadence of each Linux distribution. WKWebView comes with the OS. WebView2 updates independently and is usually the least surprising because most frontend work is tested in Chromium first.</p>
<p>The differences are not always impressive technical failures. Sometimes macOS simply decides it is an editor. A user typed a straight quote in a table filter, the system changed it to a curly quote, and the resulting SQL stopped parsing. The fix in <a href="https://github.com/TabularisDB/tabularis/pull/439">PR #439</a> normalizes typographic quotes before the clause reaches the database.</p>
<p>An Electron developer targets one rendering engine. I target three webview implementations that can disagree about CSS, input and GPU compositing. The small installer is partly financed by this work.</p>
<h2>The Linux webview lottery</h2>
<p>Most of the reports where Tabularis opens a broken window, or no useful window at all, have come from the Linux webview and graphics stack. A short history:</p>
<ul>
<li><a href="https://github.com/TabularisDB/tabularis/issues/9">#9</a>: a Wayland protocol error on Arch, very early in the project.</li>
<li><a href="https://github.com/TabularisDB/tabularis/issues/45">#45</a>: <code>libEGL fatal: did not find extension DRI_Mesa</code>. The Snap package failed; the <code>.deb</code> worked.</li>
<li><a href="https://github.com/TabularisDB/tabularis/issues/54">#54</a>: a blank AppImage window. Preloading the host&#39;s <code>libwayland-client.so</code> made it render.</li>
<li><a href="https://github.com/TabularisDB/tabularis/issues/423">#423</a>: an AppImage WebKit process abort on Solus. It is still open while I write this.</li>
</ul>
<p>The workaround in #54 deserves to be read twice. The portable bundle worked after it was forced to use a library from the system it was supposed to be portable across.</p>
<p>Electron applications have Linux and Wayland bugs too. What they generally do not have is the question “which WebKitGTK did this distribution compile, and how does it interact with this compositor?” Bundling Chromium removes that variable. This is a real advantage, not marketing.</p>
<p>It is also the structural weakness of Tauri&#39;s shared-webview bet. Anybody describing Tauri as a free lunch has not shipped an AppImage to enough Linux users.</p>
<h2>The 94 MB exception</h2>
<p>The first chart contains the whole story if you look at the last pair of bars.</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-tauri-appimage-anatomy.svg" alt="Bar chart of five representative x64 package formats from Tabularis 0.16.0. The .msi, .deb, .rpm and .dmg all sit between 15.8 and 18.3 MB. The AppImage is 93.7 MB, with a callout marking the extra 76 MB as mostly bundled WebKitGTK and its dependencies."></p>
<p>Same release, five representative x64 package formats. Four are between 15.8 and 18.3 MB. The AppImage is 93.7 MB.</p>
<p>An AppImage cannot assume that the target distribution provides the dependencies it needs, so Tabularis&#39;s portable build carries WebKitGTK and much of its dependency closure. Tauri&#39;s own documentation warns that an AppImage can take an application from a few megabytes to <a href="https://v2.tauri.app/distribute/appimage/">70 MB or more</a>.</p>
<p>The size advantage came from borrowing the webview from the operating system. When the package cannot safely borrow it, part of the advantage goes away. Tabularis is still much smaller than the Electron AppImage in the chart, but the clean “12 MB versus 200 MB” story is gone.</p>
<p>Flatpak has a better model for this particular problem: common runtimes can be shared between applications. The less pleasant part is publishing on Flathub. Source-available submissions are normally <a href="https://docs.flathub.org/docs/for-app-authors/requirements">built from source without network access</a>, so every Cargo and npm dependency must be described ahead of the build. This is solvable, and work on <a href="https://github.com/TabularisDB/tabularis/issues/326">#326</a> is ongoing. It is not a one-line manifest.</p>
<h2>You own the glibc floor</h2>
<p>I learned another Linux lesson in the traditional way: by shipping the bug.</p>
<p>At one point the Linux release ran directly on GitHub&#39;s <code>ubuntu-24.04</code> runner and linked against glibc 2.39. The binary then refused to start on Ubuntu 22.04, which has glibc 2.35 and was still in standard support.</p>
<p>The <a href="https://github.com/TabularisDB/tabularis/commit/af7d0bca">fix</a> was to build inside an <code>ubuntu:22.04</code> container. The container pins the compatibility floor even when GitHub eventually changes or removes its runner images. This is also what the <a href="https://v2.tauri.app/distribute/appimage/">Tauri AppImage guide</a> recommends: build on the oldest base system you intend to support.</p>
<p>Electron largely chooses this baseline for you through its prebuilt runtime. Native Node modules can still reintroduce the same problem, but if your Electron application is pure JavaScript, a decade of packaging knowledge is already encoded in the toolchain.</p>
<p>When you compile a native Rust backend, you own its system compatibility. Rust gives you systems programming, including the systems.</p>
<h2>Every result page pays the IPC tax</h2>
<p>The most important seam is not packaging. It is in the architecture.</p>
<p>The Rust core and the React UI live on opposite sides of Tauri&#39;s IPC boundary. Today a query result in Tabularis is a Rust <code>QueryResult</code> containing rows of <code>serde_json::Value</code>. Tauri serializes the response, the webview receives JavaScript values, and only then can React render the grid.</p>
<p>For a settings screen this cost is noise. For query results it shapes the product.</p>
<p>The UI asks for paged results, 500 rows by default, instead of moving an unbounded result set into the webview. Large exports stay on the Rust side and stream from the database to a file, avoiding the UI bridge altogether. The virtualized grid matters not only because the DOM is expensive, but because moving and retaining data is expensive too.</p>
<p>Electron has an IPC boundary in its secure default architecture as well. You can put a native module in an unsandboxed renderer and avoid some of it, but Electron&#39;s documentation is explicit that this <a href="https://www.electronjs.org/docs/latest/tutorial/sandbox">disables an important security boundary</a>. The shortcut exists. It is not free.</p>
<p>This is the same fence seen from two sides. Keeping credentials and database access out of the webview is a security benefit. Serializing every visible row across that fence is a performance cost. I want both facts in the same paragraph because they come from the same decision.</p>
<h2>Would I choose Tauri again?</h2>
<p>Yes.</p>
<p>Not because Tauri is “Electron without the bloat.” That description is too small, and on Linux AppImage it is not even particularly convincing.</p>
<p>I would choose it again because the hard part of Tabularis is where I want it: database protocols, TLS, tunnels, credentials, encryption, plugins and exports are Rust. The editor, grid, diagrams and interaction design change much faster, and web technology fits them well. The boundary between the two costs real work, but it is a boundary I understand and want.</p>
<p>Some costs can be paid once: pin the build container, drop a broken package, add the right workaround. Others recur forever. I will keep testing three webviews. New compositors and distro combinations will produce bugs I cannot reproduce. Every page of rows will still cross IPC.</p>
<p>That is acceptable to me. What would not be acceptable is maintaining three interfaces, or wishing the database core were not JavaScript after the application had already grown around it.</p>
<p>If your desktop application is mostly a frontend for HTTP APIs, Electron may be the simpler and better choice. If you support one platform and care deeply about native behavior, build native. If you need identical rendering everywhere, shipping Chromium is a feature. And if your application is a systems program that needs a rich cross-platform interface, Tauri is a very interesting compromise.</p>
<p>Just budget for the seams.</p>
<p>The AppImage users may now open an issue.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/why-tabularis-runs-on-tauri/opengraph-image.png" type="image/png" />
      <category>tauri</category>
      <category>rust</category>
      <category>electron</category>
      <category>engineering</category>
      <category>deep-dive</category>
    </item>
    <item>
      <title>v0.16.0: A Hosted Plugin Registry, Kubernetes Your Way, and Backups That Encrypt Themselves</title>
      <link>https://tabularis.dev/blog/v0160-hosted-plugin-registry-k8s-overrides-encrypted-backups</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0160-hosted-plugin-registry-k8s-overrides-encrypted-backups</guid>
      <pubDate>Tue, 21 Jul 2026 11:00:00 GMT</pubDate>
      <description>v0.16.0 gives the plugin ecosystem real infrastructure: a hosted registry at registry.tabularis.dev, a searchable driver catalogue inside New Connection, and one-click deep-link installs — plus kubectl/kubeconfig overrides for Kubernetes tunnels, automatic encrypted connection backups to a folder or WebDAV, run-statement-at-cursor in the editor, AWS RDS IAM authentication for MySQL, and Elasticsearch and Cloudflare D1 drivers.</description>
      <content:encoded><![CDATA[<h1>v0.16.0: A Hosted Plugin Registry, Kubernetes Your Way, and Backups That Encrypt Themselves</h1>
<p><strong>v0.16.0</strong> follows <a href="https://tabularis.dev/blog/v0150-import-connections-nested-groups-encrypted-exports">v0.15.0</a> and is the release where the plugin ecosystem stops being a JSON file in a git repo and becomes infrastructure: a hosted registry, a searchable driver catalogue built into the New Connection flow, one-click installs from a browser link, and two new community drivers — Elasticsearch and Cloudflare D1 — to install through it. Around that core: Kubernetes tunnels learn to use <em>your</em> kubectl and <em>your</em> kubeconfig, connections back themselves up encrypted to a folder or a WebDAV server, the SQL editor runs the statement under your cursor, and MySQL connections can authenticate against AWS RDS with IAM.</p>
<hr>
<h2>A Hosted Plugin Registry and a New Connection Catalogue</h2>
<p>Until now, discovering a Tabularis plugin meant knowing it existed: the registry was a static <code>registry.json</code>, and installing meant finding a GitHub release yourself. PR <a href="https://github.com/TabularisDB/tabularis/pull/299">#299</a>, a long-running effort from <a href="https://github.com/NewtTheWolf">@NewtTheWolf</a>, replaces that with the hosted <strong>Tabularium</strong> registry at <code>registry.tabularis.dev</code> — and rebuilds the New Connection flow around it.</p>
<ul>
<li><strong>A driver catalogue is now step one.</strong> Creating a connection starts from a searchable grid of every engine Tabularis can talk to — built-in drivers and registry plugins merged into one view, with paradigm facets to filter by. Built-ins lead, and drivers your platform can&#39;t run are badged and dimmed instead of being a click-through dead end. An uninstalled driver is install-gated: pick it, install it inline, connect.</li>
<li><strong>Deep-link installs.</strong> <code>tabularis://install/&lt;slug&gt;</code> links open the app with a version-aware confirmation — Install, Update when a newer version exists, or an already-up-to-date notice. Plugin pages on the web can now be one click from a working driver.</li>
<li><strong>Fully backwards compatible.</strong> The legacy <code>registry.json</code> is still merged in (the API wins on conflicts), so older plugins stay visible and already-shipped app versions keep working. All of the compatibility code is marked <code>COMPAT(registry-ga)</code> so it can be removed mechanically once migration completes.</li>
<li><strong>Tooling for authors.</strong> <code>@tabularis/create-plugin</code> 0.2.0 scaffolds the new <code>.tabularium</code> manifest format, adds a <code>migrate</code> command that converts legacy <code>manifest.json</code> plugins, and emits a registry-ready release workflow that publishes the manifest as a release asset the registry resolves directly.</li>
</ul>
<p>The weeks after the merge hardened the transition paths: installed plugins that drop out of the hosted listing stay updatable instead of silently losing their update button, legacy <code>manifest.json</code> bundles install and list again, legacy-only plugins no longer link to a 404 on the API, and the Installed tab finally has an <strong>Update</strong> button next to the plugin it&#39;s telling you to update.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-connection-catalogue.mp4" poster="/videos/posts/tabularis-connection-catalogue.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Two New Community Drivers: Elasticsearch and Cloudflare D1</h2>
<p>Two community-built drivers joined the registry this cycle.</p>
<p><strong>Elasticsearch</strong>, by <a href="https://github.com/erwin-lovecraft">@erwin-lovecraft</a> (<a href="https://github.com/TabularisDB/tabularis-elasticsearch-plugin">repo</a>, added in PR <a href="https://github.com/TabularisDB/tabularis/pull/476">#476</a>), brings cluster inspection to Tabularis: index browsing with stats, a mapping viewer for fields and nested structures, and document sampling. Queries run in three modes selected by a shebang on the first line — Elasticsearch SQL by default, <code>#!esql</code> for ES|QL, and <code>#!rest</code> for raw REST requests with the method and endpoint on the first line and the body below. It requires Tabularis 0.15.0 or newer.</p>
<p><strong>Cloudflare D1</strong>, by <a href="https://github.com/GabrielMalava">@GabrielMalava</a> (<a href="https://github.com/GabrielMalava/cloudflare-tabularis">repo</a>, registered in PR <a href="https://github.com/TabularisDB/tabularis/pull/430">#430</a>), talks to Cloudflare&#39;s serverless SQLite over the D1 HTTP API with per-connection credentials: browse every D1 database in your account, inspect tables, indexes, foreign keys and views, run paginated queries, edit rows, and manage tables, indexes and views — ER diagram included.</p>
<p>Plugin drivers also got more capable underneath: batch query RPCs are now forwarded to external plugin drivers (<a href="https://github.com/haos666">@haos666</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/443">#443</a>), and a new <code>explain</code> driver capability (PR <a href="https://github.com/TabularisDB/tabularis/pull/491">#491</a>) means the Visual EXPLAIN button only appears for drivers that actually implement it, instead of failing at click time.</p>
<hr>
<h2>Kubernetes Tunnels, With Your kubectl and Your kubeconfig</h2>
<p>Tabularis&#39;s Kubernetes port-forwarding always used whatever <code>kubectl</code> was first on the PATH and the default kubeconfig. If you juggle multiple clusters, wrap kubectl in a corporate shim, or keep per-project kubeconfig files, that assumption was the feature&#39;s ceiling. PR <a href="https://github.com/TabularisDB/tabularis/pull/365">#365</a>, from <a href="https://github.com/metalgrid">@metalgrid</a>, adds <strong>advanced settings</strong> to Kubernetes connections — both inline and saved — that override the <code>kubectl</code> binary and the kubeconfig file per connection.</p>
<p><img src="https://tabularis.dev/img/tabularis-k8s-advanced-settings.png" alt="The Advanced kubectl settings section of a saved Kubernetes tunnel, with kubectl and kubeconfig path overrides validated inline"></p>
<p>The override paths are validated <em>before</em> they&#39;re used: an on-blur preflight checks that the pair actually works, incomplete pairs are refused before apply, and cancelling the modal cancels the validation instead of letting it land on a closed form. Tunnel cache keys now include the overrides, so two connections pointing at the same host through different kubeconfigs no longer collide on the same cached tunnel. The new settings are translated across all supported locales.</p>
<hr>
<h2>Backups That Take Themselves</h2>
<p>v0.15.0 gave connection exports proper encryption. v0.16.0 makes them automatic. PR <a href="https://github.com/TabularisDB/tabularis/pull/470">#470</a>, from <a href="https://github.com/pokertour">@pokertour</a>, adds a dedicated <strong>Backup</strong> tab in Settings that periodically writes an encrypted export of your connections — same <strong>AES-256-GCM</strong> / <strong>Argon2id</strong> envelope as the manual export, and deliberately <em>only</em> that: plaintext automatic backups are not an option.</p>
<ul>
<li><strong>Triggers</strong>: manual, on an interval (6h/12h/daily/weekly presets or a custom value, with the next run time shown), on app launch, or on app close — the exit backup is bounded by a timeout so the app can always quit.</li>
<li><strong>Destinations</strong>: a local folder, or a <strong>WebDAV</strong> collection (Nextcloud and friends). Rotation honors your retention count and only ever touches <code>tabularis-backup-*.json</code> files, so it can&#39;t eat anything else living in the same directory.</li>
<li><strong>Secrets stay in the keychain.</strong> The encryption password and the WebDAV credentials live in the OS keychain, never in <code>config.json</code>, and the scheduler re-reads its config every minute so settings changes apply without a restart.</li>
</ul>
<p><video src="https://tabularis.dev/videos/posts/tabularis-backup-settings.mp4" poster="/videos/posts/tabularis-backup-settings.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Run the Statement Under Your Cursor</h2>
<p>The single most-used editor action got the <a href="https://tabularis.dev/compare/tableplus-alternative">TablePlus</a> treatment. PR <a href="https://github.com/TabularisDB/tabularis/pull/464">#464</a>, from <a href="https://github.com/maximumbreak">@maximumbreak</a>, makes <strong>Cmd/Ctrl+Enter</strong> run the SQL statement the cursor is <em>inside</em> when nothing is selected — no more whole-file execution or a &quot;pick a statement&quot; popup. A subtle highlight shows which statement is armed, Explain follows the same rule, and <strong>Run All</strong> moves to Cmd/Ctrl+Shift+Enter with a dedicated entry at the top of the Run dropdown. Selecting text still runs exactly the selection.</p>
<p>Making back-to-back statement runs easy exposed two long-standing result bugs, fixed in the same PR: an in-flight column-metadata fetch could resolve late and stamp the wrong table&#39;s primary key onto the current result, and pagination or post-edit refresh re-sent the whole editor buffer — which PostgreSQL rejects as &quot;multiple commands in a prepared statement&quot; once the buffer holds more than one. Both paths now track the exact last-run statement per tab, and stale async responses detect themselves and drop out.</p>
<p>And if you <em>liked</em> the old picker: <a href="https://github.com/GabrielMalava">@GabrielMalava</a> added a <strong>Query Execution</strong> toggle in Settings → General in PR <a href="https://github.com/TabularisDB/tabularis/pull/487">#487</a> — running the statement under the cursor stays the default, but switching it off brings back the query-selection dialog for multi-statement scripts.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-run-at-cursor.mp4" poster="/videos/posts/tabularis-run-at-cursor.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>AWS RDS IAM Authentication for MySQL</h2>
<p>If your MySQL lives on RDS behind IAM database authentication, Tabularis can now speak it. PR <a href="https://github.com/TabularisDB/tabularis/pull/404">#404</a>, from <a href="https://github.com/p4pupro">@p4pupro</a>, adds a <strong>Use AWS IAM Authentication (RDS)</strong> option to MySQL connections: the password field carries a generated RDS auth token (they expire after 15 minutes, so the token must be supplied per connect — the keychain is deliberately bypassed), and TLS is mandatory and enforced on every path, so a token can never travel in the clear. Connections with IAM enabled and a CA bundle configured are auto-escalated to certificate verification, empty-token mistakes fail fast with an actionable message instead of an opaque <code>1045 Access denied</code>, and a macOS-specific TLS handshake failure with the RDS regional CA bundle was fixed by moving the MySQL TLS backend to rustls with the OS trust store kept active — so corporate CAs in the system keychain keep working on every platform.</p>
<p><img src="https://tabularis.dev/img/tabularis-rds-iam-auth.png" alt="The SSL tab of a MySQL connection with SSL mode Required and the Use AWS IAM Authentication (RDS) checkbox enabled"></p>
<hr>
<h2>Stored Procedures Show All Their Result Sets</h2>
<p>A MySQL <code>CALL</code> to a stored procedure with several <code>SELECT</code>s streams back several result sets — and Tabularis only ever showed the first one. PR <a href="https://github.com/TabularisDB/tabularis/pull/415">#415</a> (fixes <a href="https://github.com/TabularisDB/tabularis/issues/414">#414</a>) rebuilds the MySQL execution path around result-set boundaries: every set now arrives, rendered through the multi-result tab UI with one tab per result set, and the per-page row cap applies to each set independently. The extra sets travel in a new optional field that other drivers and plugins simply never see, so nothing else changes shape. One known limit: a result set with zero rows is indistinguishable from the statement&#39;s OK packet at the driver level, so empty sets are still dropped.</p>
<p><img src="https://tabularis.dev/img/tabularis-multi-result-sets.png" alt="A stored procedure CALL in the editor returning multiple result sets, each in its own result tab"></p>
<hr>
<h2>Tabularis Speaks Korean</h2>
<p><a href="https://github.com/moduvoice">@moduvoice</a> contributed a complete <strong>Korean</strong> locale in PR <a href="https://github.com/TabularisDB/tabularis/pull/463">#463</a> — all 1,574 strings, every interpolation placeholder intact, plus a translated README linked from the language switchers. That makes ten UI languages.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>Copy rows as a Markdown table</strong> (<a href="https://github.com/pokertour">@pokertour</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/481">#481</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/474">#474</a>) — the grid&#39;s copy and export menus gain a Markdown table format, ready to paste into a PR description or a wiki page.</li>
<li><strong>MCP transport no longer corrupts itself</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/488">#488</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/486">#486</a>) — keychain lookups and SSH/K8s tunnel setup logged to stdout, which in MCP mode interleaves with the JSON-RPC frames; clients like Warp dropped the connection with &quot;Transport closed&quot; on queries that had actually succeeded. All runtime logging now goes to stderr.</li>
<li><strong>JSON in the row editor, not <code>[object Object]</code></strong> (<a href="https://github.com/NewtTheWolf">@NewtTheWolf</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/475">#475</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/428">#428</a>) — JSON/JSONB values from JOINs, UNIONs and aggregates arrive without column metadata; the row editor now recognizes structured values on their own and opens the JSON editor for them.</li>
<li><strong>Smart quotes normalized in filters</strong> (<a href="https://github.com/Davydhh">@Davydhh</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/439">#439</a>) — a <code>WHERE name = “foo”</code> pasted from a chat app no longer fails; curly quotes in filter and sort clauses are normalized to the straight quotes SQL expects.</li>
<li><strong>MiniMax key status</strong> (<a href="https://github.com/octo-patch">@octo-patch</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/454">#454</a>) — the AI settings now show whether a MiniMax API key is configured, like every other provider.</li>
<li><strong>Nightly builds, properly gated</strong> (<a href="https://github.com/NewtTheWolf">@NewtTheWolf</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/419">#419</a>) — signed nightly builds now ship as one tag per build (<code>nightly-&lt;date&gt;-&lt;sha&gt;</code>), cut only from the newest commit that passed CI, laying the groundwork for a selectable nightly update channel.</li>
<li><strong>Linux builds run on older distros again</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/490">#490</a>) — release and nightly Linux builds moved into an Ubuntu 22.04 container, lowering the glibc baseline so the <code>.deb</code> and AppImage keep working on older LTS systems.</li>
<li><strong>Plugin uninstall no longer trips on empty config</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/501">#501</a>) — uninstalling a plugin that never wrote a config crashed on macOS; the null case is now handled.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Eleven external contributors land in v0.16.0.</p>
<p><strong><a href="https://github.com/NewtTheWolf">@NewtTheWolf</a></strong> built the release&#39;s centerpiece: the hosted Tabularium registry and the connection catalogue (<a href="https://github.com/TabularisDB/tabularis/pull/299">#299</a>), plus the gated nightly pipeline (<a href="https://github.com/TabularisDB/tabularis/pull/419">#419</a>) and the row-editor JSON fix (<a href="https://github.com/TabularisDB/tabularis/pull/475">#475</a>). <strong><a href="https://github.com/metalgrid">@metalgrid</a></strong> made Kubernetes tunnels configurable with kubectl and kubeconfig overrides (<a href="https://github.com/TabularisDB/tabularis/pull/365">#365</a>). <strong><a href="https://github.com/pokertour">@pokertour</a></strong> added automatic encrypted backups (<a href="https://github.com/TabularisDB/tabularis/pull/470">#470</a>) and Markdown table export (<a href="https://github.com/TabularisDB/tabularis/pull/481">#481</a>).</p>
<p><strong><a href="https://github.com/maximumbreak">@maximumbreak</a></strong> brought run-at-cursor to the editor (<a href="https://github.com/TabularisDB/tabularis/pull/464">#464</a>). <strong><a href="https://github.com/p4pupro">@p4pupro</a></strong> added AWS RDS IAM authentication for MySQL (<a href="https://github.com/TabularisDB/tabularis/pull/404">#404</a>). <strong><a href="https://github.com/erwin-lovecraft">@erwin-lovecraft</a></strong> built the Elasticsearch driver plugin (<a href="https://github.com/TabularisDB/tabularis/pull/476">#476</a>) and <strong><a href="https://github.com/GabrielMalava">@GabrielMalava</a></strong> the Cloudflare D1 one, registered in <a href="https://github.com/TabularisDB/tabularis/pull/430">#430</a> — and also made the cursor-run behavior configurable (<a href="https://github.com/TabularisDB/tabularis/pull/487">#487</a>).</p>
<p><strong><a href="https://github.com/moduvoice">@moduvoice</a></strong> translated the entire app into Korean (<a href="https://github.com/TabularisDB/tabularis/pull/463">#463</a>). <strong><a href="https://github.com/haos666">@haos666</a></strong> forwarded batch query RPCs to plugin drivers (<a href="https://github.com/TabularisDB/tabularis/pull/443">#443</a>), <strong><a href="https://github.com/Davydhh">@Davydhh</a></strong> normalized smart quotes in filters (<a href="https://github.com/TabularisDB/tabularis/pull/439">#439</a>), and <strong><a href="https://github.com/octo-patch">@octo-patch</a></strong> surfaced the MiniMax key status (<a href="https://github.com/TabularisDB/tabularis/pull/454">#454</a>).</p>
<p>If you&#39;ve been waiting for plugins you can discover and install without leaving the app, if your databases live behind a corporate kubectl, or if you&#39;ve ever wished your connections backed themselves up — this is the upgrade.</p>
<hr>
<p><em>v0.16.0 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.16.0">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0160-hosted-plugin-registry-k8s-overrides-encrypted-backups/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>plugin</category>
      <category>community</category>
      <category>connections</category>
      <category>mysql</category>
      <category>security</category>
      <category>ui</category>
      <category>ux</category>
    </item>
    <item>
      <title>Optimizing a Virtualized React Grid: 3,420 Formatter Calls Down to 90</title>
      <link>https://tabularis.dev/blog/optimizing-virtualized-react-grid</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/optimizing-virtualized-react-grid</guid>
      <pubDate>Tue, 14 Jul 2026 17:43:00 GMT</pubDate>
      <description>The Tabularis data grid was already virtualized, but wide tables still took 29 ms of React render work per scroll tick. The bottleneck was the visible rows, not the total row count.</description>
      <content:encoded><![CDATA[<h1>Optimizing a Virtualized React Grid: 3,420 Formatter Calls Down to 90</h1>
<p>Our React data grid was already virtualized, but it still stuttered on wide tables. In a headless benchmark, a 30-column table took 29.2 ms of React render work on each scroll tick. After the fix, the same test took 4.4 ms.</p>
<p>The problem was not the 1,000 rows in the result set. It was the 38 rows in the virtual window. Every scroll update rendered all of them again, including cells that had not moved.</p>
<p>I fixed this in June for <a href="https://tabularis.dev/blog/v0132-managed-notebooks-live-query-progress-faster-grid">v0.13.2</a>, initially based on the very scientific observation that it felt smoother. For this post I went back, ran the old and new code through the same benchmark, and published the <a href="https://github.com/TabularisDB/website/blob/main/notes/datagrid-bench/grid-bench.test.tsx">harness</a> and <a href="https://github.com/TabularisDB/website/blob/main/notes/datagrid-bench/results.jsonl">raw results</a>.</p>
<p>The short version: at 30 columns, the old grid called <code>formatCellValue</code> 3,420 times per tick. After <a href="https://github.com/TabularisDB/tabularis/pull/287">PR #287</a>, the same tick calls it 90 times.</p>
<h2>Virtualized, and still janky</h2>
<p>The Tabularis results grid has used <a href="https://tanstack.com/virtual">TanStack Virtual</a> since its early versions. With a 600px-tall viewport, 35px rows, and overscan, the DOM holds roughly 28 rows at rest, whether the result set has 500 rows or 100,000. Total row count was not the source of the scrolling problem, and this fix did not change the windowing strategy.</p>
<p>The problem showed up on <em>wide</em> tables. A few hundred rows and 30 to 40 columns is a normal shape for a denormalized reporting table, and scrolling visibly dropped frames. That was confusing at first because &quot;virtualize your list&quot; is the standard answer to slow grids, and we had already done it.</p>
<p>Virtualization limits how much UI exists at once. It does not make the visible subtree cheap. We had bounded the number of rows, but the work per visible row was still too high.</p>
<h2>Where the work was going</h2>
<p>The old <a href="https://github.com/TabularisDB/tabularis/blob/7def318b8de70fdb365e5032da4ae94359749070/src/components/ui/DataGrid.tsx#L1203-L1228"><code>DataGrid.tsx</code></a> rendered rows inline, right inside the component body:</p>
<pre><code class="language-tsx">{rowVirtualizer.getVirtualItems().map((virtualRow) =&gt; {
  // ~560 lines of row and cell JSX, inline in DataGrid
})}
</code></pre>
<p>Scrolling updates the virtualizer, the virtualizer re-renders <code>DataGrid</code>, and because the row JSX is inline, re-rendering <code>DataGrid</code> re-executes the render of every visible row and cell. There was no memo boundary anywhere between &quot;the scroll offset changed&quot; and &quot;re-run the JSX for cell 37 of row 214, which did not change.&quot;</p>
<p>There was more work inside each cell. The old code reached <code>formatCellValue</code>, which turns a raw database value into display text, through three call sites per normal cell render:</p>
<ol>
<li>the TanStack Table <a href="https://github.com/TabularisDB/tabularis/blob/7def318b8de70fdb365e5032da4ae94359749070/src/components/ui/DataGrid.tsx#L701-L720"><code>cell:</code> renderer</a>,</li>
<li>the <a href="https://github.com/TabularisDB/tabularis/blob/7def318b8de70fdb365e5032da4ae94359749070/src/components/ui/DataGrid.tsx#L1369-L1381"><code>title</code> attribute</a> used for the hover preview,</li>
<li>the <a href="https://github.com/TabularisDB/tabularis/blob/7def318b8de70fdb365e5032da4ae94359749070/src/components/ui/DataGrid.tsx#L1509-L1515">main display path</a>, which eventually called the column renderer again.</li>
</ol>
<p>I originally described this as three direct calls in the row loop. That was imprecise. Two calls were explicit in the row loop; the third came through <code>flexRender</code> and the column renderer. The benchmark sees all three because it wraps <code>formatCellValue</code> itself. The duplication accumulated across separate features, and each call site looked reasonable in isolation.</p>
<p>There was another cost too. Every row carried <a href="https://github.com/TabularisDB/tabularis/blob/7def318b8de70fdb365e5032da4ae94359749070/src/components/ui/DataGrid.tsx#L1224-L1229"><code>data-index</code> and <code>ref={rowVirtualizer.measureElement}</code></a>, asking the virtualizer to measure rows dynamically even though normal data rows have a fixed 35px height. <code>MiniResultGrid</code>, the smaller grid in the visual query builder, already used fixed sizes.</p>
<h2>The fix is one memo boundary</h2>
<p>The main change in <a href="https://github.com/TabularisDB/tabularis/pull/287">PR #287</a> was conceptually small: extract the row into <a href="https://github.com/TabularisDB/tabularis/blob/61794dc21f6618f9e8a1fa9687f445423ba38a00/src/components/ui/DataGridRow.tsx"><code>DataGridRow.tsx</code></a> and make <code>React.memo</code> actually hold. You can read the <a href="https://github.com/TabularisDB/tabularis/compare/7def318b8de70fdb365e5032da4ae94359749070...61794dc21f6618f9e8a1fa9687f445423ba38a00">GitHub diff</a> or the <a href="https://github.com/TabularisDB/tabularis/commit/61794dc21f6618f9e8a1fa9687f445423ba38a00.diff">raw <code>.diff</code></a>.</p>
<pre><code class="language-tsx">export const MemoRow = React.memo(function MemoRow(rowCtx: MemoRowProps) {
  // renders one &lt;tr&gt;
});
</code></pre>
<p>The second half of that sentence is the difficult part. <code>React.memo</code> with the default shallow comparison is only as good as the identity of the props you pass. A row needs column metadata, pending edits, handlers, foreign keys, and a translation function. If any one of those props gets a new identity on every parent render, the memo silently does nothing and you are back to rendering everything.</p>
<p>So the props are split by volatility.</p>
<p>Everything that is stable <em>for the whole grid</em> goes into one object, memoized once in <code>DataGrid</code>:</p>
<pre><code class="language-tsx">/**
 * Stable, per-grid dependencies shared by every row. Bundled into a single
 * object that is memoized in DataGrid so React.memo&#39;s default shallow compare
 * on MemoRow only sees a new `ctx` reference when one of these actually changes.
 */
const rowCtx: RowCtx = useMemo(
  () =&gt; ({ columns, pkColumns, pendingChanges, columnTypeMap, /* … */ }),
  [columns, pkColumns, pendingChanges, columnTypeMap, /* … */],
);
</code></pre>
<p>Everything that varies <em>per row</em> is computed in the parent&#39;s <code>.map</code> and passed as primitives:</p>
<pre><code class="language-tsx">&lt;MemoRow
  ctx={rowCtx}
  rowIndex={rowIndex}
  isSelected={isSelected}
  isPendingDelete={isPendingDelete}
  editingColIndex={isRowEditing ? editingCell!.colIndex : null}
  focusedColIndex={isRowFocused ? focusedCell!.colIndex : null}
/&gt;
</code></pre>
<p>The primitive part matters more than it looks. The obvious thing to do is pass <code>editingCell</code>, the <code>{rowIndex, colIndex, value}</code> object from state, straight down. But that object gets a new identity on every keystroke, and shallow compare would then invalidate <em>every</em> row, not just the one being edited. Passing <code>editingColIndex</code> as a number means row 214 receives <code>null</code> before the keystroke and <code>null</code> after it, so the memo holds. The row selection set and pending-deletion map stay out of <code>rowCtx</code> for the same reason: they change identity when one row changes, so each row instead receives its own <code>isSelected</code> and <code>isPendingDelete</code> booleans. The <a href="https://github.com/TabularisDB/tabularis/blob/61794dc21f6618f9e8a1fa9687f445423ba38a00/src/components/ui/DataGrid.tsx#L1320-L1335">actual <code>MemoRow</code> call site</a> shows the full prop split.</p>
<p>Handlers needed one more trick. <code>handleEditCommit</code> and <code>handleKeyDown</code> read the current editing state. Putting <code>editingCell</code> in their <code>useCallback</code> dependencies would give them a new identity per keystroke and invalidate <code>rowCtx</code>, which contains them. I used a ref:</p>
<pre><code class="language-tsx">// Mirror of editingCell so the commit/keydown callbacks can read the latest
// value without listing editingCell in their deps; this keeps their identity
// stable so the memoized rows don&#39;t re-render on every keystroke/scroll.
const editingCellRef = useRef(editingCell);
useEffect(() =&gt; {
  editingCellRef.current = editingCell;
}, [editingCell]);
</code></pre>
<p>The callbacks read <code>editingCellRef.current</code> and keep a stable identity for the life of the grid.</p>
<p>While the row render was being extracted, the three formatter paths collapsed into <a href="https://github.com/TabularisDB/tabularis/blob/61794dc21f6618f9e8a1fa9687f445423ba38a00/src/components/ui/DataGridRow.tsx#L306-L313">one call per cell</a>. Dynamic measurement also went away: the data <code>&lt;tr&gt;</code> gets <code>style={{ height: 35 }}</code>, <code>measureElement</code> and <code>data-index</code> are gone, and the virtualizer keeps its <a href="https://github.com/TabularisDB/tabularis/blob/61794dc21f6618f9e8a1fa9687f445423ba38a00/src/components/ui/DataGrid.tsx#L799-L804"><code>estimateSize: () =&gt; 35</code></a>.</p>
<h2>Proving it, not vibing it</h2>
<p>At the time, I verified this the way most frontend performance work gets verified: opened a wide table, scrolled, said &quot;yeah, that&#39;s better.&quot; For this post I wanted actual numbers, so I built the benchmark I should have built in June.</p>
<p>The setup uses a git worktree pinned to <a href="https://github.com/TabularisDB/tabularis/commit/7def318b8de70fdb365e5032da4ae94359749070"><code>7def318b</code></a>, the commit immediately before the memoization change, and another worktree on <code>main</code> at v0.15.0. Both run the <a href="https://github.com/TabularisDB/website/blob/main/notes/datagrid-bench/grid-bench.test.tsx">same Vitest file</a>. The harness renders the real <code>DataGrid</code>, TanStack virtualizer, and cell components in jsdom, wraps the grid in a React <code>&lt;Profiler&gt;</code>, then drives it with synthetic scroll events and keystrokes.</p>
<p><code>main</code> has picked up smaller grid changes since the original fix, including <a href="https://github.com/TabularisDB/tabularis/commit/f664cd8c31aa9441c6922883e942bd27c4a064c4">precomputed result-color classes</a>. The after numbers therefore describe v0.15.0, not commit <code>61794dc2</code> in isolation. For the original code change, use the <a href="https://github.com/TabularisDB/tabularis/compare/7def318b8de70fdb365e5032da4ae94359749070...61794dc21f6618f9e8a1fa9687f445423ba38a00">before/after diff</a>.</p>
<p>The harness records two metrics:</p>
<ul>
<li><strong>React render duration</strong> per scroll tick, from the Profiler&#39;s <code>actualDuration</code>. I called this &quot;commit time&quot; in the first draft, but that was wrong. React documents it as the time spent rendering the committed update, not the duration of the commit phase.</li>
<li><strong>Calls to <code>formatCellValue</code></strong> per tick, counted by wrapping the real function. In the new code this equals the number of rendered cells because each cell calls it once. In the old code each normally displayed cell called it three times, so this metric is an exact call count, not a direct cell-render count.</li>
</ul>
<p>Both sides run React 19.2.4 on the same machine and Node version. The figures are medians over 7 runs of 30 scroll ticks each. Every tick scrolls 105px, or three rows.</p>
<p>There are important limits. jsdom has no real layout, paint, or GPU. The Profiler figures measure React render work, not browser frame time, style recalculation, or compositing. The dynamic-measurement change is therefore not represented faithfully by this harness. The test also uses React&#39;s development build, so the absolute timings should not be read as production timings. Both variants run in the same environment, which makes the comparison useful, but this is still a bundled before/after benchmark. It does not isolate how many milliseconds came from row memoization, formatter deduplication, fixed row height, or the smaller changes added after PR #287.</p>
<p>Getting jsdom to virtualize at all took some digging. TanStack Virtual measures the scroll container via <code>offsetWidth</code> and <code>offsetHeight</code>, which jsdom reports as zero, so the virtualizer rendered no rows. The harness stubs those getters with a 1200 by 600px viewport and 35px rows. That produces about 38 rendered rows including overscan. It reproduces the windowing behavior needed for this test, but it is not a substitute for a browser benchmark.</p>
<h2>The numbers</h2>
<p>Scrolling, 1,000-row table:</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-datagrid-scroll-commit-time.svg" alt="Grouped bar chart comparing React render duration per scroll tick before and after the fix, at 10, 30 and 50 columns. Before: 8.4, 29.2 and 43.9 milliseconds. After: 2.0, 4.4 and 10.0 milliseconds."></p>
<p>At 30 columns, React&#39;s <code>actualDuration</code> fell from 29.2 ms to 4.4 ms per scroll update. At 50 columns it fell from 43.9 ms to 10.0 ms. These are development-build figures from the headless harness, so the comparison matters more than the absolute values.</p>
<p>The call counts explain the ratio:</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-datagrid-cells-per-tick.svg" alt="Grouped bar chart of formatCellValue calls per scroll tick. Before: 1,140 calls at 10 columns, 3,420 at 30, 5,700 at 50. After: 30, 90 and 150, a 38-fold reduction at every width."></p>
<p>The call counts decompose exactly. The old result is 38 rendered rows × 30 columns × 3 formatter calls per cell = 3,420. A 105px tick brings 3 rows into the new render window, so the new result is 3 × 30 × 1 = 90. The other 35 rows do not render again. The 38-fold reduction combines two changes: unchanged rows now bail out, and each rendered cell formats its value once instead of three times.</p>
<p>Typing is the case I find more satisfying, because it&#39;s where the <code>editingCellRef</code> trick earns its keep:</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-datagrid-typing-per-keystroke.svg" alt="Grouped bar chart of headless update time per keystroke while editing a cell inline. Before: 31.5 milliseconds at 30 columns and 54.9 at 50. After: 3.7 and 4.4 milliseconds."></p>
<p>Before the fix, every keystroke in the inline editor re-rendered all 28 rows in the window. That produced 2,517 formatter calls to display one character: 28 × 30 × 3, minus the three calls for the cell showing an <code>&lt;input&gt;</code>. In the headless test, a 50-column table took 54.9 ms per keystroke. After the fix, only the edited row re-renders, producing 30 formatter calls and a 3.7 ms update at 30 columns.</p>
<p>One mount-time number is also useful. The old grid made 2,520 formatter calls on mount, three per rendered cell. The new grid made 840. Both call counts were identical with 1,000 and 100,000 result rows, confirming that virtualization bounded the rendered window. Total mount time still grew with the result set because TanStack Table had to build its row model, which is a separate cost this change did not address.</p>
<h2>A bad mock invalidated every row</h2>
<p>The first benchmark run exposed a mistake in my setup: <strong>the memoized grid re-rendered all 38 rows on every tick, exactly like the old one.</strong> It made 1,140 formatter calls where I expected 90. There was no error and the test still passed.</p>
<p>The cause was one line in the <a href="https://github.com/TabularisDB/tabularis/blob/810badaf9e1e4ae2e91f15225d8c81cd7a1a3720/tests/setup.ts#L69-L86">shared Vitest setup</a>. It mocked <code>react-i18next</code> like this:</p>
<pre><code class="language-ts">useTranslation: () =&gt; ({
  t: (key: string) =&gt; key,   // a NEW function on every call
}),
</code></pre>
<p>A fresh <code>t</code> function was created on every render. <code>t</code> is a member of <code>rowCtx</code> because the row uses it for <code>NULL</code> labels. A new <code>t</code> produced a new <code>rowCtx</code>, the shallow comparison failed, and every row rendered again. The application did not show this behavior because its <code>t</code> reference remained stable in this scenario. I changed the harness to return a singleton mock, then the expected gap appeared.</p>
<p>A context-object memo is only as strong as its least stable member. One prop with per-render identity, such as a translation function, inline callback, or freshly filtered array, can turn the memo into a no-op. Nothing breaks and React does not warn you. I wrote the component and still got the benchmark wrong on the first run, so reading the code was not enough. The corrected singleton mock is in the <a href="https://github.com/TabularisDB/website/blob/main/notes/datagrid-bench/grid-bench.test.tsx#L50-L58">published harness</a>.</p>
<h2>What&#39;s deliberately not done</h2>
<p>Column virtualization. The grid renders every column of every visible row, so a 200-column table still does 200 columns&#39; worth of cell work per new row. At 50 columns, the headless result was 10.0 ms per tick. The demo stack includes <code>perf_demo.wide_table</code>, with 50 columns and 50,000 rows, seeded for MySQL and Postgres by <a href="https://github.com/TabularisDB/tabularis/blob/810badaf9e1e4ae2e91f15225d8c81cd7a1a3720/demo/generate-perf-sql.py"><code>demo/generate-perf-sql.py</code></a>, so the case is easy to test in a real browser.</p>
<p>Horizontal virtualization has to coexist with a sticky header, a sticky row-number column, and an expansion row that uses <code>colSpan</code>. Virtualizing columns would break that expansion layout unless the editor became an overlay. I have left it out until a real workload justifies that complexity. If you have one, <a href="https://github.com/TabularisDB/tabularis/issues">open an issue</a>.</p>
<h2>If you have a slow virtualized grid</h2>
<p>The checklist I wish I&#39;d had before shipping the janky version, in the order I&#39;d apply it:</p>
<ol>
<li><strong>Count renders before timing them.</strong> Wrap something every cell calls and count invocations per scroll event. If the count tracks <em>visible cells</em> instead of <em>newly visible cells</em>, inspect component invalidation before changing the virtualizer.</li>
<li><strong>Put the memo boundary at the row</strong>, not the cell (too many boundaries) and not the body (too few).</li>
<li><strong>Split props by volatility.</strong> Grid-stable values go in one memoized context object; per-row volatile values go in as primitives, computed in the parent. Never pass the raw <code>editingCell</code>/<code>focusedCell</code> state objects into memoized children.</li>
<li><strong>Stabilize handlers with refs</strong>, not by widening dependency arrays.</li>
<li><strong>If your rows are a fixed height, tell the virtualizer</strong> and delete <code>measureElement</code>. Measurement is for content that actually varies.</li>
<li><strong>Re-verify after refactors.</strong> The memo is invisible when it stops working. A render-count assertion is cheap insurance.</li>
</ol>
<h2>Source and reproduction</h2>
<ul>
<li><a href="https://github.com/TabularisDB/tabularis/pull/287">PR #287</a> and its <a href="https://github.com/TabularisDB/tabularis/pull/287/files">Files changed view</a></li>
<li><a href="https://github.com/TabularisDB/tabularis/compare/7def318b8de70fdb365e5032da4ae94359749070...61794dc21f6618f9e8a1fa9687f445423ba38a00">Before/after GitHub diff</a> and <a href="https://github.com/TabularisDB/tabularis/commit/61794dc21f6618f9e8a1fa9687f445423ba38a00.diff">raw diff</a></li>
<li>Old <a href="https://github.com/TabularisDB/tabularis/blob/7def318b8de70fdb365e5032da4ae94359749070/src/components/ui/DataGrid.tsx"><code>DataGrid.tsx</code></a> and extracted <a href="https://github.com/TabularisDB/tabularis/blob/61794dc21f6618f9e8a1fa9687f445423ba38a00/src/components/ui/DataGridRow.tsx"><code>DataGridRow.tsx</code></a></li>
<li>Benchmark <a href="https://github.com/TabularisDB/website/blob/main/notes/datagrid-bench/README.md">README</a>, <a href="https://github.com/TabularisDB/website/blob/main/notes/datagrid-bench/grid-bench.test.tsx">test harness</a>, <a href="https://github.com/TabularisDB/website/blob/main/notes/datagrid-bench/results.jsonl">raw results</a>, and <a href="https://github.com/TabularisDB/website/blob/main/notes/datagrid-bench/make-charts.mjs">chart generator</a></li>
</ul>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/optimizing-virtualized-react-grid/opengraph-image.png" type="image/png" />
      <category>react</category>
      <category>performance</category>
      <category>data-grid</category>
      <category>frontend</category>
      <category>deep-dive</category>
    </item>
    <item>
      <title>v0.15.0: Bring Your Connections With You — Imports, Nested Groups, and Encrypted Exports</title>
      <link>https://tabularis.dev/blog/v0150-import-connections-nested-groups-encrypted-exports</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0150-import-connections-nested-groups-encrypted-exports</guid>
      <pubDate>Tue, 14 Jul 2026 11:00:00 GMT</pubDate>
      <description>v0.15.0 is the connections release: import saved connections from DBeaver, Beekeeper Studio, TablePlus, DataGrip and Sequel Ace, organize them in nested folders, act on many at once, and export them encrypted — plus ENUM dropdown editing on MySQL and PostgreSQL, an MCP safety fix for EXPLAIN ANALYZE, and Windows that finally stops flashing console windows.</description>
      <content:encoded><![CDATA[<h1>v0.15.0: Bring Your Connections With You — Imports, Nested Groups, and Encrypted Exports</h1>
<p><strong>v0.15.0</strong> follows <a href="https://tabularis.dev/blog/v0140-stored-routines-connection-windows-destructive-query-guard">v0.14.0</a> and has one clear theme: the Connections page stops being a flat list you retype things into and becomes something you can actually <em>manage</em>. You can now import your saved connections from five other SQL clients, file them into folders inside folders, select twenty of them and act on all twenty, and hand a teammate an export that is actually encrypted instead of a JSON full of plaintext passwords. Around that core: ENUM columns become dropdowns on both MySQL and PostgreSQL, the MCP safety layer closes an <code>EXPLAIN ANALYZE</code> loophole, and Windows users stop seeing phantom console windows. Fourteen external contributors land in this tag.</p>
<hr>
<h2>Import Connections From the Client You&#39;re Leaving</h2>
<p>The biggest friction in trying a new database client is retyping every connection you&#39;ve accumulated over the years. PR <a href="https://github.com/TabularisDB/tabularis/pull/393">#393</a> removes it: a new <strong>Import</strong> dropup next to <em>Add Connection</em> reads saved connections from <strong>DBeaver</strong>, <strong>Beekeeper Studio</strong>, <strong>TablePlus</strong>, <strong>DataGrip</strong> and <strong>Sequel Ace</strong> — plus Tabularis&#39;s own JSON exports.</p>
<p>Each source is parsed into a neutral envelope, and credentials are decrypted or read from the source client&#39;s keychain when you ask for them. Nothing is merged blindly: a <strong>preview</strong> lists every connection found, flags duplicates against what you already have (keep, replace, or skip — the duplicate&#39;s existing name shown so you know what you&#39;re replacing), and lets each new connection pick a target group — or create one on the fly, with defaults seeded from the source app&#39;s own folder structure.</p>
<p>The feature ships marked <strong>beta</strong>, with a visible badge and a direct link to <a href="https://github.com/TabularisDB/tabularis/issues">file an issue</a> — parsing five other apps&#39; formats across three platforms is exactly the kind of surface where real-world files will find edge cases. One already got fixed before release: Beekeeper payloads containing non-ASCII characters used to abort the whole import on a byte-boundary panic; they now parse or skip gracefully.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-import-connections.mp4" poster="/videos/posts/tabularis-import-connections.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Folders Inside Folders</h2>
<p>Connection groups were single-level since the day they shipped. PR <a href="https://github.com/TabularisDB/tabularis/pull/405">#405</a>, from <a href="https://github.com/p4pupro">@p4pupro</a>, makes them a real tree: groups can contain groups, to arbitrary depth, in both grid and list view.</p>
<p>The workflow got the attention the data model change deserved:</p>
<ul>
<li><strong>Create paths, not just names.</strong> Typing <code>clients/acme/staging</code> in the New Group input creates the whole chain, reusing existing segments case-insensitively — the same <code>/</code> syntax works in the inline subfolder input on each group header.</li>
<li><strong>Drag to re-parent.</strong> Dropping a group onto another group&#39;s header past one indent step moves it inside; dropping near the left edge keeps the plain reorder. A cycle guard refuses to move a folder into its own descendant, with a clear error rather than silent corruption.</li>
<li><strong>Cascade delete.</strong> Deleting a group now removes its entire subtree — nested groups and their connections included — instead of quietly orphaning children to the root.</li>
<li><strong>Counts that add up.</strong> A folder&#39;s badge sums direct <em>and</em> descendant connections, so a collapsed tree still tells you what&#39;s inside.</li>
</ul>
<p>Existing <code>connections.json</code> files keep working unchanged — <code>parent_id</code> is optional and defaults to root — and the export/import path preserves the hierarchy, demoting any dangling parent reference to root instead of rendering ghost trees.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-nested-groups.mp4" poster="/videos/posts/tabularis-nested-groups.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Select Many, Act Once</h2>
<p>With imports and folders in place, you need a way to move things around in bulk. PR <a href="https://github.com/TabularisDB/tabularis/pull/468">#468</a> adds <strong>multi-select</strong> to the connections list: a checkbox appears on hover, selected cards get a ring, and a pinned action bar shows the count with three actions — <strong>Move to group</strong> (a submenu built from the nested tree, plus <em>Ungrouped</em>), <strong>Delete selected</strong> behind a confirmation with the count, and clear.</p>
<p><a href="https://github.com/pokertour">@pokertour</a> immediately wired the selection into the export flow in PR <a href="https://github.com/TabularisDB/tabularis/pull/469">#469</a>: the action bar gains an <strong>Export</strong> button that writes only the selected connections. The filtering happens <em>before</em> secrets are resolved, so credentials for unselected connections never leave the keychain, and the exported group list is pruned to just the ancestor chains the retained connections actually need.</p>
<p><img src="https://tabularis.dev/img/tabularis-connections-multiselect.png" alt="Three connections selected in the grid, with the pinned action bar showing Export selected, the Move to group submenu open on the nested group tree, and Delete selected"></p>
<hr>
<h2>Exports That Don&#39;t Leak Passwords</h2>
<p>Until now, exporting connections wrote database, SSH and Kubernetes credentials in plaintext behind a single warning dialog. PR <a href="https://github.com/TabularisDB/tabularis/pull/447">#447</a>, also from <a href="https://github.com/pokertour">@pokertour</a>, replaces that with a proper export modal offering three modes:</p>
<ul>
<li><strong>Encrypted with a password</strong> (the default) — the payload is encrypted with <strong>AES-256-GCM</strong> under an <strong>Argon2id</strong>-derived key.</li>
<li><strong>Plain text without passwords</strong> — secrets stripped entirely; fine for sharing a topology.</li>
<li><strong>Plain text with all passwords</strong> — the old behavior, with the warning now attached to the option that deserves it.</li>
</ul>
<p>Import detects the encrypted envelope and prompts for the password before merging. The decryption path is hardened too: the Argon2 parameters read from the envelope are bounded, so a malicious import file can&#39;t request unbounded memory or CPU. Plain exports from older versions import unchanged.</p>
<p><img src="https://tabularis.dev/img/tabularis-export-connections-modes.png" alt="Export Connections modal offering the three export modes, with the encrypted option selected and password fields below"></p>
<hr>
<h2>ENUM Columns Become Dropdowns — on Both MySQL and PostgreSQL</h2>
<p>Editing an ENUM cell used to mean remembering the allowed values and typing one exactly. Two PRs fix that end to end.</p>
<p>On MySQL, <a href="https://github.com/fhriz">@fhriz</a> introspected the full <code>column_type</code> from <code>information_schema</code> in PR <a href="https://github.com/TabularisDB/tabularis/pull/455">#455</a> (closes <a href="https://github.com/TabularisDB/tabularis/issues/452">#452</a>), so a column shows as <code>enum(&#39;pending&#39;,&#39;approved&#39;,&#39;rejected&#39;)</code> instead of a stripped <code>enum</code> — and both the inline grid editor and the row sidebar render a <strong>dropdown</strong> of the allowed values, with a NULL option on nullable columns. SET types get the same treatment, ENUM joins the column-type picker for new tables, and the sidebar now matches column metadata by <em>name</em> rather than position, fixing type mismatches when a query&#39;s SELECT order differs from the table&#39;s.</p>
<p>PostgreSQL followed in PR <a href="https://github.com/TabularisDB/tabularis/pull/471">#471</a> (fixes <a href="https://github.com/TabularisDB/tabularis/issues/465">#465</a>), where the problem ran deeper: editing an enum cell failed outright with SQLSTATE 42804 — <em>column is of type X but expression is of type text</em> — because the bound value was never cast to the enum type. The binding layer now wraps enum parameters in a <code>CAST($N AS &quot;schema&quot;.&quot;type&quot;)</code> with the qualified type name resolved from <code>pg_catalog</code>, mirroring the existing temporal/UUID coercions. And since column introspection now aggregates labels from <code>pg_enum</code> into the same <code>enum(&#39;a&#39;,&#39;b&#39;,...)</code> shape MySQL uses, the dropdown editor kicks in for PostgreSQL automatically — grid and sidebar both.</p>
<p><img src="https://tabularis.dev/img/tabularis-enum-dropdown.png" alt="Editing a MySQL SET cell inline: the grid shows a dropdown of the allowed values with checkboxes and a NULL option"></p>
<hr>
<h2>MCP Safety: EXPLAIN ANALYZE Is Not a Read</h2>
<p>The MCP safety layer classifies queries so read-only mode and the write-approval prompt can gate them. Its classifier mapped anything starting with <code>EXPLAIN</code> to the read-only path — but <code>EXPLAIN ANALYZE DELETE ...</code> actually <em>executes</em> the DELETE. An AI agent could therefore slip a write past both gates by wrapping it. Thanks to <a href="https://github.com/daniel-mertz">@daniel-mertz</a> for reporting it; PR <a href="https://github.com/TabularisDB/tabularis/pull/456">#456</a> makes classification option-aware: a plain <code>EXPLAIN</code> stays a read (it only plans), while the presence of the <code>ANALYZE</code> option classifies the wrapped statement as if it had been submitted directly. Matching is word-boundary aware — a table named <code>analyze_runs</code> doesn&#39;t trip it — and unbalanced option lists fail closed.</p>
<p>Two more MCP improvements landed alongside it. <a href="https://github.com/erneztox">@erneztox</a> fixed <code>list_connections</code> to serialize <em>all</em> of a connection&#39;s databases instead of collapsing them, and added a dedicated <strong><code>list_databases</code></strong> tool in PR <a href="https://github.com/TabularisDB/tabularis/pull/426">#426</a>, complete with AI Activity filtering and documentation across all translated READMEs. And the repo now has a <a href="https://github.com/TabularisDB/tabularis/blob/main/SECURITY.md">security policy</a> documenting how to report vulnerabilities privately — with MCP safety-layer bypasses under an untrusted-input threat model explicitly called in scope.</p>
<hr>
<h2>Windows Stops Flashing Terminals</h2>
<p>Two independent contributors fixed the same class of Windows papercut in the same release. Launching a console executable from a GUI app on Windows allocates a visible console window — so an SSH tunnel popped an <code>ssh.exe</code> terminal, and starting a plugin popped another.</p>
<p><a href="https://github.com/kennelken">@kennelken</a> fixed the SSH side in PR <a href="https://github.com/TabularisDB/tabularis/pull/418">#418</a> (fixes <a href="https://github.com/TabularisDB/tabularis/issues/413">#413</a>), spawning <code>ssh.exe</code> with the <code>CREATE_NO_WINDOW</code> flag — and went further: SSH child processes used to outlive the app, so the fix also hooks Tauri&#39;s exit event to tear down every active tunnel when Tabularis closes. No more orphaned <code>ssh.exe</code> in Task Manager. <a href="https://github.com/erwin-lovecraft">@erwin-lovecraft</a> applied the same <code>CREATE_NO_WINDOW</code> treatment to plugin processes in PR <a href="https://github.com/TabularisDB/tabularis/pull/451">#451</a>, keeping the stdin/stdout pipe communication intact.</p>
<hr>
<h2>Grid and Editor Refinements</h2>
<ul>
<li><strong>Column headers actually stay pinned.</strong> <a href="https://github.com/thomaswasle">@thomaswasle</a> fixed the result grid&#39;s sticky header in PR <a href="https://github.com/TabularisDB/tabularis/pull/433">#433</a> — the old implementation fought the virtualizer with a counter-transform that desynced after ~40 rows of scrolling. The grid now uses spacer rows so <code>position: sticky</code> works unconditionally, and a <em>Sticky column headers</em> toggle in Settings → Appearance keeps the old behavior available.</li>
<li><strong>Hover a header, see the type.</strong> <a href="https://github.com/benedettoraviotta">@benedettoraviotta</a> added a DataGrip-style tooltip in PR <a href="https://github.com/TabularisDB/tabularis/pull/436">#436</a> (closes <a href="https://github.com/TabularisDB/tabularis/issues/435">#435</a>) showing <code>name: type</code> — e.g. <code>id: uuid</code> — on column header hover, read from metadata already in hand, so it costs no backend round-trip.</li>
<li><strong>Set Empty knows its limits.</strong> The quick action used to write a single space to <em>any</em> non-BLOB column, which strongly-typed columns rejected (<code>column is of type uuid but expression is of type text</code>). PR <a href="https://github.com/TabularisDB/tabularis/pull/442">#442</a> gates it to textual columns, writes a real empty string, and swaps its icon for an eraser.</li>
<li><strong>Summon IntelliSense on demand.</strong> <a href="https://github.com/GabrielMalava">@GabrielMalava</a> added a configurable shortcut in PR <a href="https://github.com/TabularisDB/tabularis/pull/371">#371</a> to force the SQL editor&#39;s suggestion widget open (⌘I on macOS, Ctrl+I on Windows, Ctrl+Space on Linux) — remappable in Settings → Keyboard Shortcuts, working in both the editor and notebook cells — plus a new rebindable <strong>refresh table</strong> keybinding.</li>
<li><strong>Disconnected means disconnected.</strong> Disconnecting your last open connection didn&#39;t persist the change, so the next launch auto-reconnected and restored its tabs anyway. PR <a href="https://github.com/TabularisDB/tabularis/pull/467">#467</a> persists the session at disconnect time; the saved tab file stays on disk, so <em>manually</em> reconnecting later still restores your queries.</li>
</ul>
<hr>
<h2>Tabularis Speaks Tagalog</h2>
<p><a href="https://github.com/chriscupas">@chriscupas</a> contributed a complete <strong>Tagalog</strong> locale in PR <a href="https://github.com/TabularisDB/tabularis/pull/457">#457</a> — 1,574 translated strings, a translated README, and browser-locale detection that maps Filipino (<code>fil</code>) systems to it automatically. That makes nine UI languages. <a href="https://github.com/pokertour">@pokertour</a> also brought <strong>French</strong> back to parity in PR <a href="https://github.com/TabularisDB/tabularis/pull/445">#445</a>, adding the 66 keys that had accumulated in English only — triggers, result colors, timezone settings and more.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>Typo-tolerant connection search</strong> (<a href="https://github.com/Davydhh">@Davydhh</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/444">#444</a>) — the connection search joins the table filters and Quick Navigator on Fuse.js fuzzy matching, so <code>postgre prod</code> finds what you meant, ranked by closeness.</li>
<li><strong>SSH connections get a Settings tab</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/441">#441</a>) — saved SSH tunnels are now manageable from Settings, not just from inside the connection-creation modal, and deleting one warns how many database connections still reference it.</li>
<li><strong>No more autocorrect in filters</strong> (<a href="https://github.com/Necriso">@Necriso</a>, PRs <a href="https://github.com/TabularisDB/tabularis/pull/432">#432</a> and <a href="https://github.com/TabularisDB/tabularis/pull/437">#437</a>) — spellcheck, autocorrect and autocapitalize are off in the table toolbar and filter inputs, where they never belonged.</li>
<li><strong>Visual fixes</strong> (<a href="https://github.com/math-krish">@math-krish</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/461">#461</a>) — auto-pagination colors (fixes <a href="https://github.com/TabularisDB/tabularis/issues/448">#448</a>), dropdown menu font (fixes <a href="https://github.com/TabularisDB/tabularis/issues/446">#446</a>), and the save menu that appears on edit.</li>
<li><strong>Modal consistency pass</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/440">#440</a>) — the last <code>window.confirm()</code> calls replaced with the app&#39;s ConfirmModal, Escape now closes the trigger/routine/explain modals like every other one, primary fields autofocus, and a batch of hardcoded strings moved to i18n across all locales.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Fourteen external contributors land in v0.15.0 — the widest tag yet.</p>
<p><strong><a href="https://github.com/pokertour">@pokertour</a></strong> owns the export story: password-encrypted connection exports (<a href="https://github.com/TabularisDB/tabularis/pull/447">#447</a>), export-only-the-selection (<a href="https://github.com/TabularisDB/tabularis/pull/469">#469</a>), and the French translation catch-up (<a href="https://github.com/TabularisDB/tabularis/pull/445">#445</a>). <strong><a href="https://github.com/p4pupro">@p4pupro</a></strong> built nested connection groups (<a href="https://github.com/TabularisDB/tabularis/pull/405">#405</a>). <strong><a href="https://github.com/chriscupas">@chriscupas</a></strong> translated the entire app into Tagalog (<a href="https://github.com/TabularisDB/tabularis/pull/457">#457</a>).</p>
<p><strong><a href="https://github.com/fhriz">@fhriz</a></strong> brought ENUM dropdown editing to MySQL (<a href="https://github.com/TabularisDB/tabularis/pull/455">#455</a>). <strong><a href="https://github.com/kennelken">@kennelken</a></strong> silenced the Windows SSH console window and cleaned up tunnels on exit (<a href="https://github.com/TabularisDB/tabularis/pull/418">#418</a>); <strong><a href="https://github.com/erwin-lovecraft">@erwin-lovecraft</a></strong> did the same for plugin processes (<a href="https://github.com/TabularisDB/tabularis/pull/451">#451</a>). <strong><a href="https://github.com/thomaswasle">@thomaswasle</a></strong> fixed the sticky result headers (<a href="https://github.com/TabularisDB/tabularis/pull/433">#433</a>), <strong><a href="https://github.com/benedettoraviotta">@benedettoraviotta</a></strong> added the column-type tooltip (<a href="https://github.com/TabularisDB/tabularis/pull/436">#436</a>), and <strong><a href="https://github.com/GabrielMalava">@GabrielMalava</a></strong> added the IntelliSense trigger shortcut (<a href="https://github.com/TabularisDB/tabularis/pull/371">#371</a>).</p>
<p><strong><a href="https://github.com/erneztox">@erneztox</a></strong> fixed multi-database serialization in MCP and added <code>list_databases</code> (<a href="https://github.com/TabularisDB/tabularis/pull/426">#426</a>). <strong><a href="https://github.com/Davydhh">@Davydhh</a></strong> made the connection search fuzzy (<a href="https://github.com/TabularisDB/tabularis/pull/444">#444</a>), <strong><a href="https://github.com/Necriso">@Necriso</a></strong> killed autocorrect in the filters (<a href="https://github.com/TabularisDB/tabularis/pull/432">#432</a>, <a href="https://github.com/TabularisDB/tabularis/pull/437">#437</a>), and <strong><a href="https://github.com/math-krish">@math-krish</a></strong> cleaned up pagination colors, the dropdown font and the save menu (<a href="https://github.com/TabularisDB/tabularis/pull/461">#461</a>). And thanks to <strong><a href="https://github.com/daniel-mertz">@daniel-mertz</a></strong> for responsibly reporting the <code>EXPLAIN ANALYZE</code> safety bypass.</p>
<p>If you&#39;ve been meaning to <a href="https://tabularis.dev/compare/dbeaver-alternative">switch from DBeaver</a> but dreaded retyping thirty connections, if your team shares connection files, or if your database leans on ENUMs — this is the upgrade.</p>
<hr>
<p><em>v0.15.0 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.15.0">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0150-import-connections-nested-groups-encrypted-exports/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>connections</category>
      <category>security</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>ui</category>
      <category>ux</category>
      <category>community</category>
    </item>
    <item>
      <title>Tabularis Joins the SignPath.io Open Source Program</title>
      <link>https://tabularis.dev/blog/signpath-code-signing</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/signpath-code-signing</guid>
      <pubDate>Fri, 10 Jul 2026 09:00:00 GMT</pubDate>
      <description>Tabularis has been accepted into the SignPath.io open source program: a free code signing certificate from the SignPath Foundation for our Windows releases. Over the next few weeks we&apos;ll wire signing into the release pipeline — and then the days of telling users to click &apos;More info → Run anyway&apos; are over.</description>
      <content:encoded><![CDATA[<h1>Tabularis Joins the SignPath.io Open Source Program</h1>
<p style="text-align:center;margin:1.5rem 0 2rem;"><img class="no-lightbox borderless" src="https://tabularis.dev/img/posts/signpath-partnership.svg" alt="Tabularis has joined the SignPath.io open source program" style="width:100%;max-width:800px;height:auto;display:block;margin:0 auto;" /></p>

<p>If you&#39;ve ever installed Tabularis on Windows, you&#39;ve probably met the blue screen. Not <em>that</em> blue screen — the other one. &quot;Windows protected your PC.&quot; The one where a perfectly ordinary open-source database client gets treated like something you downloaded from a forum signature in 2009, and the install instructions have to include the phrase <em>click &quot;More info&quot;, then &quot;Run anyway&quot;</em>.</p>
<p>That screen exists because unsigned binaries are, from Windows&#39; point of view, anonymous. And it goes away with a code signing certificate — which, for years, has been the single most disproportionate expense an open-source desktop project can face. Hundreds of dollars a year, identity validation paperwork, and increasingly a hardware token requirement, all to prove that a project whose entire source code is public is not hiding anything.</p>
<p>So here&#39;s the news: <strong>Tabularis has been accepted into the <a href="https://signpath.io">SignPath.io</a> open source program.</strong> The <a href="https://signpath.org">SignPath Foundation</a> provides free code signing certificates to qualifying open-source projects — and over the next few weeks, we&#39;ll be working on integrating SignPath into our release pipeline so that Windows builds ship signed.</p>
<h2>What SignPath actually does (and why it&#39;s clever)</h2>
<p>The obvious version of this program would be &quot;here&#39;s a certificate file, good luck.&quot; SignPath&#39;s version is better, and it&#39;s worth explaining why.</p>
<p>The private key never touches our machines. It lives on SignPath&#39;s Hardware Security Modules, and signing happens as a step in the release pipeline: CI builds the artifacts from the public repository, SignPath verifies that the binary being signed actually came from that repository, and only then applies the signature. The certificate doesn&#39;t just say &quot;someone signed this&quot; — it says <em>this exact binary was built from that exact public source tree, and the SignPath Foundation vouches for the link</em>.</p>
<p>For users, that&#39;s a stronger promise than most commercial software makes. You can read the code, and you can verify the thing you downloaded is that code. No trust-us step in the middle.</p>
<p>For us, it means no key material sitting on a build machine waiting to be leaked, no USB token taped inside someone&#39;s desk drawer, and no yearly renewal invoice for a nights-and-weekends project.</p>
<h2>The honest part: it&#39;s not wired up yet</h2>
<p>Being accepted is the milestone; the engineering starts now. That provenance guarantee — <em>this binary came from that repository</em> — is exactly what makes the integration non-trivial. SignPath doesn&#39;t sign whatever you upload; it signs what your pipeline provably built. Which means the work ahead of us looks like this:</p>
<ul>
<li><strong>Restructuring the release workflow</strong> so the Windows artifacts flow from CI through SignPath and back before they&#39;re published — signing becomes a pipeline stage, not an afterthought.</li>
<li><strong>Getting the build to verify cleanly.</strong> The link between the public repo and the submitted binary has to hold up to SignPath&#39;s checks, and desktop app builds have a way of accumulating steps that make provenance harder to trace than it should be.</li>
<li><strong>Not breaking releases while we do it.</strong> Tabularis ships roughly every two weeks, and that cadence doesn&#39;t pause for plumbing work.</li>
</ul>
<p>We&#39;d rather tell you this now and write the &quot;it&#39;s live&quot; post when it&#39;s actually live, than quietly flip a switch and hope nobody checks the dates. If you&#39;re curious how it goes, the work will happen in the open like everything else — watch the <a href="https://github.com/TabularisDB/tabularis">repo</a>.</p>
<h2>What will change for you</h2>
<p>Once the integration lands, if you&#39;re on Windows:</p>
<ul>
<li><strong>SmartScreen stops interrogating you.</strong> The installer will carry a valid signature, so new releases install like any other signed software — no &quot;unknown publisher&quot;, no &quot;Run anyway&quot;.</li>
<li><strong>The publisher name means something.</strong> The signature identifies the build as coming from the Tabularis open-source repository, certified by the SignPath Foundation.</li>
<li><strong>Tampering is detectable.</strong> If a downloaded installer has been modified anywhere between our CI and your disk, the signature breaks and Windows tells you.</li>
</ul>
<p>If you&#39;re on macOS or Linux, nothing changes today — but fewer scary dialogs on any platform makes the whole project easier to recommend, and that helps everyone.</p>
<p>In line with the program&#39;s terms, we&#39;ll be adding the credit to our README: free code signing provided by <a href="https://signpath.io">SignPath.io</a>, with a certificate from the <a href="https://signpath.org">SignPath Foundation</a>. It&#39;s the easiest attribution requirement we&#39;ve ever agreed to, given that the alternative was a recurring bill.</p>
<h2>The usual thank-you, because it keeps being deserved</h2>
<p>To the SignPath team: thank you for running a program that fixes a genuinely broken part of open-source distribution, and for an acceptance process that was thorough about the right things — code provenance and project health — rather than paperwork.</p>
<p>And to everyone who&#39;s starred the repo, filed a bug, built a plugin, or translated a string: programs like this accept projects that look alive, and Tabularis looks alive because of you. One &quot;Run anyway&quot; at a time, we&#39;re becoming real software.</p>
<hr>
<p><em>The Tabularis Team</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/signpath-code-signing/opengraph-image.png" type="image/png" />
      <category>community</category>
      <category>sponsors</category>
      <category>partnership</category>
      <category>open-source</category>
      <category>windows</category>
    </item>
    <item>
      <title>v0.14.0: Stored Routines, Windows of Their Own, and a Guard Against the Unrecoverable</title>
      <link>https://tabularis.dev/blog/v0140-stored-routines-connection-windows-destructive-query-guard</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0140-stored-routines-connection-windows-destructive-query-guard</guid>
      <pubDate>Tue, 07 Jul 2026 15:10:00 GMT</pubDate>
      <description>v0.14.0 opens the 0.14 line: manage stored procedures and functions from the sidebar, pop a connection into its own window, browse PostgreSQL materialized views, and get a confirmation dialog before a DELETE without a WHERE — plus typo-tolerant fuzzy search everywhere and a stack of Postgres/MySQL correctness fixes.</description>
      <content:encoded><![CDATA[<h1>v0.14.0: Stored Routines, Windows of Their Own, and a Guard Against the Unrecoverable</h1>
<p><strong>v0.14.0</strong> bumps the minor line because it stops being a database <em>viewer</em> in a few more places and starts being a database <em>client</em>. It manages stored procedures and functions the way it already manages tables and views, it lets a connection live in its own window, it browses PostgreSQL materialized views, and — the change everyone will feel eventually — it asks before you run the query that wipes a table. It follows <a href="https://tabularis.dev/blog/v0134-ssh-security-keys-detachable-results-smarter-editor">v0.13.4</a>, and like that release most of the surface area came from the community: <a href="https://github.com/Davydhh">@Davydhh</a> alone lands six of the changes below.</p>
<hr>
<h2>Manage Stored Routines, Not Just Tables</h2>
<p>Stored procedures and functions have always been visible in the sidebar. As of v0.14.0 you can actually <em>do</em> something with them. PR <a href="https://github.com/TabularisDB/tabularis/pull/416">#416</a> adds full routine management — run, create, edit, and drop — behind a new <code>routine_management</code> driver capability enabled for MySQL and PostgreSQL out of the box.</p>
<ul>
<li><strong>Run with parameters.</strong> A modal collects each <code>IN</code>/<code>INOUT</code> value — with a NULL checkbox and a raw-value toggle that defaults on for numeric types — and the driver assembles a <em>reviewable</em> invocation script before anything executes. MySQL <code>OUT</code>/<code>INOUT</code> parameters are threaded through session variables (<code>SET</code> / <code>CALL</code> / <code>SELECT @var</code>); PostgreSQL functions run as <code>SELECT * FROM fn(...)</code>, so a set-returning function comes back as an actual result set.</li>
<li><strong>Create from a template.</strong> A dialect-aware starter script — <code>DELIMITER</code>-wrapped for MySQL, <code>CREATE OR REPLACE</code> with dollar-quoting for PostgreSQL — so you&#39;re editing a valid skeleton, not a blank buffer.</li>
<li><strong>Edit the definition.</strong> A re-runnable script: MySQL wraps <code>SHOW CREATE</code> in a <code>DROP IF EXISTS</code> + <code>DELIMITER</code> block, PostgreSQL reuses <code>pg_get_functiondef</code>.</li>
<li><strong>Drop with confirmation.</strong> PostgreSQL resolves the exact identity signature via <code>pg_get_function_identity_arguments</code> and refuses to guess between overloads rather than dropping the wrong one.</li>
</ul>
<p>For plugin drivers the four new methods are <em>optional</em> JSON-RPC calls: when a plugin doesn&#39;t implement one, the host falls back to the same generic SQL, so a driver only overrides what its dialect actually needs. The manifest schema documents the capability.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-run-routine.mp4" poster="/videos/posts/tabularis-run-routine.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>A Connection in Its Own Window</h2>
<p>The results panel learned to detach in v0.13.4. In v0.14.0 the connection itself can. PR <a href="https://github.com/TabularisDB/tabularis/pull/409">#409</a> adds an <strong>Open in New Window</strong> action to both connection context menus — the open-connections sidebar rail and the Connections page — that spins a connection out into its own standalone OS window.</p>
<p>The plumbing is the interesting part. Connectivity is validated <em>before</em> the window is spawned: the connection is test-connected first and the window is only created on success, so a failing connection surfaces its error in the window you&#39;re already in rather than a freshly-opened empty one. A connection opened in a new window is <strong>owned</strong> by that window and detached from the originating sidebar rail, though its process-global pool stays warm and is reused. Open-state is shared across every window — a connection open anywhere shows as open in every window&#39;s Connections page, broadcast via a new <code>connections:active-changed</code> event. Disconnecting closes the dedicated window (the main window is never auto-closed), and closing a dedicated window tears its connection down so nothing leaks.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-open-in-window.mp4" poster="/videos/posts/tabularis-open-in-window.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Confirm Before You Wipe a Table</h2>
<p>A <code>DELETE</code> or <code>UPDATE</code> with the <code>WHERE</code> clause forgotten is one of the oldest ways to lose data with no way back. PR <a href="https://github.com/TabularisDB/tabularis/pull/429">#429</a> puts a guard in front of it. Both the SQL editor and notebook cells now detect a <code>DELETE</code>/<code>UPDATE</code> with no <code>WHERE</code>, as well as <code>DROP</code> and <code>TRUNCATE</code>, and ask for confirmation before the query is sent.</p>
<p>The detection isn&#39;t a naive substring match: it looks past comments and string literals (including backslash-escaped quotes), understands data-modifying CTEs, and handles multi-statement batches. The dialog shows kind-specific copy and a read-only preview of the exact statement it flagged, and the confirm button stays disabled for a five-second countdown — long enough that the warning is actually read instead of reflexively dismissed.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-confirmation.mp4" poster="/videos/posts/tabularis-confirmation.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>PostgreSQL Materialized Views</h2>
<p><a href="https://github.com/Davydhh">@Davydhh</a> added first-class <strong>materialized view</strong> support for PostgreSQL in PR <a href="https://github.com/TabularisDB/tabularis/pull/342">#342</a>. They show up as their own sidebar group (collapsed by default), gated on a driver capability so only databases that have them render the section. You get an in-flight spinner while a refresh runs, the same index-list rendering that tables use, and the view&#39;s columns — resolved against the active schema. They&#39;re read-only in the data grid, as a materialized view should be.</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-materialized-views.png" alt="PostgreSQL materialized views listed in their own sidebar group, created from CREATE MATERIALIZED VIEW statements in the console"></p>
<hr>
<h2>Find Things by Typo</h2>
<p>Three changes, all from <a href="https://github.com/Davydhh">@Davydhh</a>, make finding an object forgiving of how you actually type.</p>
<p>The table and trigger filters swapped their substring match for Fuse.js fuzzy matching in PR <a href="https://github.com/TabularisDB/tabularis/pull/417">#417</a>, so <code>ordrs</code> still finds <code>orders</code> and the closest names rank first — applied across the schema, per-database, and flat sidebar layouts through one shared helper. The <strong>Quick Navigator</strong> got the same treatment in PR <a href="https://github.com/TabularisDB/tabularis/pull/421">#421</a>. And PR <a href="https://github.com/TabularisDB/tabularis/pull/412">#412</a> adds a rebindable <strong>focus-table-filter</strong> shortcut (⌘⇧F / Ctrl+Shift+F) that jumps straight to the filter box in whichever layout is showing, even while another input has focus.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-fuzzy-search.mp4" poster="/videos/posts/tabularis-fuzzy-search.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Know Exactly How Many Rows</h2>
<p>The pager used to tell you which page you were on but not how big the result was. PR <a href="https://github.com/TabularisDB/tabularis/pull/410">#410</a>, also from <a href="https://github.com/Davydhh">@Davydhh</a>, displays the exact <code>{total} rows</code> next to the page indicator — and counts the <em>right</em> query. The old count ran against the base table and ignored the filter box&#39;s <code>WHERE</code>, so a filtered grid reported the unfiltered total; it now counts the reconstructed filtered query. The count resets when the query or filter changes so a stale number from a previous run never lingers, but it&#39;s preserved across pagination.</p>
<p><img src="https://tabularis.dev/img/posts/tabularis-total-row-count.png" alt="A filtered employees grid showing 16 rows next to the page indicator, matching the WHERE id greater-than 2 filter"></p>
<hr>
<h2>MySQL Through Warpgate</h2>
<p><a href="https://github.com/pokertour">@pokertour</a> added an opt-in <strong>cleartext password plugin</strong> toggle for MySQL/MariaDB in PR <a href="https://github.com/TabularisDB/tabularis/pull/337">#337</a> (closes <a href="https://github.com/TabularisDB/tabularis/issues/336">#336</a>), so connections can authenticate through bastions like Warpgate that require the <code>mysql_clear_password</code> auth plugin. Because those bastions proxy MySQL without implementing the prepared-statement protocol, any prepared query failed with server error 1047; when the toggle is on, the driver routes everything through the text protocol instead. The option is gated on an <em>enforced</em> TLS mode — cleartext credentials are refused over a link that could silently fall back to plaintext — and string literals become <code>sql_mode</code>-aware so <code>NO_BACKSLASH_ESCAPES</code> targets escape correctly. A pool cache-key collision that let two Warpgate targets behind one host:port share a pool was fixed at the same time.</p>
<hr>
<h2>Correctness, Across the Board</h2>
<ul>
<li><strong>Temporal and UUID cell edits on PostgreSQL</strong> (<a href="https://github.com/NewtTheWolf">@NewtTheWolf</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/408">#408</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/401">#401</a>) — editing a <code>timestamp</code>/<code>timestamptz</code>/<code>date</code>/<code>time</code>/<code>interval</code> cell failed with <code>error serializing parameter</code> because the client inferred the parameter type from the cast target and rejected the bound string before PostgreSQL&#39;s own parsing ran. Every bound parameter now declares its wire type explicitly via <code>prepare_typed</code>/<code>execute_typed</code>, which also finishes the UUID-shaped-string fix that <a href="https://github.com/TabularisDB/tabularis/pull/394">#394</a> only started. Verified against a live PostgreSQL 16.</li>
<li><strong>Composite indexes in generated SQL</strong> (<a href="https://github.com/snvtac">@snvtac</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/373">#373</a>) — multi-column indexes are preserved in the SQL Tabularis generates instead of being flattened to single-column ones.</li>
<li><strong>MySQL view DDL through the text protocol</strong> (<a href="https://github.com/danielnuld">@danielnuld</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/390">#390</a>) — view DDL is routed through the text protocol so it executes correctly.</li>
<li><strong>MySQL routine DDL through the text protocol</strong> (<a href="https://github.com/Stiwar0098">@Stiwar0098</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/348">#348</a>) — the same treatment for routine DDL, and compound routines are now classified as DDL in the MCP layer (<a href="https://github.com/TabularisDB/tabularis/pull/385">#385</a>).</li>
<li><strong>Qualified view definitions on PostgreSQL</strong> (<a href="https://github.com/debba">@debba</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/400">#400</a>) — <code>pg_get_viewdef</code> was called with <code>$1::regclass</code> directly, which failed for a bound text value; casting through text first (<code>($1::text)::regclass</code>) resolves qualified view names correctly.</li>
<li><strong>Plugin driver filters in the connection modal</strong> (<a href="https://github.com/debba">@debba</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/424">#424</a>) — a missing <code>driver</code> field made serde silently drop the filter on <code>ui_extensions</code> manifest entries, so every plugin&#39;s connection-content contribution rendered for every driver and plugins could overwrite each other&#39;s connection state. The filter is preserved now.</li>
</ul>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>Node 24</strong> (<a href="https://github.com/Davydhh">@Davydhh</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/422">#422</a>) — the toolchain moves to Node 24.</li>
<li><strong>Sidebar filter clear button</strong> (<a href="https://github.com/viniciusmelocodes">@viniciusmelocodes</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/403">#403</a>) — the clear (X) button no longer overlaps the scrollbar in the table filter.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Most of v0.14.0 is community work again.</p>
<p><strong><a href="https://github.com/Davydhh">@Davydhh</a></strong> carried an outsized share of this release: PostgreSQL materialized views (<a href="https://github.com/TabularisDB/tabularis/pull/342">#342</a>), fuzzy matching for the table and trigger filters (<a href="https://github.com/TabularisDB/tabularis/pull/417">#417</a>), fuzzy search in the Quick Navigator (<a href="https://github.com/TabularisDB/tabularis/pull/421">#421</a>), the focus-table-filter shortcut (<a href="https://github.com/TabularisDB/tabularis/pull/412">#412</a>), the total-row-count display (<a href="https://github.com/TabularisDB/tabularis/pull/410">#410</a>), and the Node 24 upgrade (<a href="https://github.com/TabularisDB/tabularis/pull/422">#422</a>).</p>
<p><strong><a href="https://github.com/pokertour">@pokertour</a></strong> added MySQL cleartext-auth support for Warpgate bastions (<a href="https://github.com/TabularisDB/tabularis/pull/337">#337</a>). <strong><a href="https://github.com/NewtTheWolf">@NewtTheWolf</a></strong> fixed temporal and UUID cell edits on PostgreSQL via explicit wire types (<a href="https://github.com/TabularisDB/tabularis/pull/408">#408</a>). <strong><a href="https://github.com/Stiwar0098">@Stiwar0098</a></strong> routed MySQL routine DDL through the text protocol (<a href="https://github.com/TabularisDB/tabularis/pull/348">#348</a>) and classified compound routines as DDL in the MCP layer (<a href="https://github.com/TabularisDB/tabularis/pull/385">#385</a>).</p>
<p><strong><a href="https://github.com/snvtac">@snvtac</a></strong> preserved composite indexes in generated SQL (<a href="https://github.com/TabularisDB/tabularis/pull/373">#373</a>), <strong><a href="https://github.com/danielnuld">@danielnuld</a></strong> routed MySQL view DDL through the text protocol (<a href="https://github.com/TabularisDB/tabularis/pull/390">#390</a>), and <strong><a href="https://github.com/viniciusmelocodes">@viniciusmelocodes</a></strong> fixed the sidebar filter&#39;s clear button (<a href="https://github.com/TabularisDB/tabularis/pull/403">#403</a>).</p>
<p>If you manage stored procedures, spread work across monitors, run against PostgreSQL materialized views or a Warpgate-fronted MySQL, or have ever typed a <code>DELETE</code> faster than you meant to — this is the upgrade.</p>
<hr>
<p><em>v0.14.0 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.14.0">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0140-stored-routines-connection-windows-destructive-query-guard/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>ui</category>
      <category>ux</category>
      <category>plugin</category>
      <category>community</category>
    </item>
    <item>
      <title>Tabularis Joins the JetBrains Open Source Program</title>
      <link>https://tabularis.dev/blog/jetbrains-open-source-program</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/jetbrains-open-source-program</guid>
      <pubDate>Mon, 06 Jul 2026 09:00:00 GMT</pubDate>
      <description>JetBrains accepted Tabularis into its Open Source Support program and handed the maintainers a free All Products Pack — which means the editor we write Tabularis in is now, technically, invested in Tabularis existing.</description>
      <content:encoded><![CDATA[<h1>Tabularis Joins the JetBrains Open Source Program</h1>
<p style="text-align:center;margin:1.5rem 0 2rem;"><img class="no-lightbox borderless" src="https://tabularis.dev/img/posts/jetbrains-partnership.svg" alt="Tabularis has joined the JetBrains Open Source Support program" style="width:100%;max-width:800px;height:auto;display:block;margin:0 auto;" /></p>

<p>There&#39;s a small loop closing here that we find genuinely funny: several of the people who write Tabularis do it inside a JetBrains IDE, and now JetBrains is backing the project on the other side of that screen. <strong>We&#39;ve been accepted into the <a href="https://www.jetbrains.com/community/opensource/">JetBrains Open Source Support program</a></strong>, which comes with a free annual All Products Pack for the maintainers — every IDE they make, one license.</p>
<p>IntelliJ IDEA, WebStorm, PyCharm, GoLand, RustRover. Chances are you&#39;ve had one of these open recently too. Unlike some of the sponsors we&#39;ve written about here, this isn&#39;t a product we discovered because of the deal. We already had it installed.</p>
<p>That&#39;s really the whole story, and it&#39;s a short one, so we&#39;ll spend the rest of this post on the part that&#39;s more interesting than the announcement itself.</p>
<h2>When the tool sponsors the tool</h2>
<p>DigitalOcean&#39;s credits went into infrastructure nobody using Tabularis will ever see. Vercel&#39;s program upgraded the pipeline behind this very website. Both are real and useful and mostly invisible. This one is different in a way that&#39;s hard to put a dollar figure on: it&#39;s not infrastructure, it&#39;s <em>craft</em>. Faster indexing on a codebase that&#39;s grown past what &quot;small project&quot; used to mean. Refactors that don&#39;t require holding the whole call graph in your head. A debugger that doesn&#39;t fight you. None of that ships a feature by itself, but all of it is the difference between an evening of focused work and an evening of fighting the tool instead of the problem.</p>
<p>We build Tabularis because we think working with data shouldn&#39;t be this painful. Turns out the editor felt the same way about us.</p>
<p>The terms, for anyone curious how these programs actually work: stay open source, stay maintained, put a small credit somewhere visible. No equity, no roadmap say, nothing that changes what we ship or when. Just one more expense a nights-and-weekends project doesn&#39;t have to carry anymore.</p>
<h2>The part we say every time because it&#39;s still true</h2>
<p>To the JetBrains Open Source team specifically, for turning an email exchange into licenses with zero friction, thank you.</p>
<p>And to everyone who&#39;s starred the repo, opened a plugin, filed a bug, or translated a string: a four-month-old project doesn&#39;t get picked up by the tools it&#39;s built with unless it looks like it&#39;s going somewhere. That impression is entirely yours. We just write the code — increasingly, apparently, on someone else&#39;s dime.</p>
<hr>
<p><em>The Tabularis Team</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/jetbrains-open-source-program/opengraph-image.png" type="image/png" />
      <category>community</category>
      <category>sponsors</category>
      <category>partnership</category>
      <category>open-source</category>
    </item>
    <item>
      <title>Nobody Reads the SQL Anymore</title>
      <link>https://tabularis.dev/blog/nobody-reads-the-sql-anymore</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/nobody-reads-the-sql-anymore</guid>
      <pubDate>Thu, 02 Jul 2026 10:33:00 GMT</pubDate>
      <description>AI writing our queries is fine, and often better than fine. But reading skill was funded by writing, and now that the writing is gone, the reading is decaying while the volume of SQL entering our codebases goes up. Some thoughts on why SQL is the worst possible language for this to happen to.</description>
      <content:encoded><![CDATA[<h1>Nobody Reads the SQL Anymore</h1>
<p>These days most of the SQL entering a codebase was written by a language model. I don&#39;t think this is a problem in itself: models have read more SQL than any of us ever will, and for the everyday query, the three-way join with a couple of aggregations, they are fast and usually correct. I use them for this all the time. The writing was never the sacred part of the job.</p>
<p>What worries me is a second-order effect that I started noticing in code reviews, my own included. A pull request arrives with a forty-line query in the middle, the tests pass, the data looks plausible, and the review comment says &quot;LGTM&quot;. The reviewer did not really read the query. Often the author did not read it either, in any meaningful sense, because an agent wrote it and the output looked reasonable. Every single step of this is a rational decision, and the sum is <strong>a query in production that no human being has ever actually understood</strong>.</p>
<p>For most languages I would shrug at this. But reading skill and writing skill, while different things, were historically funded by the same activity: you learned to read queries by writing bad ones first and debugging them. Now the writing is gone, so the reading is decaying, and at the same time the volume of generated SQL is going up. Fewer readers, more text. That&#39;s a bad combination anywhere — but SQL is a special case, and I want to explain why I believe it is the worst possible language for this to happen to.</p>
<h2>SQL fails differently</h2>
<p><strong>The cost of a query is invisible in its text.</strong> SQL is declarative: the query says what you want, the database decides how to get it, and two queries that look almost identical can differ by three orders of magnitude at runtime. You cannot see a sequential scan over four million rows by staring at the SELECT statement, no matter how experienced you are. With imperative code, a triple nested loop at least <em>looks</em> suspicious. In SQL the truth is simply not on the page: it lives in the execution plan — a thing almost nobody looks at voluntarily, and which the model that generated the query has never seen at all.</p>
<p><strong>Wrong SQL usually works.</strong> A subtly wrong query does not crash: the LEFT JOIN that should have been an inner join silently multiplies rows, the WHERE clause that mishandles NULL silently drops records, and the result set still looks completely plausible. The dashboard renders. Somebody makes a decision based on it. Your test suite does not catch this, because the fixture has forty rows and the bug needs four million, or needs that one customer with two addresses. This is the category of bug that surfaces months later as a number that has been wrong since March — and it is exactly the category where a human reading the query carefully would have helped.</p>
<h2>The inversion</h2>
<p>So my feeling is that we are heading into a strange inversion. For twenty years &quot;can write SQL&quot; was the skill you put on a resume. That is now table stakes, the agent writes it. The skill that is becoming scarce, and therefore valuable, is being able to look at a query you did not write and answer two questions: <strong>does it mean what we think it means, and what is it going to cost?</strong> The first is a question about semantics — row multiplicity, NULL behavior, what that DISTINCT is papering over. The second is not a question about the query at all: it is a question about the plan.</p>
<p>The developers who can answer both are turning into the editors of what the machines write, and editors were always rarer than writers. Maybe I&#39;m wrong and models will get good enough at self-reviewing that human reading will stop mattering; but for now they can&#39;t even see the plan, so I would not bet on it yet.</p>
<h2>Reading has to get cheaper</h2>
<p>The standard prescription at this point would be that developers should study SQL more, and I don&#39;t believe in it, for the same reason virtue-based prescriptions never work: nobody has slack for homework, and willpower loses against deadlines every single time. If reading matters more than before, reading has to become cheaper. <strong>That&#39;s a tooling problem, not a discipline problem.</strong></p>
<p>This is, honestly, a big part of why I build Tabularis the way I do. The <a href="https://tabularis.dev/wiki/visual-query-builder">visual query builder</a> works in both directions: you compose a query visually, and it always shows you the SQL it is generating, so the tool doubles as a reading tutor without asking anything from you. The AI assistant can <a href="https://tabularis.dev/wiki/ai-assistant">explain a query</a> you inherited instead of just writing new ones. And for the cost question — the one the text cannot answer — <a href="https://tabularis.dev/wiki/visual-explain">visual EXPLAIN</a> renders the execution plan as an interactive graph where the expensive node is red and the estimate that is off by 40x is highlighted, instead of buried at line 60 of a text dump:</p>
<p><video src="https://tabularis.dev/videos/wiki/05-visual-explain.mp4" poster="/videos/wiki/05-visual-explain.jpg" controls muted playsinline loop autoplay controlsList="nodownload noremoteplayback noplaybackrate" disablePictureInPicture style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p><em>(There is a longer walkthrough in the <a href="https://tabularis.dev/wiki/visual-explain">visual EXPLAIN wiki page</a>.)</em></p>
<p>Note that none of this is an anti-AI position — there is a model in the loop right there in the previous paragraph. The position is narrower: <strong>generation without inspection is debt</strong>, and since the generation became effortless, the inspection has to become effortless too, otherwise it simply stops happening. Which is what we are watching happen.</p>
<p>If you want to keep the muscle without doing homework, there is a very small habit that I can suggest. Next time an agent hands you a query, before running it, spend thirty seconds making two predictions: roughly how many rows it should return, and which table will dominate the cost. Then run it and check. When you are right, you spent thirty seconds. When you are wrong, you found either a bug or a hole in your mental model of your own data, and both are worth much more than the time.</p>
<p>The machines write well now, better than most of us on a Tuesday afternoon. Someone still has to be able to tell when what they wrote is not what we meant. For the moment, that someone is necessarily human, and there are fewer of them every year. It seems like a good year to be one.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/nobody-reads-the-sql-anymore/opengraph-image.png" type="image/png" />
      <category>opinion</category>
      <category>ai</category>
      <category>sql</category>
      <category>performance</category>
      <category>culture</category>
    </item>
    <item>
      <title>v0.13.4: Unlock Everything — Hardware Keys, Free-Floating Results, and a Sharper Editor</title>
      <link>https://tabularis.dev/blog/v0134-ssh-security-keys-detachable-results-smarter-editor</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0134-ssh-security-keys-detachable-results-smarter-editor</guid>
      <pubDate>Tue, 30 Jun 2026 11:00:00 GMT</pubDate>
      <description>v0.13.4 teaches SSH to prompt — unlock a hardware security key or a passphrase from an in-app dialog — pops query results out into their own window, overhauls SQL autocomplete, and lands three new community drivers (MongoDB, Cloudflare D1, Dameng) alongside a wave of correctness fixes.</description>
      <content:encoded><![CDATA[<h1>v0.13.4: Unlock Everything — Hardware Keys, Free-Floating Results, and a Sharper Editor</h1>
<p><strong>v0.13.4</strong> follows <a href="https://tabularis.dev/blog/v0133-result-colors-gruvbox-themed-tabs-session-restore">v0.13.3</a>, which was about making the app feel like yours. This one is about the moments where the app has to <em>get out of your way</em>: an SSH tunnel that can finally ask you for a PIN instead of failing silently, query results that pop into their own window when one screen isn&#39;t enough, and an editor that completes, formats, and confirms what you typed. It&#39;s another release carried by the community — nine external contributors land in this tag, including a wave of new database drivers.</p>
<hr>
<h2>SSH That Knows How to Ask</h2>
<p>Until now an SSH tunnel could only authenticate non-interactively — a key on disk, an agent, a password you&#39;d stored. If your key lived on a <strong>hardware security token</strong> (a YubiKey or any FIDO/PKCS#11 device) that wants a PIN or a touch, or your private key was passphrase-protected and not in an agent, the connection just couldn&#39;t get off the ground. v0.13.4 fixes that by letting SSH <em>prompt</em>.</p>
<p><a href="https://github.com/robertpenz">@robertpenz</a> contributed the core support for security-key authentication in PR <a href="https://github.com/TabularisDB/tabularis/pull/262">#262</a>: when the token needs a PIN to unlock, Tabularis now surfaces that request instead of giving up (tested against a hardware key on Fedora 43). To make the prompt safe and native rather than a terminal popup, the maintainer built a small <strong>in-app askpass service</strong> around it — an isolated askpass server and protocol that intercepts SSH&#39;s credential requests and serves them through a proper in-app modal, with forced interactive auth so passphrase- and PIN-protected keys are actually usable.</p>
<p>The result: when a tunnel needs a passphrase, a security-key PIN, or a password mid-connect, you get a clean modal asking for exactly that, and the secret goes straight to SSH without touching disk. A per-connection <strong>&quot;allow interactive prompts&quot;</strong> toggle in the connection modals keeps the behavior opt-in, and the prompt strings are localized across all eight languages. If you&#39;ve been stuck unable to use a YubiKey-backed jump host, this is the release that unblocks you.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-ssh-askpass.mp4" poster="/videos/posts/tabularis-ssh-askpass.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Detach Your Results Into Their Own Window</h2>
<p>The query results panel had a single chevron to collapse it and not much else. PR <a href="https://github.com/TabularisDB/tabularis/pull/369">#369</a> turns its header into the familiar set of window controls — and lets you pop results out entirely.</p>
<p>The right side of the results bar now carries <strong>Minimize</strong>, <strong>Maximize</strong>, <strong>Detach</strong>, and <strong>Close</strong>. Minimize and Close collapse the panel without losing data — the existing &quot;Show Results&quot; button brings it back. Maximize hides the editor so results take the full height, and clicking it again restores the split. Manual drag-to-resize is untouched. The new one is <strong>Detach</strong>: it pops the active tab&#39;s results into a separate OS window, so you can keep the grid on a second monitor while you keep editing SQL on the first. The detached window stays in sync with the tab it came from, and closing it folds the results back into the main layout.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-detach-results.mp4" poster="/videos/posts/tabularis-detach-results.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>A Smarter SQL Editor</h2>
<p>Three changes converge to make the editor read your intent better.</p>
<p><strong>Autocomplete, rebuilt.</strong> <a href="https://github.com/m-tonon">@m-tonon</a> (Matheus Tonon) reworked how SQL autocomplete is wired up in PR <a href="https://github.com/TabularisDB/tabularis/pull/267">#267</a>: a shared <code>useSqlAutocompleteRegistration</code> hook now manages the Monaco completion provider for both the editor and the notebook, so there are no more stale or duplicated providers when you switch connections or open a new cell. The same work fixes aliased PostgreSQL columns — <code>SELECT u.&quot;FirstName&quot; FROM users u</code> now completes <code>u.</code> against the real quoted column names — and <a href="https://github.com/NewtTheWolf">@NewtTheWolf</a> folded in a fix so accepting a completion for an already-quoted identifier no longer doubles the quotes.</p>
<p><strong>Beautify in the view editor.</strong> <a href="https://github.com/danielnuld">@danielnuld</a> added a one-click <strong>Beautify</strong> button to the view editor (create and edit) in PR <a href="https://github.com/TabularisDB/tabularis/pull/372">#372</a>. Databases hand back view definitions as a single dense line — MySQL stores <code>SELECT c.id,c.name,...</code> with no whitespace at all. The button runs the definition through <code>sql-formatter</code> with the active driver&#39;s dialect, so it&#39;s actually readable before you start editing.</p>
<p><strong>Success feedback for non-SELECT statements.</strong> Also from <a href="https://github.com/danielnuld">@danielnuld</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/391">#391</a> replaces the misleading &quot;0 rows retrieved&quot; empty grid you&#39;d get after an <code>INSERT</code>/<code>UPDATE</code>/<code>DELETE</code> or a DDL statement with an explicit success panel — a check icon, &quot;Query executed successfully&quot;, the affected-row count when there is one, and the execution time. It works for both single statements and multi-statement batches.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-editor-feedback.mp4" poster="/videos/posts/tabularis-editor-feedback.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Collapse Notebook Sections Individually</h2>
<p><video src="https://tabularis.dev/videos/posts/tabularis-notebooks-collapse.mp4" poster="/videos/posts/tabularis-notebooks-collapse.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p>Notebook cells could only be collapsed as a whole. PR <a href="https://github.com/TabularisDB/tabularis/pull/399">#399</a> adds independent collapse for the three areas inside an SQL cell — the <strong>query</strong> editor, the <strong>results</strong> grid, and the <strong>chart</strong> — on top of the existing master cell collapse. Each area gets a thin labelled header with a chevron, the chart section appears only when the result is chartable, and the collapsed state of every section is saved with the notebook so it sticks across reloads. Chart visibility is persisted too now — it used to reset on every reload — defaulting to whether a chart config already exists, so older notebooks keep showing their charts exactly as before.</p>
<hr>
<h2>A Startup Script Per Connection</h2>
<p><img src="https://tabularis.dev/img/posts/tabularis-startup-script.png" alt="Startup script field in the Advanced tab of the connection modal"></p>
<p><a href="https://github.com/boredland">@boredland</a> added an optional <strong>startup script</strong> to a connection in PR <a href="https://github.com/TabularisDB/tabularis/pull/352">#352</a> (closes <a href="https://github.com/TabularisDB/tabularis/issues/350">#350</a>). It&#39;s SQL that runs on every new pooled connection, so session-level settings stick across the whole pool no matter which physical connection serves a given query — the DataGrip-style behavior people asked for. The motivating case is RLS-bypass-in-dev: a <code>SELECT set_config(&#39;app.bypass_rls&#39;, &#39;on&#39;, false);</code> (or any <code>SET</code>) now applies to every query instead of randomly depending on which pooled connection you landed on. It&#39;s executed per physical connection — MySQL/SQLite via sqlx <code>after_connect</code>, Postgres via deadpool <code>post_create</code> — blank scripts are skipped, and the script is stored with the connection as non-secret config.</p>
<hr>
<h2>Three New Community Drivers</h2>
<p>The plugin ecosystem keeps growing. Three drivers join the registry this cycle, all community-built and installable from <strong>Settings → Plugins</strong>:</p>
<ul>
<li><strong>MongoDB</strong> by <a href="https://github.com/danielnuld">@danielnuld</a> (<a href="https://github.com/TabularisDB/tabularis/pull/368">#368</a>) — connects to MongoDB 6.0+ via the official Rust driver (rustls, no system dependencies). Multi-database browsing, schema inference by sampling, native shell queries (<code>db.coll.find/aggregate/...</code> and CRUD), SQL grid-filter translation to MongoDB filters, index management, and ObjectId-aware inline editing. Ships for Windows, Linux (x64 + arm64), and Apple Silicon. <a href="https://github.com/danielnuld/tabularis-mongodb-plugin">Source</a>.</li>
<li><strong>Cloudflare D1</strong> by <a href="https://github.com/NewtTheWolf">@NewtTheWolf</a> (<a href="https://github.com/TabularisDB/tabularis/pull/376">#376</a>) — browse and manage Cloudflare&#39;s D1 serverless SQLite databases. Linux and Windows builds in its v0.1.0 release.</li>
<li><strong>DM (Dameng)</strong> by <a href="https://github.com/haos666">@haos666</a> (<a href="https://github.com/TabularisDB/tabularis/pull/382">#382</a>) — the Dameng database driver, with macOS, Linux, and Windows assets. The Dameng JDBC driver jar stays user-provided.</li>
</ul>
<p>That brings the community driver roster — alongside the built-in MySQL, Postgres, and SQLite — to a long and growing list, with Dameng also added to the README&#39;s supported-databases section.</p>
<hr>
<h2>Oracle and Dameng SQL Block Splitting</h2>
<p><a href="https://github.com/haos666">@haos666</a> extended the SQL splitter to understand Oracle- and DM-style blocks in PR <a href="https://github.com/TabularisDB/tabularis/pull/325">#325</a> (a follow-up to the earlier splitter work). Behind the existing <code>oracle</code> dialect, it now treats a line-leading <code>/</code> as a statement terminator, folds PL/SQL-style source units instead of splitting on internal semicolons, understands Oracle <code>q</code>/<code>nq</code>-quoting so a <code>;</code> or <code>/</code> inside a quoted literal doesn&#39;t split a statement, and recognizes DM-specific block openers (<code>CREATE CLASS</code>, <code>CREATE CLASS BODY</code>, <code>CREATE JAVA CLASS</code>). Every change is dialect-gated, so the other presets are structurally unchanged.</p>
<hr>
<h2>Correctness, Across the Board</h2>
<p>A run of fixes that quietly make queries do the right thing:</p>
<ul>
<li><strong>Composite primary keys in edits</strong> (<a href="https://github.com/TabularisDB/tabularis/pull/324">@thomaswasle</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/324">#324</a>) — editing or deleting a row in a table with a composite primary key used to send only the <em>first</em> PK column, so the <code>UPDATE</code>/<code>DELETE</code> could hit <strong>every</strong> row sharing that partial key. The frontend now carries the full PK as a map and every command and driver (MySQL, SQLite, Postgres) builds a compound <code>WHERE col1 = ? AND col2 = ? AND …</code> clause.</li>
<li><strong>Vitess / PlanetScale connections</strong> (<a href="https://github.com/debba">@debba</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/387">#387</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/383">#383</a>) — connecting failed immediately with <code>setting the PIPES_AS_CONCAT sql_mode is unsupported</code> because sqlx sets that mode on every connection and Vitess rejects it. Tabularis now auto-skips <code>PIPES_AS_CONCAT</code> and <code>NO_ENGINE_SUBSTITUTION</code> so Vitess-backed databases connect.</li>
<li><strong>MySQL pagination after semicolons</strong> (<a href="https://github.com/Stiwar0098">@Stiwar0098</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/389">#389</a>, closes <a href="https://github.com/TabularisDB/tabularis/issues/388">#388</a>) — paginated SELECTs no longer leave <code>LIMIT</code>/<code>OFFSET</code> stranded after a trailing semicolon or comment, with hardened scanning for MySQL/MariaDB comment and quoting syntax.</li>
<li><strong>PostgreSQL &lt; 11 routine introspection</strong> (<a href="https://github.com/earmellin">@earmellin</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/377">#377</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/375">#375</a>) — <code>pg_proc.prokind</code> only exists from PG 11, so routine browsing threw <code>42703</code> on 9.x/10. Introspection now picks a version-appropriate query; tested against PostgreSQL 9.6.</li>
<li><strong>UUID-shaped keys in varchar columns</strong> (<a href="https://github.com/NewtTheWolf">@NewtTheWolf</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/394">#394</a>) — a primary key that <em>looks</em> like a UUID but lives in a <code>varchar</code> column is now bound as text, so editing those rows no longer fails a type check.</li>
</ul>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>Accessibility for screen readers</strong> (<a href="https://github.com/arturbent0">@arturbent0</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/355">#355</a>, fixes <a href="https://github.com/TabularisDB/tabularis/issues/86">#86</a>) — Monaco&#39;s accessibility support is on so screen readers read typed text without focus jumping to suggestions, the Run button announces its shortcut, DataGrid headers gained <code>role</code>/<code>aria-sort</code>/keyboard support, the connection-test result is an <code>aria-live</code> region, and sidebar expanders are real buttons.</li>
<li><strong>Manual update check unblocked</strong> (<a href="https://github.com/debba">@debba</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/398">#398</a>) — &quot;Check for Updates Now&quot; kept reporting &quot;You&#39;re up to date&quot; after you&#39;d dismissed an earlier update notification. A dismissed version no longer suppresses an explicit manual check.</li>
<li><strong>Export from plugin-driver connections</strong> (<a href="https://github.com/danielnuld">@danielnuld</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/366">#366</a>) — Export as CSV/JSON used to fail with <code>Unsupported driver for export</code> on any external plugin driver. It now pages through the driver&#39;s own <code>execute_query</code> so plugin-backed connections (Informix, MongoDB, …) can export too.</li>
<li><strong>Informix 32-bit Windows build</strong> (<a href="https://github.com/danielnuld">@danielnuld</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/367">#367</a>) — the Informix plugin ships its 32-bit build on the Windows slot.</li>
<li><strong>Plugin trigger capability docs aligned</strong> (<a href="https://github.com/haos666">@haos666</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/317">#317</a>) — external-plugin trigger capability and metadata documentation now match the implementation.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Nine external contributors land in v0.13.4 — once again, most of this release is community work.</p>
<p><strong><a href="https://github.com/robertpenz">@robertpenz</a></strong> contributed SSH security-key authentication (<a href="https://github.com/TabularisDB/tabularis/pull/262">#262</a>), the feature the whole interactive-prompt flow is built around.</p>
<p><strong><a href="https://github.com/m-tonon">@m-tonon</a> (Matheus Tonon)</strong> rebuilt the SQL autocomplete registration and fixed aliased PostgreSQL column completion (<a href="https://github.com/TabularisDB/tabularis/pull/267">#267</a>), and <strong><a href="https://github.com/NewtTheWolf">@NewtTheWolf</a></strong> fixed quoted-identifier double-quoting, added the Cloudflare D1 driver (<a href="https://github.com/TabularisDB/tabularis/pull/376">#376</a>), and bound UUID-shaped varchar keys as text (<a href="https://github.com/TabularisDB/tabularis/pull/394">#394</a>).</p>
<p><strong><a href="https://github.com/danielnuld">@danielnuld</a></strong> landed the view-editor Beautify button (<a href="https://github.com/TabularisDB/tabularis/pull/372">#372</a>), non-SELECT success feedback (<a href="https://github.com/TabularisDB/tabularis/pull/391">#391</a>), plugin-driver export (<a href="https://github.com/TabularisDB/tabularis/pull/366">#366</a>), the Informix 32-bit build (<a href="https://github.com/TabularisDB/tabularis/pull/367">#367</a>), and the MongoDB driver (<a href="https://github.com/TabularisDB/tabularis/pull/368">#368</a>).</p>
<p><strong><a href="https://github.com/haos666">@haos666</a></strong> extended the SQL splitter for Oracle/Dameng blocks (<a href="https://github.com/TabularisDB/tabularis/pull/325">#325</a>), aligned plugin trigger docs (<a href="https://github.com/TabularisDB/tabularis/pull/317">#317</a>), and added the DM (Dameng) driver (<a href="https://github.com/TabularisDB/tabularis/pull/382">#382</a>).</p>
<p><strong><a href="https://github.com/boredland">@boredland</a></strong> added per-connection startup scripts (<a href="https://github.com/TabularisDB/tabularis/pull/352">#352</a>), <strong><a href="https://github.com/thomaswasle">@thomaswasle</a></strong> fixed composite-PK edits (<a href="https://github.com/TabularisDB/tabularis/pull/324">#324</a>), <strong><a href="https://github.com/Stiwar0098">@Stiwar0098</a></strong> fixed MySQL pagination after semicolons (<a href="https://github.com/TabularisDB/tabularis/pull/389">#389</a>), <strong><a href="https://github.com/earmellin">@earmellin</a></strong> restored routine introspection on PostgreSQL &lt; 11 (<a href="https://github.com/TabularisDB/tabularis/pull/377">#377</a>), and <strong><a href="https://github.com/arturbent0">@arturbent0</a></strong> improved screen-reader accessibility (<a href="https://github.com/TabularisDB/tabularis/pull/355">#355</a>).</p>
<p>If you authenticate over SSH with a hardware key, work across two monitors and want results on their own, lean on autocomplete, run against Vitess/PlanetScale or an older Postgres, or connect to MongoDB, Cloudflare D1, or Dameng — this is the upgrade.</p>
<hr>
<p><em>v0.13.4 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.13.4">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0134-ssh-security-keys-detachable-results-smarter-editor/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>bugfix</category>
      <category>ssh</category>
      <category>editor</category>
      <category>notebook</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>plugin</category>
      <category>community</category>
    </item>
    <item>
      <title>Where Tabularis Keeps Its Secrets: Thank You, 1Password</title>
      <link>https://tabularis.dev/blog/managing-tabularis-secrets-with-1password</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/managing-tabularis-secrets-with-1password</guid>
      <pubDate>Mon, 29 Jun 2026 10:00:00 GMT</pubDate>
      <description>An open-source project accumulates secrets like signing keys, certificates and deploy tokens, and they end up pasted across GitHub repo settings with no real story for rotation or audit. 1Password gives open-source projects a free plan, we qualified for it, and it&apos;s good enough to deserve a genuine thank-you. Here&apos;s why 1Password is a great secret manager for developers, and how we plan to move Tabularis&apos; CI secrets into one vault and pull them into GitHub Actions with op:// references.</description>
      <content:encoded><![CDATA[<h1>Where Tabularis Keeps Its Secrets: Thank You, 1Password</h1>
<p>Secrets are invisible right up until they leak. Nobody opens a project and admires how tidily its signing keys are stored; they only ever notice the opposite: a token committed by accident, a certificate that expired over the weekend, a &quot;who even has access to this?&quot; message in a thread at 2am. It&#39;s unglamorous, easy-to-postpone work, and like localization it quietly decides how much you trust the thing you shipped.</p>
<p>Tabularis is open source and funded out of pocket, which means the boring infrastructure is all ours to get right. And the part we&#39;d been putting off was the most boring of all: where the project keeps its secrets.</p>
<h2>The mess we&#39;d been living with</h2>
<p>A desktop app that ships real builds accumulates real secrets. For Tabularis that&#39;s a Tauri updater signing key, an Apple Developer certificate plus notarization credentials, a Windows code-signing setup, an npm token, a Vercel deploy hook, a handful of GitHub tokens. None of it is exotic. All of it is sensitive, and all of it has to be reachable from CI to cut a release.</p>
<p>The default way you end up doing this is: paste each value into <strong>Settings → Secrets</strong> on GitHub, one repo at a time, and move on. It works, and that&#39;s exactly the problem. It works just well enough that you never fix it. The values live in a place you can&#39;t read back. Rotating one means remembering every repo and workflow that uses it. There&#39;s no audit trail worth the name, &quot;access&quot; is whoever has admin on the repo, and the canonical copy of a signing key ends up being a text file in a downloads folder, because that&#39;s the only place you can actually still read it.</p>
<p>We didn&#39;t want a tidier spreadsheet of secrets. We wanted the pasted-into-GitHub copies to stop being the source of truth at all.</p>
<h2>1Password, and being honest about what this is</h2>
<p>Here&#39;s the part we want to be upfront about. <strong>1Password runs a <a href="https://github.com/1Password/1password-teams-open-source?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=1password-thank-you">free plan for open-source projects</a>, we applied like anyone can, and we qualified.</strong> That&#39;s the whole story: we asked, and they gave Tabularis the paid product for free. We&#39;d rather say it plainly than dress it up.</p>
<p><strong>And 1Password asks for nothing in return.</strong> They require no blog post, no homepage logo, no &quot;sponsored by&quot; badge, nothing at all. The credit we give them, here and on our site, is entirely our choice and not a deliverable anyone asked for. We&#39;re doing it for one reason: the product is genuinely good, the program is a genuinely generous thing to offer maintainers who are footing the bill themselves, and more of them should know it exists. Credit where it&#39;s due, freely given.</p>
<p>And 1Password really is the good kind of tool. It&#39;s been the quiet standard for a long time because it gets the fundamentals right: strong, audited cryptography; your data encrypted with a key only you hold; a UX polished enough that using it is <em>easier</em> than not using it, which is the only property that actually makes a security tool get used. For a maintainer it turns &quot;where&#39;s that key&quot; from a small recurring panic into a non-event. That alone would have been worth the thank-you.</p>
<p>It&#39;s quietly fixed the un-glamorous day-to-day, too. A project is more than a codebase: there are the social accounts we post from, a domain registrar, analytics logins, the npm org. Those used to live in the worst possible place: a couple of reused passwords and a notes file. Now they&#39;re in a shared vault. The X, Bluesky and Mastodon accounts get handed off securely instead of pasted into a DM; weak and reused passwords are gone; and where a service supports it we&#39;re on passkeys and 1Password-generated one-time codes instead of an SMS or a PIN someone could guess. It&#39;s the kind of upgrade you only notice the first time you <em>don&#39;t</em> have to ask &quot;wait, what&#39;s the login for the Mastodon account again?&quot;</p>
<p>But the reason it changed how we work is the developer side.</p>
<p>1Password is genuinely built for developers. You can integrate it across a whole workflow: managing SSH keys and signing Git commits, authenticating CLIs with biometrics instead of pasted tokens, and securing secrets throughout your projects. Several of those have already earned a place in how we build Tabularis, and one more is next on the list. Here&#39;s the one every developer should steal first.</p>
<h2>1Password as a secret manager for GitHub</h2>
<p>This is the part every developer should know about, however you came to 1Password.</p>
<p>1Password isn&#39;t just a vault you copy values out of. It&#39;s a secret manager you can wire directly into your pipeline. The model is delightfully blunt: instead of storing a secret&#39;s <em>value</em> in GitHub, you store a <strong>reference</strong> to where it lives in 1Password, written as a <code>op://vault/item/field</code> URL. The real value never touches your repo settings.</p>
<p>You create a <a href="https://developer.1password.com/docs/service-accounts/?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=1password-thank-you">1Password service account</a> scoped to a single CI vault, and the <em>one</em> secret you put into GitHub is its token. Everything else becomes a <a href="https://developer.1password.com/docs/cli/secret-references/?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=1password-thank-you">secret reference</a> that the <a href="https://github.com/1Password/load-secrets-action?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=1password-thank-you"><code>load-secrets-action</code></a> resolves at runtime:</p>
<pre><code class="language-yaml">- name: Load secrets from 1Password
  uses: 1password/load-secrets-action@v2
  with:
    export-env: true
  env:
    OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
    TAURI_SIGNING_PRIVATE_KEY: op://CI/Tauri/key
    APPLE_CERTIFICATE: op://CI/Apple/cert
    NPM_TOKEN: op://CI/npm/token
    VERCEL_DEPLOY_HOOK: op://CI/Vercel/hook
</code></pre>
<p>Read that workflow and the whole pitch is right there. The only literal secret in GitHub is <code>OP_SERVICE_ACCOUNT_TOKEN</code>. Every other line is just an address. The values live in 1Password, get fetched into the job, and are masked in the logs, and when the job ends, they&#39;re gone.</p>
<p>What that buys you, concretely:</p>
<ul>
<li><strong>One source of truth.</strong> The vault is canonical. GitHub holds an address book, not a key ring.</li>
<li><strong>Rotation is a single edit.</strong> Change the value in 1Password once; every workflow that references it picks up the new one on its next run. No hunting through repo settings.</li>
<li><strong>Real access control and audit.</strong> Who can see a secret is vault membership, not repo-admin-by-accident, and 1Password actually logs it.</li>
<li><strong>The same secrets work locally.</strong> With the <a href="https://developer.1password.com/docs/cli/secret-references/?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=1password-thank-you">1Password CLI</a> you reference the exact same <code>op://</code> items from a dev machine, via <code>op run -- pnpm release</code>, so the credentials you test a release with are literally the ones CI uses. No drift, nothing copied to disk.</li>
</ul>
<p>It&#39;s the same idea that makes Tabularis itself worth using: keep the sensitive thing in one trustworthy place, and reference it everywhere else instead of making copies. (Tabularis stores <em>your</em> database credentials in the OS keychain for exactly that reason: <a href="https://tabularis.dev/blog/your-database-gui-shouldnt-need-an-account">no account, no copies on a server</a>.) Seeing 1Password apply the same principle to CI is what sold us.</p>
<h2>The part that already works today: SSH</h2>
<p>The CI migration is still ahead of us, but there&#39;s one 1Password feature that already pays off inside Tabularis right now, with zero setup on our side: the <a href="https://developer.1password.com/docs/ssh/agent/?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=1password-thank-you">SSH agent</a>.</p>
<p>1Password can hold your SSH keys and act as your system&#39;s SSH agent, so the private key never sits unencrypted on disk and every single use is an explicit, approved action. What that means for connecting to a database over SSH in Tabularis is the nicest kind of surprise: it just works out of the box. Pick an SSH connection, leave the key-file field empty, hit connect, and the 1Password window pops up. You approve with Touch ID (or however you&#39;ve set it), and you&#39;re connected. No key file to hunt down, no passphrase to paste, no agent to wire up by hand.</p>
<p>We didn&#39;t build an integration for this, and that&#39;s the whole point. Tabularis talks to the system SSH agent like any well-behaved SSH client, and 1Password registers itself as that agent. The two meet in the middle, and the result is that the most secure option is also the least effort. That&#39;s exactly the principle we care about: the safe path should be the easy one.</p>
<p>And we&#39;d like to do more of it. The SSH agent is just the first place 1Password and Tabularis happen to meet, and we want to build proper, first-class integration on top: pulling connection credentials straight from your vault, for one, so a database password never has to be pasted into Tabularis at all. One thing we want to be completely clear about: any of this will be strictly opt-in. Tabularis works fully without 1Password and always will. If you don&#39;t use it, nothing changes and nothing nags you, and the OS keychain stays the default. This is an extra door for the people who want it, never a requirement for the people who don&#39;t.</p>
<h2>What we plan to do with it</h2>
<p>Here&#39;s the plan, and to be clear it is still a plan: we haven&#39;t moved a single CI secret across yet. The intent is to take Tabularis&#39; release and deploy secrets out of scattered GitHub repo settings, put them in a dedicated 1Password CI vault, and wire <code>load-secrets-action</code> into the workflows so every job resolves what it needs from <code>op://</code> references. The end state is the one in the diagram above: a single service-account token in GitHub, everything else an address.</p>
<p>We&#39;re deliberately not rushing it. The signing keys are the careful part, and moving them is something we&#39;d rather do once, slowly, than twice. So treat this as a direction we&#39;ve committed to and a use of the free plan we intend to make, not a migration we&#39;ve finished. The everyday accounts are already in 1Password; the CI pipeline is the next step.</p>
<h2>Thanks</h2>
<p>So, plainly: <strong>thank you, 1Password.</strong> For backing open source with a free plan that lets independent maintainers manage secrets the way larger teams do, and for building a product good enough that adopting it felt like an upgrade rather than a chore. Doing that quietly, for projects you have no commercial relationship with, is a generous thing, and it&#39;s worth saying so out loud.</p>
<p>If you maintain something open source and you&#39;re still pasting tokens into repo settings one at a time, go look at <a href="https://github.com/1Password/1password-teams-open-source?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=1password-thank-you">their open-source program</a> and at <a href="https://developer.1password.com/docs/ci-cd/github-actions/?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=1password-thank-you">using 1Password in GitHub Actions</a>. It&#39;s the rare bit of security work that makes your life easier the same day you set it up.</p>
<p>Secrets should be invisible when they&#39;re handled right. The least we can do is keep them somewhere we&#39;d trust with our own, and then tell you where that is. <a href="https://github.com/TabularisDB/tabularis">Star Tabularis on GitHub</a> to follow along, and keep an eye on the <a href="https://tabularis.dev/blog">blog</a>.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/managing-tabularis-secrets-with-1password/opengraph-image.png" type="image/png" />
      <category>security</category>
      <category>devops</category>
      <category>ci</category>
      <category>github-actions</category>
      <category>secrets</category>
      <category>open-source</category>
    </item>
    <item>
      <title>v0.13.3: Color Your Results, Theme Your Tabs, and Pick Up Where You Left Off</title>
      <link>https://tabularis.dev/blog/v0133-result-colors-gruvbox-themed-tabs-session-restore</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0133-result-colors-gruvbox-themed-tabs-session-restore</guid>
      <pubDate>Wed, 24 Jun 2026 10:00:00 GMT</pubDate>
      <description>v0.13.3 is a personalization release: color query results by data type, dress the editor in a new Gruvbox theme, tint the tab bar with each connection&apos;s color, reopen the connections you had last session, and toggle CSV headers when you copy — plus a community Informix driver, driver-aware Kubernetes ports, and louder MCP approval alerts.</description>
      <content:encoded><![CDATA[<h1>v0.13.3: Color Your Results, Theme Your Tabs, and Pick Up Where You Left Off</h1>
<p><strong>v0.13.3</strong> follows <a href="https://tabularis.dev/blog/v0132-managed-notebooks-live-query-progress-faster-grid">v0.13.2</a>, which made the notebook, results panel, and grid feel responsive and managed. This one is about making the app feel like <em>yours</em>: results that read at a glance because they&#39;re colored by type, an editor that shows you which connection you&#39;re in by its color, a new theme, and a workspace that reopens where you left it. It&#39;s a release driven almost entirely by the community — ten external contributors land in this tag.</p>
<hr>
<h2>Results, Colored by Type</h2>
<p>A grid where every value renders in the same color makes you read each cell to know what it is. v0.13.3 fixes that with customizable result colors, contributed by <a href="https://github.com/GabrielMalava">@GabrielMalava</a> (Gabriel Malavazi Rodrigues) in PR <a href="https://github.com/TabularisDB/tabularis/pull/354">#354</a>.</p>
<p>Turn on <strong>Result Colors</strong> under <strong>Settings → Appearance → General</strong> and query result cells are tinted by their data type — <strong>numbers, text, dates/times, and booleans</strong> each get their own color. The defaults follow your active theme&#39;s semantic palette, so it looks coherent out of the box, and a per-type color picker with a live preview and a <strong>Reset to theme</strong> button lets you tune each one. It&#39;s off by default; values render exactly as before until you opt in. Colors apply only to plain data cells — edited, inserted, deleted, and NULL cells keep their existing styling — and the per-column colors are precomputed once rather than recalculated on every render, so there&#39;s no scroll cost.</p>
<p>The same PR sharpened in-place editing: pending grid edits now commit with a rebindable <strong><code>save_grid_changes</code></strong> shortcut (Cmd/Ctrl+S, <a href="https://tabularis.dev/compare/tableplus-alternative">TablePlus-style</a>), and editing single-table <code>SELECT</code> results is validated against the table&#39;s real columns first — so an aliased or computed column gives you a clear message instead of a cryptic <code>1054 Unknown column</code>, and a result missing its primary key is blocked with guidance to include it rather than building an unsafe <code>UPDATE</code>.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-result-colors.mp4" poster="/videos/posts/tabularis-result-colors.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>Tabs That Wear the Connection&#39;s Color</h2>
<p>If you keep several connections open, the editor tabs all looked the same — easy to run a statement against the wrong one. PR <a href="https://github.com/TabularisDB/tabularis/pull/333">#333</a> by <a href="https://github.com/Davydhh">@Davydhh</a> (with Davide Cazzetta) ties the whole tab strip to the active connection&#39;s color.</p>
<p>The active-tab indicator line now uses the connection color with a soft glow, the active tab carries an accent-tinted body gradient, and inactive tabs pick up an accent wash on hover instead of a flat grey. The loading bar and the rename input border follow the same color, and the tab bar itself uses a vertical accent gradient with an accent-tinted bottom border so the strip reads as part of the connection. The treatment extends into <a href="https://tabularis.dev/wiki/split-view">split view</a>: split-pane panel headers and the connection switcher use each pane&#39;s accent instead of a fixed blue. When no connection is active it all falls back to the default blue, and the scroll arrows and new-tab buttons stay theme-safe.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-connection-tabs.mp4" poster="/videos/posts/tabularis-connection-tabs.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>A New Theme: Gruvbox</h2>
<p><a href="https://github.com/Wilovy09">@Wilovy09</a> added <strong>Gruvbox Material</strong>, in both Dark and Light, in PR <a href="https://github.com/TabularisDB/tabularis/pull/357">#357</a> — bringing the built-in count to twelve. Each ships with a matching dedicated Monaco editor theme, so the SQL editor&#39;s syntax colors line up with the rest of the UI, and both are wired into the theme registry with sidebar and registry test coverage. Switch to it in <strong>Settings → Appearance</strong>; like every theme, it applies instantly with no restart.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-gruvbox.mp4" poster="/videos/posts/tabularis-gruvbox.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>A Workspace That Remembers</h2>
<p>Launching Tabularis dropped you on an empty workspace even if you&#39;d had three connections open when you quit. PR <a href="https://github.com/TabularisDB/tabularis/pull/332">#332</a>, also from <a href="https://github.com/GabrielMalava">@GabrielMalava</a>, adds opt-in session restore: enable it and Tabularis reopens the connections from your previous session on startup, with autoconnect set only after the connection validates so a stale credential can&#39;t wedge the launch. The same PR adds a <strong>start-maximized</strong> option for anyone who always drags the window full-size anyway. Both live in <strong>Settings → General</strong>.</p>
<hr>
<h2>Copy CSV With (or Without) Headers</h2>
<p>When you copied rows as CSV, you got the values but never the column names — fine for pasting back into a query, annoying for pasting into a spreadsheet. <a href="https://github.com/Wilovy09">@Wilovy09</a> added a <strong>CSV headers</strong> toggle in the copy controls in PR <a href="https://github.com/TabularisDB/tabularis/pull/356">#356</a>. A new <code>csvIncludeHeaders</code> setting (persisted in <code>config.json</code>, on by default) and a toolbar toggle let you decide per copy whether the header row comes along, threaded all the way down to the grid with i18n across all eight locales.</p>
<hr>
<h2>Louder MCP Approvals</h2>
<p><a href="https://tabularis.dev/wiki/mcp-approval-gates">Approval gates</a> only help if you notice them. <a href="https://github.com/Stiwar0098">@Stiwar0098</a> closed that gap in PR <a href="https://github.com/TabularisDB/tabularis/pull/311">#311</a> (closes <a href="https://github.com/TabularisDB/tabularis/issues/307">#307</a>) with an attention flow that fires when a pending approval appears: the window comes to the front via a user-attention request, an OS notification with localized title and body is sent, and an optional alert sound plays. Two new toggles under <strong>MCP → Safety</strong> — <strong>keep the approval window on top</strong> while a request is pending, and <strong>play an alert sound</strong> — let you tune how insistent it is, both localized across eight languages. On Linux the alert now plays through the OS notification sound so it actually reaches you when Tabularis is in the background.</p>
<hr>
<h2>Driver-Aware Kubernetes Connections</h2>
<p>The <a href="https://tabularis.dev/wiki/kubernetes-tunneling">Kubernetes connection</a> dialogs had two rough edges, fixed by <a href="https://github.com/metalgrid">@metalgrid</a> in PR <a href="https://github.com/TabularisDB/tabularis/pull/319">#319</a>. The context, namespace, saved-connection, and resource-name selectors are now <strong>searchable</strong> instead of forcing a scroll through long lists, and the container port no longer hard-codes MySQL&#39;s <code>3306</code> — it reads <code>default_port</code> from the active driver&#39;s manifest, so Postgres lands on <code>5432</code>, ClickHouse on <code>8123</code>, and plugin drivers on whatever they declare. The maintainer follow-up added a <strong>service port discovery</strong> command so the dialog can derive the port from the service&#39;s actually-exposed port, plus corrected inline port defaults and localized K8s validation messages across all eight locales.</p>
<hr>
<h2>A Community Informix Driver</h2>
<p><a href="https://github.com/danielnuld">@danielnuld</a> built and shipped an <strong>IBM Informix</strong> driver plugin, registered in PR <a href="https://github.com/TabularisDB/tabularis/pull/343">#343</a>. It&#39;s now in the plugin registry serving releases for Linux, macOS, and Windows — v0.1.2 adds the missing <code>linux-x64</code> asset, and earlier point releases hid the stray console window on Windows. Install it from <strong>Settings → Plugins</strong>. Informix joins the growing set of community-built drivers extending Tabularis beyond the built-in MySQL, Postgres, and SQLite.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>Fresh AI model lists</strong> (<a href="https://github.com/debba">@debba</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/359">#359</a>) — the Anthropic and MiniMax model menus are now fetched live from their APIs instead of a hardcoded list, so newly released models show up without a Tabularis update.</li>
<li><strong>Multi-database operations stay scoped</strong> (<a href="https://github.com/debba">@debba</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/346">#346</a>) — the ER diagram, dump, and export now act on the database you&#39;ve selected on a multi-database connection instead of leaking across all loaded databases.</li>
<li><strong>Social links everywhere they&#39;re expected</strong> (<a href="https://github.com/debba">@debba</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/353">#353</a>) — GitHub, Discord, X, Bluesky, and Mastodon links now appear in the Settings Info tab, the update and What&#39;s New modals, and the welcome screen, pulled from a single source of truth.</li>
<li><strong>External plugin triggers forwarded</strong> (<a href="https://github.com/haos666">@haos666</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/321">#321</a>) — plugin trigger RPCs are now forwarded through to plugin drivers, so plugins can expose trigger-style actions.</li>
<li><strong>Robust view-definition parsing</strong> (<a href="https://github.com/maacl">@maacl</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/320">#320</a>) — the view editor extracts the <code>SELECT</code> body from a view definition more reliably across the shapes different engines return.</li>
<li><strong>Flatpak via Flatpark</strong> (<a href="https://github.com/jing2uo">@jing2uo</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/341">#341</a>) landed in the README, alongside an updated sponsors list.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Nine external contributors land in v0.13.3 — this release is overwhelmingly community work.</p>
<p><strong><a href="https://github.com/GabrielMalava">@GabrielMalava</a> (Gabriel Malavazi Rodrigues)</strong> lands both customizable result colors with the editing improvements (<a href="https://github.com/TabularisDB/tabularis/pull/354">#354</a>) and session restore with the start-maximized option (<a href="https://github.com/TabularisDB/tabularis/pull/332">#332</a>) — two of the headline features of the release.</p>
<p><strong><a href="https://github.com/Davydhh">@Davydhh</a></strong> (with Davide Cazzetta) tied the editor tab bar and split panels to the active connection&#39;s color (<a href="https://github.com/TabularisDB/tabularis/pull/333">#333</a>).</p>
<p><strong><a href="https://github.com/Wilovy09">@Wilovy09</a></strong> added the Gruvbox theme (<a href="https://github.com/TabularisDB/tabularis/pull/357">#357</a>) and the CSV-header copy toggle (<a href="https://github.com/TabularisDB/tabularis/pull/356">#356</a>).</p>
<p><strong><a href="https://github.com/Stiwar0098">@Stiwar0098</a></strong> built the MCP approval attention flow (<a href="https://github.com/TabularisDB/tabularis/pull/311">#311</a>) so a pending approval never goes unnoticed.</p>
<p><strong><a href="https://github.com/metalgrid">@metalgrid</a></strong> made the Kubernetes selection dialogs searchable and driver-aware (<a href="https://github.com/TabularisDB/tabularis/pull/319">#319</a>).</p>
<p><strong><a href="https://github.com/danielnuld">@danielnuld</a></strong> built and shipped the community IBM Informix driver plugin (<a href="https://github.com/TabularisDB/tabularis/pull/343">#343</a>).</p>
<p><strong><a href="https://github.com/haos666">@haos666</a></strong> forwarded external plugin trigger RPCs (<a href="https://github.com/TabularisDB/tabularis/pull/321">#321</a>), and <strong><a href="https://github.com/maacl">@maacl</a></strong> hardened view-definition parsing (<a href="https://github.com/TabularisDB/tabularis/pull/320">#320</a>).</p>
<p><strong><a href="https://github.com/jing2uo">@jing2uo</a></strong> documented Flatpak install via Flatpark (<a href="https://github.com/TabularisDB/tabularis/pull/341">#341</a>).</p>
<p>If you juggle multiple connections and want them color-coded, read grids faster when values are typed by color, theme your editor with Gruvbox, want Tabularis to reopen where you left it, or connect to Informix — this is the upgrade.</p>
<hr>
<p><em>v0.13.3 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.13.3">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0133-result-colors-gruvbox-themed-tabs-session-restore/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>ui</category>
      <category>ux</category>
      <category>data-grid</category>
      <category>editor</category>
      <category>theme</category>
      <category>kubernetes</category>
      <category>mcp</category>
      <category>plugin</category>
      <category>community</category>
    </item>
    <item>
      <title>Database drivers as external processes</title>
      <link>https://tabularis.dev/blog/database-drivers-as-external-processes</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/database-drivers-as-external-processes</guid>
      <pubDate>Wed, 24 Jun 2026 09:00:00 GMT</pubDate>
      <description>About three months ago I added support for plugin drivers outside the Tabularis process. External drivers are ordinary programs speaking JSON-RPC over stdin/stdout. This is a retrospective on why that design held up, where it leaked, and the credential bug hidden in a HashMap insert.</description>
      <content:encoded><![CDATA[<h1>Database drivers as external processes</h1>
<p>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.</p>
<p>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.</p>
<p>Tabularis still has three database drivers compiled into the application: MySQL, PostgreSQL and SQLite. They use <code>sqlx</code>, implement the same Rust trait, and ship with the binary. This is the comfortable case.</p>
<p>The interesting case is the fourth database.</p>
<p>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.</p>
<p>Still, &quot;it does not support the database I use at work&quot; is a valid reason to close a database GUI and never open it again. So Tabularis needed plugins.</p>
<p>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.</p>
<h2>The two options I didn&#39;t take</h2>
<p><strong>Dynamic libraries.</strong> Load a <code>.so</code>, <code>.dylib</code> or <code>.dll</code>, 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.</p>
<p>There are two problems with it here.</p>
<p>The first one is Rust. Rust does not have a stable ABI, so a plugin compiled with one toolchain cannot safely pass a <code>String</code>, <code>Vec</code> or trait object to a host compiled with another one. In practice a Rust plugin ABI becomes an <code>extern &quot;C&quot;</code> interface: raw pointers, owned buffers, explicit frees, error codes, and a driver author thinking about FFI before thinking about the database.</p>
<p>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.</p>
<p><strong>WebAssembly.</strong> 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.</p>
<p>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.</p>
<p>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.</p>
<h2>The boring option</h2>
<p>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.</p>
<p>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:</p>
<ul>
<li>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.</li>
<li>If the driver crashes, the host sees EOF on a pipe. That is a normal error path, not memory corruption in the GUI process.</li>
<li>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.</li>
</ul>
<p>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.</p>
<p>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.</p>
<h2>The wire</h2>
<p>The protocol is JSON-RPC 2.0, one message per line. The whole definition lives in <a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/src/plugins/rpc.rs"><code>src-tauri/src/plugins/rpc.rs</code></a>, and it is small enough to show:</p>
<pre><code class="language-rust">#[derive(Serialize, Deserialize, Debug)]
pub struct JsonRpcRequest {
    pub jsonrpc: String,
    pub method: String,
    pub params: Value,
    pub id: u64,
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
pub enum JsonRpcResponse {
    Success { jsonrpc: String, result: Value, id: u64 },
    Error   { jsonrpc: String, error: JsonRpcError, id: u64 },
}
</code></pre>
<p>Line-delimited JSON is intentionally unsophisticated. I could have used <code>Content-Length</code> framing like LSP does. Instead a Python driver can do this:</p>
<pre><code class="language-python">for line in sys.stdin:
    request = json.loads(line)
</code></pre>
<p>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.</p>
<p>One detail that paid for itself: the child&#39;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.</p>
<p>The method surface is the database trait flattened into strings: <code>test_connection</code>, <code>get_databases</code>, <code>get_tables</code>, <code>execute_query</code>, <code>insert_record</code>, <code>get_create_index_sql</code>, 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.</p>
<p>At the boundary, <code>params</code> and <code>result</code> are <code>serde_json::Value</code>. 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.</p>
<h2>The part that is actually hard</h2>
<p>The phrase &quot;JSON-RPC over stdin/stdout&quot; hides the annoying part: there are two byte streams and many callers.</p>
<p>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.</p>
<p>So two things must be true:</p>
<ul>
<li>Writes must be serialized. Even if small pipe writes often appear atomic, relying on that would make the protocol depend on an implementation detail.</li>
<li>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.</li>
</ul>
<p>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.</p>
<p>The caller side looks like this:</p>
<pre><code class="language-rust">enum PluginCommand {
    /// Dispatch a request; route the response back via the sender.
    Call(JsonRpcRequest, oneshot::Sender&lt;Result&lt;Value, String&gt;&gt;),
    /// Drop the pending entry for `id` because the caller stopped waiting.
    Cancel(u64),
}
</code></pre>
<p>The owner holds the only <code>stdin</code>, the only <code>stdout</code>, 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.</p>
<pre><code class="language-rust">let mut pending: HashMap&lt;u64, oneshot::Sender&lt;Result&lt;Value, String&gt;&gt;&gt; = HashMap::new();

loop {
    tokio::select! {
        _ = &amp;mut shutdown_rx =&gt; { let _ = child.kill().await; break; }

        msg = rx.recv() =&gt; match msg {
            Some(PluginCommand::Call(req, resp_tx)) =&gt; {
                pending.insert(req.id, resp_tx);          // remember who&#39;s waiting
                let mut line = serde_json::to_string(&amp;req).unwrap();
                line.push(&#39;\n&#39;);
                stdin.write_all(line.as_bytes()).await?;  // serialized: only this task writes
            }
            Some(PluginCommand::Cancel(id)) =&gt; { pending.remove(&amp;id); }
            None =&gt; { let _ = child.kill().await; break; } // all callers gone
        },

        line = reader.read_line(&amp;mut buf) =&gt; match line {
            Ok(0) =&gt; break,                                // EOF: the plugin died
            Ok(_) =&gt; {
                match serde_json::from_str::&lt;JsonRpcResponse&gt;(&amp;buf) {
                    Ok(JsonRpcResponse::Success { result, id, .. }) =&gt; {
                        if let Some(tx) = pending.remove(&amp;id) { let _ = tx.send(Ok(result)); }
                    }
                    Ok(JsonRpcResponse::Error { error, id, .. }) =&gt; {
                        if let Some(tx) = pending.remove(&amp;id) { let _ = tx.send(Err(error.message)); }
                    }
                    Err(e) =&gt; log::error!(&quot;bad response from plugin: {e}&quot;),
                }
                buf.clear();
            }
            Err(e) =&gt; { log::error!(&quot;read error: {e}&quot;); break; }
        },
    }
}
</code></pre>
<p>This is the <code>PluginProcess</code> management task in <a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/src/plugins/driver.rs"><code>src-tauri/src/plugins/driver.rs</code></a>. The request <code>id</code> is a monotonic counter. Responses can arrive in any order, because each one finds its waiting caller through <code>pending</code>. Writes are serialized because there is exactly one writer.</p>
<p>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.</p>
<p>A call becomes:</p>
<pre><code class="language-rust">let (tx, rx) = oneshot::channel();
self.sender.send(PluginCommand::Call(req, tx)).await?;
match tokio::time::timeout(PLUGIN_CALL_TIMEOUT, rx).await {
    Ok(Ok(result)) =&gt; result,
    Ok(Err(_))     =&gt; Err(&quot;plugin did not respond&quot;.into()),
    Err(_)         =&gt; { /* timed out */ }
}
</code></pre>
<p>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.</p>
<h2>Two bugs that showed up</h2>
<p>The actor loop was the right abstraction, but two bugs fell directly out of its shape during the first rounds of testing.</p>
<p><strong>The leak.</strong> 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.</p>
<p>But the owner still has an entry in <code>pending</code>. It contains a <code>oneshot::Sender</code> 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.</p>
<p>That is what <code>Cancel(id)</code> is for. When a call times out, the caller also tells the owner to forget the request:</p>
<pre><code class="language-rust">Err(_) =&gt; {
    let _ = self.sender.send(PluginCommand::Cancel(id)).await;
    Err(format!(&quot;plugin call &#39;{method}&#39; timed out after {}s&quot;, timeout.as_secs()))
}
</code></pre>
<p>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.</p>
<p><strong>The zombies.</strong> 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.</p>
<p>The first time I tested that path, <code>ps</code> still showed plugin processes after the parent had gone away. The owner task was the only place that called <code>child.kill()</code>, 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.</p>
<p>The fix is one line at spawn time:</p>
<pre><code class="language-rust">.kill_on_drop(true)
</code></pre>
<p>Dropping the child handle is enough to terminate the process. This is a better invariant than &quot;the async task will always get a chance to run one more branch before the runtime disappears&quot;, because that invariant is not true.</p>
<h2>The registry bug</h2>
<p>This is the bug that changed how I think about the registry.</p>
<p>Registering a driver, built-in or plugin, eventually meant inserting it into a map keyed by driver id:</p>
<pre><code class="language-rust">registry.insert(manifest.id, driver);
</code></pre>
<p>The built-in drivers have the ids <code>&quot;mysql&quot;</code>, <code>&quot;postgres&quot;</code> and <code>&quot;sqlite&quot;</code>. A plugin declares its id in <code>manifest.json</code>.</p>
<p>Nothing stopped a plugin from declaring this:</p>
<pre><code class="language-json">{ &quot;id&quot;: &quot;mysql&quot; }
</code></pre>
<p>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 &quot;the MySQL driver&quot;. The attacker does not need a memory corruption bug. The attack is a manifest entry.</p>
<p>I caught this during the original implementation, before it shipped, but it was close enough to be uncomfortable. The fix in <a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/src/plugins/manager.rs"><code>src-tauri/src/plugins/manager.rs</code></a> is deliberately boring:</p>
<pre><code class="language-rust">const BUILTIN_DRIVER_IDS: [&amp;str; 3] = [&quot;mysql&quot;, &quot;postgres&quot;, &quot;sqlite&quot;];
if BUILTIN_DRIVER_IDS.contains(&amp;config.id.as_str()) {
    return Err(format!(
        &quot;Plugin id &#39;{}&#39; collides with a built-in driver and was refused&quot;,
        config.id
    ));
}
</code></pre>
<p>A plugin that claims a built-in id is refused at load time.</p>
<p>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 <code>HashMap</code> looked like plumbing. In practice it was deciding which process received credentials from the keychain.</p>
<p>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.</p>
<h2>About the word &quot;sandboxed&quot;</h2>
<p>It is tempting to call this sandboxing. I have used that word myself, but it needs qualification.</p>
<p>Running a driver as a separate process gives Tabularis two things:</p>
<ul>
<li><strong>Fault isolation.</strong> A crash becomes EOF on a pipe, not a corrupted heap in the GUI.</li>
<li><strong>Dependency isolation.</strong> A plugin can bring its own runtime and packages without linking them into the application.</li>
</ul>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>Installing a plugin</h2>
<p>The registry is <a href="https://github.com/TabularisDB/tabularis/blob/main/plugins/registry.json">a JSON file in the repo</a> on GitHub, fetched by <a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/src/plugins/registry.rs"><code>src-tauri/src/plugins/registry.rs</code></a>. Installing a plugin, in <a href="https://github.com/TabularisDB/tabularis/blob/main/src-tauri/src/plugins/installer.rs"><code>src-tauri/src/plugins/installer.rs</code></a>, fetches a ZIP over HTTPS, unpacks it into a temporary directory, checks that <code>manifest.json</code> exists and parses, and then atomically renames the directory into place:</p>
<pre><code class="language-rust">fs::rename(&amp;tmp_dir, &amp;final_dir)   // .tmp-&lt;id&gt;  -&gt;  &lt;id&gt;, atomically
    .map_err(|e| format!(&quot;Failed to finalize plugin installation: {e}&quot;))?;
</code></pre>
<p>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.</p>
<p>Three months later, this is still the weakest part of the install story: no signatures, no checksum pinning. The trust chain is &quot;you trust the registry review, and you trust GitHub over HTTPS to deliver the ZIP&quot;. Signing is on the roadmap. Until then, it is better to describe the chain as it is than to imply more security than exists.</p>
<h2>What held up</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>The code is in <a href="https://github.com/TabularisDB/tabularis">the Tabularis repo</a>, mostly under <a href="https://github.com/TabularisDB/tabularis/tree/main/src-tauri/src/plugins"><code>src-tauri/src/plugins/</code></a>. If you want to read a real driver instead of the trait in the abstract, the <a href="https://github.com/TabularisDB/tabularis-google-sheets-plugin">Google Sheets plugin</a> is the one I would start with.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/database-drivers-as-external-processes/opengraph-image.png" type="image/png" />
      <category>plugins</category>
      <category>rust</category>
      <category>architecture</category>
      <category>tokio</category>
      <category>json-rpc</category>
      <category>ipc</category>
    </item>
    <item>
      <title>Translating Tabularis, the Right Way: Why We Chose Tolgee</title>
      <link>https://tabularis.dev/blog/translating-tabularis-why-we-chose-tolgee</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/translating-tabularis-why-we-chose-tolgee</guid>
      <pubDate>Fri, 19 Jun 2026 10:00:00 GMT</pubDate>
      <description>Tabularis ships in 8 languages, but contributing a translation has meant editing raw JSON in a Git diff with no idea where the string lived. We&apos;ve picked Tolgee to change that: open source, self-hostable, context-rich translating. Tolgee is also backing the project while we do it. Here&apos;s the honest why, and what&apos;s coming: community translations from the web, delivered over the air, with in-app translating as the long-term goal.</description>
      <content:encoded><![CDATA[<h1>Translating Tabularis, the Right Way: Why We Chose Tolgee</h1>
<p>Localization is invisible. Nobody opens an app and thinks &quot;wow, great translation infrastructure.&quot; They notice the opposite: a button label cut off mid-word, a date in the wrong order, a tooltip that&#39;s still English when the rest of the menu isn&#39;t. It&#39;s unglamorous, easy-to-postpone work, and it quietly decides whether someone outside your own language ever gets past the first ten minutes with your tool.</p>
<p>Tabularis already ships in <strong>8 languages</strong>: English, Italian, Spanish, Chinese, French, German, Japanese and Russian. So this isn&#39;t a &quot;we should really do i18n someday&quot; post. The bones are there. Under the hood it&#39;s <code>i18next</code> today with a folder of JSON locale files, one per language — though we&#39;re partway through moving that runtime to <a href="https://lingui.dev">Lingui</a>, and I&#39;ll explain why below because it turns out to matter for this decision. The app shows whichever language you pick in settings, and falls back to your system language by default. That part works.</p>
<p>What didn&#39;t work was everything around it. So we went looking for a better way to manage translations, weighed the options, and landed on <a href="https://tolgee.io/?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=why-we-chose-tolgee">Tolgee</a>, who, as it happens, have stepped up to back the project while we do it. To be clear about where we are: this is the outcome of an evaluation, not a finished migration. We&#39;ve chosen Tolgee, but the actual switch is still ahead of us, not behind us. That backing means a lot, and I&#39;ll come back to it. But a tool is only worth writing about if it earns the place on its own, so let me walk through how we got here. It wasn&#39;t a &quot;first result on Google&quot; pick, and the <em>why</em> matters more than the logo.</p>
<h2>The problem was never the strings. It was the context.</h2>
<p>Here&#39;s what contributing a translation looked like before. You&#39;d clone the repo, find <code>src/i18n/locales/</code>, open a 1,200-line JSON file, and start editing values next to keys like <code>editor.toolbar.runSelection</code> and <code>grid.contextMenu.copyAsInsert</code>. No screenshots. No idea whether the string is a button (needs to be short) or a paragraph (can breathe). No way to tell whether <code>&quot;Open&quot;</code> is the verb on a button or the adjective in a connection-status badge.</p>
<p>That last one isn&#39;t a toy example. &quot;Open&quot; as a verb and &quot;Open&quot; as a status are the same four letters in English and two completely different words in German, Japanese, or Russian. A translator working from a flat JSON file gets one of them wrong, every time, because the file doesn&#39;t contain the one thing they actually need: <em>where does this show up?</em></p>
<p>The result is the failure mode every localized app has: translations that are technically correct and obviously machine-shaped. Literal, not native. And the barrier to fixing it (clone, find the file, edit JSON, open a PR, wait for review) is high enough that the people most able to fix it, native speakers who <em>use</em> the app, almost never do.</p>
<p>We didn&#39;t want better JSON files. We wanted to delete the JSON file from the contributor&#39;s mental model entirely.</p>
<h2>Why Tolgee, specifically</h2>
<p>I looked hard at the usual options before settling on anything. Here&#39;s what actually made the decision.</p>
<p><strong>It fits an open-source, privacy-first project.</strong> Tabularis has no account, no telemetry, and stores your secrets in your OS keychain. The <a href="https://tabularis.dev/blog/your-database-gui-shouldnt-need-an-account">whole pitch</a> is that it&#39;s <em>your</em> tool, not a client for someone&#39;s backend. Bolting a closed, VC-funded SaaS onto the one project built around not doing that felt wrong on principle. Tolgee is open source and self-hostable. The values line up instead of quietly fighting each other, and if their pricing or direction ever changes, we own the exit. That&#39;s the same bet we ask our own users to trust.</p>
<p><strong>Context-rich translating is the real unlock.</strong> This is the feature that closed it. With Tolgee, a translator isn&#39;t staring at keys. They see each string <em>with the screen it belongs to</em>: in-context editing while developing, and a translate UI that pairs every string with a screenshot of where it actually appears. &quot;Open&quot; stops being ambiguous the moment you can see it&#39;s a button in the connection panel. That&#39;s the difference between a translation and a <em>literal</em> translation, and between a contributor finishing one string and finishing a hundred.</p>
<p><strong>It isn&#39;t welded to our i18n library, which matters right now.</strong> Here&#39;s the wrinkle: we&#39;re mid-migration from <code>i18next</code> to <a href="https://lingui.dev">Lingui</a> for the app&#39;s runtime. Lingui is ICU-MessageFormat-native, with compile-time message extraction and type-safe macros — for a codebase this size it&#39;s a cleaner, safer foundation than hand-maintained i18next JSON keys, and it stops the &quot;is this key still used?&quot; rot before it starts. A translation platform that was bolted to one specific library would be a liability in the middle of that move. Tolgee isn&#39;t: it manages translations as ICU messages and exports to whatever format the runtime needs — <code>i18next</code> JSON today, Lingui&#39;s catalogs as we cut over. So adopting it removes friction instead of adding a third thing to migrate. And because Lingui is ICU-native, it&#39;s a <em>tighter</em> fit with how Tolgee stores strings internally than i18next ever was — the format round-trip that would otherwise worry me mostly disappears. Our 8 existing languages move over as-is. That&#39;s a big part of why the evaluation pointed here: going from 8 to &quot;a lot more&quot; turns into a throughput problem instead of an engineering one.</p>
<p><strong>Machine translation as a first draft, not a crutch.</strong> Tolgee can pre-fill a new language with machine translation and translation-memory suggestions, which means a human contributor starts from 80% instead of a blank file. They review and correct, which is fast, instead of typing from scratch, which is slow. English stays the source of truth; everything else is a draft until a human who speaks the language signs off.</p>
<h2>What this is <em>not</em></h2>
<p>In the interest of being honest the way we try to be about everything else: this doesn&#39;t make Tabularis magically fluent in 30 languages overnight, and machine translation is not &quot;done.&quot; English remains the canonical source. New strings still start in English in the codebase, and a locale is only as good as the humans who&#39;ve reviewed it. What changes is that reviewing becomes something a native speaker can do in an afternoon from a web browser, instead of a chore that requires Git, a JSON editor, and a tolerance for ambiguity.</p>
<p>We&#39;d rather ship 12 languages that real speakers have actually looked at than claim 40 that a model guessed.</p>
<h2>The part we&#39;re really here for: community translations</h2>
<p>There&#39;s one more reason <a href="https://tolgee.io/?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=why-we-chose-tolgee">Tolgee</a> won, and it&#39;s the one I&#39;m most excited about: <strong>community translations are on <a href="https://tolgee.io/roadmap?utm_source=tabularis.dev&utm_medium=blog&utm_campaign=why-we-chose-tolgee">Tolgee&#39;s public roadmap</a>, tracked in <a href="https://github.com/tolgee/tolgee-platform/issues/1360">issue #1360</a>.</strong> We&#39;re not building that ourselves. We don&#39;t need to, and frankly we couldn&#39;t do it better than a team that does localization for a living. We just want to flip it on the moment it lands.</p>
<p>Go read <a href="https://github.com/tolgee/tolgee-platform/issues/1360">the issue</a>. It describes almost exactly the workflow we want: public projects, where community members can <em>propose</em> translations, project maintainers <em>review and merge</em> them, and a dedicated &quot;translate one string, with a big screenshot of where it lives&quot; UI. That&#39;s the whole game. Contributors get context and a low-stakes way to help; maintainers keep a quality gate; nobody touches Git.</p>
<p>The idea is simple to state and has always been annoying to deliver: anyone should be able to help translate the app, and improving a translation in your own language should take minutes, not a pull request. A community-translation workflow on top of the platform we&#39;ve chosen gets us there without us reinventing the wheel.</p>
<p>Here&#39;s how we see it rolling out:</p>
<ul>
<li><strong>First, from the web.</strong> A hosted project where you pick your language, see what&#39;s translated and what&#39;s missing, and <em>propose</em> fixes in context, with no clone, no JSON, no Git. Maintainers review and merge. That&#39;s the workflow <a href="https://github.com/tolgee/tolgee-platform/issues/1360">#1360</a> describes, and it&#39;s the part that&#39;s coming first.</li>
<li><strong>Delivered over the air.</strong> This is the detail that makes it click. With Tolgee&#39;s Content Delivery, an approved translation can reach you <strong>without waiting for the next app release</strong>. Tabularis still ships every language bundled inside the app. That&#39;s your offline, no-network fallback, and it always works. But when you&#39;re online, improvements can land on top. No account, no API key in the build. Your French gets better on a Tuesday; you don&#39;t wait for v0.14.</li>
<li><strong>Eventually, right inside the app.</strong> The long-run goal: you spot an awkward label while you&#39;re actually using Tabularis, fix it on the spot, and send it upstream. Community translation without ever leaving the app. We&#39;re honest that we&#39;re not there yet; doing it properly while staying offline-first and account-free is the hard part. But that&#39;s the direction, and it&#39;s why we chose Tolgee over patching JSON forever.</li>
</ul>
<p>If you&#39;ve ever wanted to see a tool you use every day speak your language properly, and been put off by the &quot;submit a PR editing this JSON file&quot; barrier, that barrier is on its way out. Your language is going to need you.</p>
<h2>Thanks</h2>
<p>Which brings me back to the part I said I&#39;d return to: <strong>Tolgee is backing Tabularis.</strong> Not as a logo on a sponsors page, but as real support for an independent, open-source project that&#39;s still funded out of pocket. It&#39;s the kind of backing that lets us do localization properly instead of squeezing it between bug fixes. It&#39;s one thing to build good developer tooling; it&#39;s another to put your weight behind the people building open software with it. We don&#39;t take that for granted, and the fact that they&#39;re open source themselves is exactly why it feels like a fit rather than a transaction. If you&#39;re shipping a multi-language app and still wrangling JSON by hand, go look at what they&#39;ve built. It&#39;s genuinely good.</p>
<p>Localization will always be invisible when it&#39;s done right. The least we can do is make it easy for the people who&#39;d notice, and that&#39;s exactly what moving to Tolgee is for.</p>
<p><strong>Want Tabularis in your language?</strong> <a href="https://tabularis.dev/download">Download it</a>, switch the language in settings, and tell us what reads wrong. Issues and PRs are welcome today, and community translations are coming. <a href="https://github.com/TabularisDB/tabularis">Star us on GitHub</a> to follow along, and keep an eye on the <a href="https://tabularis.dev/blog">blog</a>. When Tolgee&#39;s community-translation feature lands, your language is going to need you.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/translating-tabularis-why-we-chose-tolgee/opengraph-image.png" type="image/png" />
      <category>community</category>
      <category>i18n</category>
      <category>localization</category>
      <category>lingui</category>
      <category>open-source</category>
      <category>sponsors</category>
      <category>roadmap</category>
    </item>
    <item>
      <title>Tabularis Joins the Vercel Open Source Program</title>
      <link>https://tabularis.dev/blog/vercel-open-source-program</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/vercel-open-source-program</guid>
      <pubDate>Fri, 19 Jun 2026 08:00:00 GMT</pubDate>
      <description>Tabularis has been accepted into Vercel&apos;s Open Source Program for the Spring 2026 cohort. Twelve months of support for the site you&apos;re reading right now — which is open source, built with Next.js, and about to get a lot better.</description>
      <content:encoded><![CDATA[<h1>Tabularis Joins the Vercel Open Source Program</h1>
<p style="text-align:center;margin:1.5rem 0 2rem;"><img class="no-lightbox" src="https://tabularis.dev/img/posts/vercel-partnership.svg" alt="Tabularis has joined the Vercel Open Source Program — Spring 2026 cohort" style="width:100%;max-width:800px;height:auto;display:block;margin:0 auto;" /></p>

<p>A short while after <a href="https://tabularis.dev/blog/digitalocean-opensource-sponsorship">DigitalOcean welcomed us into their Open Source Credits Program</a>, we got another email we weren&#39;t expecting: <strong>Tabularis has been accepted into the <a href="https://vercel.com/open-source-program">Vercel Open Source Program</a>, in the Spring 2026 cohort.</strong></p>
<p>We&#39;ll be honest about our bias here: we&#39;re genuinely fans of Vercel. We&#39;ve followed the platform and its products for years, and a fair amount of how we think about shipping the web — fast previews, sane defaults, treating the deploy as part of the developer experience rather than an afterthought — has Vercel&#39;s fingerprints on it. So being picked is not a polite &quot;thanks for applying.&quot; It&#39;s getting a nod from a team whose work we already admired. We&#39;re genuinely honored.</p>
<p>The terms are refreshingly simple. Stay fully open source, deploy on Vercel, keep a badge in the README for twelve months. That&#39;s it. No equity, no roadmap strings, no quarterly check-in deck. For a project that&#39;s still run on nights and weekends, an email that says &quot;we like what you&#39;re doing, here&#39;s a year of support, carry on&quot; is a rare kind of thing.</p>
<h2>The part that makes this one different</h2>
<p>The DigitalOcean credits go toward infrastructure for the plugin registry — backend, the stuff users never see directly. The Vercel support lands somewhere more visible, and frankly more fun to talk about.</p>
<p><strong>This site is the thing being supported.</strong> <a href="https://tabularis.dev">tabularis.dev</a> — the page you&#39;re reading this on, the wiki, the changelog, the plugin pages — is a Next.js app, it&#39;s <a href="https://github.com/TabularisDB/website">open source</a>, and it&#39;s the front door to everything else we build. The download links, the docs people land on from a Google search, the release notes that go out every couple of weeks: all of it runs through here.</p>
<p>So this isn&#39;t &quot;a sponsor pays for our servers&quot; in the abstract. It&#39;s the tooling we use to talk to you, getting a real upgrade. There&#39;s something fitting about an open-source database client having an open-source website, both supported by the same kind of program. The whole stack, all the way down, is something you can read, fork, and send a PR to.</p>
<h2>What we&#39;re actually going to do with it</h2>
<p>We&#39;re not going to pretend a year of Vercel turns the site into something it isn&#39;t. But there&#39;s a backlog of things that have been &quot;later&quot; for too long, and &quot;later&quot; just became &quot;now&quot;:</p>
<ul>
<li><strong>Faster previews on every content change.</strong> Most of what we ship here is Markdown — blog posts, wiki pages, comparison pages. Preview deployments mean we can see a post rendered, OG card and all, before it goes live. Fewer typos shipped to production.</li>
<li><strong>A site that keeps up with the app.</strong> Tabularis ships roughly every two weeks. The site has to keep pace — new features documented, the changelog current, the plugin pages accurate. Better build and deploy tooling makes that less of a chore and more of a habit.</li>
<li><strong>The boring-but-important stuff.</strong> Performance, the search index, the things that make the docs actually findable. None of it is glamorous. All of it is what makes a project feel maintained rather than abandoned.</li>
</ul>
<p>If you&#39;ve got an idea for what the site should do better, the <a href="https://github.com/TabularisDB/website">repo is right there</a>. Issues and PRs are open.</p>
<h2>Thanks, and the usual honest note</h2>
<p>To <a href="https://x.com/AmyAEgan">Amy</a> and the team running the Vercel Open Source Program: thank you. Picking a young project out of a stack of strong applications is a vote of confidence, and we don&#39;t take it as a given.</p>
<p>To everyone who&#39;s starred the repo, filed a bug, shipped a plugin, translated a string, or just told a colleague about Tabularis: this is the kind of thing that happens because a project looks alive, and a project looks alive because of you. Two sponsorships in two months isn&#39;t luck. It&#39;s the curve you&#39;ve all been bending upward.</p>
<p>We&#39;ll be sharing this across our channels over the next few days. If you want to help it travel, that&#39;s the best thank-you we could ask for.</p>
<p>The road ahead is the longest part. We&#39;re glad you&#39;re on it with us.</p>
<hr>
<p><em>The Tabularis Team</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/vercel-open-source-program/opengraph-image.png" type="image/png" />
      <category>community</category>
      <category>sponsors</category>
      <category>partnership</category>
      <category>open-source</category>
    </item>
    <item>
      <title>v0.13.2: Notebooks You Can Manage, Query Progress in Real Time, and a Grid That Scrolls</title>
      <link>https://tabularis.dev/blog/v0132-managed-notebooks-live-query-progress-faster-grid</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/v0132-managed-notebooks-live-query-progress-faster-grid</guid>
      <pubDate>Tue, 16 Jun 2026 13:01:00 GMT</pubDate>
      <description>v0.13.2 turns notebooks into a managed, per-connection workspace with undo and a visual history, streams query progress live while a batch runs, makes wide-table scrolling fluid again, teaches autocomplete to read your clauses across databases, and corrects the numbers in Visual EXPLAIN.</description>
      <content:encoded><![CDATA[<h1>v0.13.2: Notebooks You Can Manage, Query Progress in Real Time, and a Grid That Scrolls</h1>
<p><strong>v0.13.2</strong> follows <a href="https://tabularis.dev/blog/v0131-signed-macos-postgres-explain-offset-pagination">v0.13.1</a>, which was a correctness pass. This one is about the surfaces you actually live in — the notebook, the results panel, the grid, the editor — and making them feel responsive and <em>managed</em> rather than write-once. Notebooks stop being files you save into the dark and become a browsable, undoable workspace; the results panel stops waiting for a whole batch to finish before telling you anything; and the grid stops stuttering when the table is wide.</p>
<p>Five external contributors land in this tag.</p>
<hr>
<h2>Notebooks You Can Actually Manage</h2>
<p>SQL Notebooks shipped as a powerful surface, but they were write-only: you created one, ran cells, exported it, and then it disappeared onto disk as a flat file with no way back in from the app. v0.13.2 turns them into a first-class, per-connection workspace in PR <a href="https://github.com/TabularisDB/tabularis/pull/304">#304</a>.</p>
<p>Notebooks are now stored per connection at <code>notebooks/&lt;connectionId&gt;/&lt;id&gt;</code>, with lazy migration of any legacy flat notebooks the first time their connection loads — nothing you saved before is lost. A new <strong>Notebooks</strong> section in the sidebar lists the active connection&#39;s notebooks with search, one-click open, and a context menu to <strong>rename, export, import, delete</strong> (with confirmation), and <strong>Save as HTML</strong>. The list refreshes live: create a notebook and it appears immediately; a rename or delete elsewhere reflects without a manual reload. You can also rename a notebook straight from its editor tab by double-clicking the title.</p>
<p>Editing a notebook is now undoable. Each structural change — adding, removing, reordering, or editing cells — is captured into a timeline, and a <strong>history panel</strong> lets you scrub back through every state and jump to any point, with each entry labeled by what changed. It&#39;s the same instinct as undo in the SQL editor, applied to the whole document.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-notebooks-manage.mp4" poster="/videos/posts/tabularis-notebooks-manage.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<p>If you&#39;ve been treating notebooks as throwaway scratchpads because there was no way to find them again, this is the release that makes them worth keeping.</p>
<hr>
<h2>Query Progress, In Real Time</h2>
<p>Run a multi-statement batch and, until now, the results panel sat blank until the <em>entire</em> batch finished — then every result tab and the timing badge appeared at once. For a script where statement 3 of 12 is slow, that&#39;s a long stretch of staring at nothing.</p>
<p><a href="https://github.com/fzlee">@fzlee</a> rebuilt this in PR <a href="https://github.com/TabularisDB/tabularis/pull/296">#296</a>. Each result tab now resolves <em>progressively</em> as its statement completes, matched by entry id so rapid back-to-back completions never overwrite each other. The summary badge updates live — succeeded and failed counts accumulate while a spinning count shows how many statements are still running — and the elapsed time ticks up on a live wall-clock timer instead of only appearing at the end. When the batch finishes, the ticking estimate snaps to the precise server-measured total. Under the hood, all three SQL drivers gained progressive result reporting and the editor context grew an <code>updateResultEntry</code> that reads the latest state rather than a stale snapshot.</p>
<p>You now watch a long script work through itself, statement by statement, instead of waiting blind for the whole thing.</p>
<p><video src="https://tabularis.dev/videos/posts/tabularis-sql-progress.mp4" poster="/videos/posts/tabularis-sql-progress.jpg" autoplay loop muted playsinline style="width:100%;border-radius:8px;margin:1rem 0"></video></p>
<hr>
<h2>A Grid That Scrolls on Wide Tables</h2>
<p>Scrolling a table with a few hundred rows and 30–40 columns was visibly laggy. The whole <code>&lt;tbody&gt;</code> re-rendered on every scroll tick — no row or cell memoization — and each row was dynamically re-measured as it went.</p>
<p>PR <a href="https://github.com/TabularisDB/tabularis/pull/287">#287</a> fixes the render path. The per-row render is extracted into a <code>React.memo</code> <code>MemoRow</code>, with the stable per-grid dependencies bundled into a single memoized context object so the default shallow compare only re-renders the rows that actually changed; volatile per-row values are passed as primitives. The cell double-click, edit-commit, and keydown handlers are stabilized with <code>useCallback</code> (reading the live editing cell through a ref) so the memo holds, the cell value is formatted once per cell instead of twice, and rows use a fixed height with no per-row measurement — the same proven fixed-size pattern already used by the mini result grid. The default cell renderer moved to a <code>dataGridCell</code> helper with unit tests.</p>
<p>The result: scrolling stays fluid on wide, tall tables instead of dropping frames on every tick.</p>
<hr>
<h2>Autocomplete That Reads Your Clauses</h2>
<p>Two long-standing autocomplete gaps close in PR <a href="https://github.com/TabularisDB/tabularis/pull/295">#295</a>, contributed by <a href="https://github.com/thomaswasle">@thomaswasle</a> (Thomas Müller-Wasle).</p>
<p>First, clause keywords. Once you had a <code>FROM</code> clause, typing past it stopped offering <code>WHERE</code>, <code>ORDER BY</code>, <code>GROUP BY</code>, <code>LIMIT</code> and friends — a guard suppressed keyword suggestions whenever column suggestions were present. Now keyword and column completions are offered together when a <code>FROM</code> clause is in scope.</p>
<p>Second, columns across databases. The table parser was mistaking SQL keywords like <code>WHERE</code>, <code>ON</code>, and <code>HAVING</code> for table aliases, which corrupted the alias map and blocked column lookups. The fix replaces the keyword denylist with proper clause-boundary extraction: it isolates the <code>FROM</code>/<code>JOIN</code> section, strips <code>ON</code>/<code>USING</code> conditions, and captures <code>schema.table</code> qualified notation, so no clause keyword can ever land in the alias capture group. In multi-database mode each table is tagged with its source database, so a <code>db.table.</code> dotted completion resolves by exact <code>(schema, name)</code> pair and unqualified names fall back across every loaded database — MySQL multi-DB connections finally pass the right schema instead of falling back to <code>information_schema</code>. Empty column results are no longer cached, so a transient miss from a not-yet-ready connection can&#39;t poison later lookups.</p>
<hr>
<h2>Visual EXPLAIN Gets Honest Numbers</h2>
<p>Two fixes make the Visual EXPLAIN table view tell the truth about row counts and timing.</p>
<p>PR <a href="https://github.com/TabularisDB/tabularis/pull/302">#302</a> (closes <a href="https://github.com/TabularisDB/tabularis/issues/298">#298</a>) adds an <strong>Actual Rows</strong> column next to Est. Rows. The MariaDB <code>ANALYZE FORMAT=JSON</code> parser already captured actual rows from <code>r_rows</code>, but the table view only ever exposed the estimate; the column now renders whenever analyze data is present, on both MariaDB <code>ANALYZE</code> and Postgres <code>EXPLAIN ANALYZE</code>.</p>
<p>PR <a href="https://github.com/TabularisDB/tabularis/pull/303">#303</a> (fixes <a href="https://github.com/TabularisDB/tabularis/issues/300">#300</a>) corrects MySQL <code>EXPLAIN ANALYZE</code> timing. MySQL&#39;s tree-format output reports <code>time=first..last</code> as the <em>per-loop</em> timing averaged across all iterations, and the table view displayed that per-loop figure directly — so a node executed many times (an index lookup driven by a join, say) reported a tiny per-iteration cost instead of its real total. The parser now scales the per-loop end time by the loop count, so the displayed time is the node&#39;s total wall-clock cost, matching how PostgreSQL&#39;s Actual Total Time relates to Actual Loops.</p>
<hr>
<h2>SSL for Plugin Drivers</h2>
<p>The SSL/TLS tab in the connection modal was hardcoded to the <code>mysql</code> and <code>postgres</code> driver IDs, so plugin drivers — ClickHouse, for instance — could never expose SSL configuration at all.</p>
<p><a href="https://github.com/Aditeya">@Aditeya</a> (Adi) fixes this in PR <a href="https://github.com/TabularisDB/tabularis/pull/309">#309</a> by adding a <code>supports_ssl</code> capability to <code>DriverCapabilities</code> on both the Rust and TypeScript sides. The SSL tab is now gated on that capability instead of a driver-ID allow-list: built-in Postgres and MySQL set the flag, and plugins opt in through their manifest. The tab description is now driver-agnostic, and ClickHouse <code>ssl_mode</code> options (<code>disable</code>/<code>require</code>) are wired in. Plugin authors get a documented, first-class way to surface TLS configuration.</p>
<hr>
<h2>Redis (Go) Plugin: v0.4.1</h2>
<p><a href="https://github.com/gzamboni">@gzamboni</a> (Giovani Zamboni) shipped v0.4.1 of the community <a href="https://github.com/gzamboni/tabularis-redis-plugin-go">Redis (Go) plugin</a>, registered in PR <a href="https://github.com/TabularisDB/tabularis/pull/314">#314</a>. The release fixes connecting to a Redis instance that has no username, and the registry now serves 0.4.1 across Linux, macOS, and Windows. The plugin continues to offer virtual table views for Strings, Hashes, Lists, Sets, and ZSets with native write operations. Install it from <strong>Settings → Plugins</strong>.</p>
<hr>
<h2>Smaller Things</h2>
<ul>
<li><strong>Scroll-bar buttons stay reachable</strong> (<a href="https://github.com/VincentZhangy">@VincentZhangy</a>, PR <a href="https://github.com/TabularisDB/tabularis/pull/315">#315</a>) — the Refresh and Add Table buttons were hard to click once scroll bars appeared in the panel; the layout now keeps them selectable.</li>
<li><strong>README, restructured</strong> (PR <a href="https://github.com/TabularisDB/tabularis/pull/318">#318</a>) — the GitHub landing page got a clearer hero, a fixed <code>.rpm</code> download link, a &quot;Why Tabularis?&quot; comparison table, and reference material (configuration, AI providers, MCP setup) moved to the wiki. All seven translated READMEs were realigned and their stale download links refreshed.</li>
<li><strong>Grid cleanup</strong> — a dead cell renderer and the unused <code>isRawSql</code> plumbing were removed, and the demo seeds gained a 50-column × 50k-row <code>perf_demo</code> wide table (MySQL + Postgres) so the scroll-performance work stays reproducible.</li>
</ul>
<hr>
<h2>Thanks</h2>
<p>Five external contributors land in v0.13.2.</p>
<p><strong><a href="https://github.com/fzlee">@fzlee</a></strong> brings the real-time query progress rework (<a href="https://github.com/TabularisDB/tabularis/pull/296">#296</a>) — the change that makes a long multi-statement batch report itself live instead of finishing in one silent jump.</p>
<p><strong><a href="https://github.com/thomaswasle">@thomaswasle</a> (Thomas Müller-Wasle)</strong> lands the autocomplete overhaul (<a href="https://github.com/TabularisDB/tabularis/pull/295">#295</a>): clause keywords offered alongside columns, and column resolution that finally works across databases instead of corrupting the alias map on a stray keyword.</p>
<p><strong><a href="https://github.com/Aditeya">@Aditeya</a> (Adi)</strong> adds the <code>supports_ssl</code> capability (<a href="https://github.com/TabularisDB/tabularis/pull/309">#309</a>), giving plugin drivers a first-class path to TLS configuration that the SSL tab respects.</p>
<p><strong><a href="https://github.com/gzamboni">@gzamboni</a> (Giovani Zamboni)</strong> shipped Redis (Go) plugin v0.4.1 (<a href="https://github.com/TabularisDB/tabularis/pull/314">#314</a>), fixing usernameless Redis connections.</p>
<p><strong><a href="https://github.com/VincentZhangy">@VincentZhangy</a></strong> is new to the contributor list with the scroll-bar button fix (<a href="https://github.com/TabularisDB/tabularis/pull/315">#315</a>). Welcome.</p>
<p>If you keep notebooks and could never find them again, run long scripts and want to see them progress, scroll wide tables and feel the lag, lean on autocomplete across multiple databases, or read Visual EXPLAIN and want the numbers to be honest — this is the upgrade.</p>
<hr>
<p><em>v0.13.2 is available now. Update via the in-app updater, or download from the <a href="https://github.com/TabularisDB/tabularis/releases/tag/v0.13.2">releases page</a>.</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/v0132-managed-notebooks-live-query-progress-faster-grid/opengraph-image.png" type="image/png" />
      <category>release</category>
      <category>feature</category>
      <category>notebook</category>
      <category>data-grid</category>
      <category>editor</category>
      <category>mysql</category>
      <category>postgres</category>
      <category>plugin</category>
      <category>community</category>
    </item>
    <item>
      <title>We Vibe-Coded a Database-Themed Platformer</title>
      <link>https://tabularis.dev/blog/we-vibe-coded-a-database-themed-platformer</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/we-vibe-coded-a-database-themed-platformer</guid>
      <pubDate>Fri, 12 Jun 2026 10:00:00 GMT</pubDate>
      <description>We gave Fable 5 a single day and accidentally shipped a browser platformer themed entirely around Tabularis — three database worlds, boss mascots, hidden plugins. It&apos;s free, it&apos;s open source, and we&apos;re not game developers. If you are, let&apos;s build new worlds together.</description>
      <content:encoded><![CDATA[<h1>We Vibe-Coded a Database-Themed Platformer</h1>
<p style="text-align:center;margin:1.5rem 0 2rem;"><img class="no-lightbox" src="https://tabularis.dev/img/posts/tabularis-run-demo.gif" alt="Tabularis Run — a Super Mario-style browser platformer themed around Tabularis" style="width:100%;max-width:800px;height:auto;display:block;margin:0 auto;border-radius:8px;" /></p>

<p>We gave <strong>Fable 5</strong> a single day after it dropped and accidentally shipped a video game.</p>
<p>It&#39;s called <strong>Tabularis Run</strong> — a tiny Super Mario-style platformer that runs in your browser, themed entirely around Tabularis. No download, no account, no catch. It&#39;s free, it&#39;s open source, and the whole point is to have a bit of fun — and maybe put Tabularis in front of a few people who&#39;d never have clicked on a database client otherwise.</p>
<p>👉 <strong><a href="https://game.tabularis.dev?utm_source=blog">Play it right now → game.tabularis.dev</a></strong></p>
<h2>How it plays</h2>
<p>Run, jump, climb network cables and fire SQL &quot;queries&quot; across <strong>three worlds</strong> — SQLite, MySQL and PostgreSQL — each one ending in a boss fight against that database&#39;s mascot: a hummingbird, a dolphin, and an elephant.</p>
<ul>
<li><strong>12 levels</strong>, including a vertical <em>WAL ascent</em> climb</li>
<li><strong>27 hidden plugins</strong> to collect, plus power-ups — an MCP gun, an Index shield, a Vertical Scaling RAM stick</li>
<li><strong>SQL flavor everywhere</strong>: <code>COMMIT;</code> is the flag at the end of a level, <code>ROLLBACK</code> is what happens when you die, <code>BEGIN;</code> marks your checkpoints</li>
<li><strong>4 playable characters</strong>: TAB, PRIMARY KEY, CURSOR and TRIGGER</li>
<li>Plays on <strong>desktop</strong> (keyboard or gamepad) and <strong>mobile</strong> (touch controls)</li>
</ul>
<p>Every pixel is procedural — the sprite art is drawn from character grids, the music is WebAudio chiptune, there are zero external assets and zero runtime dependencies. It&#39;s just vanilla JavaScript and a <code>&lt;canvas&gt;</code>. Beat it and you can share a generated score card straight to your socials.</p>
<h2>Full disclosure: we&#39;re not game developers</h2>
<p>Let&#39;s be honest about how this got made, because it matters.</p>
<p>We&#39;re not game designers. We&#39;re not really game developers either. A good chunk of <strong>Tabularis Run is straight-up vibe-coded</strong> — we described what we wanted, Fable 5 wrote a lot of it, and we steered. The physics are tuned by feel, the level design is whatever felt fun at 1am, and an actual professional would probably wince at some of the choices.</p>
<p>And that&#39;s kind of the point. It&#39;s the same thing we keep writing about on this blog — <a href="https://tabularis.dev/blog/fable-5-opened-a-1800-line-pr-in-30-minutes">we handed Fable 5 a real task on the Tabularis codebase and it opened an 1,800-line PR in 30 minutes</a>. A whole game in a day is just the playful version of the same shift: the gap between &quot;we have an idea&quot; and &quot;it&#39;s live on the internet&quot; is collapsing.</p>
<h2>Want to build a world?</h2>
<p>Here&#39;s where you come in.</p>
<p>The game is <strong>fully open source</strong>, and we&#39;d genuinely love for people who know what they&#39;re doing to jump in. Tweak the physics, fix our questionable level design, add enemies, or — the fun one — let&#39;s design <strong>entirely new worlds together</strong>. The engine already supports three; there&#39;s no reason it has to stop there. New database mascots, new mechanics, new bosses: it&#39;s all on the table.</p>
<p>👉 <strong><a href="https://github.com/TabularisDB/game">Game source on GitHub → github.com/TabularisDB/game</a></strong></p>
<p>The codebase is small and approachable on purpose: levels are authored in a little grid DSL, sprites are character grids, and there&#39;s a test suite that validates every level is actually beatable. PRs are very welcome, and if you want to riff on an idea first, the Discord is the place.</p>
<h2>Why a game, though?</h2>
<p>Because Tabularis grows when people hear about it — and a game travels in places a database client never will. Someone shares a high score, a friend asks &quot;wait, what&#39;s Tabularis?&quot;, and the curve nudges upward.</p>
<p>So if you enjoy it, the single best thing you can do is <strong>share it</strong> — a clip, a screenshot, your best run. Have fun with it. That&#39;s the whole brief.</p>
<p>And if you came here for the actual database client: <a href="https://tabularis.dev?utm_source=blog">Tabularis</a> is a free, open-source database client for the AI era — one fast, native app for SQLite, MySQL, PostgreSQL and many more. The game is just a love letter to it.</p>
<hr>
<p><em>The Tabularis Team</em></p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/we-vibe-coded-a-database-themed-platformer/opengraph-image.png" type="image/png" />
      <category>fable</category>
      <category>game</category>
      <category>experiment</category>
      <category>community</category>
      <category>open-source</category>
    </item>
    <item>
      <title>Fable 5 Opened a 1,800-Line PR on Tabularis in 30 Minutes</title>
      <link>https://tabularis.dev/blog/fable-5-opened-a-1800-line-pr-in-30-minutes</link>
      <guid isPermaLink="true">https://tabularis.dev/blog/fable-5-opened-a-1800-line-pr-in-30-minutes</guid>
      <pubDate>Wed, 10 Jun 2026 10:00:00 GMT</pubDate>
      <description>A Tabularis dev experiment: we asked Fable 5 to add a CLI to the app and went to make coffee. We came back to a draft PR — 1,800 lines of Rust, 37 tests, a refactor we&apos;d have done ourselves, and two pre-existing bugs quietly fixed.</description>
      <content:encoded><![CDATA[<h1>Fable 5 Opened a 1,800-Line PR on Tabularis in 30 Minutes</h1>
<blockquote>
<p><em><strong>Daily Dev Experiment</strong> — a short series where we hand a real task to an AI on the Tabularis codebase and report exactly what happened. No staged demos. Today&#39;s run got a little out of hand.</em></p>
</blockquote>
<p>We asked <strong>Fable 5</strong> (Anthropic&#39;s new model) to add a command-line interface to the app. That&#39;s less trivial than it sounds: this is a Tauri app — Rust backend, React frontend — so &quot;a CLI&quot; means reaching every saved connection (keychain passwords, SSH/K8s tunnels, plugin drivers) <strong>without</strong> booting the GUI or starting the Tauri runtime at all.</p>
<p>One prompt. A coffee break. A draft PR waiting at the end of it.</p>
<p>Here&#39;s what was in it.</p>
<h2>The numbers</h2>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td>Lines added</td>
<td><strong>1,810</strong></td>
</tr>
<tr>
<td>Lines removed</td>
<td>319</td>
</tr>
<tr>
<td>Files touched</td>
<td>16</td>
</tr>
<tr>
<td>New unit tests</td>
<td><strong>37</strong> (full suite: 692 passing)</td>
</tr>
<tr>
<td>Pre-existing bugs fixed</td>
<td><strong>2</strong> — we didn&#39;t ask for either</td>
</tr>
<tr>
<td>Wall-clock time</td>
<td><strong>~30 minutes</strong></td>
</tr>
</tbody></table>
<p>We&#39;ve spent longer naming a variable.</p>
<p><video class="video-compact" src="https://tabularis.dev/videos/posts/tabularis-cli-fable.mp4" poster="/videos/posts/tabularis-cli-fable.jpg" controls autoplay loop muted playsinline></video></p>
<h2>What we expected</h2>
<p>A coherent set of <code>clap</code> subcommands, addressed by connection id <strong>or</strong> name:</p>
<table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>tabularis connections</code> (<code>ls</code>)</td>
<td>List saved connections — table or <code>--json</code></td>
</tr>
<tr>
<td><code>tabularis databases &lt;conn&gt;</code></td>
<td>List databases on the server</td>
</tr>
<tr>
<td><code>tabularis schemas &lt;conn&gt;</code></td>
<td>List schemas</td>
</tr>
<tr>
<td><code>tabularis tables &lt;conn&gt;</code></td>
<td>List tables (<code>-d</code>, <code>--schema</code>, <code>--json</code>)</td>
</tr>
<tr>
<td><code>tabularis describe &lt;conn&gt; &lt;table&gt;</code></td>
<td>Columns, indexes, foreign keys</td>
</tr>
<tr>
<td><code>tabularis query &lt;conn&gt; [SQL]</code> (<code>q</code>)</td>
<td>One-shot query, stdin pipe, or interactive shell</td>
</tr>
<tr>
<td><code>tabularis install-cli</code></td>
<td>Symlink the binary into a <code>PATH</code> directory</td>
</tr>
</tbody></table>
<p>The detail we like most: <code>query</code> picks its mode from the invocation. SQL argument → one-shot. Piped stdin → executes the piped statement. Interactive TTY with no SQL → drops into a proper SQL shell.</p>
<pre><code class="language-bash"># one-shot query, pipe-friendly
tabularis query my-db &quot;select id, name from customers&quot; --format csv &gt; out.csv

# pipe a statement in
echo &quot;select count(*) from orders&quot; | tabularis q my-db

# no SQL on a TTY → drop into a proper SQL shell
tabularis query my-db
</code></pre>
<p>The interactive shell is backed by <code>rustyline</code>: line editing, persistent history (<code>cli_history.txt</code> in the app config dir), multi-line statements that fire on a terminating <code>;</code>, <code>Ctrl-C</code> drops the current buffer, <code>Ctrl-D</code> exits. Plus psql-style meta commands:</p>
<pre><code>\l    list databases        \f table|json|csv   output format
\dn   list schemas          \limit N            row limit (0 = unlimited)
\dt   list tables           \schema NAME        set schema
\d T  describe table        \use DB             switch database
\q    quit                  \?                  help
</code></pre>
<p>Result data goes to <strong>stdout</strong>, logs go to <strong>stderr</strong> — so piping stays clean, and you get a non-zero exit code on failure. Exactly the kind of detail a human means to remember and usually forgets.</p>
<p>It even kept the GUI-launch fallback intact: macOS still passes junk like <code>-psn_*</code> to the binary on launch, and that must keep booting the GUI — while a <em>misspelled subcommand</em> should surface clap&#39;s error instead of silently opening a window. It threaded that needle, and wrote tests asserting the exact <code>clap</code> error kinds that fall through to the GUI versus the ones that don&#39;t.</p>
<h2>What we did NOT expect</h2>
<p><strong>1. It found the refactor before writing a single feature.</strong></p>
<p>The app already ships an <a href="https://tabularis.dev/wiki/mcp-server">MCP server</a>, and that server already knew how to resolve a saved connection — decrypt the keychain password, open the SSH/K8s tunnel, register the right plugin driver. Instead of duplicating all of that for the CLI, Fable 5 dug through the codebase, realized the logic was buried inside <code>mcp/mod.rs</code>, and <strong>pulled it out into a shared <code>headless.rs</code> module</strong> that both the MCP server and the new CLI consume. The MCP server now just wraps the shared helpers with its JSON-RPC error type — zero behavior change on that side.</p>
<p>That&#39;s 242 lines deleted from <code>mcp/mod.rs</code> and a 214-line module born. It&#39;s the refactor <em>we</em> would have done — unprompted, because it understood why duplicating connection resolution was the wrong move.</p>
<p>It also didn&#39;t dump everything into one file. The old 52-line <code>cli.rs</code> became a real module:</p>
<pre><code>src-tauri/src/cli/
├── mod.rs       clap definitions, GUI-fallback logic     (182 lines)
├── run.rs       command dispatch + execution             (338 lines)
├── repl.rs      the interactive shell                    (282 lines)
├── output.rs    table / JSON / CSV rendering             (138 lines)
├── install.rs   install-cli symlink logic                 (97 lines)
└── *_tests.rs   args, output, run, install               (442 lines)
</code></pre>
<p><strong>2. It fixed two real bugs that were already there.</strong></p>
<ul>
<li><code>keychain_utils</code> was logging with <code>println!</code>, dumping straight to <strong>stdout</strong>. Harmless in a GUI — silently corrupting any piped CSV/JSON the moment a CLI exists. And it was bypassing the in-app log buffer too. It rerouted everything through the <code>log</code> crate.</li>
<li>Headless processes never called <code>sqlx::any::install_default_drivers()</code>, so the default connection-test path <strong>panicked</strong>. It installed the drivers in the shared <code>headless::register_drivers()</code> — which also quietly fixed the existing <code>--mcp</code> mode.</li>
</ul>
<p>Neither was in the prompt. It found them because it actually traced the execution path from &quot;user pipes a query&quot; to &quot;bytes hit the terminal&quot; and noticed what would break along the way.</p>
<p><strong>3. It handled multi-database connections properly.</strong></p>
<p>Multi-db connections used to resolve to their first database, which made the others unreachable outside the GUI. Every db-scoped command now takes <code>-d/--database</code>, and the shell&#39;s <code>\use &lt;db&gt;</code> doesn&#39;t just flip a variable — it <strong>validates the switch with a connection test</strong> before applying it, and the prompt shows where you are (<code>Demo · MySQL:blog_demo&gt;</code>). Under the hood it reuses the GUI&#39;s per-call database-override semantics (<code>DatabaseSelection::Single</code>) instead of inventing a parallel mechanism.</p>
<p><strong>4. It tested its own work against a live database.</strong></p>
<p>The 37 unit tests aren&#39;t padding: clap parsing (including the GUI-fallback error kinds), table/CSV/JSON rendering (column alignment, control-character escaping, CSV quoting), limit and database-override semantics, and the <code>install-cli</code> symlink logic — idempotency, refusal to clobber a foreign file, <code>--force</code>.</p>
<p>Then it went further: it ran the new shell against a real MySQL connection, switched across the three demo databases with <code>\use</code>, eyeballed the table/CSV/JSON output, and fixed the formatting it didn&#39;t like — before handing over the PR.</p>
<h2>So... do we trust it?</h2>
<p>No. It&#39;s a <strong>draft</strong> PR and we&#39;re reviewing every line before anything ships. There are real limitations — which, to its credit, it flagged itself in the PR description:</p>
<ul>
<li>On Windows release builds the binary has no attached console (<code>windows_subsystem = &quot;windows&quot;</code>), so CLI output is invisible there. Same constraint the <code>--mcp</code> mode always had.</li>
<li>Each shell statement runs on its own pooled connection, so session state — <code>SET</code>, transactions, temp tables — doesn&#39;t persist between statements. It even documents that caveat inside <code>\?</code>.</li>
</ul>
<p>But here&#39;s the part that stuck with us: the review is genuinely <em>worth doing</em>. This isn&#39;t autocomplete spitting out a function body. It&#39;s an agent that read the architecture, found the seam we&#39;d have found, refactored toward it, and cleaned up two messes on the way out — then wrote 37 tests and a PR description more thorough than most humans bother with.</p>
<p>Two years ago this was Tab-complete. Today it&#39;s a coworker whose work we have to code-review.</p>
<p>👉 <strong>Read the full PR — every line, the real description:</strong> <a href="https://github.com/TabularisDB/tabularis/pull/313">#313</a></p>
<h2>Where this is going</h2>
<p>This experiment isn&#39;t a side quest — it&#39;s the direction. We&#39;re building a database client that&#39;s native to this new workflow: a built-in <a href="https://tabularis.dev/wiki/mcp-server">MCP server</a> so agents like the one in this post can work against your databases, with <a href="https://tabularis.dev/wiki/mcp-approval-gates">approval gates</a>, a <a href="https://tabularis.dev/wiki/mcp-readonly-mode">read-only mode</a>, and a full <a href="https://tabularis.dev/wiki/ai-audit-log">audit log</a> so they do it on your terms. An <a href="https://tabularis.dev/wiki/ai-assistant">AI assistant</a> that also runs on open-source models — locally via Ollama, with zero schema data leaving your machine. And now a CLI, born from that same headless core.</p>
<p>An agent wrote today&#39;s feature. The point is that tomorrow, your agents get a first-class, safe way to use it.</p>
]]></content:encoded>
      <enclosure url="https://tabularis.dev/blog/fable-5-opened-a-1800-line-pr-in-30-minutes/opengraph-image.png" type="image/png" />
      <category>ai</category>
      <category>fable</category>
      <category>rust</category>
      <category>cli</category>
      <category>experiment</category>
      <category>open-source</category>
    </item>
  </channel>
</rss>
