on master help sign in
Show per-relation on-disk size in `db info`

Each source's header now includes its on-disk footprint, aggregated from SQLite's dbstat virtual table: *users 1.9 MB · 2000 rows · migration 0 *tags 8.0 KB · 3 rows · migration 0 A relation's footprint spans several physical objects — the main table, the child/link tables for nested fields (`_knot_users/…`), and its indexes (`_knot_users_…`). The aggregation attributes each object to its owning source by longest-prefix match against the *known* source names, so a source whose name contains `_` (e.g. `order_items` vs `orders`) groups correctly and never collides with a sibling. Internal `_knot_schema` is excluded. Falls back to omitting the size when dbstat isn't compiled in. Full workspace suite: 539 passed, 0 failed.

commitc9c7da7187cd312bf4a2a4b1829f89d619d01c03 authoriko <ilyakooo0@gmail.com> dateSat 5 Sep 2026, 22:53 (1 month ago) parentde77933
1 file changed+48−2
crates/knot-runtime/src/lib.rs+48−2
crates/knot-runtime/src/lib.rs+48−2
@@ -23667,18 +23667,64 @@ fn db_info(db_path: &str) {
2366723667 println!("{}", st.bold(&format!("{} source{}", sources.len(), if sources.len() == 1 { "" } else { "s" })));
2366823668 println!();
2366923669
23670+ // Per-relation on-disk footprint: every physical table/index belonging to a
23671+ // source, summed from dbstat. A source `users` owns `_knot_users` (main
23672+ // table), `_knot_users/…` (child/link tables for nested fields), and
23673+ // `_knot_users_…` (its indexes). We match against the *known* source names
23674+ // (longest prefix wins) so a source name containing `_`/`/` isn't split
23675+ // wrongly. Falls back to None when dbstat isn't compiled in.
23676+ let source_names: Vec<&str> = sources.iter().map(|(n, _, _)| n.as_str()).collect();
23677+ let relation_bytes: Option<std::collections::HashMap<String, u64>> = conn
23678+ .prepare("SELECT name, pgsize FROM dbstat")
23679+ .ok()
23680+ .map(|mut s| {
23681+ let mut map: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
23682+ let rows = s.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)));
23683+ if let Ok(rows) = rows {
23684+ for r in rows.flatten() {
23685+ let (tbl, bytes) = (r.0, r.1 as u64);
23686+ let Some(rest) = tbl.strip_prefix("_knot_") else { continue };
23687+ if rest == "schema" {
23688+ continue;
23689+ }
23690+ // Longest known source that is `rest` exactly, or a prefix
23691+ // followed by `_` (index) or `/` (child table).
23692+ let owner = source_names
23693+ .iter()
23694+ .filter(|src| {
23695+ let s: &str = src;
23696+ rest == s
23697+ || rest
23698+ .strip_prefix(s)
23699+ .is_some_and(|t| t.starts_with('_') || t.starts_with('/'))
23700+ })
23701+ .max_by_key(|src| src.len());
23702+ if let Some(owner) = owner {
23703+ *map.entry(owner.to_string()).or_insert(0) += bytes;
23704+ }
23705+ }
23706+ }
23707+ map
23708+ });
23709+
2367023710 for (name, schema, applied) in &sources {
2367123711 let table = quote_ident(&format!("_knot_{}", name));
2367223712 let rows: i64 = conn
2367323713 .query_row(&format!("SELECT count(*) FROM {}", table), [], |r| r.get(0))
2367423714 .unwrap_or(0);
2367523715
23676− // Source header: *name, row count, migration position.
23716+ // Source header: *name, size, row count, migration position.
23717+ let size_part = relation_bytes
23718+ .as_ref()
23719+ .and_then(|m| m.get(name.as_str()))
23720+ .map(|b| format!("{} · ", human_bytes(*b)))
23721+ .unwrap_or_default();
2367723722 println!(
2367823723 "{} {}",
2367923724 st.cyan(&format!("*{}", name)),
2368023725 st.dim(&format!(
23681− "{} row{} · migration {}",
23726+ "{}{} row{} · migration {}",
23727+ size_part,
2368223728 rows,
2368323729 if rows == 1 { "" } else { "s" },
2368423730 applied