Skip to main content

aft/db/
github_read_cache.rs

1//! Durable cache rows for rendered GitHub issues and pull requests.
2//!
3//! The cache belongs in AFT's existing `aft.db`. Keeping the effective
4//! authentication identity as a cryptographic hash prevents cache rows from
5//! exposing credentials while still keeping principals strictly isolated.
6
7use rusqlite::{params, Connection, OptionalExtension};
8
9const CREATE_GITHUB_READ_CACHE_SCHEMA: &str = r#"
10CREATE TABLE IF NOT EXISTS github_read_cache (
11    resource_kind            TEXT NOT NULL CHECK (resource_kind IN ('issue', 'pr')),
12    repository               TEXT NOT NULL,
13    resource_number          INTEGER NOT NULL CHECK (resource_number > 0),
14    authentication_identity_hash BLOB NOT NULL,
15    canonical_text           TEXT NOT NULL,
16    fetched_at_ms            INTEGER NOT NULL,
17    updated_at_ms            INTEGER NOT NULL,
18    PRIMARY KEY (resource_kind, repository, resource_number, authentication_identity_hash)
19);
20CREATE INDEX IF NOT EXISTS idx_github_read_cache_hard_ttl
21    ON github_read_cache (fetched_at_ms);
22"#;
23
24/// The GitHub resource type that selects a cache namespace.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum GithubReadResourceKind {
27    Issue,
28    PullRequest,
29}
30
31impl GithubReadResourceKind {
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::Issue => "issue",
35            Self::PullRequest => "pr",
36        }
37    }
38}
39
40/// Exact durable cache key for one GitHub resource and authentication identity.
41///
42/// The identity hash intentionally has no accessor or `Debug` implementation,
43/// so callers can use the key without exposing an internal security boundary.
44#[derive(Clone, Eq, PartialEq)]
45pub struct GithubReadCacheKey {
46    resource_kind: GithubReadResourceKind,
47    normalized_repository: String,
48    resource_number: i64,
49    authentication_identity_hash: [u8; 32],
50}
51
52impl GithubReadCacheKey {
53    /// Build a cache key from the repository resolved by `gh` and its effective
54    /// authentication identity. Repository names are normalized case-insensitively.
55    pub fn new(
56        resource_kind: GithubReadResourceKind,
57        resolved_repository: &str,
58        resource_number: i64,
59        effective_authentication_identity: &str,
60    ) -> Self {
61        Self {
62            resource_kind,
63            normalized_repository: normalize_repository(resolved_repository),
64            resource_number,
65            authentication_identity_hash: authentication_identity_hash(
66                effective_authentication_identity,
67            ),
68        }
69    }
70}
71
72/// Cached canonical text and the timestamps used for cache freshness decisions.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct GithubReadCacheEntry {
75    pub canonical_text: String,
76    /// Milliseconds since the Unix epoch when GitHub supplied the cached content.
77    pub fetched_at_ms: i64,
78    /// Milliseconds since the Unix epoch when this durable row was last written.
79    pub updated_at_ms: i64,
80}
81
82/// Create the GitHub-read cache table and index in the already-open AFT database.
83///
84/// This stays separate from AFT's historical schema migrations so the cache
85/// module can be registered without coupling unrelated database consumers to its
86/// rollout. Every public cache operation calls this function before accessing the
87/// table.
88pub fn ensure_github_read_cache_schema(conn: &Connection) -> rusqlite::Result<()> {
89    conn.execute_batch(CREATE_GITHUB_READ_CACHE_SCHEMA)
90}
91
92/// Look up one cache row using the full resource and authentication-identity key.
93pub fn lookup_github_read_cache_entry(
94    conn: &Connection,
95    key: &GithubReadCacheKey,
96) -> rusqlite::Result<Option<GithubReadCacheEntry>> {
97    ensure_github_read_cache_schema(conn)?;
98    conn.query_row(
99        "SELECT canonical_text, fetched_at_ms, updated_at_ms
100         FROM github_read_cache
101         WHERE resource_kind = ?1
102           AND repository = ?2
103           AND resource_number = ?3
104           AND authentication_identity_hash = ?4",
105        params![
106            key.resource_kind.as_str(),
107            &key.normalized_repository,
108            key.resource_number,
109            key.authentication_identity_hash.as_slice(),
110        ],
111        |row| {
112            Ok(GithubReadCacheEntry {
113                canonical_text: row.get(0)?,
114                fetched_at_ms: row.get(1)?,
115                updated_at_ms: row.get(2)?,
116            })
117        },
118    )
119    .optional()
120}
121
122/// Insert or replace the canonical render for one exact cache key.
123///
124/// `fetched_at_ms` records the source-fetch time, so callers can apply fresh,
125/// soft-TTL, and hard-TTL policies without deriving age from filesystem metadata.
126pub fn upsert_github_read_cache_entry(
127    conn: &Connection,
128    key: &GithubReadCacheKey,
129    canonical_text: &str,
130    fetched_at_ms: i64,
131) -> rusqlite::Result<()> {
132    ensure_github_read_cache_schema(conn)?;
133    conn.execute(
134        "INSERT INTO github_read_cache (
135            resource_kind,
136            repository,
137            resource_number,
138            authentication_identity_hash,
139            canonical_text,
140            fetched_at_ms,
141            updated_at_ms
142         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
143         ON CONFLICT(resource_kind, repository, resource_number, authentication_identity_hash)
144         DO UPDATE SET
145            canonical_text = excluded.canonical_text,
146            fetched_at_ms = excluded.fetched_at_ms,
147            updated_at_ms = excluded.updated_at_ms",
148        params![
149            key.resource_kind.as_str(),
150            &key.normalized_repository,
151            key.resource_number,
152            key.authentication_identity_hash.as_slice(),
153            canonical_text,
154            fetched_at_ms,
155            fetched_at_ms,
156        ],
157    )?;
158    Ok(())
159}
160
161/// Delete rows whose source-fetch time has reached the hard-TTL cutoff.
162///
163/// A timestamp equal to `hard_ttl_cutoff_ms` is expired, matching the normal
164/// `age >= hard_ttl` boundary used by cache callers.
165pub fn evict_hard_expired_github_read_cache_entries(
166    conn: &Connection,
167    hard_ttl_cutoff_ms: i64,
168) -> rusqlite::Result<usize> {
169    ensure_github_read_cache_schema(conn)?;
170    conn.execute(
171        "DELETE FROM github_read_cache WHERE fetched_at_ms <= ?1",
172        [hard_ttl_cutoff_ms],
173    )
174}
175
176/// Invalidate a resource across identities, or only one identity when supplied.
177///
178/// A successful mutation can conservatively omit `effective_authentication_identity`
179/// when its result may affect caches visible to more than one principal.
180pub fn invalidate_github_read_cache_resource(
181    conn: &Connection,
182    resource_kind: GithubReadResourceKind,
183    resolved_repository: &str,
184    resource_number: i64,
185    effective_authentication_identity: Option<&str>,
186) -> rusqlite::Result<usize> {
187    ensure_github_read_cache_schema(conn)?;
188    let normalized_repository = normalize_repository(resolved_repository);
189
190    match effective_authentication_identity {
191        Some(identity) => conn.execute(
192            "DELETE FROM github_read_cache
193             WHERE resource_kind = ?1
194               AND repository = ?2
195               AND resource_number = ?3
196               AND authentication_identity_hash = ?4",
197            params![
198                resource_kind.as_str(),
199                normalized_repository,
200                resource_number,
201                authentication_identity_hash(identity).as_slice(),
202            ],
203        ),
204        None => conn.execute(
205            "DELETE FROM github_read_cache
206             WHERE resource_kind = ?1 AND repository = ?2 AND resource_number = ?3",
207            params![
208                resource_kind.as_str(),
209                normalized_repository,
210                resource_number
211            ],
212        ),
213    }
214}
215
216fn normalize_repository(repository: &str) -> String {
217    repository.trim().to_ascii_lowercase()
218}
219
220fn authentication_identity_hash(identity: &str) -> [u8; 32] {
221    *blake3::hash(identity.as_bytes()).as_bytes()
222}