1pub mod recipient;
4pub mod transport;
5
6use std::collections::HashSet;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use rusqlite::OptionalExtension;
11use uuid::Uuid;
12
13use khive_storage::attachment::{Attachment, AttachmentSubstrate};
14use khive_storage::error::StorageError;
15use khive_storage::note::{
16 FilterOp, Note, NoteFilter, NoteInstantSeekAfter, NoteKeyCursor, NoteSeekAfter, NoteTagMode,
17 NoteTimeOrder, NoteVisibility, SortDir,
18};
19use khive_storage::types::{
20 BatchWriteSummary, BoundedCount, DeleteMode, Page, PageRequest, SeekCursor, SeekPage,
21 SqlStatement, SqlValue,
22};
23use khive_storage::NoteStore;
24use khive_storage::{StorageCapability, StorageResult};
25
26use crate::error::SqliteError;
27use crate::pool::ConnectionPool;
28use crate::sql_bridge::bind_params;
29use crate::stores::attachment::{attachment_upsert_statement, delete_record_attachments_statement};
30use crate::writer_task::{execute_wrapped_transaction, WriterTaskHandle};
31
32fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
33 StorageError::driver(StorageCapability::Notes, op, e)
34}
35
36fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
37 e.into_storage_error(StorageCapability::Notes, op)
38}
39
40const NAMESPACE_COUNT_CHUNK_SIZE: usize = 500;
41
42pub fn note_key_prefix_successor(prefix: &str) -> Option<String> {
43 let mut chars: Vec<char> = prefix.chars().collect();
44 while let Some(last) = chars.pop() {
45 if last == char::MAX {
46 continue;
47 }
48 let next = if last == '\u{d7ff}' {
49 '\u{e000}'
50 } else {
51 char::from_u32(u32::from(last) + 1).expect("incremented non-max scalar")
52 };
53 chars.push(next);
54 return Some(chars.into_iter().collect());
55 }
56 None
57}
58
59pub const NOTE_UPSERT_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, key, strict_due_key, due_source) \
80 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) \
81 ON CONFLICT(id) DO UPDATE SET \
82 namespace = excluded.namespace, \
83 kind = excluded.kind, \
84 status = excluded.status, \
85 name = excluded.name, \
86 content = excluded.content, \
87 salience = excluded.salience, \
88 decay_factor = excluded.decay_factor, \
89 expires_at = excluded.expires_at, \
90 properties = excluded.properties, \
91 strict_due_key = excluded.strict_due_key, \
92 due_source = excluded.due_source, \
93 updated_at = excluded.updated_at, \
94 deleted_at = excluded.deleted_at";
95
96pub const NOTE_INSERT_IF_ABSENT_SQL: &str = "INSERT INTO notes \
105 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
106 properties, created_at, updated_at, deleted_at, key, strict_due_key, due_source) \
107 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) \
108 ON CONFLICT(id) DO NOTHING";
109
110pub fn note_insert_if_absent_statement(note: &Note) -> SqlStatement {
112 let mut statement = note_upsert_statement(note);
113 statement.sql = NOTE_INSERT_IF_ABSENT_SQL.to_string();
114 statement.label = Some("note-insert-if-absent".to_string());
115 statement
116}
117
118pub fn note_insert_keyed_statement(note: &Note) -> SqlStatement {
120 let mut statement = note_upsert_statement(note);
121 statement.sql = "INSERT INTO notes \
122 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
123 properties, created_at, updated_at, deleted_at, key, strict_due_key, due_source) \
124 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16) \
125 ON CONFLICT(namespace,kind,key) WHERE key IS NOT NULL AND deleted_at IS NULL DO NOTHING"
126 .into();
127 statement.label = Some("note-keyed-create".into());
128 statement
129}
130
131pub fn note_due_key_values(
135 properties: &Option<serde_json::Value>,
136) -> (Option<Vec<u8>>, Option<String>) {
137 let source = properties
138 .as_ref()
139 .and_then(|value| value.get("next_attempt_at"))
140 .and_then(|value| value.as_str());
141 match source.and_then(|text| crate::pool::strict_rfc3339_key(text).map(|key| (key, text))) {
142 Some((key, source)) => (Some(key), Some(source.to_string())),
143 None => (None, None),
144 }
145}
146
147pub fn note_upsert_statement(note: &Note) -> SqlStatement {
149 let (due_key, due_source) = note_due_key_values(¬e.properties);
150 let properties_str = note
151 .properties
152 .as_ref()
153 .map(|v| serde_json::to_string(v).unwrap_or_default());
154 SqlStatement {
155 sql: NOTE_UPSERT_SQL.to_string(),
156 params: vec![
157 SqlValue::Text(note.id.to_string()),
158 SqlValue::Text(note.namespace.clone()),
159 SqlValue::Text(note.kind.to_string()),
160 SqlValue::Text(note.status.clone()),
161 match ¬e.name {
162 Some(n) => SqlValue::Text(n.clone()),
163 None => SqlValue::Null,
164 },
165 SqlValue::Text(note.content.clone()),
166 match note.salience {
167 Some(s) => SqlValue::Float(s),
168 None => SqlValue::Null,
169 },
170 match note.decay_factor {
171 Some(d) => SqlValue::Float(d),
172 None => SqlValue::Null,
173 },
174 match note.expires_at {
175 Some(e) => SqlValue::Integer(e),
176 None => SqlValue::Null,
177 },
178 match properties_str {
179 Some(p) => SqlValue::Text(p),
180 None => SqlValue::Null,
181 },
182 SqlValue::Integer(note.created_at),
183 SqlValue::Integer(note.updated_at),
184 match note.deleted_at {
185 Some(d) => SqlValue::Integer(d),
186 None => SqlValue::Null,
187 },
188 match ¬e.key {
189 Some(key) => SqlValue::Text(key.clone()),
190 None => SqlValue::Null,
191 },
192 due_key.map_or(SqlValue::Null, SqlValue::Blob),
193 due_source.map_or(SqlValue::Null, SqlValue::Text),
194 ],
195 label: Some("note-upsert".to_string()),
196 }
197}
198
199pub fn note_replace_if_unchanged_statement(
206 note: &Note,
207 expected_updated_at: i64,
208 expected_deleted_at: Option<i64>,
209) -> SqlStatement {
210 let (due_key, due_source) = note_due_key_values(¬e.properties);
211 let properties_str = note
212 .properties
213 .as_ref()
214 .map(|v| serde_json::to_string(v).unwrap_or_default());
215 SqlStatement {
216 sql: "UPDATE notes SET \
217 namespace = ?1, kind = ?2, status = ?3, name = ?4, content = ?5, \
218 salience = ?6, decay_factor = ?7, expires_at = ?8, properties = ?9, \
219 updated_at = ?10, deleted_at = ?11, strict_due_key = ?15, due_source = ?16 \
220 WHERE id = ?12 AND updated_at = ?13 AND deleted_at IS ?14 \
221 AND ?10 > updated_at"
222 .to_string(),
223 params: vec![
224 SqlValue::Text(note.namespace.clone()),
225 SqlValue::Text(note.kind.to_string()),
226 SqlValue::Text(note.status.clone()),
227 match ¬e.name {
228 Some(name) => SqlValue::Text(name.clone()),
229 None => SqlValue::Null,
230 },
231 SqlValue::Text(note.content.clone()),
232 match note.salience {
233 Some(value) => SqlValue::Float(value),
234 None => SqlValue::Null,
235 },
236 match note.decay_factor {
237 Some(value) => SqlValue::Float(value),
238 None => SqlValue::Null,
239 },
240 match note.expires_at {
241 Some(value) => SqlValue::Integer(value),
242 None => SqlValue::Null,
243 },
244 match properties_str {
245 Some(value) => SqlValue::Text(value),
246 None => SqlValue::Null,
247 },
248 SqlValue::Integer(note.updated_at),
249 match note.deleted_at {
250 Some(value) => SqlValue::Integer(value),
251 None => SqlValue::Null,
252 },
253 SqlValue::Text(note.id.to_string()),
254 SqlValue::Integer(expected_updated_at),
255 match expected_deleted_at {
256 Some(value) => SqlValue::Integer(value),
257 None => SqlValue::Null,
258 },
259 due_key.map_or(SqlValue::Null, SqlValue::Blob),
260 due_source.map_or(SqlValue::Null, SqlValue::Text),
261 ],
262 label: Some("note-replace-if-unchanged".to_string()),
263 }
264}
265
266pub fn note_metadata_replace_if_unchanged_statement(
270 note: &Note,
271 expected_updated_at: i64,
272 expected_deleted_at: Option<i64>,
273) -> SqlStatement {
274 let mut statement =
275 note_replace_if_unchanged_statement(note, expected_updated_at, expected_deleted_at);
276 statement.sql = "UPDATE notes SET status=?3, name=?4, salience=?6, decay_factor=?7, expires_at=?8, updated_at=?10 \
277 WHERE id=?12 AND updated_at=?13 AND deleted_at IS ?14 AND ?10 > updated_at \
278 AND namespace=?1 AND kind=?2 AND content=?5 AND properties IS ?9 AND deleted_at IS ?11".into();
279 statement.params.truncate(14);
280 statement.label = Some("stream-note-metadata-cas".into());
281 statement
282}
283
284pub fn note_update_properties_statement(
292 id: Uuid,
293 properties: &Option<serde_json::Value>,
294 updated_at: i64,
295) -> SqlStatement {
296 let (due_key, due_source) = note_due_key_values(properties);
297 let properties_str = properties
298 .as_ref()
299 .map(|v| serde_json::to_string(v).unwrap_or_default());
300 SqlStatement {
301 sql: "UPDATE notes SET properties = ?1, updated_at = ?2, \
302 strict_due_key = ?4, due_source = ?5 \
303 WHERE id = ?3 AND deleted_at IS NULL"
304 .to_string(),
305 params: vec![
306 match properties_str {
307 Some(p) => SqlValue::Text(p),
308 None => SqlValue::Null,
309 },
310 SqlValue::Integer(updated_at),
311 SqlValue::Text(id.to_string()),
312 due_key.map_or(SqlValue::Null, SqlValue::Blob),
313 due_source.map_or(SqlValue::Null, SqlValue::Text),
314 ],
315 label: Some("note-update-properties".to_string()),
316 }
317}
318
319pub fn note_set_property_statement(
328 id: Uuid,
329 key: &str,
330 value: &serde_json::Value,
331 updated_at: i64,
332) -> Result<SqlStatement, StorageError> {
333 if key.contains('\0') {
337 return Err(StorageError::InvalidInput {
338 capability: StorageCapability::Notes,
339 operation: "set_note_property".into(),
340 message: "property key must not contain U+0000".to_string(),
341 });
342 }
343 let path = format!("$.{}", serde_json::Value::String(key.to_string()));
344 let updating_due = key == "next_attempt_at";
345 let (due_key, due_source) = if updating_due {
346 note_due_key_values(&Some(serde_json::json!({"next_attempt_at": value})))
347 } else {
348 (None, None)
349 };
350 Ok(SqlStatement {
351 sql: "UPDATE notes \
352 SET properties = json_set(COALESCE(properties, '{}'), ?1, json(?2)), \
353 updated_at = ?3, \
354 strict_due_key = CASE WHEN ?5 = 1 THEN ?6 ELSE strict_due_key END, \
355 due_source = CASE WHEN ?5 = 1 THEN ?7 ELSE due_source END \
356 WHERE id = ?4 AND deleted_at IS NULL \
357 AND (properties IS NULL OR json_type(properties) = 'object')"
358 .to_string(),
359 params: vec![
360 SqlValue::Text(path),
361 SqlValue::Text(value.to_string()),
362 SqlValue::Integer(updated_at),
363 SqlValue::Text(id.to_string()),
364 SqlValue::Integer(i64::from(updating_due)),
365 due_key.map_or(SqlValue::Null, SqlValue::Blob),
366 due_source.map_or(SqlValue::Null, SqlValue::Text),
367 ],
368 label: Some("note-set-property".to_string()),
369 })
370}
371
372pub fn note_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
374 SqlStatement {
375 sql: "UPDATE notes SET status = 'deleted', deleted_at = ?1 \
376 WHERE id = ?2 AND deleted_at IS NULL"
377 .to_string(),
378 params: vec![
379 SqlValue::Integer(deleted_at),
380 SqlValue::Text(id.to_string()),
381 ],
382 label: Some("note-delete-soft".to_string()),
383 }
384}
385
386pub fn note_hard_delete_statement(id: Uuid) -> SqlStatement {
388 SqlStatement {
389 sql: "DELETE FROM notes WHERE id = ?1".to_string(),
390 params: vec![SqlValue::Text(id.to_string())],
391 label: Some("note-delete-hard".to_string()),
392 }
393}
394
395pub struct SqlNoteStore {
401 pool: Arc<ConnectionPool>,
402 index_repair: Option<super::index_repair::IndexRepairContext>,
403 writer_task: Option<WriterTaskHandle>,
404}
405
406impl SqlNoteStore {
407 pub fn new(pool: Arc<ConnectionPool>, _is_file_backed: bool) -> Self {
409 let writer_task = pool.writer_task_handle().ok().flatten();
416
417 Self {
418 pool,
419 writer_task,
420 index_repair: None,
421 }
422 }
423
424 pub(crate) fn with_index_repair(
425 mut self,
426 repair: super::index_repair::IndexRepairContext,
427 ) -> Self {
428 self.index_repair = Some(repair);
429 self
430 }
431
432 async fn with_indexed_reader<F, R>(&self, op: &'static str, read: F) -> Result<R, StorageError>
433 where
434 F: FnMut(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
435 R: Send + 'static,
436 {
437 super::index_repair::run_indexed_read(
438 Arc::clone(&self.pool),
439 self.index_repair.clone(),
440 StorageCapability::Notes,
441 op,
442 read,
443 )
444 .await
445 }
446
447 fn current_writer_task(
448 &self,
449 operation: &'static str,
450 ) -> Result<Option<WriterTaskHandle>, StorageError> {
451 self.pool
452 .writer_task_for_write(self.writer_task.as_ref(), operation)
453 }
454
455 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
474 where
475 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
476 R: Send + 'static,
477 {
478 if let Some(writer_task) = self.current_writer_task(op)? {
479 return writer_task
480 .send_bounded_at(move |conn| f(conn).map_err(|e| map_err(e, op)), op)
481 .await;
482 }
483
484 self.pool
485 .record_direct_route(crate::timeout_sink::Site::DirectRouteNote);
486 let pool = Arc::clone(&self.pool);
487 tokio::task::spawn_blocking(move || {
488 let guard = pool
489 .autocommit_write_unit()
490 .map_err(|e| map_sqlite_err(e, op))?;
491 f(guard.conn())
492 .map_err(|e| map_err(e, op))
493 .map_err(crate::error::statement_failure)
494 .inspect_err(|error| pool.record_direct_writer_error(error))
495 })
496 .await
497 .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
498 }
499
500 async fn with_writer_tx<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
512 where
513 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
514 R: Send + 'static,
515 {
516 self.with_writer_tx_storage(op, move |conn| f(conn).map_err(|error| map_err(error, op)))
517 .await
518 }
519
520 async fn with_writer_tx_storage<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
521 where
522 F: FnOnce(&rusqlite::Connection) -> Result<R, StorageError> + Send + 'static,
523 R: Send + 'static,
524 {
525 if let Some(writer_task) = self.current_writer_task(op)? {
526 return writer_task.send_bounded_at(f, op).await;
527 }
528
529 self.pool
530 .record_direct_route(crate::timeout_sink::Site::DirectRouteNote);
531 let pool = Arc::clone(&self.pool);
532 tokio::task::spawn_blocking(move || {
533 let guard = pool
534 .transaction_write_unit()
535 .map_err(|e| map_sqlite_err(e, op))
536 .inspect_err(|error| pool.record_direct_writer_error(error))?;
537 let conn = guard.conn();
538 let (result, terminal_state) = execute_wrapped_transaction(conn, op, f);
539 if terminal_state.is_some() {
540 pool.retire_pooled_writer(conn);
541 }
542 result.inspect_err(|error| pool.record_direct_writer_error(error))
543 })
544 .await
545 .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
546 }
547
548 async fn get_notes_batch_inner(
549 &self,
550 ids: &[Uuid],
551 include_deleted: bool,
552 ) -> Result<Vec<Note>, StorageError> {
553 if ids.is_empty() {
554 return Ok(vec![]);
555 }
556 let operation = if include_deleted {
557 "get_notes_batch_including_deleted"
558 } else {
559 "get_notes_batch"
560 };
561 let deletion_filter = if include_deleted {
562 ""
563 } else {
564 " AND deleted_at IS NULL"
565 };
566 const CHUNK: usize = 900;
569 let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
570
571 let mut result = Vec::with_capacity(ids.len());
572 for chunk in id_strings.chunks(CHUNK) {
573 let chunk_owned = chunk.to_vec();
574 let notes = self
575 .with_reader(operation, move |conn| {
576 let placeholders: String = (1..=chunk_owned.len())
577 .map(|i| format!("?{i}"))
578 .collect::<Vec<_>>()
579 .join(", ");
580 let sql = format!(
581 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
582 properties, created_at, updated_at, deleted_at, key, version \
583 FROM notes WHERE id IN ({placeholders}){deletion_filter}"
584 );
585 let mut stmt = conn.prepare(&sql)?;
586 let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
587 .iter()
588 .map(|s| s as &dyn rusqlite::types::ToSql)
589 .collect();
590 let rows = stmt.query_map(params.as_slice(), read_note)?;
591 let mut notes = Vec::new();
592 for row in rows {
593 notes.push(row?);
594 }
595 Ok(notes)
596 })
597 .await?;
598 result.extend(notes);
599 }
600 Ok(result)
601 }
602
603 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
604 where
605 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
606 R: Send + 'static,
607 {
608 super::run_pooled_store_read(
609 Arc::clone(&self.pool),
610 StorageCapability::Notes,
611 op,
612 move |conn| f(conn).map_err(|error| map_err(error, op)),
613 )
614 .await
615 }
616}
617
618fn read_note(row: &rusqlite::Row<'_>) -> Result<Note, rusqlite::Error> {
623 let id_str: String = row.get(0)?;
624 let namespace: String = row.get(1)?;
625 let kind: String = row.get(2)?;
626 let status: String = row.get(3)?;
627 let name: Option<String> = row.get(4)?;
628 let content: String = row.get(5)?;
629 let salience: Option<f64> = row.get(6)?;
630 let decay_factor: Option<f64> = row.get(7)?;
631 let expires_at: Option<i64> = row.get(8)?;
632 let properties_str: Option<String> = row.get(9)?;
633 let created_at: i64 = row.get(10)?;
634 let updated_at: i64 = row.get(11)?;
635 let deleted_at: Option<i64> = row.get(12)?;
636 let key: Option<String> = row.get(13)?;
637 let version: i64 = row.get(14)?;
638
639 let id = parse_uuid(&id_str)?;
640
641 let properties = properties_str
642 .map(|s| {
643 serde_json::from_str(&s).map_err(|e| {
644 rusqlite::Error::FromSqlConversionFailure(
645 9,
646 rusqlite::types::Type::Text,
647 Box::new(e),
648 )
649 })
650 })
651 .transpose()?;
652
653 Ok(Note {
654 id,
655 namespace,
656 kind,
657 status,
658 name,
659 content,
660 salience,
661 decay_factor,
662 expires_at,
663 properties,
664 created_at,
665 updated_at,
666 deleted_at,
667 key,
668 version,
669 })
670}
671
672fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
673 Uuid::parse_str(s).map_err(|e| {
674 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
675 })
676}
677
678fn query_note_page_snapshot(
679 conn: &rusqlite::Connection,
680 operation: &'static str,
681 namespace: &str,
682 count_sql: &str,
683 count_params: &[Box<dyn rusqlite::types::ToSql>],
684 data_sql: &str,
685 data_params: &[Box<dyn rusqlite::types::ToSql>],
686) -> Result<Page<Note>, rusqlite::Error> {
687 let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Deferred)?;
688
689 let total: i64 = {
690 let mut stmt = tx.prepare(count_sql)?;
691 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
692 count_params.iter().map(|param| param.as_ref()).collect();
693 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
694 };
695
696 #[cfg(test)]
697 tests::page_snapshot_seam::hook(operation, namespace);
698 #[cfg(not(test))]
699 let _ = (operation, namespace);
700
701 let items = {
702 let mut stmt = tx.prepare(data_sql)?;
703 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
704 data_params.iter().map(|param| param.as_ref()).collect();
705 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
706 rows.collect::<Result<Vec<_>, _>>()?
707 };
708
709 tx.commit()?;
710 Ok(Page {
711 items,
712 total: Some(total as u64),
713 })
714}
715
716fn batch_upsert_notes(
724 conn: &rusqlite::Connection,
725 notes: &[Note],
726 attempted: u64,
727) -> Result<BatchWriteSummary, rusqlite::Error> {
728 let mut summary = BatchWriteSummary {
729 attempted,
730 ..BatchWriteSummary::default()
731 };
732
733 let mut stmt = conn.prepare_cached(NOTE_UPSERT_SQL)?;
738
739 for (index, note) in notes.iter().enumerate() {
740 let id_str = note.id.to_string();
741 let kind_str = note.kind.to_string();
742 let status_str = note.status.clone();
743 let properties_str = note
744 .properties
745 .as_ref()
746 .map(|v| serde_json::to_string(v).unwrap_or_default());
747 let (due_key, due_source) = note_due_key_values(¬e.properties);
748
749 match stmt.execute(rusqlite::params![
750 id_str,
751 ¬e.namespace,
752 kind_str,
753 status_str,
754 ¬e.name,
755 note.content,
756 note.salience,
757 note.decay_factor,
758 note.expires_at,
759 properties_str,
760 note.created_at,
761 note.updated_at,
762 note.deleted_at,
763 note.key,
764 due_key,
765 due_source,
766 ]) {
767 Ok(_) => {
768 assign_note_seq(conn, &id_str)?;
769 summary.affected = summary.affected.saturating_add(1);
770 }
771 Err(e) => {
772 let (class, retryability) = super::classify_batch_sqlite_error(&e);
773 summary.record_failure(index, Some(id_str), class, retryability, e.to_string());
774 }
775 }
776 }
777
778 Ok(summary)
779}
780
781fn assign_note_seq(conn: &rusqlite::Connection, note_id: &str) -> Result<(), rusqlite::Error> {
787 conn.execute(
788 "INSERT OR IGNORE INTO notes_seq (note_id) VALUES (?1)",
789 rusqlite::params![note_id],
790 )?;
791 Ok(())
792}
793
794fn build_note_where(
795 namespace: &str,
796 kind: Option<&str>,
797) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
798 let mut conditions: Vec<String> = vec![
799 "namespace = ?1".to_string(),
800 "deleted_at IS NULL".to_string(),
801 ];
802 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(namespace.to_string())];
803
804 if let Some(k) = kind {
805 params.push(Box::new(k.to_string()));
806 conditions.push(format!("kind = ?{}", params.len()));
807 }
808
809 let clause = format!(" WHERE {}", conditions.join(" AND "));
810 (clause, params)
811}
812
813fn build_note_where_for_namespaces(
814 namespaces: &[String],
815 kind: Option<&str>,
816) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
817 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = namespaces
818 .iter()
819 .map(|namespace| -> Box<dyn rusqlite::types::ToSql> { Box::new(namespace.clone()) })
820 .collect();
821 let namespace_condition = match namespaces.len() {
822 0 => "0".to_string(),
823 1 => "namespace = ?1".to_string(),
824 _ => {
825 let placeholders: Vec<String> =
826 (1..=namespaces.len()).map(|i| format!("?{i}")).collect();
827 format!("namespace IN ({})", placeholders.join(", "))
828 }
829 };
830 let mut conditions = vec![namespace_condition, "deleted_at IS NULL".to_string()];
831
832 if let Some(kind) = kind {
833 params.push(Box::new(kind.to_string()));
834 conditions.push(format!("kind = ?{}", params.len()));
835 }
836
837 let clause = format!(" WHERE {}", conditions.join(" AND "));
838 (clause, params)
839}
840
841fn validate_json_path(path: &str) -> Result<(), StorageError> {
844 let valid = path.starts_with("$.")
845 && path[2..].split('.').all(|part| {
846 !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
847 });
848 if valid {
849 Ok(())
850 } else {
851 Err(StorageError::InvalidInput {
852 capability: StorageCapability::Notes,
853 operation: "query_notes_filtered".into(),
854 message: format!("invalid JSON path for note filter: {path:?}"),
855 })
856 }
857}
858
859fn json_extract_expr(path: &str) -> String {
860 format!("json_extract(properties, '{path}')")
861}
862
863fn json_type_expr(path: &str) -> String {
864 format!("json_type(properties, '{path}')")
865}
866
867fn validate_note_time_order(
868 filter: &NoteFilter,
869 operation: &'static str,
870 supports_time_order: bool,
871) -> StorageResult<()> {
872 if filter.time_order.is_some()
873 && (!supports_time_order
874 || filter.order_by.is_some()
875 || filter.order_by_instant
876 || filter.unordered
877 || filter.after.is_some()
878 || filter.after_instant.is_some())
879 {
880 return Err(StorageError::InvalidInput {
881 capability: StorageCapability::Notes,
882 operation: operation.into(),
883 message: "time_order requires a native-order page without property order, unordered mode or cursors; fixed-order reads and filtered mutations do not support it".into(),
884 });
885 }
886 Ok(())
887}
888
889fn note_time_order_clause(order: NoteTimeOrder) -> &'static str {
890 match order {
891 NoteTimeOrder::ExpiresAt => " ORDER BY expires_at ASC, id ASC",
892 NoteTimeOrder::ExpiresAtOrCreatedAt => {
893 " ORDER BY COALESCE(expires_at, created_at) ASC, id ASC"
894 }
895 }
896}
897
898fn note_filter_page_order_clause(filter: &NoteFilter) -> String {
902 if let Some(order) = filter.time_order {
903 return note_time_order_clause(order).to_string();
904 }
905 if filter.unordered {
906 return String::new();
907 }
908 match &filter.order_by {
909 Some((path, dir)) => {
910 if filter.order_by_instant {
911 let expr = json_extract_expr(path);
912 return format!(" ORDER BY khive_rfc3339_key({expr}) ASC, {expr} ASC, id ASC");
913 }
914 let dir_str = match dir {
915 SortDir::Asc => "ASC",
916 SortDir::Desc => "DESC",
917 };
918 format!(
921 " ORDER BY {} {dir_str}, id {dir_str}",
922 json_extract_expr(path)
923 )
924 }
925 None if filter
930 .property_filters
931 .iter()
932 .any(|property| matches!(&property.op, FilterOp::TextColonPrefixBucketIndexed))
933 && filter
934 .property_filters
935 .iter()
936 .any(|property| matches!(&property.op, FilterOp::Rfc3339LteOrInvalid)) =>
937 {
938 " ORDER BY +created_at DESC, id ASC".to_string()
939 }
940 None => DEFAULT_NOTE_PAGE_ORDER.to_string(),
943 }
944}
945
946fn json_type_literal(value: &SqlValue) -> Result<&str, rusqlite::Error> {
952 const JSON_TYPES: [&str; 8] = [
953 "true", "false", "integer", "real", "text", "array", "object", "null",
954 ];
955 match value {
956 SqlValue::Text(s) if JSON_TYPES.contains(&s.as_str()) => Ok(s.as_str()),
957 other => Err(rusqlite::Error::InvalidParameterName(format!(
958 "json_type comparison value must be one of SQLite's json_type strings \
959 ({JSON_TYPES:?}), got {other:?}"
960 ))),
961 }
962}
963
964fn text_prefix_upper_bound(prefix: &str) -> Option<String> {
972 let mut chars: Vec<char> = prefix.chars().collect();
973 while let Some(last) = chars.pop() {
974 let mut next = u32::from(last) + 1;
975 if (0xD800..=0xDFFF).contains(&next) {
976 next = 0xE000;
977 }
978 if let Some(next) = char::from_u32(next) {
979 chars.push(next);
980 return Some(chars.into_iter().collect());
981 }
982 }
983 None
984}
985
986fn sql_value_param(value: &SqlValue) -> Result<Box<dyn rusqlite::types::ToSql>, rusqlite::Error> {
987 Ok(match value {
988 SqlValue::Null => Box::new(Option::<String>::None),
989 SqlValue::Bool(v) => Box::new(*v as i64),
990 SqlValue::Integer(v) => Box::new(*v),
991 SqlValue::Float(v) => Box::new(*v),
992 SqlValue::Text(v) => Box::new(v.clone()),
993 SqlValue::Blob(v) => Box::new(v.clone()),
994 SqlValue::Json(v) => Box::new(
995 serde_json::to_string(v)
996 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
997 ),
998 SqlValue::Uuid(v) => Box::new(v.to_string()),
999 SqlValue::Timestamp(v) => Box::new(v.timestamp_micros()),
1000 })
1001}
1002
1003fn build_note_filter_where(
1004 namespace: &str,
1005 filter: &NoteFilter,
1006) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
1007 let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
1010 if !filter.namespaces.is_empty() {
1011 let placeholders: Vec<String> = (1..=filter.namespaces.len())
1012 .map(|i| format!("?{i}"))
1013 .collect();
1014 let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
1015 .namespaces
1016 .iter()
1017 .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
1018 .collect();
1019 (
1020 format!("namespace IN ({})", placeholders.join(", ")),
1021 params,
1022 )
1023 } else {
1024 (
1025 "namespace = ?1".to_string(),
1026 vec![Box::new(namespace.to_string())],
1027 )
1028 };
1029
1030 let mut conditions = vec![ns_condition];
1031 if !filter.include_deleted {
1032 conditions.push("deleted_at IS NULL".to_string());
1033 }
1034 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
1035
1036 if let Some(kind) = &filter.kind {
1037 params.push(Box::new(kind.clone()));
1038 conditions.push(format!("kind = ?{}", params.len()));
1039 }
1040
1041 if let Some(since) = filter.min_updated_at {
1042 params.push(Box::new(since));
1043 conditions.push(format!("updated_at >= ?{}", params.len()));
1044 }
1045 if !filter.tags.is_empty() {
1046 let mut tag_predicates = Vec::new();
1047 for tag in &filter.tags {
1048 params.push(Box::new(tag.clone()));
1049 tag_predicates.push(format!(
1050 "EXISTS (SELECT 1 FROM json_each(CASE WHEN json_type(properties,'$.tags')='array' \
1051 THEN json_extract(properties,'$.tags') ELSE '[]' END) AS tag \
1052 WHERE tag.type='text' AND tag.value = ?{} COLLATE NOCASE)",
1053 params.len()
1054 ));
1055 }
1056 let join = match filter.tag_mode {
1057 NoteTagMode::Any => " OR ",
1058 NoteTagMode::All => " AND ",
1059 };
1060 conditions.push(format!("({})", tag_predicates.join(join)));
1061 }
1062
1063 for pf in &filter.property_filters {
1064 match &pf.op {
1065 FilterOp::Rfc3339Valid => {
1066 let expr = json_extract_expr(&pf.json_path);
1067 conditions.push(format!("khive_rfc3339_key({expr}) IS NOT NULL"));
1068 }
1069 FilterOp::Rfc3339Gte | FilterOp::Rfc3339Lte | FilterOp::Rfc3339LteOrInvalid => {
1070 let instant = match &pf.value {
1071 SqlValue::Timestamp(instant) => *instant,
1072 SqlValue::Text(text) => {
1073 text.parse::<chrono::DateTime<chrono::Utc>>()
1074 .map_err(|error| {
1075 rusqlite::Error::ToSqlConversionFailure(Box::new(error))
1076 })?
1077 }
1078 _ => {
1079 return Err(rusqlite::Error::ToSqlConversionFailure(
1080 "RFC 3339 filters require a timestamp or text value".into(),
1081 ));
1082 }
1083 };
1084 let expr = json_extract_expr(&pf.json_path);
1085 let op = if matches!(&pf.op, FilterOp::Rfc3339Gte) {
1086 ">="
1087 } else {
1088 "<="
1089 };
1090 params.push(Box::new(crate::pool::rfc3339_instant_key(instant)));
1091 if matches!(&pf.op, FilterOp::Rfc3339LteOrInvalid) {
1092 conditions.push(format!(
1097 "CASE WHEN due_source = {expr} \
1098 THEN ifnull(strict_due_key, x'') ELSE x'' END <= ?{n} \
1099 AND ifnull(khive_rfc3339_strict_key({expr}), x'') <= ?{n}",
1100 n = params.len()
1101 ));
1102 } else {
1103 conditions.push(format!("khive_rfc3339_key({expr}) {op} ?{}", params.len()));
1104 }
1105 }
1106 FilterOp::EqOrMissing => {
1107 let expr = json_extract_expr(&pf.json_path);
1108 params.push(sql_value_param(&pf.value)?);
1109 conditions.push(format!(
1110 "({expr} = ?{n} OR {expr} IS NULL)",
1111 n = params.len()
1112 ));
1113 }
1114 FilterOp::EqOrMissingIndexed => {
1115 let expr = json_extract_expr(&pf.json_path);
1116 params.push(sql_value_param(&pf.value)?);
1117 conditions.push(format!("ifnull({expr}, '') = ?{}", params.len()));
1118 }
1119 FilterOp::TextEqOrNonText => {
1120 let expr = json_extract_expr(&pf.json_path);
1121 let type_expr = json_type_expr(&pf.json_path);
1122 params.push(sql_value_param(&pf.value)?);
1123 let n = params.len();
1124 conditions.push(format!(
1125 "CASE WHEN {type_expr} = 'text' THEN {expr} ELSE ?{n} END = ?{n}"
1126 ));
1127 }
1128 FilterOp::TextInOrNonText(values) => {
1129 let expr = json_extract_expr(&pf.json_path);
1130 let type_expr = json_type_expr(&pf.json_path);
1131 let mut placeholders = Vec::with_capacity(values.len());
1132 for value in values {
1133 params.push(sql_value_param(value)?);
1134 placeholders.push(format!("?{}", params.len()));
1135 }
1136 let text_match = if placeholders.is_empty() {
1137 "0".to_string()
1138 } else {
1139 format!("{expr} IN ({})", placeholders.join(", "))
1140 };
1141 conditions.push(format!(
1142 "CASE WHEN {type_expr} = 'text' THEN {text_match} ELSE 1 END"
1143 ));
1144 }
1145 FilterOp::MissingNullOrSpaceEmptyText => {
1146 let expr = json_extract_expr(&pf.json_path);
1147 let ty = json_type_expr(&pf.json_path);
1148 conditions.push(format!(
1149 "({ty} IS NULL OR {ty} = 'null' OR ({ty} = 'text' AND trim({expr}) = ''))"
1150 ));
1151 }
1152 FilterOp::TrueOrTextTrue => {
1153 let expr = json_extract_expr(&pf.json_path);
1154 let ty = json_type_expr(&pf.json_path);
1155 conditions.push(format!("({expr} = 'true' OR {ty} = 'true')"));
1156 }
1157 FilterOp::JsonTypeEq => {
1158 let type_expr = json_type_expr(&pf.json_path);
1159 params.push(sql_value_param(&pf.value)?);
1160 conditions.push(format!("{type_expr} = ?{}", params.len()));
1161 }
1162 FilterOp::JsonTypeMissing => {
1163 let type_expr = json_type_expr(&pf.json_path);
1164 conditions.push(format!("{type_expr} IS NULL"));
1165 }
1166 FilterOp::JsonTypeMissingOrNullIndexed => {
1167 let expr = json_extract_expr(&pf.json_path);
1168 let type_expr = json_type_expr(&pf.json_path);
1169 conditions.push(format!(
1170 "ifnull({expr}, '') = '' AND ({type_expr} IS NULL OR {type_expr} = 'null')"
1171 ));
1172 }
1173 FilterOp::EqOrLegacyIndexed => {
1174 let expr = json_extract_expr(&pf.json_path);
1175 let type_expr = json_type_expr(&pf.json_path);
1176 params.push(sql_value_param(&pf.value)?);
1177 let n = params.len();
1178 conditions.push(format!(
1179 "ifnull({expr}, '') IN (?{n}, '') AND \
1180 ({type_expr} IS NULL OR {type_expr} = 'null' OR ifnull({expr}, '') != '')"
1181 ));
1182 }
1183 FilterOp::JsonTypeNeMissing => {
1184 let type_expr = json_type_expr(&pf.json_path);
1185 let literal = json_type_literal(&pf.value)?;
1195 conditions.push(format!(
1196 "({type_expr} IS NULL OR {type_expr} != '{literal}')"
1197 ));
1198 }
1199 FilterOp::In(values) => {
1200 let expr = json_extract_expr(&pf.json_path);
1201 if values.is_empty() {
1202 conditions.push("0".to_string());
1204 continue;
1205 }
1206 let mut placeholders = Vec::with_capacity(values.len());
1207 for v in values {
1208 params.push(sql_value_param(v)?);
1209 placeholders.push(format!("?{}", params.len()));
1210 }
1211 conditions.push(format!("{expr} IN ({})", placeholders.join(", ")));
1212 }
1213 FilterOp::TextStartsWithIndexed => {
1214 let expr = json_extract_expr(&pf.json_path);
1215 let SqlValue::Text(prefix) = &pf.value else {
1216 return Err(rusqlite::Error::ToSqlConversionFailure(
1217 "TextStartsWithIndexed takes a text prefix in PropertyFilter.value".into(),
1218 ));
1219 };
1220 params.push(Box::new(prefix.clone()));
1221 let lower = params.len();
1222 match text_prefix_upper_bound(prefix) {
1223 Some(upper) => {
1224 params.push(Box::new(upper));
1225 conditions.push(format!(
1226 "({expr} >= ?{lower} AND {expr} < ?{})",
1227 params.len()
1228 ));
1229 }
1230 None => {
1231 let type_expr = json_type_expr(&pf.json_path);
1232 conditions.push(format!("({type_expr} = 'text' AND {expr} >= ?{lower})"));
1233 }
1234 }
1235 }
1236 FilterOp::TextColonPrefixBucketIndexed => {
1237 let SqlValue::Text(prefix) = &pf.value else {
1238 return Err(rusqlite::Error::ToSqlConversionFailure(
1239 "TextColonPrefixBucketIndexed takes a text prefix".into(),
1240 ));
1241 };
1242 if prefix
1243 .strip_suffix(':')
1244 .is_none_or(|head| head.is_empty() || head.contains(':'))
1245 {
1246 return Err(rusqlite::Error::ToSqlConversionFailure(
1247 "TextColonPrefixBucketIndexed requires one trailing colon".into(),
1248 ));
1249 }
1250 let expr = json_extract_expr(&pf.json_path);
1251 params.push(Box::new(prefix.clone()));
1252 conditions.push(format!(
1253 "substr({expr}, 1, instr({expr}, ':')) = ?{}",
1254 params.len()
1255 ));
1256 }
1257 FilterOp::NotInOrMissing(values) => {
1258 let expr = json_extract_expr(&pf.json_path);
1259 if values.is_empty() {
1260 continue;
1262 }
1263 let mut placeholders = Vec::with_capacity(values.len());
1264 for v in values {
1265 params.push(sql_value_param(v)?);
1266 placeholders.push(format!("?{}", params.len()));
1267 }
1268 conditions.push(format!(
1269 "({expr} IS NULL OR {expr} NOT IN ({}))",
1270 placeholders.join(", ")
1271 ));
1272 }
1273 _ => {
1274 let expr = json_extract_expr(&pf.json_path);
1275 let op = match pf.op {
1276 FilterOp::Eq => "=",
1277 FilterOp::Ne => "!=",
1278 FilterOp::Lt => "<",
1279 FilterOp::Lte => "<=",
1280 FilterOp::Gt => ">",
1281 FilterOp::Gte => ">=",
1282 FilterOp::EqOrMissing
1283 | FilterOp::EqOrMissingIndexed
1284 | FilterOp::TextEqOrNonText
1285 | FilterOp::TextInOrNonText(_)
1286 | FilterOp::JsonTypeEq
1287 | FilterOp::JsonTypeMissing
1288 | FilterOp::MissingNullOrSpaceEmptyText
1289 | FilterOp::TrueOrTextTrue
1290 | FilterOp::JsonTypeMissingOrNullIndexed
1291 | FilterOp::EqOrLegacyIndexed
1292 | FilterOp::JsonTypeNeMissing
1293 | FilterOp::In(_)
1294 | FilterOp::NotInOrMissing(_)
1295 | FilterOp::TextStartsWithIndexed
1296 | FilterOp::TextColonPrefixBucketIndexed => {
1297 unreachable!()
1298 }
1299 FilterOp::Rfc3339Valid
1300 | FilterOp::Rfc3339Gte
1301 | FilterOp::Rfc3339Lte
1302 | FilterOp::Rfc3339LteOrInvalid => {
1303 unreachable!()
1304 }
1305 };
1306 params.push(sql_value_param(&pf.value)?);
1307 conditions.push(format!("{expr} {op} ?{}", params.len()));
1308 }
1309 }
1310 }
1311
1312 if let Some(min_ts) = filter.min_created_at {
1313 params.push(Box::new(min_ts));
1314 conditions.push(format!("created_at >= ?{}", params.len()));
1315 }
1316
1317 if let Some(max_ts) = filter.max_created_at {
1318 params.push(Box::new(max_ts));
1319 conditions.push(format!("created_at <= ?{}", params.len()));
1320 }
1321 if let Some(max_ts) = filter.max_expires_at {
1322 params.push(Box::new(max_ts));
1323 conditions.push(format!("expires_at <= ?{}", params.len()));
1324 }
1325 if let Some(cutoff) = filter.expiry_fallback {
1326 params.push(Box::new(cutoff.expires_at_or_before));
1327 let expiry = params.len();
1328 params.push(Box::new(cutoff.created_at_or_before));
1329 let creation = params.len();
1330 conditions.push(format!(
1331 "((expires_at IS NOT NULL AND expires_at <= ?{expiry}) OR (expires_at IS NULL AND created_at <= ?{creation}))"
1332 ));
1333 }
1334 if let Some(scope) = &filter.mailbox {
1335 conditions.push(mailbox_condition(scope, &mut params));
1336 }
1337
1338 Ok((format!(" WHERE {}", conditions.join(" AND ")), params))
1339}
1340
1341fn mailbox_condition(
1347 scope: &khive_storage::note::NoteMailboxScope,
1348 params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
1349) -> String {
1350 params.push(Box::new(scope.actor_id.clone()));
1351 let actor = format!("?{}", params.len());
1352 let absent = |path: &str| {
1353 let ty = json_type_expr(path);
1354 format!("({ty} IS NULL OR {ty} = 'null')")
1355 };
1356 let not_text = |path: &str| format!("ifnull({}, '') != 'text'", json_type_expr(path));
1357 let direction = json_extract_expr("$.direction");
1358 let text_is_actor = |path: &str| {
1359 format!(
1360 "({} = 'text' AND {} = {actor})",
1361 json_type_expr(path),
1362 json_extract_expr(path)
1363 )
1364 };
1365 let to = text_is_actor("$.to_actor");
1366 let from = text_is_actor("$.from_actor");
1367 let (inbound_legacy, outbound_legacy, unrouted) = if scope.legacy_local {
1368 (
1369 format!(" OR {}", absent("$.to_actor")),
1370 format!(" OR {}", absent("$.from_actor")),
1371 format!(
1372 " OR ({} AND {} AND {})",
1373 absent("$.direction"),
1374 not_text("$.from_actor"),
1375 not_text("$.to_actor")
1376 ),
1377 )
1378 } else {
1379 Default::default()
1380 };
1381 format!(
1382 "(kind != 'message' OR ({direction} = 'inbound' AND ({to}{inbound_legacy})) \
1383 OR ({direction} = 'outbound' AND ({from}{outbound_legacy})){unrouted})"
1384 )
1385}
1386
1387fn comm_filter_index_clause(filter: &NoteFilter, where_sql: &str) -> &'static str {
1391 if filter.kind.as_deref() != Some("message") {
1392 return "";
1393 }
1394 let Some(predicate) = where_sql.strip_prefix(" WHERE ") else {
1395 return "";
1396 };
1397 let terms: Vec<_> = predicate.split(" AND ").collect();
1398 let numbered_param = |value: &str| {
1399 value.strip_prefix('?').is_some_and(|number| {
1400 !number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit())
1401 })
1402 };
1403 let equality = |prefix: &str| {
1404 terms
1405 .iter()
1406 .any(|term| term.strip_prefix(prefix).is_some_and(numbered_param))
1407 };
1408 let namespace = equality("namespace = ")
1409 || terms.iter().any(|term| {
1410 term.strip_prefix("namespace IN (")
1411 .and_then(|term| term.strip_suffix(')'))
1412 .is_some_and(|values| values.split(", ").all(numbered_param))
1413 });
1414 let recipient = equality("ifnull(json_extract(properties, '$.to_actor'), '') = ")
1415 || terms.contains(&"ifnull(json_extract(properties, '$.to_actor'), '') = ''")
1416 || terms.iter().any(|term| {
1417 term.strip_prefix("ifnull(json_extract(properties, '$.to_actor'), '') IN (")
1418 .and_then(|term| term.strip_suffix(", '')"))
1419 .is_some_and(numbered_param)
1420 });
1421 if !namespace || !terms.contains(&"deleted_at IS NULL") || !equality("kind = ") {
1422 return "";
1423 }
1424 if !equality("json_extract(properties, '$.direction') = ") || !recipient {
1425 let thread = equality("json_extract(properties, '$.thread_id') = ")
1431 || terms.iter().any(|term| {
1432 term.strip_prefix("json_extract(properties, '$.thread_id') IN (")
1433 .and_then(|term| term.strip_suffix(')'))
1434 .is_some_and(|values| values.split(", ").all(numbered_param))
1435 });
1436 return if thread {
1437 " INDEXED BY idx_comm_message_thread"
1438 } else {
1439 ""
1440 };
1441 }
1442 if terms.contains(
1443 &"(json_type(properties, '$.read') IS NULL OR json_type(properties, '$.read') != 'true')",
1444 ) {
1445 let typed_exact_recipient =
1449 equality("ifnull(json_extract(properties, '$.to_actor'), '') = ")
1450 && equality("json_type(properties, '$.to_actor') = ")
1451 && filter.property_filters.iter().any(|property| {
1452 property.json_path == "$.to_actor"
1453 && matches!(property.op, FilterOp::JsonTypeEq)
1454 && matches!(&property.value, SqlValue::Text(value) if value == "text")
1455 });
1456 if typed_exact_recipient {
1457 " INDEXED BY idx_notes_unread_probe_recipient_type_direction"
1458 } else {
1459 " INDEXED BY idx_notes_unread_probe_recipient_direction"
1460 }
1461 } else {
1462 " INDEXED BY idx_notes_message_recipient_direction"
1463 }
1464}
1465
1466fn build_note_filter_read_clause(
1467 namespace: &str,
1468 filter: &NoteFilter,
1469) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
1470 let (where_sql, params) = build_note_filter_where(namespace, filter)?;
1471 let index_clause = comm_filter_index_clause(filter, &where_sql);
1472 Ok((format!("{index_clause}{where_sql}"), params))
1473}
1474
1475const NOTE_COLUMNS: &str = "id, namespace, kind, status, name, content, salience, decay_factor, \
1482 expires_at, properties, created_at, updated_at, deleted_at, key, version";
1483
1484const DEFAULT_NOTE_PAGE_ORDER: &str = " ORDER BY created_at DESC, id ASC";
1487
1488const NAMESPACE_ARMS_MAX: usize = 32;
1491
1492fn note_filter_page_sql(
1509 namespace: &str,
1510 filter: &NoteFilter,
1511 limit: i64,
1512 offset: i64,
1513) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
1514 let mut filter = filter.clone();
1515 let mut seen = HashSet::new();
1516 filter.namespaces.retain(|ns| seen.insert(ns.clone()));
1517 let (read_clause, mut params) = build_note_filter_read_clause(namespace, &filter)?;
1518 let order_clause = note_filter_page_order_clause(&filter);
1519 let arms = filter.namespaces.len();
1520 let in_term = format!(
1521 " WHERE namespace IN ({})",
1522 (1..=arms)
1523 .map(|i| format!("?{i}"))
1524 .collect::<Vec<_>>()
1525 .join(", ")
1526 );
1527 let arm_rest = read_clause.strip_prefix(in_term.as_str()).filter(|_| {
1528 (2..=NAMESPACE_ARMS_MAX).contains(&arms)
1529 && !filter.include_deleted
1530 && order_clause == DEFAULT_NOTE_PAGE_ORDER
1531 });
1532 let Some(rest) = arm_rest else {
1533 params.push(Box::new(limit));
1534 params.push(Box::new(offset));
1535 let limit_idx = params.len() - 1;
1536 let offset_idx = params.len();
1537 return Ok((
1538 format!(
1539 "SELECT {NOTE_COLUMNS} FROM notes{read_clause}{order_clause} \
1540 LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
1541 ),
1542 params,
1543 ));
1544 };
1545 params.push(Box::new(offset.saturating_add(limit)));
1546 let arm_limit_idx = params.len();
1547 let arm_sql: Vec<String> = (1..=arms)
1548 .map(|i| {
1549 format!(
1550 "SELECT * FROM (SELECT {NOTE_COLUMNS} FROM notes WHERE namespace = ?{i}{rest}\
1551 {order_clause} LIMIT ?{arm_limit_idx})"
1552 )
1553 })
1554 .collect();
1555 params.push(Box::new(limit));
1556 params.push(Box::new(offset));
1557 let limit_idx = params.len() - 1;
1558 let offset_idx = params.len();
1559 Ok((
1560 format!(
1561 "{}{order_clause} LIMIT ?{limit_idx} OFFSET ?{offset_idx}",
1562 arm_sql.join(" UNION ALL ")
1563 ),
1564 params,
1565 ))
1566}
1567
1568fn fetch_notes_after_instant(
1569 conn: &rusqlite::Connection,
1570 namespace: &str,
1571 base_filter: &NoteFilter,
1572 after: &NoteInstantSeekAfter,
1573 limit: i64,
1574) -> Result<Vec<Note>, rusqlite::Error> {
1575 let (where_sql, mut params) = build_note_filter_read_clause(namespace, base_filter)?;
1576 params.push(Box::new(after.value.clone()));
1577 let value_idx = params.len();
1578 params.push(Box::new(after.id.to_string()));
1579 let id_idx = params.len();
1580 params.push(Box::new(limit));
1581 let limit_idx = params.len();
1582 let (path, _) = base_filter
1583 .order_by
1584 .as_ref()
1585 .expect("instant cursor requires order_by");
1586 let expr = json_extract_expr(path);
1587 let order_clause = note_filter_page_order_clause(base_filter);
1588 let sql = format!(
1589 "SELECT {NOTE_COLUMNS} FROM notes{where_sql} \
1590 AND (khive_rfc3339_key({expr}), {expr}, id) > \
1591 (khive_rfc3339_key(?{value_idx}), ?{value_idx}, ?{id_idx}) \
1592 {order_clause} LIMIT ?{limit_idx}"
1593 );
1594 let mut stmt = conn.prepare_cached(&sql)?;
1595 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1596 params.iter().map(|param| param.as_ref()).collect();
1597 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1598 rows.collect()
1599}
1600
1601fn fetch_notes_after(
1620 conn: &rusqlite::Connection,
1621 namespace: &str,
1622 base_filter: &NoteFilter,
1623 after: &NoteSeekAfter,
1624 limit: i64,
1625) -> Result<Vec<Note>, rusqlite::Error> {
1626 let mut items = Vec::new();
1627 if limit <= 0 {
1628 return Ok(items);
1629 }
1630
1631 {
1632 let (where_sql, mut params) = build_note_filter_read_clause(namespace, base_filter)?;
1633 params.push(Box::new(after.created_at));
1634 let ts_idx = params.len();
1635 params.push(Box::new(after.id.to_string()));
1636 let id_idx = params.len();
1637 params.push(Box::new(limit));
1638 let limit_idx = params.len();
1639 let sql = format!(
1640 "SELECT {NOTE_COLUMNS} FROM notes{where_sql} AND created_at = ?{ts_idx} \
1641 AND id > ?{id_idx} ORDER BY id ASC LIMIT ?{limit_idx}"
1642 );
1643 let mut stmt = conn.prepare_cached(&sql)?;
1644 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1645 params.iter().map(|p| p.as_ref()).collect();
1646 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1647 for row in rows {
1648 items.push(row?);
1649 }
1650 }
1651
1652 let remaining = limit - items.len() as i64;
1653 if remaining > 0 {
1654 let (where_sql, mut params) = build_note_filter_read_clause(namespace, base_filter)?;
1655 params.push(Box::new(after.created_at));
1656 let ts_idx = params.len();
1657 params.push(Box::new(remaining));
1658 let limit_idx = params.len();
1659 let sql = format!(
1660 "SELECT {NOTE_COLUMNS} FROM notes{where_sql} AND created_at < ?{ts_idx} \
1661 ORDER BY created_at DESC, id ASC LIMIT ?{limit_idx}"
1662 );
1663 let mut stmt = conn.prepare_cached(&sql)?;
1664 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1665 params.iter().map(|p| p.as_ref()).collect();
1666 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1667 for row in rows {
1668 items.push(row?);
1669 }
1670 }
1671
1672 Ok(items)
1673}
1674
1675fn execute_filtered_note_property_patch(
1676 conn: &rusqlite::Connection,
1677 id: Uuid,
1678 namespace: &str,
1679 filter: &NoteFilter,
1680 json_path: &str,
1681 value_json: &str,
1682 updated_at: i64,
1683) -> Result<usize, rusqlite::Error> {
1684 let (mut where_clause, mut params) = build_note_filter_where(namespace, filter)?;
1685 if filter.include_deleted {
1687 where_clause.push_str(" AND deleted_at IS NULL");
1688 }
1689 let updating_due = json_path == "$.next_attempt_at";
1690 let due_source = if updating_due {
1691 serde_json::from_str::<serde_json::Value>(value_json)
1692 .ok()
1693 .and_then(|value| value.as_str().map(str::to_owned))
1694 } else {
1695 None
1696 };
1697 let due_key = due_source
1698 .as_deref()
1699 .and_then(crate::pool::strict_rfc3339_key);
1700
1701 let base = params.len();
1702 let sql = format!(
1703 "UPDATE notes SET properties = json_set(COALESCE(properties, '{{}}'), ?{p1}, json(?{p2})), \
1704 updated_at = ?{p3}, \
1705 strict_due_key = CASE WHEN ?{p5} = 1 THEN ?{p6} ELSE strict_due_key END, \
1706 due_source = CASE WHEN ?{p5} = 1 THEN ?{p7} ELSE due_source END \
1707 {where_clause} \
1708 AND (properties IS NULL OR json_type(properties) = 'object') AND id = ?{p4}",
1709 p1 = base + 1,
1710 p2 = base + 2,
1711 p3 = base + 3,
1712 p4 = base + 4,
1713 p5 = base + 5,
1714 p6 = base + 6,
1715 p7 = base + 7,
1716 );
1717 params.push(Box::new(json_path.to_string()));
1718 params.push(Box::new(value_json.to_string()));
1719 params.push(Box::new(updated_at));
1720 params.push(Box::new(id.to_string()));
1721 params.push(Box::new(i64::from(updating_due)));
1722 params.push(Box::new(due_key));
1723 params.push(Box::new(due_source));
1724
1725 let mut stmt = conn.prepare_cached(&sql)?;
1726 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1727 params.iter().map(|param| param.as_ref()).collect();
1728 stmt.execute(param_refs.as_slice())
1729}
1730
1731#[async_trait]
1736impl NoteStore for SqlNoteStore {
1737 async fn get_live_notes_by_key(
1738 &self,
1739 namespace: &str,
1740 key: &str,
1741 kind: Option<&str>,
1742 ) -> StorageResult<Vec<Note>> {
1743 let namespace = namespace.to_owned();
1744 let key = key.to_owned();
1745 let kind = kind.map(str::to_owned);
1746 self.with_reader("get_live_notes_by_key", move |conn| {
1747 let (mut clause, mut params) = build_note_where(&namespace, kind.as_deref());
1748 params.push(Box::new(key));
1749 clause.push_str(&format!(" AND key = ?{}", params.len()));
1750 let sql = format!("SELECT {NOTE_COLUMNS} FROM notes{clause} ORDER BY kind ASC");
1751 let mut stmt = conn.prepare(&sql)?;
1752 let params: Vec<&dyn rusqlite::types::ToSql> =
1753 params.iter().map(|p| p.as_ref()).collect();
1754 let rows = stmt.query_map(params.as_slice(), read_note)?.collect();
1755 rows
1756 })
1757 .await
1758 }
1759
1760 async fn query_keyed_notes(
1761 &self,
1762 namespace: &str,
1763 filter: &NoteFilter,
1764 prefix: &str,
1765 after: Option<&NoteKeyCursor>,
1766 page: PageRequest,
1767 ) -> StorageResult<(Vec<Note>, Option<NoteKeyCursor>)> {
1768 validate_note_time_order(filter, "query_keyed_notes", false)?;
1769 if !filter.namespaces.is_empty()
1770 || filter.order_by.is_some()
1771 || filter.after.is_some()
1772 || (after.is_some() && page.offset != 0)
1773 || page.limit == 0
1774 {
1775 return Err(StorageError::InvalidInput { capability: StorageCapability::Notes,
1776 operation: "query_keyed_notes".into(), message: "keyed paging requires primary namespace, keyed order and a positive limit; cursor excludes offset".into() });
1777 }
1778 for property in &filter.property_filters {
1779 validate_json_path(&property.json_path)?;
1780 }
1781 let offset = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1782 capability: StorageCapability::Notes,
1783 operation: "query_keyed_notes".into(),
1784 message: "offset exceeds the supported integer range".into(),
1785 })?;
1786 let namespace = namespace.to_owned();
1787 let filter = filter.clone();
1788 let prefix = prefix.to_owned();
1789 let after = after.cloned();
1790 self.with_reader("query_keyed_notes", move |conn| {
1791 let (mut clause, mut params) = build_note_filter_where(&namespace, &filter)?;
1792 params.push(Box::new(prefix.clone()));
1793 clause.push_str(&format!(" AND key IS NOT NULL AND key >= ?{}", params.len()));
1794 if let Some(upper) = note_key_prefix_successor(&prefix) {
1795 params.push(Box::new(upper));
1796 clause.push_str(&format!(" AND key < ?{}", params.len()));
1797 }
1798 if let Some(after) = after {
1799 params.push(Box::new(after.updated_at)); let u = params.len();
1800 params.push(Box::new(after.key)); let k = params.len();
1801 params.push(Box::new(after.id.to_string())); let id = params.len();
1802 clause.push_str(&format!(" AND (updated_at < ?{u} OR (updated_at = ?{u} AND key < ?{k}) \
1803 OR (updated_at = ?{u} AND key = ?{k} AND id > ?{id}))"));
1804 }
1805 params.push(Box::new(i64::from(page.limit) + 1)); let limit = params.len();
1806 params.push(Box::new(offset)); let offset = params.len();
1807 let sql = format!("SELECT {NOTE_COLUMNS} FROM notes{clause} ORDER BY updated_at DESC, key DESC, id ASC LIMIT ?{limit} OFFSET ?{offset}");
1808 let mut stmt = conn.prepare(&sql)?;
1809 let params: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1810 let mut notes = stmt.query_map(params.as_slice(), read_note)?.collect::<Result<Vec<_>, _>>()?;
1811 let has_more = notes.len() > page.limit as usize;
1812 notes.truncate(page.limit as usize);
1813 let next = if has_more { notes.last().map(NoteKeyCursor::from) } else { None };
1814 Ok((notes, next))
1815 }).await
1816 }
1817
1818 async fn upsert_note(&self, note: Note) -> Result<(), StorageError> {
1819 let id_str = note.id.to_string();
1820 let statement = note_upsert_statement(¬e);
1821 self.with_writer_tx("upsert_note", move |conn| {
1822 let mut stmt = conn.prepare_cached(&statement.sql)?;
1823 bind_params(&mut stmt, &statement.params)?;
1824 stmt.raw_execute()?;
1825 assign_note_seq(conn, &id_str)?;
1826 Ok(())
1827 })
1828 .await
1829 }
1830
1831 async fn insert_note_if_absent(&self, note: Note) -> Result<bool, StorageError> {
1832 let id_str = note.id.to_string();
1833 let statement = note_insert_if_absent_statement(¬e);
1834 self.with_writer_tx("insert_note_if_absent", move |conn| {
1835 let mut stmt = conn.prepare_cached(&statement.sql)?;
1836 bind_params(&mut stmt, &statement.params)?;
1837 let inserted = stmt.raw_execute()? > 0;
1838 if inserted {
1842 assign_note_seq(conn, &id_str)?;
1843 }
1844 Ok(inserted)
1845 })
1846 .await
1847 }
1848
1849 async fn replace_note_if_unchanged(
1850 &self,
1851 note: Note,
1852 expected_updated_at: i64,
1853 expected_deleted_at: Option<i64>,
1854 ) -> Result<bool, StorageError> {
1855 let statement =
1856 note_replace_if_unchanged_statement(¬e, expected_updated_at, expected_deleted_at);
1857 self.with_writer("replace_note_if_unchanged", move |conn| {
1858 let mut stmt = conn.prepare(&statement.sql)?;
1859 bind_params(&mut stmt, &statement.params)?;
1860 Ok(stmt.raw_execute()? > 0)
1861 })
1862 .await
1863 }
1864
1865 async fn update_note_properties(
1866 &self,
1867 id: Uuid,
1868 properties: Option<serde_json::Value>,
1869 updated_at: i64,
1870 ) -> Result<bool, StorageError> {
1871 let statement = note_update_properties_statement(id, &properties, updated_at);
1872 self.with_writer("update_note_properties", move |conn| {
1873 let mut stmt = conn.prepare(&statement.sql)?;
1874 bind_params(&mut stmt, &statement.params)?;
1875 Ok(stmt.raw_execute()? > 0)
1876 })
1877 .await
1878 }
1879
1880 async fn set_note_property(
1881 &self,
1882 id: Uuid,
1883 key: &str,
1884 value: serde_json::Value,
1885 updated_at: i64,
1886 ) -> Result<bool, StorageError> {
1887 let statement = note_set_property_statement(id, key, &value, updated_at)?;
1888 self.with_writer("set_note_property", move |conn| {
1889 let mut stmt = conn.prepare(&statement.sql)?;
1890 bind_params(&mut stmt, &statement.params)?;
1891 Ok(stmt.raw_execute()? > 0)
1892 })
1893 .await
1894 }
1895
1896 async fn try_patch_note_property(
1897 &self,
1898 id: Uuid,
1899 namespace: &str,
1900 filter: &NoteFilter,
1901 json_path: &str,
1902 value: serde_json::Value,
1903 updated_at: i64,
1904 ) -> Result<bool, StorageError> {
1905 validate_note_time_order(filter, "try_patch_note_property", false)?;
1906 for property in &filter.property_filters {
1907 validate_json_path(&property.json_path)?;
1908 }
1909 let namespace = namespace.to_string();
1910 let filter = filter.clone();
1911 let value_json = serde_json::to_string(&value).map_err(|e| {
1912 StorageError::driver(StorageCapability::Notes, "try_patch_note_property", e)
1913 })?;
1914 let json_path = json_path.to_string();
1915
1916 self.with_writer("try_patch_note_property", move |conn| {
1917 execute_filtered_note_property_patch(
1918 conn,
1919 id,
1920 &namespace,
1921 &filter,
1922 &json_path,
1923 &value_json,
1924 updated_at,
1925 )
1926 .map(|rows| rows > 0)
1927 })
1928 .await
1929 }
1930
1931 async fn patch_note_property_atomic(
1932 &self,
1933 mut ids: Vec<Uuid>,
1934 namespace: &str,
1935 filter: &NoteFilter,
1936 json_path: &str,
1937 value: serde_json::Value,
1938 updated_at: i64,
1939 ) -> Result<(), StorageError> {
1940 validate_note_time_order(filter, "patch_note_property_atomic", false)?;
1941 for property in &filter.property_filters {
1942 validate_json_path(&property.json_path)?;
1943 }
1944 let mut seen = HashSet::with_capacity(ids.len());
1945 ids.retain(|id| seen.insert(*id));
1946 if ids.is_empty() {
1947 return Err(StorageError::InvalidInput {
1948 capability: StorageCapability::Notes,
1949 operation: "patch_note_property_atomic".into(),
1950 message: "at least one note id is required".to_string(),
1951 });
1952 }
1953
1954 let namespace = namespace.to_string();
1955 let filter = filter.clone();
1956 let value_json = serde_json::to_string(&value).map_err(|e| {
1957 StorageError::driver(StorageCapability::Notes, "patch_note_property_atomic", e)
1958 })?;
1959 let json_path = json_path.to_string();
1960
1961 self.with_writer_tx_storage("patch_note_property_atomic", move |conn| {
1962 for id in ids {
1963 let rows = execute_filtered_note_property_patch(
1964 conn,
1965 id,
1966 &namespace,
1967 &filter,
1968 &json_path,
1969 &value_json,
1970 updated_at,
1971 )
1972 .map_err(|error| map_err(error, "patch_note_property_atomic"))?;
1973 if rows != 1 {
1974 return Err(StorageError::Conflict {
1975 capability: StorageCapability::Notes,
1976 operation: "patch_note_property_atomic".into(),
1977 message: format!(
1978 "precondition failed for note {id}: guarded update changed {rows} rows; expected 1"
1979 ),
1980 });
1981 }
1982 }
1983 Ok(())
1984 })
1985 .await
1986 }
1987
1988 async fn try_insert_note(&self, note: Note) -> Result<bool, StorageError> {
1989 self.try_insert_note_with_attachments(note, Vec::new())
1990 .await
1991 }
1992
1993 async fn try_insert_note_with_attachments(
1994 &self,
1995 note: Note,
1996 attachments: Vec<Attachment>,
1997 ) -> Result<bool, StorageError> {
1998 let mut attachment_statements = Vec::with_capacity(attachments.len());
1999 for attachment in attachments {
2000 attachment.validate()?;
2001 if attachment.record_uuid != note.id
2002 || attachment.substrate != AttachmentSubstrate::Note
2003 {
2004 return Err(StorageError::InvalidInput {
2005 capability: StorageCapability::Attachments,
2006 operation: "try_insert_note_with_attachments".into(),
2007 message: format!(
2008 "attachment {} must target note {}",
2009 attachment.role, note.id
2010 ),
2011 });
2012 }
2013 attachment_statements.push(attachment_upsert_statement(&attachment)?);
2014 }
2015 let namespace = note.namespace.clone();
2016 let id_str = note.id.to_string();
2017 let kind_str = note.kind.to_string();
2018 let status_str = note.status.clone();
2019 let properties_str = note
2020 .properties
2021 .as_ref()
2022 .map(|v| serde_json::to_string(v).unwrap_or_default());
2023 let (due_key, due_source) = note_due_key_values(¬e.properties);
2024
2025 let ext_id_opt: Option<String> = note
2027 .properties
2028 .as_ref()
2029 .and_then(|v| v.get("external_id"))
2030 .and_then(|v| v.as_str())
2031 .filter(|s| !s.is_empty())
2032 .map(|s| s.to_string());
2033 let channel_kind = note
2034 .properties
2035 .as_ref()
2036 .and_then(|v| v.get("channel_kind"))
2037 .and_then(|v| v.as_str())
2038 .unwrap_or_default()
2039 .to_string();
2040 let channel_slug = note
2041 .properties
2042 .as_ref()
2043 .and_then(|v| v.get("channel_slug"))
2044 .and_then(|v| v.as_str())
2045 .unwrap_or_default()
2046 .to_string();
2047
2048 self.with_writer_tx("try_insert_note", move |conn| {
2049 let rows = conn.execute(
2050 "INSERT OR IGNORE INTO notes \
2051 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
2052 properties, created_at, updated_at, deleted_at, key, strict_due_key, due_source) \
2053 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
2054 rusqlite::params![
2055 id_str,
2056 namespace,
2057 kind_str,
2058 status_str,
2059 note.name,
2060 note.content,
2061 note.salience,
2062 note.decay_factor,
2063 note.expires_at,
2064 properties_str,
2065 note.created_at,
2066 note.updated_at,
2067 note.deleted_at,
2068 note.key,
2069 due_key,
2070 due_source,
2071 ],
2072 )?;
2073
2074 if rows > 0 {
2075 assign_note_seq(conn, &id_str)?;
2076 for statement in attachment_statements {
2077 let mut stmt = conn.prepare(&statement.sql)?;
2078 bind_params(&mut stmt, &statement.params)?;
2079 stmt.raw_execute()?;
2080 }
2081 return Ok(true);
2082 }
2083
2084 if let Some(ref ext_id) = ext_id_opt {
2091 let is_dedup: bool = conn.query_row(
2092 "SELECT COUNT(*) > 0 FROM notes \
2093 WHERE namespace = ?1 \
2094 AND kind = ?2 \
2095 AND json_extract(properties, '$.external_id') = ?3 \
2096 AND ifnull(json_extract(properties, '$.channel_kind'), '') = ?4 \
2097 AND ifnull(json_extract(properties, '$.channel_slug'), '') = ?5 \
2098 AND deleted_at IS NULL",
2099 rusqlite::params![namespace, kind_str, ext_id, channel_kind, channel_slug],
2100 |row| row.get(0),
2101 )?;
2102 if is_dedup {
2103 return Ok(false);
2104 }
2105 }
2106
2107 Err(rusqlite::Error::SqliteFailure(
2110 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
2111 Some(
2112 "try_insert_note: INSERT ignored for a constraint other than \
2113 external_id dedup; not masking as deduplication"
2114 .to_string(),
2115 ),
2116 ))
2117 })
2118 .await
2119 }
2120
2121 async fn upsert_notes(&self, notes: Vec<Note>) -> Result<BatchWriteSummary, StorageError> {
2122 let attempted = notes.len() as u64;
2123
2124 let origin = self.pool.origin();
2135 self.with_writer_tx("upsert_notes", move |conn| {
2136 let _tx_handle = khive_storage::tx_registry::register_scoped(
2137 Some("note_upsert_batch".to_string()),
2138 origin,
2139 );
2140 batch_upsert_notes(conn, ¬es, attempted)
2141 })
2142 .await
2143 }
2144
2145 async fn get_note(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
2146 let id_str = id.to_string();
2147
2148 self.with_reader("get_note", move |conn| {
2149 let mut stmt = conn.prepare(
2150 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
2151 properties, created_at, updated_at, deleted_at, key, version \
2152 FROM notes WHERE id = ?1 AND deleted_at IS NULL",
2153 )?;
2154 let mut rows = stmt.query(rusqlite::params![id_str])?;
2155 match rows.next()? {
2156 Some(row) => Ok(Some(read_note(row)?)),
2157 None => Ok(None),
2158 }
2159 })
2160 .await
2161 }
2162
2163 async fn get_note_including_deleted(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
2164 let id_str = id.to_string();
2165
2166 self.with_reader("get_note_including_deleted", move |conn| {
2167 let mut stmt = conn.prepare(
2168 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
2169 properties, created_at, updated_at, deleted_at, key, version \
2170 FROM notes WHERE id = ?1",
2171 )?;
2172 let mut rows = stmt.query(rusqlite::params![id_str])?;
2173 match rows.next()? {
2174 Some(row) => Ok(Some(read_note(row)?)),
2175 None => Ok(None),
2176 }
2177 })
2178 .await
2179 }
2180
2181 async fn note_sequence(&self, id: Uuid) -> Result<Option<i64>, StorageError> {
2182 let id = id.to_string();
2183 self.with_reader("note_sequence", move |conn| {
2184 conn.query_row(
2185 "SELECT seq FROM notes_seq WHERE note_id = ?1",
2186 rusqlite::params![id],
2187 |row| row.get(0),
2188 )
2189 .optional()
2190 })
2191 .await
2192 }
2193
2194 async fn get_notes_batch(&self, ids: &[Uuid]) -> Result<Vec<Note>, StorageError> {
2195 self.get_notes_batch_inner(ids, false).await
2196 }
2197
2198 async fn get_notes_batch_including_deleted(
2199 &self,
2200 ids: &[Uuid],
2201 ) -> Result<Vec<Note>, StorageError> {
2202 self.get_notes_batch_inner(ids, true).await
2203 }
2204
2205 async fn get_note_visibility_batch(
2206 &self,
2207 ids: &[Uuid],
2208 ) -> Result<Vec<NoteVisibility>, StorageError> {
2209 if ids.is_empty() {
2210 return Ok(vec![]);
2211 }
2212 const CHUNK: usize = 900;
2214 let id_strings: Vec<String> = ids.iter().map(Uuid::to_string).collect();
2215 let mut result = Vec::with_capacity(ids.len());
2216 for chunk in id_strings.chunks(CHUNK) {
2217 let chunk_owned = chunk.to_vec();
2218 let rows = self
2219 .with_reader("get_note_visibility_batch", move |conn| {
2220 let placeholders = (1..=chunk_owned.len())
2221 .map(|i| format!("?{i}"))
2222 .collect::<Vec<_>>()
2223 .join(", ");
2224 let sql = format!(
2225 "SELECT id, namespace, deleted_at FROM notes WHERE id IN ({placeholders})"
2226 );
2227 let mut stmt = conn.prepare(&sql)?;
2228 let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
2229 .iter()
2230 .map(|s| s as &dyn rusqlite::types::ToSql)
2231 .collect();
2232 let rows = stmt.query_map(params.as_slice(), |row| {
2233 let id: String = row.get(0)?;
2234 Ok(NoteVisibility {
2235 id: parse_uuid(&id)?,
2236 namespace: row.get(1)?,
2237 deleted_at: row.get(2)?,
2238 })
2239 })?;
2240 rows.collect::<rusqlite::Result<Vec<_>>>()
2241 })
2242 .await?;
2243 result.extend(rows);
2244 }
2245 Ok(result)
2246 }
2247
2248 async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
2249 match mode {
2250 DeleteMode::Soft => {
2251 let now = chrono::Utc::now().timestamp_micros();
2252 let statement = note_soft_delete_statement(id, now);
2253 self.with_writer("delete_note_soft", move |conn| {
2254 let mut stmt = conn.prepare(&statement.sql)?;
2255 bind_params(&mut stmt, &statement.params)?;
2256 Ok(stmt.raw_execute()? > 0)
2257 })
2258 .await
2259 }
2260 DeleteMode::Hard => {
2261 let note_statement = note_hard_delete_statement(id);
2262 let attachment_statement =
2263 delete_record_attachments_statement(id, AttachmentSubstrate::Note);
2264 self.with_writer_tx("delete_note_hard", move |conn| {
2265 let mut note_stmt = conn.prepare(¬e_statement.sql)?;
2266 bind_params(&mut note_stmt, ¬e_statement.params)?;
2267 let deleted = note_stmt.raw_execute()? > 0;
2268 drop(note_stmt);
2269 if deleted {
2270 let mut attachment_stmt = conn.prepare(&attachment_statement.sql)?;
2271 bind_params(&mut attachment_stmt, &attachment_statement.params)?;
2272 attachment_stmt.raw_execute()?;
2273 }
2274 Ok(deleted)
2275 })
2276 .await
2277 }
2278 }
2279 }
2280
2281 async fn query_notes(
2282 &self,
2283 namespace: &str,
2284 kind: Option<&str>,
2285 page: PageRequest,
2286 ) -> Result<Page<Note>, StorageError> {
2287 let namespace = namespace.to_string();
2288 let kind = kind.map(|k| k.to_string());
2289 let limit_i64 = i64::from(page.limit);
2290 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
2291 capability: StorageCapability::Notes,
2292 operation: "query_notes".into(),
2293 message: format!(
2294 "PageRequest: offset must be <= i64::MAX, got {}",
2295 page.offset
2296 ),
2297 })?;
2298
2299 self.with_reader("query_notes", move |conn| {
2300 let (count_sql, count_params) = build_note_where(&namespace, kind.as_deref());
2301 let count_sql = format!("SELECT COUNT(*) FROM notes{count_sql}");
2302
2303 let (where_sql, mut data_params) = build_note_where(&namespace, kind.as_deref());
2304 data_params.push(Box::new(limit_i64));
2305 data_params.push(Box::new(offset_i64));
2306
2307 let limit_idx = data_params.len() - 1;
2308 let offset_idx = data_params.len();
2309
2310 let data_sql = format!(
2311 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
2312 properties, created_at, updated_at, deleted_at, key, version \
2313 FROM notes{} ORDER BY created_at DESC, id ASC LIMIT ?{} OFFSET ?{}",
2314 where_sql, limit_idx, offset_idx,
2315 );
2316
2317 query_note_page_snapshot(
2318 conn,
2319 "query_notes",
2320 &namespace,
2321 &count_sql,
2322 &count_params,
2323 &data_sql,
2324 &data_params,
2325 )
2326 })
2327 .await
2328 }
2329
2330 async fn query_notes_count_free(
2331 &self,
2332 namespace: &str,
2333 kind: Option<&str>,
2334 page: PageRequest,
2335 ) -> Result<Page<Note>, StorageError> {
2336 let namespace = namespace.to_string();
2337 let kind = kind.map(str::to_string);
2338 let limit_i64 = i64::from(page.limit);
2339 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
2340 capability: StorageCapability::Notes,
2341 operation: "query_notes_count_free".into(),
2342 message: format!(
2343 "PageRequest: offset must be <= i64::MAX, got {}",
2344 page.offset
2345 ),
2346 })?;
2347
2348 self.with_reader("query_notes_count_free", move |conn| {
2349 let (where_sql, mut params) = build_note_where(&namespace, kind.as_deref());
2350 params.push(Box::new(limit_i64));
2351 params.push(Box::new(offset_i64));
2352 let limit_idx = params.len() - 1;
2353 let offset_idx = params.len();
2354 let sql = format!(
2355 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
2356 expires_at, properties, created_at, updated_at, deleted_at, key, version \
2357 FROM notes{where_sql} ORDER BY created_at DESC, id ASC \
2358 LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
2359 );
2360
2361 let mut stmt = conn.prepare(&sql)?;
2362 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2363 params.iter().map(|param| param.as_ref()).collect();
2364 let mut rows = stmt.query(param_refs.as_slice())?;
2365 let mut items = Vec::new();
2366 while let Some(row) = rows.next()? {
2367 items.push(read_note(row)?);
2368 }
2369
2370 Ok(Page { items, total: None })
2371 })
2372 .await
2373 }
2374
2375 async fn query_notes_filtered(
2376 &self,
2377 namespace: &str,
2378 filter: &NoteFilter,
2379 page: PageRequest,
2380 ) -> Result<Page<Note>, StorageError> {
2381 validate_note_time_order(filter, "query_notes_filtered", true)?;
2382 if filter.unordered {
2383 return Err(StorageError::InvalidInput {
2384 capability: StorageCapability::Notes,
2385 operation: "query_notes_filtered".into(),
2386 message: "NoteFilter.unordered is supported only by \
2387 query_notes_filtered_count_free"
2388 .into(),
2389 });
2390 }
2391 for pf in &filter.property_filters {
2393 validate_json_path(&pf.json_path)?;
2394 }
2395 if let Some((path, _)) = &filter.order_by {
2396 validate_json_path(path)?;
2397 }
2398 if filter.order_by_instant && !matches!(filter.order_by.as_ref(), Some((_, SortDir::Asc))) {
2399 return Err(StorageError::InvalidInput {
2400 capability: StorageCapability::Notes,
2401 operation: "query_notes_filtered".into(),
2402 message: "order_by_instant requires an ascending property order".into(),
2403 });
2404 }
2405 if filter.after.is_some() || filter.after_instant.is_some() {
2406 return Err(StorageError::InvalidInput {
2407 capability: StorageCapability::Notes,
2408 operation: "query_notes_filtered".into(),
2409 message: "NoteFilter.after or after_instant (keyset pagination) is not supported by this \
2410 method: it computes an exact COUNT(*) total over the whole \
2411 matching set, which has no defined meaning paired with a seek \
2412 boundary; use query_notes_filtered_count_free instead, which \
2413 seeks and returns total: None"
2414 .into(),
2415 });
2416 }
2417
2418 let namespace = namespace.to_string();
2419 let filter = filter.clone();
2420 let limit_i64 = i64::from(page.limit);
2421 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
2422 capability: StorageCapability::Notes,
2423 operation: "query_notes_filtered".into(),
2424 message: format!(
2425 "PageRequest: offset must be <= i64::MAX, got {}",
2426 page.offset
2427 ),
2428 })?;
2429
2430 self.with_indexed_reader("query_notes_filtered", move |conn| {
2431 let (count_sql, count_params) = build_note_filter_read_clause(&namespace, &filter)?;
2432 let count_sql = format!("SELECT COUNT(*) FROM notes{count_sql}");
2433
2434 let (where_sql, mut data_params) = build_note_filter_read_clause(&namespace, &filter)?;
2435 data_params.push(Box::new(limit_i64));
2436 data_params.push(Box::new(offset_i64));
2437
2438 let order_clause = note_filter_page_order_clause(&filter);
2442
2443 let limit_idx = data_params.len() - 1;
2444 let offset_idx = data_params.len();
2445 let data_sql = format!(
2446 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
2447 expires_at, properties, created_at, updated_at, deleted_at, key, version \
2448 FROM notes{}{order_clause} LIMIT ?{} OFFSET ?{}",
2449 where_sql, limit_idx, offset_idx,
2450 );
2451
2452 query_note_page_snapshot(
2453 conn,
2454 "query_notes_filtered",
2455 &namespace,
2456 &count_sql,
2457 &count_params,
2458 &data_sql,
2459 &data_params,
2460 )
2461 })
2462 .await
2463 }
2464
2465 async fn query_notes_filtered_count_free(
2466 &self,
2467 namespace: &str,
2468 filter: &NoteFilter,
2469 page: PageRequest,
2470 ) -> Result<Page<Note>, StorageError> {
2471 validate_note_time_order(filter, "query_notes_filtered_count_free", true)?;
2472 for property_filter in &filter.property_filters {
2473 validate_json_path(&property_filter.json_path)?;
2474 }
2475 if let Some((path, _)) = &filter.order_by {
2476 validate_json_path(path)?;
2477 }
2478 if filter.order_by_instant && !matches!(filter.order_by.as_ref(), Some((_, SortDir::Asc))) {
2479 return Err(StorageError::InvalidInput {
2480 capability: StorageCapability::Notes,
2481 operation: "query_notes_filtered_count_free".into(),
2482 message: "order_by_instant requires an ascending property order".into(),
2483 });
2484 }
2485 if filter.order_by_instant && filter.unordered {
2486 return Err(StorageError::InvalidInput {
2487 capability: StorageCapability::Notes,
2488 operation: "query_notes_filtered_count_free".into(),
2489 message: "order_by_instant is incompatible with unordered pages".into(),
2490 });
2491 }
2492 if filter.after_instant.is_some() && !filter.order_by_instant {
2493 return Err(StorageError::InvalidInput {
2494 capability: StorageCapability::Notes,
2495 operation: "query_notes_filtered_count_free".into(),
2496 message: "after_instant requires order_by_instant".into(),
2497 });
2498 }
2499 if filter.after.is_some() && filter.after_instant.is_some() {
2500 return Err(StorageError::InvalidInput {
2501 capability: StorageCapability::Notes,
2502 operation: "query_notes_filtered_count_free".into(),
2503 message: "after and after_instant are mutually exclusive".into(),
2504 });
2505 }
2506 if filter.after.is_some() && filter.order_by.is_some() {
2507 return Err(StorageError::InvalidInput {
2508 capability: StorageCapability::Notes,
2509 operation: "query_notes_filtered_count_free".into(),
2510 message: "NoteFilter.after is incompatible with a custom order_by; it is \
2511 defined only over the default created_at DESC, id ASC order"
2512 .into(),
2513 });
2514 }
2515 if (filter.after.is_some() || filter.after_instant.is_some()) && page.offset != 0 {
2516 return Err(StorageError::InvalidInput {
2517 capability: StorageCapability::Notes,
2518 operation: "query_notes_filtered_count_free".into(),
2519 message: "NoteFilter.after or after_instant and a non-zero PageRequest.offset \
2520 are mutually exclusive pagination strategies; pass offset: 0"
2521 .into(),
2522 });
2523 }
2524
2525 let namespace = namespace.to_string();
2526 let filter = filter.clone();
2527 let limit_i64 = i64::from(page.limit);
2528 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
2529 capability: StorageCapability::Notes,
2530 operation: "query_notes_filtered_count_free".into(),
2531 message: format!(
2532 "PageRequest: offset must be <= i64::MAX, got {}",
2533 page.offset
2534 ),
2535 })?;
2536
2537 self.with_indexed_reader("query_notes_filtered_count_free", move |conn| {
2538 if let Some(after) = &filter.after_instant {
2539 let mut base_filter = filter.clone();
2540 base_filter.after_instant = None;
2541 let items =
2542 fetch_notes_after_instant(conn, &namespace, &base_filter, after, limit_i64)?;
2543 return Ok(Page { items, total: None });
2544 }
2545 if let Some(after) = &filter.after {
2546 let mut base_filter = filter.clone();
2547 base_filter.after = None;
2548 let items = fetch_notes_after(conn, &namespace, &base_filter, after, limit_i64)?;
2549 return Ok(Page { items, total: None });
2550 }
2551
2552 let (sql, params) = note_filter_page_sql(&namespace, &filter, limit_i64, offset_i64)?;
2553
2554 let mut stmt = conn.prepare(&sql)?;
2555 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2556 params.iter().map(|param| param.as_ref()).collect();
2557 let mut rows = stmt.query(param_refs.as_slice())?;
2558 let mut items = Vec::new();
2559 while let Some(row) = rows.next()? {
2560 items.push(read_note(row)?);
2561 #[cfg(test)]
2565 if items.len() == 1 {
2566 tests::page_snapshot_seam::hook("query_notes_filtered_count_free", &namespace);
2567 }
2568 }
2569
2570 Ok(Page { items, total: None })
2571 })
2572 .await
2573 }
2574
2575 async fn count_notes_filtered_in_snapshot(
2576 &self,
2577 namespace: &str,
2578 filters: &[NoteFilter],
2579 ) -> Result<Vec<u64>, StorageError> {
2580 for filter in filters {
2581 for pf in &filter.property_filters {
2582 validate_json_path(&pf.json_path)?;
2583 }
2584 }
2585
2586 let namespace = namespace.to_string();
2587 let filters = filters.to_vec();
2588 self.with_indexed_reader("count_notes_filtered_in_snapshot", move |conn| {
2589 let tx = rusqlite::Transaction::new_unchecked(
2590 conn,
2591 rusqlite::TransactionBehavior::Deferred,
2592 )?;
2593 let mut counts = Vec::with_capacity(filters.len());
2594 for filter in filters.iter() {
2595 #[cfg(test)]
2596 if !counts.is_empty() {
2597 tests::page_snapshot_seam::hook("count_notes_filtered_in_snapshot", &namespace);
2598 }
2599
2600 let (where_sql, params) = build_note_filter_read_clause(&namespace, filter)?;
2601 let sql = format!("SELECT COUNT(*) FROM notes{where_sql}");
2602 let mut stmt = tx.prepare(&sql)?;
2603 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2604 params.iter().map(|param| param.as_ref()).collect();
2605 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
2606 counts.push(count as u64);
2607 }
2608 tx.commit()?;
2609 Ok(counts)
2610 })
2611 .await
2612 }
2613
2614 async fn count_notes_filtered_bounded_in_snapshot(
2615 &self,
2616 namespace: &str,
2617 filters: &[NoteFilter],
2618 cap: u32,
2619 ) -> Result<Vec<BoundedCount>, StorageError> {
2620 for filter in filters {
2621 for property_filter in &filter.property_filters {
2622 validate_json_path(&property_filter.json_path)?;
2623 }
2624 }
2625
2626 let namespace = namespace.to_string();
2627 let filters = filters.to_vec();
2628 let cap_u64 = u64::from(cap);
2629 let probe_limit_i64 = i64::from(cap) + 1;
2630 self.with_indexed_reader("count_notes_filtered_bounded_in_snapshot", move |conn| {
2631 let tx = rusqlite::Transaction::new_unchecked(
2632 conn,
2633 rusqlite::TransactionBehavior::Deferred,
2634 )?;
2635 let mut counts = Vec::with_capacity(filters.len());
2636 for filter in &filters {
2637 #[cfg(test)]
2638 if !counts.is_empty() {
2639 tests::page_snapshot_seam::hook(
2640 "count_notes_filtered_bounded_in_snapshot",
2641 &namespace,
2642 );
2643 }
2644
2645 let (where_sql, mut params) = build_note_filter_read_clause(&namespace, filter)?;
2646 params.push(Box::new(probe_limit_i64));
2647 let limit_idx = params.len();
2648 let sql = format!(
2653 "SELECT COUNT(*) FROM (SELECT 1 FROM notes{where_sql} LIMIT ?{limit_idx})"
2654 );
2655 let mut stmt = tx.prepare(&sql)?;
2656 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2657 params.iter().map(|param| param.as_ref()).collect();
2658 let observed: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
2659 let observed = observed as u64;
2660 counts.push(BoundedCount {
2661 count: observed.min(cap_u64),
2662 cap: cap_u64,
2663 saturated: observed > cap_u64,
2664 });
2665 }
2666 tx.commit()?;
2667 Ok(counts)
2668 })
2669 .await
2670 }
2671
2672 async fn query_notes_filtered_after(
2673 &self,
2674 namespace: &str,
2675 filter: &NoteFilter,
2676 after: Option<SeekCursor>,
2677 limit: u32,
2678 ) -> Result<SeekPage<Note>, StorageError> {
2679 validate_note_time_order(filter, "query_notes_filtered_after", false)?;
2680 if limit == 0 {
2681 return Ok(SeekPage::default());
2682 }
2683 if filter.order_by.is_some() {
2684 return Err(StorageError::InvalidInput {
2685 capability: StorageCapability::Notes,
2686 operation: "query_notes_filtered_after".into(),
2687 message: "custom order_by is not compatible with insertion-sequence pagination"
2688 .into(),
2689 });
2690 }
2691 for property_filter in &filter.property_filters {
2692 validate_json_path(&property_filter.json_path)?;
2693 }
2694
2695 let namespace = namespace.to_string();
2696 let filter = filter.clone();
2697 let limit_usize = limit as usize;
2698 let probe_limit_i64 = i64::from(limit) + 1;
2699 self.with_reader("query_notes_filtered_after", move |conn| {
2700 let (mut where_sql, mut params) = build_note_filter_where(&namespace, &filter)?;
2701 if let Some(cursor) = after {
2702 params.push(Box::new(cursor.sequence));
2703 where_sql.push_str(&format!(" AND notes_seq.seq > ?{}", params.len()));
2704 }
2705 params.push(Box::new(probe_limit_i64));
2706 let limit_idx = params.len();
2707 let sql = format!(
2710 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
2711 expires_at, properties, created_at, updated_at, deleted_at, key, version, notes_seq.seq \
2712 FROM notes_seq CROSS JOIN notes ON notes.id = notes_seq.note_id{where_sql} \
2713 ORDER BY notes_seq.seq ASC LIMIT ?{limit_idx}"
2714 );
2715 let mut stmt = conn.prepare(&sql)?;
2716 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2717 params.iter().map(|param| param.as_ref()).collect();
2718 let rows = stmt.query_map(param_refs.as_slice(), |row| {
2719 Ok((read_note(row)?, row.get::<_, i64>(15)?))
2720 })?;
2721 let mut entries = rows.collect::<Result<Vec<_>, _>>()?;
2722 let has_more = entries.len() > limit_usize;
2723 if has_more {
2724 entries.truncate(limit_usize);
2725 }
2726 let next_after = if has_more {
2727 entries.last().map(|(note, sequence)| SeekCursor {
2728 sequence: *sequence,
2729 id: note.id,
2730 })
2731 } else {
2732 None
2733 };
2734 let items = entries.into_iter().map(|(note, _)| note).collect();
2735 Ok(SeekPage { items, next_after })
2736 })
2737 .await
2738 }
2739
2740 async fn query_notes_filtered_bounded(
2741 &self,
2742 namespace: &str,
2743 filter: &NoteFilter,
2744 max_rows: u32,
2745 ) -> Result<Vec<Note>, StorageError> {
2746 validate_note_time_order(filter, "query_notes_filtered_bounded", true)?;
2747 if filter.unordered {
2748 return Err(StorageError::InvalidInput {
2749 capability: StorageCapability::Notes,
2750 operation: "query_notes_filtered_bounded".into(),
2751 message: "NoteFilter.unordered is supported only by \
2752 query_notes_filtered_count_free"
2753 .into(),
2754 });
2755 }
2756 for pf in &filter.property_filters {
2757 validate_json_path(&pf.json_path)?;
2758 }
2759 if let Some((path, _)) = &filter.order_by {
2760 validate_json_path(path)?;
2761 }
2762
2763 let namespace = namespace.to_string();
2764 let filter = filter.clone();
2765 let limit_i64 = i64::from(max_rows) + 1;
2766
2767 self.with_indexed_reader("query_notes_filtered_bounded", move |conn| {
2768 let (where_sql, mut data_params) = build_note_filter_read_clause(&namespace, &filter)?;
2769 data_params.push(Box::new(limit_i64));
2770 let limit_idx = data_params.len();
2771
2772 let order_clause = match (filter.time_order, &filter.order_by) {
2776 (Some(order), _) => note_time_order_clause(order).to_string(),
2777 (None, Some((path, dir))) => {
2778 let dir_str = match dir {
2779 SortDir::Asc => "ASC",
2780 SortDir::Desc => "DESC",
2781 };
2782 format!(" ORDER BY {} {dir_str}, id ASC", json_extract_expr(path))
2783 }
2784 (None, None) => " ORDER BY created_at DESC, id ASC".to_string(),
2785 };
2786
2787 let data_sql = format!(
2788 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
2789 expires_at, properties, created_at, updated_at, deleted_at, key, version \
2790 FROM notes{where_sql}{order_clause} LIMIT ?{limit_idx}",
2791 );
2792
2793 let mut stmt = conn.prepare(&data_sql)?;
2794 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2795 data_params.iter().map(|p| p.as_ref()).collect();
2796 let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
2797
2798 let mut items = Vec::new();
2799 for row in rows {
2800 items.push(row?);
2801 }
2802 Ok(items)
2803 })
2804 .await
2805 }
2806
2807 async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> Result<u64, StorageError> {
2808 let namespace = namespace.to_string();
2809 let kind = kind.map(|k| k.to_string());
2810
2811 self.with_reader("count_notes", move |conn| {
2812 let (where_sql, params) = build_note_where(&namespace, kind.as_deref());
2813 let sql = format!("SELECT COUNT(*) FROM notes{}", where_sql);
2814 let mut stmt = conn.prepare(&sql)?;
2815 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2816 params.iter().map(|p| p.as_ref()).collect();
2817 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
2818 Ok(count as u64)
2819 })
2820 .await
2821 }
2822
2823 async fn count_notes_in_namespaces(
2824 &self,
2825 namespaces: &[String],
2826 kind: Option<&str>,
2827 ) -> Result<u64, StorageError> {
2828 let namespaces: Vec<String> = namespaces
2829 .iter()
2830 .cloned()
2831 .collect::<HashSet<_>>()
2832 .into_iter()
2833 .collect();
2834 let kind = kind.map(str::to_string);
2835
2836 self.with_reader("count_notes_in_namespaces", move |conn| {
2837 let mut total = 0;
2838 for chunk in namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
2839 let (where_sql, params) = build_note_where_for_namespaces(chunk, kind.as_deref());
2840 let sql = format!("SELECT COUNT(*) FROM notes{where_sql}");
2841 let mut stmt = conn.prepare(&sql)?;
2842 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2843 params.iter().map(|p| p.as_ref()).collect();
2844 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
2845 total += count as u64;
2846 }
2847 Ok(total)
2848 })
2849 .await
2850 }
2851}
2852
2853const NOTES_DDL: &str = include_str!("../../sql/notes-ddl.sql");
2858
2859const NOTES_SEQ_REPAIR_DDL: &str = include_str!("../../sql/008-notes-seq-repair.sql");
2866
2867pub(crate) fn ensure_notes_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
2868 conn.execute_batch(NOTES_DDL)
2869}
2870
2871pub(crate) fn repair_notes_seq(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
2877 conn.execute_batch(NOTES_SEQ_REPAIR_DDL)
2878}
2879
2880#[cfg(test)]
2881#[path = "note_tests.rs"]
2882mod tests;
2883
2884#[cfg(test)]
2885#[path = "comm_filter_plan_tests.rs"]
2886mod comm_filter_plan_tests;
2887
2888#[cfg(test)]
2889#[path = "note_list_plan_tests.rs"]
2890mod note_list_plan_tests;
2891
2892#[cfg(test)]
2893#[path = "note_busy_tests.rs"]
2894mod direct_busy_tests;