1use std::sync::Arc;
4
5use async_trait::async_trait;
6use uuid::Uuid;
7
8use khive_storage::error::StorageError;
9use khive_storage::note::{FilterOp, Note, NoteFilter, SortDir};
10use khive_storage::types::{
11 BatchWriteSummary, DeleteMode, Page, PageRequest, SqlStatement, SqlValue,
12};
13use khive_storage::NoteStore;
14use khive_storage::StorageCapability;
15
16use crate::error::SqliteError;
17use crate::pool::ConnectionPool;
18use crate::sql_bridge::bind_params;
19use crate::writer_task::WriterTaskHandle;
20
21fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
22 StorageError::driver(StorageCapability::Notes, op, e)
23}
24
25fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
26 StorageError::driver(StorageCapability::Notes, op, e)
27}
28
29pub const NOTE_UPSERT_SQL: &str = "INSERT INTO notes \
47 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
48 properties, created_at, updated_at, deleted_at) \
49 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
50 ON CONFLICT(id) DO UPDATE SET \
51 namespace = excluded.namespace, \
52 kind = excluded.kind, \
53 status = excluded.status, \
54 name = excluded.name, \
55 content = excluded.content, \
56 salience = excluded.salience, \
57 decay_factor = excluded.decay_factor, \
58 expires_at = excluded.expires_at, \
59 properties = excluded.properties, \
60 updated_at = excluded.updated_at, \
61 deleted_at = excluded.deleted_at";
62
63pub fn note_upsert_statement(note: &Note) -> SqlStatement {
65 let properties_str = note
66 .properties
67 .as_ref()
68 .map(|v| serde_json::to_string(v).unwrap_or_default());
69 SqlStatement {
70 sql: NOTE_UPSERT_SQL.to_string(),
71 params: vec![
72 SqlValue::Text(note.id.to_string()),
73 SqlValue::Text(note.namespace.clone()),
74 SqlValue::Text(note.kind.to_string()),
75 SqlValue::Text(note.status.clone()),
76 match ¬e.name {
77 Some(n) => SqlValue::Text(n.clone()),
78 None => SqlValue::Null,
79 },
80 SqlValue::Text(note.content.clone()),
81 match note.salience {
82 Some(s) => SqlValue::Float(s),
83 None => SqlValue::Null,
84 },
85 match note.decay_factor {
86 Some(d) => SqlValue::Float(d),
87 None => SqlValue::Null,
88 },
89 match note.expires_at {
90 Some(e) => SqlValue::Integer(e),
91 None => SqlValue::Null,
92 },
93 match properties_str {
94 Some(p) => SqlValue::Text(p),
95 None => SqlValue::Null,
96 },
97 SqlValue::Integer(note.created_at),
98 SqlValue::Integer(note.updated_at),
99 match note.deleted_at {
100 Some(d) => SqlValue::Integer(d),
101 None => SqlValue::Null,
102 },
103 ],
104 label: Some("note-upsert".to_string()),
105 }
106}
107
108pub fn note_update_properties_statement(
116 id: Uuid,
117 properties: &Option<serde_json::Value>,
118 updated_at: i64,
119) -> SqlStatement {
120 let properties_str = properties
121 .as_ref()
122 .map(|v| serde_json::to_string(v).unwrap_or_default());
123 SqlStatement {
124 sql: "UPDATE notes SET properties = ?1, updated_at = ?2 \
125 WHERE id = ?3 AND deleted_at IS NULL"
126 .to_string(),
127 params: vec![
128 match properties_str {
129 Some(p) => SqlValue::Text(p),
130 None => SqlValue::Null,
131 },
132 SqlValue::Integer(updated_at),
133 SqlValue::Text(id.to_string()),
134 ],
135 label: Some("note-update-properties".to_string()),
136 }
137}
138
139pub fn note_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
141 SqlStatement {
142 sql: "UPDATE notes SET status = 'deleted', deleted_at = ?1 \
143 WHERE id = ?2 AND deleted_at IS NULL"
144 .to_string(),
145 params: vec![
146 SqlValue::Integer(deleted_at),
147 SqlValue::Text(id.to_string()),
148 ],
149 label: Some("note-delete-soft".to_string()),
150 }
151}
152
153pub fn note_hard_delete_statement(id: Uuid) -> SqlStatement {
155 SqlStatement {
156 sql: "DELETE FROM notes WHERE id = ?1".to_string(),
157 params: vec![SqlValue::Text(id.to_string())],
158 label: Some("note-delete-hard".to_string()),
159 }
160}
161
162pub struct SqlNoteStore {
167 pool: Arc<ConnectionPool>,
168 is_file_backed: bool,
169 writer_task: Option<WriterTaskHandle>,
170}
171
172impl SqlNoteStore {
173 pub fn new(pool: Arc<ConnectionPool>, is_file_backed: bool) -> Self {
175 let writer_task = pool.writer_task_handle().ok().flatten();
180
181 Self {
182 pool,
183 is_file_backed,
184 writer_task,
185 }
186 }
187
188 fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
189 let config = self.pool.config();
190 let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
191 operation: "note_reader".into(),
192 message: "in-memory databases do not support standalone connections".into(),
193 })?;
194
195 let conn = rusqlite::Connection::open_with_flags(
196 path,
197 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
198 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
199 | rusqlite::OpenFlags::SQLITE_OPEN_URI,
200 )
201 .map_err(|e| map_err(e, "open_note_reader"))?;
202
203 conn.busy_timeout(config.busy_timeout)
204 .map_err(|e| map_err(e, "open_note_reader"))?;
205 conn.pragma_update(None, "foreign_keys", "ON")
206 .map_err(|e| map_err(e, "open_note_reader"))?;
207 conn.pragma_update(None, "synchronous", "NORMAL")
208 .map_err(|e| map_err(e, "open_note_reader"))?;
209
210 Ok(conn)
211 }
212
213 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
230 where
231 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
232 R: Send + 'static,
233 {
234 if let Some(writer_task) = &self.writer_task {
235 return writer_task
236 .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
237 .await;
238 }
239
240 let pool = Arc::clone(&self.pool);
241 tokio::task::spawn_blocking(move || {
242 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
243 f(guard.conn()).map_err(|e| map_err(e, op))
244 })
245 .await
246 .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
247 }
248
249 async fn with_writer_tx<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
262 where
263 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
264 R: Send + 'static,
265 {
266 if let Some(writer_task) = &self.writer_task {
267 return writer_task
268 .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
269 .await;
270 }
271
272 let pool = Arc::clone(&self.pool);
273 tokio::task::spawn_blocking(move || {
274 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
275 let conn = guard.conn();
276 conn.execute_batch("BEGIN IMMEDIATE")
277 .map_err(|e| map_err(e, op))?;
278
279 match f(conn) {
280 Ok(value) => match conn.execute_batch("COMMIT") {
281 Ok(()) => Ok(value),
282 Err(e) => {
283 let _ = conn.execute_batch("ROLLBACK");
284 Err(map_err(e, op))
285 }
286 },
287 Err(e) => {
288 let _ = conn.execute_batch("ROLLBACK");
289 Err(map_err(e, op))
290 }
291 }
292 })
293 .await
294 .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
295 }
296
297 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
298 where
299 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
300 R: Send + 'static,
301 {
302 if self.is_file_backed {
303 let conn = self.open_standalone_reader()?;
304 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
305 .await
306 .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
307 } else {
308 let pool = Arc::clone(&self.pool);
309 tokio::task::spawn_blocking(move || {
310 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
311 f(guard.conn()).map_err(|e| map_err(e, op))
312 })
313 .await
314 .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
315 }
316 }
317}
318
319fn read_note(row: &rusqlite::Row<'_>) -> Result<Note, rusqlite::Error> {
324 let id_str: String = row.get(0)?;
325 let namespace: String = row.get(1)?;
326 let kind: String = row.get(2)?;
327 let status: String = row.get(3)?;
328 let name: Option<String> = row.get(4)?;
329 let content: String = row.get(5)?;
330 let salience: Option<f64> = row.get(6)?;
331 let decay_factor: Option<f64> = row.get(7)?;
332 let expires_at: Option<i64> = row.get(8)?;
333 let properties_str: Option<String> = row.get(9)?;
334 let created_at: i64 = row.get(10)?;
335 let updated_at: i64 = row.get(11)?;
336 let deleted_at: Option<i64> = row.get(12)?;
337
338 let id = parse_uuid(&id_str)?;
339
340 let properties = properties_str
341 .map(|s| {
342 serde_json::from_str(&s).map_err(|e| {
343 rusqlite::Error::FromSqlConversionFailure(
344 9,
345 rusqlite::types::Type::Text,
346 Box::new(e),
347 )
348 })
349 })
350 .transpose()?;
351
352 Ok(Note {
353 id,
354 namespace,
355 kind,
356 status,
357 name,
358 content,
359 salience,
360 decay_factor,
361 expires_at,
362 properties,
363 created_at,
364 updated_at,
365 deleted_at,
366 })
367}
368
369fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
370 Uuid::parse_str(s).map_err(|e| {
371 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
372 })
373}
374
375fn batch_upsert_notes(
383 conn: &rusqlite::Connection,
384 notes: &[Note],
385 attempted: u64,
386) -> Result<BatchWriteSummary, rusqlite::Error> {
387 let mut affected = 0u64;
388 let mut failed = 0u64;
389 let mut first_error = String::new();
390
391 let mut stmt = conn.prepare_cached(NOTE_UPSERT_SQL)?;
396
397 for note in notes {
398 let id_str = note.id.to_string();
399 let kind_str = note.kind.to_string();
400 let status_str = note.status.clone();
401 let properties_str = note
402 .properties
403 .as_ref()
404 .map(|v| serde_json::to_string(v).unwrap_or_default());
405
406 match stmt.execute(rusqlite::params![
407 id_str,
408 ¬e.namespace,
409 kind_str,
410 status_str,
411 ¬e.name,
412 note.content,
413 note.salience,
414 note.decay_factor,
415 note.expires_at,
416 properties_str,
417 note.created_at,
418 note.updated_at,
419 note.deleted_at,
420 ]) {
421 Ok(_) => {
422 assign_note_seq(conn, &id_str)?;
423 affected += 1;
424 }
425 Err(e) => {
426 if first_error.is_empty() {
427 first_error = e.to_string();
428 }
429 failed += 1;
430 }
431 }
432 }
433
434 Ok(BatchWriteSummary {
435 attempted,
436 affected,
437 failed,
438 first_error,
439 })
440}
441
442fn assign_note_seq(conn: &rusqlite::Connection, note_id: &str) -> Result<(), rusqlite::Error> {
448 conn.execute(
449 "INSERT OR IGNORE INTO notes_seq (note_id) VALUES (?1)",
450 rusqlite::params![note_id],
451 )?;
452 Ok(())
453}
454
455fn build_note_where(
456 namespace: &str,
457 kind: Option<&str>,
458) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
459 let mut conditions: Vec<String> = vec![
460 "namespace = ?1".to_string(),
461 "deleted_at IS NULL".to_string(),
462 ];
463 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(namespace.to_string())];
464
465 if let Some(k) = kind {
466 params.push(Box::new(k.to_string()));
467 conditions.push(format!("kind = ?{}", params.len()));
468 }
469
470 let clause = format!(" WHERE {}", conditions.join(" AND "));
471 (clause, params)
472}
473
474fn validate_json_path(path: &str) -> Result<(), StorageError> {
477 let valid = path.starts_with("$.")
478 && path[2..].split('.').all(|part| {
479 !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
480 });
481 if valid {
482 Ok(())
483 } else {
484 Err(StorageError::InvalidInput {
485 capability: StorageCapability::Notes,
486 operation: "query_notes_filtered".into(),
487 message: format!("invalid JSON path for note filter: {path:?}"),
488 })
489 }
490}
491
492fn json_extract_expr(path: &str) -> String {
493 format!("json_extract(properties, '{path}')")
494}
495
496fn json_type_expr(path: &str) -> String {
497 format!("json_type(properties, '{path}')")
498}
499
500fn sql_value_param(value: &SqlValue) -> Result<Box<dyn rusqlite::types::ToSql>, rusqlite::Error> {
501 Ok(match value {
502 SqlValue::Null => Box::new(Option::<String>::None),
503 SqlValue::Bool(v) => Box::new(*v as i64),
504 SqlValue::Integer(v) => Box::new(*v),
505 SqlValue::Float(v) => Box::new(*v),
506 SqlValue::Text(v) => Box::new(v.clone()),
507 SqlValue::Blob(v) => Box::new(v.clone()),
508 SqlValue::Json(v) => Box::new(
509 serde_json::to_string(v)
510 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
511 ),
512 SqlValue::Uuid(v) => Box::new(v.to_string()),
513 SqlValue::Timestamp(v) => Box::new(v.timestamp_micros()),
514 })
515}
516
517fn build_note_filter_where(
518 namespace: &str,
519 filter: &NoteFilter,
520) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
521 let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
524 if !filter.namespaces.is_empty() {
525 let placeholders: Vec<String> = (1..=filter.namespaces.len())
526 .map(|i| format!("?{i}"))
527 .collect();
528 let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
529 .namespaces
530 .iter()
531 .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
532 .collect();
533 (
534 format!("namespace IN ({})", placeholders.join(", ")),
535 params,
536 )
537 } else {
538 (
539 "namespace = ?1".to_string(),
540 vec![Box::new(namespace.to_string())],
541 )
542 };
543
544 let mut conditions = vec![ns_condition, "deleted_at IS NULL".to_string()];
545 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
546
547 if let Some(kind) = &filter.kind {
548 params.push(Box::new(kind.clone()));
549 conditions.push(format!("kind = ?{}", params.len()));
550 }
551
552 for pf in &filter.property_filters {
553 match &pf.op {
554 FilterOp::EqOrMissing => {
555 let expr = json_extract_expr(&pf.json_path);
556 params.push(sql_value_param(&pf.value)?);
557 conditions.push(format!(
558 "({expr} = ?{n} OR {expr} IS NULL)",
559 n = params.len()
560 ));
561 }
562 FilterOp::JsonTypeEq => {
563 let type_expr = json_type_expr(&pf.json_path);
564 params.push(sql_value_param(&pf.value)?);
565 conditions.push(format!("{type_expr} = ?{}", params.len()));
566 }
567 FilterOp::JsonTypeNeMissing => {
568 let type_expr = json_type_expr(&pf.json_path);
569 params.push(sql_value_param(&pf.value)?);
570 let n = params.len();
571 conditions.push(format!("({type_expr} IS NULL OR {type_expr} != ?{n})"));
572 }
573 FilterOp::In(values) => {
574 let expr = json_extract_expr(&pf.json_path);
575 if values.is_empty() {
576 conditions.push("0".to_string());
578 continue;
579 }
580 let mut placeholders = Vec::with_capacity(values.len());
581 for v in values {
582 params.push(sql_value_param(v)?);
583 placeholders.push(format!("?{}", params.len()));
584 }
585 conditions.push(format!("{expr} IN ({})", placeholders.join(", ")));
586 }
587 FilterOp::NotInOrMissing(values) => {
588 let expr = json_extract_expr(&pf.json_path);
589 if values.is_empty() {
590 continue;
592 }
593 let mut placeholders = Vec::with_capacity(values.len());
594 for v in values {
595 params.push(sql_value_param(v)?);
596 placeholders.push(format!("?{}", params.len()));
597 }
598 conditions.push(format!(
599 "({expr} IS NULL OR {expr} NOT IN ({}))",
600 placeholders.join(", ")
601 ));
602 }
603 _ => {
604 let expr = json_extract_expr(&pf.json_path);
605 let op = match pf.op {
606 FilterOp::Eq => "=",
607 FilterOp::Ne => "!=",
608 FilterOp::Lt => "<",
609 FilterOp::Lte => "<=",
610 FilterOp::Gt => ">",
611 FilterOp::Gte => ">=",
612 FilterOp::EqOrMissing
613 | FilterOp::JsonTypeEq
614 | FilterOp::JsonTypeNeMissing
615 | FilterOp::In(_)
616 | FilterOp::NotInOrMissing(_) => {
617 unreachable!()
618 }
619 };
620 params.push(sql_value_param(&pf.value)?);
621 conditions.push(format!("{expr} {op} ?{}", params.len()));
622 }
623 }
624 }
625
626 if let Some(min_ts) = filter.min_created_at {
627 params.push(Box::new(min_ts));
628 conditions.push(format!("created_at >= ?{}", params.len()));
629 }
630
631 Ok((format!(" WHERE {}", conditions.join(" AND ")), params))
632}
633
634#[async_trait]
639impl NoteStore for SqlNoteStore {
640 async fn upsert_note(&self, note: Note) -> Result<(), StorageError> {
641 let id_str = note.id.to_string();
642 let statement = note_upsert_statement(¬e);
643 self.with_writer_tx("upsert_note", move |conn| {
644 let mut stmt = conn.prepare_cached(&statement.sql)?;
645 bind_params(&mut stmt, &statement.params)?;
646 stmt.raw_execute()?;
647 assign_note_seq(conn, &id_str)?;
648 Ok(())
649 })
650 .await
651 }
652
653 async fn update_note_properties(
654 &self,
655 id: Uuid,
656 properties: Option<serde_json::Value>,
657 updated_at: i64,
658 ) -> Result<bool, StorageError> {
659 let statement = note_update_properties_statement(id, &properties, updated_at);
660 self.with_writer("update_note_properties", move |conn| {
661 let mut stmt = conn.prepare(&statement.sql)?;
662 bind_params(&mut stmt, &statement.params)?;
663 Ok(stmt.raw_execute()? > 0)
664 })
665 .await
666 }
667
668 async fn try_insert_note(&self, note: Note) -> Result<bool, StorageError> {
669 let namespace = note.namespace.clone();
670 let id_str = note.id.to_string();
671 let kind_str = note.kind.to_string();
672 let status_str = note.status.clone();
673 let properties_str = note
674 .properties
675 .as_ref()
676 .map(|v| serde_json::to_string(v).unwrap_or_default());
677
678 let ext_id_opt: Option<String> = note
680 .properties
681 .as_ref()
682 .and_then(|v| v.get("external_id"))
683 .and_then(|v| v.as_str())
684 .filter(|s| !s.is_empty())
685 .map(|s| s.to_string());
686
687 self.with_writer_tx("try_insert_note", move |conn| {
688 let rows = conn.execute(
689 "INSERT OR IGNORE INTO notes \
690 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
691 properties, created_at, updated_at, deleted_at) \
692 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
693 rusqlite::params![
694 id_str,
695 namespace,
696 kind_str,
697 status_str,
698 note.name,
699 note.content,
700 note.salience,
701 note.decay_factor,
702 note.expires_at,
703 properties_str,
704 note.created_at,
705 note.updated_at,
706 note.deleted_at,
707 ],
708 )?;
709
710 if rows > 0 {
711 assign_note_seq(conn, &id_str)?;
712 return Ok(true);
713 }
714
715 if let Some(ref ext_id) = ext_id_opt {
721 let is_dedup: bool = conn.query_row(
722 "SELECT COUNT(*) > 0 FROM notes \
723 WHERE namespace = ?1 \
724 AND kind = ?2 \
725 AND json_extract(properties, '$.external_id') = ?3 \
726 AND deleted_at IS NULL",
727 rusqlite::params![namespace, kind_str, ext_id],
728 |row| row.get(0),
729 )?;
730 if is_dedup {
731 return Ok(false);
732 }
733 }
734
735 Err(rusqlite::Error::SqliteFailure(
738 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
739 Some(
740 "try_insert_note: INSERT ignored for a constraint other than \
741 external_id dedup; not masking as deduplication"
742 .to_string(),
743 ),
744 ))
745 })
746 .await
747 }
748
749 async fn upsert_notes(&self, notes: Vec<Note>) -> Result<BatchWriteSummary, StorageError> {
750 let attempted = notes.len() as u64;
751
752 let origin = self.pool.origin();
763 self.with_writer_tx("upsert_notes", move |conn| {
764 let _tx_handle = khive_storage::tx_registry::register_scoped(
765 Some("note_upsert_batch".to_string()),
766 origin,
767 );
768 batch_upsert_notes(conn, ¬es, attempted)
769 })
770 .await
771 }
772
773 async fn get_note(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
774 let id_str = id.to_string();
775
776 self.with_reader("get_note", move |conn| {
777 let mut stmt = conn.prepare(
778 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
779 properties, created_at, updated_at, deleted_at \
780 FROM notes WHERE id = ?1 AND deleted_at IS NULL",
781 )?;
782 let mut rows = stmt.query(rusqlite::params![id_str])?;
783 match rows.next()? {
784 Some(row) => Ok(Some(read_note(row)?)),
785 None => Ok(None),
786 }
787 })
788 .await
789 }
790
791 async fn get_note_including_deleted(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
792 let id_str = id.to_string();
793
794 self.with_reader("get_note_including_deleted", move |conn| {
795 let mut stmt = conn.prepare(
796 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
797 properties, created_at, updated_at, deleted_at \
798 FROM notes WHERE id = ?1",
799 )?;
800 let mut rows = stmt.query(rusqlite::params![id_str])?;
801 match rows.next()? {
802 Some(row) => Ok(Some(read_note(row)?)),
803 None => Ok(None),
804 }
805 })
806 .await
807 }
808
809 async fn get_notes_batch(&self, ids: &[Uuid]) -> Result<Vec<Note>, StorageError> {
810 if ids.is_empty() {
811 return Ok(vec![]);
812 }
813 const CHUNK: usize = 900;
816 let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
817
818 let mut result = Vec::with_capacity(ids.len());
819 for chunk in id_strings.chunks(CHUNK) {
820 let chunk_owned = chunk.to_vec();
821 let notes = self
822 .with_reader("get_notes_batch", move |conn| {
823 let placeholders: String = (1..=chunk_owned.len())
824 .map(|i| format!("?{i}"))
825 .collect::<Vec<_>>()
826 .join(", ");
827 let sql = format!(
828 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
829 properties, created_at, updated_at, deleted_at \
830 FROM notes WHERE id IN ({placeholders}) AND deleted_at IS NULL"
831 );
832 let mut stmt = conn.prepare(&sql)?;
833 let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
834 .iter()
835 .map(|s| s as &dyn rusqlite::types::ToSql)
836 .collect();
837 let rows = stmt.query_map(params.as_slice(), read_note)?;
838 let mut notes = Vec::new();
839 for row in rows {
840 notes.push(row?);
841 }
842 Ok(notes)
843 })
844 .await?;
845 result.extend(notes);
846 }
847 Ok(result)
848 }
849
850 async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
851 match mode {
852 DeleteMode::Soft => {
853 let now = chrono::Utc::now().timestamp_micros();
854 let statement = note_soft_delete_statement(id, now);
855 self.with_writer("delete_note_soft", move |conn| {
856 let mut stmt = conn.prepare(&statement.sql)?;
857 bind_params(&mut stmt, &statement.params)?;
858 Ok(stmt.raw_execute()? > 0)
859 })
860 .await
861 }
862 DeleteMode::Hard => {
863 let statement = note_hard_delete_statement(id);
864 self.with_writer("delete_note_hard", move |conn| {
865 let mut stmt = conn.prepare(&statement.sql)?;
866 bind_params(&mut stmt, &statement.params)?;
867 Ok(stmt.raw_execute()? > 0)
868 })
869 .await
870 }
871 }
872 }
873
874 async fn query_notes(
875 &self,
876 namespace: &str,
877 kind: Option<&str>,
878 page: PageRequest,
879 ) -> Result<Page<Note>, StorageError> {
880 let namespace = namespace.to_string();
881 let kind = kind.map(|k| k.to_string());
882 let limit_i64 = i64::from(page.limit);
883 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
884 capability: StorageCapability::Notes,
885 operation: "query_notes".into(),
886 message: format!(
887 "PageRequest: offset must be <= i64::MAX, got {}",
888 page.offset
889 ),
890 })?;
891
892 self.with_reader("query_notes", move |conn| {
893 let (count_sql, count_params) = build_note_where(&namespace, kind.as_deref());
894 let total: i64 = {
895 let sql = format!("SELECT COUNT(*) FROM notes{}", count_sql);
896 let mut stmt = conn.prepare(&sql)?;
897 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
898 count_params.iter().map(|p| p.as_ref()).collect();
899 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
900 };
901
902 let (where_sql, mut data_params) = build_note_where(&namespace, kind.as_deref());
903 data_params.push(Box::new(limit_i64));
904 data_params.push(Box::new(offset_i64));
905
906 let limit_idx = data_params.len() - 1;
907 let offset_idx = data_params.len();
908
909 let data_sql = format!(
910 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
911 properties, created_at, updated_at, deleted_at \
912 FROM notes{} ORDER BY created_at DESC LIMIT ?{} OFFSET ?{}",
913 where_sql, limit_idx, offset_idx,
914 );
915
916 let mut stmt = conn.prepare(&data_sql)?;
917 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
918 data_params.iter().map(|p| p.as_ref()).collect();
919 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
920
921 let mut items = Vec::new();
922 for row in rows {
923 items.push(row?);
924 }
925
926 Ok(Page {
927 items,
928 total: Some(total as u64),
929 })
930 })
931 .await
932 }
933
934 async fn query_notes_filtered(
935 &self,
936 namespace: &str,
937 filter: &NoteFilter,
938 page: PageRequest,
939 ) -> Result<Page<Note>, StorageError> {
940 for pf in &filter.property_filters {
942 validate_json_path(&pf.json_path)?;
943 }
944 if let Some((path, _)) = &filter.order_by {
945 validate_json_path(path)?;
946 }
947
948 let namespace = namespace.to_string();
949 let filter = filter.clone();
950 let limit_i64 = i64::from(page.limit);
951 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
952 capability: StorageCapability::Notes,
953 operation: "query_notes_filtered".into(),
954 message: format!(
955 "PageRequest: offset must be <= i64::MAX, got {}",
956 page.offset
957 ),
958 })?;
959
960 self.with_reader("query_notes_filtered", move |conn| {
961 let (count_sql, count_params) = build_note_filter_where(&namespace, &filter)?;
962 let total: i64 = {
963 let sql = format!("SELECT COUNT(*) FROM notes{}", count_sql);
964 let mut stmt = conn.prepare(&sql)?;
965 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
966 count_params.iter().map(|p| p.as_ref()).collect();
967 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
968 };
969
970 let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
971 data_params.push(Box::new(limit_i64));
972 data_params.push(Box::new(offset_i64));
973
974 let order_clause = match &filter.order_by {
975 Some((path, dir)) => {
976 let dir_str = match dir {
977 SortDir::Asc => "ASC",
978 SortDir::Desc => "DESC",
979 };
980 format!(" ORDER BY {} {dir_str}", json_extract_expr(path))
981 }
982 None => " ORDER BY created_at DESC".to_string(),
983 };
984
985 let limit_idx = data_params.len() - 1;
986 let offset_idx = data_params.len();
987 let data_sql = format!(
988 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
989 expires_at, properties, created_at, updated_at, deleted_at \
990 FROM notes{}{order_clause} LIMIT ?{} OFFSET ?{}",
991 where_sql, limit_idx, offset_idx,
992 );
993
994 let mut stmt = conn.prepare(&data_sql)?;
995 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
996 data_params.iter().map(|p| p.as_ref()).collect();
997 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
998
999 let mut items = Vec::new();
1000 for row in rows {
1001 items.push(row?);
1002 }
1003
1004 Ok(Page {
1005 items,
1006 total: Some(total as u64),
1007 })
1008 })
1009 .await
1010 }
1011
1012 async fn query_notes_filtered_bounded(
1013 &self,
1014 namespace: &str,
1015 filter: &NoteFilter,
1016 max_rows: u32,
1017 ) -> Result<Vec<Note>, StorageError> {
1018 for pf in &filter.property_filters {
1019 validate_json_path(&pf.json_path)?;
1020 }
1021 if let Some((path, _)) = &filter.order_by {
1022 validate_json_path(path)?;
1023 }
1024
1025 let namespace = namespace.to_string();
1026 let filter = filter.clone();
1027 let limit_i64 = i64::from(max_rows) + 1;
1028
1029 self.with_reader("query_notes_filtered_bounded", move |conn| {
1030 let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
1031 data_params.push(Box::new(limit_i64));
1032 let limit_idx = data_params.len();
1033
1034 let order_clause = match &filter.order_by {
1038 Some((path, dir)) => {
1039 let dir_str = match dir {
1040 SortDir::Asc => "ASC",
1041 SortDir::Desc => "DESC",
1042 };
1043 format!(" ORDER BY {} {dir_str}, id ASC", json_extract_expr(path))
1044 }
1045 None => " ORDER BY created_at DESC, id ASC".to_string(),
1046 };
1047
1048 let data_sql = format!(
1049 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1050 expires_at, properties, created_at, updated_at, deleted_at \
1051 FROM notes{where_sql}{order_clause} LIMIT ?{limit_idx}",
1052 );
1053
1054 let mut stmt = conn.prepare(&data_sql)?;
1055 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1056 data_params.iter().map(|p| p.as_ref()).collect();
1057 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1058
1059 let mut items = Vec::new();
1060 for row in rows {
1061 items.push(row?);
1062 }
1063 Ok(items)
1064 })
1065 .await
1066 }
1067
1068 async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> Result<u64, StorageError> {
1069 let namespace = namespace.to_string();
1070 let kind = kind.map(|k| k.to_string());
1071
1072 self.with_reader("count_notes", move |conn| {
1073 let (where_sql, params) = build_note_where(&namespace, kind.as_deref());
1074 let sql = format!("SELECT COUNT(*) FROM notes{}", where_sql);
1075 let mut stmt = conn.prepare(&sql)?;
1076 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1077 params.iter().map(|p| p.as_ref()).collect();
1078 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1079 Ok(count as u64)
1080 })
1081 .await
1082 }
1083}
1084
1085const NOTES_DDL: &str = include_str!("../../sql/notes-ddl.sql");
1090
1091const NOTES_SEQ_REPAIR_DDL: &str = include_str!("../../sql/008-notes-seq-repair.sql");
1098
1099pub(crate) fn ensure_notes_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1100 conn.execute_batch(NOTES_DDL)
1101}
1102
1103pub(crate) fn repair_notes_seq(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1109 conn.execute_batch(NOTES_SEQ_REPAIR_DDL)
1110}
1111
1112#[cfg(test)]
1113#[path = "note_tests.rs"]
1114mod tests;