Devtools

An embeddable SQL console and database explorer — a floating panel in dev, an inline console in docs pages.

A panel you drop into any app to browse, edit, and query the database it is already using. It floats over your page without blocking it, and every change asks first.

Try it

This is the panel an application mounts: a launcher in the corner, and a window over the page that leaves the page underneath it usable. It builds a small retail database in memory here, so nothing is written to your machine and closing it throws the data away.

A launcher appears in the corner, and the panel opens over this page — drag it by the title bar, and keep reading underneath.

The console on the home page is the same panel embedded inline, in its production shape: a database running in a web worker (the same worker client an app ships) over a seven-table retail dataset generated in your browser and kept in IndexedDB. The title-bar badge reads worker there because the queries genuinely leave the page.

Install

npm install @minnowdb/devtools

Mount it

The panel attaches directly to a MinnowDatabase or worker-hosted MinnowDatabaseClient.

import { mountMinnowDevtools } from "@minnowdb/devtools";

if (import.meta.env.DEV) {
  mountMinnowDevtools(db, { corner: "bottom-right" });
}

That adds a launcher button in the corner. Click it, or press Cmd/Ctrl + Shift + D. Escape closes the panel again from anywhere inside it, once nothing inside it wants the key.

Keep the mount behind a development check. The devtools are a separate package precisely so they can be left out of a production bundle.

Or use the element

<minnow-devtools> is a custom element, so it works unchanged in React, Vue, Svelte, Solid, Astro, and plain HTML. Its shadow root keeps your styles out and its own styles in.

import { defineMinnowDevtools } from "@minnowdb/devtools";

defineMinnowDevtools();
document.querySelector("minnow-devtools").target = db;
<minnow-devtools corner="bottom-left" hotkey="mod+k"></minnow-devtools>

The database is a property rather than an attribute, because it is an object. Attributes are live: change theme and the panel repaints in place, change any other and it remounts with the new value, so a framework that re-renders them works without a remount of its own.

Options

OptionAttributeDefaultWhat it does
modemode"launcher""launcher" floats over the page; "inline" renders in flow.
cornercorner"bottom-right"Which corner the launcher and the opening panel use.
hotkeyhotkey"mod+shift+d"Toggle shortcut. mod is Cmd or Ctrl. Empty turns it off.
defaultOpenopenfalseOpen on mount. Inline panels are always open.
zIndexz-index2147483000, 0 inlineStacking against your own overlays. A floating panel clears the page; an inline one stays in its flow, under a sticky header.
permissionswrite{ write: true }write: false refuses every statement that changes data.
initialQueryinitial-query""SQL the console starts with.
storageKeystorage-key"minnow-devtools"Namespace for the remembered panel geometry, history, and saved queries.
themetheme"system""light" or "dark" pins the palette; "system" follows the OS.
heightheightcontainerHeight of an inline panel. A number is pixels; a string is any CSS length.

The schema rail

Your tables sit down the left of every tab, views in their own group below them. Each expands to its columns with their declared SQL types — INTEGER, NUMERIC(10,2), JSONB, an enum type's name — nullability, and which one is the unique key. Columns are badged when they are enum values, generated from an expression, or filled by the engine when omitted. Below the columns come the indexes with their ordered keys and build health, the foreign keys with what they point at and how they delete, the check constraints, and the triggers. A view shows the query it stands for.

Knowing what a column or index is called is as useful for writing a statement as it is for browsing a table, so the rail never goes away. Cmd/Ctrl + K jumps to its filter box.

What clicking does follows the tab you are on:

  • On Query, a table, column, or index name is inserted at the caret — orders, orders.total, or orders_by_total — spaced from whatever precedes it. A foreign key inserts the JOIN … ON it describes, and a view's SQL replaces the statement with the view's query.
  • On Data, a table or view opens in the grid; a foreign key opens the table it points at.

The chevron expands a table either way, so you can read its columns without loading it.

Browsing data

The Data tab browses one table or view at a time. Pick it from the rail, or from the picker in the toolbar.

Sorting. Click a column header to sort ascending, again for descending, again to return to the table's own order. The unique key is appended to every sort, so rows with equal values keep a stable order instead of shuffling between pages. Drag a header's trailing edge to resize the column.

Filtering. + filter builds a typed comparison: =, ≠, contains, starts with, <, ≤, >, ≥, like, in, between, is null, and is not null, offered per column type. Values are converted to the column's type before they reach SQL, so score > 10 compares numbers, and a value the column cannot take — abc in a number column, a name outside an enum's values — is refused in the editor rather than compiled into a filter that silently matches nothing. Datetimes compare to the millisecond. Filters combine with AND.

