Skip to main content

vector_core/db/
mod.rs

1//! Database layer — SQLite with per-account databases.
2//!
3//! Architecture:
4//! - Read pool: multiple connections for parallel reads (WAL mode)
5//! - Write pool: single Mutex-protected connection (serialized writes)
6//! - RAII guards: auto-return connections to pools on drop
7//!
8//! All connection functions use static `DATA_DIR` — no Tauri AppHandle required.
9
10use std::path::PathBuf;
11use std::sync::{Arc, Mutex, OnceLock, LazyLock, RwLock};
12use std::ops::{Deref, DerefMut};
13
14use serde::{Deserialize, Serialize};
15
16pub mod settings;
17pub mod at_rest;
18pub mod miniapps;
19pub mod schema;
20pub mod profiles;
21pub mod id_cache;
22pub mod events;
23pub mod attachments;
24pub mod chats;
25pub mod wrappers;
26pub mod nip17_keys;
27pub mod community;
28pub mod bots;
29pub mod notify;
30#[cfg(target_arch = "wasm32")]
31pub mod webfs;
32#[cfg(target_arch = "wasm32")]
33use webfs::{clear_active_account_file_in, list_account_npubs_in, read_active_account_file_in, write_active_account_file_in};
34
35pub use settings::{
36    get_sql_setting, set_sql_setting, advance_u64_setting, get_pkey, set_pkey, get_seed, set_seed, remove_setting,
37    get_signer_type, set_signer_type,
38    get_bunker_url, set_bunker_url,
39    get_bunker_remote_pubkey, set_bunker_remote_pubkey,
40    commit_bunker_account_setup,
41    get_nip55_user_pubkey, set_nip55_user_pubkey,
42    get_nip55_signer_package, set_nip55_signer_package,
43    commit_nip55_account_setup, commit_external_signer_setup,
44};
45
46// ============================================================================
47// App Data Directory
48// ============================================================================
49
50static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
51
52pub fn set_app_data_dir(path: PathBuf) {
53    let _ = APP_DATA_DIR.set(path);
54}
55
56/// The data dir every test must use — ONE per process, alive for its whole life.
57///
58/// `APP_DATA_DIR` is set-once, so in a test binary only the first
59/// `set_app_data_dir` ever takes effect: every later helper silently kept using
60/// the FIRST test's `TempDir`, which was deleted the moment that test returned,
61/// leaving the rest to build account dirs under a path that no longer existed.
62/// Which test drew the short straw depended on thread scheduling, so the failures
63/// wandered. Tests already isolate by account subdirectory, so sharing the root is
64/// safe; what they cannot share is a lifetime owned by one of them.
65#[cfg(test)]
66pub(crate) fn shared_test_data_dir() -> &'static std::path::Path {
67    static DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
68    // Never dropped: process-lifetime by construction, so no test can pull it out
69    // from under another. The OS reclaims it after the run.
70    DIR.get_or_init(|| tempfile::tempdir().expect("test data dir")).path()
71}
72
73pub fn get_app_data_dir() -> Result<&'static PathBuf, String> {
74    APP_DATA_DIR.get().ok_or_else(|| "App data directory not initialized".to_string())
75}
76
77/// Host app version, stamped into each account on open so a later downgrade can
78/// name what wrote the schema. Optional: the downgrade guard keys off the
79/// migration high-water mark, never this.
80static APP_VERSION: OnceLock<String> = OnceLock::new();
81
82pub fn set_app_version(version: impl Into<String>) {
83    let _ = APP_VERSION.set(version.into());
84}
85
86/// Host-installed override for the download directory. Tauri sets this
87/// at boot via `set_download_dir()` so platform conventions (XDG on
88/// Linux, Known Folders on Windows) are honored. Headless callers
89/// (vector-agent CLI, tests) fall through to the env-var path.
90static DOWNLOAD_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
91
92/// Install the host-resolved download directory. Must be called at
93/// startup before any `get_download_dir()` consumer runs; callers that
94/// run earlier hit the fallback.
95pub fn set_download_dir(path: PathBuf) {
96    let _ = DOWNLOAD_DIR_OVERRIDE.set(path);
97}
98
99/// Platform-appropriate download directory for file attachments.
100///
101/// Prefers the host-installed override (honors `xdg-user-dirs`,
102/// `FOLDERID_Downloads`, `NSDownloadsDirectory`, `NSDocumentDirectory`).
103/// Falls back to `$HOME/Downloads/vector` on desktop, then
104/// `<app_data>/vector_downloads` on mobile / pre-init.
105pub fn get_download_dir() -> PathBuf {
106    if let Some(installed) = DOWNLOAD_DIR_OVERRIDE.get() {
107        return installed.clone();
108    }
109    #[cfg(any(target_os = "macos", target_os = "linux"))]
110    {
111        if let Ok(home) = std::env::var("HOME") {
112            return PathBuf::from(home).join("Downloads/vector");
113        }
114    }
115    #[cfg(target_os = "windows")]
116    {
117        if let Ok(profile) = std::env::var("USERPROFILE") {
118            return PathBuf::from(profile).join("Downloads").join("vector");
119        }
120    }
121    // Mobile / fallback: use data dir
122    if let Ok(data_dir) = get_app_data_dir() {
123        return data_dir.join("vector_downloads");
124    }
125    PathBuf::from("/tmp/vector_downloads")
126}
127
128// ============================================================================
129// Current Account
130// ============================================================================
131
132static CURRENT_ACCOUNT: LazyLock<Arc<RwLock<Option<String>>>> = LazyLock::new(|| Arc::new(RwLock::new(None)));
133// PENDING_ACCOUNT lives exclusively in src-tauri's account_manager —
134// any "pending account" check must go through that crate, not here.
135
136/// Filename for the persistent active-account marker. Plain text, just the npub.
137const ACTIVE_ACCOUNT_FILE: &str = "active_account";
138
139/// npub bech32 form: `npub1` + 58 chars from the bech32 alphabet (no `1`, `b`, `i`, `o`).
140fn is_valid_npub(s: &str) -> bool {
141    if s.len() != 63 || !s.starts_with("npub1") {
142        return false;
143    }
144    s.bytes().skip(5).all(|c| matches!(c,
145        b'q' | b'p' | b'z' | b'r' | b'y' | b'9' | b'x' | b'8' |
146        b'g' | b'f' | b'2' | b't' | b'v' | b'd' | b'w' | b'0' |
147        b's' | b'3' | b'j' | b'n' | b'5' | b'4' | b'k' | b'h' |
148        b'c' | b'e' | b'6' | b'm' | b'u' | b'a' | b'7' | b'l'
149    ))
150}
151
152pub fn get_current_account() -> Result<String, String> {
153    CURRENT_ACCOUNT.read().unwrap()
154        .as_ref().cloned()
155        .ok_or_else(|| "No active account".to_string())
156}
157
158/// Set the currently-active npub for THIS process AND persist it to the
159/// `<app_data>/active_account` marker so the next boot picks the same account.
160///
161/// Every call site asserts user intent ("this account is now active"); the
162/// marker write is idempotent and gracefully no-ops when `APP_DATA_DIR` is
163/// not yet configured (e.g. during in-process unit tests).
164pub fn set_current_account(npub: String) -> Result<(), String> {
165    *CURRENT_ACCOUNT.write().unwrap() = Some(npub.clone());
166    let _ = write_active_account_file(&npub);
167    Ok(())
168}
169
170/// Clear the in-memory active account WITHOUT touching the on-disk marker.
171/// Used by `reset_session()` so the next-boot marker stays intact while
172/// in-process state is torn down for an inline account swap.
173pub fn clear_current_account_in_memory() {
174    *CURRENT_ACCOUNT.write().unwrap() = None;
175}
176
177/// Read the active-account marker file. Returns the stored npub if it exists,
178/// is well-formed, AND the corresponding account directory still exists.
179/// Any failure path returns Ok(None) so boot falls back to single-account or picker.
180pub fn read_active_account_file() -> Result<Option<String>, String> {
181    let app_data = match get_app_data_dir() {
182        Ok(p) => p,
183        Err(_) => return Ok(None),
184    };
185    read_active_account_file_in(app_data)
186}
187
188/// Atomic write of the active-account marker (temp + rename).
189pub fn write_active_account_file(npub: &str) -> Result<(), String> {
190    let app_data = get_app_data_dir()?.clone();
191    write_active_account_file_in(&app_data, npub)
192}
193
194/// Remove the active-account marker. Used after deleting the active account.
195pub fn clear_active_account_file() -> Result<(), String> {
196    let app_data = get_app_data_dir()?;
197    clear_active_account_file_in(app_data)
198}
199
200/// Scan the app data directory for valid npub directories. Strict bech32 regex
201/// rejects typos and stray subdirectories. Does NOT validate that each account
202/// has a usable database — callers do that separately.
203pub fn list_account_npubs() -> Result<Vec<String>, String> {
204    let app_data = get_app_data_dir()?;
205    Ok(list_account_npubs_in(app_data))
206}
207
208// ----- path-parameterized internals (kept private so tests can inject a temp dir) -----
209
210/// Bound on bytes read from the active-account marker. A valid marker
211/// is 63 bytes (canonical npub) plus optional trailing newline. The
212/// marker lives in a user-writable dir, so accidental / malicious
213/// multi-gigabyte writes are a realistic OOM vector if read unbounded.
214#[cfg(not(target_arch = "wasm32"))]
215const MARKER_MAX_BYTES: u64 = 256;
216
217#[cfg(not(target_arch = "wasm32"))]
218fn read_active_account_file_in(app_data: &std::path::Path) -> Result<Option<String>, String> {
219    use std::io::Read;
220
221    let path = app_data.join(ACTIVE_ACCOUNT_FILE);
222    if !path.exists() {
223        return Ok(None);
224    }
225    // Pre-check size, then belt-and-suspenders cap via `take()` to
226    // cover the TOCTOU window between metadata and open. Metadata
227    // failures fail-safe to "missing".
228    if let Ok(meta) = std::fs::metadata(&path) {
229        if meta.len() > MARKER_MAX_BYTES {
230            return Ok(None);
231        }
232    } else {
233        return Ok(None);
234    }
235    let mut buf = String::new();
236    let file = match std::fs::File::open(&path) {
237        Ok(f) => f,
238        Err(_) => return Ok(None),
239    };
240    if file.take(MARKER_MAX_BYTES).read_to_string(&mut buf).is_err() {
241        return Ok(None);
242    }
243    let npub = buf.trim().to_string();
244    if !is_valid_npub(&npub) {
245        return Ok(None);
246    }
247    // `symlink_metadata` instead of `is_dir()` (which follows links): a
248    // crafted symlink at `<app_data>/<valid-npub-name>` pointing at
249    // `~/Documents` etc. would otherwise pass, and downstream
250    // `remove_dir_all` in delete_account / logout would traverse it.
251    // Bech32 validation alone is insufficient — the attacker controls
252    // the filename, not the npub semantic.
253    match std::fs::symlink_metadata(app_data.join(&npub)) {
254        Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
255        _ => return Ok(None),
256    }
257    Ok(Some(npub))
258}
259
260#[cfg(not(target_arch = "wasm32"))]
261fn write_active_account_file_in(app_data: &std::path::Path, npub: &str) -> Result<(), String> {
262    if !is_valid_npub(npub) {
263        return Err(format!("Invalid npub format: {}", npub));
264    }
265    if !app_data.exists() {
266        std::fs::create_dir_all(app_data)
267            .map_err(|e| format!("Failed to create app data dir: {}", e))?;
268    }
269    // Refuse to point the marker at a directory that doesn't exist as a
270    // real subfolder. Closes the race where a concurrent `delete_account`
271    // for `npub` runs between the caller's existence check and this write.
272    // `symlink_metadata` (matching the read path) so a crafted
273    // `<app_data>/<valid-npub-name>` symlink can't satisfy the check.
274    match std::fs::symlink_metadata(app_data.join(npub)) {
275        Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => {}
276        _ => return Err(format!("Account directory missing or invalid: {}", npub)),
277    }
278    let tmp = app_data.join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE));
279    let final_path = app_data.join(ACTIVE_ACCOUNT_FILE);
280
281    // Trailing newline so `cat` doesn't mangle the shell prompt and so editors
282    // that auto-strip trailing newlines don't dirty-mark the file on save.
283    let mut payload = String::with_capacity(npub.len() + 1);
284    payload.push_str(npub);
285    payload.push('\n');
286
287    if let Err(e) = std::fs::write(&tmp, payload.as_bytes()) {
288        let _ = std::fs::remove_file(&tmp);
289        return Err(format!("Failed to write active account temp file: {}", e));
290    }
291
292    // Retry the rename a few times. On Windows, transient antivirus or backup
293    // scans can hold a brief sharing-violation lock on the destination file.
294    let mut last_err = None;
295    for attempt in 0..3 {
296        match std::fs::rename(&tmp, &final_path) {
297            Ok(_) => return Ok(()),
298            Err(e) => {
299                last_err = Some(e);
300                if attempt < 2 {
301                    std::thread::sleep(std::time::Duration::from_millis(50));
302                }
303            }
304        }
305    }
306
307    // Don't leave the temp file behind if every attempt failed.
308    let _ = std::fs::remove_file(&tmp);
309    Err(format!(
310        "Failed to rename active account file: {}",
311        last_err.map(|e| e.to_string()).unwrap_or_default()
312    ))
313}
314
315#[cfg(not(target_arch = "wasm32"))]
316fn clear_active_account_file_in(app_data: &std::path::Path) -> Result<(), String> {
317    let path = app_data.join(ACTIVE_ACCOUNT_FILE);
318    if path.exists() {
319        std::fs::remove_file(&path)
320            .map_err(|e| format!("Failed to remove active account file: {}", e))?;
321    }
322    Ok(())
323}
324
325#[cfg(not(target_arch = "wasm32"))]
326fn list_account_npubs_in(app_data: &std::path::Path) -> Vec<String> {
327    let mut out = Vec::new();
328    if let Ok(entries) = std::fs::read_dir(app_data) {
329        for entry in entries.flatten() {
330            if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
331                let name = entry.file_name().to_string_lossy().to_string();
332                if is_valid_npub(&name) {
333                    out.push(name);
334                }
335            }
336        }
337    }
338    out
339}
340
341#[cfg(test)]
342mod active_account_tests {
343    use super::*;
344    use std::fs;
345    use tempfile::TempDir;
346
347    /// Real npub from the project's own test fixtures (matches bech32 regex).
348    const VALID_A: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
349    const VALID_B: &str = "npub12w73tzcqgpr2pcy4el5x60d2emeud4cyeeayynzqgg2fefzgytaqm4ktz3";
350
351    fn touch_account_dir(base: &std::path::Path, npub: &str) {
352        fs::create_dir_all(base.join(npub)).unwrap();
353    }
354
355    #[test]
356    fn npub_validator_accepts_canonical_form() {
357        assert!(is_valid_npub(VALID_A));
358        assert!(is_valid_npub(VALID_B));
359    }
360
361    #[test]
362    fn npub_validator_rejects_wrong_length() {
363        assert!(!is_valid_npub("npub1abc"));
364        assert!(!is_valid_npub(&format!("{}x", VALID_A)));
365        assert!(!is_valid_npub(""));
366    }
367
368    #[test]
369    fn npub_validator_rejects_missing_prefix() {
370        let body = &VALID_A[5..];
371        assert!(!is_valid_npub(&format!("nsec1{}", body)));
372        assert!(!is_valid_npub(&format!("xxxx1{}", body)));
373    }
374
375    #[test]
376    fn npub_validator_rejects_non_bech32_chars() {
377        // Replace one char in the body with each disallowed bech32 letter.
378        for bad in ['1', 'b', 'i', 'o', 'B', 'I', 'O', '!', '*', ' '] {
379            let mut s = String::from(VALID_A);
380            s.replace_range(10..11, &bad.to_string());
381            assert!(!is_valid_npub(&s), "should reject character {:?}", bad);
382        }
383    }
384
385    #[test]
386    fn write_then_read_round_trips() {
387        let tmp = TempDir::new().unwrap();
388        touch_account_dir(tmp.path(), VALID_A);
389
390        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
391        assert_eq!(
392            read_active_account_file_in(tmp.path()).unwrap(),
393            Some(VALID_A.to_string())
394        );
395    }
396
397    #[test]
398    fn write_rejects_invalid_npub() {
399        let tmp = TempDir::new().unwrap();
400        let err = write_active_account_file_in(tmp.path(), "npub1nope").unwrap_err();
401        assert!(err.contains("Invalid"));
402        // No file should have been created (neither final nor temp).
403        assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
404        assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
405    }
406
407    #[test]
408    fn write_rejects_missing_account_dir() {
409        // A concurrent `delete_account` between the caller's existence check
410        // and write_active_account_file would otherwise leave a stale marker
411        // pointing at a now-deleted account.
412        let tmp = TempDir::new().unwrap();
413        let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
414        assert!(err.contains("missing or invalid"),
415            "expected account-dir-missing error, got: {}", err);
416        // Marker must not have been written.
417        assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
418        assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
419    }
420
421    #[test]
422    fn write_rejects_symlinked_account_dir() {
423        // A crafted `<app_data>/<valid-npub-name>` symlink to ~/Documents
424        // would otherwise pass `is_dir()` and let the marker point at an
425        // attacker-controlled location, which downstream delete/logout paths
426        // would then traverse.
427        let tmp = TempDir::new().unwrap();
428        let target = TempDir::new().unwrap();
429        let link = tmp.path().join(VALID_A);
430        #[cfg(unix)]
431        {
432            std::os::unix::fs::symlink(target.path(), &link).unwrap();
433            let err = write_active_account_file_in(tmp.path(), VALID_A).unwrap_err();
434            assert!(err.contains("missing or invalid"),
435                "expected symlink rejection, got: {}", err);
436        }
437        // On Windows symlink creation may require elevated privileges; skip
438        // the assertion there rather than gate the whole test on platform.
439        #[cfg(not(unix))]
440        let _ = (target, link);
441    }
442
443    #[test]
444    fn read_returns_none_when_marker_missing() {
445        let tmp = TempDir::new().unwrap();
446        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
447    }
448
449    #[test]
450    fn read_returns_none_when_marker_is_garbage() {
451        let tmp = TempDir::new().unwrap();
452        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), b"not-an-npub\n").unwrap();
453        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
454    }
455
456    #[test]
457    fn read_returns_none_when_account_dir_missing() {
458        // Marker exists, npub is well-formed, but the account directory was
459        // deleted out from under us. Boot must fall through to picker, never crash.
460        let tmp = TempDir::new().unwrap();
461        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
462        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
463    }
464
465    #[test]
466    fn read_returns_none_when_marker_oversized() {
467        // Marker lives in a user-writable directory — guard against a giant
468        // file OOMing the app. Anything past MARKER_MAX_BYTES is treated as
469        // corrupt.
470        let tmp = TempDir::new().unwrap();
471        let payload = vec![b'x'; (MARKER_MAX_BYTES + 1024) as usize];
472        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), &payload).unwrap();
473        assert_eq!(read_active_account_file_in(tmp.path()).unwrap(), None);
474    }
475
476    #[test]
477    fn read_trims_whitespace() {
478        let tmp = TempDir::new().unwrap();
479        touch_account_dir(tmp.path(), VALID_A);
480        fs::write(
481            tmp.path().join(ACTIVE_ACCOUNT_FILE),
482            format!("  {}\n", VALID_A),
483        ).unwrap();
484        assert_eq!(
485            read_active_account_file_in(tmp.path()).unwrap(),
486            Some(VALID_A.to_string())
487        );
488    }
489
490    #[test]
491    fn read_handles_crlf_line_endings() {
492        let tmp = TempDir::new().unwrap();
493        touch_account_dir(tmp.path(), VALID_A);
494        fs::write(
495            tmp.path().join(ACTIVE_ACCOUNT_FILE),
496            format!("{}\r\n", VALID_A),
497        ).unwrap();
498        assert_eq!(
499            read_active_account_file_in(tmp.path()).unwrap(),
500            Some(VALID_A.to_string())
501        );
502    }
503
504    #[test]
505    fn npub_validator_rejects_uppercase_prefix() {
506        let upper = format!("NPUB1{}", &VALID_A[5..]);
507        assert!(!is_valid_npub(&upper));
508    }
509
510    #[test]
511    fn write_then_read_round_trips_with_newline() {
512        // Belt-and-braces check: confirms our own writer (which appends \n)
513        // round-trips through our own reader (which trims) with no surprises.
514        let tmp = TempDir::new().unwrap();
515        touch_account_dir(tmp.path(), VALID_A);
516        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
517
518        let raw = fs::read_to_string(tmp.path().join(ACTIVE_ACCOUNT_FILE)).unwrap();
519        assert!(raw.ends_with('\n'));
520
521        assert_eq!(
522            read_active_account_file_in(tmp.path()).unwrap(),
523            Some(VALID_A.to_string())
524        );
525    }
526
527    #[test]
528    fn write_overwrites_previous_marker_atomically() {
529        let tmp = TempDir::new().unwrap();
530        touch_account_dir(tmp.path(), VALID_A);
531        touch_account_dir(tmp.path(), VALID_B);
532
533        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
534        write_active_account_file_in(tmp.path(), VALID_B).unwrap();
535
536        assert_eq!(
537            read_active_account_file_in(tmp.path()).unwrap(),
538            Some(VALID_B.to_string())
539        );
540        // The temp file used for atomic rename should not linger.
541        assert!(!tmp.path().join(format!("{}.tmp", ACTIVE_ACCOUNT_FILE)).exists());
542    }
543
544    #[test]
545    fn clear_removes_marker_and_is_idempotent() {
546        let tmp = TempDir::new().unwrap();
547        touch_account_dir(tmp.path(), VALID_A);
548        write_active_account_file_in(tmp.path(), VALID_A).unwrap();
549        assert!(tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
550
551        clear_active_account_file_in(tmp.path()).unwrap();
552        assert!(!tmp.path().join(ACTIVE_ACCOUNT_FILE).exists());
553
554        // Calling clear again on an already-clean state must not error.
555        clear_active_account_file_in(tmp.path()).unwrap();
556    }
557
558    #[test]
559    fn list_npubs_finds_valid_dirs_only() {
560        let tmp = TempDir::new().unwrap();
561        touch_account_dir(tmp.path(), VALID_A);
562        touch_account_dir(tmp.path(), VALID_B);
563        // Decoys: stray dirs and files that must NOT be picked up.
564        fs::create_dir_all(tmp.path().join("npub1tooshort")).unwrap();
565        fs::create_dir_all(tmp.path().join("not-an-npub-dir")).unwrap();
566        fs::create_dir_all(tmp.path().join("tor")).unwrap();
567        fs::write(tmp.path().join(ACTIVE_ACCOUNT_FILE), VALID_A).unwrap();
568
569        let mut found = list_account_npubs_in(tmp.path());
570        found.sort();
571        let mut expected = vec![VALID_A.to_string(), VALID_B.to_string()];
572        expected.sort();
573        assert_eq!(found, expected);
574    }
575
576    #[test]
577    fn list_npubs_skips_dirs_containing_invalid_chars() {
578        let tmp = TempDir::new().unwrap();
579        // Insert a 'b', 'i', 'o', or '1' into the body — invalid bech32 chars.
580        let mut bogus = String::from(VALID_A);
581        bogus.replace_range(10..11, "b");
582        fs::create_dir_all(tmp.path().join(&bogus)).unwrap();
583
584        let found = list_account_npubs_in(tmp.path());
585        assert!(found.is_empty(), "found unexpected entries: {:?}", found);
586    }
587
588    #[test]
589    fn write_creates_app_data_dir_if_missing() {
590        let tmp = TempDir::new().unwrap();
591        let nested = tmp.path().join("does/not/exist/yet");
592        // Parent app_data is auto-created by mkdir_all; the account dir must
593        // also exist by the time we write, so the marker can't end up
594        // pointing at a non-existent account.
595        std::fs::create_dir_all(&nested).unwrap();
596        touch_account_dir(&nested, VALID_A);
597        write_active_account_file_in(&nested, VALID_A).unwrap();
598        assert!(nested.join(ACTIVE_ACCOUNT_FILE).exists());
599    }
600}
601
602// pending-account accessors removed — see comment above the static decl.
603// All callers use `src-tauri::account_manager::{get,set,clear}_pending_account`.
604
605// ============================================================================
606// Connection Pools
607// ============================================================================
608
609/// One account's database resources, held behind an `Arc`.
610///
611/// The connections an account uses are reachable ONLY through its own
612/// `Session`, so a task that captured one keeps talking to the account it
613/// started with even after a swap — it cannot be handed the next account's
614/// database, because it never asks "who is current?" again.
615///
616/// That makes teardown structural: swapping installs a new `Session` and drops
617/// the reference to the old one, whose pool closes when the last in-flight
618/// guard finishes with it. Nothing to clear, nothing to remember to clear.
619pub struct Session {
620    /// Identity for "is this the account on screen?".
621    ///
622    /// Not the `Arc` address: binding a session to a database builds a new one
623    /// holding the same state (see `rebound`), and a task bound to the session
624    /// it grew from is still that account's. Comparing addresses would make
625    /// those tasks silently stop painting after a re-initialise.
626    id: u64,
627    /// The database this session opens, resolved ONCE when it is built.
628    /// `None` only before an account is bound, where there is nothing better
629    /// than the ambient lookup to fall back to.
630    db_path: Option<PathBuf>,
631    read_pool: Mutex<Vec<rusqlite::Connection>>,
632    write_conn: Mutex<Option<rusqlite::Connection>>,
633    /// This account's chats and profiles in memory — the DB's read-through
634    /// cache, and so bound to the same account for the same reason.
635    chat_state: Arc<tokio::sync::Mutex<crate::state::ChatState>>,
636    /// Everything else the account owns, keyed by type. See [`Session::scoped`].
637    scoped: RwLock<std::collections::HashMap<std::any::TypeId, Arc<dyn std::any::Any + Send + Sync>>>,
638    /// Raised when this account stops being the one on screen.
639    stopped: SessionStop,
640}
641
642/// "This account is no longer current" — a flag long work polls, and a signal
643/// it can await.
644#[derive(Default)]
645struct SessionStop {
646    flag: std::sync::atomic::AtomicBool,
647    wake: tokio::sync::Notify,
648}
649
650impl Session {
651    fn empty() -> Arc<Self> {
652        Arc::new(Session {
653            id: next_session_id(),
654            db_path: None,
655            read_pool: Mutex::new(Vec::new()),
656            write_conn: Mutex::new(None),
657            chat_state: Arc::new(tokio::sync::Mutex::new(crate::state::ChatState::new())),
658            scoped: RwLock::new(std::collections::HashMap::new()),
659            stopped: SessionStop::default(),
660        })
661    }
662
663    /// A session bound to one account's database file, with a fresh in-memory
664    /// state — the caller loads it from that database.
665    fn bound(db_path: PathBuf) -> Arc<Self> {
666        Arc::new(Session {
667            id: next_session_id(),
668            db_path: Some(db_path),
669            read_pool: Mutex::new(Vec::new()),
670            write_conn: Mutex::new(None),
671            chat_state: Arc::new(tokio::sync::Mutex::new(crate::state::ChatState::new())),
672            scoped: RwLock::new(std::collections::HashMap::new()),
673            stopped: SessionStop::default(),
674        })
675    }
676
677    /// The same account's session, pointed at its database, keeping everything
678    /// it already holds.
679    ///
680    /// Two callers need this. Re-initialising an account that is already open —
681    /// `init_database` is documented idempotent, and the schema check and
682    /// Android's background sync both re-run it — must not empty the chat list
683    /// under a running app. And a login legitimately fills a session before the
684    /// account's database exists: creating an account installs its keys and
685    /// client first, and only reaches `init_database` once the user has chosen
686    /// a PIN. Binding the session it was filling is a promotion, not a swap.
687    fn rebound(&self, db_path: PathBuf) -> Arc<Self> {
688        Arc::new(Session {
689            id: self.id,
690            db_path: Some(db_path),
691            read_pool: Mutex::new(Vec::new()),
692            write_conn: Mutex::new(None),
693            chat_state: self.chat_state.clone(),
694            scoped: RwLock::new(self.scoped.read().unwrap_or_else(|e| e.into_inner()).clone()),
695            stopped: SessionStop::default(),
696        })
697    }
698
699    /// This account's instance of `T`, built on first touch.
700    ///
701    /// The home for anything an account owns beyond its database: caches keyed
702    /// by its row ids, queues holding its work, routing tables holding its
703    /// keys. Previously these were process globals with a hand-written clear in
704    /// two teardown paths, which is how they drifted — one path cleared fields
705    /// the other did not, and a cache added without a clear leaked into the
706    /// next account. Here there is nothing to clear: the account's instances go
707    /// when its session does.
708    ///
709    /// Keyed by a marker type the owning module declares, so the session never
710    /// has to know what any of this is, and two modules storing the same SHAPE
711    /// (say a `Mutex<HashMap<PublicKey, _>>`) can't collide on one slot.
712    ///
713    /// ```ignore
714    /// struct InboxRelayCache;               // the key, private to this module
715    /// fn cache() -> Arc<Mutex<HashMap<PublicKey, CachedRelays>>> {
716    ///     db::current_session().scoped::<InboxRelayCache, _>()
717    /// }
718    /// ```
719    pub fn scoped<K: 'static, T: Default + Send + Sync + 'static>(self: &Arc<Self>) -> Arc<T> {
720        let key = std::any::TypeId::of::<(K, T)>();
721        if let Some(existing) = self.scoped.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
722            return existing.clone().downcast::<T>().expect("keyed by its own TypeId");
723        }
724        let mut map = self.scoped.write().unwrap_or_else(|e| e.into_inner());
725        // Another thread may have inserted while the read lock was released.
726        map.entry(key)
727            .or_insert_with(|| Arc::new(T::default()) as Arc<dyn std::any::Any + Send + Sync>)
728            .clone()
729            .downcast::<T>()
730            .expect("keyed by its own TypeId")
731    }
732
733    /// This session's identity. Stable across a re-initialise of the same
734    /// account, distinct for every other. Callers that key a cache by "which
735    /// account is this" use it in place of the old generation counter.
736    pub fn id(&self) -> u64 {
737        self.id
738    }
739
740    /// Whether this account has been switched away from.
741    ///
742    /// Purely an efficiency signal. Work that continues past it is still
743    /// CORRECT — it writes to this account's own storage and paints nothing —
744    /// it is just no longer work anyone is waiting for. Long syncs poll this at
745    /// their loop heads so a swap stops the relay traffic and the decryption
746    /// rather than grinding on for a screen nobody is looking at.
747    ///
748    /// Never use it to decide whether a write is SAFE. That is structural now.
749    pub fn stopped(&self) -> bool {
750        self.stopped.flag.load(std::sync::atomic::Ordering::Acquire)
751    }
752
753    /// Resolves once this account is switched away from — for `select!` against
754    /// a fetch, so an in-flight request is dropped instead of awaited out.
755    pub async fn on_stop(&self) {
756        loop {
757            let waiting = self.stopped.wake.notified();
758            if self.stopped() {
759                return;
760            }
761            waiting.await;
762            if self.stopped() {
763                return;
764            }
765        }
766    }
767
768    fn stop(&self) {
769        self.stopped.flag.store(true, std::sync::atomic::Ordering::Release);
770        self.stopped.wake.notify_waiters();
771    }
772
773    /// Whether this session is the account currently on screen.
774    ///
775    /// For code holding a session it captured earlier — a drop handler, a
776    /// callback fired by a relay OK — where there is no await to be bound
777    /// across and the question is "is what I captured still current?".
778    pub fn is_live(&self) -> bool {
779        self.id == CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id
780    }
781
782    /// This account's in-memory chats and profiles.
783    ///
784    /// Reached through the session, so a task bound to account A keeps reading
785    /// and writing A's chats after a swap. Its state is then an orphan nothing
786    /// displays, rather than corruption in the account now on screen.
787    pub fn chat_state(&self) -> Arc<tokio::sync::Mutex<crate::state::ChatState>> {
788        self.chat_state.clone()
789    }
790
791    /// Where THIS session opens connections. A bound session never consults the
792    /// ambient account, so a pool miss after a swap still opens the file this
793    /// session belongs to rather than the incoming account's.
794    fn path(&self) -> Result<PathBuf, String> {
795        match &self.db_path {
796            Some(p) => Ok(p.clone()),
797            None => get_current_db_path(),
798        }
799    }
800
801    /// Take a READ connection from this session, opening one against this
802    /// session's own database if the pool is empty.
803    pub fn acquire_read(self: &Arc<Self>) -> Result<ConnectionGuard, String> {
804        if let Ok(mut pool) = self.read_pool.lock() {
805            if let Some(conn) = pool.pop() {
806                return Ok(ConnectionGuard::new(conn, self.clone()));
807            }
808        }
809        let conn = create_connection(&self.path()?)?;
810        Ok(ConnectionGuard::new(conn, self.clone()))
811    }
812
813    /// Take THE write connection from this session, opening one against this
814    /// session's own database if the slot is empty.
815    pub fn acquire_write(self: &Arc<Self>) -> Result<WriteConnectionGuard, String> {
816        {
817            let mut slot = self.write_conn.lock().unwrap_or_else(|e| e.into_inner());
818            if let Some(conn) = slot.take() {
819                return Ok(WriteConnectionGuard::new(conn, self.clone()));
820            }
821        }
822        let conn = create_connection(&self.path()?)?;
823        Ok(WriteConnectionGuard::new(conn, self.clone()))
824    }
825}
826
827/// The account whose resources new work binds to. Read once at the START of a
828/// unit of work; hold the `Arc` for its duration.
829static CURRENT_SESSION: LazyLock<RwLock<Arc<Session>>> = LazyLock::new(|| RwLock::new(Session::empty()));
830
831tokio::task_local! {
832    /// The session this task is bound to, installed by [`spawn_bound`].
833    ///
834    /// Readable from any code running on the task, including the synchronous
835    /// `db::` helpers an async body calls — which is what lets every existing
836    /// call site become account-correct without growing a parameter.
837    static TASK_SESSION: Arc<Session>;
838}
839
840/// The session THIS work belongs to.
841///
842/// A task bound by [`spawn_bound`] gets the account it started under, for its
843/// whole life, however many awaits it spans and whoever logs in meanwhile. That
844/// is the property the std::sync::Arc<crate::db::Session> checks were approximating by hand.
845///
846/// Unbound callers (startup, the UI command that performs the swap itself) get
847/// the live account, which for them is the correct and only meaningful answer.
848pub fn current_session() -> Arc<Session> {
849    TASK_SESSION
850        .try_with(Arc::clone)
851        .unwrap_or_else(|_| CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).clone())
852}
853
854/// Run `fut` pinned to the CURRENT account, without spawning.
855///
856/// The counterpart to [`spawn_bound`] for work that is NOT a task we started:
857/// a Tauri command, an SDK call, a JNI entry point. Nothing installs a session
858/// for those, so every `db::` call inside them re-resolves the live account and
859/// a swap mid-operation silently moves the rest of their writes. Wrapping the
860/// body fixes the account for its whole duration — a swap then leaves the
861/// operation completing against the account that asked for it, which is both
862/// the correct outcome and the one the user intended.
863///
864/// This is what makes the hand-written "did the account change?" checks
865/// unnecessary rather than merely redundant.
866/// NOT an `async fn`, and that is load-bearing. An `async fn` stores its
867/// parameters in the state machine it returns, so `scoped` would be sized to
868/// hold the body it binds — and nesting would multiply, exactly as passing the
869/// future to `scope` unboxed does. A plain fn returning `impl Future` hands
870/// back a wrapper around a `Pin<Box<_>>`: a pointer, whatever the body.
871///
872/// This is not hypothetical. Boot crashed with a stack overflow on BOTH Android
873/// and macOS, several layers into the community sync, on stacks every test here
874/// runs on happily. `binding_a_future_does_not_embed_it` is the guard.
875pub fn scoped<F: std::future::Future>(fut: F) -> impl std::future::Future<Output = F::Output> {
876    TASK_SESSION.scope(current_session(), Box::pin(fut))
877}
878
879/// [`scoped`], but the RESULT is refused if the account changed while it ran.
880///
881/// For an operation whose value goes back to the UI. The work itself completed
882/// correctly against its own account; what must not happen is that value being
883/// painted into, or acted on by, the account now on screen. One wrapper in
884/// place of a check before every write.
885pub fn scoped_result<T, E, F>(fut: F) -> impl std::future::Future<Output = Result<T, E>>
886where
887    F: std::future::Future<Output = Result<T, E>>,
888    E: From<String>,
889{
890    let session = current_session();
891    let id = session.id;
892    // Bind first, so the async block below holds only the (pointer-sized)
893    // bound future rather than the body. See `scoped`.
894    let bound = TASK_SESSION.scope(session, Box::pin(fut));
895    async move {
896        let out = bound.await;
897        if id != CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id {
898            return Err(E::from("account changed during the operation".to_string()));
899        }
900        out
901    }
902}
903
904/// Run `fut` pinned to a session you already hold.
905///
906/// For code that captured a session earlier and wants to finish that account's
907/// work through it — and for tests, which need to read an account's storage
908/// after the live session has moved on.
909pub fn with_session<F: std::future::Future>(
910    session: Arc<Session>,
911    fut: F,
912) -> impl std::future::Future<Output = F::Output> {
913    TASK_SESSION.scope(session, Box::pin(fut))
914}
915
916/// Spawn a task pinned to the CURRENT account.
917///
918/// Everything it does through `db::` resolves to that account for as long as it
919/// runs, so an account switch mid-flight can no longer redirect its writes. Use
920/// this for any task that touches per-account state; a bare `tokio::spawn`
921/// leaves the task reading whoever is live at the moment it asks.
922pub fn spawn_bound<F>(fut: F) -> crate::rt::JoinHandle<F::Output>
923where
924    F: std::future::Future + crate::rt::MaybeSend + 'static,
925    F::Output: crate::rt::MaybeSend + 'static,
926{
927    let session = current_session();
928    // spawn-detached: this IS the binding — it installs the session it just read.
929    crate::rt::spawn(TASK_SESSION.scope(session, fut))
930}
931
932impl std::fmt::Debug for Session {
933    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
934        f.debug_struct("Session").field("id", &self.id).field("db", &self.db_path).finish()
935    }
936}
937
938/// Has the account this work belongs to been switched away from?
939///
940/// Check it at the head of a long loop — a sync page, a relay in a fan-out, a
941/// channel in a sweep — and stop. Continuing is safe, just wasteful.
942pub fn session_stopped() -> bool {
943    current_session().stopped()
944}
945
946/// The live account's session id — for caches keyed by "which account is this".
947pub fn current_session_id() -> u64 {
948    current_session().id
949}
950
951/// Whether the work running here belongs to the account currently on screen.
952///
953/// There is exactly one UI, showing one account. A task bound to a previous
954/// account keeps working correctly — its database, its chats, its client — but
955/// what it produces must not be painted into the account the user is now
956/// looking at. Every emission asks this, which is why almost none of them has
957/// to ask it by hand.
958pub fn session_is_live() -> bool {
959    current_session().id == CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner()).id
960}
961
962fn next_session_id() -> u64 {
963    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
964    NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
965}
966
967/// Install a fresh session, dropping the reference to the previous one. Any
968/// guard still outstanding against the old session returns its connection
969/// there, and that pool closes with it.
970fn replace_session() {
971    install(Session::empty());
972}
973
974/// Install `next` and tell the outgoing account's work to stop.
975fn install(next: Arc<Session>) {
976    let mut current = CURRENT_SESSION.write().unwrap_or_else(|e| e.into_inner());
977    if current.id != next.id {
978        current.stop();
979    }
980    *current = next;
981}
982
983/// RAII guard for READ connections — auto-returns to its OWN session's pool.
984///
985/// Holding the `Arc` is what makes the return safe: the connection goes back
986/// where it came from, so a guard outstanding across an account swap can never
987/// hand account A's connection to account B. When that older session has no
988/// references left, its pool closes with it.
989pub struct ConnectionGuard {
990    conn: Option<rusqlite::Connection>,
991    session: Arc<Session>,
992}
993
994impl ConnectionGuard {
995    fn new(conn: rusqlite::Connection, session: Arc<Session>) -> Self {
996        Self { conn: Some(conn), session }
997    }
998}
999
1000impl Deref for ConnectionGuard {
1001    type Target = rusqlite::Connection;
1002    fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Connection already taken") }
1003}
1004
1005impl DerefMut for ConnectionGuard {
1006    fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Connection already taken") }
1007}
1008
1009impl Drop for ConnectionGuard {
1010    fn drop(&mut self) {
1011        if let Some(conn) = self.conn.take() {
1012            if let Ok(mut pool) = self.session.read_pool.lock() {
1013                pool.push(conn);
1014            }
1015        }
1016    }
1017}
1018
1019/// RAII guard for the WRITE connection — auto-returns on drop.
1020pub struct WriteConnectionGuard {
1021    conn: Option<rusqlite::Connection>,
1022    session: Arc<Session>,
1023}
1024
1025impl WriteConnectionGuard {
1026    fn new(conn: rusqlite::Connection, session: Arc<Session>) -> Self {
1027        Self { conn: Some(conn), session }
1028    }
1029}
1030
1031impl Deref for WriteConnectionGuard {
1032    type Target = rusqlite::Connection;
1033    fn deref(&self) -> &Self::Target { self.conn.as_ref().expect("Write connection already taken") }
1034}
1035
1036impl DerefMut for WriteConnectionGuard {
1037    fn deref_mut(&mut self) -> &mut Self::Target { self.conn.as_mut().expect("Write connection already taken") }
1038}
1039
1040impl Drop for WriteConnectionGuard {
1041    fn drop(&mut self) {
1042        if let Some(conn) = self.conn.take() {
1043            // Slot-empty check remains: a fresh write connection may already
1044            // have been installed for this same session.
1045            if let Ok(mut slot) = self.session.write_conn.lock() {
1046                if slot.is_none() {
1047                    *slot = Some(conn);
1048                }
1049            }
1050        }
1051    }
1052}
1053
1054// ============================================================================
1055// Connection Factory
1056// ============================================================================
1057
1058/// Single source of truth for per-account directories. Every per-account
1059/// subsystem (DB, Tor state) resolves its path through this;
1060/// compose further subpaths with `.join(...)` — never insert layers
1061/// between `<app_data>` and `<npub>`.
1062///
1063/// Validates npub format before joining as defence-in-depth against
1064/// path traversal: a crafted IPC input like `"../../etc"` would
1065/// otherwise yield `<app_data>/../../etc` and downstream
1066/// `remove_dir_all` (delete_account, logout) would walk arbitrary dirs.
1067pub fn account_dir(npub: &str) -> Result<PathBuf, String> {
1068    if !is_valid_npub(npub) {
1069        return Err(format!("Invalid npub format: {}", npub));
1070    }
1071    Ok(get_app_data_dir()?.join(npub))
1072}
1073
1074/// The directory of the account this task belongs to, for files that are that
1075/// account's alone. Resolved through the session, never the account on screen.
1076pub fn current_account_dir() -> Result<PathBuf, String> {
1077    let db = current_session().path()?;
1078    db.parent().map(PathBuf::from).ok_or_else(|| "account directory unknown".to_string())
1079}
1080
1081fn get_current_db_path() -> Result<PathBuf, String> {
1082    let npub = get_current_account()?;
1083    Ok(account_dir(&npub)?.join("vector.db"))
1084}
1085
1086/// Open a connection, riding out a TRANSIENT lock on the file.
1087///
1088/// `busy_timeout` governs waits inside a connection that already exists; it
1089/// cannot help the open itself, and `journal_mode=WAL` needs the lock briefly.
1090/// A task started under the previous account can still be holding this file for
1091/// a moment — it resolves the CURRENT account when it takes a connection, so a
1092/// swap points it here — and failing outright turns that into a failed login or
1093/// a failed account switch. Bounded: a genuinely stuck holder still surfaces.
1094fn create_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
1095    const OPEN_RETRIES: u32 = 4;
1096    let mut last_err = String::new();
1097    for attempt in 0..OPEN_RETRIES {
1098        match open_connection(path) {
1099            Ok(conn) => return Ok(conn),
1100            Err(e) if e.contains("locked") || e.contains("busy") => {
1101                last_err = e;
1102                // One thread on the web: nothing else can release the lock while we wait.
1103                #[cfg(not(target_arch = "wasm32"))]
1104                std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt + 1)));
1105                #[cfg(target_arch = "wasm32")]
1106                let _ = attempt;
1107            }
1108            Err(e) => return Err(e),
1109        }
1110    }
1111    Err(last_err)
1112}
1113
1114/// Set by a web build whose storage never syncs to disk itself (IndexedDB
1115/// mirroring, memory): SQLite must not ask it to, which IndexedDB refuses, and
1116/// with no shared memory there is no WAL, only a journal best kept in memory.
1117#[cfg(target_arch = "wasm32")]
1118static RELAXED_DURABILITY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1119
1120#[cfg(target_arch = "wasm32")]
1121pub fn set_relaxed_durability(on: bool) {
1122    RELAXED_DURABILITY.store(on, std::sync::atomic::Ordering::Relaxed);
1123}
1124
1125fn durability_pragmas() -> &'static str {
1126    #[cfg(target_arch = "wasm32")]
1127    if RELAXED_DURABILITY.load(std::sync::atomic::Ordering::Relaxed) {
1128        return "PRAGMA journal_mode=MEMORY; PRAGMA synchronous=OFF;";
1129    }
1130    "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;"
1131}
1132
1133fn open_connection(path: &PathBuf) -> Result<rusqlite::Connection, String> {
1134    let conn = rusqlite::Connection::open(path)
1135        .map_err(|e| format!("Failed to open database: {}", e))?;
1136
1137    // busy_timeout FIRST, alone: `journal_mode=WAL` takes a brief exclusive lock,
1138    // and until the timeout is set it is still 0 — so a connection opened while
1139    // another is active failed outright ("database is locked") instead of waiting
1140    // the moment or two the other needed. Every pragma below now waits too.
1141    conn.execute_batch("PRAGMA busy_timeout=5000;")
1142        .map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
1143
1144    // WAL for concurrent reads. cache_size negative = KiB (16 MiB page cache) to keep hot
1145    // pages resident on a large DB; temp_store=MEMORY keeps GROUP BY / sort scratch in
1146    // memory instead of spilling to disk.
1147    conn.execute_batch(&format!(
1148        "{} PRAGMA foreign_keys=ON; PRAGMA cache_size=-16000; PRAGMA temp_store=MEMORY;",
1149        durability_pragmas()
1150    ))
1151        .map_err(|e| format!("Failed to set pragmas: {}", e))?;
1152    // Room for every hot statement: page loads, unread counts and the persist path together
1153    // outgrow rusqlite's default of 16, and an evicted statement re-prepares on its next use.
1154    conn.set_prepared_statement_cache_capacity(64);
1155
1156    // Memory-mapped reads skip a copy and a syscall per page. 64-bit only: each pooled connection
1157    // maps the file separately, which a 32-bit address space can't spare.
1158    #[cfg(target_pointer_width = "64")]
1159    let _ = conn.execute_batch("PRAGMA mmap_size=268435456;");
1160
1161    Ok(conn)
1162}
1163
1164/// Get a READ connection (headless-safe — no AppHandle).
1165pub fn get_db_connection_guard_static() -> Result<ConnectionGuard, String> {
1166    current_session().acquire_read()
1167}
1168
1169/// Process-wide serialization lock for tests that install into the global DB pool.
1170/// Any test calling `init_database` must hold this for its whole body — otherwise
1171/// concurrent inits race on the shared account/data-dir state and clobber each other.
1172/// One shared guard across every module (community, ...) so cross-module test
1173/// parallelism can't collide.
1174#[cfg(test)]
1175pub(crate) static DB_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
1176
1177/// Get the WRITE connection (headless-safe — no AppHandle).
1178pub fn get_write_connection_guard_static() -> Result<WriteConnectionGuard, String> {
1179    current_session().acquire_write()
1180}
1181
1182// ============================================================================
1183// Downgrade guard
1184// ============================================================================
1185
1186/// Where each build stamps its version after successfully opening an account.
1187const LAST_APP_VERSION_KEY: &str = "last_app_version";
1188
1189/// A newer Vector already opened this account's database.
1190///
1191/// Vector has no downgrade path: older builds neither recognise nor preserve
1192/// newer schema, and the corruption that follows is silent until the user opens
1193/// the wrong chat.
1194#[derive(Debug, Clone, serde::Serialize)]
1195pub struct DowngradeBlock {
1196    /// Migration high-water mark found in the account's DB.
1197    pub db_schema: u32,
1198    /// Highest migration this build can apply.
1199    pub supported_schema: u32,
1200    /// App version that last opened it, when one was stamped.
1201    pub last_app_version: Option<String>,
1202}
1203
1204impl std::fmt::Display for DowngradeBlock {
1205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1206        write!(f, "This account was last opened by a newer version of Vector")?;
1207        if let Some(version) = &self.last_app_version {
1208            write!(f, " ({version})")?;
1209        }
1210        write!(
1211            f,
1212            ". Its database is at schema {} and this build only understands {}. \
1213             Opening it would corrupt your messages, so Vector has stopped. \
1214             Reinstall the newer version to continue.",
1215            self.db_schema, self.supported_schema
1216        )
1217    }
1218}
1219
1220/// The check itself, against an already-open connection.
1221fn downgrade_block(conn: &rusqlite::Connection) -> Option<DowngradeBlock> {
1222    let db_schema = schema::applied_migration_high_water(conn);
1223    if db_schema <= schema::HIGHEST_MIGRATION_ID {
1224        return None;
1225    }
1226    Some(DowngradeBlock {
1227        db_schema,
1228        supported_schema: schema::HIGHEST_MIGRATION_ID,
1229        last_app_version: conn
1230            .query_row(
1231                "SELECT value FROM settings WHERE key = ?1",
1232                rusqlite::params![LAST_APP_VERSION_KEY],
1233                |row| row.get::<_, String>(0),
1234            )
1235            .ok(),
1236    })
1237}
1238
1239/// Whether this account's DB was written by a newer Vector.
1240///
1241/// Read-only and side-effect free, so a host can call it before
1242/// [`init_database`] and put a real dialog in front of the user rather than
1243/// surfacing a failed boot.
1244pub fn inspect_downgrade(npub: &str) -> Result<Option<DowngradeBlock>, String> {
1245    let db_path = account_dir(npub)?.join("vector.db");
1246    // Never create the file just to inspect it.
1247    #[cfg(target_arch = "wasm32")]
1248    let exists = webfs::db_exists(&db_path);
1249    #[cfg(not(target_arch = "wasm32"))]
1250    let exists = db_path.exists();
1251    if !exists {
1252        return Ok(None);
1253    }
1254    let conn = create_connection(&db_path)?;
1255    Ok(downgrade_block(&conn))
1256}
1257
1258// ============================================================================
1259// Database Initialization
1260// ============================================================================
1261
1262/// Initialize the database for a given account (creates tables if needed).
1263pub fn init_database(npub: &str) -> Result<(), String> {
1264    let profile_dir = account_dir(npub)?;
1265
1266    #[cfg(not(target_arch = "wasm32"))]
1267    if !profile_dir.exists() {
1268        std::fs::create_dir_all(&profile_dir)
1269            .map_err(|e| format!("Failed to create profile directory: {}", e))?;
1270    }
1271    #[cfg(target_arch = "wasm32")]
1272    webfs::register_account(get_app_data_dir()?, npub)?;
1273
1274    let db_path = profile_dir.join("vector.db");
1275    let mut conn = create_connection(&db_path)?;
1276
1277    // Migrations are boot-time, one-shot work: if another connection holds the
1278    // write lock (a straggling task from the previous session, a slow host),
1279    // waiting longer is strictly better than failing account init. The 5s
1280    // default is tuned for steady-state queries, not this.
1281    conn.execute_batch("PRAGMA busy_timeout=30000;")
1282        .map_err(|e| format!("Failed to set migration busy_timeout: {}", e))?;
1283
1284    // Before ANY write. SQL_SCHEMA is all CREATE TABLE IF NOT EXISTS, so an
1285    // older build would resurrect tables newer migrations dropped and then
1286    // start writing rows against a schema it cannot see.
1287    if let Some(block) = downgrade_block(&conn) {
1288        return Err(block.to_string());
1289    }
1290
1291    conn.execute_batch(schema::SQL_SCHEMA)
1292        .map_err(|e| format!("Failed to create schema: {}", e))?;
1293
1294    // Run migrations
1295    schema::run_migrations(&mut conn)?;
1296
1297    // Stamped after migrations, so it names the build whose schema is now on
1298    // disk. A blocked build never reaches here, so this only ever records a
1299    // version that could actually read what it wrote.
1300    if let Some(version) = APP_VERSION.get() {
1301        let _ = conn.execute(
1302            "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
1303            rusqlite::params![LAST_APP_VERSION_KEY, version],
1304        );
1305    }
1306
1307    // SQLite's prescribed open-time step for long-lived connections: analyze every table that needs
1308    // it (missing stats, or grown/shrunk 10x), bounded by a temporary analysis_limit so it stays
1309    // fast. The 0x10000 bit forces checking all tables since a fresh connection has no query history;
1310    // this also satisfies the "run optimize after CREATE INDEX" guidance for the migrations above.
1311    let _ = conn.execute_batch("PRAGMA optimize=0x10002;");
1312
1313    // Seed the in-session delete-tombstone set from this account's durable rows,
1314    // so ingest keeps refusing deleted messages across restarts (the swap's
1315    // session bump cleared the set; a fresh boot starts empty).
1316    {
1317        let mut stmt = conn.prepare("SELECT event_id FROM deleted_messages")
1318            .map_err(|e| format!("tombstone seed prepare: {}", e))?;
1319        let ids: Vec<String> = stmt.query_map([], |row| row.get::<_, String>(0))
1320            .map_err(|e| format!("tombstone seed query: {}", e))?
1321            .filter_map(|r| r.ok())
1322            .collect();
1323        drop(stmt);
1324        crate::state::seed_message_tombstones(ids);
1325    }
1326
1327    // MLS is fully removed. Migration 41 drops the relational tables, but the OpenMLS/MDK
1328    // crypto store lived in a SEPARATE per-account file (`<account>/mls/`) that no migration
1329    // can reach. Purge it here: it's dead weight (can run to hundreds of MB) and, worse,
1330    // stale MLS private key material lingering for a feature that no longer exists. Best-effort
1331    // and idempotent — a cleanup failure must never block account init.
1332    let mls_dir = profile_dir.join("mls");
1333    if mls_dir.exists() {
1334        match std::fs::remove_dir_all(&mls_dir) {
1335            Ok(()) => crate::log_info!("[db] purged orphaned MLS store for account"),
1336            Err(e) => crate::log_warn!("[db] could not purge orphaned MLS store: {}", e),
1337        }
1338    }
1339
1340    // Install a NEW session before opening anything: guards still outstanding
1341    // against the previous one return their connections there, and that pool
1342    // closes with it. Nothing from the old account can reach the new pool.
1343    //
1344    // A session already pointed at this database, or not yet pointed at one at
1345    // all, is THIS account's — bind it and keep what it holds (see `rebound`).
1346    // Only a session belonging to a different account starts over.
1347    let session = {
1348        let next = {
1349            let current = CURRENT_SESSION.read().unwrap_or_else(|e| e.into_inner());
1350            match current.db_path.as_deref() {
1351                Some(p) if p != db_path => Session::bound(db_path.clone()),
1352                _ => current.rebound(db_path.clone()),
1353            }
1354        };
1355        install(next.clone());
1356        next
1357    };
1358
1359    // Pre-warm read pool
1360    if let Ok(mut pool) = session.read_pool.lock() {
1361        for _ in 0..4 {
1362            if let Ok(c) = create_connection(&db_path) {
1363                pool.push(c);
1364            }
1365        }
1366    }
1367
1368    // Set write connection
1369    let write_conn = create_connection(&db_path)?;
1370    *session.write_conn.lock().unwrap_or_else(|e| e.into_inner()) = Some(write_conn);
1371
1372    // Hydrate Tor's hot-path settings cache directly from `db_path`,
1373    // NOT via `get_sql_setting()` — the global helper resolves through
1374    // the read pool + `get_current_account()`, neither of which yet
1375    // reflects this account (switch_account calls init_database BEFORE
1376    // set_current_account).
1377    #[cfg(feature = "tor")]
1378    {
1379        let enabled = create_connection(&db_path)
1380            .ok()
1381            .and_then(|c| {
1382                c.query_row(
1383                    "SELECT value FROM settings WHERE key = 'tor_enabled'",
1384                    [],
1385                    |row| row.get::<_, String>(0),
1386                )
1387                .ok()
1388            })
1389            .map(|v| v == "1" || v == "true")
1390            .unwrap_or(false);
1391        crate::tor::set_tor_enabled_pref(enabled);
1392    }
1393
1394    // Read the notification preferences now: every badge recount resolves every
1395    // chat against them, so a lazy first read would land a SQLite round trip on
1396    // that path with the chat state lock held.
1397    crate::notify::warm();
1398
1399    Ok(())
1400}
1401
1402/// Drop this account's database resources.
1403///
1404/// Installing a fresh session IS the teardown: the previous one is released,
1405/// and its connections close once the last outstanding guard returns. A guard
1406/// still in flight keeps serving the account it began under.
1407pub fn close_database() {
1408    replace_session();
1409}
1410
1411/// Run plain `PRAGMA optimize` on the live write connection — the periodic top-up SQLite recommends
1412/// for long-lived connections (the heavy lifting is the `optimize=0x10002` at connection open).
1413/// Best-effort and cheap: re-analyzes only tables whose stats the planner used and that changed
1414/// materially since the last run.
1415pub fn optimize_database() {
1416    let session = current_session();
1417    let guard = session.write_conn.lock().unwrap_or_else(|e| e.into_inner());
1418    if let Some(conn) = guard.as_ref() {
1419        let _ = conn.execute_batch("PRAGMA optimize;");
1420    }
1421}
1422
1423/// Get all available accounts (npub directories in app data).
1424pub fn get_accounts() -> Result<Vec<String>, String> {
1425    let app_data = get_app_data_dir()?;
1426    #[cfg(target_arch = "wasm32")]
1427    return Ok(list_account_npubs_in(app_data)
1428        .into_iter()
1429        .filter(|npub| webfs::db_exists(&app_data.join(npub).join("vector.db")))
1430        .collect());
1431    #[cfg(not(target_arch = "wasm32"))]
1432    {
1433        let mut accounts = Vec::new();
1434
1435        if let Ok(entries) = std::fs::read_dir(app_data) {
1436            for entry in entries.flatten() {
1437                if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
1438                    let name = entry.file_name().to_string_lossy().to_string();
1439                    if name.starts_with("npub1") {
1440                        // Check if vector.db exists
1441                        if entry.path().join("vector.db").exists() {
1442                            accounts.push(name);
1443                        }
1444                    }
1445                }
1446            }
1447        }
1448
1449        Ok(accounts)
1450    }
1451}
1452
1453/// Get the profile directory path for a given npub.
1454pub fn get_profile_directory(npub: &str) -> Result<PathBuf, String> {
1455    if !npub.starts_with("npub1") {
1456        return Err(format!("Invalid npub format: {}", npub));
1457    }
1458    let dir = account_dir(npub)?;
1459    #[cfg(not(target_arch = "wasm32"))]
1460    if !dir.exists() {
1461        std::fs::create_dir_all(&dir)
1462            .map_err(|e| format!("Failed to create profile directory: {}", e))?;
1463    }
1464    Ok(dir)
1465}
1466
1467/// Get database path for a given npub.
1468pub fn get_database_path(npub: &str) -> Result<PathBuf, String> {
1469    Ok(get_profile_directory(npub)?.join("vector.db"))
1470}
1471
1472// ============================================================================
1473// ID Caches
1474// ============================================================================
1475
1476/// Clear every id cache on account switch. Row ids are PER-ACCOUNT (each account
1477/// has its own DB + id sequence), so a stale entry after a swap points into the
1478/// wrong DB: writes FK-fail silently and reads hit the wrong row. The caches live
1479/// in `id_cache`; this is the public entry the swap path + callers already use.
1480pub fn clear_id_caches() {
1481    id_cache::clear_id_caches();
1482    community::clear_banlist_cache();
1483    community::clear_channel_community_cache();
1484}
1485
1486// ============================================================================
1487// System Event Types
1488// ============================================================================
1489
1490#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1491#[repr(u8)]
1492pub enum SystemEventType {
1493    MemberLeft = 0,
1494    MemberJoined = 1,
1495    MemberRemoved = 2,
1496    WallpaperChanged = 3,
1497}
1498
1499impl SystemEventType {
1500    pub fn display_message(&self, display_name: &str) -> String {
1501        match self {
1502            SystemEventType::MemberLeft => format!("{} has left", display_name),
1503            SystemEventType::MemberJoined => format!("{} has joined", display_name),
1504            SystemEventType::MemberRemoved => format!("{} was removed", display_name),
1505            SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
1506        }
1507    }
1508
1509    pub fn as_u8(&self) -> u8 { *self as u8 }
1510}
1511
1512#[cfg(test)]
1513mod pool_generation_tests {
1514    use super::*;
1515
1516    /// Build a minimal in-memory SQLite connection — just enough to drop
1517    /// a connection through the guard machinery. We don't run schema or
1518    /// migrations because we only care about the guard's Drop pathway.
1519    fn fake_conn() -> rusqlite::Connection {
1520        rusqlite::Connection::open_in_memory().unwrap()
1521    }
1522
1523    #[test]
1524    fn close_database_installs_a_fresh_session() {
1525        // The guard is the POINT here, not boilerplate: `close_database`
1526        // replaces the process-global session, so running unguarded yanks the
1527        // database out from under whichever test is mid-query.
1528        let _guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1529        let before = current_session();
1530        close_database();
1531        let after = current_session();
1532        assert!(
1533            !Arc::ptr_eq(&before, &after),
1534            "close_database must install a NEW session — the old one is what in-flight guards return to"
1535        );
1536    }
1537
1538    #[test]
1539    fn a_guard_returns_its_connection_to_its_own_session() {
1540        let session = Session::empty();
1541        drop(ConnectionGuard::new(fake_conn(), session.clone()));
1542        assert_eq!(session.read_pool.lock().unwrap().len(), 1, "the connection goes home");
1543    }
1544
1545    #[test]
1546    fn a_guard_outstanding_across_a_swap_returns_to_the_session_it_came_from() {
1547        // The property that replaced the generation counter, and a stronger
1548        // one: the old design could only assert a stale connection did NOT
1549        // pollute the new pool. Holding the Arc says where it actually went,
1550        // so account A's connection is unreachable from account B by
1551        // construction rather than by a comparison someone has to remember.
1552        let old = Session::empty();
1553        let new_session = Session::empty();
1554
1555        let guard = ConnectionGuard::new(fake_conn(), old.clone());
1556        // ...the account switches while the guard is still in flight...
1557        drop(guard);
1558
1559        assert_eq!(old.read_pool.lock().unwrap().len(), 1, "returned to the session it was taken from");
1560        assert_eq!(new_session.read_pool.lock().unwrap().len(), 0, "never reachable from the new account");
1561    }
1562
1563    #[test]
1564    fn a_pool_miss_after_a_swap_opens_the_sessions_own_database() {
1565        // The other half of the guarantee. Returning a connection was already
1566        // safe; ACQUIRING one was not, because a miss resolved the ambient
1567        // account and could open the incoming account's file into the outgoing
1568        // account's pool. A bound session never asks what is current.
1569        let dir = tempfile::tempdir().unwrap();
1570        let path_a = dir.path().join("a.db");
1571        let path_b = dir.path().join("b.db");
1572
1573        let session_a = Session::bound(path_a.clone());
1574        let _unrelated = Session::bound(path_b.clone());
1575
1576        // A task still holding A misses A's (empty) pool and opens a connection.
1577        let guard = session_a.acquire_read().expect("acquire against the held session");
1578        // Compare by filename: macOS reports the /private-resolved path.
1579        let opened = guard.path().expect("a file-backed connection").to_string();
1580        assert!(
1581            opened.ends_with("a.db") && !opened.ends_with("b.db"),
1582            "a miss opens the session's OWN database, never the incoming account's (opened {opened})"
1583        );
1584        assert!(!Arc::ptr_eq(&session_a, &current_session()), "the live session really did move on");
1585    }
1586
1587    /// Run `fut` bound to `session`, exactly as `spawn_bound` would.
1588    ///
1589    /// These tests never install their throwaway sessions globally. They used
1590    /// to, and every other test in the binary reads chats, caches and keys
1591    /// through the live session — so one of these swapping it mid-run pulled
1592    /// them out from under whatever was executing in parallel. Binding proves
1593    /// the same property without a global write.
1594    async fn bound_to<F: std::future::Future>(session: Arc<Session>, fut: F) -> F::Output {
1595        TASK_SESSION.scope(session, fut).await
1596    }
1597
1598    /// Every task that touches per-account state must be bound to an account.
1599    ///
1600    /// The worklist is empty: every spawn in this crate either binds or says on
1601    /// the line why it owns no account state. See [`crate::spawn_audit`].
1602    #[test]
1603    fn per_account_tasks_are_spawned_bound_to_their_account() {
1604        crate::spawn_audit::assert_all_spawns_bound(std::path::Path::new(env!("CARGO_MANIFEST_DIR")), &[]);
1605    }
1606
1607    #[tokio::test]
1608    async fn a_bound_task_keeps_its_account_across_a_swap() {
1609        // What the std::sync::Arc<crate::db::Session> checks were approximating by hand: the task
1610        // began under account A, so its work resolves to A however many awaits
1611        // it spans and whoever logs in meanwhile. No check, nothing to forget.
1612        // The live session stands in for whoever logged in next.
1613        let dir = tempfile::tempdir().unwrap();
1614        let a = Session::bound(dir.path().join("a.db"));
1615
1616        let seen = bound_to(a.clone(), async {
1617            crate::rt::yield_now().await;
1618            current_session()
1619        })
1620        .await;
1621
1622        assert!(Arc::ptr_eq(&seen, &a), "the task still sees the account it started under");
1623        assert!(!Arc::ptr_eq(&seen, &current_session()), "the live account is not reachable from it");
1624    }
1625
1626    #[tokio::test]
1627    async fn a_bound_tasks_chat_writes_cannot_reach_the_new_account() {
1628        // The bug this closes, in the form it actually took: a task holding a
1629        // chat id from account A finishes after the swap and inserts it into
1630        // whatever STATE it finds. The group chats that appeared in a freshly
1631        // created account arrived exactly this way.
1632        use crate::chat::{Chat, ChatType};
1633        let dir = tempfile::tempdir().unwrap();
1634        let a = Session::bound(dir.path().join("a.db"));
1635        let live_before = current_session().chat_state().lock().await.chats.len();
1636
1637        bound_to(a.clone(), async {
1638            crate::rt::yield_now().await;
1639            crate::state::STATE.lock().await.chats.push(Chat::new("a-chat".into(), ChatType::DirectMessage, Vec::new()));
1640        })
1641        .await;
1642
1643        assert_eq!(
1644            a.chat_state().lock().await.chats.len(),
1645            1,
1646            "it landed in the state of the account the task began under"
1647        );
1648        assert_eq!(
1649            current_session().chat_state().lock().await.chats.len(),
1650            live_before,
1651            "and nothing reached the account on screen"
1652        );
1653    }
1654
1655    #[tokio::test]
1656    async fn a_bound_task_cannot_publish_through_the_new_accounts_client() {
1657        // The client carries the signer, so reaching the wrong one means
1658        // publishing account A's payload under account B's identity. A bound
1659        // task reaches the client it started with, which the swap has already
1660        // shut down — its send fails instead of succeeding as the wrong person.
1661        use nostr_sdk::prelude::*;
1662        let dir = tempfile::tempdir().unwrap();
1663        let a = Session::bound(dir.path().join("a.db"));
1664        let a_identity = Keys::generate().public_key();
1665
1666        let seen = bound_to(a.clone(), async move {
1667            crate::state::set_my_public_key(a_identity);
1668            crate::rt::yield_now().await;
1669            crate::state::my_public_key()
1670        })
1671        .await;
1672
1673        assert_eq!(seen, Some(a_identity), "the task signs as the account it began under");
1674        assert_ne!(crate::state::my_public_key(), Some(a_identity), "and the live account is someone else");
1675    }
1676
1677    #[test]
1678    fn binding_an_unbound_session_to_a_database_keeps_what_it_holds() {
1679        // Creating an account installs its keys and client BEFORE its database
1680        // exists — `init_database` only runs once the user has chosen a PIN.
1681        // Replacing the session there would discard the login in progress.
1682        let dir = tempfile::tempdir().unwrap();
1683        let staging = Session::empty();
1684        let held = staging.scoped::<Session, Mutex<u8>>();
1685        *held.lock().unwrap() = 7;
1686
1687        let promoted = staging.rebound(dir.path().join("a.db"));
1688        assert_eq!(*promoted.scoped::<Session, Mutex<u8>>().lock().unwrap(), 7, "the login survives being bound");
1689        assert!(promoted.db_path.is_some(), "and it now has a database");
1690        assert_eq!(promoted.id, staging.id, "and it is still the same account, so its tasks keep painting");
1691    }
1692
1693    #[tokio::test]
1694    async fn a_bound_task_paints_nothing_into_the_account_on_screen() {
1695        // Binding fixes where work LANDS; it cannot fix what the user SEES,
1696        // because there is one UI showing one account. So emission asks once,
1697        // centrally, and a task belonging to a previous account goes quiet.
1698        let dir = tempfile::tempdir().unwrap();
1699        let previous = Session::bound(dir.path().join("previous.db"));
1700        assert!(session_is_live(), "work for the account on screen paints");
1701
1702        let painted = bound_to(previous, async {
1703            crate::rt::yield_now().await;
1704            session_is_live()
1705        })
1706        .await;
1707
1708        assert!(!painted, "a task bound to another account paints nothing");
1709        assert!(session_is_live(), "and the account now on screen still does");
1710    }
1711
1712    #[tokio::test]
1713    async fn switching_accounts_tells_the_previous_one_to_stop() {
1714        // Purely an efficiency signal — the work would still be correct — but a
1715        // boot sync is minutes of relay traffic and decryption, and after a swap
1716        // nobody is waiting for any of it.
1717        let _serialized = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1718        let previous = current_session();
1719        assert!(!previous.stopped(), "running work is not told to stop");
1720
1721        // `on_stop` must be pending until the switch, then resolve.
1722        let waiter = { let p = previous.clone(); crate::rt::spawn(async move { p.on_stop().await }) };
1723        crate::rt::yield_now().await;
1724        assert!(!waiter.is_finished(), "nothing to report while the account is current");
1725
1726        close_database();
1727
1728        assert!(previous.stopped(), "the outgoing account is told to stop");
1729        assert!(!current_session().stopped(), "the incoming one is not");
1730        crate::rt::time::timeout(std::time::Duration::from_secs(5), waiter)
1731            .await
1732            .expect("on_stop resolves on the switch")
1733            .expect("without panicking");
1734    }
1735
1736    #[test]
1737    fn re_initialising_the_same_account_does_not_tell_it_to_stop() {
1738        // `init_database` runs more than once per account — the schema check at
1739        // boot, Android's background sync. Reading that as a swap would abort a
1740        // sync that is still perfectly wanted.
1741        let dir = tempfile::tempdir().unwrap();
1742        let staging = Session::empty();
1743        let promoted = staging.rebound(dir.path().join("a.db"));
1744        assert!(!staging.stopped(), "binding a session is not switching away from it");
1745        assert_eq!(promoted.id, staging.id);
1746    }
1747
1748    /// Binding must not grow with what it binds.
1749    ///
1750    /// `scope` takes the future BY VALUE, so an unboxed `scoped` embeds the
1751    /// whole inner state machine in its caller's. Nesting then multiplies, and
1752    /// a deep chain of bound calls overflows a worker stack — which is exactly
1753    /// what happened: boot crashed on BOTH Android and macOS, several layers
1754    /// into the community sync, on stacks every one of these tests runs on
1755    /// happily. Sizes are what the compiler gives us; the invariant is that the
1756    /// wrapper adds a bounded amount regardless of the body.
1757    #[test]
1758    fn binding_a_future_does_not_embed_it() {
1759        // A deliberately fat body: 8KB of state the future must carry.
1760        let fat = async {
1761            let block = [0u8; 8192];
1762            crate::rt::yield_now().await;
1763            block[0]
1764        };
1765        let fat_size = std::mem::size_of_val(&fat);
1766        assert!(fat_size >= 8192, "the body really is large ({fat_size})");
1767
1768        let bound = scoped(fat);
1769        let bound_size = std::mem::size_of_val(&bound);
1770        assert!(
1771            bound_size < 1024,
1772            "binding must cost a pointer, not a copy of the body \
1773             (body {fat_size} bytes, bound {bound_size})"
1774        );
1775    }
1776
1777    #[test]
1778    fn a_dropped_session_closes_its_pool() {
1779        // Teardown IS the drop: no clearing step to forget.
1780        let session = Session::empty();
1781        drop(ConnectionGuard::new(fake_conn(), session.clone()));
1782        assert_eq!(Arc::strong_count(&session), 1, "the guard released its reference");
1783        drop(session); // pool + connections close here
1784    }
1785
1786    #[test]
1787    fn a_stale_write_guard_cannot_clobber_the_new_accounts_connection() {
1788        // Under the old design the stale guard and the fresh connection shared
1789        // one global slot, so the guard's Drop had to be talked out of
1790        // overwriting it. They are now in different sessions and cannot meet.
1791        let old = Session::empty();
1792        let new_session = Session::empty();
1793
1794        let stale_guard = WriteConnectionGuard::new(fake_conn(), old.clone());
1795        // init_database installs the new account's write connection.
1796        *new_session.write_conn.lock().unwrap() = Some(fake_conn());
1797
1798        drop(stale_guard);
1799
1800        assert!(
1801            new_session.write_conn.lock().unwrap().is_some(),
1802            "the new account's write connection is untouched"
1803        );
1804        assert!(
1805            old.write_conn.lock().unwrap().is_some(),
1806            "the stale guard returned to its own session's slot"
1807        );
1808    }
1809
1810    #[test]
1811    fn a_new_accounts_empty_write_slot_stays_empty() {
1812        // The old worry: a stale guard filling the fresh account's empty slot
1813        // with a connection pointing at a different database. It has no way to
1814        // reach that slot now — it only knows its own session.
1815        let old = Session::empty();
1816        let new_session = Session::empty();
1817
1818        let stale_guard = WriteConnectionGuard::new(fake_conn(), old.clone());
1819        drop(stale_guard);
1820
1821        assert!(
1822            new_session.write_conn.lock().unwrap().is_none(),
1823            "the new account's slot is untouched by the previous account's guard"
1824        );
1825    }
1826}
1827
1828#[cfg(test)]
1829mod downgrade_tests {
1830    use super::*;
1831
1832    fn test_account() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, String) {
1833        let guard = DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1834        close_database();
1835        clear_id_caches();
1836        let tmp = tempfile::tempdir().unwrap();
1837        // Bases must not collide across test modules: APP_DATA_DIR is a
1838        // OnceLock shared by the whole binary, and this generator collapses
1839        // after ~4 chars, so nearby seeds yield near-identical npubs.
1840        // Taken: 0, 900, 5_000, 50_000, 70_000, 71_000, 81_000, 90_000.
1841        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(61_000);
1842        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1843        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1844        let mut acct = String::from("npub1");
1845        let mut v = n as usize;
1846        for _ in 0..58 {
1847            acct.push(B[v % 32] as char);
1848            v = v / 32 + 7;
1849        }
1850        set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
1851        set_current_account(acct.clone()).unwrap();
1852        (tmp, guard, acct)
1853    }
1854
1855    #[tokio::test]
1856    async fn re_initialising_the_same_account_keeps_its_loaded_state() {
1857        // init_database is documented idempotent, and callers lean on that —
1858        // the schema check and Android's background sync both re-run it against
1859        // an account already loaded. Dropping its chats there would empty the
1860        // list under a running app.
1861        use crate::chat::{Chat, ChatType};
1862        let (_dir, _lock, acct) = test_account();
1863        init_database(&acct).unwrap();
1864        crate::state::STATE.lock().await.chats.push(Chat::new("kept".into(), ChatType::DirectMessage, Vec::new()));
1865
1866        init_database(&acct).unwrap();
1867        assert_eq!(crate::state::STATE.lock().await.chats.len(), 1, "same account, same in-memory state");
1868    }
1869
1870    /// A DB this build fully understands must open, or the guard is useless.
1871    #[test]
1872    fn an_equal_schema_opens_normally() {
1873        let (_tmp, _guard, acct) = test_account();
1874        init_database(&acct).unwrap();
1875        assert!(inspect_downgrade(&acct).unwrap().is_none());
1876        // Re-opening is still fine: the stamp write must not trip the guard.
1877        init_database(&acct).unwrap();
1878        assert!(inspect_downgrade(&acct).unwrap().is_none());
1879    }
1880
1881    /// Absent DB is not a downgrade; it must not be created just to look.
1882    #[test]
1883    fn a_missing_database_is_not_a_downgrade() {
1884        let (_tmp, _guard, acct) = test_account();
1885        assert!(inspect_downgrade(&acct).unwrap().is_none());
1886        assert!(!account_dir(&acct).unwrap().join("vector.db").exists());
1887    }
1888
1889    #[test]
1890    fn a_newer_schema_blocks_the_open_and_names_the_build() {
1891        let (_tmp, _guard, acct) = test_account();
1892        init_database(&acct).unwrap();
1893
1894        // Stand in for a newer Vector having run one migration past this build.
1895        let db_path = account_dir(&acct).unwrap().join("vector.db");
1896        {
1897            let conn = create_connection(&db_path).unwrap();
1898            conn.execute(
1899                "INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
1900                rusqlite::params![schema::HIGHEST_MIGRATION_ID + 1],
1901            )
1902            .unwrap();
1903            conn.execute(
1904                "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
1905                rusqlite::params![LAST_APP_VERSION_KEY, "9.9.9"],
1906            )
1907            .unwrap();
1908        }
1909        close_database();
1910
1911        let block = inspect_downgrade(&acct)
1912            .unwrap()
1913            .expect("a higher migration id must read as a downgrade");
1914        assert_eq!(block.db_schema, schema::HIGHEST_MIGRATION_ID + 1);
1915        assert_eq!(block.supported_schema, schema::HIGHEST_MIGRATION_ID);
1916        assert_eq!(block.last_app_version.as_deref(), Some("9.9.9"));
1917
1918        let err = init_database(&acct).unwrap_err();
1919        assert!(err.contains("9.9.9"), "must name the newer build: {err}");
1920    }
1921
1922    /// The guard has to fire before SQL_SCHEMA runs: its CREATE TABLE IF NOT
1923    /// EXISTS statements would otherwise resurrect tables newer migrations drop.
1924    #[test]
1925    fn a_blocked_open_writes_nothing() {
1926        let (_tmp, _guard, acct) = test_account();
1927        init_database(&acct).unwrap();
1928        let db_path = account_dir(&acct).unwrap().join("vector.db");
1929        {
1930            let conn = create_connection(&db_path).unwrap();
1931            conn.execute(
1932                "INSERT OR REPLACE INTO schema_migrations (id, applied_at) VALUES (?1, 0)",
1933                rusqlite::params![schema::HIGHEST_MIGRATION_ID + 5],
1934            )
1935            .unwrap();
1936            conn.execute("DROP TABLE IF EXISTS settings", []).unwrap();
1937        }
1938        close_database();
1939
1940        assert!(init_database(&acct).is_err());
1941
1942        // SQL_SCHEMA would have recreated `settings`; it must still be gone.
1943        let conn = create_connection(&db_path).unwrap();
1944        let exists: bool = conn
1945            .query_row(
1946                "SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'",
1947                [],
1948                |_| Ok(true),
1949            )
1950            .unwrap_or(false);
1951        assert!(!exists, "a blocked open must not write to the database");
1952    }
1953}