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, PartialEq, 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    /// Matches rows where a JSON text field equals the value, while treating
238    /// every missing or non-text value as that same value. The SQL adapter
239    /// emits `CASE WHEN json_type(...) = 'text' THEN json_extract(...) ELSE
240    /// value END = value`, mirroring callers whose read model assigns one
241    /// textual default to absent, JSON-null, and malformed legacy values.
242    TextEqOrNonText,
243    Ne,
244    Lt,
245    Lte,
246    Gt,
247    Gte,
248    /// Matches rows where `json_type(properties, path) = value`.
249    /// Value must be a SQLite json_type string literal: 'true', 'false', 'integer',
250    /// 'real', 'text', 'array', 'object', or 'null'.
251    JsonTypeEq,
252    /// Matches rows where the json_type is absent (NULL) OR differs from value.
253    /// Equivalent to `json_type IS NULL OR json_type != value`.
254    /// Used for unread filter: matches any `$.read` that is NOT the JSON boolean true.
255    JsonTypeNeMissing,
256    /// Matches rows where `json_extract(properties, path)` equals any value in
257    /// the set. A row with a missing/NULL property does not match — use
258    /// `NotInOrMissing` with the complementary set when "absent" should count
259    /// as included. `PropertyFilter.value` is unused for this op; the set
260    /// lives in the variant itself.
261    In(Vec<SqlValue>),
262    /// Matches rows where the property is missing/NULL OR its value is not in
263    /// the set. Used for "exclude a small closed set of terminal values, but
264    /// treat a still-unset property as included" (e.g. GTD default task
265    /// listing excludes `done`/`cancelled` while a task with no `status` yet
266    /// still counts as `inbox`, i.e. included). `PropertyFilter.value` is
267    /// unused for this op; the set lives in the variant itself.
268    NotInOrMissing(Vec<SqlValue>),
269}
270
271/// A single `json_extract(properties, '$.field') op value` predicate.
272///
273/// Callers import this as `khive_storage::note::PropertyFilter` to avoid
274/// collision with the vector-metadata `PropertyFilter` in `khive_storage::types`.
275#[derive(Clone, Debug, Serialize, Deserialize)]
276pub struct PropertyFilter {
277    pub json_path: String,
278    pub op: FilterOp,
279    pub value: SqlValue,
280}
281
282/// Filter + sort options for [`NoteStore::query_notes_filtered`].
283///
284/// Designed for general property-based filtering on any JSON field, not
285/// schedule-specific, so D9 and future packs can reuse the same API.
286#[derive(Clone, Debug, Default, Serialize, Deserialize)]
287pub struct NoteFilter {
288    pub kind: Option<String>,
289    #[serde(default)]
290    pub property_filters: Vec<PropertyFilter>,
291    /// `(json_path, direction)` — `None` defaults to `created_at DESC`.
292    pub order_by: Option<(String, SortDir)>,
293    /// When non-empty, restricts results to any of these namespaces using
294    /// `namespace IN (...)`. Takes precedence over the `namespace` string
295    /// parameter passed to `query_notes_filtered`. When empty the
296    /// caller-supplied `namespace` parameter is used (backward-compatible).
297    #[serde(default)]
298    pub namespaces: Vec<String>,
299    /// Restrict to notes where `created_at >= min_created_at` (microseconds epoch).
300    /// `None` applies no lower-bound constraint.
301    pub min_created_at: Option<i64>,
302}
303
304/// Temporal-referential note CRUD over the notes substrate table.
305#[async_trait]
306pub trait NoteStore: Send + Sync + 'static {
307    /// Insert or update a single note.
308    async fn upsert_note(&self, note: Note) -> StorageResult<()>;
309    /// Replace a note only when the persisted row still matches the caller's
310    /// read snapshot.
311    ///
312    /// `expected_updated_at` is the snapshot revision and
313    /// `expected_deleted_at` closes the soft-delete race (legacy soft-delete
314    /// paths may change `deleted_at` without changing `updated_at`). The
315    /// replacement note's `updated_at` must be strictly greater than that
316    /// persisted revision. Returns `false` when the row disappeared, changed,
317    /// or was supplied a non-advancing replacement revision. This is the
318    /// full-note compare-and-swap seam used when a pack hook derives coupled
319    /// fields from that snapshot before persistence. The default returns
320    /// `Unsupported` rather than falling back to an unguarded upsert and
321    /// reintroducing the stale-snapshot race.
322    async fn replace_note_if_unchanged(
323        &self,
324        _note: Note,
325        _expected_updated_at: i64,
326        _expected_deleted_at: Option<i64>,
327    ) -> StorageResult<bool> {
328        Err(crate::StorageError::Unsupported {
329            capability: crate::StorageCapability::Notes,
330            operation: "replace_note_if_unchanged".into(),
331            message: "this backend does not implement guarded note replacement".into(),
332        })
333    }
334    /// Insert a note only if no row already holds its id, reporting whether
335    /// this call is the one that inserted it.
336    ///
337    /// This closes the other half of the read-modify-write race that
338    /// [`NoteStore::replace_note_if_unchanged`] closes. That one protects a
339    /// caller who read an existing row; this one protects a caller who read
340    /// *no* row. Where the id is derived rather than freshly generated — a
341    /// deterministic per-subject id — two callers can both read absence and
342    /// both write, and an upsert resolves that by overwriting, so the first
343    /// caller's write is lost with no error on either side. Returns `false`
344    /// when a row already existed, which the caller maps to a conflict rather
345    /// than to success.
346    ///
347    /// The pre-existing row is left exactly as it is: this must not be
348    /// implemented as an upsert, since overwriting is the behaviour being
349    /// avoided. The default returns `Unsupported` for that reason, rather
350    /// than falling back to `upsert_note` and silently reintroducing the
351    /// race under a name that promises otherwise.
352    async fn insert_note_if_absent(&self, _note: Note) -> StorageResult<bool> {
353        Err(crate::StorageError::Unsupported {
354            capability: crate::StorageCapability::Notes,
355            operation: "insert_note_if_absent".into(),
356            message: "this backend does not implement guarded note insertion".into(),
357        })
358    }
359    /// Insert or update a batch of notes.
360    async fn upsert_notes(&self, notes: Vec<Note>) -> StorageResult<BatchWriteSummary>;
361    /// Fetch a note by UUID, returning `None` if absent.
362    async fn get_note(&self, id: Uuid) -> StorageResult<Option<Note>>;
363    /// Fetch a note by UUID regardless of soft-deletion state.
364    ///
365    /// Returns the note row even when `deleted_at` is set. Callers use this
366    /// to distinguish "soft-deleted" from "never existed".
367    async fn get_note_including_deleted(&self, id: Uuid) -> StorageResult<Option<Note>>;
368    /// Delete a note by UUID using the specified delete mode.
369    async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> StorageResult<bool>;
370    /// Patch `properties`/`updated_at` on an existing note in place via a real
371    /// `UPDATE`, leaving every other column (including the row's `rowid`)
372    /// untouched.
373    ///
374    /// Unlike `upsert_note`, which writes the complete note shape, this leaves
375    /// every non-property column untouched. It also never churns the row's
376    /// implicit `rowid`, which is required by callers relying on stable row
377    /// identity (#780).
378    /// Returns `true` when a live (non-soft-deleted) row with this `id` was
379    /// found and updated, `false` otherwise.
380    async fn update_note_properties(
381        &self,
382        id: Uuid,
383        properties: Option<Value>,
384        updated_at: i64,
385    ) -> StorageResult<bool>;
386    /// Atomically set one top-level key in a note's JSON `properties` object.
387    ///
388    /// The backend must perform the read/modify/write as one storage operation
389    /// so concurrent writes to different keys cannot overwrite each other.
390    /// `value` keeps its JSON type, including explicit JSON `null`. A SQL-NULL
391    /// property document is initialized as an empty object. A live row whose
392    /// stored document is a non-object is not modified and returns `false`, as
393    /// do missing and soft-deleted rows. Keys containing U+0000 must be
394    /// rejected: SQLite JSON-path labels cannot address them without risking
395    /// mutation of a shorter sibling key.
396    async fn set_note_property(
397        &self,
398        id: Uuid,
399        key: &str,
400        value: Value,
401        updated_at: i64,
402    ) -> StorageResult<bool>;
403    /// Atomically patch a single `properties` JSON key on a note, but only
404    /// when the row's *current* state (re-evaluated inside this same
405    /// statement, not a snapshot the caller fetched earlier) still satisfies
406    /// `filter`'s namespace/kind/property_filters.
407    ///
408    /// Unlike `set_note_property` (which patches unconditionally once the row
409    /// is live) or `update_note_properties` (which replaces the whole
410    /// `properties` column with a value the caller already computed — safe
411    /// only when nothing else can have written to the row since the caller's
412    /// read), this also rechecks `filter` against the row's live state before
413    /// writing, so a target that stopped matching an eligibility predicate
414    /// between validation and this call is not mutated. Any other property
415    /// written concurrently between the caller's read and this call survives
416    /// untouched either way. A live row whose stored `properties` document is
417    /// a non-object (scalar, array, or otherwise) is not modified and returns
418    /// `false`, mirroring `set_note_property`. Returns `Ok(false)` — not an
419    /// error — when no live row currently matches `filter` (id not found,
420    /// soft-deleted, an eligibility property changed since the caller last
421    /// validated it, or the stored document is not a JSON object); the
422    /// caller degrades that the same way as `update_note_properties`'s
423    /// `Ok(false)`.
424    async fn try_patch_note_property(
425        &self,
426        id: Uuid,
427        namespace: &str,
428        filter: &NoteFilter,
429        json_path: &str,
430        value: Value,
431        updated_at: i64,
432    ) -> StorageResult<bool>;
433    /// Atomically patch one JSON property on every supplied note.
434    ///
435    /// Each target is rechecked against `namespace` and `filter` inside the
436    /// same transaction. The operation commits only when every distinct id
437    /// matches exactly one live object-valued row; a missing, soft-deleted, or
438    /// no-longer-eligible target rolls the entire unit back. Other property
439    /// keys are preserved by the same storage-side `json_set` operation as
440    /// [`Self::try_patch_note_property`]. Backends without a transactional
441    /// multi-note implementation retain the default `Unsupported` result.
442    async fn patch_note_property_atomic(
443        &self,
444        _ids: Vec<Uuid>,
445        _namespace: &str,
446        _filter: &NoteFilter,
447        _json_path: &str,
448        _value: Value,
449        _updated_at: i64,
450    ) -> StorageResult<()> {
451        Err(crate::StorageError::Unsupported {
452            capability: crate::StorageCapability::Notes,
453            operation: "patch_note_property_atomic".into(),
454            message: "this backend does not implement atomic multi-note property patches".into(),
455        })
456    }
457    /// Query notes by namespace and optional kind with pagination.
458    /// The returned total and page items must come from one consistent
459    /// backend snapshot.
460    async fn query_notes(
461        &self,
462        namespace: &str,
463        kind: Option<&str>,
464        page: PageRequest,
465    ) -> StorageResult<Page<Note>>;
466    /// Query notes with property-based filtering and custom sort.
467    /// The returned total and page items must come from one consistent
468    /// backend snapshot.
469    async fn query_notes_filtered(
470        &self,
471        namespace: &str,
472        filter: &NoteFilter,
473        page: PageRequest,
474    ) -> StorageResult<Page<Note>>;
475    /// Resolve a note id to its immutable insertion sequence.
476    async fn note_sequence(&self, _id: Uuid) -> StorageResult<Option<i64>> {
477        Err(crate::StorageError::Unsupported {
478            capability: crate::StorageCapability::Notes,
479            operation: "note_sequence".into(),
480            message: "this backend does not implement note insertion sequences".into(),
481        })
482    }
483    /// Query an immutable insertion-sequence keyset page with the same
484    /// predicates as [`Self::query_notes_filtered`].
485    async fn query_notes_filtered_after(
486        &self,
487        _namespace: &str,
488        _filter: &NoteFilter,
489        _after: Option<SeekCursor>,
490        _limit: u32,
491    ) -> StorageResult<SeekPage<Note>> {
492        Err(crate::StorageError::Unsupported {
493            capability: crate::StorageCapability::Notes,
494            operation: "query_notes_filtered_after".into(),
495            message: "this backend does not implement note seek pagination".into(),
496        })
497    }
498    /// Fetch up to `max_rows + 1` notes matching `filter` in a single
499    /// deterministically-ordered SQL statement, with no separate `COUNT(*)`
500    /// and no pagination loop.
501    ///
502    /// A single statement observes one consistent snapshot for its entire
503    /// execution, so the result cannot be split across a concurrent insert
504    /// the way a `COUNT(*)` followed by independent `LIMIT`/`OFFSET` pages
505    /// can. Callers detect the over-bound case by checking whether the
506    /// returned `Vec` has more than `max_rows` items — that means at least
507    /// `max_rows + 1` rows matched and the caller must reject the query
508    /// rather than silently return a truncated, possibly priority-incomplete
509    /// set.
510    async fn query_notes_filtered_bounded(
511        &self,
512        namespace: &str,
513        filter: &NoteFilter,
514        max_rows: u32,
515    ) -> StorageResult<Vec<Note>>;
516    /// Count notes in a namespace, optionally filtered by kind.
517    async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> StorageResult<u64>;
518    /// Count notes across the given namespaces, optionally filtered by kind.
519    /// The default preserves compatibility by summing the existing
520    /// single-namespace operation; SQL backends should override this with one
521    /// `IN` aggregate.
522    async fn count_notes_in_namespaces(
523        &self,
524        namespaces: &[String],
525        kind: Option<&str>,
526    ) -> StorageResult<u64> {
527        let mut total = 0;
528        for namespace in namespaces {
529            total += self.count_notes(namespace, kind).await?;
530        }
531        Ok(total)
532    }
533
534    /// Attempt to insert a note without overwriting an existing row.
535    ///
536    /// Returns `true` when the row was newly written.  Returns `false` only
537    /// when a live note with the same non-empty `external_id` already exists in
538    /// the same namespace and kind (confirmed dedup hit).  Any other constraint
539    /// violation (e.g. a primary key collision) is surfaced as a `StorageError`
540    /// so that callers do not misinterpret unexpected failures as deduplication.
541    async fn try_insert_note(&self, note: Note) -> StorageResult<bool>;
542
543    /// Fetch multiple notes by UUID in a single call.
544    async fn get_notes_batch(&self, ids: &[Uuid]) -> StorageResult<Vec<Note>> {
545        let mut out = Vec::with_capacity(ids.len());
546        for &id in ids {
547            if let Some(n) = self.get_note(id).await? {
548                out.push(n);
549            }
550        }
551        Ok(out)
552    }
553}