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 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 .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 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 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}