1use 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#[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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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
261fn 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
305fn 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}