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.query("SELECT id, content FROM entries", ()).await?;
60        while let Some(row) = rows.next().await? {
61            let turso::Value::Text(id) = row.get_value(0)? else {
62                continue;
63            };
64            let turso::Value::Text(content) = row.get_value(1)? else {
65                continue;
66            };
67            fts.add(&id, &content)?;
68        }
69        fts.commit()?;
70
71        Ok(Self {
72            db: Arc::new(db),
73            fts,
74            max_entries,
75        })
76    }
77}
78
79pub(crate) async fn turso_migrate(conn: &turso::Connection) -> crate::Result<()> {
80    conn.execute_batch(TURSO_SCHEMA).await?;
81    conn.execute(
82        "CREATE INDEX IF NOT EXISTS idx_turso_fts ON entries USING fts (content)",
83        (),
84    )
85    .await?;
86    Ok(())
87}
88
89pub(crate) fn turso_row_to_entry(row: &turso::Row) -> crate::Result<ContextEntry> {
90    let id = get_text(row, 0, "id")?;
91    let content = get_text(row, 1, "content")?;
92    let timestamp = get_int(row, 2, "timestamp")?;
93    let kind = get_text(row, 3, "kind")?;
94    let scope = get_text_opt(row, 4, "scope")?;
95    let session_id = get_text_opt(row, 5, "session_id")?;
96    let token_count_raw = get_int_opt(row, 6, "token_count")?;
97    let metadata_str = get_text_opt(row, 7, "metadata")?;
98
99    let token_count = token_count_raw
100        .map(usize::try_from)
101        .transpose()
102        .map_err(|e| crate::Error::Migration(e.to_string()))?;
103
104    let metadata = metadata_str
105        .map(|s| serde_json::from_str::<serde_json::Value>(&s))
106        .transpose()
107        .map_err(|e| crate::Error::Migration(e.to_string()))?
108        // json_patch('{}', json_object(nulls)) produces "{}"; treat empty object as None.
109        .and_then(|v| {
110            if v.as_object().is_some_and(serde_json::Map::is_empty) {
111                None
112            } else {
113                Some(v)
114            }
115        });
116
117    Ok(ContextEntry {
118        id,
119        content,
120        timestamp,
121        kind,
122        scope,
123        session_id,
124        token_count,
125        metadata,
126    })
127}
128
129fn get_text(row: &turso::Row, idx: usize, field: &str) -> crate::Result<String> {
130    match row.get_value(idx)? {
131        turso::Value::Text(s) => Ok(s),
132        other => Err(crate::Error::Migration(format!(
133            "{field}: expected text, got {other:?}"
134        ))),
135    }
136}
137
138fn get_text_opt(row: &turso::Row, idx: usize, field: &str) -> crate::Result<Option<String>> {
139    match row.get_value(idx)? {
140        turso::Value::Text(s) => Ok(Some(s)),
141        turso::Value::Null => Ok(None),
142        other => Err(crate::Error::Migration(format!(
143            "{field}: expected text or null, got {other:?}"
144        ))),
145    }
146}
147
148fn get_int(row: &turso::Row, idx: usize, field: &str) -> crate::Result<i64> {
149    match row.get_value(idx)? {
150        turso::Value::Integer(i) => Ok(i),
151        other => Err(crate::Error::Migration(format!(
152            "{field}: expected integer, got {other:?}"
153        ))),
154    }
155}
156
157fn get_int_opt(row: &turso::Row, idx: usize, field: &str) -> crate::Result<Option<i64>> {
158    match row.get_value(idx)? {
159        turso::Value::Integer(i) => Ok(Some(i)),
160        turso::Value::Null => Ok(None),
161        other => Err(crate::Error::Migration(format!(
162            "{field}: expected integer or null, got {other:?}"
163        ))),
164    }
165}
166
167#[async_trait]
168impl ContextStorage for TursoStorage {
169    async fn save(&self, entry: &ContextEntry) -> crate::Result<()> {
170        let conn = self.db.connect()?;
171        conn.busy_timeout(Duration::from_secs(5))?;
172        let max_entries = self.max_entries;
173
174        let metadata_json = entry
175            .metadata
176            .as_ref()
177            .map(serde_json::to_string)
178            .transpose()
179            .map_err(|e| crate::Error::InvalidEntry(format!("metadata is not valid JSON: {e}")))?;
180
181        let result = async {
182            conn.execute("BEGIN IMMEDIATE", ()).await?;
183
184            let mut rows = conn
185                .query(
186                    "SELECT EXISTS(SELECT 1 FROM entries WHERE id = ?1)",
187                    (entry.id.clone(),),
188                )
189                .await?;
190            let exists: bool = match rows.next().await? {
191                Some(row) => matches!(row.get_value(0)?, turso::Value::Integer(1)),
192                None => false,
193            };
194
195            if !exists {
196                let mut count_rows = conn.query("SELECT COUNT(*) FROM entries", ()).await?;
197                let count: i64 = match count_rows.next().await? {
198                    Some(row) => match row.get_value(0)? {
199                        turso::Value::Integer(n) => n,
200                        _ => 0,
201                    },
202                    None => 0,
203                };
204
205                if count >= i64::try_from(max_entries).unwrap_or(i64::MAX) {
206                    conn.execute(
207                        "DELETE FROM entries WHERE id = \
208                         (SELECT id FROM entries ORDER BY timestamp ASC LIMIT 1)",
209                        (),
210                    )
211                    .await?;
212                }
213            }
214
215            conn.execute(
216                "INSERT OR REPLACE INTO entries \
217                 (id, content, timestamp, kind, scope, session_id, token_count, metadata) \
218                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
219                (
220                    entry.id.clone(),
221                    entry.content.clone(),
222                    entry.timestamp,
223                    entry.kind.clone(),
224                    entry.scope.clone(),
225                    entry.session_id.clone(),
226                    entry
227                        .token_count
228                        .map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
229                    metadata_json,
230                ),
231            )
232            .await?;
233
234            conn.execute("COMMIT", ()).await?;
235            Ok(())
236        }
237        .await;
238
239        if result.is_err() {
240            let _ = conn.execute("ROLLBACK", ()).await;
241            return result;
242        }
243
244        // Turso write committed — update tantivy. If this fails the entry is
245        // still persisted in turso; next startup rebuild will re-sync the index.
246        self.fts.add(&entry.id, &entry.content)?;
247        self.fts.commit()?;
248
249        result
250    }
251
252    async fn get_top_k(&self, k: usize) -> crate::Result<Vec<ContextEntry>> {
253        let conn = self.db.connect()?;
254        conn.busy_timeout(Duration::from_secs(5))?;
255
256        let mut rows = conn
257            .query(
258                "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
259                 FROM entries ORDER BY timestamp DESC LIMIT ?1",
260                (i64::try_from(k).unwrap_or(i64::MAX),),
261            )
262            .await?;
263
264        let mut result = Vec::new();
265        while let Some(row) = rows.next().await? {
266            result.push(turso_row_to_entry(&row)?);
267        }
268        Ok(result)
269    }
270
271    async fn get_all(&self) -> crate::Result<Vec<ContextEntry>> {
272        let conn = self.db.connect()?;
273        conn.busy_timeout(Duration::from_secs(5))?;
274
275        let mut rows = conn
276            .query(
277                "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
278                 FROM entries ORDER BY timestamp DESC",
279                (),
280            )
281            .await?;
282
283        let mut result = Vec::new();
284        while let Some(row) = rows.next().await? {
285            result.push(turso_row_to_entry(&row)?);
286        }
287        Ok(result)
288    }
289
290    async fn delete(&self, id: &str) -> crate::Result<bool> {
291        let conn = self.db.connect()?;
292        conn.busy_timeout(Duration::from_secs(5))?;
293
294        let affected = conn
295            .execute("DELETE FROM entries WHERE id = ?1", (id.to_owned(),))
296            .await?;
297
298        if affected > 0 {
299            self.fts.remove(id)?;
300        }
301        Ok(affected > 0)
302    }
303
304    async fn clear(&self) -> crate::Result<usize> {
305        let conn = self.db.connect()?;
306        conn.busy_timeout(Duration::from_secs(5))?;
307
308        let affected = conn.execute("DELETE FROM entries", ()).await?;
309        self.fts.clear()?;
310        Ok(usize::try_from(affected).unwrap_or(usize::MAX))
311    }
312
313    async fn clear_scope(&self, scope: &str) -> crate::Result<usize> {
314        let conn = self.db.connect()?;
315        conn.busy_timeout(Duration::from_secs(5))?;
316
317        // Collect the ids being deleted so we can remove them from tantivy.
318        let mut id_rows = conn
319            .query(
320                "SELECT id FROM entries WHERE scope = ?1",
321                (scope.to_owned(),),
322            )
323            .await?;
324        let mut ids: Vec<String> = Vec::new();
325        while let Some(row) = id_rows.next().await? {
326            if let turso::Value::Text(id) = row.get_value(0)? {
327                ids.push(id);
328            }
329        }
330
331        let affected = conn
332            .execute("DELETE FROM entries WHERE scope = ?1", (scope.to_owned(),))
333            .await?;
334
335        for id in &ids {
336            self.fts.remove(id)?;
337        }
338        Ok(usize::try_from(affected).unwrap_or(usize::MAX))
339    }
340
341    async fn count(&self) -> crate::Result<usize> {
342        let conn = self.db.connect()?;
343        conn.busy_timeout(Duration::from_secs(5))?;
344
345        let mut rows = conn.query("SELECT COUNT(*) FROM entries", ()).await?;
346        match rows.next().await? {
347            Some(row) => match row.get_value(0)? {
348                turso::Value::Integer(n) => Ok(usize::try_from(n).unwrap_or(0)),
349                _ => Ok(0),
350            },
351            None => Ok(0),
352        }
353    }
354}