Skip to main content

kindling_store/
db.rs

1//! Database open path: connection configuration and schema bootstrap.
2//!
3//! Mirrors `openDatabase` in
4//! `packages/kindling-store-sqlite/src/db/open.ts`: WAL journal mode,
5//! foreign-key enforcement, 5s busy timeout, NORMAL synchronous, 64MB cache.
6//!
7//! Where the TypeScript store runs its migration ladder, this crate applies
8//! the canonical `schema/schema.sql` to fresh databases and refuses databases
9//! whose `PRAGMA user_version` falls outside the compatibility window in
10//! `schema/version.json` (see `schema/README.md`).
11
12use std::path::Path;
13use std::time::Duration;
14
15use rusqlite::{Connection, OpenFlags};
16
17use crate::error::{StoreError, StoreResult};
18use crate::schema::{schema_version, SCHEMA_SQL};
19
20/// Database open options.
21#[derive(Debug, Clone, Default)]
22pub struct StoreOptions {
23    /// Open the database read-only. Schema bootstrap is skipped; opening an
24    /// uninitialized database read-only is an error.
25    pub readonly: bool,
26}
27
28/// Open and initialize a kindling database at `path`.
29///
30/// Creates parent directories as needed (read-write mode only), configures
31/// the connection, applies the canonical schema to fresh databases, and
32/// verifies schema-version compatibility on existing ones.
33pub fn open_database(path: &Path, options: &StoreOptions) -> StoreResult<Connection> {
34    if !options.readonly {
35        if let Some(parent) = path.parent() {
36            if !parent.as_os_str().is_empty() {
37                std::fs::create_dir_all(parent)?;
38            }
39        }
40    }
41
42    let flags = if options.readonly {
43        OpenFlags::SQLITE_OPEN_READ_ONLY
44            | OpenFlags::SQLITE_OPEN_URI
45            | OpenFlags::SQLITE_OPEN_NO_MUTEX
46    } else {
47        OpenFlags::default()
48    };
49
50    let conn = Connection::open_with_flags(path, flags)?;
51    configure(&conn, options.readonly)?;
52    ensure_schema(&conn, options.readonly)?;
53    Ok(conn)
54}
55
56/// Open an in-memory database with the full schema applied. Test helper and
57/// scratch-space convenience; never version-gated because it is always fresh.
58pub fn open_in_memory() -> StoreResult<Connection> {
59    let conn = Connection::open_in_memory()?;
60    configure(&conn, false)?;
61    ensure_schema(&conn, false)?;
62    Ok(conn)
63}
64
65fn configure(conn: &Connection, readonly: bool) -> StoreResult<()> {
66    if !readonly {
67        // journal_mode is persistent in the DB file; read-only connections
68        // inherit it and may not change it.
69        let _mode: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
70    }
71    conn.pragma_update(None, "foreign_keys", "ON")?;
72    conn.busy_timeout(Duration::from_millis(5000))?;
73    conn.pragma_update(None, "synchronous", "NORMAL")?;
74    conn.pragma_update(None, "cache_size", -64000)?;
75    Ok(())
76}
77
78/// Apply the canonical schema to a fresh database, or verify that an existing
79/// database's `PRAGMA user_version` is within the supported window.
80fn ensure_schema(conn: &Connection, readonly: bool) -> StoreResult<()> {
81    let contract = schema_version();
82    let user_version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
83    let has_tables: bool = conn.query_row(
84        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'observations')",
85        [],
86        |row| row.get(0),
87    )?;
88
89    if !has_tables {
90        if readonly {
91            return Err(StoreError::UninitializedDatabase);
92        }
93        conn.execute_batch(SCHEMA_SQL)?;
94        return Ok(());
95    }
96
97    // Pre-005 TypeScript databases have tables but user_version = 0; they
98    // need the TS migration runner. Anything below minCompatible is refused.
99    if user_version < contract.min_compatible || user_version == 0 {
100        return Err(StoreError::SchemaTooOld {
101            found: user_version,
102            min_compatible: contract.min_compatible,
103        });
104    }
105    if user_version > contract.version {
106        return Err(StoreError::SchemaTooNew {
107            found: user_version,
108            supported: contract.version,
109        });
110    }
111    Ok(())
112}