Skip to main content

remem/db/
core.rs

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