Skip to main content

khive_storage/
note.rs

1//! Note storage capability — temporal-referential record CRUD.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use uuid::Uuid;
7
8use crate::types::{BatchWriteSummary, DeleteMode, Page, PageRequest, SqlValue, StorageResult};
9
10/// A storage-level note record. Flat, SQL-friendly representation.
11#[derive(Clone, Debug, Serialize, Deserialize)]
12pub struct Note {
13    pub id: Uuid,
14    pub namespace: String,
15    pub kind: String,
16    pub status: String,
17    pub name: Option<String>,
18    pub content: String,
19    pub salience: Option<f64>,
20    pub decay_factor: Option<f64>,
21    pub expires_at: Option<i64>,
22    pub properties: Option<Value>,
23    pub created_at: i64,
24    pub updated_at: i64,
25    pub deleted_at: Option<i64>,
26}
27
28impl Note {
29    /// Create a new note with a generated UUID and current timestamp.
30    pub fn new(
31        namespace: impl Into<String>,
32        kind: impl Into<String>,
33        content: impl Into<String>,
34    ) -> Self {
35        let now = chrono::Utc::now().timestamp_micros();
36        Self {
37            id: Uuid::new_v4(),
38            namespace: namespace.into(),
39            kind: kind.into(),
40            status: "active".to_string(),
41            name: None,
42            content: content.into(),
43            salience: None,
44            decay_factor: None,
45            expires_at: None,
46            properties: None,
47            created_at: now,
48            updated_at: now,
49            deleted_at: None,
50        }
51    }
52
53    /// Set the note display name.
54    pub fn with_name(mut self, n: impl Into<String>) -> Self {
55        self.name = Some(n.into());
56        self
57    }
58
59    /// Set salience (infallible). Rejects non-finite values by returning `self`
60    /// unchanged; clamps finite values to `[0.0, 1.0]`. Prefer
61    /// [`try_with_salience`](Self::try_with_salience) at public boundaries.
62    pub fn with_salience(mut self, s: f64) -> Self {
63        if !s.is_finite() {
64            return self;
65        }
66        self.salience = Some(s.clamp(0.0, 1.0));
67        self
68    }
69
70    /// Set decay factor (infallible). Rejects non-finite values by returning
71    /// `self` unchanged; floors finite values at `0.0`. Prefer
72    /// [`try_with_decay`](Self::try_with_decay) at public boundaries.
73    pub fn with_decay(mut self, d: f64) -> Self {
74        if !d.is_finite() {
75            return self;
76        }
77        self.decay_factor = Some(d.max(0.0));
78        self
79    }
80
81    /// Set salience with validation. Returns an error for non-finite or
82    /// out-of-range `[0.0, 1.0]` values.
83    pub fn try_with_salience(mut self, s: f64) -> Result<Self, String> {
84        if !s.is_finite() {
85            return Err(format!("salience must be finite, got {s}"));
86        }
87        if !(0.0..=1.0).contains(&s) {
88            return Err(format!("salience must be in [0.0, 1.0], got {s}"));
89        }
90        self.salience = Some(s);
91        Ok(self)
92    }
93
94    /// Set decay factor with validation. Returns an error for non-finite or
95    /// negative values.
96    pub fn try_with_decay(mut self, d: f64) -> Result<Self, String> {
97        if !d.is_finite() {
98            return Err(format!("decay_factor must be finite, got {d}"));
99        }
100        if d < 0.0 {
101            return Err(format!("decay_factor must be >= 0.0, got {d}"));
102        }
103        self.decay_factor = Some(d);
104        Ok(self)
105    }
106
107    /// Set the note properties JSON blob.
108    pub fn with_properties(mut self, p: Value) -> Self {
109        self.properties = Some(p);
110        self
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn base_note() -> Note {
119        Note::new("ns:test", "memory", "hello world")
120    }
121
122    // -- with_salience --
123
124    #[test]
125    fn with_salience_clamps_to_range() {
126        let n = base_note().with_salience(1.5);
127        assert_eq!(n.salience, Some(1.0));
128        let n = base_note().with_salience(-0.1);
129        assert_eq!(n.salience, Some(0.0));
130        let n = base_note().with_salience(0.7);
131        assert_eq!(n.salience, Some(0.7));
132    }
133
134    #[test]
135    fn with_salience_ignores_nan() {
136        let n = base_note().with_salience(f64::NAN);
137        assert_eq!(n.salience, None, "NaN must not set salience");
138    }
139
140    #[test]
141    fn with_salience_ignores_inf() {
142        let n = base_note().with_salience(f64::INFINITY);
143        assert_eq!(n.salience, None, "+Inf must not set salience");
144        let n = base_note().with_salience(f64::NEG_INFINITY);
145        assert_eq!(n.salience, None, "-Inf must not set salience");
146    }
147
148    // -- with_decay --
149
150    #[test]
151    fn with_decay_floors_at_zero() {
152        let n = base_note().with_decay(-1.0);
153        assert_eq!(n.decay_factor, Some(0.0));
154        let n = base_note().with_decay(0.5);
155        assert_eq!(n.decay_factor, Some(0.5));
156    }
157
158    #[test]
159    fn with_decay_ignores_nan() {
160        let n = base_note().with_decay(f64::NAN);
161        assert_eq!(n.decay_factor, None, "NaN must not set decay_factor");
162    }
163
164    #[test]
165    fn with_decay_ignores_inf() {
166        let n = base_note().with_decay(f64::INFINITY);
167        assert_eq!(n.decay_factor, None, "+Inf must not set decay_factor");
168    }
169
170    // -- try_with_salience --
171
172    #[test]
173    fn try_with_salience_accepts_valid_range() {
174        let n = base_note().try_with_salience(0.0).unwrap();
175        assert_eq!(n.salience, Some(0.0));
176        let n = base_note().try_with_salience(1.0).unwrap();
177        assert_eq!(n.salience, Some(1.0));
178        let n = base_note().try_with_salience(0.85).unwrap();
179        assert_eq!(n.salience, Some(0.85));
180    }
181
182    #[test]
183    fn try_with_salience_rejects_nan() {
184        let err = base_note().try_with_salience(f64::NAN).unwrap_err();
185        assert!(err.contains("finite"), "error must mention finite: {err}");
186    }
187
188    #[test]
189    fn try_with_salience_rejects_out_of_range() {
190        let err = base_note().try_with_salience(1.1).unwrap_err();
191        assert!(err.contains("1.0"), "error must mention bound: {err}");
192        let err = base_note().try_with_salience(-0.01).unwrap_err();
193        assert!(err.contains("0.0"), "error must mention bound: {err}");
194    }
195
196    // -- try_with_decay --
197
198    #[test]
199    fn try_with_decay_accepts_valid_values() {
200        let n = base_note().try_with_decay(0.0).unwrap();
201        assert_eq!(n.decay_factor, Some(0.0));
202        let n = base_note().try_with_decay(2.5).unwrap();
203        assert_eq!(n.decay_factor, Some(2.5));
204    }
205
206    #[test]
207    fn try_with_decay_rejects_nan() {
208        let err = base_note().try_with_decay(f64::NAN).unwrap_err();
209        assert!(err.contains("finite"), "error must mention finite: {err}");
210    }
211
212    #[test]
213    fn try_with_decay_rejects_negative() {
214        let err = base_note().try_with_decay(-0.1).unwrap_err();
215        assert!(err.contains("0.0"), "error must mention bound: {err}");
216    }
217}
218
219/// Sort direction for filtered note queries.
220#[derive(Clone, Debug, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum SortDir {
223    Asc,
224    Desc,
225}
226
227/// Comparison operator for a [`PropertyFilter`] on a JSON path.
228#[derive(Clone, Debug, Serialize, Deserialize)]
229#[serde(rename_all = "snake_case")]
230pub enum FilterOp {
231    Eq,
232    /// Matches rows where the JSON field equals the value OR the field is absent/NULL.
233    /// Used for properties that may be missing in legacy rows (e.g. `$.read`).
234    EqOrMissing,
235    Ne,
236    Lt,
237    Lte,
238    Gt,
239    Gte,
240    /// Matches rows where `json_type(properties, path) = value`.
241    /// Value must be a SQLite json_type string literal: 'true', 'false', 'integer',
242    /// 'real', 'text', 'array', 'object', or 'null'.
243    JsonTypeEq,
244    /// Matches rows where the json_type is absent (NULL) OR differs from value.
245    /// Equivalent to `json_type IS NULL OR json_type != value`.
246    /// Used for unread filter: matches any `$.read` that is NOT the JSON boolean true.
247    JsonTypeNeMissing,
248}
249
250/// A single `json_extract(properties, '$.field') op value` predicate.
251///
252/// Callers import this as `khive_storage::note::PropertyFilter` to avoid
253/// collision with the vector-metadata `PropertyFilter` in `khive_storage::types`.
254#[derive(Clone, Debug, Serialize, Deserialize)]
255pub struct PropertyFilter {
256    pub json_path: String,
257    pub op: FilterOp,
258    pub value: SqlValue,
259}
260
261/// Filter + sort options for [`NoteStore::query_notes_filtered`].
262///
263/// Designed for general property-based filtering on any JSON field, not
264/// schedule-specific, so D9 and future packs can reuse the same API.
265#[derive(Clone, Debug, Default, Serialize, Deserialize)]
266pub struct NoteFilter {
267    pub kind: Option<String>,
268    #[serde(default)]
269    pub property_filters: Vec<PropertyFilter>,
270    /// `(json_path, direction)` — `None` defaults to `created_at DESC`.
271    pub order_by: Option<(String, SortDir)>,
272}
273
274/// Temporal-referential note CRUD over the notes substrate table.
275#[async_trait]
276pub trait NoteStore: Send + Sync + 'static {
277    /// Insert or update a single note.
278    async fn upsert_note(&self, note: Note) -> StorageResult<()>;
279    /// Insert or update a batch of notes.
280    async fn upsert_notes(&self, notes: Vec<Note>) -> StorageResult<BatchWriteSummary>;
281    /// Fetch a note by UUID, returning `None` if absent.
282    async fn get_note(&self, id: Uuid) -> StorageResult<Option<Note>>;
283    /// Fetch a note by UUID regardless of soft-deletion state.
284    ///
285    /// Returns the note row even when `deleted_at` is set. Callers use this
286    /// to distinguish "soft-deleted" from "never existed".
287    async fn get_note_including_deleted(&self, id: Uuid) -> StorageResult<Option<Note>>;
288    /// Delete a note by UUID using the specified delete mode.
289    async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> StorageResult<bool>;
290    /// Query notes by namespace and optional kind with pagination.
291    async fn query_notes(
292        &self,
293        namespace: &str,
294        kind: Option<&str>,
295        page: PageRequest,
296    ) -> StorageResult<Page<Note>>;
297    /// Query notes with property-based filtering and custom sort.
298    async fn query_notes_filtered(
299        &self,
300        namespace: &str,
301        filter: &NoteFilter,
302        page: PageRequest,
303    ) -> StorageResult<Page<Note>>;
304    /// Count notes in a namespace, optionally filtered by kind.
305    async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> StorageResult<u64>;
306
307    /// Fetch multiple notes by UUID in a single call.
308    async fn get_notes_batch(&self, ids: &[Uuid]) -> StorageResult<Vec<Note>> {
309        let mut out = Vec::with_capacity(ids.len());
310        for &id in ids {
311            if let Some(n) = self.get_note(id).await? {
312                out.push(n);
313            }
314        }
315        Ok(out)
316    }
317}