<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[LibreDB]]></title><description><![CDATA[LibreDB]]></description><link>https://libredb.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 11 Sep 2026 06:07:24 GMT</lastBuildDate><atom:link href="https://libredb.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Trino breaks the metadata assumptions a database IDE is built on
]]></title><description><![CDATA[A note on transparency: I work on LibreDB Studio, an open source (MIT) database IDE. This post is about a design problem we hit while adding Trino support, not a pitch. The lesson generalizes to any c]]></description><link>https://libredb.hashnode.dev/trino-breaks-the-metadata-assumptions-a-database-ide-is-built-on</link><guid isPermaLink="true">https://libredb.hashnode.dev/trino-breaks-the-metadata-assumptions-a-database-ide-is-built-on</guid><category><![CDATA[Databases]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[trino]]></category><category><![CDATA[Developer Tools]]></category><dc:creator><![CDATA[Yusuf Gundogdu]]></dc:creator><pubDate>Sat, 05 Sep 2026 19:48:22 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>A note on transparency: I work on LibreDB Studio, an open source (MIT) database IDE. This post is about a design problem we hit while adding Trino support, not a pitch. The lesson generalizes to any client tool that talks to more than one kind of backend.</p>
</blockquote>
<h2>The assumption you don't know you're making</h2>
<p>Every database IDE I have worked on starts from a small set of beliefs about what a "database" is. You rarely write them down, because they hold almost everywhere:</p>
<ul>
<li>A table has an index list you can read.</li>
<li>A table has a primary key, or at least the catalog can tell you it has none.</li>
<li>There is a cheap way to get a row count, or a stored statistic close enough to it.</li>
<li><code>EXPLAIN</code> returns a plan you can parse into a tree of operators with row estimates and costs.</li>
</ul>
<p>Those four beliefs shape the whole UI: an Indexes tab, a key badge next to a column, a row count in the table header, an EXPLAIN visualizer. In our codebase they were encoded as a single metadata interface that every connection implemented:</p>
<pre><code class="language-ts">interface MetadataProvider {
  getIndexes(table: TableRef): Promise&lt;Index[]&gt;;
  getPrimaryKeys(table: TableRef): Promise&lt;Column[]&gt;;
  getRowCount(table: TableRef): Promise&lt;number&gt;;
  explain(sql: string): Promise&lt;PlanNode&gt;;
}
</code></pre>
<p>Postgres implements it. MySQL implements it. SQLite implements it. The panels just call these methods and render whatever comes back. Then we added Trino, and every one of the four returned something that made the UI lie.</p>
<h2>Trino is not a database</h2>
<p>Quick framing for anyone who has not run it. Trino is a distributed SQL query engine. It does not store data. It connects to catalogs (a Postgres database, a Hive warehouse over object storage, a Kafka cluster, a MongoDB deployment, and so on) and runs SQL across them. A table is addressed with three parts, <code>catalog.schema.table</code>, and a single query can read from several catalogs at once:</p>
<pre><code class="language-sql">SELECT o.id, u.email
FROM postgres.sales.orders o
JOIN hive.analytics.users u ON o.user_id = u.id
WHERE o.created_at &gt; current_date - interval '7' day;
</code></pre>
<p>That statement touches a relational database and a pile of files in object storage at the same time. Trino plans it, splits the work, pushes down what it can, and stitches the results back together. The part that matters for a client tool: Trino owns the query, not the storage. Everything the storage would normally tell you about itself now depends on which connector sits behind each catalog.</p>
<h2>Where each assumption breaks</h2>
<h3>Indexes</h3>
<p>Trino has no concept of an index. It cannot have one, because it does not own the storage layer where an index would live. There is no system table to query for "the indexes on this table," because the engine has nothing to put there. The Postgres behind <code>postgres.sales.orders</code> certainly has indexes, but Trino does not surface them, and <code>hive.analytics.users</code> is a set of files with no indexes at all. So <code>getIndexes</code> has no correct answer. The honest answer is "not applicable," which is not the same as "none."</p>
<p>An indexes panel that shows an empty list is telling the user this table has no indexes. For Trino that is wrong. There is no index list to be empty in the first place.</p>
<h3>Primary keys</h3>
<p>Same root cause. Trino does not model primary keys. Going through JDBC, <code>DatabaseMetaData.getPrimaryKeys</code> returns an empty result set for a Trino table, not because the underlying table lacks a key but because the engine has no notion of one. A generic IDE that draws a key icon, or uses the primary key to build an editable grid, has nothing to work with. Worse, if you infer editability from "has a primary key," you will disable editing for tables that are perfectly writable through their connector, and enable it for ones that are not.</p>
<h3>A cheap row count</h3>
<p>This is the subtle one. On a normal database <code>count(*)</code> is either cheap or there is a stored estimate you can read instead of scanning (<code>reltuples</code> in Postgres, the stats tables in MySQL). Tools lean on that to print "1,234,567 rows" in a table header without thinking about it.</p>
<p>On Trino, <code>SELECT count(*) FROM catalog.schema.table</code> is a distributed query that actually executes against the source. On a large Hive table that moves real data and takes real time. Running it every time someone clicks a table is not acceptable.</p>
<p>Trino does expose statistics, but through the connector, and only when that connector provides them:</p>
<pre><code class="language-sql">SHOW STATS FOR hive.analytics.users;
</code></pre>
<p>For a Hive table with computed stats you get row counts and per-column estimates. For a connector that does not collect stats you get nulls. So "row count" has three states, not one: a real value from stats, a value available only by running a full query, and genuinely unknown. A single <code>getRowCount</code> that returns a number cannot represent that difference, so it has to pick one and be wrong for the other two.</p>
<h3>EXPLAIN</h3>
<p>A single-node <code>EXPLAIN</code> gives you a tree: rows estimated per node, index scans, join methods. Trino gives you a distributed plan instead: fragments, each with a distribution type (<code>SINGLE</code>, <code>HASH</code>, <code>BROADCAST</code>), connected by exchange operators that move data between stages. Here is an abbreviated, illustrative version of what that looks like (real Trino output carries far more detail):</p>
<pre><code>Fragment 0 [SINGLE]
  Output[id, email]
    RemoteSource[1]