For text, contains is the one you usually want — it adds the wildcards, so crea finds created. like takes a pattern exactly as written, following PostgreSQL: like crea matches only the string crea, and you need %crea% to search inside a value. The value box hints which one you are in.

Both are case-sensitive. A contains or starts with value is treated as literal text — _ and % typed into it are escaped, so searching for 100% finds 100% and nothing else. In like they stay live as wildcards, because there you are writing the pattern yourself (with ESCAPE '\' available when you need a literal one).

Paging. Rows load as you scroll. Where it can, the explorer asks for "the rows after the last one I saw" rather than "skip the first N", so reading deep into a table costs the same as reading the start of it. The status bar always says which it is using.

A cursor needs a total order it can address exactly, so the explorer counts from the start instead when the table has no unique key, or when the sort column is nullable (no comparison matches NULL). Both give the same rows; one just gets slower the further in you go.

Counting. COUNT(*) scans the whole table, so it runs alongside the first page rather than delaying it, and only when the answer can have changed — a new table or a new filter, never a sort. While it runs, the status bar says how many rows are loaded.

Live. The Live toggle in the toolbar reloads the first page whenever the database commits something the table can see — a row the app just wrote shows up without a click. It uses the same live queries an application subscribes with, so the engine decides which commits matter and skips the ones that cannot change the rows.

Rows and relationships

Click a row to select it. Shift-click selects the run from the last selected row; Cmd/Ctrl-click adds and removes single rows. Cmd/Ctrl + C on a focused cell copies its value, or, with rows selected, copies them as tab-separated text ready for a spreadsheet.

Details in the toolbar opens a sidebar that reads the selected row top to bottom, every value in full and JSON re-indented. Where the catalog knows a foreign key, the value carries a button to the parent row; below the values, Related rows lists the tables whose foreign keys point at this row, each a click away. Following either opens the other table filtered to exactly those rows.

Right-click a cell for the same things without the sidebar:

  • Copy the value, or the whole row as JSON or as an INSERT statement.
  • Filter the table to this column's value.
  • Open the parent row a foreign key points at, or the related rows in a child table.
  • Query these rows in the console — the current table, filters, and sort as a SELECT to build on.
  • Edit the value, duplicate the row into a new-row form, or delete the selected rows.

Editing rows

Double-click a cell to edit it, then save with the check beside the input or with Enter; the × or Escape discards it. Clicking elsewhere leaves the editor open rather than throwing the edit away. An enum column offers its values as a menu, and so does a boolean. Click a row to select it, then Delete row — or select several and delete them together. Add row opens a form with one input per column; a generated column is shown with its expression and never asked for, since the engine computes it. Every one of them describes what it is about to do and waits for you to agree — the confirmation names the table, the key, and the before and after values.

Values are typed as the column is typed, and checked in the editor rather than after you confirm: twelve in a number column is refused on the spot. A blank input means NULL where the column allows it, and NULL typed into a text column means the same thing (a literal 'NULL' string needs the quotes).

Writes go through the keyed batch API rather than generated SQL, and every value — a datetime included — is written exactly as typed. After a write the row is read again rather than patched in place, so the grid shows the committed value.

When editing is unavailable, the panel says so in a banner instead of leaving a dead button:

SituationWhat you can still do
permissions.write: falseBrowse only.
The target has no write APIBrowse only.
The table has no unique keyBrowse and insert — the engine keys updates and deletes by the unique key and refuses them without one.
It is a viewBrowse only. Its rows come from a query; the rail shows which one.

The window

It is a window, not a modal. There is no backdrop over your page and no focus trap — the app underneath stays fully clickable while the panel is open. Drag it by its title bar and resize it from any edge or corner; dragging a left or top edge holds the opposite one still, the way a window manager does.

Maximize next to the close button fills the screen, and restores to the size the window was actually left at — double-clicking the title bar does the same. In the console, the divider between the editor and the results is draggable, and the height you give the editor is remembered.

Both sidebars collapse to a narrow strip with the chevron in their header, which is how you give the editor or the grid the full width. The panel reopens where you left it, with the same sidebars collapsed.

They also step aside on their own as the panel narrows — history first, then the tables — so a small panel spends its width on the thing you are looking at. The Data toolbar carries its own table picker, so choosing a table never depends on the rail being there.

Two badges in the title bar say what you are working with:

  • worker or main thread — a database built in the page runs queries on the main thread, so a slow one will freeze it. The worker client does not.
  • write on or read-only — whether permissions.write allows changes.

Downloading the database

The ⭳ button beside the badges saves the whole database as a snapshot file — one committed version, blocks and catalog and counters, in a single minnow-v42-2026-08-17.minnow. It is how you keep a copy of what you are looking at, send it to someone, or carry it to another machine.

The ⭱ button beside it loads one back. Pick a file and the panel reads its header — which costs nothing, whatever the file's size — and tells you the version, the table count, the date it was taken, and how big it is before anything is loaded. The tables reappear in the rail as soon as the load finishes.

The database has to be empty to restore into. One that already holds data refuses the load rather than merging two histories, so the usual shape is a fresh page against a fresh store.

A progress chip beside the badges reports what is happening throughout — reading, copying, writing — because a real database is not a quick file. The bytes come out of a worker in slices, so the page keeps painting while a large one is copied.

Restoring is a write, so the button is absent with permissions.write: false. Both buttons are absent when the target cannot do snapshots at all.

Storage

The Storage tab reads the database's own report of itself: the storage backend, bytes live and obsolete, block and segment counts, write-ahead log and checkpoint sizes where the store has them, and what the collector is doing — whether it is running, how many commits are waiting on it, and whether it has been failing. Each read walks the store, so the report is taken when the tab is shown and on Refresh, never in the background. The tab is absent for a target that reports nothing.

Running statements

The Query tab is a SQL console over the same database, with syntax highlighting and completion drawn from your own catalog: type a table name and its columns are offered, events. narrows to that table's columns. Cmd/Ctrl + Enter runs. When part of the editor is selected, only the selection runs.

The editor and the compiler load the first time you open the tab and run something, not when the panel mounts — the launcher and the data explorer never pay for them. Until the editor arrives (and if it fails to arrive at all) the console is a plain text box that runs queries exactly the same way.

Scripts. Several statements separated by semicolons run in order, each on its own, and the notice lists what each one did. Strings, quoted names, comments, and trigger bodies are read correctly, so a semicolon inside any of them is not a boundary. A script that changes data asks once, listing every change it contains; the first failure stops it and is pointed at in the editor.

Rows. An unbounded SELECT shows its first 1,000 rows and says so — the rows travel in one message and are held in memory with the history, so a SELECT * over a million rows is capped here rather than freezing the page. Load all rows in the status bar runs it again without the cap. A SELECT with its own LIMIT is left alone. Cancel replaces Run while a query is running and stops it between batches, where the target can be interrupted.

The status bar shows the timing and, where the engine reports it, the peak memory the statement used. Copy CSV and Copy JSON above the rows copy what is shown; Download CSV saves every row of the last query as a file, reading it again through the target's cursor when the grid holds only the capped part.

Live. The Live toggle follows the last SELECT the way the data tab follows a table: the rows re-render whenever the database commits something they can see. A statement that changes data stops the following until the next SELECT.

Queries return rows; anything that changes data is described and confirmed first:

  • The prompt names the table, the operation, and the statement itself before it runs. An UPDATE or DELETE counts the rows its WHERE clause matches and shows the number beside the statement, so a clause that matches the whole table is caught before it runs.
  • An UPDATE or DELETE with no WHERE clause is called out as hitting every row.
  • With permissions.write: false, the statement is refused outright and never reaches the database.

That confirmation step covers every statement that changes the database: INSERT, UPDATE, DELETE, MERGE, schema DDL, index and trigger DDL, and ROLLBACK, which discards uncommitted work. BEGIN and COMMIT are not confirmed: each statement inside the transaction is confirmed on its own, and COMMIT only keeps what was already approved. Session settings — SET, RESET, and SHOW — change no data, so they run without confirmation and are allowed even with permissions.write: false. Destructive schema operations say what will be removed. Successful DDL refreshes the rail and completion automatically, so a new table, column, or index is available without pressing reload.

RETURNING rows from an insert, update, or delete appear in the Rows tab and are cached with that history entry just like a SELECT result.

What a statement does is read off the compiled plan, not its text, so a SELECT that merely mentions DELETE in a string literal is still a query.

SQL that fails to compile is reported with the offending token selected in the editor — the position comes from SqlCompileError.

Diagnostics

The editor compiles as you type and underlines what it cannot parse, on the token rather than the line. This costs nothing per keystroke: compileStatement is part of the library and runs in the page, so nothing is sent to the worker and no query is executed to find out that the SQL is wrong.

Where the engine recognizes an unsupported SQL feature, the message names it and what stands in for it. For example, SET TRANSACTION ISOLATION LEVEL SERIALIZABLE explains that the engine has one isolation level. That comes from the shipped feature matrix, so it stays true as the engine changes. A message that several features share explains nothing rather than guessing between them.

Plan

The Plan tab beside the results shows what the optimizer made of the statement — the join order, where filters run, and whether the scan can stream. It is asked for only when you look at it, and running a query returns you to the rows. It explains one statement at a time: select the one you mean inside a script. Non-query statements say that a SELECT is required instead of passing DDL to the query planner.

The Query/Data/Storage and Rows/Plan tab bars use the standard arrow, Home, and End keys. The editor divider is keyboard-resizable with Up and Down. In the data grid, arrow keys move between visible cells, Space selects a row, and Enter starts editing a cell when editing is available.

History

The last 50 runs are kept beside the console, newest first. Queries show their row count; other statements show their operation-specific result. Every entry carries its timing and age — or its error message, in red, if it failed. Click one to put it back in the editor.

The star beside an entry saves it: a saved query sits in its own group at the top, never ages out, and survives Clear, which forgets every run that is not saved.

The query text and those timings persist, so history survives a reload. Result sets do not: fifty of them would exhaust the storage quota on the first wide query, so recent rows are cached in memory only and a recalled entry offers to run again once its rows have aged out. History is namespaced by storageKey, so two panels on a page keep their own.

Embedding a console

mode: "inline" renders the panel in the document instead of over it, with no launcher — the same panel, in flow. The console on the home page is exactly this, over a worker client with its blocks in IndexedDB:

mountMinnowDevtools(db, {
  container: document.querySelector("#console"),
  mode: "inline",
  initialQuery: "SELECT * FROM people",
});

Sizing it

An inline panel fills its container. Give the container a height and the panel takes all of it; give it none and the panel falls back to 520px, so dropping it into a page needs no CSS at all.

<div id="console" style="height: 70vh"></div>

Pass height instead when the container is not yours to style. It takes a number of pixels or any CSS length:

mountMinnowDevtools(db, { container, mode: "inline", height: "70vh" });
<minnow-devtools mode="inline" height="480"></minnow-devtools>

Two custom properties do the same from a stylesheet, which is what a responsive embed wants — they are read off the container, so a media query can change them without JavaScript:

#console {
  --mdt-height: 60vh;
  --mdt-min-height: 400px;
}

