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 const CHUNK: usize = 900;
790 let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
791
792 let mut result = Vec::with_capacity(ids.len());
793 for chunk in id_strings.chunks(CHUNK) {
794 let chunk_owned = chunk.to_vec();
795 let notes = self
796 .with_reader("get_notes_batch", move |conn| {
797 let placeholders: String = (1..=chunk_owned.len())
798 .map(|i| format!("?{i}"))
799 .collect::<Vec<_>>()
800 .join(", ");
801 let sql = format!(
802 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
803 properties, created_at, updated_at, deleted_at \
804 FROM notes WHERE id IN ({placeholders}) AND deleted_at IS NULL"
805 );
806 let mut stmt = conn.prepare(&sql)?;
807 let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
808 .iter()
809 .map(|s| s as &dyn rusqlite::types::ToSql)
810 .collect();
811 let rows = stmt.query_map(params.as_slice(), read_note)?;
812 let mut notes = Vec::new();
813 for row in rows {
814 notes.push(row?);
815 }
816 Ok(notes)
817 })
818 .await?;
819 result.extend(notes);
820 }
821 Ok(result)
822 }
823
824 async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
825 match mode {
826 DeleteMode::Soft => {
827 let now = chrono::Utc::now().timestamp_micros();
828 let statement = note_soft_delete_statement(id, now);
829 self.with_writer("delete_note_soft", move |conn| {
830 let mut stmt = conn.prepare(&statement.sql)?;
831 bind_params(&mut stmt, &statement.params)?;
832 Ok(stmt.raw_execute()? > 0)
833 })
834 .await
835 }
836 DeleteMode::Hard => {
837 let statement = note_hard_delete_statement(id);
838 self.with_writer("delete_note_hard", move |conn| {
839 let mut stmt = conn.prepare(&statement.sql)?;
840 bind_params(&mut stmt, &statement.params)?;
841 Ok(stmt.raw_execute()? > 0)
842 })
843 .await
844 }
845 }
846 }
847
848 async fn query_notes(
849 &self,
850 namespace: &str,
851 kind: Option<&str>,
852 page: PageRequest,
853 ) -> Result<Page<Note>, StorageError> {
854 let namespace = namespace.to_string();
855 let kind = kind.map(|k| k.to_string());
856 let limit_i64 = i64::from(page.limit);
857 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
858 capability: StorageCapability::Notes,
859 operation: "query_notes".into(),
860 message: format!(
861 "PageRequest: offset must be <= i64::MAX, got {}",
862 page.offset
863 ),
864 })?;
865
866 self.with_reader("query_notes", move |conn| {
867 let (count_sql, count_params) = build_note_where(&namespace, kind.as_deref());
868 let total: i64 = {
869 let sql = format!("SELECT COUNT(*) FROM notes{}", count_sql);
870 let mut stmt = conn.prepare(&sql)?;
871 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
872 count_params.iter().map(|p| p.as_ref()).collect();
873 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
874 };
875
876 let (where_sql, mut data_params) = build_note_where(&namespace, kind.as_deref());
877 data_params.push(Box::new(limit_i64));
878 data_params.push(Box::new(offset_i64));
879
880 let limit_idx = data_params.len() - 1;
881 let offset_idx = data_params.len();
882
883 let data_sql = format!(
884 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
885 properties, created_at, updated_at, deleted_at \
886 FROM notes{} ORDER BY created_at DESC LIMIT ?{} OFFSET ?{}",
887 where_sql, limit_idx, offset_idx,
888 );
889
890 let mut stmt = conn.prepare(&data_sql)?;
891 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
892 data_params.iter().map(|p| p.as_ref()).collect();
893 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
894
895 let mut items = Vec::new();
896 for row in rows {
897 items.push(row?);
898 }
899
900 Ok(Page {
901 items,
902 total: Some(total as u64),
903 })
904 })
905 .await
906 }
907
908 async fn query_notes_filtered(
909 &self,
910 namespace: &str,
911 filter: &NoteFilter,
912 page: PageRequest,
913 ) -> Result<Page<Note>, StorageError> {
914 for pf in &filter.property_filters {
916 validate_json_path(&pf.json_path)?;
917 }
918 if let Some((path, _)) = &filter.order_by {
919 validate_json_path(path)?;
920 }
921
922 let namespace = namespace.to_string();
923 let filter = filter.clone();
924 let limit_i64 = i64::from(page.limit);
925 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
926 capability: StorageCapability::Notes,
927 operation: "query_notes_filtered".into(),
928 message: format!(
929 "PageRequest: offset must be <= i64::MAX, got {}",
930 page.offset
931 ),
932 })?;
933
934 self.with_reader("query_notes_filtered", move |conn| {
935 let (count_sql, count_params) = build_note_filter_where(&namespace, &filter)?;
936 let total: i64 = {
937 let sql = format!("SELECT COUNT(*) FROM notes{}", count_sql);
938 let mut stmt = conn.prepare(&sql)?;
939 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
940 count_params.iter().map(|p| p.as_ref()).collect();
941 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
942 };
943
944 let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
945 data_params.push(Box::new(limit_i64));
946 data_params.push(Box::new(offset_i64));
947
948 let order_clause = match &filter.order_by {
949 Some((path, dir)) => {
950 let dir_str = match dir {
951 SortDir::Asc => "ASC",
952 SortDir::Desc => "DESC",
953 };
954 format!(" ORDER BY {} {dir_str}", json_extract_expr(path))
955 }
956 None => " ORDER BY created_at DESC".to_string(),
957 };
958
959 let limit_idx = data_params.len() - 1;
960 let offset_idx = data_params.len();
961 let data_sql = format!(
962 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
963 expires_at, properties, created_at, updated_at, deleted_at \
964 FROM notes{}{order_clause} LIMIT ?{} OFFSET ?{}",
965 where_sql, limit_idx, offset_idx,
966 );
967
968 let mut stmt = conn.prepare(&data_sql)?;
969 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
970 data_params.iter().map(|p| p.as_ref()).collect();
971 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
972
973 let mut items = Vec::new();
974 for row in rows {
975 items.push(row?);
976 }
977
978 Ok(Page {
979 items,
980 total: Some(total as u64),
981 })
982 })
983 .await
984 }
985
986 async fn query_notes_filtered_bounded(
987 &self,
988 namespace: &str,
989 filter: &NoteFilter,
990 max_rows: u32,
991 ) -> Result<Vec<Note>, StorageError> {
992 for pf in &filter.property_filters {
993 validate_json_path(&pf.json_path)?;
994 }
995 if let Some((path, _)) = &filter.order_by {
996 validate_json_path(path)?;
997 }
998
999 let namespace = namespace.to_string();
1000 let filter = filter.clone();
1001 let limit_i64 = i64::from(max_rows) + 1;
1002
1003 self.with_reader("query_notes_filtered_bounded", move |conn| {
1004 let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
1005 data_params.push(Box::new(limit_i64));
1006 let limit_idx = data_params.len();
1007
1008 let order_clause = match &filter.order_by {
1012 Some((path, dir)) => {
1013 let dir_str = match dir {
1014 SortDir::Asc => "ASC",
1015 SortDir::Desc => "DESC",
1016 };
1017 format!(" ORDER BY {} {dir_str}, id ASC", json_extract_expr(path))
1018 }
1019 None => " ORDER BY created_at DESC, id ASC".to_string(),
1020 };
1021
1022 let data_sql = format!(
1023 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1024 expires_at, properties, created_at, updated_at, deleted_at \
1025 FROM notes{where_sql}{order_clause} LIMIT ?{limit_idx}",
1026 );
1027
1028 let mut stmt = conn.prepare(&data_sql)?;
1029 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1030 data_params.iter().map(|p| p.as_ref()).collect();
1031 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1032
1033 let mut items = Vec::new();
1034 for row in rows {
1035 items.push(row?);
1036 }
1037 Ok(items)
1038 })
1039 .await
1040 }
1041
1042 async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> Result<u64, StorageError> {
1043 let namespace = namespace.to_string();
1044 let kind = kind.map(|k| k.to_string());
1045
1046 self.with_reader("count_notes", move |conn| {
1047 let (where_sql, params) = build_note_where(&namespace, kind.as_deref());
1048 let sql = format!("SELECT COUNT(*) FROM notes{}", where_sql);
1049 let mut stmt = conn.prepare(&sql)?;
1050 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1051 params.iter().map(|p| p.as_ref()).collect();
1052 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1053 Ok(count as u64)
1054 })
1055 .await
1056 }
1057}
1058
1059const NOTES_DDL: &str = include_str!("../../sql/notes-ddl.sql");
1064
1065const NOTES_SEQ_REPAIR_DDL: &str = include_str!("../../sql/008-notes-seq-repair.sql");
1072
1073pub(crate) fn ensure_notes_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1074 conn.execute_batch(NOTES_DDL)
1075}
1076
1077pub(crate) fn repair_notes_seq(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1083 conn.execute_batch(NOTES_SEQ_REPAIR_DDL)
1084}
1085
1086#[cfg(test)]
1087#[path = "note_tests.rs"]
1088mod tests;