Skip to main content

agileplus_sqlite/
storage.rs

1//! SQLite-backed storage for intent graphs.
2
3use std::path::Path;
4use std::sync::{Arc, Mutex};
5
6use agileplus_domain::intent_graph::IntentGraph;
7use anyhow::{Context, Result};
8use regex::Regex;
9use rusqlite::functions::FunctionFlags;
10use rusqlite::{params, Connection, Error as SqliteError, OptionalExtension};
11use serde::{Deserialize, Serialize};
12
13/// Thread-safe wrapper around a `rusqlite::Connection`.
14#[derive(Clone)]
15pub struct Storage {
16    conn: Arc<Mutex<Connection>>,
17}
18
19impl std::fmt::Debug for Storage {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        f.debug_struct("Storage").finish_non_exhaustive()
22    }
23}
24
25/// A free-form note attached to a graph.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Note {
28    pub id: i64,
29    pub graph_id: String,
30    pub body: String,
31    pub created_at: String,
32    pub updated_at: String,
33}
34
35impl Storage {
36    /// Open a connection at `path` and apply all migrations.
37    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
38        let conn = Connection::open(path.as_ref())
39            .with_context(|| format!("opening db at {}", path.as_ref().display()))?;
40        conn.execute_batch(
41            "PRAGMA journal_mode = WAL;\n\
42             PRAGMA synchronous = NORMAL;\n\
43             PRAGMA foreign_keys = ON;",
44        )
45        .context("setting PRAGMAs on persistent connection")?;
46        register_regexp(&conn).context("registering REGEXP SQL function")?;
47        let storage = Self {
48            conn: Arc::new(Mutex::new(conn)),
49        };
50        storage.migrate()?;
51        Ok(storage)
52    }
53
54    /// Open an in-memory database. Useful for tests.
55    pub fn in_memory() -> Result<Self> {
56        let conn = Connection::open_in_memory().context("opening in-memory db")?;
57        conn.execute_batch(
58            "PRAGMA journal_mode = MEMORY;\n\
59             PRAGMA synchronous = OFF;\n\
60             PRAGMA foreign_keys = ON;",
61        )
62        .context("setting PRAGMAs on in-memory connection")?;
63        register_regexp(&conn).context("registering REGEXP SQL function")?;
64        let storage = Self {
65            conn: Arc::new(Mutex::new(conn)),
66        };
67        storage.migrate()?;
68        Ok(storage)
69    }
70
71    /// Apply all pending migrations to the open connection.
72    pub fn migrate(&self) -> Result<()> {
73        let conn = self
74            .conn
75            .lock()
76            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
77        apply_migrations_to(&conn)
78    }
79
80    /// Persist a graph by the given `id`, replacing any previous version.
81    pub fn save_graph(&self, id: &str, graph: &IntentGraph) -> Result<()> {
82        let conn = self
83            .conn
84            .lock()
85            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
86        let json = serde_json::to_string(graph).context("serializing intent graph")?;
87        conn.execute(
88            "INSERT INTO intent_graphs (id, payload, updated_at) \
89             VALUES (?1, ?2, datetime('now')) \
90             ON CONFLICT(id) DO UPDATE SET \
91                payload = excluded.payload, \
92                updated_at = excluded.updated_at",
93            params![id, json],
94        )
95        .context("upserting intent_graphs row")?;
96        Ok(())
97    }
98
99    /// Fetch a graph by id.
100    pub fn load_graph(&self, id: &str) -> Result<Option<IntentGraph>> {
101        let conn = self
102            .conn
103            .lock()
104            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
105        let mut stmt = conn
106            .prepare("SELECT payload FROM intent_graphs WHERE id = ?1")
107            .context("preparing load_graph statement")?;
108        let mut rows = stmt
109            .query(params![id])
110            .context("querying intent_graphs")?;
111        let Some(row) = rows.next().context("iterating intent_graphs rows")? else {
112            return Ok(None);
113        };
114        let payload: String = row.get(0).context("reading payload column")?;
115        let graph: IntentGraph = serde_json::from_str(&payload)
116            .with_context(|| format!("parsing graph payload for id={}", id))?;
117        Ok(Some(graph))
118    }
119
120    /// List all stored graph ids, most-recently-updated first.
121    pub fn list_graphs(&self) -> Result<Vec<String>> {
122        let conn = self
123            .conn
124            .lock()
125            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
126        let mut stmt = conn
127            .prepare("SELECT id FROM intent_graphs ORDER BY updated_at DESC")
128            .context("preparing list_graphs statement")?;
129        let rows = stmt
130            .query_map([], |r| r.get::<_, String>(0))
131            .context("mapping list_graphs rows")?;
132        let ids: Vec<String> = rows
133            .collect::<rusqlite::Result<Vec<_>>>()
134            .context("collecting list_graphs rows")?;
135        Ok(ids)
136    }
137
138    /// Delete a graph by id; returns true if a row was removed.
139    pub fn delete_graph(&self, id: &str) -> Result<bool> {
140        let conn = self
141            .conn
142            .lock()
143            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
144        let affected = conn
145            .execute("DELETE FROM intent_graphs WHERE id = ?1", params![id])
146            .context("deleting intent_graphs row")?;
147        Ok(affected > 0)
148    }
149
150    /// Attach a tag to a graph. Returns `true` if the tag was newly inserted,
151    /// `false` if the `(graph_id, tag)` pair was already present.
152    pub fn add_tag(&self, graph_id: &str, tag: &str) -> Result<bool> {
153        let conn = self
154            .conn
155            .lock()
156            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
157        let affected = conn
158            .execute(
159                "INSERT OR IGNORE INTO graph_tags (graph_id, tag) VALUES (?1, ?2)",
160                params![graph_id, tag],
161            )
162            .context("inserting graph_tags row")?;
163        Ok(affected > 0)
164    }
165
166    /// Remove a tag from a graph. Returns `true` if a row was deleted,
167    /// `false` if the tag was not present.
168    pub fn remove_tag(&self, graph_id: &str, tag: &str) -> Result<bool> {
169        let conn = self
170            .conn
171            .lock()
172            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
173        let affected = conn
174            .execute(
175                "DELETE FROM graph_tags WHERE graph_id = ?1 AND tag = ?2",
176                params![graph_id, tag],
177            )
178            .context("deleting graph_tags row")?;
179        Ok(affected > 0)
180    }
181
182    /// List the tags attached to a graph, in alphabetical order.
183    pub fn list_tags(&self, graph_id: &str) -> Result<Vec<String>> {
184        let conn = self
185            .conn
186            .lock()
187            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
188        let mut stmt = conn
189            .prepare("SELECT tag FROM graph_tags WHERE graph_id = ?1 ORDER BY tag ASC")
190            .context("preparing list_tags statement")?;
191        let rows = stmt
192            .query_map(params![graph_id], |r| r.get::<_, String>(0))
193            .context("mapping list_tags rows")?;
194        let tags: Vec<String> = rows
195            .collect::<rusqlite::Result<Vec<_>>>()
196            .context("collecting list_tags rows")?;
197        Ok(tags)
198    }
199
200    /// Append a free-form note to a graph. Returns the new note id.
201    pub fn add_note(&self, graph_id: &str, body: &str) -> Result<i64> {
202        let conn = self
203            .conn
204            .lock()
205            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
206        conn.execute(
207            "INSERT INTO graph_notes (graph_id, body) VALUES (?1, ?2)",
208            params![graph_id, body],
209        )
210        .context("inserting graph_notes row")?;
211        Ok(conn.last_insert_rowid())
212    }
213
214    /// List notes attached to a graph, oldest first.
215    pub fn list_notes(&self, graph_id: &str) -> Result<Vec<Note>> {
216        let conn = self
217            .conn
218            .lock()
219            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
220        let mut stmt = conn
221            .prepare(
222                "SELECT id, graph_id, body, created_at, updated_at \
223                 FROM graph_notes WHERE graph_id = ?1 \
224                 ORDER BY created_at ASC, id ASC",
225            )
226            .context("preparing list_notes statement")?;
227        let rows = stmt
228            .query_map(params![graph_id], |r| {
229                Ok(Note {
230                    id: r.get(0)?,
231                    graph_id: r.get(1)?,
232                    body: r.get(2)?,
233                    created_at: r.get(3)?,
234                    updated_at: r.get(4)?,
235                })
236            })
237            .context("mapping list_notes rows")?;
238        let notes: Vec<Note> = rows
239            .collect::<rusqlite::Result<Vec<_>>>()
240            .context("collecting list_notes rows")?;
241        Ok(notes)
242    }
243
244    /// Delete a note by id, scoped to a specific graph. Returns `true` if a
245    /// row was deleted, `false` otherwise (note did not exist for that graph).
246    pub fn delete_note(&self, graph_id: &str, note_id: i64) -> Result<bool> {
247        let conn = self
248            .conn
249            .lock()
250            .map_err(|e| anyhow::anyhow!("storage mutex poisoned: {e}"))?;
251        let affected = conn
252            .execute(
253                "DELETE FROM graph_notes WHERE graph_id = ?1 AND id = ?2",
254                params![graph_id, note_id],
255            )
256            .context("deleting graph_notes row")?;
257        Ok(affected > 0)
258    }
259}
260
261/// Apply every bundled migration that has not already been recorded in
262/// `migration_history`. Runs the whole batch inside a single transaction so
263/// partially-applied migrations cannot leave the schema in an inconsistent
264/// state.
265fn apply_migrations_to(conn: &Connection) -> Result<()> {
266    conn.execute_batch(
267        "CREATE TABLE IF NOT EXISTS migration_history (\
268            id TEXT PRIMARY KEY, \
269            applied_at TEXT NOT NULL\
270         )",
271    )
272    .context("creating migration_history table")?;
273
274    let tx = conn
275        .unchecked_transaction()
276        .context("opening migration transaction")?;
277
278    for (id, sql) in super::migrations::ALL {
279        let already_applied: bool = tx
280            .query_row(
281                "SELECT 1 FROM migration_history WHERE id = ?1",
282                params![id],
283                |_| Ok(true),
284            )
285            .optional()
286            .context("checking migration_history")?
287            .unwrap_or(false);
288        if already_applied {
289            continue;
290        }
291
292        tx.execute_batch(sql)
293            .with_context(|| format!("applying migration {id}"))?;
294        tx.execute(
295            "INSERT INTO migration_history (id, applied_at) VALUES (?1, datetime('now'))",
296            params![id],
297        )
298        .with_context(|| format!("recording migration {id} in migration_history"))?;
299    }
300
301    tx.commit().context("committing migration transaction")?;
302    Ok(())
303}
304
305/// Register a SQLite `REGEXP(pattern, text)` function backed by the
306/// `regex` crate. Required because migration `025_create_intent_graph`
307/// declares `CHECK (id REGEXP '...')` constraints, and SQLite ships
308/// without a built-in `REGEXP` implementation.
309fn register_regexp(conn: &Connection) -> rusqlite::Result<()> {
310    conn.create_scalar_function(
311        "REGEXP",
312        2,
313        FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
314        |ctx| {
315            let pattern: String = ctx.get(0)?;
316            let text: String = ctx.get(1)?;
317            match Regex::new(&pattern) {
318                Ok(re) => Ok(re.is_match(&text)),
319                Err(e) => Err(SqliteError::UserFunctionError(format!(
320                    "invalid REGEXP pattern {pattern:?}: {e}"
321                )
322                .into())),
323            }
324        },
325    )
326}