feat: PIN lock, plugin API, and an ER diagram that reads - #81
Merged
Conversation
Six checks - RLS, unindexed foreign keys, unused indexes, dead-tuple bloat, very wide rows, missing primary key - each a catalog query. No user rows are read, nothing is written, and no extension is required, so the scan is safe to point at production. Severity is decided per finding, not per check, and a check that cannot run reports its own status rather than counting as a pass. Wide rows are measured as table size over row count, NOT pg_stats.avg_width: a TOASTed value shows up in pg_stats as the ~18-byte pointer in the main tuple, so a stats-based check misses precisely the tables that hurt most to read.
DbState held one cancel handle, so a second concurrent run overwrote the first's and Stop cancelled whichever query registered last instead of the one whose button was pressed. Handles live in a map keyed by the id the caller sends; cancel_query with no id still cancels everything in flight.
What stalls the main thread is bytes: 5k narrow rows is half a megabyte, 5k rows of a table with an embedding column is ninety. A large load probes 200 rows, measures them, and sizes windows to keep each request near 2MB. Also decides whether to window at all - long OR heavy - and refuses to window without an exact row count, since a sparse array sized from a guess would leave skeleton rows past the end of the table forever.
The animation keeps its own float position rather than reading scrollTop back each frame: an element quantizes the offset it is given, so a step small enough to round away would leave the measured distance unchanged - the loop then runs forever without moving and scrolling appears dead. Reduced-motion jumps.
The settings store needs the defaults and the validator on every load; putting them here keeps sql-formatter off the app's startup path.
Severity order, grouping by check, counts, filtering, export rows and the one-line summary. The backend decides what is wrong; how the report reads lives here, where it can be tested without a database.
Grouped by check and collapsed, because a real report runs to hundreds of rows: '235 warnings' is a wall, 'Foreign key without an index (37)' is a decision. Severity tiles double as filters, category counts follow the active filter so a tab never promises rows it would hide, and checks that could not run are listed at the bottom - a report that silently drops one is a report that lies.
… SQL formatting Native scrolling is off by default, so the app's eased scrolling is what you get unless you ask for the OS's. Row spacing gives the grid a measured row height rather than a constant; alternating row colours are independent of the grid-style preset, two of which already shade rows as part of their look.
…pshot A windowed tab stores its window size, base, count and ordering so returning to it restores the view instead of re-running the fetch and the count.
roundRect keeps its beginPath; roundRectPath appends a sub-path, so a row of skeleton bars is one fill instead of one per column.
Off to 5 minutes. The caret and the button share one outline and one hover surface - a free-floating caret next to the icon read as a second, unrelated control - and the armed state is carried by the caret's colour, since there is no room for a badge at this size and the tooltip already states the interval.
Six bits-ui Selects - each a floating layer with a focus trap and a search box - were mounted merely to show the Database tab. Two-to-three short options fit on screen, so an inline choice control is cheaper AND quicker to read than opening a popover to pick 'Upper'. The section starts collapsed; search still expands it. Options are read through the validator, so settings written by a build that predates them cannot throw the pane into the error boundary.
Its rows live outside the tab state, so the pane showed an empty grid where it should offer 'Focus this pane to load'.
The non-passive wheel listener the animation needs is attached only while eased scrolling is enabled; with native scrolling on, the grid keeps the modifier-gated listener it had before and the OS scrolls the container directly. Shift+wheel normalizes the delta first. A mouse reporting deltaMode 1 (lines) sends 1..3, and treating that as pixels moved the grid three pixels a notch, so horizontal scrolling looked broken under eased scrolling while native mode looked fine - there the browser does the conversion itself.
…w spacing - Skeleton bars take their column's alignment, vary in width, sit on the text band and shimmer while the window is in flight. Flat half-width bars hard left meant every right-aligned value jumped sides the moment rows landed. - The NOT NULL asterisk trails the column name. Leading it indented the name by its own width on required columns only, so those headers sat out of line with every other header and with their own values. - An expanded JSON panel is clipped out of the sticky header band. It has to sit above the canvas, which paints opaquely, so it drew over the header. - Alternating shading is one shared constant at a visible alpha; 7% of a muted tone against the panel background was nothing at all. - Row height comes from the row-spacing setting.
loadRows wrote into shell-level state guarded by one global sequence token, so switching tabs mid-fetch dropped a million-row result into whichever table was now on screen, and returning to the tab you left restarted its work. Loads are owned by their tab: the per-tab token supersedes only that tab's own loads, results are committed to the owning tab's state, and the visible grid is touched only while that tab is in front. A load left running finishes into its own tab. Windowing is generalized to any large limit and driven by measured payload weight, with the view's ordering frozen so every window slices one total order. A windowed tab restores from its snapshot rather than re-fetching, and the eviction policy sees the sparse array it keeps outside the reactive tree.
…o-save - The Advisor mounts like the other pages and is teardown-eligible: it re-scans on reopen, so unmounting it loses nothing. - Auto-refresh is per tab, and only the visible tab polls - a background tab re-fetches on activation anyway. It skips a tick while a load is in flight or a cell editor is open, and refreshes in place. - Each SQL run carries a cancel id, so Stop reaches the query whose button was pressed while another tab keeps running. - Moving to a non-SQL tab parks the shared sqlLoading flag: a run that finished while its own tab was in the background left it set, and the next snapshot of any editor tab captured that as its own state.
🦋 Changeset detectedLatest commit: 0d0d602 The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
A windowed view paged with LIMIT/OFFSET, so a window deep into a large table made Postgres walk every index entry before it: 345ms at offset 995k on a million rows, against 1.5ms for the same page fetched by key. Windows slice one total order, so the row before a window is the anchor its page needs - taken only on Postgres, on a single ordering column that is the whole primary key, and with a type the backend accepts; the first error on that path drops the view back to offsets. The NOT NULL check the keyset gate runs is memoised for ten seconds so it doesn't put a catalog round-trip in front of each window. Alongside it, the states around a window fetch: - failed windows retry three times on their own, instead of waiting for a visible-range emit that a stopped scroll never sends - read-ahead reads a rolling mean, not the first slow measurement - one deep offset page used to latch prefetch off for the rest of the view - a scroll-preserving reload re-asks for the windows under the viewport rather than having the emit swallowed as a duplicate - a pill names the rows in flight once they've been waited on long enough to be worth naming, with Retry when a window has given up - the shimmer sweeps clear of both edges (it blinked once a period) and every skeleton bar in a frame shares one path and one gradient
A sweep over comments and doc strings only - no code, no user-facing copy, no behaviour. Keeps the source consistent with how I write everywhere else, and separates 109 files of punctuation from the diff that actually changes something.
Four changesets' worth of work, all of it unreleased on this branch. Security An optional four-digit PIN gates opening Stroke and reconnecting to a database. Only a PBKDF2-HMAC-SHA256 digest is stored, in the OS keychain, so the switch that turns the lock on cannot be flipped from a devtools console. Every password field gets a reveal toggle. Extensions A plugin API with an external host, a typed surface in types/stroke-plugin.d.ts, worked examples, and PLUGIN_API.md. Databases Create, rename, duplicate and drop from the sidebar, with the SQL shown before it runs. ER diagram Mostly a rewrite of how it draws. A rank taller than the page folds into sub-columns, so a hub schema is a page instead of a 1:10 strip. Lines are right angles that share their corridors rather than each taking a lane - measured on 135 cards, that is 3.7 corners a line instead of 10.6 and 70k pixels of line instead of 247k. Parallel foreign keys between one pair of tables collapse into one line with a count. Lines that reference one column land on it as one line. A card that overlaps its partner horizontally connects through the near horizontal edge instead of doubling back around its own width. Moving a card re-routes the handful of lines it touched, not all of them. Grid and chrome Clearing a filter loads the rows again, an applied cell edit shows without a refresh, row counts fill in on a large schema, the filter bar lands the caret in its value, and the sidebar stops drawing a column of dashes for counts that have not arrived.
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.
Everything unreleased on this branch, ready to cut 1.23.0. The four
.changeset/*.mdfiles here are the changelog;auto-release.ymlfolds theminto
CHANGELOG.mdon merge and tags the build.What is in it
Security. An optional four-digit PIN gates opening Stroke and reconnecting
to a database. The PIN is never stored, only a PBKDF2-HMAC-SHA256 digest over a
random salt, kept in the OS keychain next to the saved credentials, so the
switch that turns the lock on cannot be flipped from a devtools console the way
a localStorage flag can. Auto-lock keeps the session intact behind the screen.
Every password field gets a reveal toggle.
Extensions. A plugin API with an external host, a typed surface in
types/stroke-plugin.d.ts, worked examples, anddocs/PLUGIN_API.md.Databases. Create, rename, duplicate and drop from the sidebar, with the
SQL shown before it runs.
ER diagram. Mostly a rewrite of how it draws, and the bulk of the diff.
Measured on a 135-card schema with 164 relationships:
The two things that were wrong both came from the same instinct: keeping lines
apart so each stays traceable. An anti-overlap penalty pushed every route into
its own lane, and port fanning spread arrivals across a card. Together they
turned 164 relationships into a mesh. Lines share their corridors now and land
on the column they reference, which is what every tool that draws a whole
schema does. A rank taller than the page also folds into sub-columns, so a hub
schema is a page rather than a 1:10 strip.
Grid and chrome. Clearing a filter loads the rows again (
fetchLimitFornolonger sizes a fetch from a count taken under a different filter), an applied
cell edit shows without a refresh, row counts fill in on a large schema, the
filter bar lands the caret in its value, and the sidebar stops drawing a column
of dashes for counts that have not arrived.
Reviewing
The punctuation sweep is its own commit (
style:, 109 files, comments only) soit stays out of the diff that changes behaviour. Everything else is in the
feat:commit.Checks
467 frontend tests, 88 Rust tests,
npm run buildclean. New coverage for therouter (corners per line, ink shared, never crosses a card), rank folding,
edge merging, the fetch-limit rule, and the PIN's PBKDF2 against known vectors.