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    /// Matches rows where `json_extract(properties, path)` equals any value in
249    /// the set. A row with a missing/NULL property does not match — use
250    /// `NotInOrMissing` with the complementary set when "absent" should count
251    /// as included. `PropertyFilter.value` is unused for this op; the set
252    /// lives in the variant itself.
253    In(Vec<SqlValue>),
254    /// Matches rows where the property is missing/NULL OR its value is not in
255    /// the set. Used for "exclude a small closed set of terminal values, but
256    /// treat a still-unset property as included" (e.g. GTD default task
257    /// listing excludes `done`/`cancelled` while a task with no `status` yet
258    /// still counts as `inbox`, i.e. included). `PropertyFilter.value` is
259    /// unused for this op; the set lives in the variant itself.
260    NotInOrMissing(Vec<SqlValue>),
261}
262
263/// A single `json_extract(properties, '$.field') op value` predicate.
264///
265/// Callers import this as `khive_storage::note::PropertyFilter` to avoid
266/// collision with the vector-metadata `PropertyFilter` in `khive_storage::types`.
267#[derive(Clone, Debug, Serialize, Deserialize)]
268pub struct PropertyFilter {
269    pub json_path: String,
270    pub op: FilterOp,
271    pub value: SqlValue,
272}
273
274/// Filter + sort options for [`NoteStore::query_notes_filtered`].
275///
276/// Designed for general property-based filtering on any JSON field, not
277/// schedule-specific, so D9 and future packs can reuse the same API.
278#[derive(Clone, Debug, Default, Serialize, Deserialize)]
279pub struct NoteFilter {
280    pub kind: Option<String>,
281    #[serde(default)]
282    pub property_filters: Vec<PropertyFilter>,
283    /// `(json_path, direction)` — `None` defaults to `created_at DESC`.
284    pub order_by: Option<(String, SortDir)>,
285    /// When non-empty, restricts results to any of these namespaces using
286    /// `namespace IN (...)`. Takes precedence over the `namespace` string
287    /// parameter passed to `query_notes_filtered`. When empty the
288    /// caller-supplied `namespace` parameter is used (backward-compatible).
289    #[serde(default)]
290    pub namespaces: Vec<String>,
291    /// Restrict to notes where `created_at >= min_created_at` (microseconds epoch).
292    /// `None` applies no lower-bound constraint.
293    pub min_created_at: Option<i64>,
294}
295
296/// Temporal-referential note CRUD over the notes substrate table.
297#[async_trait]
298pub trait NoteStore: Send + Sync + 'static {
299    /// Insert or update a single note.
300    async fn upsert_note(&self, note: Note) -> StorageResult<()>;
301    /// Insert or update a batch of notes.
302    async fn upsert_notes(&self, notes: Vec<Note>) -> StorageResult<BatchWriteSummary>;
303    /// Fetch a note by UUID, returning `None` if absent.
304    async fn get_note(&self, id: Uuid) -> StorageResult<Option<Note>>;
305    /// Fetch a note by UUID regardless of soft-deletion state.
306    ///
307    /// Returns the note row even when `deleted_at` is set. Callers use this
308    /// to distinguish "soft-deleted" from "never existed".
309    async fn get_note_including_deleted(&self, id: Uuid) -> StorageResult<Option<Note>>;
310    /// Delete a note by UUID using the specified delete mode.
311    async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> StorageResult<bool>;
312    /// Patch `properties`/`updated_at` on an existing note in place via a real
313    /// `UPDATE`, leaving every other column (including the row's `rowid`)
314    /// untouched.
315    ///
316    /// Unlike `upsert_note` (an `INSERT OR REPLACE`, which on a primary-key
317    /// conflict is a SQLite DELETE+INSERT that silently reassigns the row's
318    /// implicit `rowid`), this never churns `rowid`, which is required by any
319    /// caller relying on `rowid` as a stable, monotonically-increasing cursor (#780).
320    /// Returns `true` when a live (non-soft-deleted) row with this `id` was
321    /// found and updated, `false` otherwise.
322    async fn update_note_properties(
323        &self,
324        id: Uuid,
325        properties: Option<Value>,
326        updated_at: i64,
327    ) -> StorageResult<bool>;
328    /// Query notes by namespace and optional kind with pagination.
329    async fn query_notes(
330        &self,
331        namespace: &str,
332        kind: Option<&str>,
333        page: PageRequest,
334    ) -> StorageResult<Page<Note>>;
335    /// Query notes with property-based filtering and custom sort.
336    async fn query_notes_filtered(
337        &self,
338        namespace: &str,
339        filter: &NoteFilter,
340        page: PageRequest,
341    ) -> StorageResult<Page<Note>>;
342    /// Fetch up to `max_rows + 1` notes matching `filter` in a single
343    /// deterministically-ordered SQL statement, with no separate `COUNT(*)`
344    /// and no pagination loop.
345    ///
346    /// A single statement observes one consistent snapshot for its entire
347    /// execution, so the result cannot be split across a concurrent insert
348    /// the way a `COUNT(*)` followed by independent `LIMIT`/`OFFSET` pages
349    /// can. Callers detect the over-bound case by checking whether the
350    /// returned `Vec` has more than `max_rows` items — that means at least
351    /// `max_rows + 1` rows matched and the caller must reject the query
352    /// rather than silently return a truncated, possibly priority-incomplete
353    /// set.
354    async fn query_notes_filtered_bounded(
355        &self,
356        namespace: &str,
357        filter: &NoteFilter,
358        max_rows: u32,
359    ) -> StorageResult<Vec<Note>>;
360    /// Count notes in a namespace, optionally filtered by kind.
361    async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> StorageResult<u64>;
362
363    /// Attempt to insert a note without overwriting an existing row.
364    ///
365    /// Returns `true` when the row was newly written.  Returns `false` only
366    /// when a live note with the same non-empty `external_id` already exists in
367    /// the same namespace and kind (confirmed dedup hit).  Any other constraint
368    /// violation (e.g. a primary key collision) is surfaced as a `StorageError`
369    /// so that callers do not misinterpret unexpected failures as deduplication.
370    async fn try_insert_note(&self, note: Note) -> StorageResult<bool>;
371
372    /// Fetch multiple notes by UUID in a single call.
373    async fn get_notes_batch(&self, ids: &[Uuid]) -> StorageResult<Vec<Note>> {
374        let mut out = Vec::with_capacity(ids.len());
375        for &id in ids {
376            if let Some(n) = self.get_note(id).await? {
377                out.push(n);
378            }
379        }
380        Ok(out)
381    }
382}