{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Tabularis Blog",
  "home_page_url": "https://tabularis.dev/blog",
  "feed_url": "https://tabularis.dev/feed.json",
  "description": "Releases, guides and product notes from Tabularis — the open-source desktop database client.",
  "language": "en",
  "authors": [
    {
      "name": "Andrea Debernardi"
    }
  ],
  "items": [
    {
      "id": "https://tabularis.dev/blog/tabularis-dev-rebuild-story",
      "url": "https://tabularis.dev/blog/tabularis-dev-rebuild-story",
      "title": "When the Project Outgrows the Site: The tabularis.dev Rebuild Story",
      "summary": "How we rebuilt tabularis.dev: what we kept, what we removed, and how we gave the code a structure that can grow with the project.",
      "content_html": "<h1>When the Project Outgrows the Site: The tabularis.dev Rebuild Story</h1>\n<p>Tabularis.dev did its job well. It was built by developers whose expertise is databases and desktop software, not frontend, and AI helped them build a solid site quickly. It presented the project clearly and helped it reach a growing community.</p>\n<p>But a project doesn&#39;t stand still. As Tabularis grew, with more features, more plugins, more docs and more new visitors, the site had more to say than its original structure could hold. The goal of the rebuild wasn&#39;t to fix something broken, but to give the site room to grow with the project.</p>\n<p>Here is how the rebuild went, and what each step taught me.</p>\n<h2>Understand what already exists before changing anything</h2>\n<p>Before opening any design tool, I reviewed the site page by page: what was there, what each page was trying to say, and where a first-time visitor would probably get lost or leave.</p>\n<p>Three things stood out. The navigation and the page hierarchy were not obvious to someone arriving for the first time. Some content was dense where it should have been easy to scan. And the homepage, the page that matters most for convincing someone to stay, described the project without really explaining why it was worth trying.</p>\n<p>Here is what the top of the homepage looked like before the rebuild:</p>\n<img src=\"https://tabularis.dev/img/posts/tabularis-homepage-hero-before.png\" alt=\"Homepage hero section before the redesign\" loading=\"lazy\">\n\n<p><em>The homepage hero before the rebuild.</em></p>\n<p>I also looked at how well-known developer tool sites handle the same problem: Stripe, Vercel and Linear. The goal was not to copy their look, but to learn from two things. First, their navigation stays simple as the product grows, with a few clear entries instead of links to everything. Second, their design is consistent: the same spacing, the same type sizes and the same few colors on every page. This meant I didn&#39;t have to guess what &quot;clean&quot; looks like. I could reuse rules that already work.</p>\n<h2>Prototype before writing code</h2>\n<p>Next, I moved to Figma. There were no formal rounds of review, just a continuous process: sketch a direction, test it with the real content, adjust, and move on to the next page.</p>\n<p>Deciding the structure of a page in Figma is much cheaper than changing it later in code. It also gave everyone something concrete to react to before any development started.</p>\n<h2>Question every piece before keeping it</h2>\n<p>For each page, I asked whether every section was really useful, instead of keeping it just because it was already there.</p>\n<p>The homepage features section was the clearest example. It had 14 feature cards, all with the same visual weight. I kept the 4 that matter most for a first visit, and moved the full list to the <a href=\"https://tabularis.dev/solutions\">solutions page</a>, which is linked from the homepage. Fourteen cards of equal importance don&#39;t help a new visitor understand a project, they only delay the moment they decide whether it&#39;s worth their time.</p>\n<p>Two other sections were removed: one about the app&#39;s visual themes, and one introducing the <a href=\"https://tabularis.dev/wiki\">wiki</a>. Both have their place on the site, but not on a homepage whose job is to convince first-time visitors.</p>\n<p>The other pages went through the same review, including the <a href=\"https://tabularis.dev/download\">download page</a>, the <a href=\"https://tabularis.dev/compare\">compare pages</a> and the <a href=\"https://tabularis.dev/plugins/bounties\">plugin bounty board</a>. The question was always the same: does this help the reader make a decision, or is it here only because it was easy to add?</p>\n<p>The bounty board is a good example. Its header used to show a complex driver map with thirteen targets, four status colors, a legend for those colors, and three buttons, all before the visitor had finished reading the title. The new version has the same goal, showing which databases still need a driver, but it uses six database logos around the Tabularis logo and two buttons instead of three. The information is the same, and it&#39;s much easier to read.</p>\n<div class=\"post-gallery\">\n  <img src=\"https://tabularis.dev/img/posts/tabularis-bounty-board-before.png\" alt=\"Plugin bounty board hero with a driver map of thirteen targets, a status legend and three calls to action\" loading=\"lazy\">\n  <img src=\"https://tabularis.dev/img/posts/tabularis-bounty-board-after.png\" alt=\"Plugin bounty board hero simplified to six database logos orbiting the product mark with two calls to action\" loading=\"lazy\">\n</div>\n\n<p><em>The bounty board header, before (left) and after (right).</em></p>\n<h2>Make the visual identity consistent</h2>\n<p>Removing what wasn&#39;t needed was only half of the work. The other half came from the lesson of Stripe, Vercel and Linear: a site looks clean when the same few rules are used everywhere.</p>\n<p>Tabularis didn&#39;t have those rules yet. There was only one logo file, also used as the favicon, and the whole site used a monospace font, even for body text. Each page also had its own style, with different spacing, different cards and different uses of color. Going from one page to another felt like visiting different products.</p>\n<p>Now the whole site shares one visual language. It uses two fonts, each with a clear role: Urbanist for the interface and for reading, and JetBrains Mono only for code and data. It has one color palette, and the same spacing and components on every page. The logo and the icon now exist in color, white and black, so they work on any background. The <a href=\"https://tabularis.dev/brand\">brand kit</a> makes all of this available to anyone who writes about Tabularis or builds something around it.</p>\n<h2>The hardest pages</h2>\n<p>Two parts of the rebuild took much longer than the rest.</p>\n<p>The first was the download page. It looks like the simplest page on the site, since you just pick your platform and get the file, which is exactly why it has to be perfect. Every extra click or unclear option can make someone leave before downloading, so I spent more time on it than its size suggests.</p>\n<p>The second was the content pages: <a href=\"https://tabularis.dev/blog\">blog posts</a> and solution pages. Their content is written in Markdown rather than placed in a fixed layout, so every type of content needed its own design and testing: all heading levels, code blocks, videos and images. A content page is not one component but a small set of components that must look good together, whatever combination an article uses. That made it much harder to finish than a page with a fixed layout.</p>\n<h2>Fix the foundations, not just the design</h2>\n<p>A new design on top of the old code would only have moved the problem. So once the design direction was clear, I reviewed the code the same way I had reviewed the pages.</p>\n<p>Some parts were in good shape. The routing was well organized, and SEO and Open Graph images were already handled well, better than in most projects of this size.</p>\n<p>Other parts were not. All the styles of the site were in a single globals.css file, with about 11,200 lines of code, or 13,000 lines including comments and blank lines. With a file like this, there was no reliable way to know whether a rule was still used. Changing a class was risky, because it might also affect three other pages. Class names conflicted between unrelated components, and the file was too large to work with confidently. The components had the same problem: all 65 of them were in a single folder, with no organization by feature.</p>\n<p>The rebuild moved all the styles to CSS Modules, page by page during the redesign, and component by component for shared pieces. Each component now has its own style file next to it, with styles that only apply to that component: about 100 small files instead of one huge one. The components follow the same logic, with shared layout and UI components on one side, and one folder for each part of the site on the other, such as the homepage, the blog, the wiki and the download page.</p>\n<p>The global stylesheet is still there, but it went from 13,000 lines to about 300. It now only contains what really needs to be global: shared variables and basic layout classes. Changing the style of one component no longer means checking the entire site.</p>\n<p>You don&#39;t have to take my word for it: the site is open source, and you can explore its structure in <a href=\"https://github.com/TabularisDB/website\">the website repository</a>.</p>\n<h2>The result</h2>\n<p>The rebuild took several weeks, alongside other work. Here is how the code changed:</p>\n<table>\n<thead>\n<tr>\n<th></th>\n<th>Before</th>\n<th>After</th>\n<th>Change</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>Files</td>\n<td>145</td>\n<td>288</td>\n<td>+99%</td>\n</tr>\n<tr>\n<td>Lines of code</td>\n<td>28,533</td>\n<td>21,453</td>\n<td>-25%</td>\n</tr>\n<tr>\n<td>Total lines</td>\n<td>32,295</td>\n<td>24,338</td>\n<td>-25%</td>\n</tr>\n</tbody></table>\n<p>Almost twice as many files, and a quarter less code. That&#39;s not a contradiction, it&#39;s the goal: each file now has one clear job, and the code has a logical place instead of piling up in whichever file was already open. There is simply less code to read, review and maintain.</p>\n<p>The change is not only in the code. The homepage now explains the project in the few seconds a new visitor gives it, and the next person who works on the site will find code that is easy to change. The best way to judge is to explore the site, starting with the <a href=\"https://tabularis.dev/download\">download page</a> or a few <a href=\"https://tabularis.dev/demos\">demos</a>.</p>\n<h2>Before your project outgrows its site</h2>\n<p>If your project is growing and its site &quot;just works&quot;, the real question is not whether it looks fine today. It&#39;s whether it will still hold up when more people look at it closely, when traffic grows, and after six more months of changes that nobody has planned yet.</p>\n<p>Thanks to <a href=\"https://github.com/debba\">Debba</a> for the trust and the freedom to rethink everything.</p>\n<p>What still feels rough when you visit tabularis.dev? Tell us on <a href=\"https://github.com/TabularisDB/tabularis/discussions\">GitHub Discussions</a> or <a href=\"https://discord.com/invite/K2hmhfHRSt\">Discord</a>.</p>\n",
      "image": "https://tabularis.dev/blog/tabularis-dev-rebuild-story/opengraph-image.png",
      "date_published": "2026-10-05T14:00:00",
      "tags": [
        "ux",
        "ui",
        "design",
        "ai",
        "open-source",
        "website"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0260-postgres-tab-transactions-command-palette-accessible-themes",
      "url": "https://tabularis.dev/blog/v0260-postgres-tab-transactions-command-palette-accessible-themes",
      "title": "v0.26.0: Transactions That Span Runs, a Command Palette That Acts, and Themes That Reach Every Pixel",
      "summary": "v0.26.0 keeps a PostgreSQL transaction open across runs of the same tab, turns the command palette into an action surface, routes every UI color through the theme with WCAG AA checks in CI, and cancels timed-out plugin queries on the server.",
      "content_html": "<h1>v0.26.0: Transactions That Span Runs, a Command Palette That Acts, and Themes That Reach Every Pixel</h1>\n<p><strong>v0.26.0</strong> follows <a href=\"https://tabularis.dev/blog/v0250-installable-themes-aws-ssm-update-badges-mcp-toon\">v0.25.0</a>. Where that release built the theme system and the plumbing around connections, this one is about the editor session and the surface you drive it from. A PostgreSQL editor tab now behaves like a session: a transaction you open in one run is still open in the next, and a <strong>TX</strong> badge says so. The command palette grows from navigation into an action surface for the editor, the result grid and saved connections, and the shortcut editor learns to detect conflicts and work on any keyboard layout. The theme engine from v0.25.0 finally reaches the whole UI, and a new CI workflow keeps it there with contrast and accessibility checks. Driver plugins can own the SQL that <strong>Generate SQL</strong> produces, and a plugin query that runs past its timeout, now a setting, is cancelled on the server instead of left running. The grid remembers where you scrolled. Around the release, the registry gained a Cassandra and ScyllaDB plugin, an Oracle plugin and its first theme package, Ember, and the PostgreSQL and SQL Server plugins moved to new pre-releases. The rest is a set of community fixes, most of them for things that only break on someone else&#39;s machine.</p>\n<hr>\n<h2>A Tab Keeps Its Transaction Between Runs</h2>\n<p>PR <a href=\"https://github.com/TabularisDB/tabularis/pull/801\">#801</a> by <a href=\"https://github.com/egertaia\">@egertaia</a> fixes the workflow a transaction exists for. A batch already ran its statements on one pooled connection, so <code>BEGIN … COMMIT</code> inside a single script worked. Splitting it did not:</p>\n<pre><code class=\"language-sql\">-- Run 1\nBEGIN;\nUPDATE accounts SET balance = 0 WHERE id = 42;\n\n-- Run 2: check before committing\nSELECT balance FROM accounts WHERE id = 42;\n\n-- Run 3\nCOMMIT;\n</code></pre>\n<p>Between runs the connection went back to the pool, so run 2 could land on a different connection and show pre-transaction data, and run 3 could report <code>there is no transaction in progress</code> while the real transaction stayed open somewhere else. Worse, the pool did not reset connections on return, so the stranded connection, still holding its locks, could be handed to an unrelated query that then ran inside someone else&#39;s transaction.</p>\n<p>An editor tab is now a session. When a run leaves an explicit transaction open, the tab&#39;s connection is pinned to it instead of returning to the pool, and the next run from the same tab continues the same transaction. The tab shows a <strong>TX</strong> badge while that is the case, with the hint <em>&quot;Transaction open — this tab keeps its connection until you COMMIT or ROLLBACK&quot;</em>, because its uncommitted changes are invisible to every other tab. <code>COMMIT</code>, <code>ROLLBACK</code>, closing the tab or 30 minutes of inactivity release it. A pinned connection is never handed back to the pool without a <code>ROLLBACK</code> first.</p>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-tab-transaction.mp4\" poster=\"/videos/posts/tabularis-tab-transaction.jpg\" autoplay loop muted playsinline></video></p>\n<p>Pinning is lazy: a tab that never opens a transaction holds nothing, so ten idle tabs still cost no connections. Transaction control is recognized from a statement&#39;s leading keywords only (<code>BEGIN</code>, <code>START TRANSACTION</code>, <code>COMMIT</code>, <code>END</code>, <code>ROLLBACK</code>, <code>ABORT</code>, <code>PREPARE TRANSACTION</code>, and <code>... AND CHAIN</code>, which ends one transaction and opens the next), so a <code>BEGIN</code> inside a string literal or a PL/pgSQL body is not mistaken for one, and <code>ROLLBACK TO SAVEPOINT</code> leaves the transaction open. A failed ordinary statement leaves the session pinned, since PostgreSQL keeps the aborted transaction open until you roll it back from the same tab; a failed <code>COMMIT</code> releases it, since PostgreSQL has already rolled back. Paging through a result, counting rows, exporting and <strong>Copy all rows</strong> go through the tab&#39;s session too, so they see the tab&#39;s own uncommitted changes. Closing a tab releases its connection only after the unsaved-file prompt, so cancelling that prompt cannot discard a live transaction, and every close path, a disconnect and the app exit release pinned sessions.</p>\n<p>This works on the built-in PostgreSQL driver and on the <a href=\"https://github.com/TabularisDB/tabularis-postgresql-plugin\">PostgreSQL plugin</a> from 1.0.0-rc.5, where the companion change landed in plugin PR <a href=\"https://github.com/TabularisDB/tabularis-postgresql-plugin/pull/124\">#124</a>, carried by <a href=\"https://github.com/aesslinger\">@aesslinger</a> on top of @egertaia&#39;s original work. The wire format is backward compatible in both directions: an older plugin keeps the old per-run behaviour, and an older host never sends a session id. Other drivers are unchanged. One behaviour change is worth flagging: a batch that leaves a transaction open with no session to pin it to, such as a non-editor caller, is now rolled back rather than returned to the pool as it was.</p>\n<hr>\n<h2>The Command Palette Does Things</h2>\n<p><a href=\"https://github.com/verbaux\">@verbaux</a> turns the command palette from a navigation box into an action surface in PR <a href=\"https://github.com/TabularisDB/tabularis/pull/815\">#815</a>. Actions and database objects are now searched together from one input, opened with the new <strong>Command Palette</strong> shortcut, <code>⌘+K</code> / <code>Ctrl+K</code>; <code>⌘+P</code> and <code>⌘+Shift+A</code> still open it scoped to objects or to actions. What it offers depends on where you are:</p>\n<ul>\n<li><strong>Editor</strong>: run the query, run all statements, save the SQL file, close the active tab, open a new console.</li>\n<li><strong>Results</strong>: copy the selected cells, rows or columns, copy a column&#39;s values as a SQL <code>IN (...)</code> list, or copy all rows. This works in tabbed and stacked result views, includes pending inserted rows, and the label states the same row count the command will copy.</li>\n<li><strong>Connections</strong>: saved connections are listed and connect, or switch to the already open one, directly.</li>\n<li><strong>Tables</strong>: inspect, generate SQL, count rows or open in a SQL console.</li>\n</ul>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-command-palette-actions.mp4\" poster=\"/videos/posts/tabularis-command-palette-actions.jpg\" autoplay loop muted playsinline style=\"width:100%;border-radius:8px;margin:1rem 0\"></video></p>\n<p>Root commands such as Open settings and Open connection manager stay available while the editor is loading or showing its error boundary, so the palette is a way out of a broken state rather than part of it.</p>\n<p>The same contributor reworked shortcut handling in PR <a href=\"https://github.com/TabularisDB/tabularis/pull/773\">#773</a>. <strong>Settings → Keyboard Shortcuts</strong> opens a dedicated editor for each binding that records the combination, refuses one without a modifier (<em>&quot;Add a modifier key, such as Ctrl or Alt.&quot;</em>), and names the shortcut it would collide with (<em>&quot;This shortcut conflicts with “Toggle sidebar”.&quot;</em>) instead of silently overriding it. Shortcuts now track the physical key as well as the character, so switching connections with <code>Ctrl+Shift+1–9</code>, the palette shortcut and your own overrides keep working on AZERTY, QWERTZ and other layouts; an override matches either the key or its position, so on some layouts one shortcut has two triggers, which is intended. <strong>Open settings</strong> (<code>⌘+,</code> / <code>Ctrl+,</code>) is a new, remappable shortcut, notebook shortcuts appear in their own category, and closing Settings returns to where you came from.</p>\n<hr>\n<h2>Themes Reach Every Pixel, and CI Checks Them</h2>\n<p>v0.25.0 made themes installable packages. It also exposed how little of the UI honoured them. The engine defines 38 color tokens, two font stacks and four radii, but with the default themes nobody could tell that a large part of the interface ignored them, because the hardcoded Tailwind blue, red and green happened to match. Under Ember&#39;s amber accents, a square-cornered theme or any light theme, the app split into two palettes.</p>\n<p>PR <a href=\"https://github.com/TabularisDB/tabularis/pull/809\">#809</a> routes everything through the tokens. Status banners take their colors from the theme&#39;s accents instead of a fixed <code>:root</code>. Rounded corners follow <code>layout.borderRadius</code>, so square-corner themes, the built-in High Contrast included, are square everywhere. The grid paints modified, new and deleted rows and primary-key, foreign-key and index icons with the <code>semantic.*</code> tokens every theme already defined and nothing used. The ER diagram, the visual query builder, notebook charts, the drag ghost and the editor tab accent read the theme instead of hex literals. Native <code>select</code> popups are no longer forced dark, and the startup flash uses the saved theme&#39;s background. A codemod replaced 1,305 palette classes in 153 files by meaning, and labels on success, warning and error fills now pick black or white from the fill&#39;s lightness. The <strong>System</strong> font setting, which used to override every theme&#39;s font, follows the theme and is now labelled <strong>Theme default</strong>.</p>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-theme-tokens.mp4\" poster=\"/videos/posts/tabularis-theme-tokens.jpg\" autoplay loop muted playsinline style=\"width:100%;border-radius:8px;margin:1rem 0\"></video></p>\n<p>PR <a href=\"https://github.com/TabularisDB/tabularis/pull/812\">#812</a> adds a <strong>Design &amp; Accessibility</strong> workflow with three blocking checks, and fixes everything they found on the way in:</p>\n<table>\n<thead>\n<tr>\n<th align=\"left\">Check</th>\n<th align=\"left\">What it verifies</th>\n<th align=\"right\">Findings fixed</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"left\">Theme tokens</td>\n<td align=\"left\">No hardcoded colors, radii or fonts</td>\n<td align=\"right\">already passing</td>\n</tr>\n<tr>\n<td align=\"left\">Theme contrast</td>\n<td align=\"left\">WCAG 2.2 AA on the token pairs the UI paints, for all 12 built-in themes</td>\n<td align=\"right\">277 pairs</td>\n</tr>\n<tr>\n<td align=\"left\">jsx-a11y</td>\n<td align=\"left\"><code>eslint-plugin-jsx-a11y</code> recommended rules on every component</td>\n<td align=\"right\">128 violations in 52 files</td>\n</tr>\n</tbody></table>\n<p>The contrast fixes change lightness only and keep each theme&#39;s hue, and a second test keeps the primary, secondary and muted text tiers in order so a fix cannot flatten the hierarchy. You will notice them most in Solarized, Nord, Tabularis Light, whose status colors are darker, and GitHub Dark, whose accent and focus ring are lighter. The accessibility fixes are the less visible half: clickable <code>div</code>s became buttons, rows and tabs that contain other controls respond to Enter and Space, several menus that ignored Escape now close on it, resize handles are keyboard-operable separators, labels are tied to their controls, icon-only buttons have translated names, and every newly focusable element has a focus ring. The sidebar width and editor split are still mouse-only.</p>\n<p>For theme authors, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/807\">#807</a> separates what the app validates from what the registry validates. The app&#39;s manifest schema keeps only the runtime contract and tolerates extra metadata, so a manifest the registry accepts, such as the published Ember 1.0.0 with a display <code>name</code> and a stable <code>id</code>, is no longer rejected at install time. The archive validator still admits only declared files, so tolerated keys cannot smuggle payload. <code>@tabularis/create-plugin</code> 0.4.0 ships the matching <code>tabularis-theme</code> validator.</p>\n<hr>\n<h2>Plugins Can Write Their Own SQL Templates</h2>\n<p><strong>Generate SQL</strong> builds SELECT, UPDATE and DELETE previews for a table. For a driver plugin speaking a different dialect, the host&#39;s generic output was wrong: SQL Server users got <code>LIMIT 100</code>. PR <a href=\"https://github.com/TabularisDB/tabularis/pull/818\">#818</a> adds an optional <code>table_query_templates</code> capability and a <code>get_table_query_template</code> RPC. When a driver opts in, the modal asks it for the template, passing structured identifiers, schema, columns and the explicit SELECT limit; built-in drivers and plugins that do not opt in keep the existing generation. Only a JSON-RPC method-not-found falls back to it; any other error is shown instead of silently producing SQL in another dialect, and stale responses no longer overwrite the current one.</p>\n<p>The first plugin to use it is <a href=\"https://github.com/TabularisDB/tabularis-sqlserver-plugin\">SQL Server</a> 1.0.0-beta.3, which emits <code>SELECT TOP (100)</code>, schema-qualified bracket-escaped identifiers, unique <code>:value_N</code> placeholders, and <code>WHERE 1 = 0</code> guards on UPDATE and DELETE previews, and stops rewriting queries that carry their own <code>TOP</code> or <code>OFFSET/FETCH</code>. The capability defaults to false, so no plugin needs a newer runtime just because this exists.</p>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-generate-sql-sqlserver.mp4\" poster=\"/videos/posts/tabularis-generate-sql-sqlserver.jpg\" autoplay loop muted playsinline style=\"width:100%;border-radius:8px;margin:1rem 0\"></video></p>\n<hr>\n<h2>Long Plugin Queries: A Timeout You Set, and a Cancel That Reaches the Server</h2>\n<p>Every JSON-RPC call to a driver plugin, <code>execute_query</code> included, used to wait at most a hardcoded 120 seconds. A <code>VACUUM</code>, a batched <code>DELETE</code> or an index build on a plugin connection was always cut off, and nothing short of a rebuild could change it (<a href=\"https://github.com/TabularisDB/tabularis/issues/832\">#832</a>). Worse, the cut-off was only on the host side: Tabularis dropped its pending request and showed an error, while the plugin kept working and the statement kept running on the server. A <code>DELETE</code> you had just seen fail could still take effect.</p>\n<p>PR <a href=\"https://github.com/TabularisDB/tabularis/pull/833\">#833</a> makes the limit a setting. <strong>Settings → Plugins → Plugin runtime → Call timeout</strong> sets it for all plugins (default 120 seconds, <code>0</code> for no limit), and each plugin&#39;s own settings page has a <strong>Call timeout</strong> override: leave it blank to inherit the global value, or set <code>0</code> to lift the limit for that plugin only. The override lives in the host config, so plugins need no change, and a new value applies to the next call without restarting the plugin, MCP server included. Plugin initialization keeps its own 15-second limit.</p>\n<p>PR <a href=\"https://github.com/TabularisDB/tabularis/pull/842\">#842</a> closes the other half. When a call times out, the host now also writes a JSON-RPC notification to the plugin:</p>\n<pre><code class=\"language-json\">{&quot;jsonrpc&quot;: &quot;2.0&quot;, &quot;method&quot;: &quot;cancel&quot;, &quot;params&quot;: {&quot;id&quot;: 42}}\n</code></pre>\n<p>It has no top-level <code>id</code>, so the plugin must not answer it, and it is only sent if the request was still pending, so a response that raced the timeout does not trigger a cancel. The <a href=\"https://github.com/TabularisDB/tabularis-postgresql-plugin\">PostgreSQL plugin</a> handles it from 1.0.0-rc.6 by calling <code>pg_cancel_backend</code> on the statement&#39;s backend: in the PR&#39;s end-to-end test, <code>SELECT pg_sleep(60)</code> with a 3-second timeout failed on the host at 3.0 seconds and left no backend running half a second later. Plugins that do not implement <code>cancel</code> behave as before; at worst their reply is dropped with one log line. The contract is documented in the plugin guide as an optional <strong>Cancel Notification</strong>.</p>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-plugin-call-timeout.mp4\" poster=\"/videos/posts/tabularis-plugin-call-timeout.jpg\" autoplay loop muted playsinline style=\"width:100%;border-radius:8px;margin:1rem 0\"></video></p>\n<hr>\n<h2>The Grid Remembers Where You Were</h2>\n<p>Two fixes for things you notice every day. <a href=\"https://github.com/drakeo338\">@drakeo338</a> makes result grids keep their scroll position when you switch editor tabs and come back, fixing <a href=\"https://github.com/TabularisDB/tabularis/issues/823\">#823</a> in PR <a href=\"https://github.com/TabularisDB/tabularis/pull/831\">#831</a>, and extends it to every grid in the multi-result panel, tabbed or stacked, in PR <a href=\"https://github.com/TabularisDB/tabularis/pull/838\">#838</a>. The offset is forgotten on a fresh run, a page change or when the tab or result closes. The same work fixes a race that scrolled a tab with pending inserted rows back to the bottom over the restored position.</p>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-grid-scroll-restore.mp4\" poster=\"/videos/posts/tabularis-grid-scroll-restore.jpg\" autoplay loop muted playsinline style=\"width:100%;border-radius:8px;margin:1rem 0\"></video></p>\n<p>PR <a href=\"https://github.com/TabularisDB/tabularis/pull/828\">#828</a> fixes <a href=\"https://github.com/TabularisDB/tabularis/issues/826\">#826</a>: a value typed into a new row was drawn as green text on a green tint, and modified cells had the same problem in their own color, with contrast as low as 2.9:1. Edited values now use the primary text color; the row tint, the italic style and the <code>NEW</code> marker still show the state. The same PR fixes date cells on new rows: the date picker showed today&#39;s date as a starting point but never passed it on, so the cell stayed empty unless you changed a field. A <strong>Use this value</strong> button, or Enter in the grid, now accepts the date on screen.</p>\n<hr>\n<h2>New and Updated Plugins</h2>\n<p>Plugins ship on their own schedule, so this is what reached the <a href=\"https://tabularis.dev/plugins\">registry</a> since v0.25.0, not something bundled with the app.</p>\n<h3>New: Cassandra and ScyllaDB</h3>\n<p><a href=\"https://github.com/Mohamed-Fameen\">@Mohamed-Fameen</a> built the <a href=\"https://github.com/TabularisDB/tabularis-cassandra-plugin\">Cassandra plugin</a> against the <a href=\"https://tabularis.dev/plugins/bounties\">Cassandra/ScyllaDB bounty</a>, and 0.1.0 is in the registry for macOS, Linux and Windows. It is written in Rust on ScyllaDB&#39;s <code>scylla-rust-driver</code>, so one plugin serves both databases, and the author verified it end to end against real Cassandra and real ScyllaDB containers. It lists keyspaces, tables, columns and indexes, runs CQL with native forward paging, and edits rows. The README is upfront about the gaps in 0.1.0: no TLS yet, tables with a composite primary key are browsable and queryable but not editable from the grid, grid writes cover the common scalar types only (decimals, timestamps, collections and UDTs are read-only there, writable through CQL in the editor), and ScyllaDB shard-aware routing is not implemented.</p>\n<h3>New: Oracle</h3>\n<p>The <a href=\"https://github.com/TabularisDB/tabularis-oracle-plugin\">Oracle plugin</a> 0.1.0 was published the day after v0.25.0 and requires it, since it returns table and column comments. It connects to Oracle Database 12c and newer, including Free/XE, Autonomous Database (<code>tcps://</code>) and Amazon RDS for Oracle, through EZConnect, full connect descriptors or TNS aliases. It browses multiple schemas through the <code>ALL_*</code> views, runs queries with <code>OFFSET/FETCH</code> pagination, PL/SQL blocks, exact <code>NUMBER</code> values and native <code>JSON</code> columns, edits rows with composite primary keys, generates table, column, index, foreign-key and view DDL, and feeds Visual EXPLAIN from <code>PLAN_TABLE</code>, with EXPLAIN ANALYZE runtime statistics from <code>V$SQL_PLAN_STATISTICS_ALL</code>. The Oracle Instant Client must be installed separately, because its license does not allow bundling it; without it, connections fail with a DPI-1047 hint. Triggers, routine editing and user management are not implemented yet.</p>\n<h3>New theme: Ember</h3>\n<p><a href=\"https://github.com/TabularisDB/tabularis-ember-theme\">Ember</a> 1.0.0 is the first theme package in the registry: Ember Dark, charcoal-brown with amber accents, and Ember Light, parchment with copper, in one package. Its manifest uses the stable <code>id</code> and display <code>name</code> that <a href=\"https://github.com/TabularisDB/tabularis/pull/807\">#807</a> above teaches the app to accept, so v0.25.0 rejects it and v0.26.0 is the first stable release that installs it, from <strong>Settings → Appearance → Manage themes</strong> or the <strong>Themes</strong> filter in Plugins.</p>\n<h3>Updated</h3>\n<ul>\n<li><strong><a href=\"https://github.com/TabularisDB/tabularis-postgresql-plugin\">PostgreSQL</a> 1.0.0-rc.5 and rc.6</strong> (<a href=\"https://github.com/aesslinger\">@aesslinger</a>): rc.5 carries the editor-session support described above, and returns a connection to the pool only after a <code>ROLLBACK</code>. It also implements <code>get_table_ddl</code>, so <strong>Dump Database</strong> writes a schema-preserving dump for plugin connections instead of failing, and <code>get_schema_snapshot</code>, so the ER diagram loads a schema in one round trip. rc.6 handles the host&#39;s new <code>cancel</code> notification, described above, and stops reporting a lowercase <code>DEFAULT null</code> as a default value.</li>\n<li><strong><a href=\"https://github.com/TabularisDB/tabularis-sqlserver-plugin\">SQL Server</a> 1.0.0-beta.3</strong>: the driver-owned SQL templates covered above, plus no more conflicting host pagination on queries with an explicit <code>TOP</code> or <code>OFFSET/FETCH</code>.</li>\n<li><strong><a href=\"https://github.com/GabrielMalava/cloudflare-tabularis\">Cloudflare D1 over HTTP</a> 0.4.0</strong> (<a href=\"https://github.com/GabrielMalava\">@GabrielMalava</a>): updates and deletes now work on tables with composite keys (<code>pk_map</code>), and the plugin gains a Tabularium manifest and icon. Its id changes from <code>tubularis-d1</code> to <code>cloudflare-d1-http</code>. The release is on GitHub; at the time of writing the registry still lists 0.3.0.</li>\n</ul>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<li><strong>Autocomplete ranks the nearest table first</strong> (<a href=\"https://github.com/wwww-deeeee\">@wwww-deeeee</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/834\">#834</a>, superseding <a href=\"https://github.com/TabularisDB/tabularis/pull/782\">#782</a>, closes <a href=\"https://github.com/TabularisDB/tabularis/issues/760\">#760</a>): quoted identifiers and keyword-like names no longer break nearest-table detection, and the nearest table is sorted before the fetch cap, so it is never truncated when more than five tables are in scope and its columns win for shared names such as <code>id</code>.</li>\n<li><strong>MySQL foreign keys load faster</strong> (<a href=\"https://github.com/hkz329\">@hkz329</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/802\">#802</a>): expanding a table could take seconds on a server with many databases, because the <code>KEY_COLUMN_USAGE</code> and <code>REFERENTIAL_CONSTRAINTS</code> join could not be pruned on MySQL 5.7. Both views are now scoped to the selected database and table, which also stops same-named constraints from different tables being mixed on MariaDB. Tested against MySQL 5.7, 8.0 and 8.4 and MariaDB 10.11 and 12.1.</li>\n<li><strong>The plugin registry works behind TLS-inspecting proxies</strong> (<a href=\"https://github.com/aesslinger\">@aesslinger</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/811\">#811</a>, closes <a href=\"https://github.com/TabularisDB/tabularis/issues/810\">#810</a>): the Tabularium client used a bundled CA list, so behind Zscaler or Netskope every registry call failed and the app fell back to the legacy <code>registry.json</code>. It now uses the OS trust store, like the PostgreSQL and MySQL connections already did.</li>\n<li><strong>AWS SSM finds the AWS CLI when launched from the desktop</strong> (<a href=\"https://github.com/Davydhh\">@Davydhh</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/806\">#806</a>, closes <a href=\"https://github.com/TabularisDB/tabularis/issues/805\">#805</a>): an app started from Finder or a desktop launcher inherits a minimal <code>PATH</code> without <code>/opt/homebrew/bin</code> or <code>/usr/local/bin</code>. The well-known install locations are appended after the inherited entries, so your own <code>aws</code> still wins.</li>\n<li><strong>Quoted file paths connect</strong> (<a href=\"https://github.com/coloraven\">@coloraven</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/755\">#755</a>): a pasted SQLite or other file path wrapped in straight or curly quotes, prefixed with <code>file://</code>, or carrying a BOM or zero-width space is normalized before it is opened.</li>\n<li><strong>JSON viewer in read-only context menus</strong> (<a href=\"https://github.com/igorzelaya-io\">@igorzelaya-io</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/781\">#781</a>): following v0.25.0&#39;s double-click fix, the right-click menu on a read-only query result now offers <strong>Open in JSON Editor</strong>, while mutation actions stay hidden.</li>\n<li><strong>Theme packages stay out of New Connection</strong> (PR <a href=\"https://github.com/TabularisDB/tabularis/pull/825\">#825</a>, closes <a href=\"https://github.com/TabularisDB/tabularis/issues/824\">#824</a>): the connection catalogue listed theme plugins from the registry as database engines. Only driver plugins are shown now.</li>\n<li><strong>Nightlies on the AUR</strong> (PR <a href=\"https://github.com/TabularisDB/tabularis/pull/816\">#816</a>): Arch users can install <code>tabularis-nightly-bin</code> next to <code>tabularis-bin</code>, from the same <code>PKGBUILD</code>. PRs <a href=\"https://github.com/TabularisDB/tabularis/pull/835\">#835</a> and <a href=\"https://github.com/TabularisDB/tabularis/pull/836\">#836</a> fix macOS nightlies that reported the previous stable version to plugins, and nightlies that were cancelled halfway through publication.</li>\n<li><strong>Package CI tests what changed</strong> (PR <a href=\"https://github.com/TabularisDB/tabularis/pull/808\">#808</a>): a change to <code>@tabularis/explain</code> no longer scaffolds and compiles a Rust driver for <code>create-plugin</code>.</li>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Eleven external contributors land in this release or in the plugins around it. <strong><a href=\"https://github.com/egertaia\">@egertaia</a></strong> made a PostgreSQL tab a session that keeps its transaction across runs, with the badge, the cleanup on every close path and the idle sweep (<a href=\"https://github.com/TabularisDB/tabularis/pull/801\">#801</a>), and <strong><a href=\"https://github.com/aesslinger\">@aesslinger</a></strong> carried the plugin side to release in PostgreSQL rc.5 and rc.6 and fixed the registry behind corporate proxies (<a href=\"https://github.com/TabularisDB/tabularis/pull/811\">#811</a>). <strong><a href=\"https://github.com/Mohamed-Fameen\">@Mohamed-Fameen</a></strong> took the Cassandra/ScyllaDB bounty and shipped the plugin. <strong><a href=\"https://github.com/verbaux\">@verbaux</a></strong> turned the command palette into an action surface and rebuilt shortcut handling (<a href=\"https://github.com/TabularisDB/tabularis/pull/815\">#815</a>, <a href=\"https://github.com/TabularisDB/tabularis/pull/773\">#773</a>). <strong><a href=\"https://github.com/drakeo338\">@drakeo338</a></strong> made the grids remember their scroll position (<a href=\"https://github.com/TabularisDB/tabularis/pull/831\">#831</a>, <a href=\"https://github.com/TabularisDB/tabularis/pull/838\">#838</a>).</p>\n<p><strong><a href=\"https://github.com/wwww-deeeee\">@wwww-deeeee</a></strong> hardened nearest-table autocomplete (<a href=\"https://github.com/TabularisDB/tabularis/pull/782\">#782</a>), <strong><a href=\"https://github.com/hkz329\">@hkz329</a></strong> sped up MySQL foreign-key metadata (<a href=\"https://github.com/TabularisDB/tabularis/pull/802\">#802</a>), <strong><a href=\"https://github.com/Davydhh\">@Davydhh</a></strong> followed up on SSM with AWS CLI discovery (<a href=\"https://github.com/TabularisDB/tabularis/pull/806\">#806</a>), <strong><a href=\"https://github.com/coloraven\">@coloraven</a></strong> sanitized pasted file paths (<a href=\"https://github.com/TabularisDB/tabularis/pull/755\">#755</a>), <strong><a href=\"https://github.com/igorzelaya-io\">@igorzelaya-io</a></strong> finished the read-only JSON viewer work (<a href=\"https://github.com/TabularisDB/tabularis/pull/781\">#781</a>), and <strong><a href=\"https://github.com/GabrielMalava\">@GabrielMalava</a></strong> kept the Cloudflare D1 registry entry honest (<a href=\"https://github.com/TabularisDB/tabularis/pull/819\">#819</a>, <a href=\"https://github.com/TabularisDB/tabularis/pull/830\">#830</a>) and shipped D1 0.4.0 with composite-key edits. Thanks also to <strong><a href=\"https://github.com/mayeenulislam\">@mayeenulislam</a></strong> for the two precise bug reports behind the grid fixes.</p>\n<p>If you have ever run <code>COMMIT</code> and wondered which connection it went to, this is the upgrade.</p>\n<hr>\n<p><em>Download the latest published version from the <a href=\"https://tabularis.dev/download\">download page</a>. Release notes and packages are available on <a href=\"https://github.com/TabularisDB/tabularis/releases\">GitHub Releases</a>.</em></p>\n",
      "image": "https://tabularis.dev/blog/v0260-postgres-tab-transactions-command-palette-accessible-themes/opengraph-image.png",
      "date_published": "2026-10-01T10:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "postgres",
        "mysql",
        "ui",
        "ux",
        "data-grid",
        "plugin",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0250-installable-themes-aws-ssm-update-badges-mcp-toon",
      "url": "https://tabularis.dev/blog/v0250-installable-themes-aws-ssm-update-badges-mcp-toon",
      "title": "v0.25.0: A New Theme System, AWS SSM Tunnels and Startup Improvements",
      "summary": "v0.25.0 turns themes into installable, declarative packages with author tooling, adds AWS SSM Session Manager port forwarding as a third tunnel method, surfaces core and plugin updates in the sidebar and on a startup toast, cuts the JavaScript loaded at startup by 84%, lets MCP tools return TOON, shows table and column comments, and carries community fixes for the MCP driver registry, read-only JSON viewers and plugin display names.",
      "content_html": "<h1>v0.25.0: A New Theme System, AWS SSM Tunnels and Startup Improvements</h1>\n<p><strong>v0.25.0</strong> follows <a href=\"https://tabularis.dev/blog/v0240-notebook-query-plans-proxy-settings-result-fonts\">v0.24.0</a> with a release about the things around your queries rather than the queries themselves. Themes stop being a fixed list of twelve presets and become packages you install, preview, update and remove the way you already handle driver plugins, with a command-line tool for authors. Connections gain a third tunnel method, AWS Systems Manager Session Manager port forwarding, next to SSH and Kubernetes. Available updates for the app and for installed plugins are counted in the sidebar and announced once at startup instead of waiting in Settings. Startup itself loads far less JavaScript. On the MCP side, every tool can return TOON instead of JSON, and the standalone MCP process now notices plugins that were installed, disabled or removed while it was running. Table and column comments from PostgreSQL and MySQL reach the schema inspector, the sidebar and the grid. The community fixes cover read-only JSON viewers, plugin display names, schema text selection and MongoDB replica set URIs.</p>\n<hr>\n<h2>Themes Become Installable Packages</h2>\n<p>PR <a href=\"https://github.com/TabularisDB/tabularis/pull/793\">#793</a>, with the follow-up <a href=\"https://github.com/TabularisDB/tabularis/pull/803\">#803</a>, addresses <a href=\"https://github.com/TabularisDB/tabularis/issues/791\">#791</a>: themes are now <strong>declarative packages</strong> that travel through the same registry and the same install lifecycle as driver plugins. A theme package is a ZIP with a <code>.tabularium</code> manifest of <code>kind: &quot;theme&quot;</code> and one JSON file per variant, typically a light and a dark one. It contains data only. Nothing in a theme package is executed, and theme packages are excluded from the driver activation path entirely.</p>\n<p>Two surfaces change. <strong>Settings → Appearance → Manage themes</strong> lists built-in, personal and installed themes together. <strong>Preview</strong> applies a theme temporarily and shows a read-only SQL sample rendered by the shared Monaco renderer; <strong>Cancel</strong> or Escape restores the saved selection, <strong>Apply</strong> saves it. Installed packages are read-only; <strong>Duplicate as personal</strong> creates an editable copy. The application theme and the SQL editor theme are selected independently, and the Follow System light and dark picks from v0.22.0 continue to work with installed variants. <strong>Settings → Plugins</strong> gains a <strong>Filter by type</strong> control with <strong>Drivers</strong> and <strong>Themes</strong>, so theme packages can be installed from the registry, updated, enabled, disabled and uninstalled next to drivers. Removing a theme package reuses the plugin removal dialog and states that every variant of the package is affected. Installing a package never selects a variant on its own: close the dialog and pick it explicitly in Appearance.</p>\n<p>The lifecycle underneath is the careful part. The archive is inspected with size bounds, every path and payload is validated, the package is staged privately under a lock, and the install is committed by atomic replacement with rollback on failure. Interrupted operations have an explicit <strong>Recover interrupted installs</strong> action rather than silent repair. A saved selection that points at a package that is currently unavailable, because it was disabled or its file went missing, falls back to a built-in theme without overwriting your preference; the moment the package is back, the selection is restored. Packages are stored by kind, in <code>plugins/themes/&lt;package&gt;/</code> beside <code>plugins/drivers/&lt;package&gt;/</code>, and discovery still finds the legacy flat layout.</p>\n<p>Imports and exports round it out. <strong>Import from VS Code</strong> accepts a VS Code JSON or JSONC theme, converts it, lists what could not be mapped and asks you to pick the light or dark base when it cannot tell. <strong>Import Tabularis JSON</strong> and <strong>Export standalone JSON</strong> keep the old single-file format, and <strong>Export author package</strong> produces the package layout for a theme you want to publish. Theme definitions and manifests accept optional <code>$schema</code> hints for editor completion; the runtime ignores them for validation and never fetches a remote schema.</p>\n<p>For authors, <code>@tabularis/create-plugin</code> 0.3.0 ships a second binary, <code>tabularis-theme</code>, which scaffolds a two-variant repository, validates both variants offline with the same schemas the app uses, and builds a deterministic ZIP. The generated repository includes a validation workflow for branches and pull requests and a separate draft-release workflow for tags. The guide is <a href=\"https://github.com/TabularisDB/tabularis/blob/main/packages/create-plugin/THEMES.md\">THEMES.md</a>. The reference implementation is <a href=\"https://github.com/TabularisDB/tabularis-ember-theme\">Ember</a>, a warm amber and copper pair.</p>\n<p>One caveat, stated plainly: v0.25.0 is the first release that can install theme packages, so a package must declare <code>min_runtime_version: &quot;0.25.0&quot;</code> and older clients will refuse it. At the time of writing no theme package has been published to the registry yet, Ember included. The <strong>Themes</strong> filter in Plugins will fill up as authors publish; until then, local ZIP installs and VS Code imports are the way to try it.</p>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-theme-package-install.mp4\" poster=\"/videos/posts/tabularis-theme-package-install.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Connect Through AWS SSM Session Manager</h2>\n<p><a href=\"https://github.com/Davydhh\">@Davydhh</a> adds a third tunnel method in PR <a href=\"https://github.com/TabularisDB/tabularis/pull/766\">#766</a>, closing <a href=\"https://github.com/TabularisDB/tabularis/issues/765\">#765</a>. The connection modal gains an <strong>AWS SSM</strong> tab with <strong>Use AWS SSM Port Forwarding</strong>, a <strong>Managed Node</strong> id, and optional <strong>AWS Profile</strong> and <strong>AWS Region</strong>. On connect, Tabularis runs <code>aws ssm start-session</code> as a port-forwarding session and points the driver at the resulting local port, exactly as the SSH and Kubernetes methods do. The forward target is the host and port from the General tab, so a connection to an RDS instance behind a bastion node needs no extra fields.</p>\n<div class=\"post-gallery\">\n  <img src=\"https://tabularis.dev/img/tabularis-aws-ssm-tab.png\" alt=\"AWS SSM connection settings with managed node, profile, region and resolved forwarding document\" loading=\"lazy\">\n  <img src=\"https://tabularis.dev/img/tabularis-ssm-chip.png\" alt=\"Connection cards showing SSM, SSH and Kubernetes tunnel indicators\" loading=\"lazy\">\n</div>\n\n<p>The SSM document is derived from that target instead of asked for:</p>\n<table>\n<thead>\n<tr>\n<th align=\"left\">Target host</th>\n<th align=\"left\">Document</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"left\">Loopback (<code>localhost</code>, <code>127.0.0.1</code>, <code>::1</code>, blank)</td>\n<td align=\"left\"><code>AWS-StartPortForwardingSession</code></td>\n</tr>\n<tr>\n<td align=\"left\">Anything else</td>\n<td align=\"left\"><code>AWS-StartPortForwardingSessionToRemoteHost</code></td>\n</tr>\n</tbody></table>\n<p>Both are supported on purpose. Some IAM policies grant only the plain document, and a remote-host-only implementation would lock those users out. The tab shows which document will be used and reminds you that <code>ssm:StartSession</code> is needed on the target and on that document. A <strong>Test SSM</strong> button opens a real session and closes it again without touching the database.</p>\n<p>Three decisions are worth knowing. Readiness is taken from the AWS CLI reporting its own open port on stdout, never from probing the local port; a probe also succeeds against a foreign process that won the race for that port, which would silently query the wrong database. Teardown signals the whole process group, because the CLI spawns <code>session-manager-plugin</code> as a child and that child is what holds the port. Cached sessions are checked for liveness before reuse, since AWS ends sessions on idle timeout. Credentials are delegated to the AWS CLI, so SSO, assumed roles and credential processes behave as in your terminal, and nothing SSM-specific is written to the keychain.</p>\n<p>Failures are classified into actionable messages: plugin missing, expired SSO, absent credentials, <code>TargetNotConnected</code>, IAM denial, port conflict, wrong region. The second commit fixes the frontend classifier, which read an IAM &quot;Access denied&quot; as a database login failure and a missing <code>aws</code> binary as an unreachable database server. SSM, SSH and Kubernetes are mutually exclusive on a connection, and connections using SSM carry an <strong>SSM</strong> chip on the Connections page and in the sidebar. Static access keys, a plugin path override, EC2 and RDS target discovery and Secrets Manager passwords are out of scope for now.</p>\n<hr>\n<h2>Updates Show Up Where You Look</h2>\n<p>PR <a href=\"https://github.com/TabularisDB/tabularis/pull/786\">#786</a> surfaces core and plugin updates without opening Settings. The sidebar rail carries one aggregated counter; inside Settings, <strong>Plugins</strong> and <strong>Info</strong> each show their own count with a tooltip naming the plugins or the version involved. The Plugins badge used to count configuration entries, built-in drivers included; it now counts installed plugins with an update that is compatible with your app version and platform. Registry state is shared between navigation, Settings and the startup check, and refreshes after a plugin install, update or removal.</p>\n<p>When plugin updates are found at startup, one dismissible toast appears. Clicking it or <strong>Open Plugins</strong> lands on Settings → Plugins with the <strong>Updates</strong> filter applied, also when Settings is already open. No toast is shown for an empty or failed initial check, so an offline registry stays quiet. For core updates, <strong>Remind Me Later</strong> keeps suppressing the modal on background checks but no longer hides the fact that an update exists: the sidebar badge and the Info details remain.</p>\n<p>The same PR reworks how status is drawn. A semantic tone system with shared <strong>Chip</strong> and <strong>CountBadge</strong> primitives replaces hardcoded palette classes on connection cards, list rows, plugin cards, the sidebar rail and the Settings navigation, so every one of the twelve bundled themes gets readable badges; a coverage test checks chip contrast on each preset. The Plugin Center gets clickable metric tiles, a single toolbar, split-button actions with a keyboard-navigable version list, and status moved into the chip row. The Info tab gains an update status card with check, release notes and install actions.</p>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-update-toast.mp4\" poster=\"/videos/posts/tabularis-update-toast.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Startup Loads Less</h2>\n<p>Opening the Connections screen used to load 8.79 MB of JavaScript, repeat several bootstrap IPC requests, and do work for dialogs that were closed and features that were off. PR <a href=\"https://github.com/TabularisDB/tabularis/pull/790\">#790</a> loads the editor, secondary screens, dialogs and locales on demand, shares bootstrap reads with explicit invalidation, publishes persisted settings before the optional AI discovery runs, and skips the initial theme rewrite. Session restore opens the last active connection and its editor first, then restores the remaining connections in the background without stealing focus. Native plugin registration no longer waits for each enabled plugin&#39;s initialization handshake; a plugin is initialized once, before its first RPC.</p>\n<table>\n<thead>\n<tr>\n<th align=\"left\">Metric</th>\n<th align=\"right\">Before</th>\n<th align=\"right\">After</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"left\">Initial JavaScript</td>\n<td align=\"right\">8.79 MB</td>\n<td align=\"right\">1.43 MB</td>\n</tr>\n<tr>\n<td align=\"left\">Initial JavaScript requests</td>\n<td align=\"right\">9</td>\n<td align=\"right\">2</td>\n</tr>\n<tr>\n<td align=\"left\">Initial CSS</td>\n<td align=\"right\">344 KB</td>\n<td align=\"right\">181 KB</td>\n</tr>\n<tr>\n<td align=\"left\">Initial IPC calls</td>\n<td align=\"right\">49</td>\n<td align=\"right\">29</td>\n</tr>\n<tr>\n<td align=\"left\">Remote changelog requests at startup</td>\n<td align=\"right\">1</td>\n<td align=\"right\">0</td>\n</tr>\n</tbody></table>\n<p>The timing figures in the PR (Connections ready in 85 ms instead of 190 ms, first paint in 68 ms instead of 176 ms) come from a Chromium benchmark with simulated Tauri IPC, not from a measured native launch, and were taken before the update-notification work above landed. The size and request counts are what you get. The trade is that Monaco, non-English translations and plugin initialization now pay their cost on first use. The <a href=\"https://github.com/TabularisDB/tabularis/blob/d16d5bb2ea789b6d929c22e5c64ce5fe8890e9cf/.github/planning/startup-performance-2026-09-18.md\">audit</a> lists what remains.</p>\n<hr>\n<h2>MCP: TOON Output and a Registry That Keeps Up</h2>\n<p>Two changes for the MCP server. PR <a href=\"https://github.com/TabularisDB/tabularis/pull/762\">#762</a>, fixing <a href=\"https://github.com/TabularisDB/tabularis/issues/758\">#758</a>, adds an optional <code>output_format</code> argument to all five tools. It accepts <code>json</code>, the documented default, and <code>toon</code>, the <a href=\"https://github.com/toon-format/toon\">Token-Oriented Object Notation</a> encoding that represents repeated records compactly. The transport stays JSON-RPC; only the <code>content[0].text</code> payload changes. Calls that omit the argument get the same pretty-printed JSON as before, and an invalid value returns <code>-32602</code> before any connection is resolved or query executed. A new <strong>MCP Server → Safety → Tool output → Default output format</strong> picks the encoding used when a call does not specify one; it is stored as <code>mcpOutputFormat</code> in <code>config.json</code>.</p>\n<p><img src=\"https://tabularis.dev/img/tabularis-mcp-tool-output.png\" alt=\"MCP Safety settings with TOON selected as the default tool output format\"></p>\n<p>The second change comes from <a href=\"https://github.com/aesslinger\">@aesslinger</a>, in three PRs. The standalone <code>tabularis --mcp</code> process built its driver registry once at startup, so a plugin installed or enabled in the GUI, or a connection migrated to a plugin, produced <code>Unsupported driver</code> until you restarted your MCP client. PR <a href=\"https://github.com/TabularisDB/tabularis/pull/784\">#784</a>, fixing <a href=\"https://github.com/TabularisDB/tabularis/issues/783\">#783</a>, rescans installed plugins against the on-disk config on a registry miss and retries, rate-limited to once every two seconds so a connection with a genuinely wrong driver id cannot force a filesystem scan per call. The rescan is idempotent and keeps the collision refusal for plugins claiming a built-in id. PR <a href=\"https://github.com/TabularisDB/tabularis/pull/789\">#789</a>, fixing <a href=\"https://github.com/TabularisDB/tabularis/issues/787\">#787</a>, covers the other direction: a driver disabled or uninstalled in the GUI is now unregistered from the running MCP process, checked on every resolution under its own cooldown. PR <a href=\"https://github.com/TabularisDB/tabularis/pull/800\">#800</a> folds the two hand-rolled cooldown gates into one tested <code>Cooldown</code> type with no behaviour change. Both fixes were verified against a live process without restarting it.</p>\n<hr>\n<h2>Table and Column Comments</h2>\n<p><a href=\"https://github.com/TabularisDB/tabularis/issues/722\">#722</a> asked for the descriptions that live in the database to be visible in the client. PR <a href=\"https://github.com/TabularisDB/tabularis/pull/764\">#764</a> adds an optional <code>comment</code> to table and column metadata, reads it from PostgreSQL and MySQL/MariaDB including the batch metadata path, and shows it in three places: the <strong>View Schema</strong> modal gets a table description and a comment column, the sidebar shows table and column comments as tooltips, and the data grid header tooltip shows the column comment next to its type. Generated DDL preserves them, as inline <code>COMMENT</code> clauses for MySQL and <code>COMMENT ON</code> statements for PostgreSQL and Oracle, with apostrophes escaped and Unicode and newlines intact.</p>\n<div class=\"post-gallery\">\n  <img src=\"https://tabularis.dev/img/tabularis-schema-comments.png\" alt=\"PostgreSQL products schema with a table description and column comments\" loading=\"lazy\">\n  <img src=\"https://tabularis.dev/img/tabularis-grid-header-comment-tooltip.png\" alt=\"The price column tooltip showing its numeric type and database comment\" loading=\"lazy\">\n</div>\n\n<p>The plugin contract is additive: <code>get_tables</code> and <code>get_columns</code> may return <code>comment: string | null</code>, legacy payloads without it still deserialize, and no capability flag, RPC method or runtime floor is involved. The ClickHouse plugin already emitted comments from <code>system.tables</code> and <code>system.columns</code>; this release makes them visible. Follow-ups for the PostgreSQL, DuckDB, Db2, Oracle and SQL Server plugins are tracked in their repositories. SQLite has no comment syntax and returns none.</p>\n<hr>\n<h2>Plugins Can Hide the Login Fields</h2>\n<p>Driver plugins that authenticate without a database login, such as Windows integrated authentication, IAM tokens or Kerberos, had no way to remove the username and password inputs from the connection form. PR <a href=\"https://github.com/TabularisDB/tabularis/pull/780\">#780</a> extends the <code>connection-modal.extra_fields</code> slot context with <code>credentialFieldsHidden</code> and <code>setCredentialFieldsHidden(hidden)</code>. Hiding removes the block and clears both values so a stale login never reaches the driver, and the flag resets whenever the driver changes. The plugin&#39;s own choice lives in the opaque <code>extra</code> map the host already persists.</p>\n<p>The follow-up commit closes a keychain gap that surfaced with it: on edit, an empty password was omitted from the payload and the stored secret survived, so the next connect injected it again with no visible field to fix. While the inputs are hidden the modal now sends an explicit empty password, <code>update_connection</code> deletes the keychain entry on <code>&quot;&quot;</code>, and a connection-string import ignores the login part. The design comes from <a href=\"https://github.com/egertaia\">@egertaia</a>&#39;s work in <a href=\"https://github.com/TabularisDB/tabularis/pull/775\">#775</a> and the SQL Server plugin, where the <strong>Use Windows Authentication</strong> checkbox will use this hook; this PR moves it onto a driver-agnostic slot so any plugin can do the same. The typed context ships in <code>@tabularis/plugin-api</code> 0.2.0.</p>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<li><strong>JSON viewers open on read-only results</strong> (<a href=\"https://github.com/igorzelaya-io\">@igorzelaya-io</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/763\">#763</a>, closes <a href=\"https://github.com/TabularisDB/tabularis/issues/654\">#654</a>): double-click or Enter on a JSON cell in a query or notebook result grid opens the viewer, including for generated columns. The blob editor and the row sidebar stay behind the existing read-only guard.</li>\n<li><strong>Plugin display names are honoured</strong> (<a href=\"https://github.com/NewtTheWolf\">@NewtTheWolf</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/795\">#795</a>, related <a href=\"https://github.com/TabularisDB/tabularis/issues/759\">#759</a>): with Tabularium 0.14.0 a manifest can carry <code>id</code> as the stable identifier and <code>name</code> as the human-readable name. The catalogue now shows that name for standalone plugins instead of title-casing the slug; shared engine cards keep their engine title, and saved connections and folders are unaffected. The plugin guide documents the migration order: add <code>id</code> equal to the existing slug first, then change <code>name</code>.</li>\n<li><strong>Schema text can be selected and copied</strong> (PR <a href=\"https://github.com/TabularisDB/tabularis/pull/797\">#797</a>, fixes <a href=\"https://github.com/TabularisDB/tabularis/issues/794\">#794</a>): <strong>View Schema</strong> inherited the application&#39;s global <code>user-select: none</code>. Column names and metadata are selectable again, with theme-derived selection colours that stay readable in High Contrast.</li>\n<li><strong>MongoDB replica set URIs are accepted</strong> (PR <a href=\"https://github.com/TabularisDB/tabularis/pull/740\">#740</a>, closes <a href=\"https://github.com/TabularisDB/tabularis/issues/739\">#739</a>): the WHATWG URL parser rejected comma-separated multi-host authorities. Passthrough schemes are recognised first, the URI is preserved verbatim for the driver, and only the first host is used for the form&#39;s display fields.</li>\n<li><strong>Nightlies build from the right commit</strong> (PR <a href=\"https://github.com/TabularisDB/tabularis/pull/798\">#798</a>): the nightly gate trusted the ordering of a filtered workflow-runs API and once built from a commit older than the previous stable. It now sorts a 30-day window locally, refuses to build behind the previous nightly, patches <code>src/version.ts</code> so the UI shows the nightly version, and tags the commit actually built.</li>\n<li><strong>DynamoDB plugin 0.1.7</strong> (<a href=\"https://github.com/fuleinist\">@fuleinist</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/796\">#796</a>): the plugin adds its own AWS region, profile and session token fields through the <code>extra_fields</code> slot and fixes credentials-only connections that were rejected for a missing host. Notes in the <a href=\"https://github.com/TabularisDB/tabularis-dynamodb-plugin/releases/tag/v0.1.7\">plugin release</a>.</li>\n<li><strong>npm packages publish themselves</strong>: <code>@tabularis/create-plugin</code> 0.3.0 and <code>@tabularis/plugin-api</code> 0.2.0 are published with provenance whenever a version on <code>main</code> is not yet on the registry, and each package now runs its own tests in CI. The <code>tabularis-create-plugin</code> CLI reads its version from <code>package.json</code> instead of a hardcoded <code>0.1.0</code>.</li>\n<li><strong>Connection metadata cache keys are canonicalised</strong> (commit <a href=\"https://github.com/TabularisDB/tabularis/commit/b2317f8e\">b2317f8e</a>): the per-connection discovery cache from v0.24.0 sorts JSON object keys, plugin extras included, before deriving its key, so the same parameters in a different order hit the same entry.</li>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Six external contributors land in this release. <strong><a href=\"https://github.com/Davydhh\">@Davydhh</a></strong> built AWS SSM Session Manager port forwarding, from document derivation to teardown and error classification (<a href=\"https://github.com/TabularisDB/tabularis/pull/766\">#766</a>). <strong><a href=\"https://github.com/aesslinger\">@aesslinger</a></strong> made the standalone MCP process follow plugin installs, disables and removals without a restart, then cleaned up the cooldown logic (<a href=\"https://github.com/TabularisDB/tabularis/pull/784\">#784</a>, <a href=\"https://github.com/TabularisDB/tabularis/pull/789\">#789</a>, <a href=\"https://github.com/TabularisDB/tabularis/pull/800\">#800</a>). <strong><a href=\"https://github.com/igorzelaya-io\">@igorzelaya-io</a></strong> opened the JSON viewers on read-only results (<a href=\"https://github.com/TabularisDB/tabularis/pull/763\">#763</a>).</p>\n<p><strong><a href=\"https://github.com/NewtTheWolf\">@NewtTheWolf</a></strong> separated plugin identity from display names (<a href=\"https://github.com/TabularisDB/tabularis/pull/795\">#795</a>), <strong><a href=\"https://github.com/fuleinist\">@fuleinist</a></strong> shipped DynamoDB 0.1.7 with plugin-owned connection fields (<a href=\"https://github.com/TabularisDB/tabularis/pull/796\">#796</a>), and <strong><a href=\"https://github.com/egertaia\">@egertaia</a></strong> designed the hidden-credentials behaviour that became the generic slot hook (<a href=\"https://github.com/TabularisDB/tabularis/pull/775\">#775</a>, <a href=\"https://github.com/TabularisDB/tabularis/pull/780\">#780</a>).</p>\n<p>If you want a theme that is not one of the twelve, reach databases through Session Manager, or keep forgetting which plugins have an update waiting, this is the upgrade.</p>\n<hr>\n<p><em>Download the latest published version from the <a href=\"https://tabularis.dev/download\">download page</a>. Release notes and packages are available on <a href=\"https://github.com/TabularisDB/tabularis/releases\">GitHub Releases</a>.</em></p>\n",
      "image": "https://tabularis.dev/blog/v0250-installable-themes-aws-ssm-update-badges-mcp-toon/opengraph-image.png",
      "date_published": "2026-09-22T10:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "ui",
        "ux",
        "plugin",
        "mcp",
        "postgres",
        "mysql",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/how-to-grow-an-open-source-project",
      "url": "https://tabularis.dev/blog/how-to-grow-an-open-source-project",
      "title": "How to Grow an Open-Source Project: Lessons on the Road to 5,000 Stars",
      "summary": "Lessons from growing Tabularis toward 5,000 GitHub stars: make room for contributors, protect everyday workflows, and plan for the maintenance that comes with new features.",
      "content_html": "<h1>How to Grow an Open-Source Project: Lessons on the Road to 5,000 Stars</h1>\n<p>As I write this, Tabularis is about twenty GitHub stars away from 5,000. We might get there in a day or two, perhaps sooner. By the time you read this, the counter may already have moved past it.</p>\n<p>It feels like a good moment to stop and look at what has happened since January.</p>\n<p>When I wrote about <a href=\"https://tabularis.dev/blog/from-zero-to-1000-github-stars\">the first 1,000 stars</a>, the story was still mostly about getting started: shipping the first versions, meeting the first contributors, discovering that other people wanted this thing too. Five months later, there is much more to maintain, more to learn, and a growing number of people helping decide what Tabularis becomes.</p>\n<p>Here are the lessons I would take into another open-source project: start with a problem you understand, give contributors room to work, and make each expansion something you can maintain.</p>\n<p><img src=\"https://tabularis.dev/img/posts/tabularis-5k-stars-growth.svg\" alt=\"Cumulative star dates of 4,982 current Tabularis stargazers, from January 26 to September 18, 2026.\"></p>\n<p>The curve includes quiet stretches and sudden jumps. It records interest in the project; understanding what people actually need still happens through conversations, reports and contributions.</p>\n<h2>Start with a problem you can test yourself</h2>\n<p>Tabularis began as a database client I wanted to use myself. Its first name was <code>debba.sql</code>. There was a SQL editor, a Tauri application around it, and plenty of rough edges.</p>\n<p>The early pace was intense. The first week produced fifteen releases. Some fixed things the previous release had broken. Getting the application into people&#39;s hands brought feedback I could never have produced on my own machine: different Linux environments, unfamiliar database configurations, and expectations I had not considered.</p>\n<p>In the 1,000-star post, I called that phase “Ship Fast, Ship Broken, Ship Anyway.” Reading it again now, I can see how much of that advice belonged to that particular moment.</p>\n<p>Fast feedback still matters. So does being able to ship a fix quickly. But every new workflow someone builds around Tabularis adds something worth preserving. A query editor can become part of a working day. A connection configuration can represent hours of setup. An export can be the file someone else is waiting for.</p>\n<p>Start with a workflow you can test yourself, then let real users challenge your assumptions. As they build routines around the application, include those existing workflows in your release checks.</p>\n<h2>Give contributors a piece they can own</h2>\n<p>Choosing <a href=\"https://tabularis.dev/blog/why-tabularis-runs-on-tauri\">Tauri</a> gave the project a Rust backend for database connections, tunnels, credentials and exports, with React for the interface. It also brought the ongoing work of supporting different system webviews and packaging environments. Those tradeoffs are still visible in today&#39;s bug reports.</p>\n<p>The plugin system changed who could extend the application.</p>\n<p>An <a href=\"https://tabularis.dev/blog/database-drivers-as-external-processes\">external driver speaking JSON-RPC</a> gave contributors a manageable boundary. Someone could bring knowledge of a database and work on its driver without taking ownership of the entire desktop application. It opened a path for integrations I would never have had time to build alone.</p>\n<p>That path has continued to grow. Dominik Spitzli, <a href=\"https://github.com/NewtTheWolf\">@NewtTheWolf</a>, built Tabularium, the plugin registry that lets authors publish through their own repositories. SQL Server now has its own driver plugin. PostgreSQL has also moved into a plugin, putting an important part of the original application through the same extension model.</p>\n<p>These changes carry maintenance costs: protocol compatibility, installation, updates, and clear expectations about what a driver supports. They also let more work happen independently.</p>\n<p>Notebooks and Visual EXPLAIN expanded the application in another direction. A query can sit beside an explanation, a chart, and the steps that led to it. Looking at how the database executes that query becomes part of the same investigation.</p>\n<p>Give a contributor a clear boundary, a working example and a way to test their change. In Tabularis, a driver provides that starting point. Other projects might offer an integration, a theme or a small, independently testable module.</p>\n<p><img src=\"https://tabularis.dev/img/posts/tabularis-5k-community-prs.svg\" alt=\"Monthly merged pull requests in the main Tabularis repository, split between debba and other contributors. September is a partial month; bot accounts are excluded.\"></p>\n<p>As of September 18, the main repository has merged <strong>257 pull requests from 80 authors other than me</strong>, excluding bot accounts. That is one visible part of the collaboration. Work in driver repositories, translations, testing and support adds much more than this chart can show.</p>\n<h2>Turn bug reports into lasting checks</h2>\n<p>The latest release, <a href=\"https://tabularis.dev/blog/v0240-notebook-query-plans-proxy-settings-result-fonts\">v0.24.0</a>, is a useful snapshot of the challenges now.</p>\n<p>“Follow the system theme” sounds like a small setting. On Linux, the desktop preference, toolkit and webview can disagree. Getting it right required following native appearance signals and making sure the theme chosen by the application did not feed back into its own system-theme detection.</p>\n<p>Proxy support sounds like one connection setting. In practice, the application makes update requests, downloads plugins, talks to AI providers, opens database connections and connects to SSH bastions. Those requests may need different routes. Existing tunnels also need a defined lifecycle when the configuration changes.</p>\n<p>Putting query plans inside notebooks introduces another set of decisions. EXPLAIN ANALYZE actually executes the statement. The interface needs to make that choice explicit, recognise when the query has changed, and avoid running another database request just because someone opens a larger view of a plan.</p>\n<p>This is the kind of work a changelog compresses into a few lines. It means reproducing a report, understanding the environment, reviewing a contribution, and checking the surrounding behavior.</p>\n<p>Ask for the environment and the smallest reproduction, then preserve what the report teaches you in a regression test or a documented manual check. People bring operating systems, database types and network setups I cannot cover alone; their reports should keep helping after the issue closes.</p>\n<h2>Make expansion earn its maintenance cost</h2>\n<p><a href=\"https://github.com/TabularisDB/tabularis/pull/676\">Tabularis Web</a> is one of the larger pieces of work currently underway. The pull request is still open as I write this.</p>\n<p>The goal is to run the application through a browser while sharing its React interface and Rust application services with the desktop client. That requires finding all the places where the application assumes it owns a native window, a local file dialog, or access to the same machine as the user.</p>\n<p>File transfers, authentication, query cancellation, credentials and plugin assets all need explicit behavior across that boundary. A browser disconnecting is another event the application must handle. The desktop version must continue to work while these assumptions are being separated.</p>\n<p>I have already written about <a href=\"https://tabularis.dev/blog/code-generation-was-only-the-beginning-software-development-is-becoming-a-loop\">the development process behind that work</a>, including the use of AI coding tools. That experience reinforced how much depends on the plan, the checks and the review around the implementation. External review found security issues even after the planned tasks and their tests were complete.</p>\n<p>Before adding another way to run your application, identify what it can share and what will need separate verification. Budget for both. Tabularis Web makes that question concrete for the next stage of this project.</p>\n<h2>Plan for the work after the release</h2>\n<p>Reserve time for documentation, reviews and compatibility alongside feature work. For Tabularis, that leads to three priorities.</p>\n<p><strong>Reliability across everyday workflows.</strong> Query execution, editing, imports, exports and reconnection should behave predictably. This means continuing to investigate the awkward cases: session state, unusual data types, interrupted operations and platform-specific failures. It also means making the interface clearer. The <a href=\"https://tabularis.dev/roadmap/ui-design-system\">planned design system</a> is an opportunity to give both users and contributors more consistent components and conventions.</p>\n<p><strong>An ecosystem people can build on.</strong> Plugin authors need useful documentation, stable contracts and a practical way to test compatibility. The connection-specific metadata introduced in v0.24.0 is one step: a generic plugin can describe the database behind each connection instead of forcing every connection into the same static capabilities. Work like this makes future integrations possible, while leaving their implementation in the hands of people who know those systems.</p>\n<p><strong>A sustainable maintenance process.</strong> Reviews, release verification, documentation and support all compete for time. Making those tasks easier to share matters as the project grows. Clear reproduction steps, contributor guides and well-scoped changes help the next person participate with less guesswork.</p>\n<p>These are directions for the work ahead. Some have code under review; others need design, discussion and people willing to help. They will take more than another release cycle.</p>\n<h2>Thank you for making it yours</h2>\n<p>The most encouraging part of this project has been seeing people decide it was worth improving.</p>\n<p>Some have written drivers or added features. Others have translated the interface, worked through packaging problems, tested a fix, or explained exactly where a workflow fell apart. People have shared Tabularis with colleagues, written about it, and supported the infrastructure and tools that help keep development moving.</p>\n<p>A star records interest. The conversations and contributions around the repository show much more of what that interest can become. I am grateful for both.</p>\n<p>We are approaching 5,000 with an application that has grown substantially since January, and plenty of work still to do. Thank you to everyone who has helped bring it this far.</p>\n<p>If you have used Tabularis, including if you tried it and went back to something else, I would like to hear this:</p>\n<p><strong>What is the one thing Tabularis still needs before it can become your everyday database client?</strong></p>\n<p>Tell us in <a href=\"https://github.com/TabularisDB/tabularis/discussions\">GitHub Discussions</a> or <a href=\"https://discord.com/invite/K2hmhfHRSt\">Discord</a>. That feedback will help shape what comes next.</p>\n<hr>\n<p><em>About the charts: GitHub API snapshot taken on September 18, 2026. The star curve is reconstructed from the timestamps of current stargazers, so it excludes stars subsequently removed. Pull requests are grouped by merge month in UTC, with bot accounts excluded; September is incomplete. The PR chart covers the main repository only, excludes direct commits, and does not measure the size or effort of contributions.</em></p>\n",
      "image": "https://tabularis.dev/blog/how-to-grow-an-open-source-project/opengraph-image.png",
      "date_published": "2026-09-18T12:00:00",
      "tags": [
        "community",
        "milestone",
        "open-source",
        "engineering"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0240-notebook-query-plans-proxy-settings-result-fonts",
      "url": "https://tabularis.dev/blog/v0240-notebook-query-plans-proxy-settings-result-fonts",
      "title": "v0.24.0: Query Plans Inside Notebooks, HTTP/SOCKS5 Proxies, and a Font for Your Results",
      "summary": "v0.24.0 brings inline notebook query plans, scoped HTTP/SOCKS5 proxies, independent result fonts, running-tab indicators, and connection-specific plugin metadata.",
      "content_html": "<h1>v0.24.0: Query Plans Inside Notebooks, HTTP/SOCKS5 Proxies, and a Font for Your Results</h1>\n<p><strong>v0.24.0</strong> follows <a href=\"https://tabularis.dev/blog/v0230-postgres-plugin-migration-sql-files-storage-location\">v0.23.0</a> with changes to the work you do after connecting. A notebook cell can keep its execution plan beside its SQL and results, rather than sending you into a separate modal. Network settings let you route application requests, database connections, AI calls and SSH traffic independently through an HTTP or SOCKS5 proxy. Query results get their own font picker, and a running query identifies its tab even while you work elsewhere. Underneath, plugins can discover capabilities and data types per connection instead of describing every server with one static manifest. The community fixes cover autocomplete ranking, Linux appearance detection, Snap packaging and desktop deep links.</p>\n<hr>\n<h2>Query Plans Belong in the Notebook</h2>\n<p><a href=\"https://github.com/harshavardhankonisa\">@harshavardhankonisa</a> brings Visual EXPLAIN into the cell itself in PR <a href=\"https://github.com/TabularisDB/tabularis/pull/718\">#718</a>. SQL cells on a connection that supports EXPLAIN gain a <strong>Query Plan</strong> toggle in their header. Turn it on and a resizable plan section appears alongside Query, Results and Chart, using the same Visual EXPLAIN views as the standalone viewer. Visibility is saved with the cell and survives notebook export and import.</p>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-notebook-query-plan.mp4\" poster=\"/videos/posts/tabularis-notebook-query-plan.jpg\" autoplay loop muted playsinline ></video></p>\n<p>The first opening requests a plain <strong>EXPLAIN</strong>, not ANALYZE. The panel has an <strong>EXPLAIN ANALYZE</strong> checkbox, a <strong>Re-run</strong> button and a popout that opens the already-fetched plan in the full modal without another database request. Selecting ANALYZE does not execute anything until you press Re-run. That distinction matters: ANALYZE runs the statement, including writes for data-modifying queries.</p>\n<p>The plan uses the query the notebook would actually execute. <code>@param</code> values are substituted and <code>{{cell_N}}</code> references expand to their result CTEs. If a referenced cell has not run successfully, the panel names the unresolved references and sends no request. If you change the SQL, connection or schema after fetching a plan, the old plan is replaced by an outdated notice until you explicitly refresh it. The ANALYZE choice is tied to that source too, so consent to analyze one statement does not carry over to a different one. Running the cell again does not silently run EXPLAIN again.</p>\n<p>Several notebook fixes ship with it. CSV and JSON exports now report success or failure instead of swallowing write errors. Repeated checks for parameters and cell references no longer alternate between true and false because a shared global regular expression retained its cursor. Referencing the same cell twice emits one CTE instead of duplicate definitions. And reordering, inserting or deleting cells no longer remounts every cell below the change, preserving their editor and panel state.</p>\n<hr>\n<h2>A Proxy for the Traffic You Choose</h2>\n<p>PR <a href=\"https://github.com/TabularisDB/tabularis/pull/737\">#737</a>, by <a href=\"https://github.com/coloraven\">@coloraven</a>, adds <strong>Settings → Network</strong>. Configure an <strong>HTTP CONNECT</strong> or <strong>SOCKS5</strong> endpoint with an optional username and password, then choose which traffic should use it:</p>\n<table>\n<thead>\n<tr>\n<th align=\"left\">Scope</th>\n<th align=\"left\">What it covers</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"left\">App network requests</td>\n<td align=\"left\">Update checks, plugin downloads, registry requests and WebDAV backups.</td>\n</tr>\n<tr>\n<td align=\"left\">Database connections</td>\n<td align=\"left\">Direct database TCP connections.</td>\n</tr>\n<tr>\n<td align=\"left\">AI / LLM endpoints</td>\n<td align=\"left\">Requests to the configured AI providers.</td>\n</tr>\n<tr>\n<td align=\"left\">SSH tunnels</td>\n<td align=\"left\">The outbound connection to the SSH bastion.</td>\n</tr>\n</tbody></table>\n<p><img src=\"https://tabularis.dev/img/tabularis-network-proxy.png\" alt=\"Settings → Network with a SOCKS5 proxy, optional authentication fields and independent traffic-scope toggles\"></p>\n<p>The global proxy is off by default, and each scope is opt-in. A database connection&#39;s <strong>Advanced</strong> section and each AI provider&#39;s settings can <strong>inherit</strong> the global choice, use a <strong>custom</strong> endpoint or <strong>disable</strong> proxying for that target. An explicit target override takes precedence over the global setting. Proxy passwords go into the OS keychain, not <code>config.json</code> or <code>connections.json</code>.</p>\n<p>For SSH, the proxy applies to the bastion hop; the local leg produced by an SSH or Kubernetes tunnel is not sent through a second database proxy. Reusable SSH profiles do not gain a separate proxy picker in this release: use the global SSH scope or the database connection&#39;s override. Saving global proxy settings tears down existing SSH tunnels and cached forwards so later connections use the new configuration; reconnect after changing it.</p>\n<p>This is routing control, not a promise that every third-party plugin&#39;s own HTTP client obeys application settings. It is also not a replacement for TLS to the database or AI endpoint.</p>\n<hr>\n<h2>Result Cells Get Their Own Font</h2>\n<p>Until now, the grid was hardwired to a monospace stack. Changing the interface font did not change result cells, which is what <a href=\"https://github.com/TabularisDB/tabularis/issues/726\">#726</a> asked for. PR <a href=\"https://github.com/TabularisDB/tabularis/pull/741\">#741</a> adds <strong>Settings → Appearance → Data Grid → Result font</strong>.</p>\n<p>Choose <strong>Same as interface</strong>, a bundled font or a custom family. The default remains <strong>JetBrains Mono</strong>, so existing installations look the same. The choice covers result cells, inline edit inputs and multiline textareas; multiline width measurement uses the actual selected font, so proportional text is not measured as if it were monospace. SQL editors, logs, hex and JSON views keep their own typography. The setting is persisted as <code>resultFontFamily</code>; <strong>Same as interface</strong> is stored as <code>inherit</code> and follows later interface-font changes.</p>\n<p><img src=\"https://tabularis.dev/img/tabularis-result-font.png\" alt=\"Appearance → Data Grid with Result font set to Same as interface, beside bundled and custom font choices\"></p>\n<hr>\n<h2>The Running Query Identifies Its Tab</h2>\n<p>PR <a href=\"https://github.com/TabularisDB/tabularis/pull/767\">#767</a> replaces a tab&#39;s type icon with a spinner in the connection&#39;s accent colour while a query is executing. It carries an <strong>Executing query</strong> tooltip and an accessible status role, and remains visible when another tab is active. The thin pulsing line beneath the tab stays too.</p>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-running-tab.mp4\" poster=\"/videos/posts/tabularis-running-tab.jpg\" autoplay loop muted playsinline ></video></p>\n<p>Notebook tabs participate: one running cell or Run All keeps the indicator active until all currently executing cells finish. Errors and cancelled execution guards clear it correctly, and loading state is not persisted into a restored session. A small change, but it answers “which query is still running?” without opening each tab.</p>\n<hr>\n<h2>One Plugin, Different Metadata for Each Connection</h2>\n<p>A generic driver cannot describe PostgreSQL and MySQL accurately with the same type list and identifier quoting. PR <a href=\"https://github.com/TabularisDB/tabularis/pull/744\">#744</a>, following <a href=\"https://github.com/orgs/TabularisDB/discussions/728\">discussion #728</a>, gives plugins an opt-in <strong>connection metadata discovery</strong> contract.</p>\n<p>A manifest can declare <code>&quot;connection_metadata&quot;: true</code>. After a successful connection test, Tabularis calls <code>get_connection_metadata</code> before loading database objects. The reply can override capabilities, data types and type mappings for that connection. The registered manifest stays immutable: two connections using the same plugin do not overwrite each other&#39;s dialect or type selectors, and backend operations, including MCP, resolve the same connection-specific metadata.</p>\n<p>Omitted fields keep their manifest defaults; explicit <code>false</code> values and empty collections replace them. A connection cannot lift a manifest-level read-only restriction. Discovery is cached per plugin process and invalidated by connection tests and disconnects. Only JSON-RPC <code>-32601</code> falls back to the static manifest; authentication failures, transport errors and malformed responses remain visible errors.</p>\n<p>Existing plugins do not opt in automatically and receive no new discovery requests. This ships the <strong>host contract</strong>, not a finished JDBC integration: a bridge still needs to implement the method and route requests to the correct database worker, and its registry must accept the opt-in field. The <a href=\"https://github.com/TabularisDB/tabularis/blob/main/plugins/CONNECTION_METADATA.md\">protocol reference</a> documents those boundaries.</p>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<li><strong>Autocomplete ranks the nearest table first</strong> (<a href=\"https://github.com/adisusilayasa\">@adisusilayasa</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/679\">#679</a>, fixes <a href=\"https://github.com/TabularisDB/tabularis/issues/507\">#507</a>): in a join condition, columns from the table being joined rank above other in-scope columns; in SELECT, WHERE and SET contexts, the primary table takes priority. Aliases and nested scopes are respected, and other tables&#39; columns remain available.</li>\n<li><strong>Follow System uses native appearance</strong> (<a href=\"https://github.com/be-student\">@be-student</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/723\">#723</a>, fixes <a href=\"https://github.com/TabularisDB/tabularis/issues/716\">#716</a>): WebKit could report light while GNOME was dark. The app now follows native theme signals and Linux desktop-portal preferences, with browser media queries as the preview fallback, and avoids feeding a forced app theme back into OS detection.</li>\n<li><strong>Snap&#39;s editor loads correctly</strong> (<a href=\"https://github.com/wangyingsm\">@wangyingsm</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/712\">#712</a>, fixes <a href=\"https://github.com/TabularisDB/tabularis/issues/710\">#710</a>): the package gains the <code>network-status</code> plug needed by WebKit&#39;s portal requests and stops mixing a staged WebKit library with the GNOME platform&#39;s helper processes. Sandboxed launches skip redundant deep-link registration. Keychain errors in Snap now point to <code>sudo snap connect tabularis:password-manager-service</code>.</li>\n<li><strong>Linux desktop links reach the app</strong> (<a href=\"https://github.com/anandghegde\">@anandghegde</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/754\">#754</a>, fixes <a href=\"https://github.com/TabularisDB/tabularis/issues/671\">#671</a>): the desktop entry passes <code>%u</code> to the executable, so a <code>tabularis://</code> URL is not lost on a cold start. The launcher name becomes <strong>Tabularis</strong> and its category becomes <strong>Development</strong>, shared by deb, rpm and AppImage packaging.</li>\n<li><strong>Plugin activation does not revert unrelated settings</strong> (<a href=\"https://github.com/aesslinger\">@aesslinger</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/736\">#736</a>): PostgreSQL auto-activation writes only its driver setting instead of a stale full-config snapshot. The migration checklist also closes with Escape.</li>\n<li><strong>Saved query names are readable in light themes</strong> (PR <a href=\"https://github.com/TabularisDB/tabularis/pull/770\">#770</a>, fixes <a href=\"https://github.com/TabularisDB/tabularis/issues/743\">#743</a>): the Save/Edit Query modal uses theme text colours instead of hardcoded white, from both the editor and sidebar.</li>\n<li><strong>Release notes render as Markdown</strong> (PR <a href=\"https://github.com/TabularisDB/tabularis/pull/738\">#738</a>): the update notification renders headings, lists, code and links instead of a plain preformatted block. Links open through the OS opener.</li>\n<li><strong>A personal invitation in What&#39;s New</strong> (PR <a href=\"https://github.com/TabularisDB/tabularis/pull/772\">#772</a>): a compact author introduction links to GitHub Sponsors and the repository. <strong>Never show this again</strong> hides only the invitation, not future release notes; the avatar is bundled locally.</li>\n<li><strong>Test certificates are generated, not committed</strong> (<a href=\"https://github.com/igorzelaya-io\">@igorzelaya-io</a>, PR <a href=\"https://github.com/TabularisDB/tabularis/pull/742\">#742</a>, fixes <a href=\"https://github.com/TabularisDB/tabularis/issues/715\">#715</a>): the PostgreSQL mTLS test creates its self-signed EC certificate at runtime instead of carrying a static private-key fixture.</li>\n<li><strong>CI dependency update</strong>: <code>pnpm/action-setup</code> moves to 6.1.0 in PR <a href=\"https://github.com/TabularisDB/tabularis/pull/768\">#768</a>.</li>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Eight external contributors land in this release. <strong><a href=\"https://github.com/harshavardhankonisa\">@harshavardhankonisa</a></strong> built inline notebook plans and fixed the surrounding notebook state and export issues (<a href=\"https://github.com/TabularisDB/tabularis/pull/718\">#718</a>). <strong><a href=\"https://github.com/coloraven\">@coloraven</a></strong> added scoped proxy routing and target overrides (<a href=\"https://github.com/TabularisDB/tabularis/pull/737\">#737</a>). <strong><a href=\"https://github.com/adisusilayasa\">@adisusilayasa</a></strong> made multi-table autocomplete prioritise the table you are working with (<a href=\"https://github.com/TabularisDB/tabularis/pull/679\">#679</a>).</p>\n<p><strong><a href=\"https://github.com/be-student\">@be-student</a></strong> fixed native theme detection (<a href=\"https://github.com/TabularisDB/tabularis/pull/723\">#723</a>), <strong><a href=\"https://github.com/wangyingsm\">@wangyingsm</a></strong> repaired Snap integration (<a href=\"https://github.com/TabularisDB/tabularis/pull/712\">#712</a>), and <strong><a href=\"https://github.com/anandghegde\">@anandghegde</a></strong> fixed Linux desktop entries (<a href=\"https://github.com/TabularisDB/tabularis/pull/754\">#754</a>). <strong><a href=\"https://github.com/aesslinger\">@aesslinger</a></strong> closed the plugin-activation config race and added Escape to the migration checklist (<a href=\"https://github.com/TabularisDB/tabularis/pull/736\">#736</a>). <strong><a href=\"https://github.com/igorzelaya-io\">@igorzelaya-io</a></strong> replaced the committed TLS test fixture with runtime generation (<a href=\"https://github.com/TabularisDB/tabularis/pull/742\">#742</a>).</p>\n<p>If you investigate queries in notebooks, work behind a proxy, or keep losing track of the tab that is still running, this is the upgrade.</p>\n<hr>\n<p><em>v0.24.0 is available now. Download the builds from the <a href=\"https://github.com/TabularisDB/tabularis/releases/tag/v0.24.0\">release page</a>.</em></p>\n",
      "image": "https://tabularis.dev/blog/v0240-notebook-query-plans-proxy-settings-result-fonts/opengraph-image.png",
      "date_published": "2026-09-16T10:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "ui",
        "ux",
        "data-grid",
        "plugin",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0230-postgres-plugin-migration-sql-files-storage-location",
      "url": "https://tabularis.dev/blog/v0230-postgres-plugin-migration-sql-files-storage-location",
      "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",
      "summary": "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.",
      "content_html": "<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>\n<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>\n<hr>\n<h2>Plugins Ship Their Own EXPLAIN Parsers, and the SQL Server Plugin Lands</h2>\n<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>\n<p><video class=\"video-borderless\" src=\"https://tabularis.dev/videos/posts/explain-sqlserver.mp4\" poster=\"/videos/posts/explain-sqlserver.jpg\" autoplay loop muted playsinline></video></p>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<h2>Open, Edit and Save SQL Files in Editor Tabs</h2>\n<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>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<h2>Move the Data Folder Anywhere, and Sync It</h2>\n<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>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<h2>The Built-in PostgreSQL Driver Is Deprecated, and the Plugin Migration Is Reversible</h2>\n<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>\n<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>\n<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>\n<ul>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<h2>Two Bugs That Ate Keystrokes in the SQL Editor</h2>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Five external contributors land in v0.23.0.</p>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0230-postgres-plugin-migration-sql-files-storage-location/opengraph-image.png",
      "date_published": "2026-09-10T10:30:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "postgres",
        "mysql",
        "sqlite",
        "plugin",
        "ui",
        "ux",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/how-plugins-can-now-inject-their-own-parsers-into-visual-explain",
      "url": "https://tabularis.dev/blog/how-plugins-can-now-inject-their-own-parsers-into-visual-explain",
      "title": "How plugins can now inject their own parsers into Visual EXPLAIN",
      "summary": "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.",
      "content_html": "<h1>How plugins can now inject their own parsers into Visual EXPLAIN</h1>\n<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>\n<p><video class=\"video-borderless\" src=\"https://tabularis.dev/videos/posts/explain-sqlserver.mp4\" poster=\"/videos/posts/explain-sqlserver.jpg\" autoplay loop muted playsinline ></video></p>\n<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>\n<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>\n<h2>Where the parser used to live</h2>\n<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>\n<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>\n<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>\n<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>\n<h2>What changed in the core</h2>\n<p>The work landed in two steps in the Tabularis repository.</p>\n<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>\n<pre><code class=\"language-ts\">export interface RegisteredExplainParser {\n    readonly engine: string;\n    readonly format: string;\n    readonly label?: string;\n    parse(payload: string): ExplainPlan;\n    sniff?(payload: string): boolean;\n}\n\nexport function registerExplainParser(parser: RegisteredExplainParser): void;\nexport function unregisterExplainParser(format: string): void;\n</code></pre>\n<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>\n<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>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<h2>What changed in the plugin</h2>\n<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>\n<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>\n<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>\n<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>\n<h2>A note on how this was planned</h2>\n<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>\n<h2>Next week: SQL Server</h2>\n<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>\n<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>\n<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>\n",
      "image": "https://tabularis.dev/blog/how-plugins-can-now-inject-their-own-parsers-into-visual-explain/opengraph-image.png",
      "date_published": "2026-09-04T18:30:00",
      "tags": [
        "explain",
        "sql-server",
        "plugins",
        "architecture",
        "typescript",
        "rust",
        "extensibility"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0220-follow-system-theme-grid-selection-shortcuts-tiling-decorations",
      "url": "https://tabularis.dev/blog/v0220-follow-system-theme-grid-selection-shortcuts-tiling-decorations",
      "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",
      "summary": "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.",
      "content_html": "<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>\n<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>\n<hr>\n<h2>Follow System: One Theme for Light, One for Dark</h2>\n<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>\n<ul>\n<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>\n<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>\n</ul>\n<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>\n<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>\n<hr>\n<h2>Spreadsheet-Style Selection in the Data Grid</h2>\n<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>\n<table>\n<thead>\n<tr>\n<th align=\"left\">Shortcut</th>\n<th align=\"left\">Action</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"left\"><code>Shift + Arrow</code></td>\n<td align=\"left\">Extend a rectangular range by one step. The anchor stays fixed, the opposite corner moves.</td>\n</tr>\n<tr>\n<td align=\"left\"><code>Ctrl/Cmd + Arrow</code></td>\n<td align=\"left\">Jump the focused cell to the grid edge.</td>\n</tr>\n<tr>\n<td align=\"left\"><code>Ctrl/Cmd + Shift + Arrow</code></td>\n<td align=\"left\">Extend the range to the grid edge.</td>\n</tr>\n<tr>\n<td align=\"left\"><code>Ctrl/Cmd + Home</code> / <code>End</code></td>\n<td align=\"left\">First / last cell of the grid.</td>\n</tr>\n<tr>\n<td align=\"left\"><code>Shift + Space</code></td>\n<td align=\"left\">Select the row(s) of the focused cell or of the current range.</td>\n</tr>\n<tr>\n<td align=\"left\"><code>Ctrl/Cmd + Space</code>, or <code>Ctrl/Cmd + Shift + Space</code></td>\n<td align=\"left\">Select the column(s) of the focused cell or of the current range.</td>\n</tr>\n</tbody></table>\n<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>\n<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>\n<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>\n<hr>\n<h2>Native Decorations Get Out of the Way on Tiling Window Managers</h2>\n<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>\n<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>\n<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>\n<hr>\n<h2>Notebook Polish</h2>\n<p>Two PRs from <a href=\"https://github.com/harshavardhankonisa\">@harshavardhankonisa</a> went through the notebook frontend with a magnifying glass.</p>\n<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>\n<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>\n<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>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Six external contributors land in v0.22.0.</p>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0220-follow-system-theme-grid-selection-shortcuts-tiling-decorations/opengraph-image.png",
      "date_published": "2026-09-01T21:40:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "postgres",
        "mysql",
        "sqlite",
        "ui",
        "ux",
        "data-grid",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0210-sql-folding-per-tab-page-size-postgres-mtls",
      "url": "https://tabularis.dev/blog/v0210-sql-folding-per-tab-page-size-postgres-mtls",
      "title": "v0.21.0: Fold Your SQL, Pick Your Page Size Per Tab, and Postgres Client Certificates That Actually Get Sent",
      "summary": "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.",
      "content_html": "<h1>v0.21.0: Fold Your SQL, Pick Your Page Size Per Tab, and Postgres Client Certificates That Actually Get Sent</h1>\n<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>\n<hr>\n<h2>Fold Statements, Preview Them on Hover</h2>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-sql-folding.mp4\" poster=\"/videos/posts/tabularis-sql-folding.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Rows Per Page, Per Tab</h2>\n<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>\n<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>\n<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>\n<hr>\n<h2>PostgreSQL Client Certificates (mTLS)</h2>\n<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>\n<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>\n<hr>\n<h2>Cancellable Plugin Installs</h2>\n<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>\n<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 ></video></p>\n<hr>\n<h2>One Safety Guard Instead of Two</h2>\n<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>\n<ul>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<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>\n<hr>\n<h2>The SSL-Mode Migration Reaches the MCP Server</h2>\n<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>\n<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>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Six external contributors land in v0.21.0.</p>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0210-sql-folding-per-tab-page-size-postgres-mtls/opengraph-image.png",
      "date_published": "2026-08-25T18:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "postgres",
        "mysql",
        "sqlite",
        "ui",
        "ux",
        "data-grid",
        "plugin",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/code-generation-was-only-the-beginning-software-development-is-becoming-a-loop",
      "url": "https://tabularis.dev/blog/code-generation-was-only-the-beginning-software-development-is-becoming-a-loop",
      "title": "Code generation was only the beginning. Software development is becoming a loop",
      "summary": "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.",
      "content_html": "<h1>Code generation was only the beginning. Software development is becoming a loop</h1>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<p>In my case the outer loop was more or less this:</p>\n<pre><code class=\"language-text\">plan -&gt; fresh agent -&gt; change -&gt; local tests -&gt; commit -&gt; CI\n          ^                                      |\n          |--------------- repair ---------------|\n</code></pre>\n<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>\n<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>\n<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>\n<p>For this project the durable state was deliberately boring:</p>\n<ul>\n<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>\n<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>\n<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>\n<li>Git commits as checkpoints and rollback points.</li>\n</ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<pre><code class=\"language-bash\">for task in &quot;${TASKS[@]}&quot;; do\n  pi --print --approve \\\n    --name &quot;web-ui-${task}&quot; \\\n    --thinking high \\\n    --model openai/gpt-5.6-sol \\\n    @web-ui-project/docs/WEB_UI_PLAN.md \\\n    @&quot;web-ui-project/tasks/${task}.md&quot; \\\n    @web-ui-project/tasks/PROGRESS.md \\\n    &quot;$TASK_PROMPT&quot;\n\n  validate_progress_entry &quot;$task&quot;\n  watch_task_ci &quot;$task&quot;\ndone\n</code></pre>\n<p>There was more code around this, mostly checks. In particular, the runner required that:</p>\n<ul>\n<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>\n<li>only one copy could modify the branch at a time;</li>\n<li>the task updated the ledger with a summary, evidence and a date;</li>\n<li>the ledger update was included in the task commit;</li>\n<li>local checks passed before pushing;</li>\n<li>CI was green before the next task started.</li>\n</ul>\n<p>Every task was a conventional commit, so every new session inherited a branch that was supposed to be both understandable and recoverable.</p>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<table>\n<thead>\n<tr>\n<th>Mode</th>\n<th>Unit of work</th>\n<th>Durable state</th>\n<th>Completion</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>Autocomplete</td>\n<td>The next tokens</td>\n<td>The open file</td>\n<td>The developer accepts the suggestion</td>\n</tr>\n<tr>\n<td>Chat</td>\n<td>One answer</td>\n<td>The conversation</td>\n<td>The developer asks another question</td>\n</tr>\n<tr>\n<td>Coding agent</td>\n<td>One delegated task</td>\n<td>The context window and worktree</td>\n<td>The agent returns control</td>\n</tr>\n<tr>\n<td>Agent loop</td>\n<td>One verified task</td>\n<td>The repository, tests and Git</td>\n<td>External checks pass</td>\n</tr>\n</tbody></table>\n<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>\n<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>\n<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>\n<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>\n<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>\n",
      "image": "https://tabularis.dev/blog/code-generation-was-only-the-beginning-software-development-is-becoming-a-loop/opengraph-image.png",
      "date_published": "2026-08-23T13:32:00",
      "tags": [
        "ai",
        "engineering",
        "open-source",
        "ralph-loop",
        "deep-dive"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0200-command-palette-grid-paste-postgres-plugin",
      "url": "https://tabularis.dev/blog/v0200-command-palette-grid-paste-postgres-plugin",
      "title": "v0.20.0: A Command Palette That Knows Which Pane You're In, Paste That Works Like a Spreadsheet, and PostgreSQL Moves Out of the Core",
      "summary": "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.",
      "content_html": "<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>\n<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>\n<hr>\n<h2>The Quick Navigator Grows Into a Command Palette</h2>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-command-palette.mp4\" poster=\"/videos/posts/tabularis-command-palette.jpg\" autoplay loop muted playsinline ></video></p>\n<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>\n<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>\n<hr>\n<h2>Paste Lands in the Data Grid</h2>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-grid-paste.mp4\" poster=\"/videos/posts/tabularis-grid-paste.jpg\" autoplay loop muted playsinline ></video></p>\n<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>\n<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>\n<hr>\n<h2>PostgreSQL Moves Out of the Core</h2>\n<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>\n<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>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<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>\n<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>\n<hr>\n<h2>Binary Data: Hex You Can Read, Keys You Can Delete</h2>\n<p>Three threads landed on the same theme: binary and keyless data used to fail in opaque ways, and now doesn&#39;t.</p>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<h2>Package-Managed Builds Stop Fighting Their Package Manager</h2>\n<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>\n<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>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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.\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Seven external contributors land in v0.20.0.</p>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0200-command-palette-grid-paste-postgres-plugin/opengraph-image.png",
      "date_published": "2026-08-17T18:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "postgres",
        "mysql",
        "sqlite",
        "ui",
        "ux",
        "data-grid",
        "plugin",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/one-sql-script-multiple-database-connections",
      "url": "https://tabularis.dev/blog/one-sql-script-multiple-database-connections",
      "title": "We Accidentally Ran One SQL Script Across Multiple Database Connections",
      "summary": "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.",
      "content_html": "<h1>We Accidentally Ran One SQL Script Across Multiple Database Connections</h1>\n<p>The import was supposed to create 144 child rows, eight for each of 18 parents. It created 83.</p>\n<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>\n<p>There was no useful error. Every SQL statement looked successful in the UI.</p>\n<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>\n<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>\n<p>The application had taken one SQL script and executed it as a collection of unrelated, concurrent database sessions.</p>\n<h2>The script depended on a session</h2>\n<p>The original report involved a longer MySQL import, but its essential dependency fits in four statements:</p>\n<pre><code class=\"language-sql\">INSERT INTO parent (name) VALUES (&#39;A&#39;);\nSET @parent_id = LAST_INSERT_ID();\nINSERT INTO child (parent_id, name) VALUES (@parent_id, &#39;A-1&#39;);\nINSERT INTO child (parent_id, name) VALUES (@parent_id, &#39;A-2&#39;);\n</code></pre>\n<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>\n<p>The word &quot;connection&quot; is doing all the work there.</p>\n<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>\n<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>\n<h2><code>Promise.allSettled</code> was the transaction coordinator</h2>\n<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>\n<pre><code class=\"language-typescript\">await Promise.allSettled(\n  statements.map((statement) =&gt;\n    invoke(&quot;execute_query&quot;, {\n      connectionId,\n      query: statement,\n    }),\n  ),\n);\n</code></pre>\n<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>\n<p>It also meant every statement started independently.</p>\n<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>\n<pre><code class=\"language-rust\">let pool = get_mysql_pool(params).await?;\nlet mut conn = pool.acquire().await?;\nexecute_on(&amp;mut conn, query).await\n</code></pre>\n<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>\n<p>The four-statement script could therefore become this:</p>\n<pre><code class=\"language-text\">INSERT INTO parent ...       connection 17\nSET @parent_id = ...         connection 24\nINSERT INTO child ...        connection 31\nINSERT INTO child ...        connection 42\n</code></pre>\n<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>\n<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>\n<p>The database was doing exactly what each client asked. We had accidentally created several clients.</p>\n<h2>Why the failure looked healthy</h2>\n<p>Most database failures are kind enough to produce an error code. This one often produced plausible data.</p>\n<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>\n<p>So a successful insert and an insert that did nothing had the same summary: zero rows affected.</p>\n<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>\n<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>\n<h2>Serial execution is necessary, but not sufficient</h2>\n<p>The obvious first fix is to replace <code>Promise.allSettled</code> with a loop:</p>\n<pre><code class=\"language-typescript\">for (const statement of statements) {\n  await invoke(&quot;execute_query&quot;, { connectionId, query: statement });\n}\n</code></pre>\n<p>That restores ordering. It does not restore session identity.</p>\n<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>\n<p>There are two separate invariants:</p>\n<ol>\n<li>Statement B must start after statement A finishes.</li>\n<li>Statement B must execute in the same database session as statement A.</li>\n</ol>\n<p>Awaiting each query gives you the first. Holding one connection for the lifetime of the script gives you both.</p>\n<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>\n<h2>The fix was one checkout, then a loop</h2>\n<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>\n<p>The core of the fix is deliberately boring:</p>\n<pre><code class=\"language-rust\">let mut conn = pool.acquire().await?;\nlet mut results = Vec::with_capacity(statements.len());\n\nfor statement in statements {\n    let result = execute_on(&amp;mut conn, statement).await;\n    results.push(result);\n}\n\nOk(results)\n</code></pre>\n<p>The important part is not the loop. It is where <code>acquire()</code> sits.</p>\n<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>\n<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>\n<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>\n<h2>Fixing the connection exposed a protocol problem</h2>\n<p>Keeping one connection alive made transactions structurally possible, but MySQL added another constraint.</p>\n<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>\n<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>\n<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>\n<h2>Testing session identity instead of query success</h2>\n<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>\n<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>\n<p>The PostgreSQL test uses a temporary table:</p>\n<pre><code class=\"language-sql\">BEGIN;\nCREATE TEMP TABLE batch_tmp (id serial primary key, value text);\nINSERT INTO batch_tmp (value) VALUES (&#39;a&#39;), (&#39;b&#39;), (&#39;c&#39;);\nSELECT count(*) FROM batch_tmp;\nCOMMIT;\n</code></pre>\n<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>\n<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>\n<h2>A script is a conversation</h2>\n<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>\n<p>These operations all depend on session identity:</p>\n<ul>\n<li><code>BEGIN</code>, <code>COMMIT</code>, savepoints and transaction-scoped locks</li>\n<li>MySQL user variables and <code>LAST_INSERT_ID()</code></li>\n<li>PostgreSQL <code>currval()</code> and prepared statements</li>\n<li>temporary tables</li>\n<li>session settings such as <code>search_path</code>, <code>sql_mode</code> or <code>FOREIGN_KEY_CHECKS</code></li>\n<li>advisory locks and other connection-owned resources</li>\n</ul>\n<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>\n<p>That is the general lesson from this bug:</p>\n<p><strong>A SQL script is not a <code>Vec&lt;String&gt;</code>. It is a conversation with one database session.</strong></p>\n<p>We had preserved the strings and discarded the conversation. The database accepted every sentence. It was listening on four different calls.</p>\n",
      "image": "https://tabularis.dev/blog/one-sql-script-multiple-database-connections/opengraph-image.png",
      "date_published": "2026-08-12T10:00:00",
      "tags": [
        "databases",
        "sql",
        "mysql",
        "postgresql",
        "connection-pooling",
        "engineering",
        "deep-dive"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0190-connection-tags-column-masking-keyless-editing",
      "url": "https://tabularis.dev/blog/v0190-connection-tags-column-masking-keyless-editing",
      "title": "v0.19.0: Connections That Know They're Production, Columns That Keep Secrets, and Tables Without a Primary Key You Can Finally Edit",
      "summary": "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.",
      "content_html": "<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>\n<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>\n<hr>\n<h2>Tags, Environments, and a Warning Before You Write to Production</h2>\n<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>\n<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>\n<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>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-production-guard.mp4\" poster=\"/videos/posts/tabularis-production-guard.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Sensitive Columns Come Up Masked</h2>\n<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>\n<ul>\n<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>\n<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>\n<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>\n</ul>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-column-masking.mp4\" poster=\"/videos/posts/tabularis-column-masking.jpg\" autoplay loop muted playsinline ></video></p>\n<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>\n<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>\n<hr>\n<h2>Tables Without a Primary Key Are Now Editable</h2>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<h2>The Plugin Pipeline Closes the Gap</h2>\n<p>Five threads landed this cycle that together move plugin drivers from &quot;supported&quot; toward &quot;indistinguishable from built-in&quot;.</p>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<h2>The AppImage Stops Bundling a Time Bomb</h2>\n<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>\n<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>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Seven external contributors land in v0.19.0.</p>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0190-connection-tags-column-masking-keyless-editing/opengraph-image.png",
      "date_published": "2026-08-10T11:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "mysql",
        "postgres",
        "sqlite",
        "ui",
        "ux",
        "data-grid",
        "plugin",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/installing-tabularis-from-aur-without-a-helper",
      "url": "https://tabularis.dev/blog/installing-tabularis-from-aur-without-a-helper",
      "title": "Installing Tabularis from the AUR, the Careful Way",
      "summary": "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's how to build straight from our GitHub repo — and what to check in any PKGBUILD before you run it.",
      "content_html": "<h1>Installing Tabularis from the AUR, the Careful Way</h1>\n<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>\n<h2>Building straight from our repo</h2>\n<p>You can build straight from our GitHub repo, the PKGBUILD lives in the <code>aur/</code> folder:</p>\n<pre><code class=\"language-bash\">curl -O https://raw.githubusercontent.com/TabularisDB/tabularis/main/aur/PKGBUILD\nsed -i &#39;s/^pkgver=.*/pkgver={{APP_VERSION}}/&#39; PKGBUILD\nupdpkgsums          # fills in the real sha256, needs pacman-contrib\nless PKGBUILD       # read it, that&#39;s the whole point\nmakepkg -si\n</code></pre>\n<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>\n<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>\n<h2>Read the PKGBUILD — every time</h2>\n<p>Whatever you install from the AUR these days, take thirty seconds to read the PKGBUILD:</p>\n<ul>\n<li>check that <code>source=</code> points at the real upstream,</li>\n<li>be suspicious of any <code>curl</code> or <code>wget</code> inside <code>prepare()</code>/<code>build()</code>,</li>\n<li>and of <code>post_install</code> hooks.</li>\n</ul>\n<p>If you did install one of the compromised packages, assume the box is dirty and rotate your SSH keys and API tokens.</p>\n<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>\n",
      "image": "https://tabularis.dev/blog/installing-tabularis-from-aur-without-a-helper/opengraph-image.png",
      "date_published": "2026-08-03T19:00:00",
      "tags": [
        "security",
        "linux",
        "arch",
        "aur",
        "open-source",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0180-user-privileges-connection-diagnostics-grid-selection",
      "url": "https://tabularis.dev/blog/v0180-user-privileges-connection-diagnostics-grid-selection",
      "title": "v0.18.0: Users and Privileges, a Connection Test That Tells You Where It Failed, and a Data Grid You Can Actually Select",
      "summary": "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.",
      "content_html": "<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>\n<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>\n<hr>\n<h2>Users &amp; Privileges for MySQL and MariaDB</h2>\n<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>\n<ul>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<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 ></video></p>\n<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>\n<hr>\n<h2>The Connection Test Tells You Where It Failed</h2>\n<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>\n<ul>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-connection-diagnostics.mp4\" poster=\"/videos/posts/tabularis-connection-diagnostics.jpg\" autoplay loop muted playsinline ></video></p>\n<p>All new strings are translated across the eleven supported locales.</p>\n<hr>\n<h2>All-Databases Mode: Leave It Empty to Browse Everything</h2>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<h2>The Data Grid Gets a Real Selection Model</h2>\n<p>Three pieces landed here, and together they change how the grid feels under the hands.</p>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-grid-selection.mp4\" poster=\"/videos/posts/tabularis-grid-selection.jpg\" autoplay loop muted playsinline ></video></p>\n<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>\n<hr>\n<h2>The Run Button Says What It Will Run</h2>\n<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>\n<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>\n<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 ></video></p>\n<hr>\n<h2>Editor Tabs Reorder by Drag</h2>\n<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>\n<p>A follow-up fix renders editor panes in a stable order, so a reorder can&#39;t shuffle which pane is which.</p>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-reorder-tabs.mp4\" poster=\"/videos/posts/tabularis-reorder-tabs.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>The ER Diagram Exports, and Stops Overlapping</h2>\n<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>\n<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>\n<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>\n<hr>\n<h2>PostgreSQL: Editable hstore, Expression Indexes, Catalog-Based Keys</h2>\n<p>Four PostgreSQL fixes land this cycle, three of them from outside the core team.</p>\n<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>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<h2>Create a SQLite Database from Inside the App</h2>\n<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>\n<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>\n<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>\n<hr>\n<h2>Notebook SQL Generation, Hardened</h2>\n<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>\n<ul>\n<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>\n<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>\n<li><strong>MySQL and MariaDB string values escape backslashes</strong>, not just single quotes.</li>\n</ul>\n<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>\n<hr>\n<h2>DynamoDB in the Plugin Registry</h2>\n<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>\n<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>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Fifteen external contributors land in v0.18.0.</p>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0180-user-privileges-connection-diagnostics-grid-selection/opengraph-image.png",
      "date_published": "2026-08-03T18:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "mysql",
        "postgres",
        "sqlite",
        "ui",
        "ux",
        "data-grid",
        "plugin",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/rust-stack-inside-a-15mb-database-client",
      "url": "https://tabularis.dev/blog/rust-stack-inside-a-15mb-database-client",
      "title": "The Rust stack inside a ~15 MB installer",
      "summary": "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's compact installers — the other half of last week's Tauri story.",
      "content_html": "<h1>The Rust stack inside a ~15 MB installer</h1>\n<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>\n<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>\n<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>\n<p><strong>Every abstraction boundary is a decision, and every decision eventually sends an invoice.</strong></p>\n<p>Here are the invoices I found worth reading twice.</p>\n<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>\n<h2>Two database stacks, on purpose</h2>\n<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>\n<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>\n<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>\n<pre><code class=\"language-rust\">/// Decode an IEEE 754 half-precision (`binary16`) value into `f32`.\nfn f16_bits_to_f32(bits: u16) -&gt; f32 {\n    let sign = if (bits &gt;&gt; 15) &amp; 1 == 1 { -1.0f32 } else { 1.0f32 };\n    let exp = (bits &gt;&gt; 10) &amp; 0x1f;\n    let mant = bits &amp; 0x3ff;\n    match exp {\n        0 =&gt; sign * (mant as f32) * 2f32.powi(-24), // zero / subnormal\n        0x1f if mant == 0 =&gt; sign * f32::INFINITY,\n        0x1f =&gt; f32::NAN,\n        _ =&gt; sign * (1.0 + (mant as f32) / 1024.0) * 2f32.powi(exp as i32 - 15),\n    }\n}\n</code></pre>\n<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>\n<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>\n<h2>rustls, and the OpenSSL that stayed</h2>\n<p>The TLS section of <code>Cargo.toml</code> has a fifteen-line comment, which is usually the sign of a scar.</p>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<h2>The crypto shelf</h2>\n<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>\n<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>\n<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>\n<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>\n<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>\n<h2>russh and ssh. Both.</h2>\n<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>\n<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>\n<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>\n<h2>The 2⁵³ toll booth</h2>\n<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>\n<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>\n<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>\n<h2>The dependency we wrote ourselves: 31 lines of JSON-RPC</h2>\n<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>\n<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>\n<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>\n<h2>The supply-chain boundary</h2>\n<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>\n<h2>Would I choose them again?</h2>\n<p>Almost all of them, yes. Not because cost is virtuous, but because the expensive choices bought capabilities users actually need.</p>\n<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>\n<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>\n<p>The vendored OpenSSL, meanwhile, is still here. Nobody has had the heart to tell it.</p>\n",
      "image": "https://tabularis.dev/blog/rust-stack-inside-a-15mb-database-client/opengraph-image.png",
      "date_published": "2026-07-30T08:52:00",
      "tags": [
        "rust",
        "sqlx",
        "rustls",
        "engineering",
        "deep-dive"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0170-visual-explain-diagnostics-row-editor-sidebar-sql-formatting",
      "url": "https://tabularis.dev/blog/v0170-visual-explain-diagnostics-row-editor-sidebar-sql-formatting",
      "title": "v0.17.0: Visual EXPLAIN That Points at the Problem, a Row Editor That Follows You, and SQL That Formats Itself",
      "summary": "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.",
      "content_html": "<h1>v0.17.0: Visual EXPLAIN That Points at the Problem, a Row Editor That Follows You, and SQL That Formats Itself</h1>\n<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>\n<hr>\n<h2>Visual EXPLAIN: Exclusive Metrics, Findings, and Two New Views</h2>\n<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>\n<ul>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-explain-findings.mp4\" poster=\"/videos/posts/tabularis-explain-findings.jpg\" autoplay loop muted playsinline ></video></p>\n<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>\n<hr>\n<h2>The Row Editor Becomes a Sidebar That Follows You</h2>\n<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>\n<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 ></video></p>\n<hr>\n<h2>A Polished Rail, and Split View Grows to Four</h2>\n<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>\n<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 ></video></p>\n<hr>\n<h2>SQL Formatting, Your Way</h2>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-sql-format.mp4\" poster=\"/videos/posts/tabularis-sql-format.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>The Editor Knows Where Your Cursor Is</h2>\n<p>Two changes teach the editor to actually parse what&#39;s around the cursor instead of pattern-matching the whole buffer.</p>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-clause-autocomplete.mp4\" poster=\"/videos/posts/tabularis-clause-autocomplete.jpg\" autoplay loop muted playsinline ></video></p>\n<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>\n<hr>\n<h2>An Opt-In Nightly Channel in the Updater</h2>\n<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>\n<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>\n<p><img src=\"https://tabularis.dev/img/tabularis-release-channel.png\" alt=\"The release channel selector in Settings → Info → Updates, switched to Nightly\"></p>\n<hr>\n<h2>PostgreSQL Learns pgvector</h2>\n<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>\n<hr>\n<h2>The Sidebar Notices When a Database Disappears</h2>\n<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>\n<ul>\n<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>\n<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>\n<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>\n</ul>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-drop-database.mp4\" poster=\"/videos/posts/tabularis-drop-database.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Tabularis Fala Português</h2>\n<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>\n<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>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Nine external contributors land in v0.17.0.</p>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0170-visual-explain-diagnostics-row-editor-sidebar-sql-formatting/opengraph-image.png",
      "date_published": "2026-07-27T15:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "postgres",
        "ui",
        "ux",
        "data-grid",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/extracting-visual-explain",
      "url": "https://tabularis.dev/blog/extracting-visual-explain",
      "title": "Visual EXPLAIN beyond the app: one engine, multiple hosts",
      "summary": "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.",
      "content_html": "<h1>Visual EXPLAIN beyond the app: one engine, multiple hosts</h1>\n<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>\n<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>\n<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>\n<p><strong>A plan visualiser needs a plan, not a database.</strong></p>\n<p>The rest of the work was that sentence applied to one file after another.</p>\n<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>\n<h2>How the feature was tied to the app</h2>\n<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>\n<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>\n<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>\n<p>None of this stopped the feature from working inside one application. The second host turned it into an architectural problem.</p>\n<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>\n<h2>The extraction test</h2>\n<p>The rule I used for every file is now the first line of the package&#39;s description:</p>\n<blockquote>\n<p>Takes raw EXPLAIN output; never runs a query.</p>\n</blockquote>\n<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>\n<p>The decisions were not difficult once the rule existed. The problem was that these host features were threaded through otherwise portable code.</p>\n<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>\n<h2>Where should the parsers live?</h2>\n<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>\n<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>\n<p>The same parser in two hosts was attractive. One plan model was more valuable.</p>\n<p>The parsers did not need to run where Rust runs. They needed to run where the views run.</p>\n<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>\n<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>\n<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>\n<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>\n<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>\n<h2>What the extraction exposed</h2>\n<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>\n<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>\n<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>\n<p>Once these parts were in place, version 0.1.0 went to npm.</p>\n<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>\n<h2>Three entry points, and the bill</h2>\n<p>The package has three entry points. The split is the architecture:</p>\n<ul>\n<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>\n<li><code>@tabularis/explain/react</code>: the graph, table, diagram, stats, node details and bars.</li>\n<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>\n</ul>\n<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>\n<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>\n<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>\n<h2>One repository or two?</h2>\n<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>\n<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>\n<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>\n<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>\n<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>\n<h2>The second host</h2>\n<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>\n<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>\n<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>\n<p>This was technically accurate and practically useless.</p>\n<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>\n<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>\n<h2>Would I draw the boundary again?</h2>\n<p>Yes, and earlier.</p>\n<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>\n<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>\n<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>\n<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>\n",
      "image": "https://tabularis.dev/blog/extracting-visual-explain/opengraph-image.png",
      "date_published": "2026-07-27T11:48:00",
      "tags": [
        "explain",
        "architecture",
        "typescript",
        "rust",
        "engineering",
        "deep-dive"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/why-tabularis-runs-on-tauri",
      "url": "https://tabularis.dev/blog/why-tabularis-runs-on-tauri",
      "title": "Why I chose Tauri, and what it cost me",
      "summary": "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.",
      "content_html": "<h1>Why I chose Tauri, and what it cost me</h1>\n<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>\n<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>\n<p>Yes, the Windows installer is 12 MB. This is nice. It is also the least interesting part.</p>\n<p>The sentence that decided the architecture was this:</p>\n<p><strong>A database client is a systems program with a UI problem, not a web app with a database problem.</strong></p>\n<p>That sentence explains why I chose Tauri. It does not explain the bill. This post is about both.</p>\n<h2>The program behind the pixels</h2>\n<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>\n<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>\n<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>\n<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>\n<p>There is also a security argument, but it is easy to exaggerate it.</p>\n<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>\n<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>\n<p>And then there is size.</p>\n<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>\n<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>\n<p>Still, a 12 MB Windows installer for a database IDE makes me smile.</p>\n<h2>The harder comparison is native</h2>\n<p>Electron is the easy comparison. Native applications are the ones that made me hesitate.</p>\n<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>\n<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>\n<p>So why not native? Because native to what?</p>\n<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>\n<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>\n<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>\n<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>\n<p>This is the trade I eventually wrote down:</p>\n<p><strong>Electron charges you for the runtime. Native charges you for the platforms. Tauri charges you for the seams.</strong></p>\n<p>Now for the seams.</p>\n<h2>You do not ship a browser. You inherit three.</h2>\n<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>\n<p>So I do not ship a rendering engine, but I answer for three of them.</p>\n<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>\n<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>\n<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>\n<h2>The Linux webview lottery</h2>\n<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>\n<ul>\n<li><a href=\"https://github.com/TabularisDB/tabularis/issues/9\">#9</a>: a Wayland protocol error on Arch, very early in the project.</li>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<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>\n<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>\n<h2>The 94 MB exception</h2>\n<p>The first chart contains the whole story if you look at the last pair of bars.</p>\n<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>\n<p>Same release, five representative x64 package formats. Four are between 15.8 and 18.3 MB. The AppImage is 93.7 MB.</p>\n<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>\n<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>\n<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>\n<h2>You own the glibc floor</h2>\n<p>I learned another Linux lesson in the traditional way: by shipping the bug.</p>\n<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>\n<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>\n<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>\n<p>When you compile a native Rust backend, you own its system compatibility. Rust gives you systems programming, including the systems.</p>\n<h2>Every result page pays the IPC tax</h2>\n<p>The most important seam is not packaging. It is in the architecture.</p>\n<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>\n<p>For a settings screen this cost is noise. For query results it shapes the product.</p>\n<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>\n<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>\n<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>\n<h2>Would I choose Tauri again?</h2>\n<p>Yes.</p>\n<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>\n<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>\n<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>\n<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>\n<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>\n<p>Just budget for the seams.</p>\n<p>The AppImage users may now open an issue.</p>\n",
      "image": "https://tabularis.dev/blog/why-tabularis-runs-on-tauri/opengraph-image.png",
      "date_published": "2026-07-22T09:40:00",
      "tags": [
        "tauri",
        "rust",
        "electron",
        "engineering",
        "deep-dive"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0160-hosted-plugin-registry-k8s-overrides-encrypted-backups",
      "url": "https://tabularis.dev/blog/v0160-hosted-plugin-registry-k8s-overrides-encrypted-backups",
      "title": "v0.16.0: A Hosted Plugin Registry, Kubernetes Your Way, and Backups That Encrypt Themselves",
      "summary": "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.",
      "content_html": "<h1>v0.16.0: A Hosted Plugin Registry, Kubernetes Your Way, and Backups That Encrypt Themselves</h1>\n<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>\n<hr>\n<h2>A Hosted Plugin Registry and a New Connection Catalogue</h2>\n<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>\n<ul>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-connection-catalogue.mp4\" poster=\"/videos/posts/tabularis-connection-catalogue.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Two New Community Drivers: Elasticsearch and Cloudflare D1</h2>\n<p>Two community-built drivers joined the registry this cycle.</p>\n<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>\n<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>\n<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>\n<hr>\n<h2>Kubernetes Tunnels, With Your kubectl and Your kubeconfig</h2>\n<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>\n<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>\n<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>\n<hr>\n<h2>Backups That Take Themselves</h2>\n<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>\n<ul>\n<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>\n<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>\n<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>\n</ul>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-backup-settings.mp4\" poster=\"/videos/posts/tabularis-backup-settings.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Run the Statement Under Your Cursor</h2>\n<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>\n<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>\n<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>\n<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 ></video></p>\n<hr>\n<h2>AWS RDS IAM Authentication for MySQL</h2>\n<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>\n<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>\n<hr>\n<h2>Stored Procedures Show All Their Result Sets</h2>\n<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>\n<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>\n<hr>\n<h2>Tabularis Speaks Korean</h2>\n<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>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Eleven external contributors land in v0.16.0.</p>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0160-hosted-plugin-registry-k8s-overrides-encrypted-backups/opengraph-image.png",
      "date_published": "2026-07-21T11:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "plugin",
        "community",
        "connections",
        "mysql",
        "security",
        "ui",
        "ux"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/optimizing-virtualized-react-grid",
      "url": "https://tabularis.dev/blog/optimizing-virtualized-react-grid",
      "title": "Optimizing a Virtualized React Grid: 3,420 Formatter Calls Down to 90",
      "summary": "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.",
      "content_html": "<h1>Optimizing a Virtualized React Grid: 3,420 Formatter Calls Down to 90</h1>\n<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>\n<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>\n<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>\n<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>\n<h2>Virtualized, and still janky</h2>\n<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>\n<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>\n<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>\n<h2>Where the work was going</h2>\n<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>\n<pre><code class=\"language-tsx\">{rowVirtualizer.getVirtualItems().map((virtualRow) =&gt; {\n  // ~560 lines of row and cell JSX, inline in DataGrid\n})}\n</code></pre>\n<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>\n<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>\n<ol>\n<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>\n<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>\n<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>\n</ol>\n<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>\n<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>\n<h2>The fix is one memo boundary</h2>\n<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>\n<pre><code class=\"language-tsx\">export const MemoRow = React.memo(function MemoRow(rowCtx: MemoRowProps) {\n  // renders one &lt;tr&gt;\n});\n</code></pre>\n<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>\n<p>So the props are split by volatility.</p>\n<p>Everything that is stable <em>for the whole grid</em> goes into one object, memoized once in <code>DataGrid</code>:</p>\n<pre><code class=\"language-tsx\">/**\n * Stable, per-grid dependencies shared by every row. Bundled into a single\n * object that is memoized in DataGrid so React.memo&#39;s default shallow compare\n * on MemoRow only sees a new `ctx` reference when one of these actually changes.\n */\nconst rowCtx: RowCtx = useMemo(\n  () =&gt; ({ columns, pkColumns, pendingChanges, columnTypeMap, /* … */ }),\n  [columns, pkColumns, pendingChanges, columnTypeMap, /* … */],\n);\n</code></pre>\n<p>Everything that varies <em>per row</em> is computed in the parent&#39;s <code>.map</code> and passed as primitives:</p>\n<pre><code class=\"language-tsx\">&lt;MemoRow\n  ctx={rowCtx}\n  rowIndex={rowIndex}\n  isSelected={isSelected}\n  isPendingDelete={isPendingDelete}\n  editingColIndex={isRowEditing ? editingCell!.colIndex : null}\n  focusedColIndex={isRowFocused ? focusedCell!.colIndex : null}\n/&gt;\n</code></pre>\n<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>\n<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>\n<pre><code class=\"language-tsx\">// Mirror of editingCell so the commit/keydown callbacks can read the latest\n// value without listing editingCell in their deps; this keeps their identity\n// stable so the memoized rows don&#39;t re-render on every keystroke/scroll.\nconst editingCellRef = useRef(editingCell);\nuseEffect(() =&gt; {\n  editingCellRef.current = editingCell;\n}, [editingCell]);\n</code></pre>\n<p>The callbacks read <code>editingCellRef.current</code> and keep a stable identity for the life of the grid.</p>\n<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>\n<h2>Proving it, not vibing it</h2>\n<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>\n<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>\n<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>\n<p>The harness records two metrics:</p>\n<ul>\n<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>\n<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>\n</ul>\n<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>\n<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>\n<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>\n<h2>The numbers</h2>\n<p>Scrolling, 1,000-row table:</p>\n<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>\n<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>\n<p>The call counts explain the ratio:</p>\n<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>\n<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>\n<p>Typing is the case I find more satisfying, because it&#39;s where the <code>editingCellRef</code> trick earns its keep:</p>\n<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>\n<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>\n<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>\n<h2>A bad mock invalidated every row</h2>\n<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>\n<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>\n<pre><code class=\"language-ts\">useTranslation: () =&gt; ({\n  t: (key: string) =&gt; key,   // a NEW function on every call\n}),\n</code></pre>\n<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>\n<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>\n<h2>What&#39;s deliberately not done</h2>\n<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>\n<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>\n<h2>If you have a slow virtualized grid</h2>\n<p>The checklist I wish I&#39;d had before shipping the janky version, in the order I&#39;d apply it:</p>\n<ol>\n<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>\n<li><strong>Put the memo boundary at the row</strong>, not the cell (too many boundaries) and not the body (too few).</li>\n<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>\n<li><strong>Stabilize handlers with refs</strong>, not by widening dependency arrays.</li>\n<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>\n<li><strong>Re-verify after refactors.</strong> The memo is invisible when it stops working. A render-count assertion is cheap insurance.</li>\n</ol>\n<h2>Source and reproduction</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n</ul>\n",
      "image": "https://tabularis.dev/blog/optimizing-virtualized-react-grid/opengraph-image.png",
      "date_published": "2026-07-14T17:43:00",
      "tags": [
        "react",
        "performance",
        "data-grid",
        "frontend",
        "deep-dive"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0150-import-connections-nested-groups-encrypted-exports",
      "url": "https://tabularis.dev/blog/v0150-import-connections-nested-groups-encrypted-exports",
      "title": "v0.15.0: Bring Your Connections With You — Imports, Nested Groups, and Encrypted Exports",
      "summary": "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.",
      "content_html": "<h1>v0.15.0: Bring Your Connections With You — Imports, Nested Groups, and Encrypted Exports</h1>\n<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>\n<hr>\n<h2>Import Connections From the Client You&#39;re Leaving</h2>\n<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>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-import-connections.mp4\" poster=\"/videos/posts/tabularis-import-connections.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Folders Inside Folders</h2>\n<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>\n<p>The workflow got the attention the data model change deserved:</p>\n<ul>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-nested-groups.mp4\" poster=\"/videos/posts/tabularis-nested-groups.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Select Many, Act Once</h2>\n<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>\n<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>\n<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>\n<hr>\n<h2>Exports That Don&#39;t Leak Passwords</h2>\n<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>\n<ul>\n<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>\n<li><strong>Plain text without passwords</strong> — secrets stripped entirely; fine for sharing a topology.</li>\n<li><strong>Plain text with all passwords</strong> — the old behavior, with the warning now attached to the option that deserves it.</li>\n</ul>\n<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>\n<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>\n<hr>\n<h2>ENUM Columns Become Dropdowns — on Both MySQL and PostgreSQL</h2>\n<p>Editing an ENUM cell used to mean remembering the allowed values and typing one exactly. Two PRs fix that end to end.</p>\n<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>\n<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>\n<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>\n<hr>\n<h2>MCP Safety: EXPLAIN ANALYZE Is Not a Read</h2>\n<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>\n<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>\n<hr>\n<h2>Windows Stops Flashing Terminals</h2>\n<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>\n<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>\n<hr>\n<h2>Grid and Editor Refinements</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Tabularis Speaks Tagalog</h2>\n<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>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Fourteen external contributors land in v0.15.0 — the widest tag yet.</p>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0150-import-connections-nested-groups-encrypted-exports/opengraph-image.png",
      "date_published": "2026-07-14T11:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "connections",
        "security",
        "postgres",
        "mysql",
        "ui",
        "ux",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/signpath-code-signing",
      "url": "https://tabularis.dev/blog/signpath-code-signing",
      "title": "Tabularis Joins the SignPath.io Open Source Program",
      "summary": "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'll wire signing into the release pipeline — and then the days of telling users to click 'More info → Run anyway' are over.",
      "content_html": "<h1>Tabularis Joins the SignPath.io Open Source Program</h1>\n<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>\n\n<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>\n<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>\n<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>\n<h2>What SignPath actually does (and why it&#39;s clever)</h2>\n<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>\n<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>\n<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>\n<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>\n<h2>The honest part: it&#39;s not wired up yet</h2>\n<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>\n<ul>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<h2>What will change for you</h2>\n<p>Once the integration lands, if you&#39;re on Windows:</p>\n<ul>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<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>\n<h2>The usual thank-you, because it keeps being deserved</h2>\n<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>\n<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>\n<hr>\n<p><em>The Tabularis Team</em></p>\n",
      "image": "https://tabularis.dev/blog/signpath-code-signing/opengraph-image.png",
      "date_published": "2026-07-10T09:00:00",
      "tags": [
        "community",
        "sponsors",
        "partnership",
        "open-source",
        "windows"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0140-stored-routines-connection-windows-destructive-query-guard",
      "url": "https://tabularis.dev/blog/v0140-stored-routines-connection-windows-destructive-query-guard",
      "title": "v0.14.0: Stored Routines, Windows of Their Own, and a Guard Against the Unrecoverable",
      "summary": "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.",
      "content_html": "<h1>v0.14.0: Stored Routines, Windows of Their Own, and a Guard Against the Unrecoverable</h1>\n<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>\n<hr>\n<h2>Manage Stored Routines, Not Just Tables</h2>\n<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>\n<ul>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-run-routine.mp4\" poster=\"/videos/posts/tabularis-run-routine.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>A Connection in Its Own Window</h2>\n<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>\n<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>\n<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 ></video></p>\n<hr>\n<h2>Confirm Before You Wipe a Table</h2>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-confirmation.mp4\" poster=\"/videos/posts/tabularis-confirmation.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>PostgreSQL Materialized Views</h2>\n<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>\n<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>\n<hr>\n<h2>Find Things by Typo</h2>\n<p>Three changes, all from <a href=\"https://github.com/Davydhh\">@Davydhh</a>, make finding an object forgiving of how you actually type.</p>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-fuzzy-search.mp4\" poster=\"/videos/posts/tabularis-fuzzy-search.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Know Exactly How Many Rows</h2>\n<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>\n<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>\n<hr>\n<h2>MySQL Through Warpgate</h2>\n<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>\n<hr>\n<h2>Correctness, Across the Board</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Most of v0.14.0 is community work again.</p>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0140-stored-routines-connection-windows-destructive-query-guard/opengraph-image.png",
      "date_published": "2026-07-07T15:10:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "postgres",
        "mysql",
        "ui",
        "ux",
        "plugin",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/jetbrains-open-source-program",
      "url": "https://tabularis.dev/blog/jetbrains-open-source-program",
      "title": "Tabularis Joins the JetBrains Open Source Program",
      "summary": "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.",
      "content_html": "<h1>Tabularis Joins the JetBrains Open Source Program</h1>\n<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>\n\n<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>\n<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>\n<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>\n<h2>When the tool sponsors the tool</h2>\n<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>\n<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>\n<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>\n<h2>The part we say every time because it&#39;s still true</h2>\n<p>To the JetBrains Open Source team specifically, for turning an email exchange into licenses with zero friction, thank you.</p>\n<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>\n<hr>\n<p><em>The Tabularis Team</em></p>\n",
      "image": "https://tabularis.dev/blog/jetbrains-open-source-program/opengraph-image.png",
      "date_published": "2026-07-06T09:00:00",
      "tags": [
        "community",
        "sponsors",
        "partnership",
        "open-source"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/nobody-reads-the-sql-anymore",
      "url": "https://tabularis.dev/blog/nobody-reads-the-sql-anymore",
      "title": "Nobody Reads the SQL Anymore",
      "summary": "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.",
      "content_html": "<h1>Nobody Reads the SQL Anymore</h1>\n<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>\n<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>\n<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>\n<h2>SQL fails differently</h2>\n<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>\n<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>\n<h2>The inversion</h2>\n<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>\n<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>\n<h2>Reading has to get cheaper</h2>\n<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>\n<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>\n<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 ></video></p>\n<p><em>(There is a longer walkthrough in the <a href=\"https://tabularis.dev/wiki/visual-explain\">visual EXPLAIN wiki page</a>.)</em></p>\n<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>\n<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>\n<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>\n",
      "image": "https://tabularis.dev/blog/nobody-reads-the-sql-anymore/opengraph-image.png",
      "date_published": "2026-07-02T10:33:00",
      "tags": [
        "opinion",
        "ai",
        "sql",
        "performance",
        "culture"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0134-ssh-security-keys-detachable-results-smarter-editor",
      "url": "https://tabularis.dev/blog/v0134-ssh-security-keys-detachable-results-smarter-editor",
      "title": "v0.13.4: Unlock Everything — Hardware Keys, Free-Floating Results, and a Sharper Editor",
      "summary": "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.",
      "content_html": "<h1>v0.13.4: Unlock Everything — Hardware Keys, Free-Floating Results, and a Sharper Editor</h1>\n<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>\n<hr>\n<h2>SSH That Knows How to Ask</h2>\n<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>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-ssh-askpass.mp4\" poster=\"/videos/posts/tabularis-ssh-askpass.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Detach Your Results Into Their Own Window</h2>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-detach-results.mp4\" poster=\"/videos/posts/tabularis-detach-results.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>A Smarter SQL Editor</h2>\n<p>Three changes converge to make the editor read your intent better.</p>\n<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>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-editor-feedback.mp4\" poster=\"/videos/posts/tabularis-editor-feedback.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Collapse Notebook Sections Individually</h2>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-notebooks-collapse.mp4\" poster=\"/videos/posts/tabularis-notebooks-collapse.jpg\" autoplay loop muted playsinline ></video></p>\n<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>\n<hr>\n<h2>A Startup Script Per Connection</h2>\n<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>\n<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>\n<hr>\n<h2>Three New Community Drivers</h2>\n<p>The plugin ecosystem keeps growing. Three drivers join the registry this cycle, all community-built and installable from <strong>Settings → Plugins</strong>:</p>\n<ul>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<hr>\n<h2>Oracle and Dameng SQL Block Splitting</h2>\n<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>\n<hr>\n<h2>Correctness, Across the Board</h2>\n<p>A run of fixes that quietly make queries do the right thing:</p>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Nine external contributors land in v0.13.4 — once again, most of this release is community work.</p>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0134-ssh-security-keys-detachable-results-smarter-editor/opengraph-image.png",
      "date_published": "2026-06-30T11:00:00",
      "tags": [
        "release",
        "feature",
        "bugfix",
        "ssh",
        "editor",
        "notebook",
        "postgres",
        "mysql",
        "plugin",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/managing-tabularis-secrets-with-1password",
      "url": "https://tabularis.dev/blog/managing-tabularis-secrets-with-1password",
      "title": "Where Tabularis Keeps Its Secrets: Thank You, 1Password",
      "summary": "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's good enough to deserve a genuine thank-you. Here's why 1Password is a great secret manager for developers, and how we plan to move Tabularis' CI secrets into one vault and pull them into GitHub Actions with op:// references.",
      "content_html": "<h1>Where Tabularis Keeps Its Secrets: Thank You, 1Password</h1>\n<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>\n<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>\n<h2>The mess we&#39;d been living with</h2>\n<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>\n<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>\n<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>\n<h2>1Password, and being honest about what this is</h2>\n<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>\n<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>\n<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>\n<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>\n<p>But the reason it changed how we work is the developer side.</p>\n<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>\n<h2>1Password as a secret manager for GitHub</h2>\n<p>This is the part every developer should know about, however you came to 1Password.</p>\n<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>\n<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>\n<pre><code class=\"language-yaml\">- name: Load secrets from 1Password\n  uses: 1password/load-secrets-action@v2\n  with:\n    export-env: true\n  env:\n    OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}\n    TAURI_SIGNING_PRIVATE_KEY: op://CI/Tauri/key\n    APPLE_CERTIFICATE: op://CI/Apple/cert\n    NPM_TOKEN: op://CI/npm/token\n    VERCEL_DEPLOY_HOOK: op://CI/Vercel/hook\n</code></pre>\n<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>\n<p>What that buys you, concretely:</p>\n<ul>\n<li><strong>One source of truth.</strong> The vault is canonical. GitHub holds an address book, not a key ring.</li>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<h2>The part that already works today: SSH</h2>\n<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>\n<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>\n<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>\n<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>\n<h2>What we plan to do with it</h2>\n<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>\n<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>\n<h2>Thanks</h2>\n<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>\n<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>\n<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>\n",
      "image": "https://tabularis.dev/blog/managing-tabularis-secrets-with-1password/opengraph-image.png",
      "date_published": "2026-06-29T10:00:00",
      "tags": [
        "security",
        "devops",
        "ci",
        "github-actions",
        "secrets",
        "open-source"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/v0133-result-colors-gruvbox-themed-tabs-session-restore",
      "url": "https://tabularis.dev/blog/v0133-result-colors-gruvbox-themed-tabs-session-restore",
      "title": "v0.13.3: Color Your Results, Theme Your Tabs, and Pick Up Where You Left Off",
      "summary": "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'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.",
      "content_html": "<h1>v0.13.3: Color Your Results, Theme Your Tabs, and Pick Up Where You Left Off</h1>\n<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>\n<hr>\n<h2>Results, Colored by Type</h2>\n<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>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-result-colors.mp4\" poster=\"/videos/posts/tabularis-result-colors.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>Tabs That Wear the Connection&#39;s Color</h2>\n<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>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-connection-tabs.mp4\" poster=\"/videos/posts/tabularis-connection-tabs.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>A New Theme: Gruvbox</h2>\n<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>\n<p><video src=\"https://tabularis.dev/videos/posts/tabularis-gruvbox.mp4\" poster=\"/videos/posts/tabularis-gruvbox.jpg\" autoplay loop muted playsinline ></video></p>\n<hr>\n<h2>A Workspace That Remembers</h2>\n<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>\n<hr>\n<h2>Copy CSV With (or Without) Headers</h2>\n<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>\n<hr>\n<h2>Louder MCP Approvals</h2>\n<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>\n<hr>\n<h2>Driver-Aware Kubernetes Connections</h2>\n<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>\n<hr>\n<h2>A Community Informix Driver</h2>\n<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>\n<hr>\n<h2>Smaller Things</h2>\n<ul>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n</ul>\n<hr>\n<h2>Thanks</h2>\n<p>Nine external contributors land in v0.13.3 — this release is overwhelmingly community work.</p>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<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>\n<hr>\n<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>\n",
      "image": "https://tabularis.dev/blog/v0133-result-colors-gruvbox-themed-tabs-session-restore/opengraph-image.png",
      "date_published": "2026-06-24T10:00:00",
      "tags": [
        "release",
        "feature",
        "ui",
        "ux",
        "data-grid",
        "editor",
        "theme",
        "kubernetes",
        "mcp",
        "plugin",
        "community"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    },
    {
      "id": "https://tabularis.dev/blog/database-drivers-as-external-processes",
      "url": "https://tabularis.dev/blog/database-drivers-as-external-processes",
      "title": "Database drivers as external processes",
      "summary": "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.",
      "content_html": "<h1>Database drivers as external processes</h1>\n<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>\n<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>\n<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>\n<p>The interesting case is the fourth database.</p>\n<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>\n<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>\n<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>\n<h2>The two options I didn&#39;t take</h2>\n<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>\n<p>There are two problems with it here.</p>\n<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>\n<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>\n<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>\n<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>\n<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>\n<h2>The boring option</h2>\n<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>\n<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>\n<ul>\n<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>\n<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>\n<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>\n</ul>\n<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>\n<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>\n<h2>The wire</h2>\n<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>\n<pre><code class=\"language-rust\">#[derive(Serialize, Deserialize, Debug)]\npub struct JsonRpcRequest {\n    pub jsonrpc: String,\n    pub method: String,\n    pub params: Value,\n    pub id: u64,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\n#[serde(untagged)]\npub enum JsonRpcResponse {\n    Success { jsonrpc: String, result: Value, id: u64 },\n    Error   { jsonrpc: String, error: JsonRpcError, id: u64 },\n}\n</code></pre>\n<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>\n<pre><code class=\"language-python\">for line in sys.stdin:\n    request = json.loads(line)\n</code></pre>\n<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>\n<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>\n<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>\n<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>\n<h2>The part that is actually hard</h2>\n<p>The phrase &quot;JSON-RPC over stdin/stdout&quot; hides the annoying part: there are two byte streams and many callers.</p>\n<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>\n<p>So two things must be true:</p>\n<ul>\n<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>\n<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>\n</ul>\n<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>\n<p>The caller side looks like this:</p>\n<pre><code class=\"language-rust\">enum PluginCommand {\n    /// Dispatch a request; route the response back via the sender.\n    Call(JsonRpcRequest, oneshot::Sender&lt;Result&lt;Value, String&gt;&gt;),\n    /// Drop the pending entry for `id` because the caller stopped waiting.\n    Cancel(u64),\n}\n</code></pre>\n<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>\n<pre><code class=\"language-rust\">let mut pending: HashMap&lt;u64, oneshot::Sender&lt;Result&lt;Value, String&gt;&gt;&gt; = HashMap::new();\n\nloop {\n    tokio::select! {\n        _ = &amp;mut shutdown_rx =&gt; { let _ = child.kill().await; break; }\n\n        msg = rx.recv() =&gt; match msg {\n            Some(PluginCommand::Call(req, resp_tx)) =&gt; {\n                pending.insert(req.id, resp_tx);          // remember who&#39;s waiting\n                let mut line = serde_json::to_string(&amp;req).unwrap();\n                line.push(&#39;\\n&#39;);\n                stdin.write_all(line.as_bytes()).await?;  // serialized: only this task writes\n            }\n            Some(PluginCommand::Cancel(id)) =&gt; { pending.remove(&amp;id); }\n            None =&gt; { let _ = child.kill().await; break; } // all callers gone\n        },\n\n        line = reader.read_line(&amp;mut buf) =&gt; match line {\n            Ok(0) =&gt; break,                                // EOF: the plugin died\n            Ok(_) =&gt; {\n                match serde_json::from_str::&lt;JsonRpcResponse&gt;(&amp;buf) {\n                    Ok(JsonRpcResponse::Success { result, id, .. }) =&gt; {\n                        if let Some(tx) = pending.remove(&amp;id) { let _ = tx.send(Ok(result)); }\n                    }\n                    Ok(JsonRpcResponse::Error { error, id, .. }) =&gt; {\n                        if let Some(tx) = pending.remove(&amp;id) { let _ = tx.send(Err(error.message)); }\n                    }\n                    Err(e) =&gt; log::error!(&quot;bad response from plugin: {e}&quot;),\n                }\n                buf.clear();\n            }\n            Err(e) =&gt; { log::error!(&quot;read error: {e}&quot;); break; }\n        },\n    }\n}\n</code></pre>\n<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>\n<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>\n<p>A call becomes:</p>\n<pre><code class=\"language-rust\">let (tx, rx) = oneshot::channel();\nself.sender.send(PluginCommand::Call(req, tx)).await?;\nmatch tokio::time::timeout(PLUGIN_CALL_TIMEOUT, rx).await {\n    Ok(Ok(result)) =&gt; result,\n    Ok(Err(_))     =&gt; Err(&quot;plugin did not respond&quot;.into()),\n    Err(_)         =&gt; { /* timed out */ }\n}\n</code></pre>\n<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>\n<h2>Two bugs that showed up</h2>\n<p>The actor loop was the right abstraction, but two bugs fell directly out of its shape during the first rounds of testing.</p>\n<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>\n<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>\n<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>\n<pre><code class=\"language-rust\">Err(_) =&gt; {\n    let _ = self.sender.send(PluginCommand::Cancel(id)).await;\n    Err(format!(&quot;plugin call &#39;{method}&#39; timed out after {}s&quot;, timeout.as_secs()))\n}\n</code></pre>\n<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>\n<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>\n<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>\n<p>The fix is one line at spawn time:</p>\n<pre><code class=\"language-rust\">.kill_on_drop(true)\n</code></pre>\n<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>\n<h2>The registry bug</h2>\n<p>This is the bug that changed how I think about the registry.</p>\n<p>Registering a driver, built-in or plugin, eventually meant inserting it into a map keyed by driver id:</p>\n<pre><code class=\"language-rust\">registry.insert(manifest.id, driver);\n</code></pre>\n<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>\n<p>Nothing stopped a plugin from declaring this:</p>\n<pre><code class=\"language-json\">{ &quot;id&quot;: &quot;mysql&quot; }\n</code></pre>\n<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>\n<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>\n<pre><code class=\"language-rust\">const BUILTIN_DRIVER_IDS: [&amp;str; 3] = [&quot;mysql&quot;, &quot;postgres&quot;, &quot;sqlite&quot;];\nif BUILTIN_DRIVER_IDS.contains(&amp;config.id.as_str()) {\n    return Err(format!(\n        &quot;Plugin id &#39;{}&#39; collides with a built-in driver and was refused&quot;,\n        config.id\n    ));\n}\n</code></pre>\n<p>A plugin that claims a built-in id is refused at load time.</p>\n<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>\n<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>\n<h2>About the word &quot;sandboxed&quot;</h2>\n<p>It is tempting to call this sandboxing. I have used that word myself, but it needs qualification.</p>\n<p>Running a driver as a separate process gives Tabularis two things:</p>\n<ul>\n<li><strong>Fault isolation.</strong> A crash becomes EOF on a pipe, not a corrupted heap in the GUI.</li>\n<li><strong>Dependency isolation.</strong> A plugin can bring its own runtime and packages without linking them into the application.</li>\n</ul>\n<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>\n<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>\n<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>\n<h2>Installing a plugin</h2>\n<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>\n<pre><code class=\"language-rust\">fs::rename(&amp;tmp_dir, &amp;final_dir)   // .tmp-&lt;id&gt;  -&gt;  &lt;id&gt;, atomically\n    .map_err(|e| format!(&quot;Failed to finalize plugin installation: {e}&quot;))?;\n</code></pre>\n<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>\n<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>\n<h2>What held up</h2>\n<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>\n<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>\n<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>\n<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>\n",
      "image": "https://tabularis.dev/blog/database-drivers-as-external-processes/opengraph-image.png",
      "date_published": "2026-06-24T09:00:00",
      "tags": [
        "plugins",
        "rust",
        "architecture",
        "tokio",
        "json-rpc",
        "ipc"
      ],
      "authors": [
        {
          "name": "Andrea Debernardi"
        }
      ]
    }
  ]
}
