1use std::collections::HashSet;
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use rusqlite::OptionalExtension;
8use uuid::Uuid;
9
10use khive_storage::attachment::AttachmentSubstrate;
11use khive_storage::error::{StorageError, WriterTaskRequestState};
12use khive_storage::note::{FilterOp, Note, NoteFilter, SortDir};
13use khive_storage::types::{
14 BatchWriteSummary, DeleteMode, Page, PageRequest, SeekCursor, SeekPage, SqlStatement, SqlValue,
15};
16use khive_storage::NoteStore;
17use khive_storage::StorageCapability;
18
19use crate::error::SqliteError;
20use crate::pool::ConnectionPool;
21use crate::sql_bridge::bind_params;
22use crate::stores::attachment::delete_record_attachments_statement;
23use crate::writer_task::{execute_wrapped_transaction, WriterTaskHandle};
24
25fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
26 StorageError::driver(StorageCapability::Notes, op, e)
27}
28
29fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
30 StorageError::driver(StorageCapability::Notes, op, e)
31}
32
33const NAMESPACE_COUNT_CHUNK_SIZE: usize = 500;
34
35pub const NOTE_UPSERT_SQL: &str = "INSERT INTO notes \
53 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
54 properties, created_at, updated_at, deleted_at) \
55 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
56 ON CONFLICT(id) DO UPDATE SET \
57 namespace = excluded.namespace, \
58 kind = excluded.kind, \
59 status = excluded.status, \
60 name = excluded.name, \
61 content = excluded.content, \
62 salience = excluded.salience, \
63 decay_factor = excluded.decay_factor, \
64 expires_at = excluded.expires_at, \
65 properties = excluded.properties, \
66 updated_at = excluded.updated_at, \
67 deleted_at = excluded.deleted_at";
68
69pub const NOTE_INSERT_IF_ABSENT_SQL: &str = "INSERT INTO notes \
78 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
79 properties, created_at, updated_at, deleted_at) \
80 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
81 ON CONFLICT(id) DO NOTHING";
82
83pub fn note_insert_if_absent_statement(note: &Note) -> SqlStatement {
85 let mut statement = note_upsert_statement(note);
86 statement.sql = NOTE_INSERT_IF_ABSENT_SQL.to_string();
87 statement.label = Some("note-insert-if-absent".to_string());
88 statement
89}
90
91pub fn note_upsert_statement(note: &Note) -> SqlStatement {
93 let properties_str = note
94 .properties
95 .as_ref()
96 .map(|v| serde_json::to_string(v).unwrap_or_default());
97 SqlStatement {
98 sql: NOTE_UPSERT_SQL.to_string(),
99 params: vec![
100 SqlValue::Text(note.id.to_string()),
101 SqlValue::Text(note.namespace.clone()),
102 SqlValue::Text(note.kind.to_string()),
103 SqlValue::Text(note.status.clone()),
104 match ¬e.name {
105 Some(n) => SqlValue::Text(n.clone()),
106 None => SqlValue::Null,
107 },
108 SqlValue::Text(note.content.clone()),
109 match note.salience {
110 Some(s) => SqlValue::Float(s),
111 None => SqlValue::Null,
112 },
113 match note.decay_factor {
114 Some(d) => SqlValue::Float(d),
115 None => SqlValue::Null,
116 },
117 match note.expires_at {
118 Some(e) => SqlValue::Integer(e),
119 None => SqlValue::Null,
120 },
121 match properties_str {
122 Some(p) => SqlValue::Text(p),
123 None => SqlValue::Null,
124 },
125 SqlValue::Integer(note.created_at),
126 SqlValue::Integer(note.updated_at),
127 match note.deleted_at {
128 Some(d) => SqlValue::Integer(d),
129 None => SqlValue::Null,
130 },
131 ],
132 label: Some("note-upsert".to_string()),
133 }
134}
135
136pub fn note_replace_if_unchanged_statement(
143 note: &Note,
144 expected_updated_at: i64,
145 expected_deleted_at: Option<i64>,
146) -> SqlStatement {
147 let properties_str = note
148 .properties
149 .as_ref()
150 .map(|v| serde_json::to_string(v).unwrap_or_default());
151 SqlStatement {
152 sql: "UPDATE notes SET \
153 namespace = ?1, kind = ?2, status = ?3, name = ?4, content = ?5, \
154 salience = ?6, decay_factor = ?7, expires_at = ?8, properties = ?9, \
155 updated_at = ?10, deleted_at = ?11 \
156 WHERE id = ?12 AND updated_at = ?13 AND deleted_at IS ?14 \
157 AND ?10 > updated_at"
158 .to_string(),
159 params: vec![
160 SqlValue::Text(note.namespace.clone()),
161 SqlValue::Text(note.kind.to_string()),
162 SqlValue::Text(note.status.clone()),
163 match ¬e.name {
164 Some(name) => SqlValue::Text(name.clone()),
165 None => SqlValue::Null,
166 },
167 SqlValue::Text(note.content.clone()),
168 match note.salience {
169 Some(value) => SqlValue::Float(value),
170 None => SqlValue::Null,
171 },
172 match note.decay_factor {
173 Some(value) => SqlValue::Float(value),
174 None => SqlValue::Null,
175 },
176 match note.expires_at {
177 Some(value) => SqlValue::Integer(value),
178 None => SqlValue::Null,
179 },
180 match properties_str {
181 Some(value) => SqlValue::Text(value),
182 None => SqlValue::Null,
183 },
184 SqlValue::Integer(note.updated_at),
185 match note.deleted_at {
186 Some(value) => SqlValue::Integer(value),
187 None => SqlValue::Null,
188 },
189 SqlValue::Text(note.id.to_string()),
190 SqlValue::Integer(expected_updated_at),
191 match expected_deleted_at {
192 Some(value) => SqlValue::Integer(value),
193 None => SqlValue::Null,
194 },
195 ],
196 label: Some("note-replace-if-unchanged".to_string()),
197 }
198}
199
200pub fn note_update_properties_statement(
208 id: Uuid,
209 properties: &Option<serde_json::Value>,
210 updated_at: i64,
211) -> SqlStatement {
212 let properties_str = properties
213 .as_ref()
214 .map(|v| serde_json::to_string(v).unwrap_or_default());
215 SqlStatement {
216 sql: "UPDATE notes SET properties = ?1, updated_at = ?2 \
217 WHERE id = ?3 AND deleted_at IS NULL"
218 .to_string(),
219 params: vec![
220 match properties_str {
221 Some(p) => SqlValue::Text(p),
222 None => SqlValue::Null,
223 },
224 SqlValue::Integer(updated_at),
225 SqlValue::Text(id.to_string()),
226 ],
227 label: Some("note-update-properties".to_string()),
228 }
229}
230
231pub fn note_set_property_statement(
240 id: Uuid,
241 key: &str,
242 value: &serde_json::Value,
243 updated_at: i64,
244) -> Result<SqlStatement, StorageError> {
245 if key.contains('\0') {
249 return Err(StorageError::InvalidInput {
250 capability: StorageCapability::Notes,
251 operation: "set_note_property".into(),
252 message: "property key must not contain U+0000".to_string(),
253 });
254 }
255 let path = format!("$.{}", serde_json::Value::String(key.to_string()));
256 Ok(SqlStatement {
257 sql: "UPDATE notes \
258 SET properties = json_set(COALESCE(properties, '{}'), ?1, json(?2)), \
259 updated_at = ?3 \
260 WHERE id = ?4 AND deleted_at IS NULL \
261 AND (properties IS NULL OR json_type(properties) = 'object')"
262 .to_string(),
263 params: vec![
264 SqlValue::Text(path),
265 SqlValue::Text(value.to_string()),
266 SqlValue::Integer(updated_at),
267 SqlValue::Text(id.to_string()),
268 ],
269 label: Some("note-set-property".to_string()),
270 })
271}
272
273pub fn note_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
275 SqlStatement {
276 sql: "UPDATE notes SET status = 'deleted', deleted_at = ?1 \
277 WHERE id = ?2 AND deleted_at IS NULL"
278 .to_string(),
279 params: vec![
280 SqlValue::Integer(deleted_at),
281 SqlValue::Text(id.to_string()),
282 ],
283 label: Some("note-delete-soft".to_string()),
284 }
285}
286
287pub fn note_hard_delete_statement(id: Uuid) -> SqlStatement {
289 SqlStatement {
290 sql: "DELETE FROM notes WHERE id = ?1".to_string(),
291 params: vec![SqlValue::Text(id.to_string())],
292 label: Some("note-delete-hard".to_string()),
293 }
294}
295
296pub struct SqlNoteStore {
301 pool: Arc<ConnectionPool>,
302 is_file_backed: bool,
303 writer_task: Option<WriterTaskHandle>,
304}
305
306impl SqlNoteStore {
307 pub fn new(pool: Arc<ConnectionPool>, is_file_backed: bool) -> Self {
309 let writer_task = pool.writer_task_handle().ok().flatten();
316
317 Self {
318 pool,
319 is_file_backed,
320 writer_task,
321 }
322 }
323
324 fn current_writer_task(
325 &self,
326 operation: &'static str,
327 ) -> Result<Option<WriterTaskHandle>, StorageError> {
328 self.pool
329 .writer_task_for_write(self.writer_task.as_ref(), operation)
330 }
331
332 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
351 where
352 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
353 R: Send + 'static,
354 {
355 if let Some(writer_task) = self.current_writer_task(op)? {
356 return writer_task
357 .send_bounded(move |conn| f(conn).map_err(|e| map_err(e, op)))
358 .await;
359 }
360
361 self.pool
362 .record_direct_route(crate::timeout_sink::Site::DirectRouteNote);
363 let pool = Arc::clone(&self.pool);
364 tokio::task::spawn_blocking(move || {
365 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
366 f(guard.conn()).map_err(|e| map_err(e, op))
367 })
368 .await
369 .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
370 }
371
372 async fn with_writer_tx<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
384 where
385 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
386 R: Send + 'static,
387 {
388 self.with_writer_tx_storage(op, move |conn| f(conn).map_err(|error| map_err(error, op)))
389 .await
390 }
391
392 async fn with_writer_tx_storage<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
393 where
394 F: FnOnce(&rusqlite::Connection) -> Result<R, StorageError> + Send + 'static,
395 R: Send + 'static,
396 {
397 if let Some(writer_task) = self.current_writer_task(op)? {
398 return writer_task.send_bounded(f).await;
399 }
400
401 self.pool
402 .record_direct_route(crate::timeout_sink::Site::DirectRouteNote);
403 let pool = Arc::clone(&self.pool);
404 tokio::task::spawn_blocking(move || {
405 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
406 let conn = guard.conn();
407 if !conn.is_autocommit() {
408 pool.retire_pooled_writer(conn);
409 return Err(StorageError::WriterTaskTerminated {
410 request_state: WriterTaskRequestState::SideEffectsUnknown,
411 });
412 }
413 if let Err(begin_error) = conn.execute_batch("BEGIN IMMEDIATE") {
414 if !conn.is_autocommit() {
415 pool.retire_pooled_writer(conn);
416 return Err(StorageError::WriterTaskTerminated {
417 request_state: WriterTaskRequestState::SideEffectsUnknown,
418 });
419 }
420 return Err(map_err(begin_error, op));
421 }
422
423 let (result, terminal_state) = execute_wrapped_transaction(conn, op, f);
424 if terminal_state.is_some() {
425 pool.retire_pooled_writer(conn);
426 }
427 result
428 })
429 .await
430 .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
431 }
432
433 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
434 where
435 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
436 R: Send + 'static,
437 {
438 if self.is_file_backed {
439 let pool = Arc::clone(&self.pool);
440 crate::read_cancellation::run_declared_interruptible_read(
441 StorageCapability::Notes,
442 op,
443 move |scope| {
444 scope.ensure_active()?;
445 let conn = pool
446 .open_standalone_reader()
447 .map_err(|error| map_sqlite_err(error, op))?;
448 scope.run(&conn, || f(&conn).map_err(|e| map_err(e, op)))
449 },
450 )
451 .await
452 } else {
453 let pool = Arc::clone(&self.pool);
454 crate::read_cancellation::run_declared_interruptible_read(
455 StorageCapability::Notes,
456 op,
457 move |scope| {
458 let mut guard = pool.resolve_reader_checkout(
459 StorageCapability::Notes,
460 op,
461 pool.reader_until(|| scope.should_stop()),
462 )?;
463 scope.run_pooled_reader(&mut guard, |conn| f(conn).map_err(|e| map_err(e, op)))
464 },
465 )
466 .await
467 }
468 }
469}
470
471fn read_note(row: &rusqlite::Row<'_>) -> Result<Note, rusqlite::Error> {
476 let id_str: String = row.get(0)?;
477 let namespace: String = row.get(1)?;
478 let kind: String = row.get(2)?;
479 let status: String = row.get(3)?;
480 let name: Option<String> = row.get(4)?;
481 let content: String = row.get(5)?;
482 let salience: Option<f64> = row.get(6)?;
483 let decay_factor: Option<f64> = row.get(7)?;
484 let expires_at: Option<i64> = row.get(8)?;
485 let properties_str: Option<String> = row.get(9)?;
486 let created_at: i64 = row.get(10)?;
487 let updated_at: i64 = row.get(11)?;
488 let deleted_at: Option<i64> = row.get(12)?;
489
490 let id = parse_uuid(&id_str)?;
491
492 let properties = properties_str
493 .map(|s| {
494 serde_json::from_str(&s).map_err(|e| {
495 rusqlite::Error::FromSqlConversionFailure(
496 9,
497 rusqlite::types::Type::Text,
498 Box::new(e),
499 )
500 })
501 })
502 .transpose()?;
503
504 Ok(Note {
505 id,
506 namespace,
507 kind,
508 status,
509 name,
510 content,
511 salience,
512 decay_factor,
513 expires_at,
514 properties,
515 created_at,
516 updated_at,
517 deleted_at,
518 })
519}
520
521fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
522 Uuid::parse_str(s).map_err(|e| {
523 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
524 })
525}
526
527fn query_note_page_snapshot(
528 conn: &rusqlite::Connection,
529 operation: &'static str,
530 namespace: &str,
531 count_sql: &str,
532 count_params: &[Box<dyn rusqlite::types::ToSql>],
533 data_sql: &str,
534 data_params: &[Box<dyn rusqlite::types::ToSql>],
535) -> Result<Page<Note>, rusqlite::Error> {
536 let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Deferred)?;
537
538 let total: i64 = {
539 let mut stmt = tx.prepare(count_sql)?;
540 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
541 count_params.iter().map(|param| param.as_ref()).collect();
542 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
543 };
544
545 #[cfg(test)]
546 tests::page_snapshot_seam::hook(operation, namespace);
547 #[cfg(not(test))]
548 let _ = (operation, namespace);
549
550 let items = {
551 let mut stmt = tx.prepare(data_sql)?;
552 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
553 data_params.iter().map(|param| param.as_ref()).collect();
554 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
555 rows.collect::<Result<Vec<_>, _>>()?
556 };
557
558 tx.commit()?;
559 Ok(Page {
560 items,
561 total: Some(total as u64),
562 })
563}
564
565fn batch_upsert_notes(
573 conn: &rusqlite::Connection,
574 notes: &[Note],
575 attempted: u64,
576) -> Result<BatchWriteSummary, rusqlite::Error> {
577 let mut affected = 0u64;
578 let mut failed = 0u64;
579 let mut first_error = String::new();
580
581 let mut stmt = conn.prepare_cached(NOTE_UPSERT_SQL)?;
586
587 for note in notes {
588 let id_str = note.id.to_string();
589 let kind_str = note.kind.to_string();
590 let status_str = note.status.clone();
591 let properties_str = note
592 .properties
593 .as_ref()
594 .map(|v| serde_json::to_string(v).unwrap_or_default());
595
596 match stmt.execute(rusqlite::params![
597 id_str,
598 ¬e.namespace,
599 kind_str,
600 status_str,
601 ¬e.name,
602 note.content,
603 note.salience,
604 note.decay_factor,
605 note.expires_at,
606 properties_str,
607 note.created_at,
608 note.updated_at,
609 note.deleted_at,
610 ]) {
611 Ok(_) => {
612 assign_note_seq(conn, &id_str)?;
613 affected += 1;
614 }
615 Err(e) => {
616 if first_error.is_empty() {
617 first_error = e.to_string();
618 }
619 failed += 1;
620 }
621 }
622 }
623
624 Ok(BatchWriteSummary {
625 attempted,
626 affected,
627 failed,
628 first_error,
629 })
630}
631
632fn assign_note_seq(conn: &rusqlite::Connection, note_id: &str) -> Result<(), rusqlite::Error> {
638 conn.execute(
639 "INSERT OR IGNORE INTO notes_seq (note_id) VALUES (?1)",
640 rusqlite::params![note_id],
641 )?;
642 Ok(())
643}
644
645fn build_note_where(
646 namespace: &str,
647 kind: Option<&str>,
648) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
649 let mut conditions: Vec<String> = vec![
650 "namespace = ?1".to_string(),
651 "deleted_at IS NULL".to_string(),
652 ];
653 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(namespace.to_string())];
654
655 if let Some(k) = kind {
656 params.push(Box::new(k.to_string()));
657 conditions.push(format!("kind = ?{}", params.len()));
658 }
659
660 let clause = format!(" WHERE {}", conditions.join(" AND "));
661 (clause, params)
662}
663
664fn build_note_where_for_namespaces(
665 namespaces: &[String],
666 kind: Option<&str>,
667) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
668 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = namespaces
669 .iter()
670 .map(|namespace| -> Box<dyn rusqlite::types::ToSql> { Box::new(namespace.clone()) })
671 .collect();
672 let namespace_condition = match namespaces.len() {
673 0 => "0".to_string(),
674 1 => "namespace = ?1".to_string(),
675 _ => {
676 let placeholders: Vec<String> =
677 (1..=namespaces.len()).map(|i| format!("?{i}")).collect();
678 format!("namespace IN ({})", placeholders.join(", "))
679 }
680 };
681 let mut conditions = vec![namespace_condition, "deleted_at IS NULL".to_string()];
682
683 if let Some(kind) = kind {
684 params.push(Box::new(kind.to_string()));
685 conditions.push(format!("kind = ?{}", params.len()));
686 }
687
688 let clause = format!(" WHERE {}", conditions.join(" AND "));
689 (clause, params)
690}
691
692fn validate_json_path(path: &str) -> Result<(), StorageError> {
695 let valid = path.starts_with("$.")
696 && path[2..].split('.').all(|part| {
697 !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
698 });
699 if valid {
700 Ok(())
701 } else {
702 Err(StorageError::InvalidInput {
703 capability: StorageCapability::Notes,
704 operation: "query_notes_filtered".into(),
705 message: format!("invalid JSON path for note filter: {path:?}"),
706 })
707 }
708}
709
710fn json_extract_expr(path: &str) -> String {
711 format!("json_extract(properties, '{path}')")
712}
713
714fn json_type_expr(path: &str) -> String {
715 format!("json_type(properties, '{path}')")
716}
717
718fn sql_value_param(value: &SqlValue) -> Result<Box<dyn rusqlite::types::ToSql>, rusqlite::Error> {
719 Ok(match value {
720 SqlValue::Null => Box::new(Option::<String>::None),
721 SqlValue::Bool(v) => Box::new(*v as i64),
722 SqlValue::Integer(v) => Box::new(*v),
723 SqlValue::Float(v) => Box::new(*v),
724 SqlValue::Text(v) => Box::new(v.clone()),
725 SqlValue::Blob(v) => Box::new(v.clone()),
726 SqlValue::Json(v) => Box::new(
727 serde_json::to_string(v)
728 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
729 ),
730 SqlValue::Uuid(v) => Box::new(v.to_string()),
731 SqlValue::Timestamp(v) => Box::new(v.timestamp_micros()),
732 })
733}
734
735fn build_note_filter_where(
736 namespace: &str,
737 filter: &NoteFilter,
738) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
739 let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
742 if !filter.namespaces.is_empty() {
743 let placeholders: Vec<String> = (1..=filter.namespaces.len())
744 .map(|i| format!("?{i}"))
745 .collect();
746 let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
747 .namespaces
748 .iter()
749 .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
750 .collect();
751 (
752 format!("namespace IN ({})", placeholders.join(", ")),
753 params,
754 )
755 } else {
756 (
757 "namespace = ?1".to_string(),
758 vec![Box::new(namespace.to_string())],
759 )
760 };
761
762 let mut conditions = vec![ns_condition, "deleted_at IS NULL".to_string()];
763 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
764
765 if let Some(kind) = &filter.kind {
766 params.push(Box::new(kind.clone()));
767 conditions.push(format!("kind = ?{}", params.len()));
768 }
769
770 for pf in &filter.property_filters {
771 match &pf.op {
772 FilterOp::EqOrMissing => {
773 let expr = json_extract_expr(&pf.json_path);
774 params.push(sql_value_param(&pf.value)?);
775 conditions.push(format!(
776 "({expr} = ?{n} OR {expr} IS NULL)",
777 n = params.len()
778 ));
779 }
780 FilterOp::TextEqOrNonText => {
781 let expr = json_extract_expr(&pf.json_path);
782 let type_expr = json_type_expr(&pf.json_path);
783 params.push(sql_value_param(&pf.value)?);
784 let n = params.len();
785 conditions.push(format!(
786 "CASE WHEN {type_expr} = 'text' THEN {expr} ELSE ?{n} END = ?{n}"
787 ));
788 }
789 FilterOp::JsonTypeEq => {
790 let type_expr = json_type_expr(&pf.json_path);
791 params.push(sql_value_param(&pf.value)?);
792 conditions.push(format!("{type_expr} = ?{}", params.len()));
793 }
794 FilterOp::JsonTypeNeMissing => {
795 let type_expr = json_type_expr(&pf.json_path);
796 params.push(sql_value_param(&pf.value)?);
797 let n = params.len();
798 conditions.push(format!("({type_expr} IS NULL OR {type_expr} != ?{n})"));
799 }
800 FilterOp::In(values) => {
801 let expr = json_extract_expr(&pf.json_path);
802 if values.is_empty() {
803 conditions.push("0".to_string());
805 continue;
806 }
807 let mut placeholders = Vec::with_capacity(values.len());
808 for v in values {
809 params.push(sql_value_param(v)?);
810 placeholders.push(format!("?{}", params.len()));
811 }
812 conditions.push(format!("{expr} IN ({})", placeholders.join(", ")));
813 }
814 FilterOp::NotInOrMissing(values) => {
815 let expr = json_extract_expr(&pf.json_path);
816 if values.is_empty() {
817 continue;
819 }
820 let mut placeholders = Vec::with_capacity(values.len());
821 for v in values {
822 params.push(sql_value_param(v)?);
823 placeholders.push(format!("?{}", params.len()));
824 }
825 conditions.push(format!(
826 "({expr} IS NULL OR {expr} NOT IN ({}))",
827 placeholders.join(", ")
828 ));
829 }
830 _ => {
831 let expr = json_extract_expr(&pf.json_path);
832 let op = match pf.op {
833 FilterOp::Eq => "=",
834 FilterOp::Ne => "!=",
835 FilterOp::Lt => "<",
836 FilterOp::Lte => "<=",
837 FilterOp::Gt => ">",
838 FilterOp::Gte => ">=",
839 FilterOp::EqOrMissing
840 | FilterOp::TextEqOrNonText
841 | FilterOp::JsonTypeEq
842 | FilterOp::JsonTypeNeMissing
843 | FilterOp::In(_)
844 | FilterOp::NotInOrMissing(_) => {
845 unreachable!()
846 }
847 };
848 params.push(sql_value_param(&pf.value)?);
849 conditions.push(format!("{expr} {op} ?{}", params.len()));
850 }
851 }
852 }
853
854 if let Some(min_ts) = filter.min_created_at {
855 params.push(Box::new(min_ts));
856 conditions.push(format!("created_at >= ?{}", params.len()));
857 }
858
859 Ok((format!(" WHERE {}", conditions.join(" AND ")), params))
860}
861
862fn execute_filtered_note_property_patch(
863 conn: &rusqlite::Connection,
864 id: Uuid,
865 namespace: &str,
866 filter: &NoteFilter,
867 json_path: &str,
868 value_json: &str,
869 updated_at: i64,
870) -> Result<usize, rusqlite::Error> {
871 let (where_clause, mut params) = build_note_filter_where(namespace, filter)?;
872
873 let base = params.len();
874 let sql = format!(
875 "UPDATE notes SET properties = json_set(COALESCE(properties, '{{}}'), ?{p1}, json(?{p2})), \
876 updated_at = ?{p3} {where_clause} \
877 AND (properties IS NULL OR json_type(properties) = 'object') AND id = ?{p4}",
878 p1 = base + 1,
879 p2 = base + 2,
880 p3 = base + 3,
881 p4 = base + 4,
882 );
883 params.push(Box::new(json_path.to_string()));
884 params.push(Box::new(value_json.to_string()));
885 params.push(Box::new(updated_at));
886 params.push(Box::new(id.to_string()));
887
888 let mut stmt = conn.prepare_cached(&sql)?;
889 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
890 params.iter().map(|param| param.as_ref()).collect();
891 stmt.execute(param_refs.as_slice())
892}
893
894#[async_trait]
899impl NoteStore for SqlNoteStore {
900 async fn upsert_note(&self, note: Note) -> Result<(), StorageError> {
901 let id_str = note.id.to_string();
902 let statement = note_upsert_statement(¬e);
903 self.with_writer_tx("upsert_note", move |conn| {
904 let mut stmt = conn.prepare_cached(&statement.sql)?;
905 bind_params(&mut stmt, &statement.params)?;
906 stmt.raw_execute()?;
907 assign_note_seq(conn, &id_str)?;
908 Ok(())
909 })
910 .await
911 }
912
913 async fn insert_note_if_absent(&self, note: Note) -> Result<bool, StorageError> {
914 let id_str = note.id.to_string();
915 let statement = note_insert_if_absent_statement(¬e);
916 self.with_writer_tx("insert_note_if_absent", move |conn| {
917 let mut stmt = conn.prepare_cached(&statement.sql)?;
918 bind_params(&mut stmt, &statement.params)?;
919 let inserted = stmt.raw_execute()? > 0;
920 if inserted {
924 assign_note_seq(conn, &id_str)?;
925 }
926 Ok(inserted)
927 })
928 .await
929 }
930
931 async fn replace_note_if_unchanged(
932 &self,
933 note: Note,
934 expected_updated_at: i64,
935 expected_deleted_at: Option<i64>,
936 ) -> Result<bool, StorageError> {
937 let statement =
938 note_replace_if_unchanged_statement(¬e, expected_updated_at, expected_deleted_at);
939 self.with_writer("replace_note_if_unchanged", move |conn| {
940 let mut stmt = conn.prepare(&statement.sql)?;
941 bind_params(&mut stmt, &statement.params)?;
942 Ok(stmt.raw_execute()? > 0)
943 })
944 .await
945 }
946
947 async fn update_note_properties(
948 &self,
949 id: Uuid,
950 properties: Option<serde_json::Value>,
951 updated_at: i64,
952 ) -> Result<bool, StorageError> {
953 let statement = note_update_properties_statement(id, &properties, updated_at);
954 self.with_writer("update_note_properties", move |conn| {
955 let mut stmt = conn.prepare(&statement.sql)?;
956 bind_params(&mut stmt, &statement.params)?;
957 Ok(stmt.raw_execute()? > 0)
958 })
959 .await
960 }
961
962 async fn set_note_property(
963 &self,
964 id: Uuid,
965 key: &str,
966 value: serde_json::Value,
967 updated_at: i64,
968 ) -> Result<bool, StorageError> {
969 let statement = note_set_property_statement(id, key, &value, updated_at)?;
970 self.with_writer("set_note_property", move |conn| {
971 let mut stmt = conn.prepare(&statement.sql)?;
972 bind_params(&mut stmt, &statement.params)?;
973 Ok(stmt.raw_execute()? > 0)
974 })
975 .await
976 }
977
978 async fn try_patch_note_property(
979 &self,
980 id: Uuid,
981 namespace: &str,
982 filter: &NoteFilter,
983 json_path: &str,
984 value: serde_json::Value,
985 updated_at: i64,
986 ) -> Result<bool, StorageError> {
987 let namespace = namespace.to_string();
988 let filter = filter.clone();
989 let value_json = serde_json::to_string(&value).map_err(|e| {
990 StorageError::driver(StorageCapability::Notes, "try_patch_note_property", e)
991 })?;
992 let json_path = json_path.to_string();
993
994 self.with_writer("try_patch_note_property", move |conn| {
995 execute_filtered_note_property_patch(
996 conn,
997 id,
998 &namespace,
999 &filter,
1000 &json_path,
1001 &value_json,
1002 updated_at,
1003 )
1004 .map(|rows| rows > 0)
1005 })
1006 .await
1007 }
1008
1009 async fn patch_note_property_atomic(
1010 &self,
1011 mut ids: Vec<Uuid>,
1012 namespace: &str,
1013 filter: &NoteFilter,
1014 json_path: &str,
1015 value: serde_json::Value,
1016 updated_at: i64,
1017 ) -> Result<(), StorageError> {
1018 let mut seen = HashSet::with_capacity(ids.len());
1019 ids.retain(|id| seen.insert(*id));
1020 if ids.is_empty() {
1021 return Err(StorageError::InvalidInput {
1022 capability: StorageCapability::Notes,
1023 operation: "patch_note_property_atomic".into(),
1024 message: "at least one note id is required".to_string(),
1025 });
1026 }
1027
1028 let namespace = namespace.to_string();
1029 let filter = filter.clone();
1030 let value_json = serde_json::to_string(&value).map_err(|e| {
1031 StorageError::driver(StorageCapability::Notes, "patch_note_property_atomic", e)
1032 })?;
1033 let json_path = json_path.to_string();
1034
1035 self.with_writer_tx_storage("patch_note_property_atomic", move |conn| {
1036 for id in ids {
1037 let rows = execute_filtered_note_property_patch(
1038 conn,
1039 id,
1040 &namespace,
1041 &filter,
1042 &json_path,
1043 &value_json,
1044 updated_at,
1045 )
1046 .map_err(|error| map_err(error, "patch_note_property_atomic"))?;
1047 if rows != 1 {
1048 return Err(StorageError::Conflict {
1049 capability: StorageCapability::Notes,
1050 operation: "patch_note_property_atomic".into(),
1051 message: format!(
1052 "precondition failed for note {id}: guarded update changed {rows} rows; expected 1"
1053 ),
1054 });
1055 }
1056 }
1057 Ok(())
1058 })
1059 .await
1060 }
1061
1062 async fn try_insert_note(&self, note: Note) -> Result<bool, StorageError> {
1063 let namespace = note.namespace.clone();
1064 let id_str = note.id.to_string();
1065 let kind_str = note.kind.to_string();
1066 let status_str = note.status.clone();
1067 let properties_str = note
1068 .properties
1069 .as_ref()
1070 .map(|v| serde_json::to_string(v).unwrap_or_default());
1071
1072 let ext_id_opt: Option<String> = note
1074 .properties
1075 .as_ref()
1076 .and_then(|v| v.get("external_id"))
1077 .and_then(|v| v.as_str())
1078 .filter(|s| !s.is_empty())
1079 .map(|s| s.to_string());
1080
1081 self.with_writer_tx("try_insert_note", move |conn| {
1082 let rows = conn.execute(
1083 "INSERT OR IGNORE INTO notes \
1084 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
1085 properties, created_at, updated_at, deleted_at) \
1086 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
1087 rusqlite::params![
1088 id_str,
1089 namespace,
1090 kind_str,
1091 status_str,
1092 note.name,
1093 note.content,
1094 note.salience,
1095 note.decay_factor,
1096 note.expires_at,
1097 properties_str,
1098 note.created_at,
1099 note.updated_at,
1100 note.deleted_at,
1101 ],
1102 )?;
1103
1104 if rows > 0 {
1105 assign_note_seq(conn, &id_str)?;
1106 return Ok(true);
1107 }
1108
1109 if let Some(ref ext_id) = ext_id_opt {
1115 let is_dedup: bool = conn.query_row(
1116 "SELECT COUNT(*) > 0 FROM notes \
1117 WHERE namespace = ?1 \
1118 AND kind = ?2 \
1119 AND json_extract(properties, '$.external_id') = ?3 \
1120 AND deleted_at IS NULL",
1121 rusqlite::params![namespace, kind_str, ext_id],
1122 |row| row.get(0),
1123 )?;
1124 if is_dedup {
1125 return Ok(false);
1126 }
1127 }
1128
1129 Err(rusqlite::Error::SqliteFailure(
1132 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
1133 Some(
1134 "try_insert_note: INSERT ignored for a constraint other than \
1135 external_id dedup; not masking as deduplication"
1136 .to_string(),
1137 ),
1138 ))
1139 })
1140 .await
1141 }
1142
1143 async fn upsert_notes(&self, notes: Vec<Note>) -> Result<BatchWriteSummary, StorageError> {
1144 let attempted = notes.len() as u64;
1145
1146 let origin = self.pool.origin();
1157 self.with_writer_tx("upsert_notes", move |conn| {
1158 let _tx_handle = khive_storage::tx_registry::register_scoped(
1159 Some("note_upsert_batch".to_string()),
1160 origin,
1161 );
1162 batch_upsert_notes(conn, ¬es, attempted)
1163 })
1164 .await
1165 }
1166
1167 async fn get_note(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
1168 let id_str = id.to_string();
1169
1170 self.with_reader("get_note", move |conn| {
1171 let mut stmt = conn.prepare(
1172 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
1173 properties, created_at, updated_at, deleted_at \
1174 FROM notes WHERE id = ?1 AND deleted_at IS NULL",
1175 )?;
1176 let mut rows = stmt.query(rusqlite::params![id_str])?;
1177 match rows.next()? {
1178 Some(row) => Ok(Some(read_note(row)?)),
1179 None => Ok(None),
1180 }
1181 })
1182 .await
1183 }
1184
1185 async fn get_note_including_deleted(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
1186 let id_str = id.to_string();
1187
1188 self.with_reader("get_note_including_deleted", move |conn| {
1189 let mut stmt = conn.prepare(
1190 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
1191 properties, created_at, updated_at, deleted_at \
1192 FROM notes WHERE id = ?1",
1193 )?;
1194 let mut rows = stmt.query(rusqlite::params![id_str])?;
1195 match rows.next()? {
1196 Some(row) => Ok(Some(read_note(row)?)),
1197 None => Ok(None),
1198 }
1199 })
1200 .await
1201 }
1202
1203 async fn note_sequence(&self, id: Uuid) -> Result<Option<i64>, StorageError> {
1204 let id = id.to_string();
1205 self.with_reader("note_sequence", move |conn| {
1206 conn.query_row(
1207 "SELECT seq FROM notes_seq WHERE note_id = ?1",
1208 rusqlite::params![id],
1209 |row| row.get(0),
1210 )
1211 .optional()
1212 })
1213 .await
1214 }
1215
1216 async fn get_notes_batch(&self, ids: &[Uuid]) -> Result<Vec<Note>, StorageError> {
1217 if ids.is_empty() {
1218 return Ok(vec![]);
1219 }
1220 const CHUNK: usize = 900;
1223 let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
1224
1225 let mut result = Vec::with_capacity(ids.len());
1226 for chunk in id_strings.chunks(CHUNK) {
1227 let chunk_owned = chunk.to_vec();
1228 let notes = self
1229 .with_reader("get_notes_batch", move |conn| {
1230 let placeholders: String = (1..=chunk_owned.len())
1231 .map(|i| format!("?{i}"))
1232 .collect::<Vec<_>>()
1233 .join(", ");
1234 let sql = format!(
1235 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
1236 properties, created_at, updated_at, deleted_at \
1237 FROM notes WHERE id IN ({placeholders}) AND deleted_at IS NULL"
1238 );
1239 let mut stmt = conn.prepare(&sql)?;
1240 let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
1241 .iter()
1242 .map(|s| s as &dyn rusqlite::types::ToSql)
1243 .collect();
1244 let rows = stmt.query_map(params.as_slice(), read_note)?;
1245 let mut notes = Vec::new();
1246 for row in rows {
1247 notes.push(row?);
1248 }
1249 Ok(notes)
1250 })
1251 .await?;
1252 result.extend(notes);
1253 }
1254 Ok(result)
1255 }
1256
1257 async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
1258 match mode {
1259 DeleteMode::Soft => {
1260 let now = chrono::Utc::now().timestamp_micros();
1261 let statement = note_soft_delete_statement(id, now);
1262 self.with_writer("delete_note_soft", move |conn| {
1263 let mut stmt = conn.prepare(&statement.sql)?;
1264 bind_params(&mut stmt, &statement.params)?;
1265 Ok(stmt.raw_execute()? > 0)
1266 })
1267 .await
1268 }
1269 DeleteMode::Hard => {
1270 let note_statement = note_hard_delete_statement(id);
1271 let attachment_statement =
1272 delete_record_attachments_statement(id, AttachmentSubstrate::Note);
1273 self.with_writer_tx("delete_note_hard", move |conn| {
1274 let mut note_stmt = conn.prepare(¬e_statement.sql)?;
1275 bind_params(&mut note_stmt, ¬e_statement.params)?;
1276 let deleted = note_stmt.raw_execute()? > 0;
1277 drop(note_stmt);
1278 if deleted {
1279 let mut attachment_stmt = conn.prepare(&attachment_statement.sql)?;
1280 bind_params(&mut attachment_stmt, &attachment_statement.params)?;
1281 attachment_stmt.raw_execute()?;
1282 }
1283 Ok(deleted)
1284 })
1285 .await
1286 }
1287 }
1288 }
1289
1290 async fn query_notes(
1291 &self,
1292 namespace: &str,
1293 kind: Option<&str>,
1294 page: PageRequest,
1295 ) -> Result<Page<Note>, StorageError> {
1296 let namespace = namespace.to_string();
1297 let kind = kind.map(|k| k.to_string());
1298 let limit_i64 = i64::from(page.limit);
1299 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1300 capability: StorageCapability::Notes,
1301 operation: "query_notes".into(),
1302 message: format!(
1303 "PageRequest: offset must be <= i64::MAX, got {}",
1304 page.offset
1305 ),
1306 })?;
1307
1308 self.with_reader("query_notes", move |conn| {
1309 let (count_sql, count_params) = build_note_where(&namespace, kind.as_deref());
1310 let count_sql = format!("SELECT COUNT(*) FROM notes{count_sql}");
1311
1312 let (where_sql, mut data_params) = build_note_where(&namespace, kind.as_deref());
1313 data_params.push(Box::new(limit_i64));
1314 data_params.push(Box::new(offset_i64));
1315
1316 let limit_idx = data_params.len() - 1;
1317 let offset_idx = data_params.len();
1318
1319 let data_sql = format!(
1320 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
1321 properties, created_at, updated_at, deleted_at \
1322 FROM notes{} ORDER BY created_at DESC, id ASC LIMIT ?{} OFFSET ?{}",
1323 where_sql, limit_idx, offset_idx,
1324 );
1325
1326 query_note_page_snapshot(
1327 conn,
1328 "query_notes",
1329 &namespace,
1330 &count_sql,
1331 &count_params,
1332 &data_sql,
1333 &data_params,
1334 )
1335 })
1336 .await
1337 }
1338
1339 async fn query_notes_filtered(
1340 &self,
1341 namespace: &str,
1342 filter: &NoteFilter,
1343 page: PageRequest,
1344 ) -> Result<Page<Note>, StorageError> {
1345 for pf in &filter.property_filters {
1347 validate_json_path(&pf.json_path)?;
1348 }
1349 if let Some((path, _)) = &filter.order_by {
1350 validate_json_path(path)?;
1351 }
1352
1353 let namespace = namespace.to_string();
1354 let filter = filter.clone();
1355 let limit_i64 = i64::from(page.limit);
1356 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1357 capability: StorageCapability::Notes,
1358 operation: "query_notes_filtered".into(),
1359 message: format!(
1360 "PageRequest: offset must be <= i64::MAX, got {}",
1361 page.offset
1362 ),
1363 })?;
1364
1365 self.with_reader("query_notes_filtered", move |conn| {
1366 let (count_sql, count_params) = build_note_filter_where(&namespace, &filter)?;
1367 let count_sql = format!("SELECT COUNT(*) FROM notes{count_sql}");
1368
1369 let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
1370 data_params.push(Box::new(limit_i64));
1371 data_params.push(Box::new(offset_i64));
1372
1373 let order_clause = match &filter.order_by {
1374 Some((path, dir)) => {
1375 let dir_str = match dir {
1376 SortDir::Asc => "ASC",
1377 SortDir::Desc => "DESC",
1378 };
1379 format!(
1387 " ORDER BY {} {dir_str}, id {dir_str}",
1388 json_extract_expr(path)
1389 )
1390 }
1391 None => " ORDER BY created_at DESC, id ASC".to_string(),
1397 };
1398
1399 let limit_idx = data_params.len() - 1;
1400 let offset_idx = data_params.len();
1401 let data_sql = format!(
1402 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1403 expires_at, properties, created_at, updated_at, deleted_at \
1404 FROM notes{}{order_clause} LIMIT ?{} OFFSET ?{}",
1405 where_sql, limit_idx, offset_idx,
1406 );
1407
1408 query_note_page_snapshot(
1409 conn,
1410 "query_notes_filtered",
1411 &namespace,
1412 &count_sql,
1413 &count_params,
1414 &data_sql,
1415 &data_params,
1416 )
1417 })
1418 .await
1419 }
1420
1421 async fn query_notes_filtered_after(
1422 &self,
1423 namespace: &str,
1424 filter: &NoteFilter,
1425 after: Option<SeekCursor>,
1426 limit: u32,
1427 ) -> Result<SeekPage<Note>, StorageError> {
1428 if limit == 0 {
1429 return Ok(SeekPage::default());
1430 }
1431 if filter.order_by.is_some() {
1432 return Err(StorageError::InvalidInput {
1433 capability: StorageCapability::Notes,
1434 operation: "query_notes_filtered_after".into(),
1435 message: "custom order_by is not compatible with insertion-sequence pagination"
1436 .into(),
1437 });
1438 }
1439 for property_filter in &filter.property_filters {
1440 validate_json_path(&property_filter.json_path)?;
1441 }
1442
1443 let namespace = namespace.to_string();
1444 let filter = filter.clone();
1445 let limit_usize = limit as usize;
1446 let probe_limit_i64 = i64::from(limit) + 1;
1447 self.with_reader("query_notes_filtered_after", move |conn| {
1448 let (mut where_sql, mut params) = build_note_filter_where(&namespace, &filter)?;
1449 if let Some(cursor) = after {
1450 params.push(Box::new(cursor.sequence));
1451 where_sql.push_str(&format!(" AND notes_seq.seq > ?{}", params.len()));
1452 }
1453 params.push(Box::new(probe_limit_i64));
1454 let limit_idx = params.len();
1455 let sql = format!(
1458 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1459 expires_at, properties, created_at, updated_at, deleted_at, notes_seq.seq \
1460 FROM notes_seq CROSS JOIN notes ON notes.id = notes_seq.note_id{where_sql} \
1461 ORDER BY notes_seq.seq ASC LIMIT ?{limit_idx}"
1462 );
1463 let mut stmt = conn.prepare(&sql)?;
1464 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1465 params.iter().map(|param| param.as_ref()).collect();
1466 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1467 Ok((read_note(row)?, row.get::<_, i64>(13)?))
1468 })?;
1469 let mut entries = rows.collect::<Result<Vec<_>, _>>()?;
1470 let has_more = entries.len() > limit_usize;
1471 if has_more {
1472 entries.truncate(limit_usize);
1473 }
1474 let next_after = if has_more {
1475 entries.last().map(|(note, sequence)| SeekCursor {
1476 sequence: *sequence,
1477 id: note.id,
1478 })
1479 } else {
1480 None
1481 };
1482 let items = entries.into_iter().map(|(note, _)| note).collect();
1483 Ok(SeekPage { items, next_after })
1484 })
1485 .await
1486 }
1487
1488 async fn query_notes_filtered_bounded(
1489 &self,
1490 namespace: &str,
1491 filter: &NoteFilter,
1492 max_rows: u32,
1493 ) -> Result<Vec<Note>, StorageError> {
1494 for pf in &filter.property_filters {
1495 validate_json_path(&pf.json_path)?;
1496 }
1497 if let Some((path, _)) = &filter.order_by {
1498 validate_json_path(path)?;
1499 }
1500
1501 let namespace = namespace.to_string();
1502 let filter = filter.clone();
1503 let limit_i64 = i64::from(max_rows) + 1;
1504
1505 self.with_reader("query_notes_filtered_bounded", move |conn| {
1506 let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
1507 data_params.push(Box::new(limit_i64));
1508 let limit_idx = data_params.len();
1509
1510 let order_clause = match &filter.order_by {
1514 Some((path, dir)) => {
1515 let dir_str = match dir {
1516 SortDir::Asc => "ASC",
1517 SortDir::Desc => "DESC",
1518 };
1519 format!(" ORDER BY {} {dir_str}, id ASC", json_extract_expr(path))
1520 }
1521 None => " ORDER BY created_at DESC, id ASC".to_string(),
1522 };
1523
1524 let data_sql = format!(
1525 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1526 expires_at, properties, created_at, updated_at, deleted_at \
1527 FROM notes{where_sql}{order_clause} LIMIT ?{limit_idx}",
1528 );
1529
1530 let mut stmt = conn.prepare(&data_sql)?;
1531 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1532 data_params.iter().map(|p| p.as_ref()).collect();
1533 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1534
1535 let mut items = Vec::new();
1536 for row in rows {
1537 items.push(row?);
1538 }
1539 Ok(items)
1540 })
1541 .await
1542 }
1543
1544 async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> Result<u64, StorageError> {
1545 let namespace = namespace.to_string();
1546 let kind = kind.map(|k| k.to_string());
1547
1548 self.with_reader("count_notes", move |conn| {
1549 let (where_sql, params) = build_note_where(&namespace, kind.as_deref());
1550 let sql = format!("SELECT COUNT(*) FROM notes{}", where_sql);
1551 let mut stmt = conn.prepare(&sql)?;
1552 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1553 params.iter().map(|p| p.as_ref()).collect();
1554 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1555 Ok(count as u64)
1556 })
1557 .await
1558 }
1559
1560 async fn count_notes_in_namespaces(
1561 &self,
1562 namespaces: &[String],
1563 kind: Option<&str>,
1564 ) -> Result<u64, StorageError> {
1565 let namespaces: Vec<String> = namespaces
1566 .iter()
1567 .cloned()
1568 .collect::<HashSet<_>>()
1569 .into_iter()
1570 .collect();
1571 let kind = kind.map(str::to_string);
1572
1573 self.with_reader("count_notes_in_namespaces", move |conn| {
1574 let mut total = 0;
1575 for chunk in namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
1576 let (where_sql, params) = build_note_where_for_namespaces(chunk, kind.as_deref());
1577 let sql = format!("SELECT COUNT(*) FROM notes{where_sql}");
1578 let mut stmt = conn.prepare(&sql)?;
1579 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1580 params.iter().map(|p| p.as_ref()).collect();
1581 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1582 total += count as u64;
1583 }
1584 Ok(total)
1585 })
1586 .await
1587 }
1588}
1589
1590const NOTES_DDL: &str = include_str!("../../sql/notes-ddl.sql");
1595
1596const NOTES_SEQ_REPAIR_DDL: &str = include_str!("../../sql/008-notes-seq-repair.sql");
1603
1604pub(crate) fn ensure_notes_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1605 conn.execute_batch(NOTES_DDL)
1606}
1607
1608pub(crate) fn repair_notes_seq(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1614 conn.execute_batch(NOTES_SEQ_REPAIR_DDL)
1615}
1616
1617#[cfg(test)]
1618#[path = "note_tests.rs"]
1619mod tests;