Skip to content

perf, correctness and UI sweep - #75

Merged
broisnischal merged 141 commits into
masterfrom
fix/perf-ui-sweep
Aug 8, 2026
Merged

broisnischal merged 141 commits into
masterfrom
fix/perf-ui-sweep

Conversation

@broisnischal

@broisnischal broisnischal commented Aug 8, 2026 •

Copy link
Copy Markdown
Collaborator

Two months of work, split into 100 commits so each one is a single change with its own reasoning. 172 files, +20.4k/−4.8k.

Full user-facing notes are in the [Unreleased] section of CHANGELOG.md — this is the engineering summary.


Correctness

Data integrity was the worst of it. MySQL inserts read back the wrong row: LAST_INSERT_ID() is connection-scoped and was being read on a separate pooled connection, so whenever background work touched the pool the re-fetch returned 0 or another statement's id. MySQL backups corrupted three types on the way out — DECIMAL was consumed by the integer decoder and written with its fraction dropped (9.99 → 9), DATETIME/DATE/TIME matched no decoder at all and exported as NULL, and BIGINT UNSIGNED was rejected by the signed decoder. Restoring a MySQL dump could strand tables in the wrong database, because the dump's USE …; directives are connection-scoped and the statements depending on them could land elsewhere in the pool. And a Postgres backup silently omitted enums, sequences, FKs, views, functions or triggers whenever a catalog query failed — every one of them ended in unwrap_or_default().

Storage leaked per-connection state indefinitely. Deleting a connection left its recents, charts, dashboards, diagrams, per-table prefs, SQL draft, query history, saved queries, conversations and schema snapshots behind forever. Enough deletions exhausted the localStorage quota, at which point unrelated saves started failing. Saved column order and the structure cache were also keyed without the connection, so two databases with a public.users shared one.

Also: a failed schema capture was stored as an empty snapshot and then diffed as "every table removed"; an edit made just before switching connection was dropped, or written under the new connection's key; Cloudflare and provider sign-in errors rendered as an empty list instead of the error; live mode kept polling a connection you'd switched away from, erroring every second forever.

Performance

Reading cells tried each type in order, and every mismatch makes the driver allocate a formatted error — up to 12 per cell on a Postgres text column, 9 on MySQL. Common types are now routed by name first, with the old cascade as the fallback.

Large results were resident twice at peak: rows were collected from the driver, then mapped into JSON. They're converted as they stream now.

Round-trips were the other half. Multi-row deletes cost one statement per row — one HTTPS request each on D1/Turso. Every page/sort/filter refetched primary and foreign keys that cannot have changed since the table opened. Incoming-FK discovery was one round-trip per table in the database. All three are batched or skipped now.

The grid was painting each frame twice while scrolling (scheduleDraw queued its own rAF alongside the scroll loop, both calling draw()), and walking the columns scrolled off to the left once per visible row. Infinite-scroll rows were going through a $state proxy on every rows[r][c] read in the draw loop.

Elsewhere: the schema timeline caps its LCS matrix, the data diff debounces its filter and yields while comparing, the Redis keyspace bounds its TYPE fan-out, query history finds the last statement with a cursor instead of loading a log capped at 100k, the AI agent fetches SQLite table info in parallel rather than one round-trip per table, and the sidebar stopped forcing layout on every keystroke in the app.

Features

  • Geometry cells open a real map — pan, zoom, offline country outlines by default, tiled basemaps one click away and named with their provider. A value only reaches the map when it's actually on Earth; an SRID-less one is labelled as an assumption rather than presented as fact.
  • Inserting a row says what the database will do. An identity column was labelled "Required" — backwards, since a value there overrides the sequence. No type string can identify one (a Postgres serial reports as bigint), so the catalog is asked directly: attidentity/attgenerated/nextval on Postgres, EXTRA on MySQL, the rowid alias on SQLite.
  • Enum and boolean insert fields take typing — filter, arrow, Enter. Leaving one empty is a real captioned choice (default / NULL / Required), not a blank to guess at.
  • Vectors show standard deviation and a value histogram; the status bar shows the selected-row count; connections reopen on the schema you last used; the window title names the database instead of printing a miniflare path.
  • The AI sidebar can read SQLite, D1 and libSQL schemas — its tools queried information_schema, which those engines don't have, so every call failed and the agent worked blind.

Verification

cargo check clean · npm run build clean · npm test 246 passing across 17 files.

Not verified by running the app: the geometry map and the insert pickers are new UI that builds and type-checks but has not been exercised against a live database in this branch.

broisnischal and others added 30 commits August 6, 2026 17:35
Hovering a bar erased it. The theme accent is authored as `oklch()`, and
`resolveChartAccent()` handed that string straight to ECharts. Canvas paints
oklch fine, so the bars looked right - but zrender's own colour parser only
understands hex/rgb/hsl, so `liftColor()` (which derives the emphasis fill)
returned `undefined` and the hovered bar was drawn with no fill at all.

Normalise the token through a scratch-canvas round trip, falling back to a 1px
rasterize when the engine echoes a modern notation back. Every derived state
(hover, blur, gradients) works across themes now, not just bars.

Also replace zrender's default axis-pointer shadow (30% mid-grey) with a
theme-aware whisper. The default painted a heavy slab across the plot that
competed with the bars it was meant to point at.
An `interval` column rendered as boxes and a stray letter. `INTERVAL` had no
decode arm in `cell_to_json`, so it fell through to the raw-bytes branch, which
reinterpreted Postgres's 16-byte binary interval (months/days/microseconds) as
UTF-8: the NULs became tofu and 0x6D printed as "m".

Add a `PgInterval` arm with `format_pg_interval`, rebuilding Postgres's own text
form. Verified against the real column via the app's MCP server, which returned
the binary next to `365 days`, plus: `1 year 2 mons 2 days 03:05:06`,
`25:01:01.5`, `00:00:00`, `-1 mon -2 days -01:00:00`.

Units stay separate rather than collapsing to seconds - months and days are not
fixed-length, so normalising would change the value.
Connecting took seconds. Measured, in order of size:

1. `warm_pool` was awaited. The log read `pool warmed in 10001ms` - exactly the
   pool's acquire_timeout - on every connect, including ones whose handshake took
   267ms. That whole 10s sat between the user and the app. It now runs spawned
   after `set_conn` (the pool is an Arc, so the task fills the same one the UI is
   already using) and is bounded so a dead host is not retried forever.

