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