storebound/apps/desktop/src-tauri/src/cache.rs

115 lines
5.1 KiB
Rust

//! 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<Connection>,
}
#[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<Cache, String> {
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, String> {
Cache::with(Connection::open_in_memory().map_err(|e| e.to_string())?)
}
fn with(db: Connection) -> Result<Cache, String> {
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<Option<Entry>, 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<Vec<(String, String)>, 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::<Result<Vec<_>, _>>().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>), 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<usize, String> {
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(())
}
}