There is no SQLite server to monitor
Health is an integrity check, a journal mode and file sizes, and one per-table figure depends on which built-in driver the deployment is running.
A SQLite database is a file. It has no listener, no process of its own, no
session table and no statistics collector, so a SQLite database health check
cannot be the thing that word usually means. There is no pg_stat_database to
read, because there is no database server holding one. What exists is the file,
the pragmas the engine will answer, and whatever the operating system says about
bytes on disk.
That is a smaller set of readings than a monitoring dashboard has room for. The question is what to put in the space that is left.
What health can mean for a file
Studio connects to SQLite through the runtime’s built-in driver: bun:sqlite
under Bun, node:sqlite (DatabaseSync) under Node, selected at connect time by
the sqlite-driver adapter. Not better-sqlite3 - Bun refuses to load it, and a
native binding compiled against one runtime’s ABI fails under the other. The
connection is a server-local file path, and the connection form collapses to a
single Database File Path input: no host, no port, no user, no password.
getCapabilities() reports defaultPort: null for the same reason.
Everything a server-backed dashboard reads follows from a running process that counts while it works. SQLite counts almost nothing, so the monitoring tab here answers four questions instead:
- Is the file internally consistent?
- Which journal mode is it in?
- How large are the database, the
-waland the-shmfiles? - How many objects and rows does the schema hold?
Those are the readings that exist.
Integrity, journal mode and file statistics
getHealth() runs PRAGMA integrity_check and PRAGMA journal_mode and
reports both as information rows, alongside activeConnections: 1.
getOverview() adds sqlite_version(), the file size and sqlite_master
counts, with uptime as N/A and maxConnections: 1 - a single embedded
handle has no pool, and getPoolStats does not exist here.
Journal mode is worth reading rather than assuming. On connect the provider
opens the file { create: true, readwrite: true } and sets
PRAGMA foreign_keys = ON, journal_mode = WAL and synchronous = NORMAL, so
a database Studio has opened normally will report WAL. A file another process
left in delete or truncate mode is a fact about that process, and the panel
shows it.
getStorageStats() reads fs.statSync on three paths - the database, its
-wal and its -shm - so the WAL growing while the main file stays flat is
visible without inference. Whole-database size in the schema browser comes from
pragma_page_count * pragma_page_size; row counts are SELECT COUNT(*) per
table, which is the only count SQLite offers.
The maintenance side matches: vacuum, analyze, reindex and check are the
declared operations, check being PRAGMA integrity_check again. VACUUM
rewrites the entire file, so it is declared perEntity: false with the label
Vacuum Database and POST /api/db/maintenance answers 400 for
{type:"vacuum", target:"users"}. The toolkit is admin-only.
The counters that live behind an API no driver exposes
Here is the flat version. There is no cache-hit ratio, no queries-per-second,
no buffer-pool usage, no slow queries and no per-index size on SQLite. Only
deadlocks: 0 is reported, which is a fact about the engine rather than a
measurement of this database.
The cache counters are not missing for want of a better query. SQLite’s page
cache hit and miss figures live behind the C API - sqlite3_db_status() with
SQLITE_DBSTATUS_CACHE_HIT and SQLITE_DBSTATUS_CACHE_MISS - and neither
driver exposes a status call at all. Walking a live handle’s prototype chain on
2026-08-23 gave the surface of each:
| Driver | Status call reachable? |
|---|---|
bun:sqlite (Bun 1.3.14, SQLite 3.53.0) |
none |
node:sqlite (Node 24.14.0, SQLite 3.51.2) |
none |
Nothing SQL-reachable stands in either, and the near misses are worse than nothing:
PRAGMA cache_size; -- -2000: the configured page budget in KiB, not a hit count
PRAGMA cache_hit; -- [] : not a pragma at all
PRAGMA cache_miss; -- [] : same
PRAGMA stats; -- []
SQLite answers an unknown pragma with zero rows rather than an error, so
PRAGMA cache_hit returns something that looks exactly like an empty reading
from a real counter. That is the trap. The field is omitted permanently, not
pending a better query, and getHealth() says so in its own string field:
cacheHitRatio is N/A.
Slow queries are the same shape of absence. getSlowQueries() returns []
unconditionally, and the empty state is overridden away from PostgreSQL’s
pg_stat_statements advice to the sentence that is actually true here: SQLite
keeps no statistics about finished statements, so there is nothing to enable.
Index scans is always 0 for the same reason - there is no usage counter to
read.
Per-table size, and the driver it depends on
SQLite has no catalog column for a table’s size. The only source is dbstat, a
virtual table reporting one row per b-tree page group, and it sits behind the
compile-time SQLITE_ENABLE_DBSTAT_VTAB option that the two built-in drivers do
not agree on. Measured 2026-08-24 against the same seeded database - 200 rows of
4 KB text in big with an index on it, 200 short rows in small, file
1,761,280 B:
| Driver | SELECT name, SUM(pgsize) FROM dbstat GROUP BY name |
|---|---|
bun:sqlite |
no such table: dbstat |
node:sqlite |
big 823296, idx_big 929792, small 4096 |
So the same connection reports different per-table sizes depending on which
driver is under it, and LIBREDB_SQLITE_DRIVER=bun|node is what moves between
them. Any size claim has to name the driver it was measured with.
In practice the packaged deployments are on the measuring side. Every
distribution channel - the Docker image, npx @libredb/studio, the Homebrew
tap, the .deb and .rpm packages and the standalone tarballs - runs the built
app with node server.js, so they all use node:sqlite and all have dbstat.
bun:sqlite is what local development (bun dev) and the test suite run on;
under it the byte fields are omitted rather than estimated, and the Storage tab
shows N/A for the Tables and Indexes cards.
Per-index size is absent under both. indexSize is N/A and indexSizeBytes
is omitted, because SQLite publishes no per-index figure - under node:sqlite
an index’s pages are added to its table’s index bytes instead, implicit
sqlite_autoindex_* ones included, which is what the Storage tab’s index total
is built from.
Why a missing panel beats a populated wrong one
Both of those absences were once numbers.
Through 0.13.1 this provider reported a cache-hit ratio of 95 whenever
PRAGMA cache_size came back truthy - which it always does - and 99
otherwise. The Performance panel then rated that figure Excellent. Nobody had
measured anything. The number was the provider’s, not SQLite’s, and it read as
an engine reading because it was sitting in a panel labelled like one.
Through 0.13.3 per-table size was rowCount * 100, documented in the code as
“assume 100 bytes average per row”, and the Storage tab summed it into the Data
figure it drew beside the measured file size. On the database in the table
above, that estimate answered 20,000 B for both tables: 40 times under for
big, whose pages are 804 KB, and 5 times over for small, which is 4 KB. A
guess presented as a measurement, added to a real one.
A 0 would have been the same fabrication in a different digit, which is why
the byte fields are absent rather than zero, and why every consumer gates on the
absent tableSizeBytes instead of reading a placeholder.
The rule that came out of it governs the rest of this dashboard, and it is the same rule every entry in the feature list follows: a control that cannot answer is absent, with the reason written where it would have been. An empty panel is indistinguishable from a broken one, so the absence carries a sentence. The SQLite engine page publishes that sentence before anyone connects: there is no server to monitor, and health reads file size and pragma statistics only.