db: add is_expired column and implement vacuum/stats commands

Migrates schema to v5; `is_expired` column is added with partial index
and `include_expired` parameter to `list_entries()` and `list_json()`
methods. Also adds `vacuum()` and `stats()` methods for SQlite
"administration", and removes `next_sequence()` from trait and impl.

This has been a valuable addition to stash, as the database is now *less
abstract* in the sense that user is made aware of its presence (stash
wipe -> stash db wipe) and can modify it.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icfab67753d7f18e3798c0a930b16d05e6a6a6964
This commit is contained in:
raf 2026-01-22 16:07:30 +03:00
commit d40b547c07
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -80,6 +80,7 @@ pub trait ClipboardDb {
&self,
out: impl Write,
preview_width: u32,
include_expired: bool,
) -> Result<usize, StashError>;
fn decode_entry(
&self,
@ -93,7 +94,6 @@ pub trait ClipboardDb {
&self,
id: i64,
) -> Result<(i64, Vec<u8>, Option<String>), StashError>;
fn next_sequence(&self) -> i64;
}
#[derive(Serialize, Deserialize)]
@ -296,6 +296,49 @@ impl SqliteClipboardDb {
})?;
}
// Add is_expired column if it doesn't exist (v5)
if schema_version < 5 {
let has_is_expired: bool = tx
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND \
name='clipboard'",
[],
|row| {
let sql: String = row.get(0)?;
Ok(sql.to_lowercase().contains("is_expired"))
},
)
.unwrap_or(false);
if !has_is_expired {
tx.execute(
"ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0",
[],
)
.map_err(|e| {
StashError::Store(
format!("Failed to add is_expired column: {e}").into(),
)
})?;
}
// Create index for is_expired (for filtering)
tx.execute(
"CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \
WHERE is_expired = 1",
[],
)
.map_err(|e| {
StashError::Store(
format!("Failed to create is_expired index: {e}").into(),
)
})?;
tx.execute("PRAGMA user_version = 5", []).map_err(|e| {
StashError::Store(format!("Failed to set schema version: {e}").into())
})?;
}
tx.commit().map_err(|e| {
StashError::Store(
format!("Failed to commit migration transaction: {e}").into(),
@ -311,13 +354,17 @@ impl SqliteClipboardDb {
}
impl SqliteClipboardDb {
pub fn list_json(&self) -> Result<String, StashError> {
pub fn list_json(&self, include_expired: bool) -> Result<String, StashError> {
let query = if include_expired {
"SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) DESC, id DESC"
} else {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC"
};
let mut stmt = self
.conn
.prepare(
"SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) DESC, id DESC",
)
.prepare(query)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt
.query([])
@ -526,13 +573,18 @@ impl ClipboardDb for SqliteClipboardDb {
&self,
mut out: impl Write,
preview_width: u32,
include_expired: bool,
) -> Result<usize, StashError> {
let query = if include_expired {
"SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) DESC, id DESC"
} else {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC"
};
let mut stmt = self
.conn
.prepare(
"SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) DESC, id DESC",
)
.prepare(query)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt
.query([])
@ -680,17 +732,6 @@ impl ClipboardDb for SqliteClipboardDb {
Ok((id, contents, mime))
}
fn next_sequence(&self) -> i64 {
match self
.conn
.query_row("SELECT MAX(id) FROM clipboard", [], |row| {
row.get::<_, Option<i64>>(0)
}) {
Ok(Some(max_id)) => max_id + 1,
Ok(None) | Err(_) => 1,
}
}
}
impl SqliteClipboardDb {
@ -715,40 +756,6 @@ impl SqliteClipboardDb {
.map_err(|e| StashError::Trim(e.to_string().into()))
}
/// Get expired entries (for --expired flag diagnostic output)
pub fn get_expired_entries(
&self,
) -> Result<Vec<(i64, Vec<u8>, Option<String>)>, StashError> {
let now = Self::now();
let mut stmt = self
.conn
.prepare(
"SELECT id, contents, mime FROM clipboard WHERE expires_at IS NOT \
NULL AND expires_at <= ?1 ORDER BY expires_at ASC",
)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt
.query([now])
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut entries = Vec::new();
while let Some(row) = rows
.next()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
let id: i64 = row
.get(0)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let contents: Vec<u8> = row
.get(1)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mime: Option<String> = row
.get(2)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
entries.push((id, contents, mime));
}
Ok(entries)
}
/// Get the earliest expiration (timestamp, id) for heap initialization
pub fn get_next_expiration(&self) -> Result<Option<(f64, i64)>, StashError> {
match self.conn.query_row(
@ -778,6 +785,66 @@ impl SqliteClipboardDb {
.map_err(|e| StashError::Store(e.to_string().into()))?;
Ok(())
}
/// Optimize database using VACUUM
pub fn vacuum(&self) -> Result<(), StashError> {
self
.conn
.execute("VACUUM", [])
.map_err(|e| StashError::Store(e.to_string().into()))?;
Ok(())
}
/// Get database statistics
pub fn stats(&self) -> Result<String, StashError> {
let total: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let expired: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM clipboard WHERE is_expired = 1",
[],
|row| row.get(0),
)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let active = total - expired;
let with_expiration: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM clipboard WHERE expires_at IS NOT NULL AND \
(is_expired IS NULL OR is_expired = 0)",
[],
|row| row.get(0),
)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
// Get database file size
let page_count: i64 = self
.conn
.query_row("PRAGMA page_count", [], |row| row.get(0))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let page_size: i64 = self
.conn
.query_row("PRAGMA page_size", [], |row| row.get(0))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let size_bytes = page_count * page_size;
let size_mb = size_bytes as f64 / 1024.0 / 1024.0;
Ok(format!(
"Database Statistics:\n\nEntries:\nTotal: {total}\nActive: \
{active}\nExpired: {expired}\nWith TTL: \
{with_expiration}\n\nStorage:\nSize: {size_mb:.2} MB \
({size_bytes} bytes)\nPages: {page_count}\nPage size: \
{page_size} bytes"
))
}
}
/// Try to load a sensitive regex from systemd credential or env.