115 lines
5.1 KiB
Rust
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(())
|
|
}
|
|
}
|