//! Offline cache of the last successful reads (app data `cache.sqlite`): store status, health, reviews, TestFlight, //! catalog parity, listings, icons. The window shows a cached value with its "last read" time while nothing newer is //! there, and says "unknown" when there is none; a cached value is never shown as fresh. Secrets are never cached //! (results only, never params), and a project's rows go when the project is removed. use std::path::Path; use std::sync::Mutex; use rusqlite::{params, Connection, OptionalExtension}; use serde::Serialize; use serde_json::Value; pub const FILE: &str = "cache.sqlite"; /// Results above this size are not kept (logs of a long render, say); the window reads them live. pub const MAX_VALUE_BYTES: usize = 4 * 1024 * 1024; pub struct Cache { db: Mutex, } #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Entry { pub value: Value, /// When it was read (RFC 3339 UTC). pub at: String, } /// Cache key: project id, method and the canonical arguments (the same canonical JSON as confirm tokens). pub fn key(project: &str, method: &str, args: &Value) -> String { let a = crate::confirm::canonical(args).unwrap_or_else(|_| args.to_string()); format!("{project}\u{1f}{method}\u{1f}{a}") } impl Cache { pub fn open(dir: &Path) -> Result { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; Cache::with(Connection::open(dir.join(FILE)).map_err(|e| e.to_string())?) } pub fn in_memory() -> Result { Cache::with(Connection::open_in_memory().map_err(|e| e.to_string())?) } fn with(db: Connection) -> Result { db.execute_batch( "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS reads ( key TEXT PRIMARY KEY, project TEXT NOT NULL, method TEXT NOT NULL, value TEXT NOT NULL, at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS reads_project ON reads(project);", ) .map_err(|e| e.to_string())?; Ok(Cache { db: Mutex::new(db) }) } pub fn put(&self, project: &str, method: &str, args: &Value, value: &Value, at: &str) -> Result<(), String> { let text = value.to_string(); if text.len() > MAX_VALUE_BYTES { return Ok(()); } let db = self.db.lock().map_err(|_| "cache poisoned")?; db.execute( "INSERT INTO reads(key, project, method, value, at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(key) DO UPDATE SET value = excluded.value, at = excluded.at", params![key(project, method, args), project, method, text, at], ) .map_err(|e| e.to_string())?; Ok(()) } pub fn get(&self, project: &str, method: &str, args: &Value) -> Result, String> { let db = self.db.lock().map_err(|_| "cache poisoned")?; let row: Option<(String, String)> = db .query_row("SELECT value, at FROM reads WHERE key = ?1", params![key(project, method, args)], |r| Ok((r.get(0)?, r.get(1)?))) .optional() .map_err(|e| e.to_string())?; Ok(row.and_then(|(v, at)| serde_json::from_str(&v).ok().map(|value| Entry { value, at }))) } /// Every cached method of a project with its time (for the "last read" stamps). pub fn stamps(&self, project: &str) -> Result, String> { let db = self.db.lock().map_err(|_| "cache poisoned")?; let mut st = db.prepare("SELECT method, MAX(at) FROM reads WHERE project = ?1 GROUP BY method ORDER BY method").map_err(|e| e.to_string())?; let rows = st.query_map(params![project], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } pub fn forget_project(&self, project: &str) -> Result<(), String> { let db = self.db.lock().map_err(|_| "cache poisoned")?; db.execute("DELETE FROM reads WHERE project = ?1", params![project]).map_err(|e| e.to_string())?; Ok(()) } /// Rows, bytes of the saved values and the oldest read time. pub fn info(&self) -> Result<(u64, u64, Option), String> { let db = self.db.lock().map_err(|_| "cache poisoned")?; db.query_row("SELECT COUNT(*), COALESCE(SUM(LENGTH(value)), 0), MIN(at) FROM reads", [], |r| Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as u64, r.get(2)?))) .map_err(|e| e.to_string()) } /// Drops reads older than `cutoff` (RFC 3339, compared as text: every `at` is written in the same UTC format). pub fn prune_before(&self, cutoff: &str) -> Result { let db = self.db.lock().map_err(|_| "cache poisoned")?; db.execute("DELETE FROM reads WHERE at < ?1", params![cutoff]).map_err(|e| e.to_string()) } pub fn clear(&self) -> Result<(), String> { let db = self.db.lock().map_err(|_| "cache poisoned")?; db.execute("DELETE FROM reads", []).map_err(|e| e.to_string())?; Ok(()) } }