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