Skip to content

fix(db): tolerate a SQLite build without the dbstat virtual table - #8

Merged
TechNickAI merged 5 commits into
release/v3.8.50from
nick/db-pragma-settings
Aug 4, 2026
Merged

TechNickAI merged 5 commits into
release/v3.8.50from
nick/db-pragma-settings

Conversation

@TechNickAI

Copy link
Copy Markdown
Owner

The bug

getDatabaseStats() queried the dbstat virtual table once per table with no guard:

const tableSize = db
  .prepare(`SELECT SUM(pgsize) as size FROM dbstat WHERE name = ?`)
  .get(table.name) as { size: number | null };

dbstat is compile-time optional in SQLite (ENABLE_DBSTAT_VTAB). It is not a schema table, is never created by a migration, and does not appear in sqlite_master. Builds without it — sql.js/WASM among them — reject the query outright.

openSqliteDatabase() (src/lib/db/core.ts:178) falls back to a sql.js WASM adapter when no sync native driver is available, so this is reachable in normal operation.

Impact

The throw propagates out of getDatabaseStats() and takes every caller with it. Most visibly, GET and PATCH /api/settings/database return HTTP 500:

Error getting database settings: Error: no such table: dbstat
    at src/lib/db/stats.ts

The entire database settings page becomes unusable — page size, cache size, and vacuum settings can be neither read nor changed. Observed in production on a live router.

The fix

The function already anticipated missing virtual-table modules: the COUNT(*) lookup a few lines above swallows "no such module:" errors. The dbstat query simply sat outside that guard.

Probe dbstat once per call and skip the per-table size lookups when unavailable, reporting size: 0. Database-level figures (total size, page count, cache size) come from pragmas and stay accurate — only per-table byte sizes are lost, which is the right trade against a hard 500.

Unrelated failures (I/O errors, corruption) still propagate.

Both spellings are handled, and the match is deliberately unanchored because drivers prefix their error class onto the message (SqliteError: no such table: dbstat, RuntimeError: ...). A test pins this so the guard cannot silently regress if anchored later.

Tests

tests/unit/db/stats-dbstat-optional.test.ts — 8 cases against a SqliteAdapter double:

  • dbstat available → real per-table sizes
  • no such module: dbstat → degrades to 0
  • no such table: dbstat → degrades to 0
  • prefixed (SqliteError: / RuntimeError:) → degrades to 0
  • database with no user tables
  • SUM(pgsize) returning NULL → 0
  • dbstat failing after a successful probe → propagates (a mid-iteration fault is a real fault, not an absent module)
  • database disk image is malformed → propagates

Falsified, not assumed: reverting the guard makes exactly 2 of these fail with the pre-fix 500 path. Introducing a ^ anchor makes exactly the prefixed-error case fail.

Verification

tests/unit/db/**       110 pass, 0 fail
tests/unit/api/**      215 pass, 0 fail
npm run lint           PASS (whole repo)
typecheck:core         0 errors
prettier --check       clean
check:db-rules         PASS
check:cycles           PASS
check:file-size        PASS
check:test-discovery   PASS
check:any-budget:t11   PASS

Notes for review

  • Behaviour change: callers now see size: 0 for every table on a dbstat-less build instead of an exception. No caller was found that depends on size being truthy.
  • The probe is per-getDatabaseStats() call rather than cached on the adapter. Caching capability at connection setup would be tidier; that felt like a larger change than this fix warrants, and I'd happily follow up if preferred.
  • Based on 2fc1229fe (v3.8.50), the commit currently deployed in our production environment.

Nick Sullivan added 3 commits August 3, 2026 17:43
getDatabaseStats() queried `dbstat` once per table with no guard. `dbstat` is
compile-time optional (ENABLE_DBSTAT_VTAB) and is absent from sql.js/WASM
builds, so on those runtimes the query throws and the error propagates out of
getDatabaseStats().

Every caller dies with it. Most visibly, GET and PATCH /api/settings/database
return HTTP 500, which makes the entire database settings page unusable — users
cannot read or change page size, cache size, or vacuum settings.

The function already anticipated missing virtual-table modules: the COUNT(*)
lookup a few lines above swallows "no such module:" errors. The dbstat query
simply sat outside that guard.

Probe dbstat once per call and skip the per-table size lookups when it is
unavailable, reporting size 0. Database-level figures (total size, page count,
cache size) come from pragmas and stay accurate; only per-table byte sizes are
lost, which is the correct trade against a hard 500.

Unrelated failures (I/O errors, corruption) still propagate.

Both spellings are handled: sql.js reports "no such module: dbstat" while
better-sqlite3 can surface "no such table: dbstat".
Review follow-up on the previous commit.

The guard is deliberately unanchored because real drivers stringify errors
with their class name attached ("SqliteError: no such table: dbstat",
"RuntimeError: ..."). Nothing pinned that, so anchoring the regex would have
passed the suite while silently breaking every real driver. Add a case for the
prefixed form; it fails if a caret is introduced.

Also cover three shapes the fake previously could not express:
- a database with no user tables, which is what a fresh install hits first
- SUM(pgsize) returning NULL for a table occupying no pages
- dbstat answering the probe but failing on a later table, which documents
  that a mid-iteration fault still propagates rather than being mistaken for
  an absent module

Correct the source comment: the two error spellings track the SQLite build,
not the driver package, so the earlier attribution to better-sqlite3 was
wrong.
c317b6c moved this workflow to omniroute-ops, which builds a container image
and is not yet the deployment path. The router still runs an unpacked
standalone bundle under releases/, so without this workflow there is no way to
produce a shippable artifact without building on the live host — which caused a
~30 minute fleet outage on 2026-07-26.

Restored verbatim from 2fc1229, the commit that produced the currently
running release, with the bundle assertions extended to cover what this deploy
actually ships: the dbstat guard and the Claude entrypoint override. Both use
grep -F so the regex literal is matched as text.

This can be dropped again once the container path is proven.
@TechNickAI
TechNickAI force-pushed the nick/db-pragma-settings branch from 9e8ecbd to 3761736 Compare August 3, 2026 22:55
@TechNickAI
TechNickAI merged commit 8129321 into release/v3.8.50 Aug 4, 2026
8 of 14 checks passed
@TechNickAI
TechNickAI deleted the nick/db-pragma-settings branch August 4, 2026 00:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant