Skip to main content

remem/db/
core.rs

1#[cfg(test)]
2use std::cell::Cell;
3use std::cell::RefCell;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use rusqlite::{Connection, OpenFlags};
8use sha2::{Digest, Sha256};
9
10thread_local! {
11    static DATA_DIR_OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
12}
13
14#[cfg(test)]
15thread_local! {
16    static CONFIGURED_CONNECTION_OPENS: Cell<usize> = const { Cell::new(0) };
17}
18
19#[cfg(test)]
20pub(crate) fn reset_configured_connection_open_count() {
21    CONFIGURED_CONNECTION_OPENS.with(|count| count.set(0));
22}
23
24#[cfg(test)]
25pub(crate) fn configured_connection_open_count() -> usize {
26    CONFIGURED_CONNECTION_OPENS.with(Cell::get)
27}
28
29#[cfg(test)]
30fn record_configured_connection_open() {
31    CONFIGURED_CONNECTION_OPENS.with(|count| count.set(count.get() + 1));
32}
33
34pub(crate) fn with_data_dir<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
35    let _guard = DataDirOverrideGuard::set(dir.to_path_buf());
36    f()
37}
38
39pub fn deterministic_hash(data: &[u8]) -> u64 {
40    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
41    const FNV_PRIME: u64 = 0x00000100000001B3;
42    let mut hash = FNV_OFFSET;
43    for &byte in data {
44        hash ^= byte as u64;
45        hash = hash.wrapping_mul(FNV_PRIME);
46    }
47    hash
48}
49
50pub fn content_identity_hash(data: &[u8]) -> String {
51    let mut hasher = Sha256::new();
52    hasher.update(data);
53    format!("sha256:content-v1:{:x}", hasher.finalize())
54}
55
56pub fn legacy_content_identity_hash(data: &[u8]) -> String {
57    format!("{:016x}", deterministic_hash(data))
58}
59
60pub fn to_sql_refs(params: &[Box<dyn rusqlite::types::ToSql>]) -> Vec<&dyn rusqlite::types::ToSql> {
61    params.iter().map(|b| b.as_ref()).collect()
62}
63
64pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
65    if s.len() <= max_bytes {
66        return s;
67    }
68    let mut end = max_bytes;
69    while end > 0 && !s.is_char_boundary(end) {
70        end -= 1;
71    }
72    &s[..end]
73}
74
75pub fn canonical_project_path(cwd: &str) -> PathBuf {
76    crate::project_id::canonical_project_path(cwd)
77}
78
79pub fn project_from_cwd(cwd: &str) -> String {
80    crate::project_id::project_from_cwd(cwd)
81}
82
83pub fn data_dir() -> PathBuf {
84    if let Some(path) = DATA_DIR_OVERRIDE.with(|slot| slot.borrow().clone()) {
85        return path;
86    }
87    std::env::var("REMEM_DATA_DIR")
88        .map(PathBuf::from)
89        .unwrap_or_else(|_| {
90            dirs::home_dir()
91                .unwrap_or_else(|| PathBuf::from("."))
92                .join(".remem")
93        })
94}
95
96pub fn absolute_data_dir() -> Result<PathBuf> {
97    let path = data_dir();
98    if path.is_absolute() {
99        return Ok(path);
100    }
101    Ok(std::env::current_dir()
102        .context("read current directory for relative REMEM_DATA_DIR")?
103        .join(path))
104}
105
106pub fn db_path() -> PathBuf {
107    data_dir().join("remem.db")
108}
109
110pub fn open_db() -> Result<Connection> {
111    let path = db_path();
112    let key = super::crypto::require_cipher_key_or_plaintext_override()?;
113    if let Some(parent) = path.parent() {
114        std::fs::create_dir_all(parent)?;
115        #[cfg(unix)]
116        {
117            use std::os::unix::fs::PermissionsExt;
118            let perms = std::fs::Permissions::from_mode(0o700);
119            if let Err(e) = std::fs::set_permissions(parent, perms) {
120                crate::log::warn("db", &format!("cannot set data dir permissions: {}", e));
121            }
122        }
123    }
124
125    let conn = open_configured_connection(&path, key.as_ref())?;
126    crate::retrieval::vector::load_vec_extension(&conn)?;
127    crate::migrate::run_migrations(&conn)?;
128    crate::retrieval::vector::ensure_vec_table(&conn)?;
129    Ok(conn)
130}
131
132pub fn open_db_no_migrate() -> Result<Connection> {
133    let path = db_path();
134    let key = super::crypto::require_cipher_key_or_plaintext_override()?;
135    if !path.exists() {
136        anyhow::bail!("database not found: {}", path.display());
137    }
138
139    let conn = open_configured_existing_read_write_connection(&path, key.as_ref())?;
140    crate::retrieval::vector::load_vec_extension(&conn)?;
141    crate::migrate::ensure_schema_current(&conn)?;
142    Ok(conn)
143}
144
145pub fn open_db_for_hook() -> Result<Connection> {
146    let conn = open_db_no_migrate().context(
147        "hook database open requires an existing current schema without drift; run `remem install` outside the hook path",
148    )?;
149    Ok(conn)
150}
151
152pub fn open_db_read_only() -> Result<Connection> {
153    let path = db_path();
154    let key = super::crypto::require_cipher_key_or_plaintext_override()?;
155    if !path.exists() {
156        anyhow::bail!("database not found: {}", path.display());
157    }
158
159    open_configured_read_only_connection(&path, key.as_ref())
160}
161
162/// Open an existing database read-only and fail closed unless its schema is
163/// current for this binary. This never creates, migrates, or repairs the store.
164pub fn open_db_read_only_current() -> Result<Connection> {
165    let conn = open_db_read_only()?;
166    crate::migrate::ensure_schema_current(&conn)?;
167    Ok(conn)
168}
169
170pub(crate) fn open_configured_connection(
171    path: &Path,
172    key: Option<&super::crypto::CipherKey>,
173) -> Result<Connection> {
174    let conn = Connection::open(path)
175        .with_context(|| format!("Failed to open database: {}", path.display()))?;
176    #[cfg(test)]
177    record_configured_connection_open();
178
179    super::crypto::configure_cipher(&conn, key)?;
180
181    conn.execute_batch(
182        "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;",
183    )?;
184    Ok(conn)
185}
186
187pub(crate) fn open_configured_existing_read_write_connection(
188    path: &Path,
189    key: Option<&super::crypto::CipherKey>,
190) -> Result<Connection> {
191    let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE).with_context(
192        || {
193            format!(
194                "Failed to open existing database read-write: {}",
195                path.display()
196            )
197        },
198    )?;
199    #[cfg(test)]
200    record_configured_connection_open();
201
202    super::crypto::configure_cipher(&conn, key)?;
203
204    conn.execute_batch(
205        "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;",
206    )?;
207    Ok(conn)
208}
209
210pub(crate) fn open_configured_read_only_connection(
211    path: &Path,
212    key: Option<&super::crypto::CipherKey>,
213) -> Result<Connection> {
214    let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
215        .with_context(|| format!("Failed to open database read-only: {}", path.display()))?;
216    #[cfg(test)]
217    record_configured_connection_open();
218
219    super::crypto::configure_cipher(&conn, key)?;
220    conn.execute_batch("PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;")?;
221    Ok(conn)
222}
223
224pub fn detect_git_branch(cwd: &str) -> Option<String> {
225    let output =
226        crate::git_util::git_output_soft(Path::new(cwd), &["rev-parse", "--abbrev-ref", "HEAD"])?;
227    if !output.status.success() {
228        return None;
229    }
230    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
231    if branch.is_empty() || branch == "HEAD" {
232        None
233    } else {
234        Some(branch)
235    }
236}
237
238struct DataDirOverrideGuard {
239    previous: Option<PathBuf>,
240}
241
242impl DataDirOverrideGuard {
243    fn set(path: PathBuf) -> Self {
244        let previous = DATA_DIR_OVERRIDE.with(|slot| slot.replace(Some(path)));
245        Self { previous }
246    }
247}
248
249impl Drop for DataDirOverrideGuard {
250    fn drop(&mut self) {
251        let previous = self.previous.take();
252        DATA_DIR_OVERRIDE.with(|slot| {
253            slot.replace(previous);
254        });
255    }
256}
257
258pub fn detect_git_commit(cwd: &str) -> Option<String> {
259    let output =
260        crate::git_util::git_output_soft(Path::new(cwd), &["rev-parse", "--short", "HEAD"])?;
261    if !output.status.success() {
262        return None;
263    }
264    let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
265    if sha.is_empty() {
266        None
267    } else {
268        Some(sha)
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use rusqlite::{params, Connection};
275
276    use super::*;
277    use crate::db::crypto::ALLOW_PLAINTEXT_ENV;
278    use crate::db::test_support::ScopedTestDataDir;
279
280    #[test]
281    fn content_identity_hash_is_versioned_sha256() {
282        let hash = content_identity_hash(b"hello world");
283
284        assert_eq!(
285            hash,
286            "sha256:content-v1:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
287        );
288        let legacy_hash = legacy_content_identity_hash(b"hello world");
289        assert_eq!(legacy_hash.len(), 16);
290        assert!(!legacy_hash.starts_with("sha256:"));
291    }
292
293    #[test]
294    fn open_db_for_hook_does_not_create_missing_database() {
295        let test_dir = ScopedTestDataDir::new("hook-open-missing");
296        test_dir.remove_db_files();
297
298        let err = open_db_for_hook().expect_err("missing database should fail");
299
300        let message = err.to_string();
301        assert!(
302            message.contains("hook database open requires"),
303            "unexpected error: {message}"
304        );
305        assert!(
306            !test_dir.path.exists(),
307            "hook open must not create data dir"
308        );
309        assert!(
310            !test_dir.db_path().exists(),
311            "hook open must not create database file"
312        );
313    }
314
315    #[test]
316    fn open_db_for_hook_opens_current_schema_read_write() -> Result<()> {
317        let _test_dir = ScopedTestDataDir::new("hook-open-current-rw");
318        let setup = crate::db::open_db()?;
319        drop(setup);
320
321        let conn = crate::db::open_db_for_hook()?;
322        conn.execute("CREATE TABLE hook_rw_probe(id INTEGER PRIMARY KEY)", [])?;
323        conn.execute("INSERT INTO hook_rw_probe(id) VALUES (1)", [])?;
324        let count: i64 =
325            conn.query_row("SELECT COUNT(*) FROM hook_rw_probe", [], |row| row.get(0))?;
326
327        assert_eq!(count, 1);
328        Ok(())
329    }
330
331    #[test]
332    fn open_db_for_hook_rejects_older_schema_without_migrating() -> Result<()> {
333        let _test_dir = ScopedTestDataDir::new("hook-open-older-schema");
334        let setup = crate::db::open_db()?;
335        let latest = crate::migrate::latest_schema_version();
336        setup.execute(
337            "DELETE FROM _schema_migrations WHERE version = ?1",
338            [latest],
339        )?;
340        drop(setup);
341
342        let err = crate::db::open_db_for_hook()
343            .expect_err("older schema should require foreground migration");
344
345        assert!(
346            err.to_string().contains("hook database open requires"),
347            "unexpected error: {err:#}"
348        );
349        let check = Connection::open(crate::db::db_path())?;
350        let latest_rows: i64 = check.query_row(
351            "SELECT COUNT(*) FROM _schema_migrations WHERE version = ?1",
352            [latest],
353            |row| row.get(0),
354        )?;
355        assert_eq!(latest_rows, 0);
356        Ok(())
357    }
358
359    #[test]
360    fn open_db_for_hook_rejects_schema_drift_without_repairing() -> Result<()> {
361        let test_dir = ScopedTestDataDir::new("hook-open-schema-drift");
362        create_current_schema_missing_migration(&test_dir.db_path(), 22)?;
363
364        let err = crate::db::open_db_for_hook().expect_err("schema drift should fail closed");
365
366        assert!(
367            format!("{err:#}").contains("schema drift requires foreground migration"),
368            "unexpected error: {err:#}"
369        );
370        assert!(
371            format!("{err:#}").contains("schema drift"),
372            "unexpected error: {err:#}"
373        );
374        let check = Connection::open(test_dir.db_path())?;
375        let state_keys_exists: i64 = check.query_row(
376            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'memory_state_keys'",
377            [],
378            |row| row.get(0),
379        )?;
380        assert_eq!(state_keys_exists, 0);
381        Ok(())
382    }
383
384    #[test]
385    fn open_db_no_migrate_rejects_post_v022_schema_drift_without_repairing() -> Result<()> {
386        let test_dir = ScopedTestDataDir::new("no-migrate-post-v022-schema-drift");
387        create_current_schema_missing_migration(&test_dir.db_path(), 45)?;
388
389        let err = crate::db::open_db_no_migrate().expect_err("schema drift should fail closed");
390
391        let message = format!("{err:#}");
392        assert!(
393            message.contains("schema drift requires foreground migration"),
394            "unexpected error: {message}"
395        );
396        assert!(
397            message.contains("v045_memory_usage_columns"),
398            "unexpected error: {message}"
399        );
400        let check = Connection::open(test_dir.db_path())?;
401        let usage_table_exists: i64 = check.query_row(
402            "SELECT COUNT(*) FROM sqlite_master
403             WHERE type = 'table' AND name = 'memory_citation_events'",
404            [],
405            |row| row.get(0),
406        )?;
407        assert_eq!(usage_table_exists, 0);
408        Ok(())
409    }
410
411    #[test]
412    fn open_db_read_only_does_not_create_missing_database() {
413        let test_dir = ScopedTestDataDir::new("readonly-missing");
414        test_dir.remove_db_files();
415
416        let err = open_db_read_only().expect_err("missing database should fail");
417
418        let message = err.to_string();
419        assert!(
420            message.contains("database not found"),
421            "unexpected error: {message}"
422        );
423        assert!(
424            !test_dir.path.exists(),
425            "read-only open must not create data dir"
426        );
427        assert!(
428            !test_dir.db_path().exists(),
429            "read-only open must not create database file"
430        );
431    }
432
433    #[test]
434    fn open_db_read_only_refuses_plaintext_without_explicit_override() -> Result<()> {
435        let test_dir = ScopedTestDataDir::new("readonly-cipher-fail-closed");
436        let conn = crate::db::open_db()?;
437        drop(conn);
438        std::env::remove_var(ALLOW_PLAINTEXT_ENV);
439
440        let err = crate::db::open_db_read_only()
441            .expect_err("read-only open must enforce the plaintext guard");
442
443        let message = err.to_string();
444        assert!(message.contains("SQLCipher key"), "got: {message}");
445        assert!(
446            test_dir.db_path().exists(),
447            "read-only guard must not remove the existing database"
448        );
449        Ok(())
450    }
451
452    #[test]
453    fn open_db_does_not_backfill_missing_vector_embeddings() -> Result<()> {
454        let _test_dir = ScopedTestDataDir::new("open-no-vector-backfill");
455        let conn = crate::db::open_db()?;
456        conn.execute(
457            "INSERT INTO memories
458             (id, project, title, content, memory_type, created_at_epoch, updated_at_epoch, status)
459             VALUES (1, '/repo', 'Vector row', 'Open must not backfill this row.', 'decision', 1, 1, 'active')",
460            [],
461        )?;
462        drop(conn);
463
464        let reopened = crate::db::open_db()?;
465        let count: i64 =
466            reopened.query_row("SELECT COUNT(*) FROM memory_embeddings", [], |row| {
467                row.get(0)
468            })?;
469        assert_eq!(count, 0);
470        assert_eq!(
471            crate::retrieval::vector::backfill_missing_memory_embeddings(&reopened, 10)?,
472            1
473        );
474        Ok(())
475    }
476
477    #[test]
478    fn open_db_read_only_does_not_run_migrations() -> Result<()> {
479        let _test_dir = ScopedTestDataDir::new("readonly-no-migration");
480        let path = crate::db::db_path();
481        std::fs::create_dir_all(crate::db::data_dir())?;
482        let conn = Connection::open(&path)?;
483        conn.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY)", [])?;
484        drop(conn);
485
486        let readonly = crate::db::open_db_read_only()?;
487        let marker_exists: i64 = readonly.query_row(
488            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'marker'",
489            [],
490            |row| row.get(0),
491        )?;
492        let migrations_exists: i64 = readonly.query_row(
493            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'",
494            [],
495            |row| row.get(0),
496        )?;
497
498        assert_eq!(marker_exists, 1);
499        assert_eq!(migrations_exists, 0);
500        Ok(())
501    }
502
503    #[test]
504    fn open_db_read_only_current_works_while_writer_holds_immediate_lock() -> Result<()> {
505        let _test_dir = ScopedTestDataDir::new("readonly-current-write-lock");
506        let writer = crate::db::open_db()?;
507        writer.execute_batch("BEGIN IMMEDIATE")?;
508
509        let readonly = crate::db::open_db_read_only_current()?;
510        let latest: i64 =
511            readonly.query_row("SELECT MAX(version) FROM _schema_migrations", [], |row| {
512                row.get(0)
513            })?;
514
515        assert_eq!(latest, crate::migrate::latest_schema_version());
516        writer.execute_batch("ROLLBACK")?;
517        Ok(())
518    }
519
520    #[test]
521    fn open_db_read_only_current_rejects_stale_schema_without_writes() -> Result<()> {
522        let _test_dir = ScopedTestDataDir::new("readonly-current-stale");
523        let setup = crate::db::open_db()?;
524        let latest = crate::migrate::latest_schema_version();
525        setup.execute(
526            "DELETE FROM _schema_migrations WHERE version = ?1",
527            [latest],
528        )?;
529        drop(setup);
530
531        let err =
532            crate::db::open_db_read_only_current().expect_err("stale schema must fail closed");
533        assert!(
534            err.to_string().contains("run a foreground remem command"),
535            "unexpected error: {err:#}"
536        );
537
538        let check = Connection::open(crate::db::db_path())?;
539        let rows: i64 = check.query_row(
540            "SELECT COUNT(*) FROM _schema_migrations WHERE version = ?1",
541            [latest],
542            |row| row.get(0),
543        )?;
544        assert_eq!(rows, 0);
545        Ok(())
546    }
547
548    #[test]
549    fn open_db_no_migrate_does_not_create_missing_database() {
550        let test_dir = ScopedTestDataDir::new("no-migrate-missing");
551        test_dir.remove_db_files();
552
553        let err = open_db_no_migrate().expect_err("missing database should fail");
554
555        let message = err.to_string();
556        assert!(
557            message.contains("database not found"),
558            "unexpected error: {message}"
559        );
560        assert!(
561            !test_dir.path.exists(),
562            "no-migrate open must not create data dir"
563        );
564        assert!(
565            !test_dir.db_path().exists(),
566            "no-migrate open must not create database file"
567        );
568    }
569
570    #[test]
571    fn open_db_no_migrate_refuses_plaintext_without_explicit_override() -> Result<()> {
572        let test_dir = ScopedTestDataDir::new("no-migrate-cipher-fail-closed");
573        let conn = crate::db::open_db()?;
574        drop(conn);
575        std::env::remove_var(ALLOW_PLAINTEXT_ENV);
576
577        let err = crate::db::open_db_no_migrate()
578            .expect_err("no-migrate open must enforce the plaintext guard");
579
580        let message = err.to_string();
581        assert!(message.contains("SQLCipher key"), "got: {message}");
582        assert!(
583            test_dir.db_path().exists(),
584            "no-migrate guard must not remove the existing database"
585        );
586        Ok(())
587    }
588
589    #[test]
590    fn open_db_no_migrate_opens_current_schema_read_write() -> Result<()> {
591        let _test_dir = ScopedTestDataDir::new("no-migrate-current-rw");
592        let setup = crate::db::open_db()?;
593        drop(setup);
594
595        let conn = crate::db::open_db_no_migrate()?;
596        conn.execute(
597            "CREATE TABLE no_migrate_rw_probe(id INTEGER PRIMARY KEY)",
598            [],
599        )?;
600        conn.execute("INSERT INTO no_migrate_rw_probe(id) VALUES (1)", [])?;
601        let count: i64 = conn.query_row("SELECT COUNT(*) FROM no_migrate_rw_probe", [], |row| {
602            row.get(0)
603        })?;
604
605        assert_eq!(count, 1);
606        Ok(())
607    }
608
609    #[test]
610    fn open_db_no_migrate_does_not_run_migrations() -> Result<()> {
611        let test_dir = ScopedTestDataDir::new("no-migrate-no-migration");
612        std::fs::create_dir_all(&test_dir.path)?;
613        let setup = Connection::open(test_dir.db_path())?;
614        setup.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY)", [])?;
615        drop(setup);
616
617        let err = crate::db::open_db_no_migrate().expect_err("stale schema should fail");
618
619        assert!(
620            err.to_string().contains("schema is not initialized"),
621            "unexpected error: {err:#}"
622        );
623        let check = Connection::open(test_dir.db_path())?;
624        let migrations_exists: i64 = check.query_row(
625            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '_schema_migrations'",
626            [],
627            |row| row.get(0),
628        )?;
629        assert_eq!(migrations_exists, 0);
630        Ok(())
631    }
632
633    #[test]
634    fn open_db_no_migrate_rejects_older_schema_without_migrating() -> Result<()> {
635        let _test_dir = ScopedTestDataDir::new("no-migrate-older-schema");
636        let setup = crate::db::open_db()?;
637        let latest = crate::migrate::latest_schema_version();
638        setup.execute(
639            "DELETE FROM _schema_migrations WHERE version = ?1",
640            [latest],
641        )?;
642        drop(setup);
643
644        let err = crate::db::open_db_no_migrate()
645            .expect_err("older schema should require foreground migration");
646
647        assert!(
648            err.to_string().contains("requires schema"),
649            "unexpected error: {err:#}"
650        );
651        let check = Connection::open(crate::db::db_path())?;
652        let latest_rows: i64 = check.query_row(
653            "SELECT COUNT(*) FROM _schema_migrations WHERE version = ?1",
654            [latest],
655            |row| row.get(0),
656        )?;
657        assert_eq!(latest_rows, 0);
658        Ok(())
659    }
660
661    #[test]
662    fn open_db_no_migrate_rejects_incomplete_schema_without_migrating() -> Result<()> {
663        let _test_dir = ScopedTestDataDir::new("no-migrate-incomplete-schema");
664        let setup = crate::db::open_db()?;
665        let latest = crate::migrate::latest_schema_version();
666        let missing = crate::migrate::MIGRATIONS
667            .iter()
668            .rev()
669            .find(|migration| migration.version < latest)
670            .expect("test requires at least two migrations")
671            .version;
672        setup.execute(
673            "DELETE FROM _schema_migrations WHERE version = ?1",
674            [missing],
675        )?;
676        drop(setup);
677
678        let err = crate::db::open_db_no_migrate()
679            .expect_err("incomplete schema should require foreground migration");
680
681        assert!(
682            err.to_string().contains("missing migration"),
683            "unexpected error: {err:#}"
684        );
685        let check = Connection::open(crate::db::db_path())?;
686        let (missing_rows, latest_rows): (i64, i64) = check.query_row(
687            "SELECT
688                SUM(CASE WHEN version = ?1 THEN 1 ELSE 0 END),
689                SUM(CASE WHEN version = ?2 THEN 1 ELSE 0 END)
690             FROM _schema_migrations",
691            (missing, latest),
692            |row| Ok((row.get(0)?, row.get(1)?)),
693        )?;
694        assert_eq!(missing_rows, 0);
695        assert_eq!(latest_rows, 1);
696        Ok(())
697    }
698
699    #[test]
700    fn open_db_no_migrate_rejects_newer_schema_without_migrating() -> Result<()> {
701        let _test_dir = ScopedTestDataDir::new("no-migrate-newer-schema");
702        let setup = crate::db::open_db()?;
703        let latest = crate::migrate::latest_schema_version();
704        setup.execute(
705            "INSERT INTO _schema_migrations (version, name, applied_at_epoch)
706             VALUES (?1, 'future-test', 0)",
707            [latest + 1],
708        )?;
709        drop(setup);
710
711        let err = crate::db::open_db_no_migrate().expect_err("newer schema should fail closed");
712
713        assert!(
714            err.to_string().contains("only knows up to"),
715            "unexpected error: {err:#}"
716        );
717        Ok(())
718    }
719
720    #[test]
721    fn open_db_read_only_opens_existing_database_without_write_access() -> Result<()> {
722        let test_dir = ScopedTestDataDir::new("readonly-existing");
723        std::fs::create_dir_all(&test_dir.path)?;
724        let setup = Connection::open(test_dir.db_path())?;
725        setup.execute_batch(
726            "CREATE TABLE readonly_probe(id INTEGER PRIMARY KEY);
727             INSERT INTO readonly_probe(id) VALUES (1);",
728        )?;
729        drop(setup);
730
731        let conn = open_db_read_only()?;
732        let count: i64 =
733            conn.query_row("SELECT COUNT(*) FROM readonly_probe", [], |row| row.get(0))?;
734        assert_eq!(count, 1);
735
736        let err = conn
737            .execute("INSERT INTO readonly_probe(id) VALUES (2)", [])
738            .expect_err("read-only connection must reject writes");
739        assert_eq!(err.sqlite_error_code(), Some(rusqlite::ErrorCode::ReadOnly));
740        Ok(())
741    }
742
743    fn create_current_schema_missing_migration(path: &Path, missing_version: i64) -> Result<()> {
744        if let Some(parent) = path.parent() {
745            std::fs::create_dir_all(parent)?;
746        }
747        let conn = Connection::open(path)?;
748        conn.execute_batch(
749            "PRAGMA journal_mode=WAL;
750             PRAGMA foreign_keys=OFF;
751             PRAGMA writable_schema=ON;",
752        )?;
753        for migration in crate::migrate::MIGRATIONS
754            .iter()
755            .filter(|migration| migration.version != missing_version)
756        {
757            conn.execute_batch(migration.sql)?;
758        }
759        conn.execute_batch(
760            "CREATE TABLE _schema_migrations (
761                version INTEGER PRIMARY KEY,
762                name TEXT NOT NULL,
763                applied_at_epoch INTEGER NOT NULL
764            );",
765        )?;
766        for migration in crate::migrate::MIGRATIONS {
767            conn.execute(
768                "INSERT INTO _schema_migrations (version, name, applied_at_epoch)
769                 VALUES (?1, ?2, 1700000000)",
770                params![migration.version, migration.name],
771            )?;
772        }
773        conn.execute_batch(&format!(
774            "PRAGMA writable_schema=OFF;
775             PRAGMA user_version = {};
776             PRAGMA foreign_keys=ON;",
777            crate::migrate::latest_schema_version()
778        ))?;
779        Ok(())
780    }
781}