Skip to main content

kanban_persistence_sqlite/
lib.rs

1pub mod sqlite_store;
2
3pub use sqlite_store::SqliteStore;
4pub use sqlite_store::SUPPORTED_SCHEMA_VERSION;
5
6use kanban_domain::KanbanError;
7use kanban_persistence::{PersistenceError, PersistenceStore, StoreFactory};
8use std::sync::Arc;
9
10/// Construct a SQLite database file at `path` with a `metadata` row whose
11/// `schema_version` is forced to `version`. Intended for cross-crate
12/// integration tests that need to exercise the `UnsupportedFutureVersion`
13/// refusal at every surface (service, MCP, CLI) without each test
14/// reimplementing the seed SQL.
15///
16/// Bypasses `SqliteStore::open` deliberately — opening would normalise the
17/// version via `migrate()` before the test could observe the pre-bumped
18/// state. Writes only the `metadata` table; the rest of the schema is
19/// created by `SqliteStore::open` on first real load.
20///
21/// Gated behind the `test-helpers` feature so it does not ship in release
22/// binaries. Mirrors the pattern in `kanban-persistence::test_helpers`.
23#[cfg(feature = "test-helpers")]
24pub async fn write_test_metadata_with_schema_version(
25    path: &std::path::Path,
26    version: u32,
27) -> Result<(), PersistenceError> {
28    use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
29    let pool = SqlitePoolOptions::new()
30        .max_connections(1)
31        .connect_with(
32            SqliteConnectOptions::new()
33                .filename(path)
34                .create_if_missing(true),
35        )
36        .await
37        .map_err(|e| PersistenceError::Database(e.to_string()))?;
38    sqlx::raw_sql(&format!(
39        "CREATE TABLE IF NOT EXISTS metadata (
40            id INTEGER PRIMARY KEY CHECK (id = 1),
41            instance_id TEXT NOT NULL,
42            saved_at TEXT NOT NULL,
43            schema_version INTEGER NOT NULL DEFAULT {SUPPORTED_SCHEMA_VERSION},
44            writer_version TEXT,
45            writer_commit TEXT
46        );
47        INSERT OR REPLACE INTO metadata (id, instance_id, saved_at, schema_version)
48        VALUES (1, '550e8400-e29b-41d4-a716-446655440000', '2030-01-01T00:00:00Z', {version});"
49    ))
50    .execute(&pool)
51    .await
52    .map_err(|e| PersistenceError::Database(e.to_string()))?;
53    pool.close().await;
54    Ok(())
55}
56
57/// Test-only companion to [`write_test_metadata_with_schema_version`]: probe
58/// the metadata row's `schema_version` without going through `SqliteStore::open`
59/// (which would normalise it). Used by integration tests that want to assert
60/// a refused open didn't bump the on-disk version.
61///
62/// Gated behind the `test-helpers` feature; see the sibling function for
63/// rationale.
64#[cfg(feature = "test-helpers")]
65pub async fn read_test_schema_version(
66    path: &std::path::Path,
67) -> Result<Option<u32>, PersistenceError> {
68    use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
69    let pool = SqlitePoolOptions::new()
70        .max_connections(1)
71        .connect_with(SqliteConnectOptions::new().filename(path))
72        .await
73        .map_err(|e| PersistenceError::Database(e.to_string()))?;
74    let version: Option<u32> =
75        sqlx::query_scalar("SELECT schema_version FROM metadata WHERE id = 1")
76            .fetch_optional(&pool)
77            .await
78            .map_err(|e| PersistenceError::Database(e.to_string()))?;
79    pool.close().await;
80    Ok(version)
81}
82
83pub struct SqliteStoreFactory;
84
85impl StoreFactory for SqliteStoreFactory {
86    fn name(&self) -> &str {
87        "sqlite"
88    }
89
90    fn matches_content(&self, header: &[u8]) -> bool {
91        header.starts_with(b"SQLite format 3\0")
92    }
93
94    fn create(
95        &self,
96        locator: &str,
97    ) -> Result<Arc<dyn PersistenceStore + Send + Sync>, PersistenceError> {
98        let handle = tokio::runtime::Handle::current();
99        if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread {
100            return Err(PersistenceError::Database(
101                "SqliteStoreFactory::create requires a multi-thread Tokio runtime; \
102                 block_in_place is unavailable on a current_thread runtime. \
103                 Use #[tokio::test(flavor = \"multi_thread\")] in tests."
104                    .to_string(),
105            ));
106        }
107        let store = tokio::task::block_in_place(|| handle.block_on(SqliteStore::open(locator)))
108            // Preserve typed variants across the KanbanError → PersistenceError
109            // boundary so downstream callers can discriminate them (esp.
110            // UnsupportedFutureVersion via `KanbanError::is_unsupported_future_version`).
111            // Stringifying everything into Database would flatten the typed
112            // variant and break the cross-surface refusal contract.
113            .map_err(|e| match e {
114                KanbanError::UnsupportedFutureVersion {
115                    file_version,
116                    binary_max,
117                } => PersistenceError::UnsupportedFutureVersion {
118                    file_version,
119                    binary_max,
120                },
121                other => PersistenceError::Database(other.to_string()),
122            })?;
123        Ok(Arc::new(store))
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use kanban_persistence::StoreFactory;
131
132    #[test]
133    fn test_sqlite_factory_matches_content_sqlite_magic_bytes() {
134        let header = b"SQLite format 3\0extra";
135        assert!(SqliteStoreFactory.matches_content(header));
136    }
137
138    #[test]
139    fn test_sqlite_factory_matches_content_rejects_json() {
140        let header = b"{\"boards\": []}";
141        assert!(!SqliteStoreFactory.matches_content(header));
142    }
143
144    #[test]
145    fn test_sqlite_factory_matches_content_rejects_empty() {
146        assert!(!SqliteStoreFactory.matches_content(b""));
147    }
148
149    #[test]
150    fn test_sqlite_factory_name_is_sqlite() {
151        assert_eq!(SqliteStoreFactory.name(), "sqlite");
152    }
153
154    #[tokio::test(flavor = "multi_thread")]
155    async fn test_sqlite_factory_create_returns_persistence_store() {
156        let dir = tempfile::tempdir().unwrap();
157        let path = dir.path().join("test.db");
158        let store = SqliteStoreFactory.create(path.to_str().unwrap()).unwrap();
159        assert!(store.exists().await);
160    }
161}