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
29pub struct TursoStorage {
31 pub(crate) db: Arc<turso::Database>,
32 pub(crate) fts: Arc<FtsIndex>,
33 max_entries: usize,
34}
35
36impl TursoStorage {
37 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 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 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 .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 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 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}