winbrew_database/
lib.rs

1//! Persistence layer for WinBrew.
2//!
3//! `winbrew-database` owns SQLite access, config persistence, journal replay,
4//! and MSI inventory normalization. It stays close to the runtime database
5//! contract so higher layers can use typed helpers instead of direct SQL
6//! plumbing.
7//!
8//! The database module keeps its pool registry keyed by resolved paths. That
9//! makes the current process-local root model explicit while still keeping the
10//! storage boundary centralized for the app and CLI layers.
11
12#![cfg(windows)]
13
14pub use winbrew_core as core;
15pub use winbrew_models as models;
16
17mod bootstrap;
18mod catalog;
19mod command_registry;
20mod config;
21pub mod connection;
22pub mod error;
23mod installed_packages;
24mod journal;
25mod migration;
26mod msi_inventory;
27
28use self::connection::SqliteConnectionManager;
29use crate::core::ResolvedPaths;
30use anyhow::{Context, Result};
31use r2d2::{Pool, PooledConnection};
32use std::cell::RefCell;
33use std::collections::HashMap;
34use std::path::PathBuf;
35use std::sync::{Mutex, OnceLock};
36
37pub type DbConnection = PooledConnection<SqliteConnectionManager>;
38
39pub use error::{CatalogNotFoundError, CatalogSchemaVersionMismatchError};
40
41pub use command_registry::{
42    CommandRegistryConflictError, find_command_owner, find_command_owners,
43    get_package_command_names, list_commands_for_package, parse_command_names,
44    sync_package_commands,
45};
46pub use config::{
47    Config, ConfigEnv, ConfigError, ConfigSection, ConfigSource, ConfigValidationError, CoreConfig,
48    PathsConfig, config_sections, config_set, config_unset, get_effective_value, suggest_key,
49};
50pub use installed_packages::{
51    PackageNotFoundError, commit_install, commit_install_with_commands, delete_package,
52    get_package, insert_package, list_installing_packages, list_packages, replay_committed_journal,
53    update_installing_identity, update_status, update_status_and_engine_metadata,
54};
55pub use journal::{
56    CommittedJournalPackage, FileHash, HashAlgo, JournalEntry, JournalReadError, JournalReader,
57    JournalReplayError, JournalShimBinding, JournalWriter, package_journal_key,
58};
59pub use msi_inventory::{
60    apply_snapshot, find_packages_by_normalized_path,
61    find_packages_by_normalized_registry_key_path, get_snapshot, replace_snapshot, upsert_receipt,
62};
63
64#[cfg(test)]
65mod tests {
66    use super::{JournalWriter, get_conn, init, package_journal_key};
67    use crate::core::resolved_paths;
68    use std::fs;
69    use tempfile::tempdir;
70
71    #[test]
72    fn init_bootstraps_primary_pool_and_journal_paths() {
73        let root = tempdir().expect("temp dir");
74        let paths = resolved_paths(
75            root.path(),
76            "${root}\\packages",
77            "${root}\\data",
78            "${root}\\data\\logs",
79            "${root}\\data\\cache",
80        );
81
82        init(&paths).expect("initialize database state");
83
84        let _conn = get_conn().expect("open primary database connection");
85        let journal_key = package_journal_key("winget/Contoso.App", "1.0.0");
86        fs::create_dir_all(paths.package_journal_dir(&journal_key))
87            .expect("create journal directory");
88        let writer = JournalWriter::open_for_package_in(&paths, "winget/Contoso.App", "1.0.0")
89            .expect("open journal writer");
90
91        assert_eq!(writer.path(), &paths.package_journal_file(&journal_key));
92    }
93}
94
95/// Search the catalog database for packages matching the query.
96pub fn search(
97    conn: &rusqlite::Connection,
98    query: &str,
99) -> Result<Vec<crate::models::catalog::package::CatalogPackage>> {
100    catalog::search(conn, query)
101}
102
103/// Return a single catalog package by its catalog package id.
104pub fn get_package_by_id(
105    conn: &rusqlite::Connection,
106    package_id: &str,
107) -> Result<Option<crate::models::catalog::package::CatalogPackage>> {
108    catalog::get_package_by_id(conn, package_id)
109}
110
111/// Return all catalog installers for a package id.
112pub fn get_installers(
113    conn: &rusqlite::Connection,
114    package_id: &str,
115) -> Result<Vec<crate::models::catalog::package::CatalogInstaller>> {
116    catalog::get_installers(conn, package_id)
117}
118
119thread_local! {
120    static CURRENT_PATHS: RefCell<Option<ResolvedPaths>> = const { RefCell::new(None) };
121}
122
123static DB_POOLS: OnceLock<Mutex<HashMap<PathBuf, &'static Pool<SqliteConnectionManager>>>> =
124    OnceLock::new();
125static CATALOG_DB_POOLS: OnceLock<Mutex<HashMap<PathBuf, &'static Pool<SqliteConnectionManager>>>> =
126    OnceLock::new();
127
128/// Initialize the process-local storage state for the given resolved paths.
129pub fn init(paths: &ResolvedPaths) -> Result<()> {
130    bootstrap::ensure_managed_root_dirs(paths)?;
131
132    CURRENT_PATHS.with(|current_paths| {
133        *current_paths.borrow_mut() = Some(paths.clone());
134    });
135
136    let _ = get_pool()?;
137
138    Ok(())
139}
140
141fn resolved_paths() -> Result<ResolvedPaths> {
142    CURRENT_PATHS.with(|current_paths| {
143        if current_paths.borrow().is_none() {
144            let paths = Config::load_current()?.resolved_paths();
145            *current_paths.borrow_mut() = Some(paths);
146        }
147
148        current_paths
149            .borrow()
150            .as_ref()
151            .cloned()
152            .context("failed to initialize database resolved paths")
153    })
154}
155
156fn get_pool() -> Result<&'static Pool<SqliteConnectionManager>> {
157    pool_for(
158        DB_POOLS.get_or_init(|| Mutex::new(HashMap::new())),
159        resolved_paths()?.db.clone(),
160        false,
161        10,
162        Some(migration::migrate),
163    )
164}
165
166/// Return a pooled connection to the primary database.
167pub fn get_conn() -> Result<PooledConnection<SqliteConnectionManager>> {
168    let pool = get_pool()?;
169    pool.get()
170        .context("failed to acquire database connection from pool")
171}
172
173/// Return a pooled connection to the catalog database.
174pub fn get_catalog_conn() -> Result<PooledConnection<SqliteConnectionManager>> {
175    if !resolved_paths()?.catalog_db.exists() {
176        return Err(CatalogNotFoundError.into());
177    }
178
179    let pool = get_catalog_pool()?;
180    let conn = pool
181        .get()
182        .context("failed to acquire catalog database connection from pool")?;
183    catalog::ensure_schema_version(&conn)?;
184
185    Ok(conn)
186}
187
188fn get_catalog_pool() -> Result<&'static Pool<SqliteConnectionManager>> {
189    pool_for(
190        CATALOG_DB_POOLS.get_or_init(|| Mutex::new(HashMap::new())),
191        resolved_paths()?.catalog_db.clone(),
192        true,
193        4,
194        None,
195    )
196}
197
198fn pool_for(
199    pools: &'static Mutex<HashMap<PathBuf, &'static Pool<SqliteConnectionManager>>>,
200    path: PathBuf,
201    read_only: bool,
202    max_size: u32,
203    migrate: Option<fn(&rusqlite::Connection) -> Result<()>>,
204) -> Result<&'static Pool<SqliteConnectionManager>> {
205    let mut pools = pools
206        .lock()
207        .map_err(|_| anyhow::anyhow!("database pool registry lock poisoned"))?;
208
209    if let Some(pool) = pools.get(&path) {
210        return Ok(*pool);
211    }
212
213    let pool = Box::leak(Box::new(connection::build_pool(
214        path.clone(),
215        read_only,
216        max_size,
217        migrate,
218    )?)) as &'static Pool<SqliteConnectionManager>;
219
220    pools.insert(path, pool);
221    Ok(pool)
222}