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