2. `test_before_acquire` was on (sqlx's default), pinging the connection before
   every hand-out. On a remote host that is a full round trip per acquire, and
   one table open acquires six. A failed ping also makes the pool discard and
   reopen until acquire_timeout - which is why warming five connections took
   exactly 10s. Off for Postgres and MySQL; max_lifetime still bounds staleness
   and the app's silent auto-reconnect heals a dead connection.

3. `min_connections` was 0, so a burst had to open connections mid-flight. On a
   link that drops ~20% of SYNs those opens stall on the kernel's ~4s retransmit,
   and several at once outran acquire_timeout - surfacing as "pool timed out
   while waiting for an open connection". Four now stand by, opened and replaced
   by the pool's background task.

Also: `retry_fast` reissues a stalled connect at 800ms with a fresh SYN rather
than waiting out the kernel's backoff (healthy connects here measure 265-364ms,
so it never fires on a merely slow one), and `prewarm_dns` resolves saved hosts
ahead of time - a cold lookup measured 4147ms against 58ms warm, and the connect
tracked it almost exactly.

Connect now logs 265-364ms when the network cooperates.
OmniRoute is a global npm package the user otherwise installs and runs in a
terminal. The app can now do it for them - but only on an explicit click, only
for this one package, and never silently.

New `omniroute.rs`: `omniroute_env` reports node/npm/omniroute versions,
`omniroute_install` runs `npm i -g omniroute` streaming npm's output, and
`omniroute_start` spawns the server then polls until it answers (spawning only
proves it launched). Each failure names the missing piece - no Node, no npm, a
rejected global install - because "could not start OmniRoute" is unactionable.

The status probe asks for `/v1/models` rather than opening a TCP socket: any dev
server satisfies a bare connect, and the panel then claimed OmniRoute was running
beside a failed fetch. The port is parsed from the provider's own URL for the
same reason - a hardcoded one probed a port OmniRoute never uses.

The failure state is one line naming which server is not answering, with Start
inline, instead of a wall of red containing the raw fetch error.

