Skip to main content

newt_data/store/
sqlite.rs

1//! The SQLite [`DataStore`] backend (Phase 21).
2//!
3//! The first (and, until step 21.8's DuckDB backend, only) implementation of
4//! the [`DataStore`] seam. It reuses the family's bundled `rusqlite 0.31`
5//! (MSRV 1.75) and the same WAL-with-DELETE-fallback connection setup as the
6//! conversation store ([`newt_core::store`]) so it behaves identically on NFS
7//! homes. The data database is **separate** from the conversation store
8//! (`<workspace>/.newt-data/data.db`), never entangled — see
9//! [`docs/design/centaur-data-scientist.md`](../../../../docs/design/centaur-data-scientist.md).
10//!
11//! [`newt_core::store`]: https://docs.rs/newt-core
12
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15
16use rusqlite::types::ValueRef;
17use rusqlite::Connection;
18
19use crate::error::Result;
20use crate::ingest::quote_ident;
21use crate::store::{DataStore, IngestReport, QueryResult, TableInfo, TableSummary};
22use crate::{ingest, summary};
23
24/// The directory created under a workspace to hold the data database
25/// (`<workspace>/.newt-data/data.db`). Kept separate from `~/.newt` so the DS
26/// store never touches the conversation database.
27const DATA_DIR: &str = ".newt-data";
28
29/// The data database file name under [`DATA_DIR`].
30const DB_FILE: &str = "data.db";
31
32/// SQLite-backed data engine (see module docs).
33///
34/// Cheap to clone: clones share one connection behind a mutex, matching the
35/// conversation store. All methods take `&self`.
36#[derive(Debug, Clone)]
37pub struct SqliteBackend {
38    conn: Arc<Mutex<Connection>>,
39}
40
41impl SqliteBackend {
42    /// Open (creating if needed) the data database at `path`.
43    ///
44    /// Creates the parent directory, opens the connection, applies
45    /// `journal_mode=WAL` (+ `synchronous=NORMAL`) with a `journal_mode=DELETE`
46    /// fallback on the known network-filesystem failure modes — exactly like
47    /// [`newt_core::store`](crate::store::sqlite) — and ensures the
48    /// `__datasets` metadata table exists.
49    pub fn open(path: &Path) -> Result<Self> {
50        if let Some(parent) = path.parent() {
51            std::fs::create_dir_all(parent)?;
52        }
53        let conn = Connection::open(path)?;
54        apply_journal_mode(&conn)?;
55        ensure_metadata_table(&conn)?;
56        Ok(Self {
57            conn: Arc::new(Mutex::new(conn)),
58        })
59    }
60
61    /// Open an in-memory database with the `__datasets` table — used by tests
62    /// (no WAL on `:memory:`; the metadata table is created the same way).
63    pub fn open_in_memory() -> Result<Self> {
64        let conn = Connection::open_in_memory()?;
65        ensure_metadata_table(&conn)?;
66        Ok(Self {
67            conn: Arc::new(Mutex::new(conn)),
68        })
69    }
70
71    /// The default data-database path for a workspace:
72    /// `<workspace>/.newt-data/data.db`.
73    pub fn default_db_path(workspace: &Path) -> PathBuf {
74        workspace.join(DATA_DIR).join(DB_FILE)
75    }
76
77    /// Lock the shared connection, recovering from a poisoned mutex (a panic in
78    /// another thread leaves the connection itself usable — transactions roll
79    /// back), matching the conversation store's policy.
80    fn lock_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
81        self.conn
82            .lock()
83            .unwrap_or_else(std::sync::PoisonError::into_inner)
84    }
85}
86
87impl DataStore for SqliteBackend {
88    fn ingest_csv(&self, path: &Path, table: &str) -> Result<IngestReport> {
89        let conn = self.lock_conn();
90        ingest::ingest_csv(&conn, path, table)
91    }
92
93    fn query(&self, sql: &str, row_cap: usize) -> Result<QueryResult> {
94        let conn = self.lock_conn();
95        let mut stmt = conn.prepare(sql)?;
96        let columns: Vec<String> = stmt.column_names().iter().map(|c| c.to_string()).collect();
97
98        // A non-row statement (INSERT/UPDATE/CREATE/…): execute it via the same
99        // prepared statement and report an empty, untruncated result.
100        if stmt.column_count() == 0 {
101            stmt.raw_execute()?;
102            return Ok(QueryResult {
103                columns,
104                rows: Vec::new(),
105                returned: 0,
106                truncated: false,
107            });
108        }
109
110        let mut sql_rows = stmt.query([])?;
111        let mut rows: Vec<Vec<serde_json::Value>> = Vec::new();
112        let mut truncated = false;
113        while let Some(row) = sql_rows.next()? {
114            if rows.len() >= row_cap {
115                // One row past the cap exists → mark truncated, drop it, stop.
116                truncated = true;
117                break;
118            }
119            let mut out_row = Vec::with_capacity(columns.len());
120            for i in 0..columns.len() {
121                out_row.push(value_ref_to_json(row.get_ref(i)?));
122            }
123            rows.push(out_row);
124        }
125
126        let returned = rows.len();
127        Ok(QueryResult {
128            columns,
129            rows,
130            returned,
131            truncated,
132        })
133    }
134
135    fn summarize(&self, table: &str) -> Result<TableSummary> {
136        let conn = self.lock_conn();
137        summary::summarize(&conn, table)
138    }
139
140    fn list_tables(&self) -> Result<Vec<TableInfo>> {
141        let conn = self.lock_conn();
142        let names: Vec<String> = {
143            let mut stmt = conn.prepare(
144                "SELECT name FROM sqlite_master
145                  WHERE type = 'table'
146                    AND name NOT LIKE 'sqlite_%'
147                    AND name != '__datasets'
148                  ORDER BY name",
149            )?;
150            let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
151            rows.collect::<rusqlite::Result<Vec<_>>>()?
152        };
153
154        let mut infos = Vec::with_capacity(names.len());
155        for name in names {
156            let quoted = quote_ident(&name);
157            let row_count: u64 =
158                conn.query_row(&format!("SELECT COUNT(*) FROM {quoted}"), [], |row| {
159                    row.get::<_, i64>(0).map(|n| n.max(0) as u64)
160                })?;
161            let source: Option<String> = conn
162                .query_row(
163                    "SELECT source FROM __datasets WHERE table_name = ?1",
164                    [&name],
165                    |row| row.get::<_, Option<String>>(0),
166                )
167                .ok()
168                .flatten();
169            infos.push(TableInfo {
170                table: name,
171                row_count,
172                source,
173            });
174        }
175        Ok(infos)
176    }
177
178    fn backend_name(&self) -> &'static str {
179        "sqlite"
180    }
181}
182
183/// Apply `journal_mode=WAL` (+ `synchronous=NORMAL`) with a
184/// `journal_mode=DELETE` fallback on the SQLite errors WAL is known to produce
185/// on network filesystems (NFS homes) — the same policy as
186/// [`newt_core::store`]. Any other error propagates.
187fn apply_journal_mode(conn: &Connection) -> Result<()> {
188    let wal: rusqlite::Result<String> =
189        conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0));
190    match wal {
191        // Assert the pragma actually took (it has documented silent-no-op
192        // cases): NORMAL is only safe under WAL.
193        Ok(mode) if mode.eq_ignore_ascii_case("wal") => {
194            conn.pragma_update(None, "synchronous", "NORMAL")?;
195            Ok(())
196        }
197        Ok(mode) => {
198            tracing::warn!(%mode, "journal_mode=WAL did not take; keeping synchronous=FULL");
199            Ok(())
200        }
201        Err(e) if wal_fallback_eligible(&e.to_string()) => {
202            tracing::warn!(
203                error = %e,
204                "SQLite refused WAL (network filesystem?); data.db is running on the \
205                 slower journal_mode=DELETE fallback"
206            );
207            conn.pragma_update(None, "journal_mode", "DELETE")?;
208            Ok(())
209        }
210        Err(e) => Err(e.into()),
211    }
212}
213
214/// `true` for the SQLite error texts WAL produces on filesystems without
215/// shared-memory mmap / POSIX lock support (NFS homes), where the store should
216/// fall back to `journal_mode=DELETE` rather than fail to open. Mirrors the
217/// conversation store's predicate.
218fn wal_fallback_eligible(error_text: &str) -> bool {
219    let lower = error_text.to_lowercase();
220    lower.contains("locking protocol") || lower.contains("disk i/o error")
221}
222
223/// Create the engine's metadata table if absent. One row per ingested table,
224/// recording the source path, row count, and ingest time (Phase 21).
225fn ensure_metadata_table(conn: &Connection) -> Result<()> {
226    conn.execute(
227        "CREATE TABLE IF NOT EXISTS __datasets (
228             table_name  TEXT PRIMARY KEY,
229             source      TEXT,
230             row_count   INTEGER NOT NULL,
231             ingested_at INTEGER NOT NULL
232         )",
233        [],
234    )?;
235    Ok(())
236}
237
238/// Map a SQLite [`ValueRef`] to a [`serde_json::Value`] (Phase 21).
239///
240/// `NULL` → `Null`, `INTEGER` → `Number`, `REAL` → `Number` (via
241/// [`serde_json::Number::from_f64`]), `TEXT` → `String`, and `BLOB` → a base64
242/// `String` so binary survives the JSON boundary.
243///
244/// ## Non-finite reals are lossy at this boundary (Phase 21 contract)
245///
246/// JSON has no representation for `NaN` / `±Infinity`, so
247/// [`serde_json::Number::from_f64`] returns `None` for any non-finite float and
248/// this function emits `Null`. This is a **reachable, lossy** case, not merely
249/// defensive: CSV ingest infers a column as [`Real`](crate::ColumnDtype::Real)
250/// whenever every cell parses as `f64`, and Rust's `f64` parser accepts the
251/// tokens `inf`, `-inf`, `nan`, `infinity` (case-insensitively) as well as
252/// magnitudes that overflow to `±inf` (e.g. `1e9999`). Such a cell is stored as
253/// `REAL` by [`ingest`](crate::ingest) and then degrades to JSON `Null` on
254/// query — the original token is not preserved. (This differs from pandas,
255/// which parses the same tokens to float but *keeps* the `inf`/`NaN` value.)
256/// The behavior is pinned by `value_ref_real_non_finite_becomes_null` and the
257/// end-to-end `query_non_finite_real_cell_is_null` regression test.
258fn value_ref_to_json(value: ValueRef<'_>) -> serde_json::Value {
259    use serde_json::Value;
260    match value {
261        ValueRef::Null => Value::Null,
262        ValueRef::Integer(i) => Value::Number(i.into()),
263        ValueRef::Real(f) => serde_json::Number::from_f64(f)
264            .map(Value::Number)
265            .unwrap_or(Value::Null),
266        ValueRef::Text(bytes) => Value::String(String::from_utf8_lossy(bytes).into_owned()),
267        ValueRef::Blob(bytes) => Value::String(base64_encode(bytes)),
268    }
269}
270
271/// Standard base64 (RFC 4648, with `=` padding) of `bytes`.
272///
273/// A tiny self-contained encoder so `newt-data` needs no `base64` dependency
274/// just to ferry the rare `BLOB` cell across the JSON boundary (Phase 21 — the
275/// dependency budget is deliberately minimal; every new dep must compile on
276/// MSRV 1.75).
277fn base64_encode(bytes: &[u8]) -> String {
278    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
279    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
280    for chunk in bytes.chunks(3) {
281        let b0 = chunk[0];
282        let b1 = chunk.get(1).copied().unwrap_or(0);
283        let b2 = chunk.get(2).copied().unwrap_or(0);
284        out.push(ALPHABET[(b0 >> 2) as usize] as char);
285        out.push(ALPHABET[(((b0 & 0b11) << 4) | (b1 >> 4)) as usize] as char);
286        if chunk.len() > 1 {
287            out.push(ALPHABET[(((b1 & 0b1111) << 2) | (b2 >> 6)) as usize] as char);
288        } else {
289            out.push('=');
290        }
291        if chunk.len() > 2 {
292            out.push(ALPHABET[(b2 & 0b111111) as usize] as char);
293        } else {
294            out.push('=');
295        }
296    }
297    out
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn base64_matches_known_vectors() {
306        // RFC 4648 test vectors.
307        assert_eq!(base64_encode(b""), "");
308        assert_eq!(base64_encode(b"f"), "Zg==");
309        assert_eq!(base64_encode(b"fo"), "Zm8=");
310        assert_eq!(base64_encode(b"foo"), "Zm9v");
311        assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
312        assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
313        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
314    }
315
316    #[test]
317    fn default_db_path_is_under_newt_data() {
318        let p = SqliteBackend::default_db_path(Path::new("/tmp/ws"));
319        assert_eq!(p, Path::new("/tmp/ws/.newt-data/data.db"));
320    }
321
322    #[test]
323    fn wal_fallback_predicate() {
324        assert!(wal_fallback_eligible("disk I/O error"));
325        assert!(wal_fallback_eligible("locking protocol"));
326        assert!(!wal_fallback_eligible("syntax error"));
327    }
328
329    #[test]
330    fn open_creates_parent_and_metadata() {
331        let dir = tempfile::tempdir().unwrap();
332        let path = SqliteBackend::default_db_path(dir.path());
333        let backend = SqliteBackend::open(&path).unwrap();
334        assert!(path.exists());
335        // __datasets exists and is queryable; no user tables yet.
336        assert_eq!(backend.list_tables().unwrap().len(), 0);
337    }
338
339    #[test]
340    fn value_ref_blob_becomes_base64() {
341        assert_eq!(
342            value_ref_to_json(ValueRef::Blob(b"foobar")),
343            serde_json::Value::String("Zm9vYmFy".to_string())
344        );
345        assert_eq!(value_ref_to_json(ValueRef::Null), serde_json::Value::Null);
346    }
347
348    #[test]
349    fn value_ref_real_finite_becomes_number() {
350        assert_eq!(
351            value_ref_to_json(ValueRef::Real(1.5)),
352            serde_json::json!(1.5)
353        );
354    }
355
356    /// Pins the lossy non-finite boundary documented on [`value_ref_to_json`]:
357    /// JSON cannot represent `NaN` / `±Infinity`, so every non-finite `REAL`
358    /// degrades to `Null` rather than erroring. A future change to the
359    /// `from_f64` handling (e.g. preserving these as strings) must update this
360    /// test deliberately.
361    #[test]
362    fn value_ref_real_non_finite_becomes_null() {
363        for f in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
364            assert_eq!(
365                value_ref_to_json(ValueRef::Real(f)),
366                serde_json::Value::Null,
367                "non-finite real {f} should degrade to JSON null"
368            );
369        }
370    }
371}