Matching your page

The panel lives in a shadow root, so it follows the reader's OS colour scheme rather than your page's. A page with its own light/dark switch tells the panel which way it went:

const devtools = mountMinnowDevtools(db, { container, mode: "inline", theme: "dark" });
devtools.setTheme("light"); // when your switch is flipped — no remount, the query survives

The editor turns with the panel, and a panel on "system" follows the OS when it changes while the page is open.

Its colours are custom properties, and properties set on the container reach inside the shadow root, so the whole palette is yours to override:

#console {
  --mdt-accent: #7c3aed;
  --mdt-bg: #ffffff;
  --mdt-bg-secondary: #f7f7f5;
  --mdt-text: #37352f;
  --mdt-border: rgba(55, 53, 47, 0.12);
  --mdt-sans: "Inter", sans-serif;
  --mdt-mono: "JetBrains Mono", monospace;
}

The full set is --mdt-bg, --mdt-bg-secondary, --mdt-bg-hover, --mdt-bg-active, --mdt-bg-code, --mdt-text, --mdt-text-secondary, --mdt-text-faint, --mdt-border, --mdt-border-strong, --mdt-accent, --mdt-accent-bg, --mdt-selection, --mdt-danger, --mdt-danger-bg, --mdt-warn, --mdt-warn-bg, --mdt-ok, --mdt-ok-bg, --mdt-shadow, --mdt-sans, and --mdt-mono. Set them in a dark-mode block too, or the ones you override will be the only colours that do not turn.

Nothing else crosses the boundary in either direction: your stylesheet cannot restyle the panel's internals, and the panel cannot leak into your page.

Cleaning up

mountMinnowDevtools returns a handle:

const devtools = mountMinnowDevtools(db);
devtools.open();
devtools.close();
devtools.destroy(); // removes every listener and the element it created

On this page