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}