use std::io::Read;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use rusqlite::{Connection, OpenFlags};
const BUSY_TIMEOUT: Duration = Duration::from_secs(2);
const DIGEST_HEX: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct KeyedRow {
pub key: String,
pub at: String,
pub digest: String,
}
impl KeyedRow {
fn new(key: String, at: String, content: &[&str]) -> Self {
let material = content.join("\u{0}");
let mut digest = crate::entities::chat_file::sha256_hex(material.as_bytes());
digest.truncate(DIGEST_HEX);
Self { key, at, digest }
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DbStats {
pub notes: u64,
pub notes_superseded: u64,
pub note_links: u64,
pub last_note_change: Option<DateTime<Utc>>,
pub rag_sources: u64,
pub rag_chunks: u64,
pub self_models: u64,
#[serde(default)]
pub note_list: Vec<KeyedRow>,
#[serde(default)]
pub source_list: Vec<KeyedRow>,
#[serde(default)]
pub self_model_list: Vec<KeyedRow>,
}
pub fn stats_of_file(path: &Path) -> Result<DbStats> {
if !super::is_sqlite_file(path) {
bail!("{} is not a SQLite database", path.display());
}
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.with_context(|| format!("opening {} read-only", path.display()))?;
conn.busy_timeout(BUSY_TIMEOUT)?;
collect(&conn)
}
pub fn stats_of_image(image: impl Read, size: usize) -> Result<DbStats> {
let mut conn = Connection::open_in_memory()?;
conn.deserialize_read_exact(rusqlite::MAIN_DB, image, size, true)
.context("loading the database image")?;
collect(&conn)
}
fn collect(conn: &Connection) -> Result<DbStats> {
Ok(DbStats {
notes: count(conn, "notes", "SELECT COUNT(*) FROM notes")?,
notes_superseded: if has_table(conn, "notes")? {
count(
conn,
"note_superseded",
"SELECT COUNT(*) FROM note_superseded s JOIN notes n ON n.id = s.note_id",
)?
} else {
0
},
note_links: count(conn, "note_links", "SELECT COUNT(*) FROM note_links")?,
last_note_change: last_note_change(conn)?,
rag_sources: count(conn, "rag_sources", "SELECT COUNT(*) FROM rag_sources")?,
rag_chunks: count(conn, "rag_documents", "SELECT COUNT(*) FROM rag_documents")?,
self_models: count(conn, "self_models", "SELECT COUNT(*) FROM self_models")?,
note_list: note_rows(conn)?,
source_list: rows(
conn,
"rag_sources",
"SELECT profile_id || '/' || source, created_at, content, '', ''
FROM rag_sources ORDER BY 1",
)?,
self_model_list: rows(
conn,
"self_models",
"SELECT profile_id, updated_at, data, '', '' FROM self_models ORDER BY 1",
)?,
})
}
fn note_rows(conn: &Connection) -> Result<Vec<KeyedRow>> {
let sql = if has_table(conn, "note_superseded")? {
"SELECT n.id, MAX(n.updated_at, COALESCE(s.superseded_at, '')), n.content, n.tags,
COALESCE(s.superseded_by, '')
FROM notes n LEFT JOIN note_superseded s ON s.note_id = n.id ORDER BY 1"
} else {
"SELECT id, updated_at, content, tags, '' FROM notes ORDER BY 1"
};
rows(conn, "notes", sql)
}
fn rows(conn: &Connection, table: &str, sql: &str) -> Result<Vec<KeyedRow>> {
if !has_table(conn, table)? {
return Ok(Vec::new());
}
let mut statement = conn.prepare(sql)?;
let listed = statement
.query_map([], |row| {
let content: [String; 3] = [row.get(2)?, row.get(3)?, row.get(4)?];
Ok(KeyedRow::new(
row.get(0)?,
row.get(1)?,
&[&content[0], &content[1], &content[2]],
))
})?
.collect::<rusqlite::Result<Vec<_>>>()
.with_context(|| format!("listing {table}"))?;
Ok(listed)
}
fn has_table(conn: &Connection, table: &str) -> Result<bool> {
let found: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|row| row.get(0),
)?;
Ok(found > 0)
}
fn count(conn: &Connection, table: &str, sql: &str) -> Result<u64> {
if !has_table(conn, table)? {
return Ok(0);
}
let n: i64 = conn
.query_row(sql, [], |row| row.get(0))
.with_context(|| format!("counting {table}"))?;
Ok(u64::try_from(n).unwrap_or(0))
}
fn last_note_change(conn: &Connection) -> Result<Option<DateTime<Utc>>> {
if !has_table(conn, "notes")? {
return Ok(None);
}
let newest: Option<String> =
conn.query_row("SELECT MAX(updated_at) FROM notes", [], |row| row.get(0))?;
Ok(newest
.and_then(|text| DateTime::parse_from_rfc3339(&text).ok())
.map(|at| at.with_timezone(&Utc)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn seeded() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.db");
{
let conn = Connection::open(&path).unwrap();
super::super::baseline_ddl(&conn).unwrap();
conn.execute_batch(
"INSERT INTO notes VALUES
('n1','p','old','[]','2026-01-01T00:00:00+00:00','2026-01-02T00:00:00+00:00'),
('n2','p','new','[]','2026-01-01T00:00:00+00:00','2026-03-05T10:20:30.5+00:00'),
('n3','q','other','[]','2026-01-01T00:00:00+00:00','2026-02-01T00:00:00+00:00');
INSERT INTO note_superseded VALUES
('n1','p','n2','2026-03-05T10:20:30+00:00'),
('gone','p','n2','2026-03-05T10:20:30+00:00');
INSERT INTO note_links VALUES ('p','n2','n3','relates','2026-03-05T10:20:30+00:00');
INSERT INTO rag_sources VALUES ('p','a.txt','text','2026-01-01T00:00:00+00:00');
INSERT INTO rag_documents (id, profile_id, source, chunk_text, created_at) VALUES
('d1','p','a.txt','one','2026-01-01T00:00:00+00:00'),
('d2','p','a.txt','two','2026-01-01T00:00:00+00:00');
INSERT INTO self_models VALUES ('p','{}',1,'2026-01-01T00:00:00+00:00');",
)
.unwrap();
}
(dir, path)
}
fn expected() -> DbStats {
DbStats {
notes: 3,
notes_superseded: 1,
note_links: 1,
last_note_change: Some("2026-03-05T10:20:30.5Z".parse().unwrap()),
rag_sources: 1,
rag_chunks: 2,
self_models: 1,
note_list: vec![
KeyedRow::new(
"n1".into(),
"2026-03-05T10:20:30+00:00".into(),
&["old", "[]", "n2"],
),
KeyedRow::new(
"n2".into(),
"2026-03-05T10:20:30.5+00:00".into(),
&["new", "[]", ""],
),
KeyedRow::new(
"n3".into(),
"2026-02-01T00:00:00+00:00".into(),
&["other", "[]", ""],
),
],
source_list: vec![KeyedRow::new(
"p/a.txt".into(),
"2026-01-01T00:00:00+00:00".into(),
&["text", "", ""],
)],
self_model_list: vec![KeyedRow::new(
"p".into(),
"2026-01-01T00:00:00+00:00".into(),
&["{}", "", ""],
)],
}
}
#[test]
fn a_digest_tells_fields_apart_and_sees_a_supersession() {
let row = |content: &[&str]| KeyedRow::new("k".into(), "t".into(), content).digest;
assert_ne!(row(&["ab", "c", ""]), row(&["a", "bc", ""]));
assert_ne!(row(&["old", "[]", ""]), row(&["old", "[]", "n2"]));
assert_eq!(row(&["old", "[]", ""]).len(), DIGEST_HEX);
}
fn listing(dir: &Path) -> Vec<(String, u64)> {
let mut out: Vec<_> = fs::read_dir(dir)
.unwrap()
.map(|e| {
let e = e.unwrap();
(
e.file_name().to_string_lossy().into_owned(),
e.metadata().unwrap().len(),
)
})
.collect();
out.sort();
out
}
#[test]
fn a_file_is_counted_and_left_as_it_was() {
let (dir, path) = seeded();
let before = (listing(dir.path()), fs::read(&path).unwrap());
assert_eq!(stats_of_file(&path).unwrap(), expected());
assert_eq!((listing(dir.path()), fs::read(&path).unwrap()), before);
}
#[test]
fn an_image_counts_the_same_as_the_file_it_was_read_from() {
let (_dir, path) = seeded();
let bytes = fs::read(&path).unwrap();
assert_eq!(
stats_of_image(bytes.as_slice(), bytes.len()).unwrap(),
expected()
);
}
#[test]
fn a_non_database_is_refused_before_sqlite_can_touch_its_directory() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.db");
fs::write(&path, b"").unwrap();
fs::write(dir.path().join("data.db-wal"), b"stale").unwrap();
let before = listing(dir.path());
assert!(stats_of_file(&path).is_err());
assert_eq!(listing(dir.path()), before);
}
#[test]
fn a_garbage_image_is_an_error_not_a_row_of_zeros() {
let junk = vec![0x5a_u8; 4096];
assert!(stats_of_image(junk.as_slice(), junk.len()).is_err());
}
#[test]
fn tables_the_database_predates_count_as_zero() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.db");
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE rag_sources (profile_id TEXT, source TEXT, content TEXT, created_at TEXT);
INSERT INTO rag_sources VALUES ('p','a','b','c');
CREATE TABLE note_superseded (note_id TEXT, profile_id TEXT,
superseded_by TEXT, superseded_at TEXT);
INSERT INTO note_superseded VALUES ('x','p','y','z');",
)
.unwrap();
}
assert_eq!(
stats_of_file(&path).unwrap(),
DbStats {
rag_sources: 1,
source_list: vec![KeyedRow::new("p/a".into(), "c".into(), &["b", "", ""])],
..DbStats::default()
}
);
}
}