Also: a "Free models only" toggle over presets and server-listed models
(OpenRouter's `:free` suffix or our own `free` tag), hidden when nothing free
exists; the models dialog closes itself after adding a model, offers right-click
edit/delete, and its rows are compact enough to scan.
…ction

A Databases section now sits above Recent, listing what else is reachable on the
current connection. Clicking one asks before switching, since switching drops the
pool, the catalog and every open tab.

The per-engine dispatch (provider API, pg_catalog, SHOW DATABASES, Cloudflare D1)
moves out of StatusBar into `databases.js` so the sidebar and the status-bar
switcher share one implementation. That surfaced a bug: MySQL was lumped in with
Postgres and ran a `pg_catalog.pg_database` query, which always threw and left
the list silently empty. It uses SHOW DATABASES now.

The list loads only once the section is expanded, and via an effect rather than
the toggle handler - hanging it off the toggle missed both cases that matter: the
section restoring already-expanded from saved prefs, and a connection switch
invalidating the list while it stayed open. Both showed an "empty" list that had
never been fetched.

The filter box now filters databases and recents alongside tables and views, and
is labelled "Filter…" to match. Scoped to tables, typing a database name emptied
the table list and left the database sitting there unmatched.

Also: the connect overlay names the connection it is actually dialling. Only the
startup path set that name, so the three other paths that raise the same overlay
showed whatever was set at launch - during a switch, the previous database. And
"Reconnecting" is now "Connecting" for anything that is not resuming a session.
…itself

Every "are you sure?" prompt hand-rolled its own width, padding and footer, so
they came out different sizes with buttons at different insets. They now render
through `ConfirmDialog`: one geometry at 440px (380 wrapped names mid-sentence),
one footer, icons on both buttons. `DangerousActionDialog` and `DdlConfirmDialog`
keep their cascade toggle and SQL preview through an `extra` slot.

The connection modal no longer closes on outside interaction. It is inset to
leave the titlebar and status bar usable, so "outside" is window chrome -
dragging the window to move or resize it, or a stray click on the tab bar, threw
away a half-filled connection form. It closes on x, Escape, or a successful
connect. That also deletes the chrome-detection layer built to special-case some
of those interactions: a capture-phase pointerdown tracker, a selector list and
an elementFromPoint fallback, all moot once nothing outside dismisses it.

It also gains a Disconnect action beside Resume, and double-clicking the
status-bar connection pill opens it - a single click only opens the switcher, so
reaching the manager cost an extra hop.
…atch

The remaining 13 `<select>` elements opened the OS popup, which cannot be
styled - on Linux/WebKitGTK that is a grey system list in the middle of the
app's own theme. All of them now use the bits-ui Select.

They go through a new `FieldSelect`, which keeps a native select's shape
(`bind:value` plus a flat options array) so each call site is a few lines rather
than fifteen of Root/Trigger/Content boilerplate. Each one also sheds its
hand-drawn chevron, since the primitive supplies one.

Converted: StructureView, RowDetailPanel, InsertRowDialog, CreateDatabaseDialog,
CreateTableDialog, CreateTriggerDialog, ForeignKeyDialog, DateFilterControl,
SqlConsole, BackupPage, RedisKeyspacePage, TableRecordView.

Separately, the select trigger drew a 1px border while Input and Textarea drew
2px, so a field and the dropdown beside it read as different components. The
trigger is aligned, and 36 hand-rolled fields across 20 files with it - keyed on
the `focus:border-ring/55` signature the design system prescribes, so cards and
chips are untouched. DESIGN_SYSTEM.md said 1px, which is how the drift started.
…fixes

An open dialog locks `pointer-events: none` on <body> (bits-ui), and only the
dialog's own content and overlay opt back in. The connection modal is inset to
leave the titlebar and status bar visible, so that chrome rendered and hovered
but was completely dead - minimize/maximize/close, the drag region and every
status-bar control. Both regions now opt back in. Dialogs whose overlay covers
the viewport are unaffected: the overlay still sits above this chrome.

Icons: `eye` was doing double duty as "Views" and as the visibility control in
the table toolbar, so two unrelated things looked identical. Views gets a new
`table-view` key (a view is a derived table), mapped across all three icon
styles. The hugeicons `eye`/`eye-off` pair was mismatched shapes; now a pair.

Theme: catppuccin set `--panel` DARKER than `--background`, the only theme where
the data grid sank below the chrome instead of rising above it - which is why the
table looked like it belonged to a different theme than the app. Panel is now
Mocha's `base` with chrome on `mantle`, matching both Catppuccin's convention and
the app's raised-panel rule. Checked all 16 themes; no inversions remain.
The chart view had no image export in the Export menu and the diagram viewer had
no copy. Both now offer the same set the ERD does, from the same place.

`ChartView.exportChart` handles png/svg/copy-png. SVG re-renders the option
offscreen with the SVG renderer rather than grabbing the canvas - the on-screen
chart is a canvas and has no vector to hand out. The toolbar's Export submenu
grows a Chart group in chart view, mirroring the existing Diagram group.

Fixes a real ERD export bug on the way: `renderPng` scraped the diagram's size
back out of its own markup with `/width="(\d+)" height="(\d+)"/`. Dragging a card
stores a sub-pixel position, so the root `<svg width="2400.37">` stopped matching
and the regex kept scanning - hitting the dot-grid `<pattern width="22">`. Copy
as PNG then produced a 66x66 image. With no drag it matched the root and looked
fine, which is why it seemed intermittent. `generateSvg` now returns its
dimensions instead of anyone re-parsing them, bounds are integral, and the root
carries a viewBox.

The clipboard write is shared (`copyPngToClipboard`) rather than living inline in
the ERD: native Tauri plugin first, web Clipboard API as the browser fallback.
…r chat

Asking for a table did nothing. Not the renderer - the system prompt said "Do
NOT repeat or echo the data as JSON, a markdown table, or a prose enumeration",
so the model was obeying the prompt and refusing the user. The rule keeps its
default (the live grid already shows queried rows) but now carves out an explicit
request, and prefers a table for derived or comparative values.

Underneath it, `.prose-ai table` set `overflow-x: auto` and then `overflow:
hidden` on the same element. The shorthand wins, so any table wider than the
message was clipped with no way to reach the rest. The wrapper scrolls now, with
a sticky header and a bounded height so a long answer scrolls inside its own
message. The copy button anchors to a non-scrolling parent - as a child of the
scroller it drifted into the middle of the table.

`.prose-ai` hardcoded the Inter stack, so changing the app font restyled the
composer and the user's turns but left every response in Inter.

Scrolling: the scroll handler computed scrollHeight - scrollTop - clientHeight on
every frame to answer "am I at the bottom?". Each read flushes layout, and with
content-visibility:auto on the messages that flush has to resolve exactly the
off-screen subtrees the property exists to skip - the optimisation was being
cancelled 60 times a second. An IntersectionObserver on a sentinel answers the
same question for free. Streaming used the same reads per chunk; it uses
scrollIntoView now. And the wheel handler assigned scrollTop directly, bypassing
event coalescing, while stealing every vertical wheel over a code block - it
yields when the block can still scroll, which scrollable tables need too.

Composer rests at 60px rather than 38px, which read as a search field.
Four credit lines join the existing joke rotation, weighted double so they
actually surface, and nothing repeats until ten other lines have shown.

The history is module-level, which is the part that makes the no-repeat rule
hold: every spinner is a fresh component instance, so per-instance state would
forget the previous pick the moment that loader unmounted and repeat across
loads. One shared history spans every loader in the session, so a line shown in
the tab loader will not come up next in the table loader.

The rotation and the draw move into `loading-messages.js`; `TableLoading` is down
to one call, and `TabLoading` - which showed a bare spinner with no text unless a
caller passed a label - falls back to a credit. Four tests cover the window, the
ratio over 40k draws, the small-pool degradation and the shared history.
Replacing the scroll-position handler with an IntersectionObserver deleted the
`_scrollRafId` declaration but left its cancellation in onDestroy, so unmounting
the chat threw "Can't find variable: _scrollRafId" and dropped the whole app into
the error boundary.

That also explains DevTools not opening: F12 and Ctrl+Shift+I are registered by
StudioShell, which the boundary had replaced.

The observer's own $effect disconnects it, so there is nothing left to cancel.
…k hanging

The DevTools waterfall showed connect_postgres at 15.24s. Cause: retry_fast made
one bounded attempt (800ms) and then an UNBOUNDED one, so a run of lost SYNs on
that second attempt sat through the kernel's full backoff - 1+2+4+8s. 800ms +
14.4s is exactly the 15.24s observed.

Every attempt is bounded now, on a doubling ladder (800/1500/3000/6000), so no
single attempt can cost more than its own budget and a lost SYN is always retried
within a second or two. The outer CONNECT_DEADLINE still caps the total. A
timed-out attempt drops its half-built pool, closing whatever connections it
opened, so retrying does not pile connections onto the server.

Also drops the license check's HTTP timeout from 10s to 4s. It is fire-and-forget
(only catching server-side revocation) and a network failure already falls
through to the offline grace path, so hanging for ten seconds - the 10s
run_license_check in that same waterfall - bought nothing.
Adds `instance_set_config` (Postgres `ALTER SYSTEM`, MySQL `SET PERSIST`,
value = None resets to the server default) and the UI around it: per-setting
editability from `pg_settings`, the right control per vartype, and a reset to
default. `internal` settings are marked non-editable because the server rejects
every attempt to set them.

Authored in parallel with the session's other work; the api.js wrapper and the
command registration in lib.rs land in the following commit, which is where
those two shared files were also being edited.
A `prisma studio` or `drizzle-kit studio` is already pointed at a database, and
a `docker compose` database already has its credentials written down. Asking the
user to type either again is asking twice, so the connection screen offers them
directly: one click, nothing to fill in, and the session survives a restart.

Three sources, three groups (studios, installed servers, Docker last):

- Studios, by process rather than by HTTP: both expose private unversioned APIs,
  but a command line, a cwd and an environment are stable and give the real
  database URL — so a click opens a native session, not a proxy. Current Prisma
  Studio takes a random high port, so the port comes from the process's own
  listening sockets, not a default. Resolves schema.prisma, prisma.config.ts
  (Prisma 6 moved the url there) and drizzle.config.*, including remote
  dialects: Neon, Supabase, PlanetScale, Turso (token from the environment),
  D1 over HTTP, Cockroach, SQL Server.
- Installed servers, matched on the executable so `psql` and `redis-cli` don't
  register as one. A container's server process is visible on the host but its
  socket lives in another netns, so it never matches and can't be listed twice.
- Docker containers, credentials read from the container's own environment.
  Engine comes from the image, falling back to the port it listens on when the
  image name says nothing (a bare image id). A container with no published port
  isn't offered, because it can't be reached from the host.

Also fixes `parseMssqlUri`, which couldn't read the semicolon form Prisma
actually writes (`sqlserver://host:1433;database=…`) — `new URL` reads that as a
malformed port and throws, so every SQL Server studio failed.

Carries the api.js wrapper and lib.rs registration for the preceding commit's
`instance_set_config`; both files were in flight for both threads.
Reading a schema in the shape your ORM uses it is a different job from reading
DDL: you want the model names, the field types you would actually type, and
above all the relations spelled out in both directions. So the introspection the
app already does is rendered as a schema file instead.

- Prisma: PascalCase models with `@@map`, camelCase fields with `@map`, native
  type attributes, enums, `@@id`/`@@unique`/`@@index`, and both ends of every
  relation — with generated relation names when two keys point at the same table
  (`authorPosts` / `reviewerPosts`, never `posts` / `posts2`).
- Drizzle: current syntax — the array-form table extras callback, `pgEnum`, an
  exact import list, `.references()`, and a `relations()` block per table with
  the destructure the body actually calls.

Three schema-wide introspection calls, no per-table fan-out, and the
Prisma/Drizzle switch re-renders locally — instant on a hundred-table schema.
Views are left out and named in a header comment rather than rendered as models
with no key. Engines each ORM can't describe say so instead of failing.

Opens from Quick Access or the command palette. Also guards the workspace
sidebar's Mod+B while the connection dialog owns that key for its own rail.
sqlx decodes the core types; anything an extension adds arrives as raw bytes,
and the generic "is it UTF-8?" fallback can never reach them because a vector is
packed floats and a geometry is packed doubles. So the cells read `<VECTOR>` and
`<GEOMETRY>`: the app could see there was data and then refused to show it.

Decoders for `vector`, `halfvec`, `sparsevec` (pgvector's own text form), EWKB →
EWKT for `geometry`/`geography` (point through collection, Z/M, both byte
orders, ISO and EWKB encodings), and `bit`/`varbit`. Anything else non-text now
shows a hex preview rather than a type name. Every read is bounds-checked: these
bytes come off the wire and a truncated value must not panic the app.

A 1536-number literal in a textarea is the value without any of the meaning, so
a vector cell opens its own viewer: a distribution strip that keeps each
bucket's extreme (a single spike survives downsampling), the figures that say
whether the vector is what you expect — dim, L2 norm flagged when it isn't unit
length, min/max with their indices — the indexed values, and the raw literal.

In the grid a vector reads `384d · 0.1, 0.1, …`, with the head count following
the column width: too narrow for values shows the dimension alone, because half
a number is noise.

Two grid fixes alongside: relationship columns painted the panel background over
the row tint, so a selected row went dark at the last two columns; and
left-aligned text reserved 4px on the right on the assumption a value never
reaches the edge, which a truncated one does every time — the ellipsis sat on
the column divider in every long column, for every type.
Two separate failures produced the same unreadable diagram.

Routing was switched off at scale. The router built one Hanan grid over the
whole diagram — cells growing with the square of the card count — so at around a
hundred tables it blew its budget, returned nothing, and every relationship was
drawn as a direct elbow straight through whatever stood in its way. There was
also a hard 120-card gate above it.

Edges are now routed against only the cards near them, so cost is set by the
link's neighbourhood rather than the size of the schema, and the lane pitch stays
tight instead of being coarsened 8x to fit a global budget. Every finished
polyline is then checked segment-by-segment against every card; a crossing is
re-routed on its own tight grid, then with a wider window, and only then dropped.
The module's promise — a route avoids every card or the router reports failure —
now actually holds. Cap raised 120 → 600. Measured: 100 cards / 58 links in
68ms, 225 / 248 in 1.25s, none crossing, where all three routed nothing before.

Nothing enforced that cards didn't overlap each other. Dagre reserves space for
the cards it places, but that doesn't survive a re-flowed rank, a hand-dragged
card, or a card that changed size since the layout ran — and an overlapping card
is worse than a crossed line, because it hides data. Positions are now relaxed
apart along the axis of least penetration (rows stay rows, columns stay columns),
with a deterministic spill for the pile-ups relaxation can't settle, so the
invariant holds for any input and the same input never jitters.
From the log of a real remote connect:

    preflight db.prisma.io:5432 -> reachable in 91ms
    connect attempt 1 exceeded 800ms,  retrying with a fresh SYN
    connect attempt 2 exceeded 1500ms, retrying with a fresh SYN
    connect attempt 3 exceeded 3000ms, retrying with a fresh SYN
    connected in 11067ms

The retry ladder fired on every connect, calibrated against a nearby host ("a
healthy connect measures 265-364ms"). Against a proxied serverless Postgres whose
handshake genuinely takes ~5.7s it binned 5.3 seconds of progress and left four
half-built pools behind — which is also how "pool timed out" reached the first
table open.

A retry only ever helped a lost SYN: a packet dropped before the socket exists.
It cannot help a handshake that is merely slow, because restarting one pays TLS
and auth again from zero. The preflight already opens its own TCP connection to
the same host, so the moment that succeeds we know SYNs are getting through and
the attempt in flight is the fastest one available. The ladder now stops there,
and still fires while TCP is unproven.

Three more things the same log showed:

- min_connections 4 -> 2. Four background opens raced the first table open's six
  queries for the same ten slots; on a host where one handshake costs seconds the
  row counts lost that race.
- acquire_timeout 10s -> 20s. One handshake measured 5.7s, so a query queued
  behind two of them was failed as a timeout for a connection still being made.
- `before_acquire` pings only a connection idle 25s or more. min_connections and
  test_before_acquire(false) pulled against each other: connections stood by, and
  after a sleep or a network change every one was dead and handed out anyway.
  A ping on every acquire costs a round trip six times over on one table open; a
  connection handed back seconds ago cannot have died. So a stale pool now
  repairs itself inside acquire() instead of surfacing as a failed query and a
  full reconnect.

Removes warm_pool: min_connections has the pool's own maintenance task doing
that in the background, so it was three redundant acquires per connect — and
because it held each until the last landed, it was itself a source of
"pool timed out".
Three defects behind one bad experience.

The desktop app never retried anything. fetchWithAiRetry short-circuited to the
Tauri bridge before its own retry loop, so RETRYABLE_STATUSES and the backoff
were dead code in the only build that ships — a provider blipping for two seconds
failed instantly. Fixing that alone wouldn't have helped chat, because the
streaming bridge resolves as soon as the request is accepted and the 503 arrives
later as an error on the stream. The retry now lives in chatCompletionStream, and
fires only before the first token: restarting after tokens have been shown would
duplicate the answer on screen.

The system prompt ordered the refusal people were seeing. Rule 7 said "if you
lack enough context, say exactly …" with no exception for someone saying hello,
and a 70B at temperature 0 with twelve tools follows that literally. Greetings
now get a warm reply naming two things it could do with the connected database;
the refusal wording is reserved for a real request that is genuinely missing
something. A general question ("what is an index?") gets an answer, not a tool
call.

And the error bar showed raw JSON, clipped mid-token, because Rust formats the
message as `AI API 503:` plus 400 characters of body and the UI's parser choked
on that prefix. describeAiError now gives a plain title per status, reads a
router's `diagnostics` to answer *why* (pool size, how many endpoints were
attempted, the distinct exclusion reasons — the actual answer, thrown away with
the rest of the JSON), and keeps the payload behind a Details disclosure with a
copy button. A body cut off mid-JSON still yields its message.
…ceholder

- Schema Explorer never received the read-only flag: both drop buttons and both
  "New …" buttons were live on a connection opened read-only. They now follow the
  session store, and askDrop guards itself — a disabled button is the hint, not
  the gate.
- Virtual columns panel: the header wrapped because a long table name squeezed the
  title, and the empty state was centred prose in a 260px column, which broke
  every line in a different place. Header is one line now; the empty state is
  left-aligned with its action under the explanation instead of stranded at the
  bottom of an empty panel.
- Sidebar row counts: a pulsing pill per row turned a long table list into thirty
  things blinking out of sync, which reads as the app struggling — and the counts
  arrive in well under a second. One static rule on the digits' own baseline
  instead, holding the column width.
A fixed 900px panel made payloads with long URLs or tokens readable only by
scrolling sideways. The widest of the first ~400 lines decides the width now,
clamped between 720 and 1600 so a 2MB document neither pays a full scan on open
nor outgrows the window.
Dialog.Content positions its close button absolutely at the top right, which put
it on top of the view switcher. The viewer opts out of that one and puts a
Dialog.Close in the header row, where it sits beside the tabs instead of over
them.
Rebuilds the page around grouped, filterable config sections and richer
activity/state panels, with humanised labels and per-metric tone.

`resolveChartAccent` generalises into `resolveCssColor(varName)` so any
theme token can be resolved to a concrete colour echarts can both paint
and parse — the dashboard needs `--success`/`--warning`, not just
`--primary`. `resolveChartAccent` stays as the `--primary` shorthand its
existing callers use.

Carries small supporting changes in FieldSelect, StructureView and
TableTextView.
…he table

sqlx always asks for binary results, so a single column of a type without
`typsend` — PostGIS `raster`, `box2d`, `box3d`, `spheroid`, and plenty of
other extension types — failed the whole `SELECT *`. Opening such a table
showed an error where the grid should be, and every other column was lost
with it.

The failure is now caught and the page re-read with those columns
projected as text. It costs nothing on the common path: an ordinary table
never runs the extra catalog query, because the query only runs after a
fetch has already failed with that specific error. The original type is
restored on the column so the header still reads `raster`, not `text`.

`raster` is the one exception to `::text`: its text form is the entire
tile as WKB hex, megabytes for a real raster and useless in a grid, so it
gets a description of the tile instead. That value is display-only, which
costs nothing that wasn't already lost — a type with no binary output
cannot be edited through the grid regardless.

The SQL console gets the same treatment via a describe-driven wrapper, so
a hand-written `SELECT * FROM tiles` returns rows too. It is guarded: a
statement with unnamed or duplicate output columns can't be wrapped
without changing what it means, and there the original error stands.
Adds a Map view that plots any geometry/geography column on a world map.
It appears in the command palette and page navigator when PostGIS is
detected on the connection, and in a table's own view switcher when that
table has a spatial column — gated, because listing it everywhere would
make it the one view that opens onto nothing on almost every table.

Backend (`db/geo.rs`), two commands:

- `geo_overview` lists every mappable column with its SRID, geometry type
  and row estimate. Safe on any connection: a non-Postgres engine or a
  Postgres without the extension reports `available: false` rather than
  failing.
- `geo_features` fetches one viewport. Three things it has to get right:
  the bbox predicate is applied to the *stored* column in its own SRID so
  the GiST index still answers it; a viewport holding more rows than the
  budget is collapsed onto a grid server-side, so a million-row layer
  sends clusters rather than a million features; and an untyped
  `geometry` column is routed through ST_Dump + ST_CurveToLine, because
  `ST_AsGeoJSON` raises on curves, TINs and nested collections and one
  such row would otherwise fail the entire viewport query.

The cluster grid is built on the Mercator ordinate, not on degrees. A
degree of latitude is 1/cos(φ) taller on screen — 1.6x across Europe —
so a degree-snapped grid produces cells taller than they are wide, and
each place breaks into a vertical stack of separate marks. Markers sit at
the mean position of their members, never at the snapped cell corner,
which would put every marker on a lattice offset from its own data.

Frontend: `geo-map.js` is pure Web Mercator and viewport maths (26 tests);
`MapPage.svelte` renders it. Basemaps are Minimal (the `world.geo.json`
already bundled for the choropleth — offline, the default), plus Dark,
Streets and Satellite raster tiles. The online ones fetch from third-party
hosts, so they are opt-in and labelled as such, and tiles draw *over* the
bundled map so a blocked or slow provider degrades to the map that ships
with the app instead of to a black rectangle.

Clusters are drawn as markers with their count printed inside, sized by
area rather than radius. Filtering reuses the grid's operator vocabulary
and reaches the same `build_where`; the server fills in each column's real
type, without which `population > 3000000` compares as text and returns
658 rows instead of 378.
…ding

Two problems, one symptom: a large table opened on "All" showed "No rows
in this table" for the whole of a multi-second load.

The root cause is in the windowed load path. Opening a table on "All"
always sets `wantsWindow` (`effectivePageSize` is MAX_PAGE_SIZE while
`total` is still 0), so the first fetch returns one 20k window plus the
real count. When that count comes back at or under WINDOW_THRESHOLD the
code drops out of windowing and goes back for the remainder — but only
assigned `rows` after that second round-trip resolved. Columns were
already set from the first fetch, so the grid drew its header over an
empty body for the entire second read. Every table between WINDOW_FETCH
and WINDOW_THRESHOLD rows came through here. It now paints the first
window immediately and appends the rest when it lands.

The empty state itself was also unguarded. "No rows" is a claim about the
data, so it must never be shown while a fetch is running — the full-page
skeleton above it only covers the first load, when there are no columns
yet, and every later fetch that empties the grid fell straight through to
a flat assertion that the table is empty. It now shows a loading state
instead, which closes the same class of bug on every other path.
…support

Two seeded Postgres containers, so the extension types the driver hands
back as raw bytes can be exercised against real data at real size.

  npm run fixtures            # start both, seed, wait until the data is there
  npm run fixtures:status     # what is running and how many rows
  npm run fixtures:reset      # destroy the volumes and reseed

`scripts/fixtures.sh` waits for the seed's own ready marker rather than
the container health check — the difference between "Postgres accepts
connections" and "the tables have rows in them" — and surfaces the actual
SQL error if a seed statement fails instead of timing out.
`scripts/seed-fixtures.sh` points the same SQL at a Postgres you already
have, for a remote box or a second machine.

stroke_geo covers geometry and geography, mixed SRIDs, raster, a
million-row point table, and a `geom_zoo` carrying one row per shape with
an `expected` column stating what the cell should read — including the
curve and surface types the EWKB decoder does not model, which must
degrade to a hex preview rather than crash. stroke_vec covers
vector/halfvec/sparsevec at 384 and 1536 dimensions with HNSW and IVFFlat
indexes, a million-row table, and a `vector_zoo` of values that break
naive formatters.

Geometry is scattered around 100 real cities with real names, countries
and populations, not spread evenly over the globe: evenly-spread synthetic
points form a lattice that is all you can see on a map, which makes the
fixture useless for judging whether the renderer is right.

Placement is derived from a hash of the row number rather than random().
Three separate bugs came from getting that wrong. The scatter sat in
LATERAL subqueries referencing only the joined city, and Postgres
memoizes those — random() ran once per city and stacked 600 rows on one
point. Replacing it with two linear congruential hashes of the same seed
made y an affine function of x, so every row landed on a straight line,
invisible at world zoom and unmistakable once you zoomed into a city. And
the parcel grid's column stride shared a factor with the city count, so
each city received only every other column and the layer drew as vertical
stripes. The seed is now reproducible byte-for-byte.
AGENT.md is an editor-local scratchpad, and a .sqlnb notebook stores its
query results inline — the one committed here carried 48k lines of real
rows. Both are ignored now rather than shipped in the repo.
The preview was an abstract plot: a dot in an empty grid, which shows the
shape of a geometry but never answers the question you actually opened it
for — where is that. So the Shape tab is now a map. It opens framed on the
geometry, drags to pan, scrolls to zoom, and has one button back to the
whole world.

Offline by default: the country outlines already ship with the app for the
map view, so nothing leaves the machine. The tiled basemaps (dark, streets,
satellite) are one click away and labelled with their provider, because
turning one on sends this value's location to a third-party host.

A geometry only reaches the map when it is actually on Earth — 4326, 3857,
or an SRID-less value whose every coordinate falls inside the degree
ranges, which is called out as an assumption rather than presented as
fact. A projected local CRS keeps the plot.

The basemap plumbing (outline, tile cache, theme probe) moves to
geo-basemap.js so it is written once for both maps.
An identity column was labelled "Required", which is not just unhelpful
but backwards — sending a value there overrides the sequence. The reason
it slipped through is that no type string can identify one: a Postgres
serial reports as bigint, an identity column as integer, so the old
heuristic (name in the primary key AND type contains "serial") matched
almost nothing real.

The catalog is asked instead. Postgres reads attidentity, attgenerated and
a nextval default off pg_attribute; MySQL reads EXTRA and COLUMN_DEFAULT,
already in the projection it fetches; SQLite treats INTEGER PRIMARY KEY as
the rowid alias it is. Columns now carry autoGenerated and hasDefault, and
insertOmitBehaviour turns those into the one word the field should show:
auto-increment, generated, default, NULL or Required.
The insert row used a plain select, so choosing a value meant reaching for
the mouse and scanning a list that can run to sixty labels — and the list
could not express the thing that matters most on an insert, which is
leaving a column alone so its default applies.

It is an input first now: type to filter, arrows to move, Enter to take
the highlighted row, and free text is kept as-is so a value you typed
correctly needs no trip through the list. Leaving it empty is a real first
row, captioned with what will actually happen (default / NULL / Required)
rather than left blank to guess at.

The menu itself drops the per-call-site padding overrides that made these
rows half again as tall as every other menu in the app.
broisnischal added a commit that referenced this pull request Aug 8, 2026
It still told readers the source was private and to clone `stroke-app` —
which, on the front page of a repo that is now open, is the first thing
anyone reads and the one thing that is no longer true. Text is taken
verbatim from #75 so the two resolve as the same change rather than a
conflict.
@broisnischal broisnischal added the release:minor Bump minor version (0.x.0) label Aug 8, 2026
…launchd does

Start and Install were implemented and unreachable on macOS. A .app is
started by launchd, which hands it PATH=/usr/bin:/bin:/usr/sbin:/sbin and
nothing else — no Homebrew, no nvm, no fnm, no Volta. Node is in exactly
those places (on this machine, ~/.nvm/versions/node/v24.12.0/bin), so
`Cmd::new("node")` missed, omniroute_env reported node: null, and the
dialog collapsed to "install it with npm i -g omniroute, then run
omniroute" for users who already had all three. Nothing in the UI was
wrong; it was rendering an honest answer to a question asked with the
wrong PATH. Linux .desktop launches have the same gap.

Resolution now asks the login shell (`$SHELL -ilc`) — the only thing that
knows where a version manager put its bin directory, since that directory
is created by an rc file and recorded nowhere a process can read. stdin is
closed and the call is capped at 4s so an interactive shell entitled to
wait for input cannot wedge startup, and the result is cached for the
process. Static fallbacks (Homebrew both architectures, MacPorts, ~/.local,
bun, volta, cargo, asdf, and the newest few nvm/fnm Node directories) cover
a shell that can't be asked. nvm/fnm directories are sorted numerically:
lexically, v9 outranks v24.

Verified by running the resolver under launchd's exact minimal environment:
34 entries, node -> ~/.nvm/versions/node/v24.12.0/bin/node, npm ->
/opt/homebrew/bin/npm.
The rate-limit banner led with "Wait a moment and try again, or check your
plan and usage limits" — advice that is wrong for a quota resetting at
midnight — while the sentence that said so ("They reset at midnight UTC —
or add your own API key in Settings → AI") sat behind a Details toggle,
next to a second copy of itself wrapped in the JSON envelope. Two prints of
one fact, the useful one hidden.

The provider's message is the hint now. A router diagnosis still outranks
it, because "none of the 6 endpoints were tried, all cooling down after
429" explains something no message can — that ordering is what the existing
test was protecting, and narrowing to it is what makes this safe. Details
only appear when the payload carries more than the message: an envelope of
message/code/type is dropped, one with retry_after_seconds is kept.

The banner keeps red for the icon and the hairline and lets the words sit in
normal text colours, so it reads as information rather than alarm. Continue
in new chat is gone, along with the handoff builder behind it.
Ollama running with zero models was routed through localModelsError, so it
rendered as a destructive card advising "start it with ollama serve" —
under a server that had just answered. Verified here: /api/tags returns
{"models":[]} while the port is plainly up, which is the state the UI was
calling a failure.

Empty is its own state now: a green dot, "Ollama is running, with no models
yet", and the one command that fixes it with a copy button. Only unreachable
stays red, on a destructive-tinted surface rather than the same neutral card
the empty state used, so the two are distinguishable at a glance.

Install no longer looks hung. The Rust side has streamed every npm line on
omniroute-log since it was written and nothing listened, so a global install
that legitimately runs for tens of seconds showed a bare spinner with no way
to tell it apart from a wedge. The last line now shows under the button, in
reserved height so arriving output does not push the dialog around — the same
reason the node/npm/omniroute row holds one line whether or not the probe has
answered, and the action buttons share a fixed height so swapping between
Install and Start cannot resize the card.
I added an onDestroy handler in the previous commit and the import never
landed, so opening AI settings took the whole app to the crash screen with
"Can't find variable: onDestroy".

Nothing in the toolchain catches this. Svelte treats an unimported lifecycle
call as a free variable and emits it verbatim, `vite build` succeeds, and
svelte-check reports zero errors — I confirmed that on a probe file, because
these script blocks are plain JS with no checkJs. It fails only on mount.

So the guard is a test rather than a linter adopted wholesale for one
deterministic mistake: every .svelte file is scanned for calls to Svelte's
runtime API that the file never imported. Comments are stripped first —
prose like "the parent holds this closure past unmount (…)" is exactly the
shape being searched for, and all three initial hits were that. Verified by
removing the import again and watching the suite name the file and symbol.
With Ollama running and nothing pulled, the screen offered one hardcoded
command: `ollama pull llama3.1:8b`. Two problems. It named a single small
local model, when Ollama now proxies to hosted ones far larger than any
laptop — which is what you actually want from a database assistant. And it
was stale: the registry no longer lists llama3.1 at all. A command the user
pastes into a terminal has to be right, so it is no longer written down.

Suggestions come from https://ollama.com/api/tags, fetched only once the
empty state is on screen — the user is configuring Ollama at that moment —
never at startup. Anything at or under 20 GB is offered as a local pull;
everything else is offered as Ollama Cloud, tagged `:cloud`, because that
suffix is what makes a local instance route to the hosted copy instead of
starting a 595 GB download. An unreported size counts as cloud: unknown is
not a reason to tell someone to pull something that might be a terabyte.

Picking one fills the Model ID field, so Continue works without retyping.
If the registry can't be reached the card points at ollama.com/library
rather than naming a model that may no longer exist, and the Model ID
placeholder now shows a real current tag instead of the same dead literal.
A cloud model is the point of Ollama Cloud — nothing downloads, the local
instance proxies to their hardware — but there was no way to select one.
/v1/models lists only what is on disk, so a cloud-only user saw "no models"
forever and a user with local models saw a grid that could never contain a
cloud entry. The suggestion list is now the path to both, and it is shown for
any Ollama setup rather than only the empty state.

The tag form is the part that had to be right, and I had it wrong. Verified
against the running Ollama 0.24.0: a bare `gemma4:31b` is rejected outright
("model not found"), so the suffix cannot be skipped, and it belongs on the
tag — `gemma4:31b-cloud`, not `gemma4:31b:cloud`. Untagged names take
`:cloud` instead (`kimi-k2.6:cloud`). Every id the registry now produces was
sent to Ollama as the app would send it: all six resolve, four returning
completions and two answering "requires a subscription", which is a resolved
model with a plan limit rather than a bad identifier.

Unreported sizes sort last rather than as zero, so the list opens with the
cheapest thing that works instead of the three models nobody can size.
…dates

Two fields refused input. A generated column rendered as a static label, so
importing a row that has to keep its key, or backfilling a gap in a sequence,
meant leaving the grid for raw SQL over one cell. And the timestamp field was
a picker only — a calendar is the wrong tool for "same as the row above but a
year earlier", just as typing is the wrong tool for "some Tuesday in March".

Both take text now. The generated column keeps its key glyph and shows
"auto-increment" as a placeholder, so the normal path is still to leave it
alone: blank omits the column entirely and the sequence is untouched. The
date field pairs an input with the calendar button, and shows the column's own
text rather than a formatted label — an epoch column stays editable as digits
instead of being hidden behind a rendering of itself.

Tab now reaches every column, since none is read-only; initial focus still
skips the generated ones, because overriding a sequence is the exception.

Required blanks are the only ones that can stop an insert, so they are the
only ones that read at full strength — the rest describe a value the database
will supply and recede. Nothing is coloured as an error, because nothing is
wrong yet.

Six tests cover the payload path end to end: blank omits, a typed value is
sent as values.id, a bad one is still rejected by column type, and a table of
nothing but generated columns still inserts.
The suggestion list spun forever and never showed a cloud model. Two causes,
both mine.

ollama.com sends no Access-Control-Allow-Origin, so fetching it from the
webview is refused outright — it could never have worked in the app. The
codebase already solved this for the local model list (`ai_list_models` exists
precisely because "the WebView can't reach localhost cross-origin") and I
didn't follow the pattern. The registry now goes through a Rust command too,
which also settles the OS question: the packaged origin is tauri://localhost on
macOS and http://tauri.localhost on Windows and Linux, so a CORS dependency
would have failed three different ways. Through Rust there is no origin at all.

The infinite spinner was the guard. It re-entered while the lists were empty,
and a failed fetch leaves them empty — so it refetched the moment it settled,
forever. Exactly the loop the Copilot model fetch had a few lines above, which
I fixed earlier this week and then rewrote from scratch. It is latched now:
one attempt per dialog session, and Recheck clears the latch so it is a real
retry rather than a local-only refresh.

Verified against the live registry: 18 models, every one carrying the name and
size the classifier reads, and the cloud list now opens
nemotron-3-nano:30b-cloud, gemma4:31b-cloud, gpt-oss:120b-cloud — smallest
first, with the three the registry cannot size sorted last instead of leading
as zero.
…gh zoom

Zooming in far enough cut the right-hand end of the table toolbar off — the
page picker and arrows ran past the edge with no way to reach them.

The progressive collapse was already there: ten container-query breakpoints
between 420px and 600px that hide the optional controls as space runs out.
What defeated it was the search box, the one element that should give way
first and the only one that couldn't — shrink-0 with a fixed w-52. So once
every optional control had been hidden, the row still demanded 208px for
search plus Add plus the pager, and overflowed. App zoom is webview zoom, so
zooming shrinks the container in CSS pixels: at 300% a 1440px window is
480px, which is under that floor.

Search is now shrinkable with a 7.5rem floor — enough for the icon and a word
of query — which brings the essentials to ~414px and keeps the pager on
screen well past any zoom level anyone uses.

overflow-x-auto underneath is the floor rather than the plan: if a window is
narrow enough that even the essentials don't fit, they stay reachable by
scrolling instead of vanishing. Menus are portaled so nothing is trapped by
it, and app.css already gives the class a 4px overlay scrollbar with the
wheel handling a horizontal-only scroller needs.
The gaps looked uneven because the boxes were. Three heights shared one row:
size-6 icon buttons at 24px, h-5 pills (version, vim, Apply, Reset) at 20px,
and px-2 py-1 label buttons at whatever their content came to. Under a uniform
flex gap, unequal boxes never sit on a rhythm — the space between them varies
with their height even though the gap value never changes. DESIGN_SYSTEM.md
fixes control heights for this reason; the status bar had drifted off it.

Everything interactive is h-6 now, 24px in a 32px row: 4px of breathing above
and below, 2px between neighbours, 6px either side of a separator. The one
remaining py-1 is a dropdown menu item, which is not part of this row.

Also drops a third meaning for the same glyph. "Go to page (⌘P)" used a plus,
which already appeared twice in this bar meaning "create a database" — so the
row showed three identical + icons, two of which created something and one of
which navigated. It is layout-template now.
The list stopped somewhere around the point the app kept growing. Nine
sections were missing entirely — the map view, instance insights, database
objects, notebooks, codegen, split panes, the activity log, the per-tab view
modes, and the cell viewers that are the reason a vector or a PostGIS
geometry is readable at all.

Corrections rather than additions in three places. MariaDB and CockroachDB
are their own drivers in the connection modal, not "connect through the
PostgreSQL option". The AI section described a bring-your-own-key box and
said nothing about the free tier, local models, Ollama Cloud, Copilot,
OmniRoute, skills or web search. And connections are discovered — Docker
containers, local servers, the database an ORM config points at — which is
the first thing most people will meet and went unmentioned.

The intro counts eleven engines and four providers rather than calling all
fifteen engines: Neon, Supabase, PlanetScale and Prisma Postgres are
Postgres and MySQL underneath, and saying otherwise inflates the number.
…data

Counts which features get used so there is something better than guesswork
behind what gets built next. Events are names, not payloads — track('table_open')
has nowhere to put a table name, which is the property that makes this safe to
ship in a client pointed at production databases.

Never sent, and with no code path to be sent: connection details, hostnames,
credentials, database/schema/table/column names, SQL, query text, results, row
data, file paths, IP, account. The server validates the same allowlist
independently, so neither end trusts the other to have got it right.

Batched on a 60s timer and flushed on close, so a working session is a handful
of requests rather than one per click. A failed flush drops the batch instead
of queueing it: a retry buffer for analytics is a memory leak that grows
exactly when the network is worst. Counts are cleared before the await so a
slow request cannot double-report.

On by default and stated plainly in Settings, next to the AI web-access
toggle. Turning it off takes effect immediately rather than at next launch —
a privacy switch that needs a restart to mean anything is not one.
Everything that landed after the changelog was first written: the insert row
taking typing in every field, Ollama Cloud models, anonymous usage data, and
the run of AI-settings, OmniRoute, toolbar and status-bar fixes.
SECURITY.md matters more here than in most projects: Stroke holds database
credentials and connects to production systems, so a publicly filed
vulnerability is a working exploit until it is patched. It names a private
channel, says what is in scope, and states where secrets are kept so a
reporter can tell a real finding from a design they merely dislike.

Issue templates ask for version, OS and engine, because a bug that is real on
Postgres is often not real on SQLite and the first exchange is otherwise
always the same three questions. Both open by asking people not to paste
credentials or real rows — the natural thing to do when reporting a problem
with a database client, and the thing you cannot take back.

The PR template asks which engine a change was tested against, for the same
reason.
…sion

30 of the 36 open alerts, none of which change a line of application code.

  vite    8.0.14 -> 8.2.1   pulls postcss 8.5.26 and nanoid 3.3.18 (high)
  mermaid 11.15.0 -> 11.16.1
  dompurify override ^3.4.13 — mermaid and monaco-editor both still resolve an
    affected version and neither has bumped; an override is the only way to
    close it without waiting on two upstreams, and 3.4.13 is a patch on the
    line mermaid already used
  quinn-proto 0.11.14 -> 0.11.16   (high)
  serde_with  3.20.0  -> 3.21.0

Four are left, deliberately.

rustls-webpki 0.101.7 and glib 0.18.5 are pinned by dependencies we do not
control: rustls-webpki by tiberius (the SQL Server driver, via rustls 0.21),
glib by the GTK stack under Tauri's Linux webview. Both need an upstream major
before they can move, and forcing either would mean losing SQL Server support
or changing the Linux windowing stack.

echarts is the interesting one. The XSS is real and the only fix is 6.1.0, a
major. I tried it: it installs, builds and passes, and ECharts ships a v5 theme
specifically so a v6 upgrade keeps the old appearance, so the visual risk was
manageable. What stops it is echarts-wordcloud, whose latest release (2.1.0)
still declares a peer range of echarts ^5.0.1. word-cloud is a chart type this
app offers and builds with series type wordCloud, so the bump trades a working
feature for a moderate advisory whose attack path is data in the user's own
database. Reverted to 5.6.0 and left for whenever the plugin supports v6.

Verified after: cargo check, vite build, 265 tests.
The blanket override also replaced monaco-editor's dompurify, and monaco pins
it to an exact 3.2.7 rather than a range. An exact pin is a decision, not an
oversight, and monaco's dompurify sanitises the HTML behind the SQL console,
the DDL viewer and every hover in the app — overriding it to satisfy an
advisory would have risked the editor to fix a dependency I was not asked to
break.

Scoped to mermaid, which declares ^3.3.3 and is therefore satisfied by 3.4.13
on its own terms. Diagram rendering is the path where dompurify actually sees
untrusted input here, so this closes the reachable case.

monaco's 3.2.7 keeps a moderate advisory. It stays until monaco bumps its own
pin: the alternative is overriding a vendor's explicit version choice in the
component the whole SQL surface is built on.
@broisnischal

Copy link
Copy Markdown
Collaborator Author

uhuhh

@broisnischal
broisnischal merged commit 57dc13b into master Aug 8, 2026
1 check passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release:minor Bump minor version (0.x.0)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant