Repository navigation
chore(deps): security updates - #79
Closed
broisnischal wants to merge 140 commits into
Closed
broisnischal wants to merge 140 commits into
broisnischal wants to merge 140 commits into
Conversation
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 AI settings module still carried a set of pre-profiles compatibility wrappers, license.js exported an unused feature gate and its PRO_FEATURES set, and a couple of stores exported getters with no callers. Also moves a comment in settings.js that had drifted three declarations away from the constant it describes.
The Docker modal accepted an initialDbType it applied to a list that renders no selection, the appearance menu bound an open state nothing read, and the JSON view kept two callbacks the view toolbar took over. Log lines are keyed by index so duplicates render.
Closing the modal threw away whatever you had typed. A snapshot of the editable fields is re-stamped whenever a form loads, so isDirty flips the moment anything changes and the close path routes through a confirm.
focusField looked up the plain `cn-*` id, but the ClickHouse, SQL Server and Redis forms prefix theirs (cn-ch-*, cn-mssql-*, cn-redis-*) — so on those engines the suggested fix silently did nothing. It now falls through to the visible engine's variant.
…he modal The save and connect paths each assembled a SavedConnection by hand, including the per-engine default port table; buildSavedConn is the one copy. D1's bespoke account discovery goes too — it now runs through the same provider flow as Neon and Supabase, which already lists accounts and databases. The rest of the diff is the project formatter over a file that had drifted to hand-aligned declarations.
Also removes a stray empty [Unreleased] heading that had been left behind above 1.13.0.
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.
…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.
|
Collaborator
Author
|
Folded into #75 instead. This branch was cut from The dependency commit ( |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes 30 of 36 Dependabot alerts. No application code changes.
^3.4.13Four left, deliberately
rustls-webpkiandglibare pinned by dependencies we don't 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. Forcing either means losing SQL Server support or changing the Linux windowing stack.echarts— the XSS is real and the only fix is 6.1.0, a major. I tried it: installs, builds, tests pass, and ECharts ships av5theme precisely 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
peerDependencies: { echarts: ^5.0.1 }.word-cloudis a chart type this app offers and builds withseries.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; revisit when the plugin supports v6.Verified
cargo check·vite build· 265 tests — all clean after.