Fragment 1 [HASH]
  InnerJoin[...]
    RemoteSource[2]            (build)
    TableScan[postgres:orders] (probe)
</code></pre>
<p>There are no per-row index scans, because there are no indexes. The facts worth reading are the exchanges (what gets broadcast, what gets repartitioned) and the fragment boundaries. A visualizer written for the single-node shape either fails to parse this, or renders it as if it were the same kind of tree and quietly hides the parts that actually matter. They are two different formats that happen to share the word <code>EXPLAIN</code>.</p>
<h2>The fix: describe capabilities, do not assume them</h2>
<p>The mistake was baked into the interface itself. <code>MetadataProvider</code> assumed every method has a meaningful answer for every backend. It encoded "this is a database with indexes, keys, cheap counts, and a single-node plan" as a shape that everything had to pretend to fit.</p>
<p>We replaced the shared interface with a capability descriptor that each engine declares up front:</p>
<pre><code class="language-ts">interface EngineCapabilities {
  indexes: "supported" | "none";
  primaryKeys: "supported" | "none";
  rowCount: "stat" | "query" | "none" | "connector";
  explain: "single-node" | "distributed" | "none";
  writes: "supported" | "readonly" | "connector";
}
</code></pre>
<p>The descriptor carries one field the old interface never modeled directly: <code>writes</code>. We used to derive it implicitly from primary keys, which is exactly the mistake from the primary keys section, so it became its own declaration.</p>
<p>Postgres declares indexes and primary keys <code>supported</code>, rowCount <code>stat</code>, explain <code>single-node</code>, writes <code>supported</code>. Trino declares something honest instead:</p>
<pre><code class="language-ts">const trino: EngineCapabilities = {
  indexes: "none",
  primaryKeys: "none",
  rowCount: "connector",
  explain: "distributed",
  writes: "connector",
};
</code></pre>
<p>The UI reads the descriptor before it renders anything. If <code>indexes</code> is <code>none</code>, there is no Indexes tab at all, not an empty one. If <code>explain</code> is <code>distributed</code>, it binds the fragment and exchange parser rather than the operator tree parser. There is no default parser, which is the whole point.</p>
<p><code>connector</code> is the state the old boolean world could not express. For a row count it means: try <code>SHOW STATS</code>, and if <code>row_count</code> comes back non-null use it, otherwise show "unknown" and offer a full count as an explicit action rather than firing a distributed <code>count(*)</code> behind the user's back. For writes it means: do not decide from the connection type, check whether the specific catalog actually supports the operation, because one Trino connection can front a writable warehouse and a read-only Kafka catalog at the same time.</p>
<h2>What actually changed in our heads</h2>
<p>The bug was not "Trino is weird." The bug was that we had modeled metadata as a property of a connection type, when it is really a property of whatever sits behind the connection. For a normal database those are the same thing, so the shortcut holds and you never notice you took it. Trino separates the query engine from the storage, and the shortcut falls apart exactly along that seam. Indexes, keys, counts, plans, and writes all belong to the storage, and Trino does not own the storage.</p>
<p>Concretely: open a Trino table in the current build and there is no Indexes tab where the empty one used to sit, and the header reads "rows: unknown" with a "Run count" button beside it instead of a confident zero. The panels no longer state something false.</p>
<p>Once capabilities are explicit, adding an engine stops being "make it implement the interface and hope the methods return something sensible." It becomes "declare what it can do, and let the UI switch off the parts that do not apply." The panels stop lying. An engine with no indexes says so, instead of showing an empty list and letting the user draw the wrong conclusion.</p>
<p>If you are building anything that speaks to more than one kind of backend, that is the part worth stealing. Make each backend state its capabilities as data, and let the interface adapt to the declaration, instead of forcing every backend to imitate the one you happened to design for first.</p>
]]></content:encoded></item></channel></rss>