1use std::future::Future;
46use std::path::Path;
47use std::time::Instant;
48
49use thiserror::Error;
50use turso::{Builder, Connection, Database};
51
52use crate::embedder::{
53 GEMINI_EMBED_MODEL, GEMINI_EMBED_PROVIDER, LOCAL_EMBED_MODEL, LOCAL_EMBED_PROVIDER,
54};
55use crate::types::{Drawer, Entity, Triple};
56
57#[derive(Debug, Error)]
58pub enum DbError {
59 #[error("Turso error: {0}")]
60 Turso(#[from] turso::Error),
61 #[error("I/O error: {0}")]
62 Io(#[from] std::io::Error),
63 #[error("Type conversion: {0}")]
64 Conversion(String),
65 #[error("Serialization error: {0}")]
66 Serde(#[from] serde_json::Error),
67 #[error("Embedding dimension mismatch: store uses {expected}, attempted {actual}")]
68 EmbeddingDimensionMismatch { expected: usize, actual: usize },
69 #[error(
70 "Embedding model mismatch: store uses {expected_provider}/{expected_model}, attempted {actual_provider}/{actual_model}"
71 )]
72 EmbeddingModelMismatch {
73 expected_provider: String,
74 expected_model: String,
75 actual_provider: String,
76 actual_model: String,
77 },
78}
79
80pub type DbResult<T> = Result<T, DbError>;
81
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub struct EmbeddingStoreProfile {
84 pub provider: Option<String>,
85 pub model: Option<String>,
86 pub dimension: Option<usize>,
87}
88
89const INIT_SQL: &str = "
91CREATE TABLE IF NOT EXISTS drawers (
92 id TEXT PRIMARY KEY,
93 wing TEXT NOT NULL,
94 room TEXT NOT NULL,
95 content TEXT NOT NULL,
96 parts TEXT NOT NULL DEFAULT '[]',
97 embedding BLOB,
98 source_file TEXT,
99 chunk_index INTEGER NOT NULL DEFAULT 0,
100 added_by TEXT NOT NULL DEFAULT 'aimem',
101 filed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
102);
103
104CREATE INDEX IF NOT EXISTS idx_drawers_wing ON drawers(wing);
105CREATE INDEX IF NOT EXISTS idx_drawers_room ON drawers(room);
106CREATE INDEX IF NOT EXISTS idx_drawers_wing_room ON drawers(wing, room);
107CREATE INDEX IF NOT EXISTS idx_drawers_source ON drawers(source_file);
108
109CREATE TABLE IF NOT EXISTS drawers_fts (
110 drawer_id TEXT PRIMARY KEY,
111 search_text TEXT NOT NULL
112);
113
114CREATE INDEX IF NOT EXISTS idx_drawers_fts ON drawers_fts USING fts(search_text);
115
116CREATE TABLE IF NOT EXISTS aimem_meta (
117 key TEXT PRIMARY KEY,
118 value TEXT NOT NULL
119);
120
121CREATE TABLE IF NOT EXISTS entities (
122 id TEXT PRIMARY KEY,
123 name TEXT NOT NULL,
124 type TEXT NOT NULL DEFAULT 'unknown',
125 properties TEXT NOT NULL DEFAULT '{}',
126 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
127);
128
129CREATE TABLE IF NOT EXISTS triples (
130 id TEXT PRIMARY KEY,
131 subject TEXT NOT NULL,
132 predicate TEXT NOT NULL,
133 object TEXT NOT NULL,
134 valid_from TEXT,
135 valid_to TEXT,
136 confidence REAL NOT NULL DEFAULT 1.0,
137 source_closet TEXT,
138 source_file TEXT,
139 extracted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
140 FOREIGN KEY (subject) REFERENCES entities(id),
141 FOREIGN KEY (object) REFERENCES entities(id)
142);
143
144CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);
145CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);
146CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
147CREATE INDEX IF NOT EXISTS idx_triples_valid ON triples(valid_from, valid_to);
148";
149
150const DB_BUSY_TIMEOUT_MS: u64 = 5_000;
151const DB_WRITE_RETRY_LIMIT: usize = 8;
152
153#[derive(Clone)]
157pub struct AimemDb {
158 db: Database,
159 read_conn: Connection,
160}
161
162impl std::fmt::Debug for AimemDb {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 f.debug_struct("AimemDb").finish_non_exhaustive()
165 }
166}
167
168impl AimemDb {
169 pub async fn open(path: impl AsRef<Path>) -> DbResult<Self> {
171 let path = path.as_ref();
172 if let Some(parent) = path.parent() {
173 std::fs::create_dir_all(parent)?;
174 }
175
176 let path_str = path.to_string_lossy();
177 let db = Builder::new_local(path_str.as_ref())
178 .experimental_index_method(true)
179 .build()
180 .await?;
181 let bootstrap_conn = db.connect()?;
182 Self::configure_write_conn(&bootstrap_conn).await?;
183 Self::migrate_conn(&bootstrap_conn).await?;
184 let read_conn = db.connect()?;
185 Self::configure_read_conn(&read_conn).await?;
186 Ok(Self { db, read_conn })
187 }
188
189 pub async fn memory() -> DbResult<Self> {
191 let db = Builder::new_local(":memory:")
192 .experimental_index_method(true)
193 .build()
194 .await?;
195 let bootstrap_conn = db.connect()?;
196 Self::configure_write_conn(&bootstrap_conn).await?;
197 Self::migrate_conn(&bootstrap_conn).await?;
198 let read_conn = db.connect()?;
199 Self::configure_read_conn(&read_conn).await?;
200 Ok(Self { db, read_conn })
201 }
202
203 pub fn conn(&self) -> DbResult<Connection> {
207 Ok(self.db.connect()?)
208 }
209
210 pub(crate) fn read_conn(&self) -> DbResult<Connection> {
211 Ok(self.read_conn.clone())
212 }
213
214 async fn configure_write_conn(conn: &Connection) -> DbResult<()> {
215 conn.pragma_update("busy_timeout", DB_BUSY_TIMEOUT_MS)
216 .await?;
217 Ok(())
218 }
219
220 async fn configure_read_conn(conn: &Connection) -> DbResult<()> {
221 conn.pragma_update("busy_timeout", DB_BUSY_TIMEOUT_MS)
222 .await?;
223 conn.pragma_update("query_only", 1).await?;
224 Ok(())
225 }
226
227 async fn migrate_conn(conn: &Connection) -> DbResult<()> {
229 conn.execute_batch(INIT_SQL).await?;
230
231 if let Err(err) = conn
232 .execute(
233 "ALTER TABLE drawers ADD COLUMN parts TEXT NOT NULL DEFAULT '[]'",
234 (),
235 )
236 .await
237 {
238 let msg = err.to_string();
239 if !msg.contains("duplicate column name: parts") {
240 return Err(err.into());
241 }
242 }
243
244 Self::sync_drawers_fts(conn).await?;
245
246 Ok(())
247 }
248
249 async fn sync_drawers_fts(conn: &Connection) -> DbResult<()> {
250 conn.execute(
251 "INSERT OR IGNORE INTO drawers_fts (drawer_id, search_text) \
252 SELECT id, content FROM drawers",
253 (),
254 )
255 .await?;
256 conn.execute(
257 "DELETE FROM drawers_fts WHERE drawer_id NOT IN (SELECT id FROM drawers)",
258 (),
259 )
260 .await?;
261 Ok(())
262 }
263
264 async fn meta_value(&self, key: &str) -> DbResult<Option<String>> {
265 let conn = self.read_conn()?;
266 let mut rows = conn
267 .query("SELECT value FROM aimem_meta WHERE key = ?1 LIMIT 1", [key])
268 .await?;
269 let Some(row) = rows.next().await? else {
270 return Ok(None);
271 };
272 Ok(Some(val_to_string(&row, 0)?))
273 }
274
275 async fn set_meta_value(&self, key: &str, value: &str) -> DbResult<()> {
276 self.with_write_retry("set_meta_value", move |conn| async move {
277 conn.execute(
278 "INSERT OR REPLACE INTO aimem_meta (key, value) VALUES (?1, ?2)",
279 [key, value],
280 )
281 .await?;
282 Ok(())
283 })
284 .await
285 }
286
287 fn infer_profile_from_dimension(dim: usize) -> Option<(&'static str, &'static str)> {
288 match dim {
289 384 => Some((LOCAL_EMBED_PROVIDER, LOCAL_EMBED_MODEL)),
290 768 => Some((GEMINI_EMBED_PROVIDER, GEMINI_EMBED_MODEL)),
291 _ => None,
292 }
293 }
294
295 async fn with_write_retry<T, F, Fut>(&self, label: &'static str, op: F) -> DbResult<T>
296 where
297 F: Fn(Connection) -> Fut,
298 Fut: Future<Output = DbResult<T>>,
299 {
300 let started_at = Instant::now();
301 for attempt in 0..DB_WRITE_RETRY_LIMIT {
302 let conn = self.conn()?;
303 conn.pragma_update("busy_timeout", DB_BUSY_TIMEOUT_MS)
304 .await?;
305 match conn.execute("BEGIN IMMEDIATE", ()).await {
306 Ok(_) => {}
307 Err(err) if is_retryable_turso_write_error(&err) => {
308 let attempt_num = attempt + 1;
309 let elapsed_ms = started_at.elapsed().as_millis() as u64;
310 if attempt_num == DB_WRITE_RETRY_LIMIT {
311 tracing::warn!(
312 target: "aimem::db",
313 op = label,
314 attempt = attempt_num,
315 max_attempts = DB_WRITE_RETRY_LIMIT,
316 elapsed_ms,
317 kind = turso_write_error_kind(&err),
318 error = %err,
319 "AimemDb write transaction could not start before retry budget was exhausted"
320 );
321 return Err(DbError::Turso(err));
322 }
323 tracing::warn!(
324 target: "aimem::db",
325 op = label,
326 attempt = attempt_num,
327 max_attempts = DB_WRITE_RETRY_LIMIT,
328 elapsed_ms,
329 kind = turso_write_error_kind(&err),
330 error = %err,
331 "AimemDb write transaction could not start; retrying"
332 );
333 tokio::task::yield_now().await;
334 continue;
335 }
336 Err(err) => return Err(DbError::Turso(err)),
337 }
338
339 match op(conn.clone()).await {
340 Ok(value) => match conn.execute("COMMIT", ()).await {
341 Ok(_) => {
342 conn.cacheflush()?;
343 if attempt > 0 {
344 tracing::info!(
345 target: "aimem::db",
346 op = label,
347 attempts = attempt + 1,
348 elapsed_ms = started_at.elapsed().as_millis() as u64,
349 "AimemDb write transaction succeeded after retries"
350 );
351 }
352 return Ok(value);
353 }
354 Err(err) if is_retryable_turso_write_error(&err) => {
355 let _ = conn.execute("ROLLBACK", ()).await;
356 let attempt_num = attempt + 1;
357 let elapsed_ms = started_at.elapsed().as_millis() as u64;
358 if attempt_num == DB_WRITE_RETRY_LIMIT {
359 tracing::warn!(
360 target: "aimem::db",
361 op = label,
362 attempt = attempt_num,
363 max_attempts = DB_WRITE_RETRY_LIMIT,
364 elapsed_ms,
365 kind = turso_write_error_kind(&err),
366 error = %err,
367 "AimemDb write transaction kept retrying until the retry budget was exhausted"
368 );
369 } else {
370 tracing::warn!(
371 target: "aimem::db",
372 op = label,
373 attempt = attempt_num,
374 max_attempts = DB_WRITE_RETRY_LIMIT,
375 elapsed_ms,
376 kind = turso_write_error_kind(&err),
377 error = %err,
378 "AimemDb write transaction commit failed; retrying"
379 );
380 }
381 tokio::task::yield_now().await;
382 if attempt_num == DB_WRITE_RETRY_LIMIT {
383 return Err(DbError::Turso(err));
384 }
385 }
386 Err(err) => {
387 let _ = conn.execute("ROLLBACK", ()).await;
388 return Err(DbError::Turso(err));
389 }
390 },
391 Err(err) => {
392 let _ = conn.execute("ROLLBACK", ()).await;
393 return Err(err);
394 }
395 }
396 }
397
398 unreachable!("AimemDb with_write_retry should return inside retry loop");
399 }
400
401 pub async fn embedding_dimension(&self) -> DbResult<Option<usize>> {
404 if let Some(raw) = self.meta_value("embedding_dim").await? {
405 let dim = raw.parse::<usize>().map_err(|err| {
406 DbError::Conversion(format!("invalid embedding_dim metadata {raw:?}: {err}"))
407 })?;
408 return Ok(Some(dim));
409 }
410
411 let conn = self.read_conn()?;
412
413 let mut rows = conn
414 .query(
415 "SELECT length(embedding) FROM drawers WHERE embedding IS NOT NULL LIMIT 1",
416 (),
417 )
418 .await?;
419 let Some(row) = rows.next().await? else {
420 return Ok(None);
421 };
422
423 let bytes = row.get_value(0)?.as_integer().copied().ok_or_else(|| {
424 DbError::Conversion("length(embedding) did not return an integer".to_string())
425 })?;
426 if bytes <= 0 || bytes % 4 != 0 {
427 return Err(DbError::Conversion(format!(
428 "unexpected embedding blob length {bytes}; cannot infer dimension"
429 )));
430 }
431
432 let dim = (bytes / 4) as usize;
433 self.set_meta_value("embedding_dim", &dim.to_string())
434 .await?;
435 Ok(Some(dim))
436 }
437
438 pub async fn embedding_profile(&self) -> DbResult<EmbeddingStoreProfile> {
439 let dimension = self.embedding_dimension().await?;
440 let mut provider = self.meta_value("embedding_provider").await?;
441 let mut model = self.meta_value("embedding_model").await?;
442
443 if let Some(dim) = dimension {
444 if let Some((inferred_provider, inferred_model)) =
445 Self::infer_profile_from_dimension(dim)
446 {
447 if provider.is_none() {
448 self.set_meta_value("embedding_provider", inferred_provider)
449 .await?;
450 provider = Some(inferred_provider.to_string());
451 }
452 if model.is_none() {
453 self.set_meta_value("embedding_model", inferred_model)
454 .await?;
455 model = Some(inferred_model.to_string());
456 }
457 }
458 }
459
460 Ok(EmbeddingStoreProfile {
461 provider,
462 model,
463 dimension,
464 })
465 }
466
467 pub async fn assert_embedding_dimension(&self, dim: usize) -> DbResult<()> {
469 if let Some(expected) = self.embedding_dimension().await? {
470 if expected != dim {
471 return Err(DbError::EmbeddingDimensionMismatch {
472 expected,
473 actual: dim,
474 });
475 }
476 }
477 Ok(())
478 }
479
480 pub async fn assert_embedding_profile(
481 &self,
482 dim: usize,
483 provider: &str,
484 model: &str,
485 ) -> DbResult<()> {
486 let profile = self.embedding_profile().await?;
487 if let Some(expected) = profile.dimension {
488 if expected != dim {
489 return Err(DbError::EmbeddingDimensionMismatch {
490 expected,
491 actual: dim,
492 });
493 }
494 }
495
496 if let (Some(expected_provider), Some(expected_model)) = (profile.provider, profile.model) {
497 if expected_provider != provider || expected_model != model {
498 return Err(DbError::EmbeddingModelMismatch {
499 expected_provider,
500 expected_model,
501 actual_provider: provider.to_string(),
502 actual_model: model.to_string(),
503 });
504 }
505 }
506
507 Ok(())
508 }
509
510 async fn ensure_embedding_dimension_for_write(&self, dim: usize) -> DbResult<()> {
512 if let Some(expected) = self.embedding_dimension().await? {
513 if expected != dim {
514 return Err(DbError::EmbeddingDimensionMismatch {
515 expected,
516 actual: dim,
517 });
518 }
519 return Ok(());
520 }
521
522 self.set_meta_value("embedding_dim", &dim.to_string()).await
523 }
524
525 async fn ensure_embedding_profile_for_write(
526 &self,
527 dim: usize,
528 provider: &str,
529 model: &str,
530 ) -> DbResult<()> {
531 let profile = self.embedding_profile().await?;
532 if let Some(expected) = profile.dimension {
533 if expected != dim {
534 return Err(DbError::EmbeddingDimensionMismatch {
535 expected,
536 actual: dim,
537 });
538 }
539 }
540
541 if let (Some(expected_provider), Some(expected_model)) =
542 (profile.provider.clone(), profile.model.clone())
543 {
544 if expected_provider != provider || expected_model != model {
545 return Err(DbError::EmbeddingModelMismatch {
546 expected_provider,
547 expected_model,
548 actual_provider: provider.to_string(),
549 actual_model: model.to_string(),
550 });
551 }
552 }
553
554 self.set_meta_value("embedding_dim", &dim.to_string())
555 .await?;
556 self.set_meta_value("embedding_provider", provider).await?;
557 self.set_meta_value("embedding_model", model).await?;
558 Ok(())
559 }
560
561 pub async fn insert_drawer(
567 &self,
568 drawer: &Drawer,
569 embedding: Option<&[f32]>,
570 ) -> DbResult<bool> {
571 if let Some(v) = embedding {
572 if let Some((provider, model)) = Self::infer_profile_from_dimension(v.len()) {
573 self.ensure_embedding_profile_for_write(v.len(), provider, model)
574 .await?;
575 } else {
576 self.ensure_embedding_dimension_for_write(v.len()).await?;
577 }
578 }
579
580 self.insert_drawer_inner(drawer, embedding).await
581 }
582
583 pub async fn insert_drawer_with_profile(
585 &self,
586 drawer: &Drawer,
587 embedding: Option<&[f32]>,
588 provider: &str,
589 model: &str,
590 ) -> DbResult<bool> {
591 if let Some(v) = embedding {
592 self.ensure_embedding_profile_for_write(v.len(), provider, model)
593 .await?;
594 }
595
596 self.insert_drawer_inner(drawer, embedding).await
597 }
598
599 async fn insert_drawer_inner(
600 &self,
601 drawer: &Drawer,
602 embedding: Option<&[f32]>,
603 ) -> DbResult<bool> {
604 let emb_json: Option<String> = embedding.map(|v| {
606 let nums: Vec<String> = v.iter().map(|f| f.to_string()).collect();
607 format!("[{}]", nums.join(","))
608 });
609 let parts_json = serde_json::to_string(&drawer.parts)?;
610
611 let sql = if emb_json.is_some() {
613 "INSERT OR IGNORE INTO drawers \
614 (id, wing, room, content, parts, embedding, source_file, chunk_index, added_by, filed_at) \
615 VALUES (?1, ?2, ?3, ?4, ?5, vector32(?6), ?7, ?8, ?9, ?10)"
616 } else {
617 "INSERT OR IGNORE INTO drawers \
618 (id, wing, room, content, parts, embedding, source_file, chunk_index, added_by, filed_at) \
619 VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8, ?9)"
620 };
621
622 self.with_write_retry("insert_drawer", move |conn| {
623 let emb_json = emb_json.clone();
624 let parts_json = parts_json.clone();
625 async move {
626 let rows_affected = if let Some(ref emb) = emb_json {
627 conn.execute(
628 sql,
629 turso::params![
630 drawer.id.as_str(),
631 drawer.wing.as_str(),
632 drawer.room.as_str(),
633 drawer.content.as_str(),
634 parts_json.as_str(),
635 emb.as_str(),
636 drawer.source_file.as_deref(),
637 drawer.chunk_index,
638 drawer.added_by.as_str(),
639 drawer.filed_at.as_str(),
640 ],
641 )
642 .await?
643 } else {
644 conn.execute(
645 sql,
646 turso::params![
647 drawer.id.as_str(),
648 drawer.wing.as_str(),
649 drawer.room.as_str(),
650 drawer.content.as_str(),
651 parts_json.as_str(),
652 drawer.source_file.as_deref(),
653 drawer.chunk_index,
654 drawer.added_by.as_str(),
655 drawer.filed_at.as_str(),
656 ],
657 )
658 .await?
659 };
660
661 if rows_affected > 0 {
662 conn.execute(
663 "INSERT OR REPLACE INTO drawers_fts (drawer_id, search_text) VALUES (?1, ?2)",
664 turso::params![drawer.id.as_str(), drawer.content.as_str()],
665 )
666 .await?;
667 }
668
669 Ok(rows_affected > 0)
670 }
671 })
672 .await
673 }
674
675 pub async fn drawer_exists(&self, id: &str) -> DbResult<bool> {
677 let conn = self.read_conn()?;
678 let mut rows = conn
679 .query("SELECT 1 FROM drawers WHERE id = ?1 LIMIT 1", [id])
680 .await?;
681 Ok(rows.next().await?.is_some())
682 }
683
684 pub async fn source_already_mined(&self, source_file: &str) -> DbResult<bool> {
686 let conn = self.read_conn()?;
687 let mut rows = conn
688 .query(
689 "SELECT 1 FROM drawers WHERE source_file = ?1 LIMIT 1",
690 [source_file],
691 )
692 .await?;
693 Ok(rows.next().await?.is_some())
694 }
695
696 pub async fn delete_drawer(&self, id: &str) -> DbResult<bool> {
698 self.with_write_retry("delete_drawer", move |conn| async move {
699 conn.execute("DELETE FROM drawers_fts WHERE drawer_id = ?1", [id])
700 .await?;
701 let n = conn
702 .execute("DELETE FROM drawers WHERE id = ?1", [id])
703 .await?;
704 Ok(n > 0)
705 })
706 .await
707 }
708
709 pub async fn find_drawers_by_exact_content(
711 &self,
712 content: &str,
713 limit: usize,
714 ) -> DbResult<Vec<Drawer>> {
715 let conn = self.read_conn()?;
716 let mut rows = conn
717 .query(
718 "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
719 FROM drawers WHERE content = ?1 ORDER BY filed_at DESC LIMIT ?2",
720 turso::params![content, limit as i64],
721 )
722 .await?;
723
724 let mut drawers = Vec::new();
725 while let Some(row) = rows.next().await? {
726 drawers.push(row_to_drawer(&row)?);
727 }
728 Ok(drawers)
729 }
730
731 pub async fn drawer_count(&self) -> DbResult<i64> {
733 let conn = self.read_conn()?;
734 let mut rows = conn.query("SELECT COUNT(*) FROM drawers", ()).await?;
735 let row = rows
736 .next()
737 .await?
738 .ok_or_else(|| DbError::Conversion("COUNT returned no row".to_string()))?;
739 Ok(row.get_value(0)?.as_integer().copied().unwrap_or(0))
740 }
741
742 pub async fn taxonomy(&self) -> DbResult<(Vec<(String, i64)>, Vec<(String, i64)>)> {
744 let conn = self.read_conn()?;
745
746 let mut wing_rows = conn
747 .query(
748 "SELECT wing, COUNT(*) as cnt FROM drawers GROUP BY wing ORDER BY cnt DESC",
749 (),
750 )
751 .await?;
752 let mut wings: Vec<(String, i64)> = Vec::new();
753 while let Some(row) = wing_rows.next().await? {
754 let wing = match row.get_value(0)? {
755 turso::Value::Text(s) => s,
756 turso::Value::Null => String::new(),
757 v => format!("{v:?}"),
758 };
759 let cnt = row.get_value(1)?.as_integer().copied().unwrap_or(0);
760 wings.push((wing, cnt));
761 }
762
763 let mut room_rows = conn
764 .query(
765 "SELECT room, COUNT(*) as cnt FROM drawers GROUP BY room ORDER BY cnt DESC",
766 (),
767 )
768 .await?;
769 let mut rooms: Vec<(String, i64)> = Vec::new();
770 while let Some(row) = room_rows.next().await? {
771 let room = match row.get_value(0)? {
772 turso::Value::Text(s) => s,
773 turso::Value::Null => String::new(),
774 v => format!("{v:?}"),
775 };
776 let cnt = row.get_value(1)?.as_integer().copied().unwrap_or(0);
777 rooms.push((room, cnt));
778 }
779
780 Ok((wings, rooms))
781 }
782
783 pub async fn fetch_drawers(
785 &self,
786 wing: Option<&str>,
787 room: Option<&str>,
788 limit: usize,
789 ) -> DbResult<Vec<Drawer>> {
790 let conn = self.read_conn()?;
791 let limit = limit as i64;
792
793 let (sql, params_vec): (String, Vec<String>) = match (wing, room) {
794 (Some(w), Some(r)) => (
795 "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
796 FROM drawers WHERE wing = ?1 AND room = ?2 \
797 ORDER BY filed_at DESC LIMIT ?3"
798 .to_string(),
799 vec![w.to_string(), r.to_string(), limit.to_string()],
800 ),
801 (Some(w), None) => (
802 "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
803 FROM drawers WHERE wing = ?1 \
804 ORDER BY filed_at DESC LIMIT ?2"
805 .to_string(),
806 vec![w.to_string(), limit.to_string()],
807 ),
808 (None, Some(r)) => (
809 "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
810 FROM drawers WHERE room = ?1 \
811 ORDER BY filed_at DESC LIMIT ?2"
812 .to_string(),
813 vec![r.to_string(), limit.to_string()],
814 ),
815 (None, None) => (
816 "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
817 FROM drawers ORDER BY filed_at DESC LIMIT ?1"
818 .to_string(),
819 vec![limit.to_string()],
820 ),
821 };
822
823 let params: Vec<&str> = params_vec.iter().map(|s| s.as_str()).collect();
824 let mut rows = match params.len() {
825 1 => conn.query(&sql, [params[0]]).await?,
826 2 => conn.query(&sql, [params[0], params[1]]).await?,
827 3 => conn.query(&sql, [params[0], params[1], params[2]]).await?,
828 _ => unreachable!(),
829 };
830
831 let mut drawers = Vec::new();
832 while let Some(row) = rows.next().await? {
833 drawers.push(row_to_drawer(&row)?);
834 }
835 Ok(drawers)
836 }
837
838 pub async fn upsert_entity(&self, entity: &Entity) -> DbResult<()> {
844 self.with_write_retry("upsert_entity", move |conn| async move {
845 conn.execute(
846 "INSERT OR REPLACE INTO entities (id, name, type, properties, created_at) \
847 VALUES (?1, ?2, ?3, ?4, ?5)",
848 turso::params![
849 entity.id.as_str(),
850 entity.name.as_str(),
851 entity.entity_type.as_str(),
852 entity.properties.as_str(),
853 entity.created_at.as_str(),
854 ],
855 )
856 .await?;
857 Ok(())
858 })
859 .await
860 }
861
862 pub async fn insert_triple(&self, triple: &Triple) -> DbResult<bool> {
864 self.with_write_retry("insert_triple", move |conn| async move {
865 let n = conn
866 .execute(
867 "INSERT OR IGNORE INTO triples \
868 (id, subject, predicate, object, valid_from, valid_to, confidence, \
869 source_closet, source_file, extracted_at) \
870 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
871 turso::params![
872 triple.id.as_str(),
873 triple.subject.as_str(),
874 triple.predicate.as_str(),
875 triple.object.as_str(),
876 triple.valid_from.as_deref(),
877 triple.valid_to.as_deref(),
878 triple.confidence,
879 triple.source_closet.as_deref(),
880 triple.source_file.as_deref(),
881 triple.extracted_at.as_str(),
882 ],
883 )
884 .await?;
885 Ok(n > 0)
886 })
887 .await
888 }
889
890 pub async fn invalidate_triple(
892 &self,
893 subject: &str,
894 predicate: &str,
895 object: &str,
896 ended: &str,
897 ) -> DbResult<u64> {
898 self.with_write_retry("invalidate_triple", move |conn| async move {
899 let n = conn
900 .execute(
901 "UPDATE triples SET valid_to = ?1 \
902 WHERE subject = ?2 AND predicate = ?3 AND object = ?4 AND valid_to IS NULL",
903 turso::params![ended, subject, predicate, object],
904 )
905 .await?;
906 Ok(n)
907 })
908 .await
909 }
910}
911
912fn row_to_drawer(row: &turso::Row) -> DbResult<Drawer> {
917 Ok(Drawer {
918 id: val_to_string(row, 0)?,
919 wing: val_to_string(row, 1)?,
920 room: val_to_string(row, 2)?,
921 content: val_to_string(row, 3)?,
922 parts: parse_parts(&val_to_string(row, 4)?)?,
923 source_file: {
924 let s = val_to_string(row, 5)?;
925 if s.is_empty() { None } else { Some(s) }
926 },
927 chunk_index: row.get_value(6)?.as_integer().copied().unwrap_or(0),
928 added_by: val_to_string(row, 7)?,
929 filed_at: val_to_string(row, 8)?,
930 })
931}
932
933fn parse_parts(raw: &str) -> Result<Vec<crate::types::ContentPart>, serde_json::Error> {
934 if raw.is_empty() || raw == "[]" {
935 Ok(Vec::new())
936 } else {
937 serde_json::from_str(raw)
938 }
939}
940
941fn val_to_string(row: &turso::Row, idx: usize) -> DbResult<String> {
942 match row.get_value(idx)? {
943 turso::Value::Text(s) => Ok(s),
944 turso::Value::Null => Ok(String::new()),
945 turso::Value::Integer(i) => Ok(i.to_string()),
946 turso::Value::Real(f) => Ok(f.to_string()),
947 turso::Value::Blob(_) => Ok(String::new()),
948 }
949}
950
951fn is_retryable_turso_write_error(err: &turso::Error) -> bool {
952 matches!(err, turso::Error::Busy(_) | turso::Error::BusySnapshot(_))
953 || matches!(err, turso::Error::Error(msg) if msg.contains("conflict"))
954}
955
956fn turso_write_error_kind(err: &turso::Error) -> &'static str {
957 match err {
958 turso::Error::Busy(_) => "busy",
959 turso::Error::BusySnapshot(_) => "busy_snapshot",
960 turso::Error::Error(msg) if msg.contains("conflict") => "conflict",
961 _ => "other",
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968
969 #[test]
970 fn turso_write_error_kind_classifies_busy_variants_and_conflicts() {
971 assert_eq!(
972 turso_write_error_kind(&turso::Error::Busy("busy".to_string())),
973 "busy"
974 );
975 assert_eq!(
976 turso_write_error_kind(&turso::Error::BusySnapshot("snapshot".to_string())),
977 "busy_snapshot"
978 );
979 assert_eq!(
980 turso_write_error_kind(&turso::Error::Error("write conflict".to_string())),
981 "conflict"
982 );
983 assert_eq!(
984 turso_write_error_kind(&turso::Error::Constraint("nope".to_string())),
985 "other"
986 );
987 }
988
989 #[tokio::test]
990 async fn read_connection_uses_query_only_mode() {
991 let db = AimemDb::memory().await.expect("memory db should open");
992 let err = db
993 .read_conn()
994 .expect("read connection should exist")
995 .execute(
996 "INSERT INTO aimem_meta (key, value) VALUES (?1, ?2)",
997 ["forbidden", "1"],
998 )
999 .await
1000 .expect_err("query-only connection must reject writes");
1001 let msg = err.to_string().to_ascii_lowercase();
1002 assert!(
1003 matches!(&err, turso::Error::Readonly(_))
1004 || msg.contains("readonly")
1005 || msg.contains("query_only"),
1006 "expected readonly/query_only error, got {err:?}"
1007 );
1008 }
1009}