Skip to main content

context_forge/storage/
turso_storage.rs

1use std::path::Path;
2use std::sync::Arc;
3use std::time::Duration;
4
5use async_trait::async_trait;
6
7use crate::entry::ContextEntry;
8use crate::storage::fts_index::FtsIndex;
9use crate::traits::ContextStorage;
10
11const TURSO_SCHEMA: &str = r"
12CREATE TABLE IF NOT EXISTS entries (
13    id          TEXT PRIMARY KEY,
14    content     TEXT NOT NULL,
15    timestamp   INTEGER NOT NULL,
16    kind        TEXT NOT NULL,
17    scope       TEXT,
18    session_id  TEXT,
19    token_count INTEGER CHECK(token_count >= 0),
20    metadata    TEXT,
21    created_at  INTEGER NOT NULL DEFAULT (CAST(strftime('%s', 'now') AS INTEGER))
22) STRICT;
23
24CREATE INDEX IF NOT EXISTS idx_entries_timestamp ON entries(timestamp);
25CREATE INDEX IF NOT EXISTS idx_entries_scope ON entries(scope);
26CREATE INDEX IF NOT EXISTS idx_entries_session_id ON entries(session_id);
27";
28
29/// Turso-backed implementation of [`crate::traits::ContextStorage`].
30pub struct TursoStorage {
31    pub(crate) db: Arc<turso::Database>,
32    pub(crate) fts: Arc<FtsIndex>,
33    max_entries: usize,
34}
35
36impl TursoStorage {
37    /// Open (or create) a turso database at `db_path` and run migrations.
38    ///
39    /// Rebuilds the in-memory tantivy FTS index from all existing entries so
40    /// BM25 corpus statistics are accurate from the first query.
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if the database cannot be opened or migrations fail.
45    pub async fn open(db_path: &Path, max_entries: usize) -> crate::Result<Self> {
46        let path = db_path
47            .to_str()
48            .ok_or_else(|| crate::Error::Migration("non-UTF-8 database path".into()))?;
49        let db = turso::Builder::new_local(path)
50            .experimental_index_method(true)
51            .build()
52            .await?;
53        let conn = db.connect()?;
54        conn.busy_timeout(Duration::from_secs(5))?;
55        turso_migrate(&conn).await?;
56
57        // Rebuild tantivy index from all existing entries.
58        let fts = FtsIndex::new()?;
59        let mut rows = conn
60            .query("SELECT id, content, scope FROM entries", ())
61            .await?;
62        while let Some(row) = rows.next().await? {
63            let turso::Value::Text(id) = row.get_value(0)? else {
64                continue;
65            };
66            let turso::Value::Text(content) = row.get_value(1)? else {
67                continue;
68            };
69            let scope = match row.get_value(2)? {
70                turso::Value::Text(s) => Some(s),
71                _ => None,
72            };
73            fts.add(&id, &content, scope.as_deref())?;
74        }
75        fts.commit()?;
76
77        Ok(Self {
78            db: Arc::new(db),
79            fts,
80            max_entries,
81        })
82    }
83}
84
85pub(crate) async fn turso_migrate(conn: &turso::Connection) -> crate::Result<()> {
86    conn.execute_batch(TURSO_SCHEMA).await?;
87    conn.execute(
88        "CREATE INDEX IF NOT EXISTS idx_turso_fts ON entries USING fts (content)",
89        (),
90    )
91    .await?;
92    // Additive migration: add embedding column for vector search.
93    // ALTER TABLE ... ADD COLUMN doesn't support IF NOT EXISTS in SQLite;
94    // we intentionally discard the error if the column already exists.
95    let _ = conn
96        .execute("ALTER TABLE entries ADD COLUMN embedding BLOB", ())
97        .await;
98    Ok(())
99}
100
101pub(crate) fn turso_row_to_entry(row: &turso::Row) -> crate::Result<ContextEntry> {
102    let id = get_text(row, 0, "id")?;
103    let content = get_text(row, 1, "content")?;
104    let timestamp = get_int(row, 2, "timestamp")?;
105    let kind = get_text(row, 3, "kind")?;
106    let scope = get_text_opt(row, 4, "scope")?;
107    let session_id = get_text_opt(row, 5, "session_id")?;
108    let token_count_raw = get_int_opt(row, 6, "token_count")?;
109    let metadata_str = get_text_opt(row, 7, "metadata")?;
110
111    let token_count = token_count_raw
112        .map(usize::try_from)
113        .transpose()
114        .map_err(|e| crate::Error::Migration(e.to_string()))?;
115
116    let metadata = metadata_str
117        .map(|s| serde_json::from_str::<serde_json::Value>(&s))
118        .transpose()
119        .map_err(|e| crate::Error::Migration(e.to_string()))?
120        // json_patch('{}', json_object(nulls)) produces "{}"; treat empty object as None.
121        .and_then(|v| {
122            if v.as_object().is_some_and(serde_json::Map::is_empty) {
123                None
124            } else {
125                Some(v)
126            }
127        });
128
129    Ok(ContextEntry {
130        id,
131        content,
132        timestamp,
133        kind,
134        scope,
135        session_id,
136        token_count,
137        metadata,
138    })
139}
140
141fn get_text(row: &turso::Row, idx: usize, field: &str) -> crate::Result<String> {
142    match row.get_value(idx)? {
143        turso::Value::Text(s) => Ok(s),
144        other => Err(crate::Error::Migration(format!(
145            "{field}: expected text, got {other:?}"
146        ))),
147    }
148}
149
150fn get_text_opt(row: &turso::Row, idx: usize, field: &str) -> crate::Result<Option<String>> {
151    match row.get_value(idx)? {
152        turso::Value::Text(s) => Ok(Some(s)),
153        turso::Value::Null => Ok(None),
154        other => Err(crate::Error::Migration(format!(
155            "{field}: expected text or null, got {other:?}"
156        ))),
157    }
158}
159
160fn get_int(row: &turso::Row, idx: usize, field: &str) -> crate::Result<i64> {
161    match row.get_value(idx)? {
162        turso::Value::Integer(i) => Ok(i),
163        other => Err(crate::Error::Migration(format!(
164            "{field}: expected integer, got {other:?}"
165        ))),
166    }
167}
168
169fn get_int_opt(row: &turso::Row, idx: usize, field: &str) -> crate::Result<Option<i64>> {
170    match row.get_value(idx)? {
171        turso::Value::Integer(i) => Ok(Some(i)),
172        turso::Value::Null => Ok(None),
173        other => Err(crate::Error::Migration(format!(
174            "{field}: expected integer or null, got {other:?}"
175        ))),
176    }
177}
178
179#[async_trait]
180impl ContextStorage for TursoStorage {
181    async fn save(&self, entry: &ContextEntry) -> crate::Result<()> {
182        let conn = self.db.connect()?;
183        conn.busy_timeout(Duration::from_secs(5))?;
184        let max_entries = self.max_entries;
185
186        let metadata_json = entry
187            .metadata
188            .as_ref()
189            .map(serde_json::to_string)
190            .transpose()
191            .map_err(|e| crate::Error::InvalidEntry(format!("metadata is not valid JSON: {e}")))?;
192
193        let mut evicted_id: Option<String> = None;
194
195        let result = async {
196            conn.execute("BEGIN IMMEDIATE", ()).await?;
197
198            let mut rows = conn
199                .query(
200                    "SELECT EXISTS(SELECT 1 FROM entries WHERE id = ?1)",
201                    (entry.id.clone(),),
202                )
203                .await?;
204            let exists: bool = match rows.next().await? {
205                Some(row) => matches!(row.get_value(0)?, turso::Value::Integer(1)),
206                None => false,
207            };
208
209            if !exists {
210                let mut count_rows = conn.query("SELECT COUNT(*) FROM entries", ()).await?;
211                let count: i64 = match count_rows.next().await? {
212                    Some(row) => match row.get_value(0)? {
213                        turso::Value::Integer(n) => n,
214                        _ => 0,
215                    },
216                    None => 0,
217                };
218
219                if count >= i64::try_from(max_entries).unwrap_or(i64::MAX) {
220                    let mut id_rows = conn
221                        .query("SELECT id FROM entries ORDER BY timestamp ASC LIMIT 1", ())
222                        .await?;
223                    if let Some(row) = id_rows.next().await? {
224                        if let turso::Value::Text(id) = row.get_value(0)? {
225                            conn.execute("DELETE FROM entries WHERE id = ?1", (id.clone(),))
226                                .await?;
227                            evicted_id = Some(id);
228                        }
229                    }
230                }
231            }
232
233            conn.execute(
234                "INSERT OR REPLACE INTO entries \
235                 (id, content, timestamp, kind, scope, session_id, token_count, metadata) \
236                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
237                (
238                    entry.id.clone(),
239                    entry.content.clone(),
240                    entry.timestamp,
241                    entry.kind.clone(),
242                    entry.scope.clone(),
243                    entry.session_id.clone(),
244                    entry
245                        .token_count
246                        .map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
247                    metadata_json,
248                ),
249            )
250            .await?;
251
252            conn.execute("COMMIT", ()).await?;
253            Ok(())
254        }
255        .await;
256
257        if result.is_err() {
258            let _ = conn.execute("ROLLBACK", ()).await;
259            return result;
260        }
261
262        // Turso write committed — update tantivy. If this fails the entry is
263        // still persisted in turso; next startup rebuild will re-sync the index.
264        if let Some(id) = evicted_id {
265            self.fts.remove(&id)?;
266        }
267        self.fts
268            .add(&entry.id, &entry.content, entry.scope.as_deref())?;
269        self.fts.commit()?;
270
271        result
272    }
273
274    async fn get_top_k(&self, k: usize) -> crate::Result<Vec<ContextEntry>> {
275        let conn = self.db.connect()?;
276        conn.busy_timeout(Duration::from_secs(5))?;
277
278        let mut rows = conn
279            .query(
280                "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
281                 FROM entries ORDER BY timestamp DESC LIMIT ?1",
282                (i64::try_from(k).unwrap_or(i64::MAX),),
283            )
284            .await?;
285
286        let mut result = Vec::new();
287        while let Some(row) = rows.next().await? {
288            result.push(turso_row_to_entry(&row)?);
289        }
290        Ok(result)
291    }
292
293    async fn get_all(&self) -> crate::Result<Vec<ContextEntry>> {
294        let conn = self.db.connect()?;
295        conn.busy_timeout(Duration::from_secs(5))?;
296
297        let mut rows = conn
298            .query(
299                "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
300                 FROM entries ORDER BY timestamp DESC",
301                (),
302            )
303            .await?;
304
305        let mut result = Vec::new();
306        while let Some(row) = rows.next().await? {
307            result.push(turso_row_to_entry(&row)?);
308        }
309        Ok(result)
310    }
311
312    async fn delete(&self, id: &str) -> crate::Result<bool> {
313        let conn = self.db.connect()?;
314        conn.busy_timeout(Duration::from_secs(5))?;
315
316        let affected = conn
317            .execute("DELETE FROM entries WHERE id = ?1", (id.to_owned(),))
318            .await?;
319
320        if affected > 0 {
321            self.fts.remove(id)?;
322        }
323        Ok(affected > 0)
324    }
325
326    async fn clear(&self) -> crate::Result<usize> {
327        let conn = self.db.connect()?;
328        conn.busy_timeout(Duration::from_secs(5))?;
329
330        let affected = conn.execute("DELETE FROM entries", ()).await?;
331        self.fts.clear()?;
332        Ok(usize::try_from(affected).unwrap_or(usize::MAX))
333    }
334
335    async fn clear_scope(&self, scope: &str) -> crate::Result<usize> {
336        let conn = self.db.connect()?;
337        conn.busy_timeout(Duration::from_secs(5))?;
338
339        // Collect the ids being deleted so we can remove them from tantivy.
340        let mut id_rows = conn
341            .query(
342                "SELECT id FROM entries WHERE scope = ?1",
343                (scope.to_owned(),),
344            )
345            .await?;
346        let mut ids: Vec<String> = Vec::new();
347        while let Some(row) = id_rows.next().await? {
348            if let turso::Value::Text(id) = row.get_value(0)? {
349                ids.push(id);
350            }
351        }
352
353        let affected = conn
354            .execute("DELETE FROM entries WHERE scope = ?1", (scope.to_owned(),))
355            .await?;
356
357        for id in &ids {
358            self.fts.remove(id)?;
359        }
360        Ok(usize::try_from(affected).unwrap_or(usize::MAX))
361    }
362
363    async fn count(&self) -> crate::Result<usize> {
364        let conn = self.db.connect()?;
365        conn.busy_timeout(Duration::from_secs(5))?;
366
367        let mut rows = conn.query("SELECT COUNT(*) FROM entries", ()).await?;
368        match rows.next().await? {
369            Some(row) => match row.get_value(0)? {
370                turso::Value::Integer(n) => Ok(usize::try_from(n).unwrap_or(0)),
371                _ => Ok(0),
372            },
373            None => Ok(0),
374        }
375    }
376
377    async fn save_embedding(&self, id: &str, embedding: &[f32]) -> crate::Result<()> {
378        let vec_str = format!(
379            "[{}]",
380            embedding
381                .iter()
382                .map(ToString::to_string)
383                .collect::<Vec<_>>()
384                .join(",")
385        );
386        let conn = self.db.connect()?;
387        conn.busy_timeout(Duration::from_secs(5))?;
388        conn.execute(
389            "UPDATE entries SET embedding = vector32(?1) WHERE id = ?2",
390            (vec_str, id.to_owned()),
391        )
392        .await?;
393        tracing::trace!(id = %id, dims = embedding.len(), "embedding saved to turso");
394        Ok(())
395    }
396
397    async fn get_unembedded(&self, limit: usize) -> crate::Result<Vec<crate::entry::ContextEntry>> {
398        let conn = self.db.connect()?;
399        conn.busy_timeout(Duration::from_secs(5))?;
400
401        let mut rows = conn
402            .query(
403                "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
404                 FROM entries WHERE embedding IS NULL LIMIT ?1",
405                (i64::try_from(limit).unwrap_or(i64::MAX),),
406            )
407            .await?;
408
409        let mut result = Vec::new();
410        while let Some(row) = rows.next().await? {
411            result.push(turso_row_to_entry(&row)?);
412        }
413        tracing::trace!(count = %result.len(), "get_unembedded: returning entries");
414        Ok(result)
415    }
416}