Offline, zero-network, zero-credential health scorecard for captured Databricks Lakebase / Postgres diagnostic dumps.
lbinspect reads a directory of diagnostic files that you (or a capture script) exported from a Lakebase/Postgres instance, analyzes seven operational domains — indexing, bloat, connection pools, autoscaling, query plans, cost, and zero-copy branching — and prints a single weighted health score (0–100) with per-domain sub-scores, ranked findings, and remediation SQL.
Everything runs locally against files on disk. lbinspect never connects to a database, calls a network service, or reads a credential. The only inputs are the dump directory and the flags you pass. It has zero third-party dependencies — pure Node.js (>= 20) built-ins.
lbinspect is a plain ES-module Node project with no dependencies.
# from a checkout
node src/cli.js <dump-dir>
# or link it as a global `lbinspect` command
npm link
lbinspect <dump-dir>package.json declares the binary:
"bin": { "lbinspect": "src/cli.js" }lbinspect <dump-dir> [options]
| Option | Meaning |
|---|---|
--format <fmt> |
Output format: terminal (default), json, markdown, html, sql, junit. |
--fail-under <N> |
Exit non-zero when the health score is below N (0–100). The report still prints to stdout. For CI gates. |
-h, --help |
Show help and exit. |
| Code | Meaning |
|---|---|
0 |
Success — and, when --fail-under is given, the score met the threshold. |
1 |
The health score is below --fail-under N. The report is still printed. |
2 |
Bad arguments: missing/extra positional, unreadable dump dir, or invalid --format. |
The report is always written to stdout; diagnostics and gate messages go to
stderr. The terminal format honors NO_COLOR —
set NO_COLOR=1 for plain, ANSI-free output (also nice for piping to a file).
# Print the terminal report (default)
lbinspect ./sample-dump
# Full-screen CLI monitor: box-framed panels, gauges, meters, severity histogram
lbinspect ./sample-dump --format dashboard
# Live monitor — redraw the dashboard every 2s, re-reading the dump each tick
lbinspect ./sample-dump --watch
lbinspect ./sample-dump --watch --interval 5
# Interactive explorer — arrow keys to browse findings, drill into detail
lbinspect ./sample-dump -i
# ↑↓ / j k move PgUp/PgDn page Home/End jump
# / filter c copy remediation SQL q quit
# (falls back to the static dashboard when output isn't a TTY)
# Machine-readable output for a dashboard or diff
lbinspect ./sample-dump --format json > report.json
# Self-contained, light-mode HTML dashboard (no external assets)
lbinspect ./sample-dump --format html > report.html
# Just the remediation SQL, ready to review and run
lbinspect ./sample-dump --format sql
# CI gate: fail the build if health drops below 80
lbinspect ./sample-dump --fail-under 80 --format junitYou can generate a realistic sample dump to try it against — the fixture generator writes the exact six-format layout below:
node src/fixtures/generate.js --dir ./sample-dump --seed 42
lbinspect ./sample-dumplbinspect report
Generated at 2026-07-22T14:14:10.548Z
Overall score: 42.2/100
Sub-scores
Indexing ████████████░░░░░░░░ 62
Bloat ░░░░░░░░░░░░░░░░░░░░ 0
Connections ███████░░░░░░░░░░░░░ 34
Autoscaling █████████████████░░░ 84
Query Plans ░░░░░░░░░░░░░░░░░░░░ 0
Cost ████████████████░░░░ 78
Branching ███████████████░░░░░ 75
Rationale
Overall health score 42.2/100.
Lowest domains: bloat 0/100 (5 finding(s)); queryPlans 0/100 (8 finding(s)); ...
Most urgent: [critical] Table bloat 66.5% on public.order_items ...
Cross-domain correlations:
• Autoscaler flapping coincides with connection-pool saturation: ...
The dashboard format (and --watch) renders a full-screen, box-framed monitor.
Panels lay out in two columns on wide terminals and stack on narrow ones; with
color on, gauges and bars are banded green/amber/red and findings are tinted by
severity.
╭ Overview ──────────────────────────────────────────────────────────────────╮
│lbinspect · Lakebase / Postgres health monitor │
│instance: lbinspect-fixture-instance pg: 16.4 (Lakebase) │
╰──────────────────────────────────────────────────────────────────────────────╯
╭ Health ─────────────────────────╮ ╭ Domains ──────────────────────────────╮
│ 40.2/100 │ │Indexing ███████████████░░░░░░░ 62 │
│ █████████████░░░░░░░░░░░░░░░░░░ │ │Bloat ░░░░░░░░░░░░░░░░░░░░░░ 0 │
│ AT RISK │ │Connections ████████░░░░░░░░░░░░░░ 26 │
╰─────────────────────────────────╯ │Autoscaling █████████████████████ 84 │
╭ Severity ───────────────────────╮ │ … │
│critical ████░░░░░░░░░░░░░░░ 3 │ ╰───────────────────────────────────────╯
│high ████████░░░░░░░░░░░ 7 │ ╭ Cost / reclaimable ───────────────────╮
│ … │ │• ~76.3 GiB reclaimable across 65 idx │
╰─────────────────────────────────╯ ╰───────────────────────────────────────╯
╭ Findings (64) ───────────────────────────────────────────────────────────────╮
│[CRITICAL] public.order_items — Table bloat 64.7% on public.order_items │
│ … +51 more finding(s) │
╰──────────────────────────────────────────────────────────────────────────────╯
SCORE 40.2/100 CRITICAL:3 HIGH:7 MEDIUM:18 LOW:10 INFO:26
A dump directory holds these files. JSON/JSONL files use lbinspect's own capture
format and carry the exact camelCase field names; TSV files mirror native
Postgres pg_stat_* column names (snake_case), because that is what a real
\copy … TO capture produces. Missing or empty files degrade gracefully (that
domain simply contributes no data), so a partial dump still scores.
<dump-dir>/
meta.json SnapshotMeta JSON object (camelCase)
stat_tables.tsv StatTable[] (per table) TSV + header (snake_case cols)
stat_indexes.tsv IndexStat[] (per index) TSV + header (snake_case cols)
storage.tsv StorageStat[] TSV + header (snake_case cols)
pool.json Pool[] JSON array (camelCase)
autoscale.jsonl AutoscaleEvent[] JSONL, 1/line (camelCase)
branches.json BranchTree[] (flat) JSON array (camelCase)
plans/
plan-01.json Plan JSON object (camelCase + raw)
plan-02.json … one file per plan, zero-padded 2-digit index, sorted.
Six formats: TSV (stat_tables, stat_indexes, storage), JSON array
(pool, branches), JSONL (autoscale), per-file JSON (plans/), plus
meta.json.
Tab-separated, first line header, nulls as \N. Columns (TSV → core field):
schema→schema, table→table, relid→relid, seq_scan→seqScan,
seq_tup_read→seqTupRead, idx_scan→idxScan, idx_tup_fetch→idxTupFetch,
n_live_tup→nLiveTup, n_dead_tup→nDeadTup, n_tup_ins/upd/del→nTupIns/Upd/Del,
last_autovacuum→lastAutovacuum, last_analyze→lastAnalyze. Indexes are not
inline — they live in stat_indexes.tsv and are joined by (schema, table).
schema, table (join keys), index_name→name, idx_scan→idxScan,
idx_tup_read→idxTupRead, idx_tup_fetch→idxTupFetch, size_bytes→sizeBytes,
is_unique→isUnique (t/f), is_primary→isPrimary (t/f).
schema, table, size_bytes→sizeBytes, table_bytes→tableBytes,
index_bytes→indexBytes, bloat_bytes→bloatBytes, bloat_pct→bloatPct (0–100),
branch_ref→branchRef (a branchId, or \N → null). Root-branch rows use
schema=public; child-branch rows use schema=<branchId> so every relation key
is globally unique.
JSON array of camelCase pool objects:
[
{ "name": "lakebase-primary-rw", "maxConnections": 100, "activeConnections": 100,
"idleConnections": 0, "waitingClients": 13, "totalConnections": 100 }
]One compact JSON object per line, chronological, camelCase:
{"timestamp":"2026-06-01T11:05:00.000Z","epochMs":1780311900000,"fromCapacity":2,"toCapacity":4,"direction":"up","reason":"cpu_high"}JSON array of camelCase branch nodes; exactly one root (parent: null,
depth: 0). children, depth, and storageRefs (relation keys present in
storage.tsv) are pre-derived and internally consistent:
{
"branchId": "br_001", "name": "feature/deep-1", "parent": "br_000",
"createdAt": "2026-03-11T12:00:00.000Z", "children": ["br_002"],
"sizeBytes": 662700032, "uniqueBytes": 7340032, "depth": 1,
"storageRefs": ["br_001.payments", "br_001.order_items"]
}{
"capturedAt": "2026-06-01T12:00:00.000Z",
"pgVersion": "16.4 (Lakebase)",
"instanceName": "lbinspect-fixture-instance"
}dumpDir is not written to disk — lbinspect stamps it at load time, since it
knows where it read from.
Each file is a single camelCase Plan with flattened top-of-tree fields plus a
raw array holding the real EXPLAIN (FORMAT JSON) output. statTableRef is
the relation key of the scanned StatTable, or null.
{
"id": "q_events_scan",
"query": "SELECT * FROM public.events WHERE payload->>'type' = $1 ORDER BY ts DESC",
"nodeType": "Seq Scan", "relationName": "events",
"planRows": 723, "actualRows": 23859,
"totalCost": 1614392, "actualTotalTimeMs": 4647, "executionTimeMs": 4647.538,
"statTableRef": "public.events",
"raw": [ { "Plan": { "Node Type": "Seq Scan", "Relation Name": "events", "...": "..." } } ]
}The score is a weighted mean of seven per-domain sub-scores, each derived by subtracting severity penalties from a perfect 100.
1. Findings → domains. Seven pure analyzers each read the assembled snapshot
and emit Findings at a severity (info / low / medium / high /
critical). Every finding is bucketed into exactly one scoring domain:
| Domain | Analyzer | What it flags |
|---|---|---|
indexing |
index-advisor | unused / duplicate / missing indexes |
bloat |
bloat | table bloat %, high dead-tuple ratios |
connections |
connection-pool | pool saturation, waiting clients, suspected leaks |
autoscaling |
autoscaling | flapping, scale-to-zero churn, under-provisioning |
queryPlans |
query-plan | seq-scan pressure, row-estimate skew, expensive/slow plans |
cost |
cost | wasted storage/index spend, expensive compute, amplification |
branching |
zero-copy-branch | branch sprawl, deep nesting, stale leaves, storage amplification |
2. Per-domain sub-score. Start at 100, subtract the penalty for each
finding in that domain, then clamp to [0, 100] and round to 1 decimal place.
Penalties per finding:
| Severity | info |
low |
medium |
high |
critical |
|---|---|---|---|---|---|
| Penalty | 0 | 3 | 8 | 18 | 40 |
So a domain with one critical and one medium finding scores 100 − 40 − 8 = 52; enough severe findings drive a domain to 0.
3. Weighted aggregation. The overall score is the weighted mean of the
sub-scores, over only the domains that actually have input data, normalized
by the sum of those domains' weights. A domain with no relevant data is excluded
entirely — an autoscale-only dump is not handed six free 100s. If no domain has
data at all, the score is 100. Domain weights:
| Domain | indexing | bloat | connections | autoscaling | queryPlans | cost | branching |
|---|---|---|---|---|---|---|---|
| Weight | 1.0 | 1.2 | 1.3 | 1.0 | 1.4 | 1.1 | 0.6 |
Query plans and connections weigh heaviest (they hit live latency); branching weighs least (hygiene, not an outage).
4. Cross-domain correlations. The rationale surfaces correlated problems that share a root cause and should be fixed together, e.g.:
- Bloat + seq-scan on the same table — wasted pages inflate every seq scan.
- Autoscale flapping + pool saturation — scale events reset warm connections while the pool is already full.
- Scale-to-zero churn + waiting clients — cold starts add reconnection latency to queued requests.
- Missing/unused index + seq-scan-heavy tables — indexing debt visible in the plans.
- Branch sprawl + storage-amplification spend — branching hygiene compounding future storage cost.
These are reported for context; they do not double-count against the score.
Pure, offline, dependency-free ES modules. One shared core module
(src/core/index.js) defines every shape, threshold, and factory; everything
else imports it directly (no barrels, no registries):
dump dir ─▶ src/snapshot.js (assembleSnapshot: runs the six loaders)
─▶ src/scorecard.js (scoreSnapshot: runs the seven analyzers → Report)
─▶ src/exporters/* (one pure Report → string per --format)
─▶ stdout
The CLI (src/cli.js) owns all wiring and flag parsing (via node:util
parseArgs); each stage is a pure function tested in isolation, plus one
end-to-end test (test/e2e.test.js) that drives the built CLI as a child
process over a generated dump.
Run the full suite offline:
node --test