Skip to main content

basalt/
database.rs

1//! Public database, connection, transaction, and recovery API.
2//!
3//! `State` remains available for small embedded/in-memory executor tests, but
4//! applications should use [`Database`].  A transaction works on a private
5//! snapshot and publishes it with an optimistic generation check.  Readers
6//! therefore never observe half of a write, and concurrent writers fail with a
7//! transaction conflict instead of silently losing updates.
8
9use std::fs::{self, File, OpenOptions};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::{Arc, Mutex, RwLock};
13
14use crate::db::{DbError, DbErrorKind, State, StatementResult, dberr};
15use crate::sql::ast::Statement;
16use crate::sql::parser::parse;
17use crate::{storage, wal};
18
19struct Inner {
20    path: Option<PathBuf>,
21    wal_path: Option<PathBuf>,
22    _lock_file: Option<File>,
23    _workspace_lock_file: Option<File>,
24    state: RwLock<State>,
25    generation: AtomicU64,
26    commit_lock: Mutex<()>,
27}
28
29/// A cloneable handle to an embedded Basalt database.
30#[derive(Clone)]
31pub struct Database {
32    inner: Arc<Inner>,
33}
34
35impl Database {
36    /// Create an empty, non-durable database.
37    pub fn in_memory() -> Database {
38        Database {
39            inner: Arc::new(Inner {
40                path: None,
41                wal_path: None,
42                _lock_file: None,
43                _workspace_lock_file: None,
44                state: RwLock::new(State::empty()),
45                generation: AtomicU64::new(0),
46                commit_lock: Mutex::new(()),
47            }),
48        }
49    }
50
51    /// Open or create a durable database at `path`.
52    pub fn open(path: impl AsRef<Path>) -> Result<Database, DbError> {
53        Self::open_internal(path, false)
54    }
55
56    /// Open a workspace database when the caller already owns its workspace
57    /// lock. The returned handle still owns the database lock as usual.
58    pub(crate) fn open_in_workspace(path: impl AsRef<Path>) -> Result<Database, DbError> {
59        Self::open_internal(path, true)
60    }
61
62    fn open_internal(
63        path: impl AsRef<Path>,
64        workspace_lock_already_held: bool,
65    ) -> Result<Database, DbError> {
66        let path = path.as_ref().to_path_buf();
67        let workspace_lock_file = if workspace_lock_already_held {
68            None
69        } else {
70            acquire_workspace_lock(&path)?
71        };
72        let lock_file = acquire_lock(&path)?;
73        let wal_path = wal_path(&path);
74        let frame = wal::latest(&wal_path)?;
75        let snapshot = storage::read_snapshot(&path);
76        let (mut state, mut generation, mut repair_snapshot) = match snapshot {
77            Ok((state, generation)) => (state, generation, false),
78            Err(error) => {
79                let Some(frame) = &frame else {
80                    return Err(error);
81                };
82                (State::decode(&frame.payload)?, frame.generation, true)
83            }
84        };
85        if let Some(frame) = frame {
86            if frame.generation > generation {
87                state = State::decode(&frame.payload)?;
88                generation = frame.generation;
89                // Complete recovery before exposing the handle.  If this
90                // process is killed again, the valid WAL frame remains.
91                storage::write_snapshot(&path, &state, generation)?;
92                wal::truncate(&wal_path)?;
93                repair_snapshot = false;
94            } else {
95                // The snapshot is at least as new as every WAL frame; a
96                // previous checkpoint may have been interrupted after the
97                // snapshot install and left stale frames behind.
98                if repair_snapshot {
99                    storage::write_snapshot(&path, &state, generation)?;
100                    repair_snapshot = false;
101                }
102                wal::truncate(&wal_path)?;
103            }
104        }
105        if repair_snapshot || !path.exists() {
106            storage::write_snapshot(&path, &state, generation)?;
107        }
108        Ok(Database {
109            inner: Arc::new(Inner {
110                path: Some(path),
111                wal_path: Some(wal_path),
112                _lock_file: Some(lock_file),
113                _workspace_lock_file: workspace_lock_file,
114                state: RwLock::new(state),
115                generation: AtomicU64::new(generation),
116                commit_lock: Mutex::new(()),
117            }),
118        })
119    }
120
121    /// Begin an optimistic snapshot transaction.
122    pub fn begin(&self) -> Result<Transaction, DbError> {
123        let mut budget = crate::engine::ExecutionBudget::unlimited();
124        self.begin_with_budget(&mut budget)
125    }
126
127    pub(crate) fn begin_with_budget(
128        &self,
129        budget: &mut crate::engine::ExecutionBudget,
130    ) -> Result<Transaction, DbError> {
131        let state_guard = self
132            .inner
133            .state
134            .read()
135            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
136        budget.state_clone(&state_guard, "starting a database snapshot")?;
137        // The generation is published while the write lock is held. Reading
138        // it under the same read guard keeps the cloned state and its
139        // snapshot number from crossing a concurrent commit.
140        let state = state_guard.clone();
141        let generation = self.inner.generation.load(Ordering::Acquire);
142        Ok(Transaction {
143            db: self.clone(),
144            state,
145            base_generation: generation,
146            active: true,
147            dirty: false,
148        })
149    }
150
151    /// Alias for [`Database::begin`] using the conventional transaction name.
152    pub fn transaction(&self) -> Result<Transaction, DbError> {
153        self.begin()
154    }
155
156    /// Create a stateful connection.  Connections are useful when SQL
157    /// `BEGIN`, `COMMIT`, and `ROLLBACK` statements span multiple calls.
158    pub fn connect(&self) -> Connection {
159        Connection {
160            db: self.clone(),
161            transaction: None,
162        }
163    }
164
165    /// Execute one statement as an autocommit operation.
166    pub fn execute(&self, stmt: &Statement) -> Result<StatementResult, DbError> {
167        let mut budget = crate::engine::ExecutionBudget::unlimited();
168        self.execute_with_budget(stmt, &mut budget)
169    }
170
171    pub(crate) fn execute_with_budget(
172        &self,
173        stmt: &Statement,
174        budget: &mut crate::engine::ExecutionBudget,
175    ) -> Result<StatementResult, DbError> {
176        match stmt {
177            Statement::Checkpoint => {
178                self.checkpoint_with_budget(budget)?;
179                Ok(StatementResult::Checkpoint)
180            }
181            Statement::Begin => Ok(StatementResult::Begin),
182            Statement::Commit => Ok(StatementResult::Commit),
183            Statement::Rollback => Ok(StatementResult::Rollback),
184            _ => {
185                let mut transaction = self.begin_with_budget(budget)?;
186                let result = transaction.execute_with_budget(stmt, budget)?;
187                if is_mutation(&result) {
188                    transaction.commit_with_budget(budget)?;
189                } else {
190                    transaction.rollback();
191                }
192                Ok(result)
193            }
194        }
195    }
196
197    /// Parse and execute all statements through a fresh autocommit connection.
198    pub fn execute_sql(&self, sql: &str) -> Result<Vec<StatementResult>, DbError> {
199        self.connect().execute_sql(sql)
200    }
201
202    pub(crate) fn execute_sql_with_budget(
203        &self,
204        sql: &str,
205        max_work: usize,
206    ) -> Result<Vec<StatementResult>, DbError> {
207        self.connect().execute_sql_with_budget(sql, max_work)
208    }
209
210    /// Flush the current state into the page file and clear committed WAL
211    /// frames.  Checkpointing is safe while readers are active.
212    pub fn checkpoint(&self) -> Result<(), DbError> {
213        let mut budget = crate::engine::ExecutionBudget::unlimited();
214        self.checkpoint_with_budget(&mut budget)
215    }
216
217    pub(crate) fn checkpoint_with_budget(
218        &self,
219        budget: &mut crate::engine::ExecutionBudget,
220    ) -> Result<(), DbError> {
221        let _commit = self
222            .inner
223            .commit_lock
224            .lock()
225            .map_err(|_| dberr(DbErrorKind::Transaction, "database commit lock poisoned"))?;
226        let Some(path) = &self.inner.path else {
227            return Ok(());
228        };
229        let state = self
230            .inner
231            .state
232            .read()
233            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
234        budget.state_clone(&state, "preparing a database checkpoint")?;
235        let state = state.clone();
236        let generation = self.inner.generation.load(Ordering::Acquire);
237        storage::write_snapshot(path, &state, generation)?;
238        if let Some(wal_path) = &self.inner.wal_path {
239            wal::truncate(wal_path)?;
240        }
241        Ok(())
242    }
243
244    /// Current committed generation, useful for diagnostics and tests.
245    pub fn generation(&self) -> u64 {
246        self.inner.generation.load(Ordering::Acquire)
247    }
248
249    /// Return table names in deterministic order for schema discovery tools.
250    pub fn table_names(&self) -> Result<Vec<String>, DbError> {
251        let state = self
252            .inner
253            .state
254            .read()
255            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
256        let mut names: Vec<String> = state.tables.keys().cloned().collect();
257        names.sort_by_key(|name| name.to_ascii_lowercase());
258        Ok(names)
259    }
260
261    /// Return a table's column metadata for migrations and introspection.
262    pub fn columns(&self, table: &str) -> Result<Vec<crate::db::Column>, DbError> {
263        let state = self
264            .inner
265            .state
266            .read()
267            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
268        state
269            .table(table)
270            .map(|value| value.columns.clone())
271            .ok_or_else(|| dberr(DbErrorKind::UnknownTable, format!("no such table: {table}")))
272    }
273
274    /// Return the number of live rows in a table without materializing them.
275    pub fn row_count(&self, table: &str) -> Result<usize, DbError> {
276        let state = self
277            .inner
278            .state
279            .read()
280            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
281        state
282            .table(table)
283            .map(crate::db::Table::row_count)
284            .ok_or_else(|| dberr(DbErrorKind::UnknownTable, format!("no such table: {table}")))
285    }
286
287    fn commit_state(&self, state: State, expected: u64) -> Result<u64, DbError> {
288        let _commit = self
289            .inner
290            .commit_lock
291            .lock()
292            .map_err(|_| dberr(DbErrorKind::Transaction, "database commit lock poisoned"))?;
293        let mut current = self
294            .inner
295            .state
296            .write()
297            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
298        let actual = self.inner.generation.load(Ordering::Acquire);
299        if actual != expected {
300            return Err(dberr(
301                DbErrorKind::Transaction,
302                format!("transaction conflict: snapshot {expected}, database is at {actual}"),
303            ));
304        }
305        let generation = actual
306            .checked_add(1)
307            .ok_or_else(|| dberr(DbErrorKind::Transaction, "transaction generation exhausted"))?;
308        if let Some(wal_path) = &self.inner.wal_path {
309            let payload = state.encode();
310            wal::append(wal_path, generation, &payload)?;
311        }
312        *current = state;
313        self.inner.generation.store(generation, Ordering::Release);
314        Ok(generation)
315    }
316}
317
318/// A connection that can hold one transaction across multiple statements.
319pub struct Connection {
320    db: Database,
321    transaction: Option<Transaction>,
322}
323
324impl Connection {
325    pub fn execute(&mut self, stmt: &Statement) -> Result<StatementResult, DbError> {
326        let mut budget = crate::engine::ExecutionBudget::unlimited();
327        self.execute_with_budget(stmt, &mut budget)
328    }
329
330    pub(crate) fn execute_with_budget(
331        &mut self,
332        stmt: &Statement,
333        budget: &mut crate::engine::ExecutionBudget,
334    ) -> Result<StatementResult, DbError> {
335        match stmt {
336            Statement::Checkpoint => {
337                if self.transaction.is_some() {
338                    return Err(dberr(
339                        DbErrorKind::Transaction,
340                        "cannot checkpoint while a transaction is active",
341                    ));
342                }
343                self.db.checkpoint_with_budget(budget)?;
344                Ok(StatementResult::Checkpoint)
345            }
346            Statement::Begin => {
347                if self.transaction.is_some() {
348                    return Err(dberr(
349                        DbErrorKind::Transaction,
350                        "transaction already active",
351                    ));
352                }
353                self.transaction = Some(self.db.begin_with_budget(budget)?);
354                Ok(StatementResult::Begin)
355            }
356            Statement::Commit => {
357                let Some(transaction) = self.transaction.take() else {
358                    return Err(dberr(DbErrorKind::Transaction, "no transaction is active"));
359                };
360                transaction.commit_with_budget(budget)?;
361                Ok(StatementResult::Commit)
362            }
363            Statement::Rollback => {
364                if let Some(transaction) = self.transaction.take() {
365                    transaction.rollback();
366                }
367                Ok(StatementResult::Rollback)
368            }
369            _ => match self.transaction.as_mut() {
370                Some(transaction) => transaction.execute_with_budget(stmt, budget),
371                None => self.db.execute_with_budget(stmt, budget),
372            },
373        }
374    }
375
376    pub fn execute_sql(&mut self, sql: &str) -> Result<Vec<StatementResult>, DbError> {
377        let mut budget = crate::engine::ExecutionBudget::unlimited();
378        self.execute_sql_using_budget(sql, &mut budget)
379    }
380
381    pub(crate) fn execute_sql_with_budget(
382        &mut self,
383        sql: &str,
384        max_work: usize,
385    ) -> Result<Vec<StatementResult>, DbError> {
386        let mut budget = crate::engine::ExecutionBudget::bounded(max_work);
387        self.execute_sql_using_budget(sql, &mut budget)
388    }
389
390    pub(crate) fn execute_sql_using_budget(
391        &mut self,
392        sql: &str,
393        budget: &mut crate::engine::ExecutionBudget,
394    ) -> Result<Vec<StatementResult>, DbError> {
395        let statements = parse(sql).map_err(|e| {
396            dberr(
397                DbErrorKind::Syntax(e.message.clone()),
398                format!("{} at byte {}", e.message, e.offset),
399            )
400        })?;
401        let mut results = Vec::with_capacity(statements.len());
402        for statement in statements {
403            results.push(self.execute_with_budget(&statement, budget)?);
404        }
405        Ok(results)
406    }
407
408    pub fn in_transaction(&self) -> bool {
409        self.transaction.is_some()
410    }
411
412    /// Return the committed generation visible to this connection's database.
413    pub fn generation(&self) -> u64 {
414        self.db.generation()
415    }
416}
417
418/// A private MVCC-style snapshot.  Reads use the snapshot without holding a
419/// database lock; commit publishes it only if no newer generation exists.
420pub struct Transaction {
421    db: Database,
422    state: State,
423    base_generation: u64,
424    active: bool,
425    dirty: bool,
426}
427
428impl Transaction {
429    pub fn execute(&mut self, stmt: &Statement) -> Result<StatementResult, DbError> {
430        let mut budget = crate::engine::ExecutionBudget::unlimited();
431        self.execute_with_budget(stmt, &mut budget)
432    }
433
434    pub(crate) fn execute_with_budget(
435        &mut self,
436        stmt: &Statement,
437        budget: &mut crate::engine::ExecutionBudget,
438    ) -> Result<StatementResult, DbError> {
439        if !self.active {
440            return Err(dberr(DbErrorKind::Transaction, "transaction is closed"));
441        }
442        match stmt {
443            Statement::Begin | Statement::Commit | Statement::Rollback | Statement::Checkpoint => {
444                Err(dberr(
445                    DbErrorKind::Transaction,
446                    "transaction control is owned by the connection",
447                ))
448            }
449            _ => {
450                let result = crate::engine::execute_with_budget(&mut self.state, stmt, budget)?;
451                if is_mutation(&result) {
452                    self.dirty = true;
453                }
454                Ok(result)
455            }
456        }
457    }
458
459    pub fn execute_sql(&mut self, sql: &str) -> Result<Vec<StatementResult>, DbError> {
460        let mut budget = crate::engine::ExecutionBudget::unlimited();
461        self.execute_sql_using_budget(sql, &mut budget)
462    }
463
464    fn execute_sql_using_budget(
465        &mut self,
466        sql: &str,
467        budget: &mut crate::engine::ExecutionBudget,
468    ) -> Result<Vec<StatementResult>, DbError> {
469        let statements = parse(sql).map_err(|e| {
470            dberr(
471                DbErrorKind::Syntax(e.message.clone()),
472                format!("{} at byte {}", e.message, e.offset),
473            )
474        })?;
475        let mut results = Vec::with_capacity(statements.len());
476        for statement in statements {
477            results.push(self.execute_with_budget(&statement, budget)?);
478        }
479        Ok(results)
480    }
481
482    pub fn commit(self) -> Result<u64, DbError> {
483        let mut budget = crate::engine::ExecutionBudget::unlimited();
484        self.commit_with_budget(&mut budget)
485    }
486
487    pub(crate) fn commit_with_budget(
488        mut self,
489        budget: &mut crate::engine::ExecutionBudget,
490    ) -> Result<u64, DbError> {
491        if !self.active {
492            return Err(dberr(DbErrorKind::Transaction, "transaction is closed"));
493        }
494        self.active = false;
495        if !self.dirty {
496            return Ok(self.db.generation());
497        }
498        budget.state_clone(&self.state, "preparing a database commit")?;
499        self.db.commit_state(self.state, self.base_generation)
500    }
501
502    pub fn rollback(mut self) {
503        self.active = false;
504    }
505
506    pub fn is_active(&self) -> bool {
507        self.active
508    }
509}
510
511fn is_mutation(result: &StatementResult) -> bool {
512    matches!(
513        result,
514        StatementResult::Insert { .. }
515            | StatementResult::Update { .. }
516            | StatementResult::Delete { .. }
517            | StatementResult::CreateTable { .. }
518            | StatementResult::DropTable { .. }
519            | StatementResult::CreateIndex { .. }
520            | StatementResult::DropIndex { .. }
521    )
522}
523
524fn wal_path(path: &Path) -> PathBuf {
525    let mut value = path.as_os_str().to_os_string();
526    value.push(".wal");
527    PathBuf::from(value)
528}
529
530fn acquire_lock(path: &Path) -> Result<File, DbError> {
531    if let Some(parent) = path
532        .parent()
533        .filter(|parent| !parent.as_os_str().is_empty())
534    {
535        fs::create_dir_all(parent).map_err(|error| {
536            dberr(
537                DbErrorKind::Io(format!("create database directory: {error}")),
538                format!("create database directory: {error}"),
539            )
540        })?;
541    }
542    let mut lock_os = path.as_os_str().to_os_string();
543    lock_os.push(".lock");
544    let lock_path = PathBuf::from(lock_os);
545    let file = OpenOptions::new()
546        .create(true)
547        .truncate(false)
548        .read(true)
549        .write(true)
550        .open(&lock_path)
551        .map_err(|error| {
552            dberr(
553                DbErrorKind::Io(format!("open database lock: {error}")),
554                format!("open database lock: {error}"),
555            )
556        })?;
557    match fs4::FileExt::try_lock(&file) {
558        Ok(()) => Ok(file),
559        Err(fs4::TryLockError::WouldBlock) => Err(dberr(
560            DbErrorKind::Busy,
561            format!("database is already open: {}", path.display()),
562        )),
563        Err(fs4::TryLockError::Error(error)) => Err(dberr(
564            DbErrorKind::Io(format!("lock database: {error}")),
565            format!("lock database: {error}"),
566        )),
567    }
568}
569
570fn acquire_workspace_lock(path: &Path) -> Result<Option<File>, DbError> {
571    if !path
572        .file_name()
573        .and_then(|name| name.to_str())
574        .is_some_and(|name| name.eq_ignore_ascii_case("data.basalt"))
575    {
576        return Ok(None);
577    }
578    let Some(parent) = path
579        .parent()
580        .filter(|parent| !parent.as_os_str().is_empty())
581    else {
582        return Ok(None);
583    };
584    let lock_path = parent.join(".workspace.lock");
585    let metadata = match fs::symlink_metadata(&lock_path) {
586        Ok(metadata) => metadata,
587        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
588        Err(error) => {
589            return Err(dberr(
590                DbErrorKind::Io(format!("inspect workspace lock: {error}")),
591                format!("inspect workspace lock: {error}"),
592            ));
593        }
594    };
595    if metadata.file_type().is_symlink() {
596        return Err(dberr(
597            DbErrorKind::Io("workspace lock cannot be a symbolic link".into()),
598            "workspace lock cannot be a symbolic link",
599        ));
600    }
601    let file = OpenOptions::new()
602        .read(true)
603        .write(true)
604        .open(&lock_path)
605        .map_err(|error| {
606            dberr(
607                DbErrorKind::Io(format!("open workspace lock: {error}")),
608                format!("open workspace lock: {error}"),
609            )
610        })?;
611    match fs4::FileExt::try_lock(&file) {
612        Ok(()) => Ok(Some(file)),
613        Err(fs4::TryLockError::WouldBlock) => Err(dberr(
614            DbErrorKind::Busy,
615            format!("workspace is already open: {}", parent.display()),
616        )),
617        Err(fs4::TryLockError::Error(error)) => Err(dberr(
618            DbErrorKind::Io(format!("lock workspace: {error}")),
619            format!("lock workspace: {error}"),
620        )),
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use std::fs;
628
629    #[test]
630    fn durable_commit_reopens() {
631        let dir = std::env::temp_dir().join(format!("basalt-db-{}", std::process::id()));
632        let _ = fs::remove_dir_all(&dir);
633        fs::create_dir_all(&dir).unwrap();
634        let path = dir.join("main.db");
635        {
636            let db = Database::open(&path).unwrap();
637            db.execute_sql(
638                "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT); INSERT INTO t VALUES (1, 'one');",
639            )
640            .unwrap();
641            db.checkpoint().unwrap();
642        }
643        let db = Database::open(&path).unwrap();
644        let result = db.execute_sql("SELECT * FROM t").unwrap();
645        assert!(matches!(
646            &result[0],
647            StatementResult::Select { rows, .. } if rows.len() == 1
648        ));
649        let _ = fs::remove_dir_all(dir);
650    }
651
652    #[test]
653    fn concurrent_snapshot_conflicts() {
654        let db = Database::in_memory();
655        db.execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY)")
656            .unwrap();
657        let mut a = db.begin().unwrap();
658        let mut b = db.begin().unwrap();
659        a.execute_sql("INSERT INTO t VALUES (1)").unwrap();
660        b.execute_sql("INSERT INTO t VALUES (2)").unwrap();
661        a.commit().unwrap();
662        assert!(b.commit().is_err());
663    }
664
665    #[test]
666    fn bounded_sql_accounts_for_snapshot_and_keeps_failed_mutations_unpublished() {
667        let database = Database::in_memory();
668        database
669            .execute_sql("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1)")
670            .unwrap();
671
672        let error = database
673            .execute_sql_with_budget("INSERT INTO t VALUES (2)", 5)
674            .unwrap_err();
675
676        assert_eq!(error.kind, DbErrorKind::Limit);
677        assert_eq!(database.row_count("t").unwrap(), 1);
678    }
679
680    #[test]
681    fn bounded_commit_rejects_before_publishing_its_snapshot() {
682        let database = Database::in_memory();
683        database.execute_sql("CREATE TABLE t (id INTEGER)").unwrap();
684        let mut transaction = database.begin().unwrap();
685        transaction.execute_sql("INSERT INTO t VALUES (1)").unwrap();
686        let mut budget = crate::engine::ExecutionBudget::bounded(0);
687
688        let error = transaction.commit_with_budget(&mut budget).unwrap_err();
689
690        assert_eq!(error.kind, DbErrorKind::Limit);
691        assert_eq!(database.row_count("t").unwrap(), 0);
692    }
693
694    #[test]
695    fn replays_wal_and_restores_user_indexes() {
696        let dir = std::env::temp_dir().join(format!("basalt-wal-recovery-{}", std::process::id()));
697        let _ = std::fs::remove_dir_all(&dir);
698        std::fs::create_dir_all(&dir).unwrap();
699        let path = dir.join("main.db");
700        {
701            let db = Database::open(&path).unwrap();
702            db.execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)")
703                .unwrap();
704            db.execute_sql("INSERT INTO t VALUES (1, 10), (2, 20), (3, 30)")
705                .unwrap();
706            db.execute_sql("CREATE INDEX value_idx ON t(value)")
707                .unwrap();
708            // Deliberately do not checkpoint: reopening must use the WAL.
709        }
710        let db = Database::open(&path).unwrap();
711        let results = db.execute_sql("SELECT id FROM t WHERE value = 20").unwrap();
712        let StatementResult::Select { rows, .. } = &results[0] else {
713            panic!()
714        };
715        assert_eq!(rows.len(), 1);
716        assert_eq!(db.generation(), 3);
717        let _ = std::fs::remove_dir_all(dir);
718    }
719
720    #[test]
721    fn readers_and_writer_can_share_a_handle() {
722        let db = Database::in_memory();
723        db.execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)")
724            .unwrap();
725        db.execute_sql("INSERT INTO t VALUES (1, 0)").unwrap();
726        let writer_db = db.clone();
727        let writer = std::thread::spawn(move || {
728            for value in 1..=20 {
729                writer_db
730                    .execute_sql(&format!("UPDATE t SET value = {value} WHERE id = 1"))
731                    .unwrap();
732            }
733        });
734        let mut readers = Vec::new();
735        for _ in 0..4 {
736            let reader_db = db.clone();
737            readers.push(std::thread::spawn(move || {
738                for _ in 0..20 {
739                    let result = reader_db.execute_sql("SELECT value FROM t").unwrap();
740                    assert!(matches!(&result[0], StatementResult::Select { rows, .. } if rows.len() == 1));
741                }
742            }));
743        }
744        writer.join().unwrap();
745        for reader in readers {
746            reader.join().unwrap();
747        }
748        let result = db.execute_sql("SELECT value FROM t").unwrap();
749        assert!(
750            matches!(&result[0], StatementResult::Select { rows, .. } if rows[0][0] == crate::types::Value::Integer(20))
751        );
752    }
753
754    #[test]
755    fn relative_paths_are_supported() {
756        let filename = format!("basalt-relative-{}.tmp", std::process::id());
757        let path = std::path::Path::new(&filename);
758        let _ = std::fs::remove_file(path);
759        let _ = std::fs::remove_file(format!("{filename}.wal"));
760        let database = Database::open(path).unwrap();
761        database
762            .execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY)")
763            .unwrap();
764        database.checkpoint().unwrap();
765        assert!(path.exists());
766        drop(database);
767        let _ = std::fs::remove_file(path);
768        let _ = std::fs::remove_file(format!("{filename}.wal"));
769    }
770
771    #[test]
772    fn valid_wal_recovers_a_corrupt_snapshot() {
773        let dir =
774            std::env::temp_dir().join(format!("basalt-corrupt-recovery-{}", std::process::id()));
775        let _ = std::fs::remove_dir_all(&dir);
776        std::fs::create_dir_all(&dir).unwrap();
777        let path = dir.join("main.db");
778        {
779            let database = Database::open(&path).unwrap();
780            database
781                .execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (7)")
782                .unwrap();
783        }
784        let mut bytes = std::fs::read(&path).unwrap();
785        bytes[0] ^= 0xff;
786        std::fs::write(&path, bytes).unwrap();
787        let database = Database::open(&path).unwrap();
788        let result = database.execute_sql("SELECT * FROM t").unwrap();
789        assert!(matches!(&result[0], StatementResult::Select { rows, .. } if rows.len() == 1));
790        let _ = std::fs::remove_dir_all(dir);
791    }
792
793    #[test]
794    fn durable_database_rejects_a_second_open_handle() {
795        let dir = std::env::temp_dir().join(format!("basalt-db-lock-{}", std::process::id()));
796        let _ = fs::remove_dir_all(&dir);
797        fs::create_dir_all(&dir).unwrap();
798        let path = dir.join("main.db");
799        let first = Database::open(&path).unwrap();
800        let error = match Database::open(&path) {
801            Ok(_) => panic!("a second database open should be rejected"),
802            Err(error) => error,
803        };
804        assert_eq!(error.kind, DbErrorKind::Busy);
805        assert!(error.message.contains("already open"));
806        drop(first);
807        let second = Database::open(&path).unwrap();
808        drop(second);
809        let _ = fs::remove_dir_all(dir);
810    }
811}