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 let _ = conn.execute("DROP INDEX IF EXISTS idx_turso_fts", ()).await;
96 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 .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 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 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 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 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 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 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}