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