Skip to main content

brows3r_lib/cache/
mod.rs

1//! Authoritative SWR cache — types and configuration.
2//!
3//! # Layers
4//!
5//! - `mod.rs`          — `CacheKey`, `CacheEntry<T>`, `CacheConfig`, `Freshness`.
6//! - `store.rs`        — `CacheStore`: in-memory LRU + `redb` disk backend.
7//! - `invalidation.rs` — mutation-triggered invalidation helpers.
8//! - `capability.rs`   — stub placeholder for the capability cache (task 20).
9//!
10//! # OCP
11//!
12//! - Add a new cached resource: add a `CacheKey` variant + one arm in
13//!   `serialize_key`. Nothing else changes.
14//! - Add a new validation gate: add one `Option` arg to `store::get` and one
15//!   check at the top of the function. Existing call sites are unaffected.
16//! - Swap the KV backend: replace the `redb`-specific code inside `CacheStore`
17//!   without touching any caller.
18
19pub mod capability;
20pub mod invalidation;
21pub mod store;
22
23pub use capability::{
24    CapabilityCache, CapabilityClass, CapabilityHandle, CapabilityMap, CapabilityRecord, ClearScope,
25};
26
27use serde::{Deserialize, Serialize};
28
29use crate::ids::{BucketId, ObjectKey, ProfileId};
30
31// ---------------------------------------------------------------------------
32// CacheKey
33// ---------------------------------------------------------------------------
34
35/// Discriminated key for every entity the cache can hold.
36///
37/// `serialize_key` produces a stable, prefix-safe byte sequence so that
38/// per-profile invalidation can iterate by prefix without deserialising values.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub enum CacheKey {
41    /// Bucket list for a profile.
42    Buckets(ProfileId),
43    /// Object listing at a prefix inside a bucket.
44    Objects {
45        profile: ProfileId,
46        bucket: BucketId,
47        prefix: String,
48    },
49    /// Single-object `HeadObject` result.
50    ObjectHead {
51        profile: ProfileId,
52        bucket: BucketId,
53        key: ObjectKey,
54    },
55    /// Inspector panel data (bucket or object properties).
56    Inspector {
57        profile: ProfileId,
58        bucket: BucketId,
59        key: Option<ObjectKey>,
60    },
61    /// Capability classification result (allowed / denied / unsupported).
62    Capability {
63        profile: ProfileId,
64        bucket: Option<BucketId>,
65        op: String,
66    },
67}
68
69impl CacheKey {
70    /// Stable byte representation used as the `redb` table key and as the
71    /// in-memory `HashMap` lookup key serialisation.
72    ///
73    /// Format: `<variant_tag>/<profile_id>[/<extra...>]`
74    /// Fields are separated by `\x00` so no URL-encoding is needed and the
75    /// keys are prefix-scannable for per-profile invalidation.
76    pub fn serialize_key(&self) -> Vec<u8> {
77        let s = match self {
78            CacheKey::Buckets(pid) => {
79                format!("buckets\x00{}", pid.as_str())
80            }
81            CacheKey::Objects {
82                profile,
83                bucket,
84                prefix,
85            } => {
86                format!(
87                    "objects\x00{}\x00{}\x00{}",
88                    profile.as_str(),
89                    bucket.as_str(),
90                    prefix
91                )
92            }
93            CacheKey::ObjectHead {
94                profile,
95                bucket,
96                key,
97            } => {
98                format!(
99                    "object_head\x00{}\x00{}\x00{}",
100                    profile.as_str(),
101                    bucket.as_str(),
102                    key.as_str()
103                )
104            }
105            CacheKey::Inspector {
106                profile,
107                bucket,
108                key,
109            } => {
110                let key_part = key.as_ref().map(|k| k.as_str()).unwrap_or("");
111                format!(
112                    "inspector\x00{}\x00{}\x00{}",
113                    profile.as_str(),
114                    bucket.as_str(),
115                    key_part
116                )
117            }
118            CacheKey::Capability {
119                profile,
120                bucket,
121                op,
122            } => {
123                let bucket_part = bucket.as_ref().map(|b| b.as_str()).unwrap_or("");
124                format!(
125                    "capability\x00{}\x00{}\x00{}",
126                    profile.as_str(),
127                    bucket_part,
128                    op
129                )
130            }
131        };
132        s.into_bytes()
133    }
134
135    /// Return the `ProfileId` this key is scoped to.
136    pub fn profile_id(&self) -> &ProfileId {
137        match self {
138            CacheKey::Buckets(pid) => pid,
139            CacheKey::Objects { profile, .. } => profile,
140            CacheKey::ObjectHead { profile, .. } => profile,
141            CacheKey::Inspector { profile, .. } => profile,
142            CacheKey::Capability { profile, .. } => profile,
143        }
144    }
145}
146
147// ---------------------------------------------------------------------------
148// CacheEntry<T>
149// ---------------------------------------------------------------------------
150
151/// A cached value together with its validity metadata.
152///
153/// `T` is the value type; when stored on disk the bytes are
154/// `serde_json::to_vec(CacheEntry<serde_json::Value>)`.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct CacheEntry<T> {
157    /// The cached value.
158    pub value: T,
159    /// Unix timestamp (seconds) when the entry was written.
160    pub fetched_at: i64,
161    /// Unix timestamp (seconds) after which the entry is considered expired
162    /// (i.e. `fetched_at + ttl`).  A read between `expires_at` and
163    /// `expires_at + swr_window` returns `Freshness::Stale`.
164    pub expires_at: i64,
165    /// ETag of the S3 object at fetch time, if available.
166    pub etag: Option<String>,
167}
168
169// ---------------------------------------------------------------------------
170// Freshness
171// ---------------------------------------------------------------------------
172
173/// Freshness classification returned by `CacheStore::get`.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum Freshness {
176    /// Within TTL — safe to use directly.
177    Fresh,
178    /// Past TTL but within the SWR window — return to caller while the
179    /// background revalidation (wired in later tasks) fetches a fresh copy.
180    Stale,
181    /// Past the SWR window; the caller must fetch a fresh value before rendering.
182    Missing,
183}
184
185// ---------------------------------------------------------------------------
186// CacheRead<T>
187// ---------------------------------------------------------------------------
188
189/// Return type of `CacheStore::get`: value + freshness classification.
190pub struct CacheRead<T> {
191    pub value: T,
192    pub freshness: Freshness,
193}
194
195// ---------------------------------------------------------------------------
196// CacheConfig
197// ---------------------------------------------------------------------------
198
199/// Runtime-configurable cache parameters.
200///
201/// Loaded from `Settings` at startup; `Default` reflects the v1 proposal
202/// values so callers can construct a sensible default without a settings handle.
203#[derive(Debug, Clone)]
204pub struct CacheConfig {
205    /// Time-to-live in seconds (default 30, from `Settings.cache_ttl_secs`).
206    pub default_ttl_secs: u64,
207    /// How long stale data may be served while background revalidation runs.
208    /// Default 60 s — twice the default TTL so there is always a window
209    /// without a blocking S3 fetch during normal browsing.
210    pub swr_window_secs: u64,
211    /// Maximum number of entries kept in the in-memory LRU map.
212    /// Eviction is LRU; disk is unaffected by eviction.
213    pub max_in_memory_entries: usize,
214}
215
216impl Default for CacheConfig {
217    fn default() -> Self {
218        Self {
219            default_ttl_secs: 30,
220            swr_window_secs: 60,
221            max_in_memory_entries: 1024,
222        }
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Tests
228// ---------------------------------------------------------------------------
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::ids::{BucketId, ObjectKey, ProfileId};
234
235    #[test]
236    fn serialize_key_buckets_is_stable() {
237        let pid = ProfileId::new("p1");
238        let key = CacheKey::Buckets(pid.clone());
239        let bytes = key.serialize_key();
240        assert!(bytes.starts_with(b"buckets\x00"));
241        // Idempotent
242        assert_eq!(key.serialize_key(), bytes);
243    }
244
245    #[test]
246    fn serialize_key_objects_includes_all_fields() {
247        let key = CacheKey::Objects {
248            profile: ProfileId::new("prof"),
249            bucket: BucketId::new("bkt"),
250            prefix: "folder/".to_string(),
251        };
252        let s = String::from_utf8(key.serialize_key()).unwrap();
253        assert!(s.contains("objects"));
254        assert!(s.contains("prof"));
255        assert!(s.contains("bkt"));
256        assert!(s.contains("folder/"));
257    }
258
259    #[test]
260    fn serialize_key_inspector_none_key_is_stable() {
261        let key = CacheKey::Inspector {
262            profile: ProfileId::new("p"),
263            bucket: BucketId::new("b"),
264            key: None,
265        };
266        let bytes = key.serialize_key();
267        let with_key = CacheKey::Inspector {
268            profile: ProfileId::new("p"),
269            bucket: BucketId::new("b"),
270            key: Some(ObjectKey::new("obj.txt")),
271        };
272        assert_ne!(bytes, with_key.serialize_key());
273    }
274
275    #[test]
276    fn serialize_key_capability_none_bucket_is_stable() {
277        let k1 = CacheKey::Capability {
278            profile: ProfileId::new("p"),
279            bucket: None,
280            op: "ListBuckets".to_string(),
281        };
282        let k2 = CacheKey::Capability {
283            profile: ProfileId::new("p"),
284            bucket: Some(BucketId::new("bkt")),
285            op: "ListBuckets".to_string(),
286        };
287        assert_ne!(k1.serialize_key(), k2.serialize_key());
288    }
289
290    #[test]
291    fn profile_id_accessor_returns_correct_profile() {
292        let pid = ProfileId::new("my-profile");
293        let key = CacheKey::Buckets(pid.clone());
294        assert_eq!(key.profile_id(), &pid);
295
296        let key2 = CacheKey::Objects {
297            profile: pid.clone(),
298            bucket: BucketId::new("b"),
299            prefix: String::new(),
300        };
301        assert_eq!(key2.profile_id(), &pid);
302    }
303
304    #[test]
305    fn cache_config_defaults_match_v1_spec() {
306        let cfg = CacheConfig::default();
307        assert_eq!(cfg.default_ttl_secs, 30);
308        assert_eq!(cfg.swr_window_secs, 60);
309        assert_eq!(cfg.max_in_memory_entries, 1024);
310    }
311}