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    /// When non-empty, restricts results to any of these namespaces using
273    /// `namespace IN (...)`. Takes precedence over the `namespace` string
274    /// parameter passed to `query_notes_filtered`. When empty the
275    /// caller-supplied `namespace` parameter is used (backward-compatible).
276    #[serde(default)]
277    pub namespaces: Vec<String>,
278    /// Restrict to notes where `created_at >= min_created_at` (microseconds epoch).
279    /// `None` applies no lower-bound constraint.
280    pub min_created_at: Option<i64>,
281}
282
283/// Temporal-referential note CRUD over the notes substrate table.
284#[async_trait]
285pub trait NoteStore: Send + Sync + 'static {
286    /// Insert or update a single note.
287    async fn upsert_note(&self, note: Note) -> StorageResult<()>;
288    /// Insert or update a batch of notes.
289    async fn upsert_notes(&self, notes: Vec<Note>) -> StorageResult<BatchWriteSummary>;
290    /// Fetch a note by UUID, returning `None` if absent.
291    async fn get_note(&self, id: Uuid) -> StorageResult<Option<Note>>;
292    /// Fetch a note by UUID regardless of soft-deletion state.
293    ///
294    /// Returns the note row even when `deleted_at` is set. Callers use this
295    /// to distinguish "soft-deleted" from "never existed".
296    async fn get_note_including_deleted(&self, id: Uuid) -> StorageResult<Option<Note>>;
297    /// Delete a note by UUID using the specified delete mode.
298    async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> StorageResult<bool>;
299    /// Query notes by namespace and optional kind with pagination.
300    async fn query_notes(
301        &self,
302        namespace: &str,
303        kind: Option<&str>,
304        page: PageRequest,
305    ) -> StorageResult<Page<Note>>;
306    /// Query notes with property-based filtering and custom sort.
307    async fn query_notes_filtered(
308        &self,
309        namespace: &str,
310        filter: &NoteFilter,
311        page: PageRequest,
312    ) -> StorageResult<Page<Note>>;
313    /// Count notes in a namespace, optionally filtered by kind.
314    async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> StorageResult<u64>;
315
316    /// Attempt to insert a note without overwriting an existing row.
317    ///
318    /// Returns `true` when the row was newly written.  Returns `false` only
319    /// when a live note with the same non-empty `external_id` already exists in
320    /// the same namespace and kind (confirmed dedup hit).  Any other constraint
321    /// violation (e.g. a primary key collision) is surfaced as a `StorageError`
322    /// so that callers do not misinterpret unexpected failures as deduplication.
323    async fn try_insert_note(&self, note: Note) -> StorageResult<bool>;
324
325    /// Fetch multiple notes by UUID in a single call.
326    async fn get_notes_batch(&self, ids: &[Uuid]) -> StorageResult<Vec<Note>> {
327        let mut out = Vec::with_capacity(ids.len());
328        for &id in ids {
329            if let Some(n) = self.get_note(id).await? {
330                out.push(n);
331            }
332        }
333        Ok(out)
334    }
335}