Skip to main content

aimem_core/
db.rs

1//! PalaceDb — Turso connection wrapper + schema bootstrap.
2//!
3//! # Overview
4//!
5//! [`PalaceDb`] wraps a [`turso::Database`] and owns the full schema lifecycle.
6//! It is cheaply cloneable (Arc-backed internally) and safe to share across
7//! async tasks.
8//!
9//! ## Schema
10//!
11//! Three tables live in one file:
12//!
13//! | Table | Purpose |
14//! |-------|---------|
15//! | `drawers` | Verbatim text chunks + `vector32` embeddings |
16//! | `entities` | Knowledge-graph entity nodes |
17//! | `triples` | Temporal (subject→predicate→object) edges |
18//!
19//! ## Example
20//!
21//! ```rust,no_run
22//! use aimem_core::{Drawer, PalaceDb};
23//! use chrono::Utc;
24//!
25//! # #[tokio::main] async fn main() -> anyhow::Result<()> {
26//! // In-memory DB — perfect for tests
27//! let db = PalaceDb::memory().await?;
28//!
29//! let drawer = Drawer {
30//!     id: "d_001".into(),
31//!     wing: "my_project".into(),
32//!     room: "decisions".into(),
33//!     content: "We chose Turso for native vector support.".into(),
34//!     source_file: None,
35//!     chunk_index: 0,
36//!     added_by: "claude".into(),
37//!     filed_at: Utc::now().to_rfc3339(),
38//! };
39//!
40//! let inserted = db.insert_drawer(&drawer, None).await?;
41//! assert!(inserted);
42//! assert_eq!(db.drawer_count().await?, 1);
43//! # Ok(())
44//! # }
45//! ```
46
47use 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
66/// SQL to initialize all tables.
67const 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/// Shared Turso database handle.
115///
116/// Clone is cheap — the underlying `Database` is `Arc`-wrapped.
117#[derive(Clone)]
118pub struct PalaceDb {
119    db: Database,
120}
121
122impl std::fmt::Debug for PalaceDb {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("PalaceDb").finish_non_exhaustive()
125    }
126}
127
128impl PalaceDb {
129    /// Open (or create) the palace DB at the given path and run schema migrations.
130    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 palace = Self { db };
139        palace.migrate().await?;
140        Ok(palace)
141    }
142
143    /// Open an in-memory DB (for tests).
144    pub async fn memory() -> DbResult<Self> {
145        let db = Builder::new_local(":memory:").build().await?;
146        let palace = Self { db };
147        palace.migrate().await?;
148        Ok(palace)
149    }
150
151    /// Acquire a connection from the database.
152    pub fn conn(&self) -> DbResult<Connection> {
153        Ok(self.db.connect()?)
154    }
155
156    /// Run schema bootstrap — idempotent (`CREATE TABLE IF NOT EXISTS`).
157    async fn migrate(&self) -> DbResult<()> {
158        let conn = self.conn()?;
159        conn.execute_batch(INIT_SQL).await?;
160        Ok(())
161    }
162
163    // ──────────────────────────────────────────────────────────────────────
164    // Drawer operations
165    // ──────────────────────────────────────────────────────────────────────
166
167    /// Insert a drawer.  Returns `false` if the ID already exists (no-op).
168    pub async fn insert_drawer(
169        &self,
170        drawer: &Drawer,
171        embedding: Option<&[f32]>,
172    ) -> DbResult<bool> {
173        let conn = self.conn()?;
174
175        // Serialize embedding as JSON array for vector32()
176        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        // Use vector32(?) to store embedding in Turso's native format
182        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    /// Check if a source file has already been mined.
229    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    /// Delete a drawer by ID.
241    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    /// Find drawers whose content exactly matches the provided text.
250    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    /// Total drawer count.
272    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    /// Return wing → count and room → count aggregates.
283    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    /// Fetch all drawers in a wing (optionally filtered by room), ordered by filing time desc.
324    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    // ──────────────────────────────────────────────────────────────────────
379    // Entity / Triple operations (delegated to knowledge module)
380    // ──────────────────────────────────────────────────────────────────────
381
382    /// Insert or replace an entity.
383    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    /// Insert a triple (ignore if already exists).
401    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    /// Mark a triple as expired (set valid_to).
427    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
446// ──────────────────────────────────────────────────────────────────────────
447// Row helpers
448// ──────────────────────────────────────────────────────────────────────────
449
450fn 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}