use std::path::Path;
use std::sync::{Arc, Mutex};
use agileplus_domain::intent_graph::IntentGraph;
use anyhow::{Context, Result};
use regex::Regex;
use rusqlite::functions::FunctionFlags;
use rusqlite::{params, Connection, Error as SqliteError, OptionalExtension};
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct Storage {
conn: Arc<Mutex<Connection>>,
}
impl std::fmt::Debug for Storage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Storage").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Note {
pub id: i64,
pub graph_id: String,
pub body: String,
pub created_at: String,
pub updated_at: String,
}
impl Storage {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let conn = Connection::open(path.as_ref())
.with_context(|| format!("opening db at {}", path.as_ref().display()))?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;\n\
PRAGMA synchronous = NORMAL;\n\
PRAGMA foreign_keys = ON;",
)
.context("setting PRAGMAs on persistent connection")?;
register_regexp(&conn).context("registering REGEXP SQL function")?;
let storage = Self {
conn: Arc::new(Mutex::new(conn)),
};
storage.migrate()?;
Ok(storage)
}
pub fn in_memory() -> Result<Self> {
let conn = Connection::open_in_memory().context("opening in-memory db")?;
conn.execute_batch(
"PRAGMA journal_mode = MEMORY;\n\
PRAGMA synchronous = OFF;\n\
PRAGMA foreign_keys = ON;",
)
.context("setting PRAGMAs on in-memory connection")?;
register_regexp(&conn).context("registering REGEXP SQL function")?;
let storage = Self {
conn: Arc::new(Mutex::new(conn)),
};
storage.migrate()?;
Ok(storage)
}
pub fn migrate(&self) -> Result<()> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
apply_migrations_to(&conn)
}
pub fn save_graph(&self, id: &str, graph: &IntentGraph) -> Result<()> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
let json = serde_json::to_string(graph).context("serializing intent graph")?;
conn.execute(
"INSERT INTO intent_graphs (id, payload, updated_at) \
VALUES (?1, ?2, datetime('now')) \
ON CONFLICT(id) DO UPDATE SET \
payload = excluded.payload, \
updated_at = excluded.updated_at",
params![id, json],
)
.context("upserting intent_graphs row")?;
Ok(())
}
pub fn load_graph(&self, id: &str) -> Result<Option<IntentGraph>> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
let mut stmt = conn
.prepare("SELECT payload FROM intent_graphs WHERE id = ?1")
.context("preparing load_graph statement")?;
let mut rows = stmt
.query(params![id])
.context("querying intent_graphs")?;
let Some(row) = rows.next().context("iterating intent_graphs rows")? else {
return Ok(None);
};
let payload: String = row.get(0).context("reading payload column")?;
let graph: IntentGraph = serde_json::from_str(&payload)
.with_context(|| format!("parsing graph payload for id={}", id))?;
Ok(Some(graph))
}
pub fn list_graphs(&self) -> Result<Vec<String>> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
let mut stmt = conn
.prepare("SELECT id FROM intent_graphs ORDER BY updated_at DESC")
.context("preparing list_graphs statement")?;
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))
.context("mapping list_graphs rows")?;
let ids: Vec<String> = rows
.collect::<rusqlite::Result<Vec<_>>>()
.context("collecting list_graphs rows")?;
Ok(ids)
}
pub fn delete_graph(&self, id: &str) -> Result<bool> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
let affected = conn
.execute("DELETE FROM intent_graphs WHERE id = ?1", params![id])
.context("deleting intent_graphs row")?;
Ok(affected > 0)
}
pub fn add_tag(&self, graph_id: &str, tag: &str) -> Result<bool> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
let affected = conn
.execute(
"INSERT OR IGNORE INTO graph_tags (graph_id, tag) VALUES (?1, ?2)",
params![graph_id, tag],
)
.context("inserting graph_tags row")?;
Ok(affected > 0)
}
pub fn remove_tag(&self, graph_id: &str, tag: &str) -> Result<bool> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
let affected = conn
.execute(
"DELETE FROM graph_tags WHERE graph_id = ?1 AND tag = ?2",
params![graph_id, tag],
)
.context("deleting graph_tags row")?;
Ok(affected > 0)
}
pub fn list_tags(&self, graph_id: &str) -> Result<Vec<String>> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
let mut stmt = conn
.prepare("SELECT tag FROM graph_tags WHERE graph_id = ?1 ORDER BY tag ASC")
.context("preparing list_tags statement")?;
let rows = stmt
.query_map(params![graph_id], |r| r.get::<_, String>(0))
.context("mapping list_tags rows")?;
let tags: Vec<String> = rows
.collect::<rusqlite::Result<Vec<_>>>()
.context("collecting list_tags rows")?;
Ok(tags)
}
pub fn add_note(&self, graph_id: &str, body: &str) -> Result<i64> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
conn.execute(
"INSERT INTO graph_notes (graph_id, body) VALUES (?1, ?2)",
params![graph_id, body],
)
.context("inserting graph_notes row")?;
Ok(conn.last_insert_rowid())
}
pub fn list_notes(&self, graph_id: &str) -> Result<Vec<Note>> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
let mut stmt = conn
.prepare(
"SELECT id, graph_id, body, created_at, updated_at \
FROM graph_notes WHERE graph_id = ?1 \
ORDER BY created_at ASC, id ASC",
)
.context("preparing list_notes statement")?;
let rows = stmt
.query_map(params![graph_id], |r| {
Ok(Note {
id: r.get(0)?,
graph_id: r.get(1)?,
body: r.get(2)?,
created_at: r.get(3)?,
updated_at: r.get(4)?,
})
})
.context("mapping list_notes rows")?;
let notes: Vec<Note> = rows
.collect::<rusqlite::Result<Vec<_>>>()
.context("collecting list_notes rows")?;
Ok(notes)
}
pub fn delete_note(&self, graph_id: &str, note_id: i64) -> Result<bool> {
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
let affected = conn
.execute(
"DELETE FROM graph_notes WHERE graph_id = ?1 AND id = ?2",
params![graph_id, note_id],
)
.context("deleting graph_notes row")?;
Ok(affected > 0)
}
}
fn apply_migrations_to(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS migration_history (\
id TEXT PRIMARY KEY, \
applied_at TEXT NOT NULL\
)",
)
.context("creating migration_history table")?;
let tx = conn
.unchecked_transaction()
.context("opening migration transaction")?;
for (id, sql) in super::migrations::ALL {
let already_applied: bool = tx
.query_row(
"SELECT 1 FROM migration_history WHERE id = ?1",
params![id],
|_| Ok(true),
)
.optional()
.context("checking migration_history")?
.unwrap_or(false);
if already_applied {
continue;
}
tx.execute_batch(sql)
.with_context(|| format!("applying migration {id}"))?;
tx.execute(
"INSERT INTO migration_history (id, applied_at) VALUES (?1, datetime('now'))",
params![id],
)
.with_context(|| format!("recording migration {id} in migration_history"))?;
}
tx.commit().context("committing migration transaction")?;
Ok(())
}
fn register_regexp(conn: &Connection) -> rusqlite::Result<()> {
conn.create_scalar_function(
"REGEXP",
2,
FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
|ctx| {
let pattern: String = ctx.get(0)?;
let text: String = ctx.get(1)?;
match Regex::new(&pattern) {
Ok(re) => Ok(re.is_match(&text)),
Err(e) => Err(SqliteError::UserFunctionError(format!(
"invalid REGEXP pattern {pattern:?}: {e}"
)
.into())),
}
},
)
}