Skip to main content

aimem_core/
db.rs

1//! AimemDb — Turso connection wrapper + schema bootstrap.
2//!
3//! # Overview
4//!
5//! [`AimemDb`] 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, AimemDb};
23//! use chrono::Utc;
24//!
25//! # #[tokio::main] async fn main() -> anyhow::Result<()> {
26//! // In-memory DB — perfect for tests
27//! let db = AimemDb::memory().await?;
28//!
29//! let drawer = Drawer::new(
30//!     "d_001",
31//!     "my_project",
32//!     "decisions",
33//!     "We chose Turso for native vector support.",
34//!     "claude",
35//! )
36//! .with_filed_at(Utc::now().to_rfc3339());
37//!
38//! let inserted = db.insert_drawer(&drawer, None).await?;
39//! assert!(inserted);
40//! assert_eq!(db.drawer_count().await?, 1);
41//! # Ok(())
42//! # }
43//! ```
44
45use std::future::Future;
46use std::path::Path;
47use std::time::Instant;
48
49use thiserror::Error;
50use turso::{Builder, Connection, Database};
51
52use crate::embedder::{
53    GEMINI_EMBED_MODEL, GEMINI_EMBED_PROVIDER, LOCAL_EMBED_MODEL, LOCAL_EMBED_PROVIDER,
54};
55use crate::types::{Drawer, Entity, Triple};
56
57#[derive(Debug, Error)]
58pub enum DbError {
59    #[error("Turso error: {0}")]
60    Turso(#[from] turso::Error),
61    #[error("I/O error: {0}")]
62    Io(#[from] std::io::Error),
63    #[error("Type conversion: {0}")]
64    Conversion(String),
65    #[error("Serialization error: {0}")]
66    Serde(#[from] serde_json::Error),
67    #[error("Embedding dimension mismatch: store uses {expected}, attempted {actual}")]
68    EmbeddingDimensionMismatch { expected: usize, actual: usize },
69    #[error(
70        "Embedding model mismatch: store uses {expected_provider}/{expected_model}, attempted {actual_provider}/{actual_model}"
71    )]
72    EmbeddingModelMismatch {
73        expected_provider: String,
74        expected_model: String,
75        actual_provider: String,
76        actual_model: String,
77    },
78}
79
80pub type DbResult<T> = Result<T, DbError>;
81
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub struct EmbeddingStoreProfile {
84    pub provider: Option<String>,
85    pub model: Option<String>,
86    pub dimension: Option<usize>,
87}
88
89/// SQL to initialize all tables.
90const INIT_SQL: &str = "
91CREATE TABLE IF NOT EXISTS drawers (
92    id          TEXT PRIMARY KEY,
93    wing        TEXT NOT NULL,
94    room        TEXT NOT NULL,
95    content     TEXT NOT NULL,
96    parts       TEXT NOT NULL DEFAULT '[]',
97    embedding   BLOB,
98    source_file TEXT,
99    chunk_index INTEGER NOT NULL DEFAULT 0,
100    added_by    TEXT    NOT NULL DEFAULT 'aimem',
101    filed_at    TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
102);
103
104CREATE INDEX IF NOT EXISTS idx_drawers_wing     ON drawers(wing);
105CREATE INDEX IF NOT EXISTS idx_drawers_room     ON drawers(room);
106CREATE INDEX IF NOT EXISTS idx_drawers_wing_room ON drawers(wing, room);
107CREATE INDEX IF NOT EXISTS idx_drawers_source   ON drawers(source_file);
108
109CREATE TABLE IF NOT EXISTS drawers_fts (
110    drawer_id    TEXT PRIMARY KEY,
111    search_text  TEXT NOT NULL
112);
113
114CREATE INDEX IF NOT EXISTS idx_drawers_fts ON drawers_fts USING fts(search_text);
115
116CREATE TABLE IF NOT EXISTS aimem_meta (
117    key   TEXT PRIMARY KEY,
118    value TEXT NOT NULL
119);
120
121CREATE TABLE IF NOT EXISTS entities (
122    id          TEXT PRIMARY KEY,
123    name        TEXT NOT NULL,
124    type        TEXT NOT NULL DEFAULT 'unknown',
125    properties  TEXT NOT NULL DEFAULT '{}',
126    created_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
127);
128
129CREATE TABLE IF NOT EXISTS triples (
130    id            TEXT PRIMARY KEY,
131    subject       TEXT NOT NULL,
132    predicate     TEXT NOT NULL,
133    object        TEXT NOT NULL,
134    valid_from    TEXT,
135    valid_to      TEXT,
136    confidence    REAL NOT NULL DEFAULT 1.0,
137    source_closet TEXT,
138    source_file   TEXT,
139    extracted_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
140    FOREIGN KEY (subject) REFERENCES entities(id),
141    FOREIGN KEY (object)  REFERENCES entities(id)
142);
143
144CREATE INDEX IF NOT EXISTS idx_triples_subject   ON triples(subject);
145CREATE INDEX IF NOT EXISTS idx_triples_object    ON triples(object);
146CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
147CREATE INDEX IF NOT EXISTS idx_triples_valid     ON triples(valid_from, valid_to);
148";
149
150const DB_BUSY_TIMEOUT_MS: u64 = 5_000;
151const DB_WRITE_RETRY_LIMIT: usize = 8;
152
153/// Shared Turso database handle.
154///
155/// Clone is cheap — the underlying `Database` is `Arc`-wrapped.
156#[derive(Clone)]
157pub struct AimemDb {
158    db: Database,
159    read_conn: Connection,
160}
161
162impl std::fmt::Debug for AimemDb {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.debug_struct("AimemDb").finish_non_exhaustive()
165    }
166}
167
168impl AimemDb {
169    /// Open (or create) the AiMem DB at the given path and run schema migrations.
170    pub async fn open(path: impl AsRef<Path>) -> DbResult<Self> {
171        let path = path.as_ref();
172        if let Some(parent) = path.parent() {
173            std::fs::create_dir_all(parent)?;
174        }
175
176        let path_str = path.to_string_lossy();
177        let db = Builder::new_local(path_str.as_ref())
178            .experimental_index_method(true)
179            .build()
180            .await?;
181        let bootstrap_conn = db.connect()?;
182        Self::configure_write_conn(&bootstrap_conn).await?;
183        Self::migrate_conn(&bootstrap_conn).await?;
184        let read_conn = db.connect()?;
185        Self::configure_read_conn(&read_conn).await?;
186        Ok(Self { db, read_conn })
187    }
188
189    /// Open an in-memory DB (for tests).
190    pub async fn memory() -> DbResult<Self> {
191        let db = Builder::new_local(":memory:")
192            .experimental_index_method(true)
193            .build()
194            .await?;
195        let bootstrap_conn = db.connect()?;
196        Self::configure_write_conn(&bootstrap_conn).await?;
197        Self::migrate_conn(&bootstrap_conn).await?;
198        let read_conn = db.connect()?;
199        Self::configure_read_conn(&read_conn).await?;
200        Ok(Self { db, read_conn })
201    }
202
203    /// Acquire a write-capable connection from the database.
204    ///
205    /// Kept write-capable for backward compatibility with existing callers.
206    pub fn conn(&self) -> DbResult<Connection> {
207        Ok(self.db.connect()?)
208    }
209
210    pub(crate) fn read_conn(&self) -> DbResult<Connection> {
211        Ok(self.read_conn.clone())
212    }
213
214    async fn configure_write_conn(conn: &Connection) -> DbResult<()> {
215        conn.pragma_update("busy_timeout", DB_BUSY_TIMEOUT_MS)
216            .await?;
217        Ok(())
218    }
219
220    async fn configure_read_conn(conn: &Connection) -> DbResult<()> {
221        conn.pragma_update("busy_timeout", DB_BUSY_TIMEOUT_MS)
222            .await?;
223        conn.pragma_update("query_only", 1).await?;
224        Ok(())
225    }
226
227    /// Run schema bootstrap — idempotent (`CREATE TABLE IF NOT EXISTS`).
228    async fn migrate_conn(conn: &Connection) -> DbResult<()> {
229        conn.execute_batch(INIT_SQL).await?;
230
231        if let Err(err) = conn
232            .execute(
233                "ALTER TABLE drawers ADD COLUMN parts TEXT NOT NULL DEFAULT '[]'",
234                (),
235            )
236            .await
237        {
238            let msg = err.to_string();
239            if !msg.contains("duplicate column name: parts") {
240                return Err(err.into());
241            }
242        }
243
244        Self::sync_drawers_fts(conn).await?;
245
246        Ok(())
247    }
248
249    async fn sync_drawers_fts(conn: &Connection) -> DbResult<()> {
250        conn.execute(
251            "INSERT OR IGNORE INTO drawers_fts (drawer_id, search_text) \
252             SELECT id, content FROM drawers",
253            (),
254        )
255        .await?;
256        conn.execute(
257            "DELETE FROM drawers_fts WHERE drawer_id NOT IN (SELECT id FROM drawers)",
258            (),
259        )
260        .await?;
261        Ok(())
262    }
263
264    async fn meta_value(&self, key: &str) -> DbResult<Option<String>> {
265        let conn = self.read_conn()?;
266        let mut rows = conn
267            .query("SELECT value FROM aimem_meta WHERE key = ?1 LIMIT 1", [key])
268            .await?;
269        let Some(row) = rows.next().await? else {
270            return Ok(None);
271        };
272        Ok(Some(val_to_string(&row, 0)?))
273    }
274
275    async fn set_meta_value(&self, key: &str, value: &str) -> DbResult<()> {
276        self.with_write_retry("set_meta_value", move |conn| async move {
277            conn.execute(
278                "INSERT OR REPLACE INTO aimem_meta (key, value) VALUES (?1, ?2)",
279                [key, value],
280            )
281            .await?;
282            Ok(())
283        })
284        .await
285    }
286
287    fn infer_profile_from_dimension(dim: usize) -> Option<(&'static str, &'static str)> {
288        match dim {
289            384 => Some((LOCAL_EMBED_PROVIDER, LOCAL_EMBED_MODEL)),
290            768 => Some((GEMINI_EMBED_PROVIDER, GEMINI_EMBED_MODEL)),
291            _ => None,
292        }
293    }
294
295    async fn with_write_retry<T, F, Fut>(&self, label: &'static str, op: F) -> DbResult<T>
296    where
297        F: Fn(Connection) -> Fut,
298        Fut: Future<Output = DbResult<T>>,
299    {
300        let started_at = Instant::now();
301        for attempt in 0..DB_WRITE_RETRY_LIMIT {
302            let conn = self.conn()?;
303            conn.pragma_update("busy_timeout", DB_BUSY_TIMEOUT_MS)
304                .await?;
305            match conn.execute("BEGIN IMMEDIATE", ()).await {
306                Ok(_) => {}
307                Err(err) if is_retryable_turso_write_error(&err) => {
308                    let attempt_num = attempt + 1;
309                    let elapsed_ms = started_at.elapsed().as_millis() as u64;
310                    if attempt_num == DB_WRITE_RETRY_LIMIT {
311                        tracing::warn!(
312                            target: "aimem::db",
313                            op = label,
314                            attempt = attempt_num,
315                            max_attempts = DB_WRITE_RETRY_LIMIT,
316                            elapsed_ms,
317                            kind = turso_write_error_kind(&err),
318                            error = %err,
319                            "AimemDb write transaction could not start before retry budget was exhausted"
320                        );
321                        return Err(DbError::Turso(err));
322                    }
323                    tracing::warn!(
324                        target: "aimem::db",
325                        op = label,
326                        attempt = attempt_num,
327                        max_attempts = DB_WRITE_RETRY_LIMIT,
328                        elapsed_ms,
329                        kind = turso_write_error_kind(&err),
330                        error = %err,
331                        "AimemDb write transaction could not start; retrying"
332                    );
333                    tokio::task::yield_now().await;
334                    continue;
335                }
336                Err(err) => return Err(DbError::Turso(err)),
337            }
338
339            match op(conn.clone()).await {
340                Ok(value) => match conn.execute("COMMIT", ()).await {
341                    Ok(_) => {
342                        conn.cacheflush()?;
343                        if attempt > 0 {
344                            tracing::info!(
345                                target: "aimem::db",
346                                op = label,
347                                attempts = attempt + 1,
348                                elapsed_ms = started_at.elapsed().as_millis() as u64,
349                                "AimemDb write transaction succeeded after retries"
350                            );
351                        }
352                        return Ok(value);
353                    }
354                    Err(err) if is_retryable_turso_write_error(&err) => {
355                        let _ = conn.execute("ROLLBACK", ()).await;
356                        let attempt_num = attempt + 1;
357                        let elapsed_ms = started_at.elapsed().as_millis() as u64;
358                        if attempt_num == DB_WRITE_RETRY_LIMIT {
359                            tracing::warn!(
360                                target: "aimem::db",
361                                op = label,
362                                attempt = attempt_num,
363                                max_attempts = DB_WRITE_RETRY_LIMIT,
364                                elapsed_ms,
365                                kind = turso_write_error_kind(&err),
366                                error = %err,
367                                "AimemDb write transaction kept retrying until the retry budget was exhausted"
368                            );
369                        } else {
370                            tracing::warn!(
371                                target: "aimem::db",
372                                op = label,
373                                attempt = attempt_num,
374                                max_attempts = DB_WRITE_RETRY_LIMIT,
375                                elapsed_ms,
376                                kind = turso_write_error_kind(&err),
377                                error = %err,
378                                "AimemDb write transaction commit failed; retrying"
379                            );
380                        }
381                        tokio::task::yield_now().await;
382                        if attempt_num == DB_WRITE_RETRY_LIMIT {
383                            return Err(DbError::Turso(err));
384                        }
385                    }
386                    Err(err) => {
387                        let _ = conn.execute("ROLLBACK", ()).await;
388                        return Err(DbError::Turso(err));
389                    }
390                },
391                Err(err) => {
392                    let _ = conn.execute("ROLLBACK", ()).await;
393                    return Err(err);
394                }
395            }
396        }
397
398        unreachable!("AimemDb with_write_retry should return inside retry loop");
399    }
400
401    /// Return the configured embedding dimension, inferring it from existing
402    /// stored vectors when upgrading older databases that predate metadata.
403    pub async fn embedding_dimension(&self) -> DbResult<Option<usize>> {
404        if let Some(raw) = self.meta_value("embedding_dim").await? {
405            let dim = raw.parse::<usize>().map_err(|err| {
406                DbError::Conversion(format!("invalid embedding_dim metadata {raw:?}: {err}"))
407            })?;
408            return Ok(Some(dim));
409        }
410
411        let conn = self.read_conn()?;
412
413        let mut rows = conn
414            .query(
415                "SELECT length(embedding) FROM drawers WHERE embedding IS NOT NULL LIMIT 1",
416                (),
417            )
418            .await?;
419        let Some(row) = rows.next().await? else {
420            return Ok(None);
421        };
422
423        let bytes = row.get_value(0)?.as_integer().copied().ok_or_else(|| {
424            DbError::Conversion("length(embedding) did not return an integer".to_string())
425        })?;
426        if bytes <= 0 || bytes % 4 != 0 {
427            return Err(DbError::Conversion(format!(
428                "unexpected embedding blob length {bytes}; cannot infer dimension"
429            )));
430        }
431
432        let dim = (bytes / 4) as usize;
433        self.set_meta_value("embedding_dim", &dim.to_string())
434            .await?;
435        Ok(Some(dim))
436    }
437
438    pub async fn embedding_profile(&self) -> DbResult<EmbeddingStoreProfile> {
439        let dimension = self.embedding_dimension().await?;
440        let mut provider = self.meta_value("embedding_provider").await?;
441        let mut model = self.meta_value("embedding_model").await?;
442
443        if let Some(dim) = dimension {
444            if let Some((inferred_provider, inferred_model)) =
445                Self::infer_profile_from_dimension(dim)
446            {
447                if provider.is_none() {
448                    self.set_meta_value("embedding_provider", inferred_provider)
449                        .await?;
450                    provider = Some(inferred_provider.to_string());
451                }
452                if model.is_none() {
453                    self.set_meta_value("embedding_model", inferred_model)
454                        .await?;
455                    model = Some(inferred_model.to_string());
456                }
457            }
458        }
459
460        Ok(EmbeddingStoreProfile {
461            provider,
462            model,
463            dimension,
464        })
465    }
466
467    /// Validate that a query vector matches the store dimension when known.
468    pub async fn assert_embedding_dimension(&self, dim: usize) -> DbResult<()> {
469        if let Some(expected) = self.embedding_dimension().await? {
470            if expected != dim {
471                return Err(DbError::EmbeddingDimensionMismatch {
472                    expected,
473                    actual: dim,
474                });
475            }
476        }
477        Ok(())
478    }
479
480    pub async fn assert_embedding_profile(
481        &self,
482        dim: usize,
483        provider: &str,
484        model: &str,
485    ) -> DbResult<()> {
486        let profile = self.embedding_profile().await?;
487        if let Some(expected) = profile.dimension {
488            if expected != dim {
489                return Err(DbError::EmbeddingDimensionMismatch {
490                    expected,
491                    actual: dim,
492                });
493            }
494        }
495
496        if let (Some(expected_provider), Some(expected_model)) = (profile.provider, profile.model) {
497            if expected_provider != provider || expected_model != model {
498                return Err(DbError::EmbeddingModelMismatch {
499                    expected_provider,
500                    expected_model,
501                    actual_provider: provider.to_string(),
502                    actual_model: model.to_string(),
503                });
504            }
505        }
506
507        Ok(())
508    }
509
510    /// Validate or initialize the store dimension before writing an embedding.
511    async fn ensure_embedding_dimension_for_write(&self, dim: usize) -> DbResult<()> {
512        if let Some(expected) = self.embedding_dimension().await? {
513            if expected != dim {
514                return Err(DbError::EmbeddingDimensionMismatch {
515                    expected,
516                    actual: dim,
517                });
518            }
519            return Ok(());
520        }
521
522        self.set_meta_value("embedding_dim", &dim.to_string()).await
523    }
524
525    async fn ensure_embedding_profile_for_write(
526        &self,
527        dim: usize,
528        provider: &str,
529        model: &str,
530    ) -> DbResult<()> {
531        let profile = self.embedding_profile().await?;
532        if let Some(expected) = profile.dimension {
533            if expected != dim {
534                return Err(DbError::EmbeddingDimensionMismatch {
535                    expected,
536                    actual: dim,
537                });
538            }
539        }
540
541        if let (Some(expected_provider), Some(expected_model)) =
542            (profile.provider.clone(), profile.model.clone())
543        {
544            if expected_provider != provider || expected_model != model {
545                return Err(DbError::EmbeddingModelMismatch {
546                    expected_provider,
547                    expected_model,
548                    actual_provider: provider.to_string(),
549                    actual_model: model.to_string(),
550                });
551            }
552        }
553
554        self.set_meta_value("embedding_dim", &dim.to_string())
555            .await?;
556        self.set_meta_value("embedding_provider", provider).await?;
557        self.set_meta_value("embedding_model", model).await?;
558        Ok(())
559    }
560
561    // ──────────────────────────────────────────────────────────────────────
562    // Drawer operations
563    // ──────────────────────────────────────────────────────────────────────
564
565    /// Insert a drawer.  Returns `false` if the ID already exists (no-op).
566    pub async fn insert_drawer(
567        &self,
568        drawer: &Drawer,
569        embedding: Option<&[f32]>,
570    ) -> DbResult<bool> {
571        if let Some(v) = embedding {
572            if let Some((provider, model)) = Self::infer_profile_from_dimension(v.len()) {
573                self.ensure_embedding_profile_for_write(v.len(), provider, model)
574                    .await?;
575            } else {
576                self.ensure_embedding_dimension_for_write(v.len()).await?;
577            }
578        }
579
580        self.insert_drawer_inner(drawer, embedding).await
581    }
582
583    /// Insert a drawer and validate the embedding profile against store metadata.
584    pub async fn insert_drawer_with_profile(
585        &self,
586        drawer: &Drawer,
587        embedding: Option<&[f32]>,
588        provider: &str,
589        model: &str,
590    ) -> DbResult<bool> {
591        if let Some(v) = embedding {
592            self.ensure_embedding_profile_for_write(v.len(), provider, model)
593                .await?;
594        }
595
596        self.insert_drawer_inner(drawer, embedding).await
597    }
598
599    async fn insert_drawer_inner(
600        &self,
601        drawer: &Drawer,
602        embedding: Option<&[f32]>,
603    ) -> DbResult<bool> {
604        // Serialize embedding as JSON array for vector32()
605        let emb_json: Option<String> = embedding.map(|v| {
606            let nums: Vec<String> = v.iter().map(|f| f.to_string()).collect();
607            format!("[{}]", nums.join(","))
608        });
609        let parts_json = serde_json::to_string(&drawer.parts)?;
610
611        // Use vector32(?) to store embedding in Turso's native format
612        let sql = if emb_json.is_some() {
613            "INSERT OR IGNORE INTO drawers \
614             (id, wing, room, content, parts, embedding, source_file, chunk_index, added_by, filed_at) \
615             VALUES (?1, ?2, ?3, ?4, ?5, vector32(?6), ?7, ?8, ?9, ?10)"
616        } else {
617            "INSERT OR IGNORE INTO drawers \
618             (id, wing, room, content, parts, embedding, source_file, chunk_index, added_by, filed_at) \
619             VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8, ?9)"
620        };
621
622        self.with_write_retry("insert_drawer", move |conn| {
623            let emb_json = emb_json.clone();
624            let parts_json = parts_json.clone();
625            async move {
626                let rows_affected = if let Some(ref emb) = emb_json {
627                    conn.execute(
628                        sql,
629                        turso::params![
630                            drawer.id.as_str(),
631                            drawer.wing.as_str(),
632                            drawer.room.as_str(),
633                            drawer.content.as_str(),
634                            parts_json.as_str(),
635                            emb.as_str(),
636                            drawer.source_file.as_deref(),
637                            drawer.chunk_index,
638                            drawer.added_by.as_str(),
639                            drawer.filed_at.as_str(),
640                        ],
641                    )
642                    .await?
643                } else {
644                    conn.execute(
645                        sql,
646                        turso::params![
647                            drawer.id.as_str(),
648                            drawer.wing.as_str(),
649                            drawer.room.as_str(),
650                            drawer.content.as_str(),
651                            parts_json.as_str(),
652                            drawer.source_file.as_deref(),
653                            drawer.chunk_index,
654                            drawer.added_by.as_str(),
655                            drawer.filed_at.as_str(),
656                        ],
657                    )
658                    .await?
659                };
660
661                if rows_affected > 0 {
662                    conn.execute(
663                        "INSERT OR REPLACE INTO drawers_fts (drawer_id, search_text) VALUES (?1, ?2)",
664                        turso::params![drawer.id.as_str(), drawer.content.as_str()],
665                    )
666                    .await?;
667                }
668
669                Ok(rows_affected > 0)
670            }
671        })
672        .await
673    }
674
675    /// Check whether a drawer ID already exists.
676    pub async fn drawer_exists(&self, id: &str) -> DbResult<bool> {
677        let conn = self.read_conn()?;
678        let mut rows = conn
679            .query("SELECT 1 FROM drawers WHERE id = ?1 LIMIT 1", [id])
680            .await?;
681        Ok(rows.next().await?.is_some())
682    }
683
684    /// Check if a source file has already been mined.
685    pub async fn source_already_mined(&self, source_file: &str) -> DbResult<bool> {
686        let conn = self.read_conn()?;
687        let mut rows = conn
688            .query(
689                "SELECT 1 FROM drawers WHERE source_file = ?1 LIMIT 1",
690                [source_file],
691            )
692            .await?;
693        Ok(rows.next().await?.is_some())
694    }
695
696    /// Delete a drawer by ID.
697    pub async fn delete_drawer(&self, id: &str) -> DbResult<bool> {
698        self.with_write_retry("delete_drawer", move |conn| async move {
699            conn.execute("DELETE FROM drawers_fts WHERE drawer_id = ?1", [id])
700                .await?;
701            let n = conn
702                .execute("DELETE FROM drawers WHERE id = ?1", [id])
703                .await?;
704            Ok(n > 0)
705        })
706        .await
707    }
708
709    /// Find drawers whose content exactly matches the provided text.
710    pub async fn find_drawers_by_exact_content(
711        &self,
712        content: &str,
713        limit: usize,
714    ) -> DbResult<Vec<Drawer>> {
715        let conn = self.read_conn()?;
716        let mut rows = conn
717            .query(
718                "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
719                 FROM drawers WHERE content = ?1 ORDER BY filed_at DESC LIMIT ?2",
720                turso::params![content, limit as i64],
721            )
722            .await?;
723
724        let mut drawers = Vec::new();
725        while let Some(row) = rows.next().await? {
726            drawers.push(row_to_drawer(&row)?);
727        }
728        Ok(drawers)
729    }
730
731    /// Total drawer count.
732    pub async fn drawer_count(&self) -> DbResult<i64> {
733        let conn = self.read_conn()?;
734        let mut rows = conn.query("SELECT COUNT(*) FROM drawers", ()).await?;
735        let row = rows
736            .next()
737            .await?
738            .ok_or_else(|| DbError::Conversion("COUNT returned no row".to_string()))?;
739        Ok(row.get_value(0)?.as_integer().copied().unwrap_or(0))
740    }
741
742    /// Return wing → count and room → count aggregates.
743    pub async fn taxonomy(&self) -> DbResult<(Vec<(String, i64)>, Vec<(String, i64)>)> {
744        let conn = self.read_conn()?;
745
746        let mut wing_rows = conn
747            .query(
748                "SELECT wing, COUNT(*) as cnt FROM drawers GROUP BY wing ORDER BY cnt DESC",
749                (),
750            )
751            .await?;
752        let mut wings: Vec<(String, i64)> = Vec::new();
753        while let Some(row) = wing_rows.next().await? {
754            let wing = match row.get_value(0)? {
755                turso::Value::Text(s) => s,
756                turso::Value::Null => String::new(),
757                v => format!("{v:?}"),
758            };
759            let cnt = row.get_value(1)?.as_integer().copied().unwrap_or(0);
760            wings.push((wing, cnt));
761        }
762
763        let mut room_rows = conn
764            .query(
765                "SELECT room, COUNT(*) as cnt FROM drawers GROUP BY room ORDER BY cnt DESC",
766                (),
767            )
768            .await?;
769        let mut rooms: Vec<(String, i64)> = Vec::new();
770        while let Some(row) = room_rows.next().await? {
771            let room = match row.get_value(0)? {
772                turso::Value::Text(s) => s,
773                turso::Value::Null => String::new(),
774                v => format!("{v:?}"),
775            };
776            let cnt = row.get_value(1)?.as_integer().copied().unwrap_or(0);
777            rooms.push((room, cnt));
778        }
779
780        Ok((wings, rooms))
781    }
782
783    /// Fetch all drawers in a wing (optionally filtered by room), ordered by filing time desc.
784    pub async fn fetch_drawers(
785        &self,
786        wing: Option<&str>,
787        room: Option<&str>,
788        limit: usize,
789    ) -> DbResult<Vec<Drawer>> {
790        let conn = self.read_conn()?;
791        let limit = limit as i64;
792
793        let (sql, params_vec): (String, Vec<String>) = match (wing, room) {
794            (Some(w), Some(r)) => (
795                "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
796                 FROM drawers WHERE wing = ?1 AND room = ?2 \
797                 ORDER BY filed_at DESC LIMIT ?3"
798                    .to_string(),
799                vec![w.to_string(), r.to_string(), limit.to_string()],
800            ),
801            (Some(w), None) => (
802                "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
803                 FROM drawers WHERE wing = ?1 \
804                 ORDER BY filed_at DESC LIMIT ?2"
805                    .to_string(),
806                vec![w.to_string(), limit.to_string()],
807            ),
808            (None, Some(r)) => (
809                "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
810                 FROM drawers WHERE room = ?1 \
811                 ORDER BY filed_at DESC LIMIT ?2"
812                    .to_string(),
813                vec![r.to_string(), limit.to_string()],
814            ),
815            (None, None) => (
816                "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
817                 FROM drawers ORDER BY filed_at DESC LIMIT ?1"
818                    .to_string(),
819                vec![limit.to_string()],
820            ),
821        };
822
823        let params: Vec<&str> = params_vec.iter().map(|s| s.as_str()).collect();
824        let mut rows = match params.len() {
825            1 => conn.query(&sql, [params[0]]).await?,
826            2 => conn.query(&sql, [params[0], params[1]]).await?,
827            3 => conn.query(&sql, [params[0], params[1], params[2]]).await?,
828            _ => unreachable!(),
829        };
830
831        let mut drawers = Vec::new();
832        while let Some(row) = rows.next().await? {
833            drawers.push(row_to_drawer(&row)?);
834        }
835        Ok(drawers)
836    }
837
838    // ──────────────────────────────────────────────────────────────────────
839    // Entity / Triple operations (delegated to knowledge module)
840    // ──────────────────────────────────────────────────────────────────────
841
842    /// Insert or replace an entity.
843    pub async fn upsert_entity(&self, entity: &Entity) -> DbResult<()> {
844        self.with_write_retry("upsert_entity", move |conn| async move {
845            conn.execute(
846                "INSERT OR REPLACE INTO entities (id, name, type, properties, created_at) \
847                 VALUES (?1, ?2, ?3, ?4, ?5)",
848                turso::params![
849                    entity.id.as_str(),
850                    entity.name.as_str(),
851                    entity.entity_type.as_str(),
852                    entity.properties.as_str(),
853                    entity.created_at.as_str(),
854                ],
855            )
856            .await?;
857            Ok(())
858        })
859        .await
860    }
861
862    /// Insert a triple (ignore if already exists).
863    pub async fn insert_triple(&self, triple: &Triple) -> DbResult<bool> {
864        self.with_write_retry("insert_triple", move |conn| async move {
865            let n = conn
866                .execute(
867                    "INSERT OR IGNORE INTO triples \
868                     (id, subject, predicate, object, valid_from, valid_to, confidence, \
869                      source_closet, source_file, extracted_at) \
870                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
871                    turso::params![
872                        triple.id.as_str(),
873                        triple.subject.as_str(),
874                        triple.predicate.as_str(),
875                        triple.object.as_str(),
876                        triple.valid_from.as_deref(),
877                        triple.valid_to.as_deref(),
878                        triple.confidence,
879                        triple.source_closet.as_deref(),
880                        triple.source_file.as_deref(),
881                        triple.extracted_at.as_str(),
882                    ],
883                )
884                .await?;
885            Ok(n > 0)
886        })
887        .await
888    }
889
890    /// Mark a triple as expired (set valid_to).
891    pub async fn invalidate_triple(
892        &self,
893        subject: &str,
894        predicate: &str,
895        object: &str,
896        ended: &str,
897    ) -> DbResult<u64> {
898        self.with_write_retry("invalidate_triple", move |conn| async move {
899            let n = conn
900                .execute(
901                    "UPDATE triples SET valid_to = ?1 \
902                     WHERE subject = ?2 AND predicate = ?3 AND object = ?4 AND valid_to IS NULL",
903                    turso::params![ended, subject, predicate, object],
904                )
905                .await?;
906            Ok(n)
907        })
908        .await
909    }
910}
911
912// ──────────────────────────────────────────────────────────────────────────
913// Row helpers
914// ──────────────────────────────────────────────────────────────────────────
915
916fn row_to_drawer(row: &turso::Row) -> DbResult<Drawer> {
917    Ok(Drawer {
918        id: val_to_string(row, 0)?,
919        wing: val_to_string(row, 1)?,
920        room: val_to_string(row, 2)?,
921        content: val_to_string(row, 3)?,
922        parts: parse_parts(&val_to_string(row, 4)?)?,
923        source_file: {
924            let s = val_to_string(row, 5)?;
925            if s.is_empty() { None } else { Some(s) }
926        },
927        chunk_index: row.get_value(6)?.as_integer().copied().unwrap_or(0),
928        added_by: val_to_string(row, 7)?,
929        filed_at: val_to_string(row, 8)?,
930    })
931}
932
933fn parse_parts(raw: &str) -> Result<Vec<crate::types::ContentPart>, serde_json::Error> {
934    if raw.is_empty() || raw == "[]" {
935        Ok(Vec::new())
936    } else {
937        serde_json::from_str(raw)
938    }
939}
940
941fn val_to_string(row: &turso::Row, idx: usize) -> DbResult<String> {
942    match row.get_value(idx)? {
943        turso::Value::Text(s) => Ok(s),
944        turso::Value::Null => Ok(String::new()),
945        turso::Value::Integer(i) => Ok(i.to_string()),
946        turso::Value::Real(f) => Ok(f.to_string()),
947        turso::Value::Blob(_) => Ok(String::new()),
948    }
949}
950
951fn is_retryable_turso_write_error(err: &turso::Error) -> bool {
952    matches!(err, turso::Error::Busy(_) | turso::Error::BusySnapshot(_))
953        || matches!(err, turso::Error::Error(msg) if msg.contains("conflict"))
954}
955
956fn turso_write_error_kind(err: &turso::Error) -> &'static str {
957    match err {
958        turso::Error::Busy(_) => "busy",
959        turso::Error::BusySnapshot(_) => "busy_snapshot",
960        turso::Error::Error(msg) if msg.contains("conflict") => "conflict",
961        _ => "other",
962    }
963}
964
965#[cfg(test)]
966mod tests {
967    use super::*;
968
969    #[test]
970    fn turso_write_error_kind_classifies_busy_variants_and_conflicts() {
971        assert_eq!(
972            turso_write_error_kind(&turso::Error::Busy("busy".to_string())),
973            "busy"
974        );
975        assert_eq!(
976            turso_write_error_kind(&turso::Error::BusySnapshot("snapshot".to_string())),
977            "busy_snapshot"
978        );
979        assert_eq!(
980            turso_write_error_kind(&turso::Error::Error("write conflict".to_string())),
981            "conflict"
982        );
983        assert_eq!(
984            turso_write_error_kind(&turso::Error::Constraint("nope".to_string())),
985            "other"
986        );
987    }
988
989    #[tokio::test]
990    async fn read_connection_uses_query_only_mode() {
991        let db = AimemDb::memory().await.expect("memory db should open");
992        let err = db
993            .read_conn()
994            .expect("read connection should exist")
995            .execute(
996                "INSERT INTO aimem_meta (key, value) VALUES (?1, ?2)",
997                ["forbidden", "1"],
998            )
999            .await
1000            .expect_err("query-only connection must reject writes");
1001        let msg = err.to_string().to_ascii_lowercase();
1002        assert!(
1003            matches!(&err, turso::Error::Readonly(_))
1004                || msg.contains("readonly")
1005                || msg.contains("query_only"),
1006            "expected readonly/query_only error, got {err:?}"
1007        );
1008    }
1009}