Skip to main content

fathomdb_engine/
sqlite.rs

1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use rusqlite::{Connection, OpenFlags};
5
6use crate::EngineError;
7
8// Vendored copy of tooling/sqlite.env so the crate is self-contained for
9// `cargo publish`. Keep in sync with the workspace copy at
10// /tooling/sqlite.env (also referenced by the Go bridge and dev scripts).
11const SHARED_SQLITE_POLICY: &str = include_str!("../sqlite.env");
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct SharedSqlitePolicy {
15    pub minimum_supported_version: String,
16    pub repo_dev_version: String,
17    pub repo_local_binary_relpath: PathBuf,
18}
19
20#[cfg(feature = "tracing")]
21static SQLITE_LOG_INIT: std::sync::Once = std::sync::Once::new();
22
23/// Forward `SQLite` internal error/warning events into the tracing facade.
24///
25/// Registered once per process via `SQLITE_CONFIG_LOG` before any connections
26/// are opened.  The primary error code determines the tracing level:
27/// `NOTICE` → `INFO`, `WARNING` → `WARN`, everything else → `ERROR`.
28#[cfg(feature = "tracing")]
29fn sqlite_log_callback(code: std::os::raw::c_int, msg: &str) {
30    let primary = code & 0xFF;
31    if primary == rusqlite::ffi::SQLITE_NOTICE as std::os::raw::c_int {
32        tracing::info!(target: "fathomdb_engine::sqlite", sqlite_error_code = code, "{msg}");
33    } else if primary == rusqlite::ffi::SQLITE_WARNING as std::os::raw::c_int {
34        tracing::warn!(target: "fathomdb_engine::sqlite", sqlite_error_code = code, "{msg}");
35    } else {
36        tracing::error!(target: "fathomdb_engine::sqlite", sqlite_error_code = code, "{msg}");
37    }
38}
39
40/// Install `sqlite3_trace_v2` with `SQLITE_TRACE_PROFILE` on a connection.
41///
42/// Fires a TRACE-level event for each statement completion with the SQL text
43/// and execution duration.  Only registered in debug builds — TRACE events are
44/// compiled out by `release_max_level_info` in release builds, so registering
45/// the callback would waste FFI overhead on every statement for no output.
46#[cfg(all(feature = "tracing", debug_assertions))]
47fn install_trace_v2(conn: &Connection) {
48    use std::os::raw::{c_int, c_uint, c_void};
49
50    unsafe extern "C" fn trace_v2_callback(
51        event_type: c_uint,
52        _ctx: *mut c_void,
53        p: *mut c_void,
54        x: *mut c_void,
55    ) -> c_int {
56        if event_type == rusqlite::ffi::SQLITE_TRACE_PROFILE as c_uint {
57            let stmt = p.cast::<rusqlite::ffi::sqlite3_stmt>();
58            let nanos = unsafe { *(x.cast::<i64>()) };
59            let sql_ptr = unsafe { rusqlite::ffi::sqlite3_sql(stmt) };
60            if !sql_ptr.is_null() {
61                let sql = unsafe { std::ffi::CStr::from_ptr(sql_ptr) }.to_string_lossy();
62                tracing::trace!(
63                    target: "fathomdb_engine::sqlite",
64                    sql = %sql,
65                    duration_us = nanos / 1000,
66                    "sqlite statement profile"
67                );
68            }
69        }
70        0
71    }
72
73    unsafe {
74        rusqlite::ffi::sqlite3_trace_v2(
75            conn.handle(),
76            rusqlite::ffi::SQLITE_TRACE_PROFILE as c_uint,
77            Some(trace_v2_callback),
78            std::ptr::null_mut(),
79        );
80    }
81}
82
83pub fn open_connection(path: &Path) -> Result<Connection, EngineError> {
84    #[cfg(feature = "tracing")]
85    SQLITE_LOG_INIT.call_once(|| {
86        // Safety: Once guard ensures no concurrent SQLite calls during config.
87        // config_log must be called before any connections are opened.
88        unsafe {
89            let _ = rusqlite::trace::config_log(Some(sqlite_log_callback));
90        }
91    });
92
93    let conn = Connection::open_with_flags(
94        path,
95        OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
96    )?;
97    conn.busy_timeout(Duration::from_millis(5_000))?;
98
99    #[cfg(all(feature = "tracing", debug_assertions))]
100    install_trace_v2(&conn);
101
102    Ok(conn)
103}
104
105/// Open a read-only database connection.
106///
107/// Uses `SQLITE_OPEN_READONLY` so that any attempt to write through this
108/// connection fails at the `SQLite` level.  Intended for reader-pool connections
109/// where the writer has already created the database and set WAL mode.
110///
111/// # Errors
112/// Returns [`EngineError`] if the database file cannot be opened.
113pub fn open_readonly_connection(path: &Path) -> Result<Connection, EngineError> {
114    #[cfg(feature = "tracing")]
115    SQLITE_LOG_INIT.call_once(|| {
116        // Safety: Once guard ensures no concurrent SQLite calls during config.
117        // config_log must be called before any connections are opened.
118        unsafe {
119            let _ = rusqlite::trace::config_log(Some(sqlite_log_callback));
120        }
121    });
122
123    let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
124    conn.busy_timeout(Duration::from_millis(5_000))?;
125
126    #[cfg(all(feature = "tracing", debug_assertions))]
127    install_trace_v2(&conn);
128
129    Ok(conn)
130}
131
132/// Open a read-only database connection with the sqlite-vec extension loaded.
133///
134/// Combines [`open_readonly_connection`] with the `sqlite3_vec_init`
135/// auto-extension registration.
136///
137/// # Errors
138/// Returns [`EngineError`] if the underlying database connection cannot be
139/// opened (same failure modes as [`open_readonly_connection`]).
140#[cfg(feature = "sqlite-vec")]
141pub fn open_readonly_connection_with_vec(path: &Path) -> Result<Connection, EngineError> {
142    // Safety: sqlite3_auto_extension is idempotent for the same function pointer.
143    unsafe {
144        rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute(
145            sqlite_vec::sqlite3_vec_init as *const (),
146        )));
147    }
148    open_readonly_connection(path)
149}
150
151/// Open a database connection with the sqlite-vec extension loaded.
152///
153/// Registers `sqlite3_vec_init` as a global auto-extension so the extension is
154/// available in every connection opened after this call.  The registration is
155/// idempotent — SQLite deduplicates identical function-pointer registrations.
156///
157/// # Errors
158/// Returns [`EngineError`] if the underlying database connection cannot be
159/// opened (same failure modes as [`open_connection`]).
160#[cfg(feature = "sqlite-vec")]
161pub fn open_connection_with_vec(path: &Path) -> Result<Connection, EngineError> {
162    // Safety: sqlite3_auto_extension is idempotent for the same function pointer.
163    // The transmute converts the sqlite-vec init signature
164    // (db, pz_err_msg, p_api) -> c_int to the erased () -> c_int expected by
165    // sqlite3_auto_extension; SQLite passes the real args at load time.
166    unsafe {
167        rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute(
168            sqlite_vec::sqlite3_vec_init as *const (),
169        )));
170    }
171    open_connection(path)
172}
173
174/// # Errors
175/// Returns a `String` error if the embedded `sqlite.env` policy file is malformed or missing
176/// required keys (`SQLITE_MIN_VERSION`, `SQLITE_VERSION`).
177pub fn shared_sqlite_policy() -> Result<SharedSqlitePolicy, String> {
178    let mut minimum_supported_version = None;
179    let mut repo_dev_version = None;
180
181    for raw_line in SHARED_SQLITE_POLICY.lines() {
182        let line = raw_line.trim();
183        if line.is_empty() || line.starts_with('#') {
184            continue;
185        }
186
187        let Some((key, value)) = line.split_once('=') else {
188            return Err(format!("invalid sqlite policy line: {line}"));
189        };
190
191        match key.trim() {
192            "SQLITE_MIN_VERSION" => minimum_supported_version = Some(value.trim().to_owned()),
193            "SQLITE_VERSION" => repo_dev_version = Some(value.trim().to_owned()),
194            other => return Err(format!("unknown sqlite policy key: {other}")),
195        }
196    }
197
198    let minimum_supported_version =
199        minimum_supported_version.ok_or_else(|| "missing SQLITE_MIN_VERSION".to_owned())?;
200    let repo_dev_version = repo_dev_version.ok_or_else(|| "missing SQLITE_VERSION".to_owned())?;
201    let repo_local_binary_relpath =
202        PathBuf::from(format!(".local/sqlite-{repo_dev_version}/bin/sqlite3"));
203
204    Ok(SharedSqlitePolicy {
205        minimum_supported_version,
206        repo_dev_version,
207        repo_local_binary_relpath,
208    })
209}
210
211#[cfg(test)]
212#[allow(clippy::expect_used)]
213mod tests {
214    use super::shared_sqlite_policy;
215
216    #[test]
217    fn shared_sqlite_policy_matches_repo_defaults() {
218        let policy = shared_sqlite_policy().expect("shared sqlite policy");
219
220        assert_eq!(policy.minimum_supported_version, "3.41.0");
221        assert_eq!(policy.repo_dev_version, "3.46.0");
222        assert!(
223            policy
224                .repo_local_binary_relpath
225                .ends_with("sqlite-3.46.0/bin/sqlite3")
226        );
227    }
228}