use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; use anyhow::{Context, Result, bail}; use chrono::{SecondsFormat, Utc}; use clap::Parser; use rusqlite::{Connection, DatabaseName, OpenFlags, params}; use serde::Serialize; use serde_json::Value; const DEFAULT_DB_PATH: &str = "/var/lib/activitywatch/aw-server-rust/sqlite.db"; const DEFAULT_BACKUP_DIR: &str = "/var/lib/activitywatch/backups/db"; const DEFAULT_HOST: &str = "HOST-EXAMPLE"; const DEFAULT_SERVICE_UNIT: &str = "activitywatch-server.service"; const DEFAULT_LOCK_PATH: &str = "/run/aw-db-maintenance.lock"; const ALLOWED_EVENT_TYPES: &[&str] = &["process_start", "process_stop"]; #[derive(Debug, Parser)] #[command(about = "Safe ActivityWatch SQLite maintenance for old process-level session events")] struct Cli { #[arg(long, default_value = DEFAULT_DB_PATH, env = "AW_DB_MAINTENANCE_DB_PATH")] db_path: PathBuf, #[arg(long, default_value = DEFAULT_BACKUP_DIR, env = "AW_DB_MAINTENANCE_BACKUP_DIR")] backup_dir: PathBuf, #[arg(long, env = "AW_DB_MAINTENANCE_SESSION_BUCKET")] session_bucket: Option, #[arg(long, env = "AW_WORKTIME_HOST")] host: Option, #[arg(long, default_value_t = 7, env = "AW_DB_MAINTENANCE_RETENTION_DAYS")] retention_days: i64, #[arg(long, default_value_t = 1000, env = "AW_DB_MAINTENANCE_CHUNK_SIZE")] chunk_size: usize, #[arg(long)] apply: bool, #[arg(long)] vacuum: bool, #[arg( long, default_value = DEFAULT_SERVICE_UNIT, env = "AW_DB_MAINTENANCE_SERVICE_UNIT" )] service_unit: String, #[arg(long, default_value = DEFAULT_LOCK_PATH, env = "AW_DB_MAINTENANCE_LOCK_PATH")] lock_path: PathBuf, #[arg(long)] json: bool, } #[derive(Debug, Serialize)] struct Report { apply: bool, generated_at_utc: String, db_path: String, backup_path: Option, bucket: String, bucketrow: Option, retention_days: i64, cutoff_ns: i64, allowed_event_types: Vec<&'static str>, planned_delete_rows: usize, deleted_rows: usize, backup_created: bool, lock_path: String, skipped_reason: Option, } #[derive(Debug, Serialize)] struct VacuumReport { apply: bool, generated_at_utc: String, db_path: String, service_unit: String, service_was_active: bool, service_restarted: bool, backup_path: Option, backup_created: bool, lock_path: String, db_size_before_bytes: Option, vacuumed_path: Option, vacuumed_size_bytes: Option, integrity_check: Option, replaced_db: bool, skipped_reason: Option, } struct VacuumResult { backup_path: PathBuf, vacuumed_path: PathBuf, db_size_before_bytes: u64, vacuumed_size_bytes: u64, integrity_check: String, } struct ServiceGuard { unit: String, was_active: bool, restored: bool, } struct TempFileGuard { path: PathBuf, keep: bool, } struct LockFileGuard { path: PathBuf, } fn main() { let code = match run() { Ok(code) => code, Err(err) => { eprintln!("{err:#}"); 1 } }; std::process::exit(code); } fn run() -> Result { let cli = Cli::parse(); if cli.vacuum { let report = build_vacuum_report(&cli)?; if cli.json { println!("{}", serde_json::to_string_pretty(&report)?); } else { print_vacuum_text(&report); } } else { let report = build_report(&cli)?; if cli.json { println!("{}", serde_json::to_string_pretty(&report)?); } else { print_text(&report); } } Ok(0) } fn build_report(cli: &Cli) -> Result { if cli.retention_days < 1 { bail!("retention_days must be >= 1"); } if cli.chunk_size == 0 { bail!("chunk_size must be > 0"); } let bucket = cli.session_bucket.clone().unwrap_or_else(|| { format!( "aw-session-events_{}", cli.host.as_deref().unwrap_or(DEFAULT_HOST) ) }); let cutoff_ns = (Utc::now().timestamp() - cli.retention_days * 86_400) * 1_000_000_000; let conn = open_connection(&cli.db_path, cli.apply)?; let bucketrow = bucket_row(&conn, &bucket)?; let Some(bucketrow) = bucketrow else { return Ok(base_report( cli, bucket, None, cutoff_ns, 0, 0, None, false, Some("session bucket not found".to_string()), )); }; let delete_ids = find_deletable_event_ids(&conn, bucketrow, cutoff_ns)?; let planned = delete_ids.len(); let mut backup_file = None; let mut backup_created = false; let mut deleted = 0; let _lock_guard = if cli.apply && planned > 0 { Some(LockFileGuard::acquire(&cli.lock_path)?) } else { None }; if cli.apply && planned > 0 { fs::create_dir_all(&cli.backup_dir) .with_context(|| format!("create backup dir {}", cli.backup_dir.display()))?; let backup = backup_path(&cli.backup_dir, "aw-sqlite-before-db-maintenance"); copy_sqlite_via_backup(&cli.db_path, &backup)?; backup_file = Some(backup); backup_created = true; deleted = delete_events(&conn, &delete_ids, cli.chunk_size)?; } Ok(base_report( cli, bucket, Some(bucketrow), cutoff_ns, planned, deleted, backup_file, backup_created, None, )) } fn build_vacuum_report(cli: &Cli) -> Result { if !cli.db_path.exists() { return Ok(vacuum_report( cli, false, None, false, false, false, None, None, None, None, Some("database not found".to_string()), )); } if !cli.apply { return Ok(vacuum_report( cli, false, Some(file_size(&cli.db_path)?), false, false, false, None, None, None, None, Some("dry-run".to_string()), )); } let _lock_guard = LockFileGuard::acquire(&cli.lock_path)?; let mut service_guard = ServiceGuard::stop_if_active(&cli.service_unit)?; let service_was_active = service_guard.was_active; let result = vacuum_sqlite_db(&cli.db_path, &cli.backup_dir)?; let service_restarted = service_guard.restore()?; Ok(vacuum_report( cli, true, Some(result.db_size_before_bytes), true, service_was_active, service_restarted, Some(result.backup_path), Some(result.vacuumed_path), Some(result.vacuumed_size_bytes), Some(result.integrity_check), None, )) } #[allow(clippy::too_many_arguments)] fn base_report( cli: &Cli, bucket: String, bucketrow: Option, cutoff_ns: i64, planned_delete_rows: usize, deleted_rows: usize, backup_path: Option, backup_created: bool, skipped_reason: Option, ) -> Report { Report { apply: cli.apply, generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), db_path: cli.db_path.display().to_string(), backup_path: backup_path.map(|path| path.display().to_string()), bucket, bucketrow, retention_days: cli.retention_days, cutoff_ns, allowed_event_types: ALLOWED_EVENT_TYPES.to_vec(), planned_delete_rows, deleted_rows, backup_created, lock_path: cli.lock_path.display().to_string(), skipped_reason, } } #[allow(clippy::too_many_arguments)] fn vacuum_report( cli: &Cli, apply: bool, db_size_before_bytes: Option, backup_created: bool, service_was_active: bool, service_restarted: bool, backup_path: Option, vacuumed_path: Option, vacuumed_size_bytes: Option, integrity_check: Option, skipped_reason: Option, ) -> VacuumReport { VacuumReport { apply, generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), db_path: cli.db_path.display().to_string(), service_unit: cli.service_unit.clone(), service_was_active, service_restarted, backup_path: backup_path.map(|path| path.display().to_string()), backup_created, lock_path: cli.lock_path.display().to_string(), db_size_before_bytes, vacuumed_path: vacuumed_path.map(|path| path.display().to_string()), vacuumed_size_bytes, integrity_check, replaced_db: apply && skipped_reason.is_none(), skipped_reason, } } fn open_connection(path: &Path, writable: bool) -> Result { let flags = if writable { OpenFlags::SQLITE_OPEN_READ_WRITE } else { OpenFlags::SQLITE_OPEN_READ_ONLY }; let conn = Connection::open_with_flags(path, flags) .with_context(|| format!("open SQLite DB {}", path.display()))?; conn.busy_timeout(Duration::from_secs(10))?; Ok(conn) } fn bucket_row(conn: &Connection, bucket: &str) -> Result> { let mut stmt = conn.prepare("select rowid from buckets where name = ?1 order by rowid limit 1")?; let mut rows = stmt.query([bucket])?; Ok(rows.next()?.map(|row| row.get::<_, i64>(0)).transpose()?) } fn find_deletable_event_ids(conn: &Connection, bucketrow: i64, cutoff_ns: i64) -> Result> { let mut stmt = conn .prepare("select id, data from events where bucketrow = ?1 and endtime < ?2 order by id")?; let rows = stmt.query_map(params![bucketrow, cutoff_ns], |row| { Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) })?; let mut ids = Vec::new(); for row in rows { let (id, data) = row?; if is_allowed_process_event(&data) { ids.push(id); } } Ok(ids) } fn is_allowed_process_event(data: &str) -> bool { let Ok(value) = serde_json::from_str::(data) else { return false; }; let event_type = value .pointer("/eventType") .or_else(|| value.pointer("/data/eventType")) .and_then(Value::as_str); event_type.is_some_and(|event_type| ALLOWED_EVENT_TYPES.contains(&event_type)) } fn copy_sqlite_via_backup(src: &Path, dst: &Path) -> Result<()> { let source = Connection::open(src).with_context(|| format!("open backup source {}", src.display()))?; source .backup(DatabaseName::Main, dst, None) .with_context(|| format!("backup {} to {}", src.display(), dst.display())) } fn delete_events(conn: &Connection, ids: &[i64], chunk_size: usize) -> Result { let mut deleted = 0; for chunk in ids.chunks(chunk_size) { let tx = conn.unchecked_transaction()?; { let mut stmt = tx.prepare("delete from events where id = ?1")?; for id in chunk { deleted += stmt.execute([id])?; } } tx.commit()?; } Ok(deleted) } fn backup_path(backup_dir: &Path, prefix: &str) -> PathBuf { backup_dir.join(format!( "{}-{}.db", prefix, Utc::now().format("%Y%m%dT%H%M%SZ") )) } fn vacuum_sqlite_db(db_path: &Path, backup_dir: &Path) -> Result { fs::create_dir_all(backup_dir) .with_context(|| format!("create backup dir {}", backup_dir.display()))?; let db_size_before_bytes = file_size(db_path)?; let backup_path = backup_path(backup_dir, "aw-sqlite-before-db-vacuum"); copy_sqlite_via_backup(db_path, &backup_path)?; let vacuumed_path = vacuumed_path(db_path)?; let mut vacuum_cleanup = TempFileGuard::new(vacuumed_path.clone()); vacuum_into(db_path, &vacuumed_path)?; preserve_sqlite_metadata(db_path, &vacuumed_path)?; let vacuumed_size_bytes = file_size(&vacuumed_path)?; let integrity_check = integrity_check(&vacuumed_path)?; remove_sqlite_sidecars(db_path)?; fs::rename(&vacuumed_path, db_path).with_context(|| { format!( "replace {} with {}", db_path.display(), vacuumed_path.display() ) })?; vacuum_cleanup.disarm(); Ok(VacuumResult { backup_path, vacuumed_path, db_size_before_bytes, vacuumed_size_bytes, integrity_check, }) } fn vacuum_into(src: &Path, dst: &Path) -> Result<()> { let conn = open_connection(src, true)?; let sql = format!("VACUUM INTO {}", sqlite_string_literal(dst)); conn.execute_batch(&sql) .with_context(|| format!("VACUUM INTO {}", dst.display())) } fn integrity_check(path: &Path) -> Result { let conn = open_connection(path, false)?; let result: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; if result != "ok" { bail!("integrity_check failed for {}: {result}", path.display()); } Ok(result) } fn remove_sqlite_sidecars(db_path: &Path) -> Result<()> { for suffix in ["-wal", "-shm", "-journal"] { let sidecar = sqlite_sidecar_path(db_path, suffix)?; match fs::remove_file(&sidecar) { Ok(()) => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(err).with_context(|| format!("remove {}", sidecar.display())), } } Ok(()) } fn sqlite_sidecar_path(db_path: &Path, suffix: &str) -> Result { let file_name = db_path .file_name() .and_then(|value| value.to_str()) .context("database path must have a file name")?; Ok(db_path.with_file_name(format!("{file_name}{suffix}"))) } fn vacuumed_path(db_path: &Path) -> Result { let file_name = db_path .file_name() .and_then(|value| value.to_str()) .context("database path must have a file name")?; Ok(db_path.with_file_name(format!( "{file_name}.vacuumed-{}", Utc::now().format("%Y%m%dT%H%M%SZ") ))) } fn file_size(path: &Path) -> Result { Ok(fs::metadata(path) .with_context(|| format!("stat {}", path.display()))? .len()) } fn sqlite_string_literal(path: &Path) -> String { format!("'{}'", path.display().to_string().replace('\'', "''")) } fn preserve_sqlite_metadata(src: &Path, dst: &Path) -> Result<()> { let metadata = fs::metadata(src).with_context(|| format!("stat {}", src.display()))?; let permissions = metadata.permissions(); fs::set_permissions(dst, permissions) .with_context(|| format!("preserve permissions for {}", dst.display()))?; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; let dst_metadata = fs::metadata(dst).with_context(|| format!("stat {}", dst.display()))?; if dst_metadata.uid() != metadata.uid() || dst_metadata.gid() != metadata.gid() { let status = Command::new("chown") .arg(format!("{}:{}", metadata.uid(), metadata.gid())) .arg(dst) .status() .context("run chown for vacuumed SQLite DB")?; if !status.success() { bail!("chown failed for {}", dst.display()); } } } Ok(()) } fn systemctl_is_active(unit: &str) -> Result { let load_state = systemctl_load_state(unit)?; if load_state != "loaded" { bail!("refusing SQLite VACUUM because systemd unit {unit} load_state={load_state:?}"); } let output = Command::new("systemctl") .args(["is-active", unit]) .output() .with_context(|| format!("systemctl is-active {unit}"))?; let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); if output.status.success() && state == "active" { return Ok(true); } if output.status.code() == Some(3) && state == "inactive" { return Ok(false); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); bail!( "refusing SQLite VACUUM because systemctl is-active {unit} returned state={state:?}, status={}, stderr={stderr:?}", output.status ); } fn systemctl_load_state(unit: &str) -> Result { let output = Command::new("systemctl") .args(["show", "-p", "LoadState", "--value", unit]) .output() .with_context(|| format!("systemctl show LoadState {unit}"))?; let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); if output.status.success() && !state.is_empty() { return Ok(state); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); bail!( "refusing SQLite VACUUM because systemctl show LoadState {unit} failed with status={}, stderr={stderr:?}", output.status ); } fn systemctl_action(action: &str, unit: &str) -> Result<()> { let status = Command::new("systemctl") .args([action, unit]) .status() .with_context(|| format!("systemctl {action} {unit}"))?; if status.success() { Ok(()) } else { bail!("systemctl {action} {unit} failed with status {status}"); } } impl ServiceGuard { fn stop_if_active(unit: &str) -> Result { let was_active = systemctl_is_active(unit)?; if was_active { systemctl_action("stop", unit)?; } Ok(Self { unit: unit.to_string(), was_active, restored: !was_active, }) } fn restore(&mut self) -> Result { if self.was_active && !self.restored { systemctl_action("start", &self.unit)?; self.restored = true; } Ok(self.was_active) } } impl Drop for ServiceGuard { fn drop(&mut self) { if self.was_active && !self.restored { let _ = systemctl_action("start", &self.unit); } } } impl TempFileGuard { fn new(path: PathBuf) -> Self { Self { path, keep: false } } fn disarm(&mut self) { self.keep = true; } } impl Drop for TempFileGuard { fn drop(&mut self) { if !self.keep { let _ = fs::remove_file(&self.path); } } } impl LockFileGuard { fn acquire(path: &Path) -> Result { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("create lock parent {}", parent.display()))?; } let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { Ok(file) => file, Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { bail!("maintenance lock already exists: {}", path.display()); } Err(err) => return Err(err).with_context(|| format!("create lock {}", path.display())), }; writeln!( file, "pid={} generated_at_utc={}", std::process::id(), Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) ) .with_context(|| format!("write lock {}", path.display()))?; Ok(Self { path: path.to_path_buf(), }) } } impl Drop for LockFileGuard { fn drop(&mut self) { let _ = fs::remove_file(&self.path); } } fn print_text(report: &Report) { println!( "aw-db-maintenance: {}", if report.apply { "apply" } else { "dry-run" } ); println!("db_path: {}", report.db_path); println!("bucket: {}", report.bucket); println!("retention_days: {}", report.retention_days); println!("planned_delete_rows: {}", report.planned_delete_rows); println!("deleted_rows: {}", report.deleted_rows); println!("backup_created: {}", report.backup_created); println!("lock_path: {}", report.lock_path); if let Some(path) = &report.backup_path { println!("backup_path: {path}"); } if let Some(reason) = &report.skipped_reason { println!("skipped_reason: {reason}"); } } fn print_vacuum_text(report: &VacuumReport) { println!( "aw-db-vacuum: {}", if report.apply { "apply" } else { "dry-run" } ); println!("db_path: {}", report.db_path); println!("service_unit: {}", report.service_unit); println!("service_was_active: {}", report.service_was_active); println!("service_restarted: {}", report.service_restarted); println!("backup_created: {}", report.backup_created); println!("lock_path: {}", report.lock_path); if let Some(path) = &report.backup_path { println!("backup_path: {path}"); } if let Some(size) = report.db_size_before_bytes { println!("db_size_before_bytes: {size}"); } if let Some(path) = &report.vacuumed_path { println!("vacuumed_path: {path}"); } if let Some(size) = report.vacuumed_size_bytes { println!("vacuumed_size_bytes: {size}"); } if let Some(check) = &report.integrity_check { println!("integrity_check: {check}"); } println!("replaced_db: {}", report.replaced_db); if let Some(reason) = &report.skipped_reason { println!("skipped_reason: {reason}"); } } #[cfg(test)] mod tests { use super::*; #[test] fn only_allows_process_start_stop_events() { assert!(is_allowed_process_event(r#"{"eventType":"process_start"}"#)); assert!(is_allowed_process_event( r#"{"data":{"eventType":"process_stop"}}"# )); assert!(!is_allowed_process_event(r#"{"eventType":"logon"}"#)); assert!(!is_allowed_process_event(r#"not-json"#)); } #[test] fn dry_run_does_not_delete_or_backup() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("aw.db"); create_fixture_db(&db); let cli = Cli { db_path: db.clone(), backup_dir: dir.path().join("backups"), session_bucket: Some("aw-session-events_TEST".to_string()), host: None, retention_days: 7, chunk_size: 100, apply: false, vacuum: false, service_unit: DEFAULT_SERVICE_UNIT.to_string(), lock_path: dir.path().join("maintenance.lock"), json: true, }; let report = build_report(&cli).unwrap(); assert_eq!(report.planned_delete_rows, 2); assert_eq!(report.deleted_rows, 0); assert!(!report.backup_created); assert_eq!(count_events(&db), 3); } #[test] fn apply_deletes_only_old_process_events_and_keeps_logon() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("aw.db"); create_fixture_db(&db); let cli = Cli { db_path: db.clone(), backup_dir: dir.path().join("backups"), session_bucket: Some("aw-session-events_TEST".to_string()), host: None, retention_days: 7, chunk_size: 1, apply: true, vacuum: false, service_unit: DEFAULT_SERVICE_UNIT.to_string(), lock_path: dir.path().join("maintenance.lock"), json: true, }; let report = build_report(&cli).unwrap(); assert_eq!(report.planned_delete_rows, 2); assert_eq!(report.deleted_rows, 2); assert!(report.backup_created); assert_eq!(count_events(&db), 1); } #[test] fn vacuum_apply_compacts_database_and_preserves_rows() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("sqlite.db"); create_vacuum_fixture_db(&db); let before = file_size(&db).unwrap(); let result = vacuum_sqlite_db(&db, dir.path()).unwrap(); let after = file_size(&db).unwrap(); assert!(result.vacuumed_size_bytes < result.db_size_before_bytes); assert!(after < before); assert_eq!(result.integrity_check, "ok"); assert!(result.backup_path.exists()); assert_eq!(count_rows(&db), 32); } #[test] fn vacuum_dry_run_skips_mutation() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("sqlite.db"); create_vacuum_fixture_db(&db); let cli = Cli { db_path: db.clone(), backup_dir: dir.path().join("backups"), session_bucket: None, host: None, retention_days: 7, chunk_size: 100, apply: false, vacuum: true, service_unit: DEFAULT_SERVICE_UNIT.to_string(), lock_path: dir.path().join("maintenance.lock"), json: true, }; let report = build_vacuum_report(&cli).unwrap(); assert!(!report.backup_created); assert!(!report.replaced_db); assert_eq!(report.skipped_reason.as_deref(), Some("dry-run")); assert_eq!(count_rows(&db), 32); } #[test] fn apply_refuses_when_lock_exists() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("aw.db"); create_fixture_db(&db); let lock_path = dir.path().join("maintenance.lock"); fs::write(&lock_path, "busy").unwrap(); let cli = Cli { db_path: db.clone(), backup_dir: dir.path().join("backups"), session_bucket: Some("aw-session-events_TEST".to_string()), host: None, retention_days: 7, chunk_size: 1, apply: true, vacuum: false, service_unit: DEFAULT_SERVICE_UNIT.to_string(), lock_path, json: true, }; let err = build_report(&cli).unwrap_err().to_string(); assert!(err.contains("maintenance lock already exists")); assert_eq!(count_events(&db), 3); } fn create_fixture_db(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch( "create table buckets (name text); create table events (id integer primary key autoincrement, bucketrow integer, starttime integer, endtime integer, data text);", ) .unwrap(); conn.execute( "insert into buckets (name) values ('aw-session-events_TEST')", [], ) .unwrap(); conn.execute( "insert into events (bucketrow,starttime,endtime,data) values (1,1,2,'{\"eventType\":\"process_start\"}')", [], ) .unwrap(); conn.execute( "insert into events (bucketrow,starttime,endtime,data) values (1,3,4,'{\"eventType\":\"process_stop\"}')", [], ) .unwrap(); conn.execute( "insert into events (bucketrow,starttime,endtime,data) values (1,5,6,'{\"eventType\":\"logon\"}')", [], ) .unwrap(); } fn create_vacuum_fixture_db(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch( "create table items (id integer primary key autoincrement, payload text);", ) .unwrap(); let payload = "x".repeat(4096); for _ in 0..64 { conn.execute("insert into items (payload) values (?1)", [&payload]) .unwrap(); } for id in 1..=32 { conn.execute("delete from items where id = ?1", [id]) .unwrap(); } } fn count_events(path: &Path) -> i64 { Connection::open(path) .unwrap() .query_row("select count(*) from events", [], |row| row.get(0)) .unwrap() } fn count_rows(path: &Path) -> i64 { Connection::open(path) .unwrap() .query_row("select count(*) from items", [], |row| row.get(0)) .unwrap() } }