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}