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::{
9    BatchWriteSummary, DeleteMode, Page, PageRequest, SeekCursor, SeekPage, SqlValue, StorageResult,
10};
11
12/// A storage-level note record. Flat, SQL-friendly representation.
13#[derive(Clone, Debug, Serialize, Deserialize)]
14pub struct Note {
15    pub id: Uuid,
16    pub namespace: String,
17    pub kind: String,
18    pub status: String,
19    pub name: Option<String>,
20    pub content: String,
21    pub salience: Option<f64>,
22    pub decay_factor: Option<f64>,
23    pub expires_at: Option<i64>,
24    pub properties: Option<Value>,
25    pub created_at: i64,
26    pub updated_at: i64,
27    pub deleted_at: Option<i64>,
28}
29
30impl Note {
31    /// Create a new note with a generated UUID and current timestamp.
32    pub fn new(
33        namespace: impl Into<String>,
34        kind: impl Into<String>,
35        content: impl Into<String>,
36    ) -> Self {
37        let now = chrono::Utc::now().timestamp_micros();
38        Self {
39            id: Uuid::new_v4(),
40            namespace: namespace.into(),
41            kind: kind.into(),
42            status: "active".to_string(),
43            name: None,
44            content: content.into(),
45            salience: None,
46            decay_factor: None,
47            expires_at: None,
48            properties: None,
49            created_at: now,
50            updated_at: now,
51            deleted_at: None,
52        }
53    }
54
55    /// Set the note display name.
56    pub fn with_name(mut self, n: impl Into<String>) -> Self {
57        self.name = Some(n.into());
58        self
59    }
60
61    /// Set salience (infallible). Rejects non-finite values by returning `self`
62    /// unchanged; clamps finite values to `[0.0, 1.0]`. Prefer
63    /// [`try_with_salience`](Self::try_with_salience) at public boundaries.
64    pub fn with_salience(mut self, s: f64) -> Self {
65        if !s.is_finite() {
66            return self;
67        }
68        self.salience = Some(s.clamp(0.0, 1.0));
69        self
70    }
71
72    /// Set decay factor (infallible). Rejects non-finite values by returning
73    /// `self` unchanged; floors finite values at `0.0`. Prefer
74    /// [`try_with_decay`](Self::try_with_decay) at public boundaries.
75    pub fn with_decay(mut self, d: f64) -> Self {
76        if !d.is_finite() {
77            return self;
78        }
79        self.decay_factor = Some(d.max(0.0));
80        self
81    }
82
83    /// Set salience with validation. Returns an error for non-finite or
84    /// out-of-range `[0.0, 1.0]` values.
85    pub fn try_with_salience(mut self, s: f64) -> Result<Self, String> {
86        if !s.is_finite() {
87            return Err(format!("salience must be finite, got {s}"));
88        }
89        if !(0.0..=1.0).contains(&s) {
90            return Err(format!("salience must be in [0.0, 1.0], got {s}"));
91        }
92        self.salience = Some(s);
93        Ok(self)
94    }
95
96    /// Set decay factor with validation. Returns an error for non-finite or
97    /// negative values.
98    pub fn try_with_decay(mut self, d: f64) -> Result<Self, String> {
99        if !d.is_finite() {
100            return Err(format!("decay_factor must be finite, got {d}"));
101        }
102        if d < 0.0 {
103            return Err(format!("decay_factor must be >= 0.0, got {d}"));
104        }
105        self.decay_factor = Some(d);
106        Ok(self)
107    }
108
109    /// Set the note properties JSON blob.
110    pub fn with_properties(mut self, p: Value) -> Self {
111        self.properties = Some(p);
112        self
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn base_note() -> Note {
121        Note::new("ns:test", "memory", "hello world")
122    }
123
124    // -- with_salience --
125
126    #[test]
127    fn with_salience_clamps_to_range() {
128        let n = base_note().with_salience(1.5);
129        assert_eq!(n.salience, Some(1.0));
130        let n = base_note().with_salience(-0.1);
131        assert_eq!(n.salience, Some(0.0));
132        let n = base_note().with_salience(0.7);
133        assert_eq!(n.salience, Some(0.7));
134    }
135
136    #[test]
137    fn with_salience_ignores_nan() {
138        let n = base_note().with_salience(f64::NAN);
139        assert_eq!(n.salience, None, "NaN must not set salience");
140    }
141
142    #[test]
143    fn with_salience_ignores_inf() {
144        let n = base_note().with_salience(f64::INFINITY);
145        assert_eq!(n.salience, None, "+Inf must not set salience");
146        let n = base_note().with_salience(f64::NEG_INFINITY);
147        assert_eq!(n.salience, None, "-Inf must not set salience");
148    }
149
150    // -- with_decay --
151
152    #[test]
153    fn with_decay_floors_at_zero() {
154        let n = base_note().with_decay(-1.0);
155        assert_eq!(n.decay_factor, Some(0.0));
156        let n = base_note().with_decay(0.5);
157        assert_eq!(n.decay_factor, Some(0.5));
158    }
159
160    #[test]
161    fn with_decay_ignores_nan() {
162        let n = base_note().with_decay(f64::NAN);
163        assert_eq!(n.decay_factor, None, "NaN must not set decay_factor");
164    }
165
166    #[test]
167    fn with_decay_ignores_inf() {
168        let n = base_note().with_decay(f64::INFINITY);
169        assert_eq!(n.decay_factor, None, "+Inf must not set decay_factor");
170    }
171
172    // -- try_with_salience --
173
174    #[test]
175    fn try_with_salience_accepts_valid_range() {
176        let n = base_note().try_with_salience(0.0).unwrap();
177        assert_eq!(n.salience, Some(0.0));
178        let n = base_note().try_with_salience(1.0).unwrap();
179        assert_eq!(n.salience, Some(1.0));
180        let n = base_note().try_with_salience(0.85).unwrap();
181        assert_eq!(n.salience, Some(0.85));
182    }
183
184    #[test]
185    fn try_with_salience_rejects_nan() {
186        let err = base_note().try_with_salience(f64::NAN).unwrap_err();
187        assert!(err.contains("finite"), "error must mention finite: {err}");
188    }
189
190    #[test]
191    fn try_with_salience_rejects_out_of_range() {
192        let err = base_note().try_with_salience(1.1).unwrap_err();
193        assert!(err.contains("1.0"), "error must mention bound: {err}");
194        let err = base_note().try_with_salience(-0.01).unwrap_err();
195        assert!(err.contains("0.0"), "error must mention bound: {err}");
196    }
197
198    // -- try_with_decay --
199
200    #[test]
201    fn try_with_decay_accepts_valid_values() {
202        let n = base_note().try_with_decay(0.0).unwrap();
203        assert_eq!(n.decay_factor, Some(0.0));
204        let n = base_note().try_with_decay(2.5).unwrap();
205        assert_eq!(n.decay_factor, Some(2.5));
206    }
207
208    #[test]
209    fn try_with_decay_rejects_nan() {
210        let err = base_note().try_with_decay(f64::NAN).unwrap_err();
211        assert!(err.contains("finite"), "error must mention finite: {err}");
212    }
213
214    #[test]
215    fn try_with_decay_rejects_negative() {
216        let err = base_note().try_with_decay(-0.1).unwrap_err();
217        assert!(err.contains("0.0"), "error must mention bound: {err}");
218    }
219}
220
221/// Sort direction for filtered note queries.
222#[derive(Clone, Debug, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum SortDir {
225    Asc,
226    Desc,
227}
228
229/// Comparison operator for a [`PropertyFilter`] on a JSON path.
230#[derive(Clone, Debug, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum FilterOp {
233    Eq,
234    /// Matches rows where the JSON field equals the value OR the field is absent/NULL.
235    /// Used for properties that may be missing in legacy rows (e.g. `$.read`).
236    EqOrMissing,
237    Ne,
238    Lt,
239    Lte,
240    Gt,
241    Gte,
242    /// Matches rows where `json_type(properties, path) = value`.
243    /// Value must be a SQLite json_type string literal: 'true', 'false', 'integer',
244    /// 'real', 'text', 'array', 'object', or 'null'.
245    JsonTypeEq,
246    /// Matches rows where the json_type is absent (NULL) OR differs from value.
247    /// Equivalent to `json_type IS NULL OR json_type != value`.
248    /// Used for unread filter: matches any `$.read` that is NOT the JSON boolean true.
249    JsonTypeNeMissing,
250    /// Matches rows where `json_extract(properties, path)` equals any value in
251    /// the set. A row with a missing/NULL property does not match — use
252    /// `NotInOrMissing` with the complementary set when "absent" should count
253    /// as included. `PropertyFilter.value` is unused for this op; the set
254    /// lives in the variant itself.
255    In(Vec<SqlValue>),
256    /// Matches rows where the property is missing/NULL OR its value is not in
257    /// the set. Used for "exclude a small closed set of terminal values, but
258    /// treat a still-unset property as included" (e.g. GTD default task
259    /// listing excludes `done`/`cancelled` while a task with no `status` yet
260    /// still counts as `inbox`, i.e. included). `PropertyFilter.value` is
261    /// unused for this op; the set lives in the variant itself.
262    NotInOrMissing(Vec<SqlValue>),
263}
264
265/// A single `json_extract(properties, '$.field') op value` predicate.
266///
267/// Callers import this as `khive_storage::note::PropertyFilter` to avoid
268/// collision with the vector-metadata `PropertyFilter` in `khive_storage::types`.
269#[derive(Clone, Debug, Serialize, Deserialize)]
270pub struct PropertyFilter {
271    pub json_path: String,
272    pub op: FilterOp,
273    pub value: SqlValue,
274}
275
276/// Filter + sort options for [`NoteStore::query_notes_filtered`].
277///
278/// Designed for general property-based filtering on any JSON field, not
279/// schedule-specific, so D9 and future packs can reuse the same API.
280#[derive(Clone, Debug, Default, Serialize, Deserialize)]
281pub struct NoteFilter {
282    pub kind: Option<String>,
283    #[serde(default)]
284    pub property_filters: Vec<PropertyFilter>,
285    /// `(json_path, direction)` — `None` defaults to `created_at DESC`.
286    pub order_by: Option<(String, SortDir)>,
287    /// When non-empty, restricts results to any of these namespaces using
288    /// `namespace IN (...)`. Takes precedence over the `namespace` string
289    /// parameter passed to `query_notes_filtered`. When empty the
290    /// caller-supplied `namespace` parameter is used (backward-compatible).
291    #[serde(default)]
292    pub namespaces: Vec<String>,
293    /// Restrict to notes where `created_at >= min_created_at` (microseconds epoch).
294    /// `None` applies no lower-bound constraint.
295    pub min_created_at: Option<i64>,
296}
297
298/// Temporal-referential note CRUD over the notes substrate table.
299#[async_trait]
300pub trait NoteStore: Send + Sync + 'static {
301    /// Insert or update a single note.
302    async fn upsert_note(&self, note: Note) -> StorageResult<()>;
303    /// Insert or update a batch of notes.
304    async fn upsert_notes(&self, notes: Vec<Note>) -> StorageResult<BatchWriteSummary>;
305    /// Fetch a note by UUID, returning `None` if absent.
306    async fn get_note(&self, id: Uuid) -> StorageResult<Option<Note>>;
307    /// Fetch a note by UUID regardless of soft-deletion state.
308    ///
309    /// Returns the note row even when `deleted_at` is set. Callers use this
310    /// to distinguish "soft-deleted" from "never existed".
311    async fn get_note_including_deleted(&self, id: Uuid) -> StorageResult<Option<Note>>;
312    /// Delete a note by UUID using the specified delete mode.
313    async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> StorageResult<bool>;
314    /// Patch `properties`/`updated_at` on an existing note in place via a real
315    /// `UPDATE`, leaving every other column (including the row's `rowid`)
316    /// untouched.
317    ///
318    /// Unlike `upsert_note`, which writes the complete note shape, this leaves
319    /// every non-property column untouched. It also never churns the row's
320    /// implicit `rowid`, which is required by callers relying on stable row
321    /// identity (#780).
322    /// Returns `true` when a live (non-soft-deleted) row with this `id` was
323    /// found and updated, `false` otherwise.
324    async fn update_note_properties(
325        &self,
326        id: Uuid,
327        properties: Option<Value>,
328        updated_at: i64,
329    ) -> StorageResult<bool>;
330    /// Atomically set one top-level key in a note's JSON `properties` object.
331    ///
332    /// The backend must perform the read/modify/write as one storage operation
333    /// so concurrent writes to different keys cannot overwrite each other.
334    /// `value` keeps its JSON type, including explicit JSON `null`. A SQL-NULL
335    /// property document is initialized as an empty object. A live row whose
336    /// stored document is a non-object is not modified and returns `false`, as
337    /// do missing and soft-deleted rows. Keys containing U+0000 must be
338    /// rejected: SQLite JSON-path labels cannot address them without risking
339    /// mutation of a shorter sibling key.
340    async fn set_note_property(
341        &self,
342        id: Uuid,
343        key: &str,
344        value: Value,
345        updated_at: i64,
346    ) -> StorageResult<bool>;
347    /// Atomically patch a single `properties` JSON key on a note, but only
348    /// when the row's *current* state (re-evaluated inside this same
349    /// statement, not a snapshot the caller fetched earlier) still satisfies
350    /// `filter`'s namespace/kind/property_filters.
351    ///
352    /// Unlike `set_note_property` (which patches unconditionally once the row
353    /// is live) or `update_note_properties` (which replaces the whole
354    /// `properties` column with a value the caller already computed — safe
355    /// only when nothing else can have written to the row since the caller's
356    /// read), this also rechecks `filter` against the row's live state before
357    /// writing, so a target that stopped matching an eligibility predicate
358    /// between validation and this call is not mutated. Any other property
359    /// written concurrently between the caller's read and this call survives
360    /// untouched either way. A live row whose stored `properties` document is
361    /// a non-object (scalar, array, or otherwise) is not modified and returns
362    /// `false`, mirroring `set_note_property`. Returns `Ok(false)` — not an
363    /// error — when no live row currently matches `filter` (id not found,
364    /// soft-deleted, an eligibility property changed since the caller last
365    /// validated it, or the stored document is not a JSON object); the
366    /// caller degrades that the same way as `update_note_properties`'s
367    /// `Ok(false)`.
368    async fn try_patch_note_property(
369        &self,
370        id: Uuid,
371        namespace: &str,
372        filter: &NoteFilter,
373        json_path: &str,
374        value: Value,
375        updated_at: i64,
376    ) -> StorageResult<bool>;
377    /// Query notes by namespace and optional kind with pagination.
378    /// The returned total and page items must come from one consistent
379    /// backend snapshot.
380    async fn query_notes(
381        &self,
382        namespace: &str,
383        kind: Option<&str>,
384        page: PageRequest,
385    ) -> StorageResult<Page<Note>>;
386    /// Query notes with property-based filtering and custom sort.
387    /// The returned total and page items must come from one consistent
388    /// backend snapshot.
389    async fn query_notes_filtered(
390        &self,
391        namespace: &str,
392        filter: &NoteFilter,
393        page: PageRequest,
394    ) -> StorageResult<Page<Note>>;
395    /// Resolve a note id to its immutable insertion sequence.
396    async fn note_sequence(&self, _id: Uuid) -> StorageResult<Option<i64>> {
397        Err(crate::StorageError::Unsupported {
398            capability: crate::StorageCapability::Notes,
399            operation: "note_sequence".into(),
400            message: "this backend does not implement note insertion sequences".into(),
401        })
402    }
403    /// Query an immutable insertion-sequence keyset page with the same
404    /// predicates as [`Self::query_notes_filtered`].
405    async fn query_notes_filtered_after(
406        &self,
407        _namespace: &str,
408        _filter: &NoteFilter,
409        _after: Option<SeekCursor>,
410        _limit: u32,
411    ) -> StorageResult<SeekPage<Note>> {
412        Err(crate::StorageError::Unsupported {
413            capability: crate::StorageCapability::Notes,
414            operation: "query_notes_filtered_after".into(),
415            message: "this backend does not implement note seek pagination".into(),
416        })
417    }
418    /// Fetch up to `max_rows + 1` notes matching `filter` in a single
419    /// deterministically-ordered SQL statement, with no separate `COUNT(*)`
420    /// and no pagination loop.
421    ///
422    /// A single statement observes one consistent snapshot for its entire
423    /// execution, so the result cannot be split across a concurrent insert
424    /// the way a `COUNT(*)` followed by independent `LIMIT`/`OFFSET` pages
425    /// can. Callers detect the over-bound case by checking whether the
426    /// returned `Vec` has more than `max_rows` items — that means at least
427    /// `max_rows + 1` rows matched and the caller must reject the query
428    /// rather than silently return a truncated, possibly priority-incomplete
429    /// set.
430    async fn query_notes_filtered_bounded(
431        &self,
432        namespace: &str,
433        filter: &NoteFilter,
434        max_rows: u32,
435    ) -> StorageResult<Vec<Note>>;
436    /// Count notes in a namespace, optionally filtered by kind.
437    async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> StorageResult<u64>;
438    /// Count notes across the given namespaces, optionally filtered by kind.
439    /// The default preserves compatibility by summing the existing
440    /// single-namespace operation; SQL backends should override this with one
441    /// `IN` aggregate.
442    async fn count_notes_in_namespaces(
443        &self,
444        namespaces: &[String],
445        kind: Option<&str>,
446    ) -> StorageResult<u64> {
447        let mut total = 0;
448        for namespace in namespaces {
449            total += self.count_notes(namespace, kind).await?;
450        }
451        Ok(total)
452    }
453
454    /// Attempt to insert a note without overwriting an existing row.
455    ///
456    /// Returns `true` when the row was newly written.  Returns `false` only
457    /// when a live note with the same non-empty `external_id` already exists in
458    /// the same namespace and kind (confirmed dedup hit).  Any other constraint
459    /// violation (e.g. a primary key collision) is surfaced as a `StorageError`
460    /// so that callers do not misinterpret unexpected failures as deduplication.
461    async fn try_insert_note(&self, note: Note) -> StorageResult<bool>;
462
463    /// Fetch multiple notes by UUID in a single call.
464    async fn get_notes_batch(&self, ids: &[Uuid]) -> StorageResult<Vec<Note>> {
465        let mut out = Vec::with_capacity(ids.len());
466        for &id in ids {
467            if let Some(n) = self.get_note(id).await? {
468                out.push(n);
469            }
470        }
471        Ok(out)
472    }
473}