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    // Drop the turso-native FTS index if a prior version created it. BM25 ranking
88    // is served entirely by the standalone tantivy index ([`FtsIndex`]); this
89    // turso index was never queried, cost an index write on every insert, and —
90    // most importantly — panicked in turso_core's FTS drop handler
91    // ("FTS Drop: transaction already committed, cannot flush") when an in-memory
92    // database was dropped with a pending doc. `IF EXISTS` makes this a no-op on
93    // fresh databases and a one-time cleanup on existing ones (e.g. persistent
94    // consumer DBs that were created before this change).
95    let _ = conn.execute("DROP INDEX IF EXISTS idx_turso_fts", ()).await;
96    // Additive migration: add embedding column for vector search.
97    // ALTER TABLE ... ADD COLUMN doesn't support IF NOT EXISTS in SQLite;
98    // we intentionally discard the error if the column already exists.
99    let _ = conn
100        .execute("ALTER TABLE entries ADD COLUMN embedding BLOB", ())
101        .await;
102    Ok(())
103}
104
105pub(crate) fn turso_row_to_entry(row: &turso::Row) -> crate::Result<ContextEntry> {
106    let id = get_text(row, 0, "id")?;
107    let content = get_text(row, 1, "content")?;
108    let timestamp = get_int(row, 2, "timestamp")?;
109    let kind = get_text(row, 3, "kind")?;
110    let scope = get_text_opt(row, 4, "scope")?;
111    let session_id = get_text_opt(row, 5, "session_id")?;
112    let token_count_raw = get_int_opt(row, 6, "token_count")?;
113    let metadata_str = get_text_opt(row, 7, "metadata")?;
114
115    let token_count = token_count_raw
116        .map(usize::try_from)
117        .transpose()
118        .map_err(|e| crate::Error::Migration(e.to_string()))?;
119
120    let metadata = metadata_str
121        .map(|s| serde_json::from_str::<serde_json::Value>(&s))
122        .transpose()
123        .map_err(|e| crate::Error::Migration(e.to_string()))?
124        // json_patch('{}', json_object(nulls)) produces "{}"; treat empty object as None.
125        .and_then(|v| {
126            if v.as_object().is_some_and(serde_json::Map::is_empty) {
127                None
128            } else {
129                Some(v)
130            }
131        });
132
133    Ok(ContextEntry {
134        id,
135        content,
136        timestamp,
137        kind,
138        scope,
139        session_id,
140        token_count,
141        metadata,
142    })
143}
144
145fn get_text(row: &turso::Row, idx: usize, field: &str) -> crate::Result<String> {
146    match row.get_value(idx)? {
147        turso::Value::Text(s) => Ok(s),
148        other => Err(crate::Error::Migration(format!(
149            "{field}: expected text, got {other:?}"
150        ))),
151    }
152}
153
154fn get_text_opt(row: &turso::Row, idx: usize, field: &str) -> crate::Result<Option<String>> {
155    match row.get_value(idx)? {
156        turso::Value::Text(s) => Ok(Some(s)),
157        turso::Value::Null => Ok(None),
158        other => Err(crate::Error::Migration(format!(
159            "{field}: expected text or null, got {other:?}"
160        ))),
161    }
162}
163
164fn get_int(row: &turso::Row, idx: usize, field: &str) -> crate::Result<i64> {
165    match row.get_value(idx)? {
166        turso::Value::Integer(i) => Ok(i),
167        other => Err(crate::Error::Migration(format!(
168            "{field}: expected integer, got {other:?}"
169        ))),
170    }
171}
172
173fn get_int_opt(row: &turso::Row, idx: usize, field: &str) -> crate::Result<Option<i64>> {
174    match row.get_value(idx)? {
175        turso::Value::Integer(i) => Ok(Some(i)),
176        turso::Value::Null => Ok(None),
177        other => Err(crate::Error::Migration(format!(
178            "{field}: expected integer or null, got {other:?}"
179        ))),
180    }
181}
182
183impl TursoStorage {
184    /// Persist one entry to turso and stage it in the tantivy index **without**
185    /// committing the index. Callers commit afterward — [`save`](ContextStorage::save)
186    /// once per entry, [`save_batch`](ContextStorage::save_batch) once per batch.
187    async fn persist_one(&self, entry: &ContextEntry) -> crate::Result<()> {
188        let conn = self.db.connect()?;
189        conn.busy_timeout(Duration::from_secs(5))?;
190        let max_entries = self.max_entries;
191
192        let metadata_json = entry
193            .metadata
194            .as_ref()
195            .map(serde_json::to_string)
196            .transpose()
197            .map_err(|e| crate::Error::InvalidEntry(format!("metadata is not valid JSON: {e}")))?;
198
199        let mut evicted_id: Option<String> = None;
200
201        let result = async {
202            conn.execute("BEGIN IMMEDIATE", ()).await?;
203
204            let mut rows = conn
205                .query(
206                    "SELECT EXISTS(SELECT 1 FROM entries WHERE id = ?1)",
207                    (entry.id.clone(),),
208                )
209                .await?;
210            let exists: bool = match rows.next().await? {
211                Some(row) => matches!(row.get_value(0)?, turso::Value::Integer(1)),
212                None => false,
213            };
214
215            if !exists {
216                let mut count_rows = conn.query("SELECT COUNT(*) FROM entries", ()).await?;
217                let count: i64 = match count_rows.next().await? {
218                    Some(row) => match row.get_value(0)? {
219                        turso::Value::Integer(n) => n,
220                        _ => 0,
221                    },
222                    None => 0,
223                };
224
225                if count >= i64::try_from(max_entries).unwrap_or(i64::MAX) {
226                    let mut id_rows = conn
227                        .query("SELECT id FROM entries ORDER BY timestamp ASC LIMIT 1", ())
228                        .await?;
229                    if let Some(row) = id_rows.next().await? {
230                        if let turso::Value::Text(id) = row.get_value(0)? {
231                            conn.execute("DELETE FROM entries WHERE id = ?1", (id.clone(),))
232                                .await?;
233                            evicted_id = Some(id);
234                        }
235                    }
236                }
237            }
238
239            conn.execute(
240                "INSERT OR REPLACE INTO entries \
241                 (id, content, timestamp, kind, scope, session_id, token_count, metadata) \
242                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
243                (
244                    entry.id.clone(),
245                    entry.content.clone(),
246                    entry.timestamp,
247                    entry.kind.clone(),
248                    entry.scope.clone(),
249                    entry.session_id.clone(),
250                    entry
251                        .token_count
252                        .map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
253                    metadata_json,
254                ),
255            )
256            .await?;
257
258            conn.execute("COMMIT", ()).await?;
259            Ok(())
260        }
261        .await;
262
263        if result.is_err() {
264            let _ = conn.execute("ROLLBACK", ()).await;
265            return result;
266        }
267
268        // Turso write committed — stage in tantivy (no commit; the caller
269        // commits). If this fails the entry is still persisted in turso; the
270        // next startup rebuild will re-sync the index.
271        if let Some(id) = evicted_id {
272            self.fts.remove(&id)?;
273        }
274        self.fts
275            .add(&entry.id, &entry.content, entry.scope.as_deref())?;
276
277        Ok(())
278    }
279}
280
281#[async_trait]
282impl ContextStorage for TursoStorage {
283    async fn save(&self, entry: &ContextEntry) -> crate::Result<()> {
284        self.persist_one(entry).await?;
285        self.fts.commit()?;
286        Ok(())
287    }
288
289    async fn save_batch(&self, entries: &[ContextEntry]) -> crate::Result<()> {
290        if entries.is_empty() {
291            return Ok(());
292        }
293
294        let conn = self.db.connect()?;
295        conn.busy_timeout(Duration::from_secs(5))?;
296        let max_entries = self.max_entries;
297        let mut evicted_ids: Vec<String> = Vec::new();
298
299        let result = async {
300            conn.execute("BEGIN IMMEDIATE", ()).await?;
301
302            // Prepare the INSERT once and reuse it for every row: all rows are the
303            // same statement, only the bound values differ, so this avoids the
304            // per-row SQL re-parse that `conn.execute(sql, ...)` incurs. INSERT OR
305            // REPLACE is idempotent, so no per-row existence check is needed.
306            let mut insert = conn
307                .prepare_cached(
308                    "INSERT OR REPLACE INTO entries \
309                     (id, content, timestamp, kind, scope, session_id, token_count, metadata) \
310                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
311                )
312                .await?;
313
314            for entry in entries {
315                let metadata_json = entry
316                    .metadata
317                    .as_ref()
318                    .map(serde_json::to_string)
319                    .transpose()
320                    .map_err(|e| {
321                        crate::Error::InvalidEntry(format!("metadata is not valid JSON: {e}"))
322                    })?;
323                insert
324                    .execute((
325                        entry.id.clone(),
326                        entry.content.clone(),
327                        entry.timestamp,
328                        entry.kind.clone(),
329                        entry.scope.clone(),
330                        entry.session_id.clone(),
331                        entry
332                            .token_count
333                            .map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
334                        metadata_json,
335                    ))
336                    .await?;
337            }
338
339            // Eviction is handled once for the whole batch: if the inserts pushed
340            // the store over capacity, drop the oldest overflow rows. The batch's
341            // own entries carry current timestamps, so they are never the ones
342            // evicted here.
343            let mut count_rows = conn.query("SELECT COUNT(*) FROM entries", ()).await?;
344            let count: i64 = match count_rows.next().await? {
345                Some(row) => match row.get_value(0)? {
346                    turso::Value::Integer(n) => n,
347                    _ => 0,
348                },
349                None => 0,
350            };
351            drop(count_rows);
352
353            let cap = i64::try_from(max_entries).unwrap_or(i64::MAX);
354            let overflow = count - cap;
355            if overflow > 0 {
356                let mut id_rows = conn
357                    .query(
358                        "SELECT id FROM entries ORDER BY timestamp ASC LIMIT ?1",
359                        (overflow,),
360                    )
361                    .await?;
362                while let Some(row) = id_rows.next().await? {
363                    if let turso::Value::Text(id) = row.get_value(0)? {
364                        evicted_ids.push(id);
365                    }
366                }
367                drop(id_rows);
368                for id in &evicted_ids {
369                    conn.execute("DELETE FROM entries WHERE id = ?1", (id.clone(),))
370                        .await?;
371                }
372            }
373
374            conn.execute("COMMIT", ()).await?;
375            Ok(())
376        }
377        .await;
378
379        if result.is_err() {
380            let _ = conn.execute("ROLLBACK", ()).await;
381            return result;
382        }
383
384        // Turso batch committed — sync tantivy: remove evicted docs, stage all
385        // new entries, and commit the index once for the whole batch.
386        for id in &evicted_ids {
387            self.fts.remove(id)?;
388        }
389        for entry in entries {
390            self.fts
391                .add(&entry.id, &entry.content, entry.scope.as_deref())?;
392        }
393        self.fts.commit()?;
394        Ok(())
395    }
396
397    async fn get_top_k(&self, k: usize) -> crate::Result<Vec<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 ORDER BY timestamp DESC LIMIT ?1",
405                (i64::try_from(k).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        Ok(result)
414    }
415
416    async fn get_all(&self) -> crate::Result<Vec<ContextEntry>> {
417        let conn = self.db.connect()?;
418        conn.busy_timeout(Duration::from_secs(5))?;
419
420        let mut rows = conn
421            .query(
422                "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
423                 FROM entries ORDER BY timestamp DESC",
424                (),
425            )
426            .await?;
427
428        let mut result = Vec::new();
429        while let Some(row) = rows.next().await? {
430            result.push(turso_row_to_entry(&row)?);
431        }
432        Ok(result)
433    }
434
435    async fn delete(&self, id: &str) -> crate::Result<bool> {
436        let conn = self.db.connect()?;
437        conn.busy_timeout(Duration::from_secs(5))?;
438
439        let affected = conn
440            .execute("DELETE FROM entries WHERE id = ?1", (id.to_owned(),))
441            .await?;
442
443        if affected > 0 {
444            self.fts.remove(id)?;
445        }
446        Ok(affected > 0)
447    }
448
449    async fn clear(&self) -> crate::Result<usize> {
450        let conn = self.db.connect()?;
451        conn.busy_timeout(Duration::from_secs(5))?;
452
453        let affected = conn.execute("DELETE FROM entries", ()).await?;
454        self.fts.clear()?;
455        Ok(usize::try_from(affected).unwrap_or(usize::MAX))
456    }
457
458    async fn clear_scope(&self, scope: &str) -> crate::Result<usize> {
459        let conn = self.db.connect()?;
460        conn.busy_timeout(Duration::from_secs(5))?;
461
462        // Collect the ids being deleted so we can remove them from tantivy.
463        let mut id_rows = conn
464            .query(
465                "SELECT id FROM entries WHERE scope = ?1",
466                (scope.to_owned(),),
467            )
468            .await?;
469        let mut ids: Vec<String> = Vec::new();
470        while let Some(row) = id_rows.next().await? {
471            if let turso::Value::Text(id) = row.get_value(0)? {
472                ids.push(id);
473            }
474        }
475
476        let affected = conn
477            .execute("DELETE FROM entries WHERE scope = ?1", (scope.to_owned(),))
478            .await?;
479
480        for id in &ids {
481            self.fts.remove(id)?;
482        }
483        Ok(usize::try_from(affected).unwrap_or(usize::MAX))
484    }
485
486    async fn count(&self) -> crate::Result<usize> {
487        let conn = self.db.connect()?;
488        conn.busy_timeout(Duration::from_secs(5))?;
489
490        let mut rows = conn.query("SELECT COUNT(*) FROM entries", ()).await?;
491        match rows.next().await? {
492            Some(row) => match row.get_value(0)? {
493                turso::Value::Integer(n) => Ok(usize::try_from(n).unwrap_or(0)),
494                _ => Ok(0),
495            },
496            None => Ok(0),
497        }
498    }
499
500    async fn save_embedding(&self, id: &str, embedding: &[f32]) -> crate::Result<()> {
501        let vec_str = format!(
502            "[{}]",
503            embedding
504                .iter()
505                .map(ToString::to_string)
506                .collect::<Vec<_>>()
507                .join(",")
508        );
509        let conn = self.db.connect()?;
510        conn.busy_timeout(Duration::from_secs(5))?;
511        conn.execute(
512            "UPDATE entries SET embedding = vector32(?1) WHERE id = ?2",
513            (vec_str, id.to_owned()),
514        )
515        .await?;
516        tracing::trace!(id = %id, dims = embedding.len(), "embedding saved to turso");
517        Ok(())
518    }
519
520    async fn get_unembedded(&self, limit: usize) -> crate::Result<Vec<crate::entry::ContextEntry>> {
521        let conn = self.db.connect()?;
522        conn.busy_timeout(Duration::from_secs(5))?;
523
524        let mut rows = conn
525            .query(
526                "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
527                 FROM entries WHERE embedding IS NULL LIMIT ?1",
528                (i64::try_from(limit).unwrap_or(i64::MAX),),
529            )
530            .await?;
531
532        let mut result = Vec::new();
533        while let Some(row) = rows.next().await? {
534            result.push(turso_row_to_entry(&row)?);
535        }
536        tracing::trace!(count = %result.len(), "get_unembedded: returning entries");
537        Ok(result)
538    }
539}