All build notes

Selekta: one query editor for PostgreSQL, MySQL, SQLite and SQL Server

Selekta

Selekta is our database client. It is in public beta for Mac, and it connects to PostgreSQL, MySQL, SQLite and SQL Server from one familiar layout: a navigator tree on the left, SQL editor tabs, and a result grid. The goal we wrote down at the start was simple to say and harder to do: native Rust drivers instead of JDBC on a JVM, so the client opens instantly and stays light.

This post is about what it takes to make one editor behave well on four engines that all speak "SQL" and disagree on almost everything else.

The shape of the app

Selekta is a Tauri app. The interface is written in Svelte and runs in the operating system's own web view; everything that touches a database is Rust. The database layer uses sqlx for PostgreSQL, MySQL and SQLite, and tiberius for SQL Server.

The UI talks to Rust through one small typed wrapper, and every editor tab owns its own dedicated connection, so a long query in one tab never blocks another. The rule we hold the code to is written at the top of the driver module: the UI never branches on engine for SQL. Engine differences belong in Rust, in one place.

We planned a trait and shipped an enum

The first architecture sketch had the textbook answer: a Driver trait with connect, list_schemas, list_tables and so on, and one implementation per engine.

What shipped is a plain enum with one variant per engine's connection pool, and match statements that send each call to the right module. The reason is that the engines differ more than a shared trait wants them to. Take the navigator tree. In PostgreSQL and SQL Server you go database, then schema, then folders of tables and views. In MySQL a database is a schema, so you go straight from database to folders. SQLite has exactly one database, called main. A trait would either hide those differences behind awkward defaults or leak them through every method. An explicit match keeps each engine's path readable and in one file.

Cancel is four different features

Every database client has a Cancel button. Behind ours are four separate mechanisms:

  • PostgreSQL: Selekta asks the server to cancel the running backend with pg_cancel_backend.
  • MySQL: a KILL QUERY for the running connection.
  • SQL Server: a TDS Attention message on the same connection. This needs no extra permission, unlike KILL. The catch is that the connection can end up one reply behind. So after a cancel, Selekta sends a small marker query with a fresh random ID and checks that the answer it gets back is its own. If it isn't, the connection is replaced.
  • SQLite: stops reading between rows. One long-running statement can't be interrupted mid-way; that is a known beta limitation.

For the server engines there is also a grace period: if a statement still hasn't stopped three seconds after cancel, the connection is dropped on our side so the tab is usable again.

Keeping numbers exact

A database client that rounds your numbers is worse than useless. Selekta runs statements through the simple text protocol, so PostgreSQL and MySQL send values as the server's own text rendering: exact for numerics, with dates in an ISO-like form that keeps time-zone offsets. Integers and decimals stay as strings all the way to the grid, so they never pass through a floating-point number on their way to your screen.

SQL Server needed extra care, because the driver hands its money type over as a floating-point value, which can't hold the largest amounts exactly. So before running a SELECT, Selekta asks the server to describe the result columns without running the query. If any of them is money, it wraps the query so those columns come back as exact decimals, asks the server to check the wrapped version, and only then runs it. That costs one extra round trip per SELECT (two when money columns are found), and it is skipped inside a transaction where a describe error could abort your work. Geometry from PostGIS and MySQL is parsed by our own code, so spatial values can be shown on a map instead of as hex.

Streaming a million rows

One of our targets was that a result of one million rows should scroll at 60 frames a second without lag. Rows stream from Rust to the UI over a Tauri channel, in pages, and the grid only draws the rows you can see, with a small buffer above and below.

The first profile of "Fetch all" on a million rows showed the UI thread spending 60–100 ms per frame. Two things were wrong:

  1. The UI copied the whole result on every frame. Each animation frame appended the new rows by concatenating arrays. At a million rows that is a full copy roughly 75 times a second — quadratic work in total. Above 50,000 rows the UI now publishes new rows at most four times a second.
  2. Each batch was just too big. Rows went out 100 at a time. A 100-row event was about 11 KB, just over Tauri's 8 KB limit for delivering a message directly, so every batch cost an extra round trip. During "Fetch all", Selekta now sends 1,000 rows per event: 1,000 trips for a million rows instead of 10,000. Normal paged results still go out 100 at a time.

What we measured, including the misses

We keep a benchmark script in the repository and record its results, pass or fail. On an Apple M4 with an early release (0.1.1):

  • Fetch all of 1,000,000 rows: 0.8–1.2 s, and smooth scrolling through them dropped 0 of 600 frames.
  • Idle memory with no connection: 90 MB, against a 150 MB target.
  • Installer: 11.3 MiB for Apple silicon, against a 30 MB target.
  • Warm start: 0.34–0.64 s, against a 1.5 s target.

And the misses. The very first launch after installing or updating took 1.55–2.38 s, mostly macOS checking a freshly linked binary — a one-time cost, but over our target. A deliberately harsh "sweep" scroll test still shows one visible hitch. And with a connection open, the window focused and the cursor in a text field, memory can reach about 190 MB. We'd rather write these down than pretend they aren't there.

Being careful with other people's data

A database client sits next to production data, so some decisions are about safety rather than speed:

  • Passwords live in the operating system's keychain, not in Selekta's own settings database.
  • Read-only connections are enforced twice: with the engine's own read-only setting on the server side, and by a check that refuses write statements before they are sent.
  • Destructive statements ask first, and when you edit data or structure in the grid, Selekta shows you the SQL it is about to run before running it. Editing rows is only allowed on tables with a primary or unique key, so an edit can't hit the wrong row.
  • Backups never put passwords on the command line. When Selekta runs pg_dump or mysqldump, the password goes to the child process through its environment, never its arguments, and never into a log.

Where it stands

Selekta is a beta, and parts are still rough: CSV import can't create new columns yet, and a few engine-specific editors are unfinished. If you work with databases on a Mac, you can download it from selekta.kreasimaju.id. Tell us what breaks.