Skip to main content

vector_core/db/
id_cache.rs

1//! ID cache — maps chat identifiers and npubs to SQLite row IDs.
2//!
3//! All lookups are cached in memory after first DB hit. Caches are
4//! preloaded at boot and cleared on account switch.
5
6use std::collections::HashMap;
7use std::sync::{Arc, RwLock};
8
9// Row ids belong to ONE account's database. Resolving a chat or npub against
10// another account's cache returns a live id for the wrong row, which reads and
11// writes then follow silently.
12struct ChatIds;
13struct UserIds;
14
15fn chat_id_cache() -> Arc<RwLock<HashMap<String, i64>>> {
16    crate::db::current_session().scoped::<ChatIds, _>()
17}
18
19fn user_id_cache() -> Arc<RwLock<HashMap<String, i64>>> {
20    crate::db::current_session().scoped::<UserIds, _>()
21}
22
23/// Drop a chat's cached identifier→id mapping. Call after deleting a chat row so
24/// a later recreate doesn't reuse the stale (now-deleted) integer id.
25pub fn forget_chat_id(chat_identifier: &str) {
26    chat_id_cache().write().unwrap().remove(chat_identifier);
27}
28
29/// Lookup-only: get integer chat ID from identifier. Errors if not found.
30pub fn get_chat_id_by_identifier(chat_identifier: &str) -> Result<i64, String> {
31    // Fast path: cache hit
32    {
33        let owner = chat_id_cache();
34        let cache = owner.read().unwrap();
35        if let Some(&id) = cache.get(chat_identifier) {
36            return Ok(id);
37        }
38    }
39
40    // Cache miss: query DB
41    let conn = super::get_db_connection_guard_static()?;
42    let id: i64 = conn.query_row(
43        "SELECT id FROM chats WHERE chat_identifier = ?1",
44        rusqlite::params![chat_identifier],
45        |row| row.get(0)
46    ).map_err(|_| format!("Chat not found: {}", chat_identifier))?;
47
48    // Update cache
49    {
50        let owner = chat_id_cache();
51        let mut cache = owner.write().unwrap();
52        cache.insert(chat_identifier.to_string(), id);
53    }
54
55    Ok(id)
56}
57
58/// Get or create integer chat ID from identifier.
59pub fn get_or_create_chat_id(chat_identifier: &str) -> Result<i64, String> {
60    // Fast path: cache hit
61    {
62        let owner = chat_id_cache();
63        let cache = owner.read().unwrap();
64        if let Some(&id) = cache.get(chat_identifier) {
65            return Ok(id);
66        }
67    }
68
69    let conn = super::get_db_connection_guard_static()?;
70
71    // Try existing
72    let existing: Option<i64> = conn.query_row(
73        "SELECT id FROM chats WHERE chat_identifier = ?1",
74        rusqlite::params![chat_identifier],
75        |row| row.get(0)
76    ).ok();
77
78    let id = if let Some(id) = existing {
79        id
80    } else {
81        // Create stub chat entry. Discriminant must match ChatType::to_i32:
82        // 0 = DirectMessage (npub), 2 = Community (non-npub). Value 1 was the
83        // retired MlsGroup variant and is dropped by the get_all_chats load filter,
84        // so a non-npub stub MUST be 2 or the chat vanishes on reload.
85        let now = std::time::SystemTime::now()
86            .duration_since(std::time::UNIX_EPOCH).unwrap()
87            .as_secs() as i64;
88        let chat_type: i32 = if chat_identifier.starts_with("npub1") { 0 } else { 2 };
89
90        // A DM stub's id IS its counterparty: write the participant now, or the
91        // row boots with an empty roster and every participant-keyed lookup
92        // (attachment downloads) misses forever.
93        let participants = if chat_type == 0 {
94            format!("[\"{}\"]", chat_identifier)
95        } else {
96            "[]".to_string()
97        };
98
99        conn.execute(
100            "INSERT INTO chats (chat_identifier, chat_type, participants, created_at) VALUES (?1, ?2, ?3, ?4)",
101            rusqlite::params![chat_identifier, chat_type, participants, now],
102        ).map_err(|e| format!("Failed to create chat stub: {}", e))?;
103
104        conn.last_insert_rowid()
105    };
106
107    // Update cache
108    {
109        let owner = chat_id_cache();
110        let mut cache = owner.write().unwrap();
111        cache.insert(chat_identifier.to_string(), id);
112    }
113
114    Ok(id)
115}
116
117/// Get or create integer user ID from npub. Returns None for empty npub.
118pub fn get_or_create_user_id(npub: &str) -> Result<Option<i64>, String> {
119    if npub.is_empty() {
120        return Ok(None);
121    }
122
123    // Fast path: cache hit
124    {
125        let owner = user_id_cache();
126        let cache = owner.read().unwrap();
127        if let Some(&id) = cache.get(npub) {
128            return Ok(Some(id));
129        }
130    }
131
132    let conn = super::get_db_connection_guard_static()?;
133
134    let existing: Option<i64> = conn.query_row(
135        "SELECT id FROM profiles WHERE npub = ?1",
136        rusqlite::params![npub],
137        |row| row.get(0)
138    ).ok();
139
140    let id = if let Some(id) = existing {
141        id
142    } else {
143        conn.execute(
144            "INSERT INTO profiles (npub, name, display_name) VALUES (?1, '', '')",
145            rusqlite::params![npub],
146        ).map_err(|e| format!("Failed to create profile stub: {}", e))?;
147        conn.last_insert_rowid()
148    };
149
150    // Update cache
151    {
152        let owner = user_id_cache();
153        let mut cache = owner.write().unwrap();
154        cache.insert(npub.to_string(), id);
155    }
156
157    Ok(Some(id))
158}
159
160/// Preload all ID mappings into memory cache (call at boot).
161pub fn preload_id_caches() -> Result<(), String> {
162    let conn = match super::get_db_connection_guard_static() {
163        Ok(c) => c,
164        Err(_) => return Ok(()), // No DB yet, skip
165    };
166
167    // Load chat ID mappings
168    {
169        let mut stmt = conn.prepare("SELECT chat_identifier, id FROM chats")
170            .map_err(|e| format!("Failed to prepare chat query: {}", e))?;
171        let rows = stmt.query_map([], |row| {
172            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
173        }).map_err(|e| format!("Failed to query chats: {}", e))?;
174
175        let owner = chat_id_cache();
176        let mut cache = owner.write().unwrap();
177        for row in rows.flatten() {
178            cache.insert(row.0, row.1);
179        }
180    }
181
182    // Load user ID mappings
183    {
184        let mut stmt = conn.prepare("SELECT npub, id FROM profiles")
185            .map_err(|e| format!("Failed to prepare user query: {}", e))?;
186        let rows = stmt.query_map([], |row| {
187            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
188        }).map_err(|e| format!("Failed to query profiles: {}", e))?;
189
190        let owner = user_id_cache();
191        let mut cache = owner.write().unwrap();
192        for row in rows.flatten() {
193            cache.insert(row.0, row.1);
194        }
195    }
196
197    Ok(())
198}
199
200/// Clear all ID caches (call on account switch).
201pub fn clear_id_caches() {
202    chat_id_cache().write().unwrap().clear();
203    user_id_cache().write().unwrap().clear();
204}