1use std::path::Path;
48
49use thiserror::Error;
50use turso::{Builder, Connection, Database};
51
52use crate::types::{Drawer, Entity, Triple};
53
54#[derive(Debug, Error)]
55pub enum DbError {
56 #[error("Turso error: {0}")]
57 Turso(#[from] turso::Error),
58 #[error("I/O error: {0}")]
59 Io(#[from] std::io::Error),
60 #[error("Type conversion: {0}")]
61 Conversion(String),
62}
63
64pub type DbResult<T> = Result<T, DbError>;
65
66const INIT_SQL: &str = "
68CREATE TABLE IF NOT EXISTS drawers (
69 id TEXT PRIMARY KEY,
70 wing TEXT NOT NULL,
71 room TEXT NOT NULL,
72 content TEXT NOT NULL,
73 embedding BLOB,
74 source_file TEXT,
75 chunk_index INTEGER NOT NULL DEFAULT 0,
76 added_by TEXT NOT NULL DEFAULT 'aimem',
77 filed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
78);
79
80CREATE INDEX IF NOT EXISTS idx_drawers_wing ON drawers(wing);
81CREATE INDEX IF NOT EXISTS idx_drawers_room ON drawers(room);
82CREATE INDEX IF NOT EXISTS idx_drawers_wing_room ON drawers(wing, room);
83CREATE INDEX IF NOT EXISTS idx_drawers_source ON drawers(source_file);
84
85CREATE TABLE IF NOT EXISTS entities (
86 id TEXT PRIMARY KEY,
87 name TEXT NOT NULL,
88 type TEXT NOT NULL DEFAULT 'unknown',
89 properties TEXT NOT NULL DEFAULT '{}',
90 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
91);
92
93CREATE TABLE IF NOT EXISTS triples (
94 id TEXT PRIMARY KEY,
95 subject TEXT NOT NULL,
96 predicate TEXT NOT NULL,
97 object TEXT NOT NULL,
98 valid_from TEXT,
99 valid_to TEXT,
100 confidence REAL NOT NULL DEFAULT 1.0,
101 source_closet TEXT,
102 source_file TEXT,
103 extracted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
104 FOREIGN KEY (subject) REFERENCES entities(id),
105 FOREIGN KEY (object) REFERENCES entities(id)
106);
107
108CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);
109CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);
110CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
111CREATE INDEX IF NOT EXISTS idx_triples_valid ON triples(valid_from, valid_to);
112";
113
114#[derive(Clone)]
118pub struct AimemDb {
119 db: Database,
120}
121
122impl std::fmt::Debug for AimemDb {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("AimemDb").finish_non_exhaustive()
125 }
126}
127
128impl AimemDb {
129 pub async fn open(path: impl AsRef<Path>) -> DbResult<Self> {
131 let path = path.as_ref();
132 if let Some(parent) = path.parent() {
133 std::fs::create_dir_all(parent)?;
134 }
135
136 let path_str = path.to_string_lossy();
137 let db = Builder::new_local(path_str.as_ref()).build().await?;
138 let aimem_db = Self { db };
139 aimem_db.migrate().await?;
140 Ok(aimem_db)
141 }
142
143 pub async fn memory() -> DbResult<Self> {
145 let db = Builder::new_local(":memory:").build().await?;
146 let aimem_db = Self { db };
147 aimem_db.migrate().await?;
148 Ok(aimem_db)
149 }
150
151 pub fn conn(&self) -> DbResult<Connection> {
153 Ok(self.db.connect()?)
154 }
155
156 async fn migrate(&self) -> DbResult<()> {
158 let conn = self.conn()?;
159 conn.execute_batch(INIT_SQL).await?;
160 Ok(())
161 }
162
163 pub async fn insert_drawer(
169 &self,
170 drawer: &Drawer,
171 embedding: Option<&[f32]>,
172 ) -> DbResult<bool> {
173 let conn = self.conn()?;
174
175 let emb_json: Option<String> = embedding.map(|v| {
177 let nums: Vec<String> = v.iter().map(|f| f.to_string()).collect();
178 format!("[{}]", nums.join(","))
179 });
180
181 let sql = if emb_json.is_some() {
183 "INSERT OR IGNORE INTO drawers \
184 (id, wing, room, content, embedding, source_file, chunk_index, added_by, filed_at) \
185 VALUES (?1, ?2, ?3, ?4, vector32(?5), ?6, ?7, ?8, ?9)"
186 } else {
187 "INSERT OR IGNORE INTO drawers \
188 (id, wing, room, content, embedding, source_file, chunk_index, added_by, filed_at) \
189 VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6, ?7, ?8)"
190 };
191
192 let rows_affected = if let Some(ref emb) = emb_json {
193 conn.execute(
194 sql,
195 turso::params![
196 drawer.id.as_str(),
197 drawer.wing.as_str(),
198 drawer.room.as_str(),
199 drawer.content.as_str(),
200 emb.as_str(),
201 drawer.source_file.as_deref(),
202 drawer.chunk_index,
203 drawer.added_by.as_str(),
204 drawer.filed_at.as_str(),
205 ],
206 )
207 .await?
208 } else {
209 conn.execute(
210 sql,
211 turso::params![
212 drawer.id.as_str(),
213 drawer.wing.as_str(),
214 drawer.room.as_str(),
215 drawer.content.as_str(),
216 drawer.source_file.as_deref(),
217 drawer.chunk_index,
218 drawer.added_by.as_str(),
219 drawer.filed_at.as_str(),
220 ],
221 )
222 .await?
223 };
224
225 Ok(rows_affected > 0)
226 }
227
228 pub async fn source_already_mined(&self, source_file: &str) -> DbResult<bool> {
230 let conn = self.conn()?;
231 let mut rows = conn
232 .query(
233 "SELECT 1 FROM drawers WHERE source_file = ?1 LIMIT 1",
234 [source_file],
235 )
236 .await?;
237 Ok(rows.next().await?.is_some())
238 }
239
240 pub async fn delete_drawer(&self, id: &str) -> DbResult<bool> {
242 let conn = self.conn()?;
243 let n = conn
244 .execute("DELETE FROM drawers WHERE id = ?1", [id])
245 .await?;
246 Ok(n > 0)
247 }
248
249 pub async fn find_drawers_by_exact_content(
251 &self,
252 content: &str,
253 limit: usize,
254 ) -> DbResult<Vec<Drawer>> {
255 let conn = self.conn()?;
256 let mut rows = conn
257 .query(
258 "SELECT id, wing, room, content, source_file, chunk_index, added_by, filed_at \
259 FROM drawers WHERE content = ?1 ORDER BY filed_at DESC LIMIT ?2",
260 turso::params![content, limit as i64],
261 )
262 .await?;
263
264 let mut drawers = Vec::new();
265 while let Some(row) = rows.next().await? {
266 drawers.push(row_to_drawer(&row)?);
267 }
268 Ok(drawers)
269 }
270
271 pub async fn drawer_count(&self) -> DbResult<i64> {
273 let conn = self.conn()?;
274 let mut rows = conn.query("SELECT COUNT(*) FROM drawers", ()).await?;
275 let row = rows
276 .next()
277 .await?
278 .ok_or_else(|| DbError::Conversion("COUNT returned no row".to_string()))?;
279 Ok(row.get_value(0)?.as_integer().copied().unwrap_or(0))
280 }
281
282 pub async fn taxonomy(&self) -> DbResult<(Vec<(String, i64)>, Vec<(String, i64)>)> {
284 let conn = self.conn()?;
285
286 let mut wing_rows = conn
287 .query(
288 "SELECT wing, COUNT(*) as cnt FROM drawers GROUP BY wing ORDER BY cnt DESC",
289 (),
290 )
291 .await?;
292 let mut wings: Vec<(String, i64)> = Vec::new();
293 while let Some(row) = wing_rows.next().await? {
294 let wing = match row.get_value(0)? {
295 turso::Value::Text(s) => s,
296 turso::Value::Null => String::new(),
297 v => format!("{v:?}"),
298 };
299 let cnt = row.get_value(1)?.as_integer().copied().unwrap_or(0);
300 wings.push((wing, cnt));
301 }
302
303 let mut room_rows = conn
304 .query(
305 "SELECT room, COUNT(*) as cnt FROM drawers GROUP BY room ORDER BY cnt DESC",
306 (),
307 )
308 .await?;
309 let mut rooms: Vec<(String, i64)> = Vec::new();
310 while let Some(row) = room_rows.next().await? {
311 let room = match row.get_value(0)? {
312 turso::Value::Text(s) => s,
313 turso::Value::Null => String::new(),
314 v => format!("{v:?}"),
315 };
316 let cnt = row.get_value(1)?.as_integer().copied().unwrap_or(0);
317 rooms.push((room, cnt));
318 }
319
320 Ok((wings, rooms))
321 }
322
323 pub async fn fetch_drawers(
325 &self,
326 wing: Option<&str>,
327 room: Option<&str>,
328 limit: usize,
329 ) -> DbResult<Vec<Drawer>> {
330 let conn = self.conn()?;
331 let limit = limit as i64;
332
333 let (sql, params_vec): (String, Vec<String>) = match (wing, room) {
334 (Some(w), Some(r)) => (
335 "SELECT id, wing, room, content, source_file, chunk_index, added_by, filed_at \
336 FROM drawers WHERE wing = ?1 AND room = ?2 \
337 ORDER BY filed_at DESC LIMIT ?3"
338 .to_string(),
339 vec![w.to_string(), r.to_string(), limit.to_string()],
340 ),
341 (Some(w), None) => (
342 "SELECT id, wing, room, content, source_file, chunk_index, added_by, filed_at \
343 FROM drawers WHERE wing = ?1 \
344 ORDER BY filed_at DESC LIMIT ?2"
345 .to_string(),
346 vec![w.to_string(), limit.to_string()],
347 ),
348 (None, Some(r)) => (
349 "SELECT id, wing, room, content, source_file, chunk_index, added_by, filed_at \
350 FROM drawers WHERE room = ?1 \
351 ORDER BY filed_at DESC LIMIT ?2"
352 .to_string(),
353 vec![r.to_string(), limit.to_string()],
354 ),
355 (None, None) => (
356 "SELECT id, wing, room, content, source_file, chunk_index, added_by, filed_at \
357 FROM drawers ORDER BY filed_at DESC LIMIT ?1"
358 .to_string(),
359 vec![limit.to_string()],
360 ),
361 };
362
363 let params: Vec<&str> = params_vec.iter().map(|s| s.as_str()).collect();
364 let mut rows = match params.len() {
365 1 => conn.query(&sql, [params[0]]).await?,
366 2 => conn.query(&sql, [params[0], params[1]]).await?,
367 3 => conn.query(&sql, [params[0], params[1], params[2]]).await?,
368 _ => unreachable!(),
369 };
370
371 let mut drawers = Vec::new();
372 while let Some(row) = rows.next().await? {
373 drawers.push(row_to_drawer(&row)?);
374 }
375 Ok(drawers)
376 }
377
378 pub async fn upsert_entity(&self, entity: &Entity) -> DbResult<()> {
384 let conn = self.conn()?;
385 conn.execute(
386 "INSERT OR REPLACE INTO entities (id, name, type, properties, created_at) \
387 VALUES (?1, ?2, ?3, ?4, ?5)",
388 turso::params![
389 entity.id.as_str(),
390 entity.name.as_str(),
391 entity.entity_type.as_str(),
392 entity.properties.as_str(),
393 entity.created_at.as_str(),
394 ],
395 )
396 .await?;
397 Ok(())
398 }
399
400 pub async fn insert_triple(&self, triple: &Triple) -> DbResult<bool> {
402 let conn = self.conn()?;
403 let n = conn
404 .execute(
405 "INSERT OR IGNORE INTO triples \
406 (id, subject, predicate, object, valid_from, valid_to, confidence, \
407 source_closet, source_file, extracted_at) \
408 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
409 turso::params![
410 triple.id.as_str(),
411 triple.subject.as_str(),
412 triple.predicate.as_str(),
413 triple.object.as_str(),
414 triple.valid_from.as_deref(),
415 triple.valid_to.as_deref(),
416 triple.confidence,
417 triple.source_closet.as_deref(),
418 triple.source_file.as_deref(),
419 triple.extracted_at.as_str(),
420 ],
421 )
422 .await?;
423 Ok(n > 0)
424 }
425
426 pub async fn invalidate_triple(
428 &self,
429 subject: &str,
430 predicate: &str,
431 object: &str,
432 ended: &str,
433 ) -> DbResult<u64> {
434 let conn = self.conn()?;
435 let n = conn
436 .execute(
437 "UPDATE triples SET valid_to = ?1 \
438 WHERE subject = ?2 AND predicate = ?3 AND object = ?4 AND valid_to IS NULL",
439 turso::params![ended, subject, predicate, object],
440 )
441 .await?;
442 Ok(n)
443 }
444}
445
446fn row_to_drawer(row: &turso::Row) -> DbResult<Drawer> {
451 Ok(Drawer {
452 id: val_to_string(row, 0)?,
453 wing: val_to_string(row, 1)?,
454 room: val_to_string(row, 2)?,
455 content: val_to_string(row, 3)?,
456 source_file: {
457 let s = val_to_string(row, 4)?;
458 if s.is_empty() { None } else { Some(s) }
459 },
460 chunk_index: row.get_value(5)?.as_integer().copied().unwrap_or(0),
461 added_by: val_to_string(row, 6)?,
462 filed_at: val_to_string(row, 7)?,
463 })
464}
465
466fn val_to_string(row: &turso::Row, idx: usize) -> DbResult<String> {
467 match row.get_value(idx)? {
468 turso::Value::Text(s) => Ok(s),
469 turso::Value::Null => Ok(String::new()),
470 turso::Value::Integer(i) => Ok(i.to_string()),
471 turso::Value::Real(f) => Ok(f.to_string()),
472 turso::Value::Blob(_) => Ok(String::new()),
473 }
474}