1#![forbid(unsafe_code)]
3
4use serde::{Deserialize, Serialize};
5pub use sqlx::SqlitePool;
6use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
7use std::{path::Path, str::FromStr, time::Duration};
8use thiserror::Error;
9
10pub mod plugin_adapters;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SqlitePoolConfig {
14 pub url: String,
15 pub max_connections: u32,
16 pub acquire_timeout_seconds: u64,
17}
18
19impl SqlitePoolConfig {
20 pub fn file(path: impl AsRef<Path>) -> Self {
21 Self {
22 url: format!("sqlite://{}", path.as_ref().display()),
23 max_connections: 4,
24 acquire_timeout_seconds: 5,
25 }
26 }
27 pub fn memory() -> Self {
28 Self {
29 url: "sqlite::memory:".into(),
30 max_connections: 1,
31 acquire_timeout_seconds: 5,
32 }
33 }
34 pub fn is_memory(&self) -> bool {
35 self.url == "sqlite::memory:" || self.url.contains("mode=memory")
36 }
37 pub fn validate(&self) -> Result<(), SqliteError> {
38 if self.url.trim().is_empty() {
39 return Err(SqliteError::InvalidConfig("database URL is empty".into()));
40 }
41 if self.max_connections == 0 {
42 return Err(SqliteError::InvalidConfig(
43 "max_connections must be at least 1".into(),
44 ));
45 }
46 if self.is_memory() && self.max_connections != 1 {
47 return Err(SqliteError::InvalidConfig(
48 "in-memory SQLite requires exactly one pooled connection".into(),
49 ));
50 }
51 Ok(())
52 }
53}
54
55pub async fn connect(config: &SqlitePoolConfig) -> Result<SqlitePool, SqliteError> {
56 config.validate()?;
57 let mut options = SqliteConnectOptions::from_str(&config.url)?
58 .create_if_missing(!config.is_memory())
59 .foreign_keys(true)
60 .busy_timeout(Duration::from_secs(config.acquire_timeout_seconds));
61 if !config.is_memory() {
62 options = options.journal_mode(SqliteJournalMode::Wal);
63 }
64 Ok(SqlitePoolOptions::new()
65 .max_connections(config.max_connections)
66 .acquire_timeout(Duration::from_secs(config.acquire_timeout_seconds))
67 .connect_with(options)
68 .await?)
69}
70
71pub async fn migrate(pool: &SqlitePool, path: impl AsRef<Path>) -> Result<(), SqliteError> {
72 let migrator = sqlx::migrate::Migrator::new(path.as_ref()).await?;
73 migrator.run(pool).await?;
74 Ok(())
75}
76
77pub async fn migrate_with_history_table(
78 pool: &SqlitePool,
79 path: impl AsRef<Path>,
80 history_table: &'static str,
81) -> Result<(), SqliteError> {
82 validate_identifier(history_table, "migration history table")?;
83 let mut migrator = sqlx::migrate::Migrator::new(path.as_ref()).await?;
84 migrator.dangerous_set_table_name(history_table);
85 migrator.run(pool).await?;
86 Ok(())
87}
88
89pub async fn ready(pool: &SqlitePool) -> bool {
90 matches!(
91 sqlx::query_scalar::<_, i64>("SELECT 1")
92 .fetch_one(pool)
93 .await,
94 Ok(1)
95 )
96}
97
98fn validate_identifier(value: &str, description: &str) -> Result<(), SqliteError> {
99 let mut bytes = value.bytes();
100 let valid_start = bytes
101 .next()
102 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
103 if !valid_start
104 || value.len() > 63
105 || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
106 {
107 return Err(SqliteError::InvalidConfig(format!(
108 "{description} must be a SQLite identifier of at most 63 ASCII characters"
109 )));
110 }
111 Ok(())
112}
113
114#[derive(Debug, Error)]
115pub enum SqliteError {
116 #[error("invalid SQLite configuration: {0}")]
117 InvalidConfig(String),
118 #[error("SQLite error: {0}")]
119 Sqlx(#[from] sqlx::Error),
120 #[error("SQLite migration error: {0}")]
121 Migration(#[from] sqlx::migrate::MigrateError),
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 #[test]
128 fn memory_profile_rejects_multiple_connections() {
129 let mut config = SqlitePoolConfig::memory();
130 config.max_connections = 2;
131 assert!(config.validate().is_err());
132 }
133
134 #[tokio::test]
135 async fn migration_history_table_rejects_dynamic_sql_tokens() {
136 let pool = connect(&SqlitePoolConfig::memory())
137 .await
138 .expect("in-memory pool");
139 let result =
140 migrate_with_history_table(&pool, Path::new("missing"), "_migrations;DROP").await;
141 assert!(matches!(result, Err(SqliteError::InvalidConfig(_))));
142 }
143}