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, BoundedCount, DeleteMode, Page, PageRequest, SeekCursor, SeekPage, SqlValue,
10    StorageResult,
11};
12
13/// A storage-level note record. Flat, SQL-friendly representation.
14#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
15pub struct Note {
16    pub id: Uuid,
17    pub namespace: String,
18    pub kind: String,
19    pub status: String,
20    pub name: Option<String>,
21    pub content: String,
22    pub salience: Option<f64>,
23    pub decay_factor: Option<f64>,
24    pub expires_at: Option<i64>,
25    pub properties: Option<Value>,
26    pub created_at: i64,
27    pub updated_at: i64,
28    pub deleted_at: Option<i64>,
29    /// Immutable caller-chosen identity, unique among live notes of the same namespace and kind.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub key: Option<String>,
32    /// Persisted revision, assigned by storage and advanced on every matched update.
33    #[serde(default = "initial_note_version")]
34    pub version: i64,
35}
36
37/// The columns needed to decide whether a note may appear in an ID-based result.
38/// Includes soft-deleted rows so the caller can apply its visibility policy.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct NoteVisibility {
41    pub id: Uuid,
42    pub namespace: String,
43    pub deleted_at: Option<i64>,
44}
45
46const fn initial_note_version() -> i64 {
47    1
48}
49
50impl Note {
51    /// Create a new note with a generated UUID and current timestamp.
52    pub fn new(
53        namespace: impl Into<String>,
54        kind: impl Into<String>,
55        content: impl Into<String>,
56    ) -> Self {
57        let now = chrono::Utc::now().timestamp_micros();
58        Self {
59            id: Uuid::new_v4(),
60            namespace: namespace.into(),
61            kind: kind.into(),
62            status: "active".to_string(),
63            name: None,
64            content: content.into(),
65            salience: None,
66            decay_factor: None,
67            expires_at: None,
68            properties: None,
69            created_at: now,
70            updated_at: now,
71            deleted_at: None,
72            key: None,
73            version: 1,
74        }
75    }
76
77    /// Set the note display name.
78    pub fn with_name(mut self, n: impl Into<String>) -> Self {
79        self.name = Some(n.into());
80        self
81    }
82
83    /// Set salience (infallible). Rejects non-finite values by returning `self`
84    /// unchanged; clamps finite values to `[0.0, 1.0]`. Prefer
85    /// [`try_with_salience`](Self::try_with_salience) at public boundaries.
86    pub fn with_salience(mut self, s: f64) -> Self {
87        if !s.is_finite() {
88            return self;
89        }
90        self.salience = Some(s.clamp(0.0, 1.0));
91        self
92    }
93
94    /// Set decay factor (infallible). Rejects non-finite values by returning
95    /// `self` unchanged; floors finite values at `0.0`. Prefer
96    /// [`try_with_decay`](Self::try_with_decay) at public boundaries.
97    pub fn with_decay(mut self, d: f64) -> Self {
98        if !d.is_finite() {
99            return self;
100        }
101        self.decay_factor = Some(d.max(0.0));
102        self
103    }
104
105    /// Set salience with validation. Returns an error for non-finite or
106    /// out-of-range `[0.0, 1.0]` values.
107    pub fn try_with_salience(mut self, s: f64) -> Result<Self, String> {
108        if !s.is_finite() {
109            return Err(format!("salience must be finite, got {s}"));
110        }
111        if !(0.0..=1.0).contains(&s) {
112            return Err(format!("salience must be in [0.0, 1.0], got {s}"));
113        }
114        self.salience = Some(s);
115        Ok(self)
116    }
117
118    /// Set decay factor with validation. Returns an error for non-finite or
119    /// negative values.
120    pub fn try_with_decay(mut self, d: f64) -> Result<Self, String> {
121        if !d.is_finite() {
122            return Err(format!("decay_factor must be finite, got {d}"));
123        }
124        if d < 0.0 {
125            return Err(format!("decay_factor must be >= 0.0, got {d}"));
126        }
127        self.decay_factor = Some(d);
128        Ok(self)
129    }
130
131    /// Set the note properties JSON blob.
132    pub fn with_properties(mut self, p: Value) -> Self {
133        self.properties = Some(p);
134        self
135    }
136}
137
138#[cfg(test)]
139#[path = "note/tests.rs"]
140mod tests;
141
142/// Sort direction for filtered note queries.
143#[derive(Clone, Debug, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum SortDir {
146    Asc,
147    Desc,
148}
149
150/// Comparison operator for a [`PropertyFilter`] on a JSON path.
151#[derive(Clone, Debug, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum FilterOp {
154    Eq,
155    /// Matches rows where the JSON field equals the value OR the field is absent/NULL.
156    /// Used for properties that may be missing in legacy rows (e.g. `$.read`).
157    EqOrMissing,
158    /// Matches the supplied value using the indexable
159    /// `ifnull(json_extract(...), '')` expression. The value may be any
160    /// [`SqlValue`] accepted by the SQL adapter; in particular, `Text("")`
161    /// matches both missing values and present-but-empty values.
162    EqOrMissingIndexed,
163    /// Matches rows where the JSON field is absent or SQL-NULL.
164    JsonTypeMissing,
165    /// Matches a missing path, JSON null, or JSON text empty after SQLite's
166    /// ASCII-SPACE-only `trim`. Tabs and Unicode whitespace do not match.
167    /// `PropertyFilter.value` is unused.
168    MissingNullOrSpaceEmptyText,
169    /// Matches JSON boolean true or the exact JSON text "true", not numeric 1.
170    /// `PropertyFilter.value` is unused.
171    TrueOrTextTrue,
172    /// Matches rows where the JSON field is absent or explicitly JSON `null`,
173    /// while constraining its index key to the empty recipient key. This is
174    /// the index-friendly legacy-recipient partition used with
175    /// [`FilterOp::EqOrMissingIndexed`].
176    JsonTypeMissingOrNullIndexed,
177    /// Combines the exact-value and legacy-recipient partitions
178    /// (`EqOrMissingIndexed` + `JsonTypeMissingOrNullIndexed`) into one
179    /// predicate over the same indexable `ifnull(json_extract(...), '')`
180    /// expression, so a single index seek serves both partitions instead of
181    /// two separate bounded queries. Matches rows where the field equals the
182    /// value, OR the field is absent/JSON-`null`. A present-but-empty JSON
183    /// string value does NOT match through the legacy branch — the same
184    /// `json_type` guard `JsonTypeMissingOrNullIndexed` uses excludes it —
185    /// so this reproduces `EqOrMissing` exactly, given a non-empty compared
186    /// value.
187    EqOrLegacyIndexed,
188    /// Matches rows where a JSON text field equals the value, while treating
189    /// every missing or non-text value as that same value. The SQL adapter
190    /// emits `CASE WHEN json_type(...) = 'text' THEN json_extract(...) ELSE
191    /// value END = value`, mirroring callers whose read model assigns one
192    /// textual default to absent, JSON-null, and malformed legacy values.
193    TextEqOrNonText,
194    /// Matches a JSON text field against the supplied set, or any missing or
195    /// non-text value. The non-text branch still matches when the set is empty.
196    /// `PropertyFilter.value` is unused; the set lives in this variant.
197    TextInOrNonText(Vec<SqlValue>),
198    Ne,
199    Lt,
200    Lte,
201    Gt,
202    Gte,
203    /// Matches rows where `json_type(properties, path) = value`.
204    /// Value must be a SQLite json_type string literal: 'true', 'false', 'integer',
205    /// 'real', 'text', 'array', 'object', or 'null'.
206    JsonTypeEq,
207    /// Matches rows where the json_type is absent (NULL) OR differs from value.
208    /// Equivalent to `json_type IS NULL OR json_type != value`.
209    /// Value must be a SQLite json_type string literal: 'true', 'false',
210    /// 'integer', 'real', 'text', 'array', 'object', or 'null'. Used for
211    /// unread filter: matches any `$.read` that is NOT the JSON boolean true.
212    JsonTypeNeMissing,
213    /// Matches rows where `json_extract(properties, path)` equals any value in
214    /// the set. A row with a missing/NULL property does not match — use
215    /// `TextInOrNonText` when a textual read model includes missing/non-text
216    /// legacy values. `PropertyFilter.value` is unused for this op; the set
217    /// lives in the variant itself.
218    In(Vec<SqlValue>),
219    /// Matches rows where the property is missing/NULL OR its value is not in
220    /// the set. Used to exclude a closed set while treating an unset property
221    /// as included (e.g. comm inbox excludes `outbound`). `PropertyFilter.value` is
222    /// unused for this op; the set lives in the variant itself.
223    NotInOrMissing(Vec<SqlValue>),
224    /// Matches rows whose JSON text field starts with the supplied prefix.
225    /// Rendered as an index-seekable half-open range over the plain
226    /// `json_extract` expression (`expr >= prefix AND expr < next(prefix)`,
227    /// where `next` increments the prefix's last code point), so an index
228    /// keyed on that expression serves the seek instead of a scan. SQLite
229    /// sorts non-text values outside the text range, so a missing, null or
230    /// numeric field never matches. `PropertyFilter.value` is the prefix and
231    /// must be `SqlValue::Text`; an empty prefix matches every text value.
232    TextStartsWithIndexed,
233    /// Match a recipient's first colon-terminated channel prefix by equality
234    /// on its indexed bucket. The value must be a nonempty `SqlValue::Text`
235    /// prefix with exactly one trailing colon, such as `email:`. Arbitrary
236    /// partial prefixes use `TextStartsWithIndexed` instead.
237    TextColonPrefixBucketIndexed,
238    /// Keep only RFC 3339 text values that parse as UTC instants.
239    Rfc3339Valid,
240    /// Compare parsed UTC instants, including subsecond precision and offsets.
241    /// The value is a `Timestamp` or RFC 3339 `Text`.
242    Rfc3339Gte,
243    Rfc3339Lte,
244    /// Matches an RFC 3339 instant at or before the supplied timestamp, or a
245    /// missing, null, non-text, or malformed value. The outbox uses this to
246    /// fail open on legacy retry deadlines without filtering after LIMIT.
247    Rfc3339LteOrInvalid,
248}
249
250/// A single `json_extract(properties, '$.field') op value` predicate.
251///
252/// Callers import this as `khive_storage::note::PropertyFilter` to avoid
253/// collision with the vector-metadata `PropertyFilter` in `khive_storage::types`.
254#[derive(Clone, Debug, Serialize, Deserialize)]
255pub struct PropertyFilter {
256    pub json_path: String,
257    pub op: FilterOp,
258    pub value: SqlValue,
259}
260
261/// Keyset pagination boundary over the notes store's default total order
262/// (`created_at DESC, id ASC` — see `note_filter_page_order_clause`).
263///
264/// Only meaningful when [`NoteFilter::order_by`] is `None`: the boundary is
265/// expressed in terms of that default order, and combining it with a custom
266/// sort field has no defined meaning, so callers must not set both.
267#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
268pub struct NoteSeekAfter {
269    pub created_at: i64,
270    pub id: Uuid,
271}
272
273/// Boundary in an ascending RFC 3339 property order. Equal instants sort by
274/// their stored text and then note ID, so this cursor identifies one row even
275/// when distinct offsets encode the same instant.
276#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277pub struct NoteInstantSeekAfter {
278    pub value: String,
279    pub id: Uuid,
280}
281
282#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
283#[serde(rename_all = "snake_case")]
284pub enum NoteTagMode {
285    #[default]
286    Any,
287    All,
288}
289
290#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
291#[serde(deny_unknown_fields)]
292pub struct NoteKeyCursor {
293    pub updated_at: i64,
294    pub key: String,
295    pub id: Uuid,
296}
297
298impl From<&Note> for NoteKeyCursor {
299    fn from(note: &Note) -> Self {
300        Self {
301            updated_at: note.updated_at,
302            key: note.key.clone().expect("keyed row"),
303            id: note.id,
304        }
305    }
306}
307
308/// Inclusive expiry cutoff with a creation cutoff only for notes without an expiry.
309/// Both bounds use microseconds since the Unix epoch.
310#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
311pub struct NoteExpiryFallback {
312    pub expires_at_or_before: i64,
313    pub created_at_or_before: i64,
314}
315
316/// Ascending native timestamp order, with ascending note ID as the final tie-break.
317#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case")]
319pub enum NoteTimeOrder {
320    /// `expires_at ASC, id ASC`; absent expiries sort first.
321    ExpiresAt,
322    /// `COALESCE(expires_at, created_at) ASC, id ASC`.
323    ExpiresAtOrCreatedAt,
324}
325
326/// Filter + sort options for [`NoteStore::query_notes_filtered`].
327///
328/// Designed for general property-based filtering on any JSON field, not
329/// schedule-specific, so D9 and future packs can reuse the same API.
330#[derive(Clone, Debug, Default, Serialize, Deserialize)]
331pub struct NoteFilter {
332    pub kind: Option<String>,
333    #[serde(default)]
334    pub property_filters: Vec<PropertyFilter>,
335    /// `(json_path, direction)` — `None` defaults to `created_at DESC`.
336    pub order_by: Option<(String, SortDir)>,
337    /// Interpret `order_by`'s text property as an RFC 3339 UTC instant before
338    /// sorting. Only ascending order is currently supported.
339    #[serde(default)]
340    pub order_by_instant: bool,
341    /// When true, omit the SQL ordering clause from count-free pages. The
342    /// caller is responsible for ordering the bounded result set.
343    #[serde(default)]
344    pub unordered: bool,
345    /// When non-empty, restricts results to any of these namespaces using
346    /// `namespace IN (...)`. Takes precedence over the `namespace` string
347    /// parameter passed to `query_notes_filtered`. When empty the
348    /// caller-supplied `namespace` parameter is used (backward-compatible).
349    #[serde(default)]
350    pub namespaces: Vec<String>,
351    /// Restrict to notes where `created_at >= min_created_at` (microseconds epoch).
352    /// `None` applies no lower-bound constraint.
353    pub min_created_at: Option<i64>,
354    /// Inclusive creation-time upper bound in microseconds since the Unix epoch.
355    #[serde(default)]
356    pub max_created_at: Option<i64>,
357    /// Inclusive expiry-time upper bound in microseconds since the Unix epoch.
358    /// Rows with no expiry do not match a supplied bound.
359    #[serde(default)]
360    pub max_expires_at: Option<i64>,
361    /// Include soft-deleted rows in filtered reads. Property patches remain live-only.
362    #[serde(default)]
363    pub include_deleted: bool,
364    /// Select expired notes, falling back to creation time only when expiry is absent.
365    /// This predicate is ANDed with all other bounds and property filters.
366    #[serde(default)]
367    pub expiry_fallback: Option<NoteExpiryFallback>,
368    /// Native ascending timestamp order for counted/count-free/bounded pages.
369    /// Incompatible with property/instant/unordered ordering or any cursor.
370    /// Keyed/sequence reads and filtered mutations reject it; counts ignore ordering.
371    #[serde(default)]
372    pub time_order: Option<NoteTimeOrder>,
373    #[serde(default)]
374    pub min_updated_at: Option<i64>,
375    #[serde(default)]
376    pub tags: Vec<String>,
377    #[serde(default)]
378    pub tag_mode: NoteTagMode,
379    /// Restrict results to rows strictly after this boundary in the default
380    /// `created_at DESC, id ASC` order, for keyset (seek) pagination that
381    /// avoids re-walking earlier pages the way `PageRequest.offset` does.
382    /// Requires `order_by` to be `None`.
383    ///
384    /// Honoured only by [`NoteStore::query_notes_filtered_count_free`], which
385    /// seeks directly to the boundary and returns `total: None`.
386    /// [`NoteStore::query_notes_filtered`] rejects a non-`None` value with
387    /// `StorageError::InvalidInput`: it computes an exact `COUNT(*)` total
388    /// over the whole matching set, which has no defined meaning paired with
389    /// a seek boundary.
390    #[serde(default)]
391    pub after: Option<NoteSeekAfter>,
392    /// Seek after a row in the `order_by_instant` total order. Honoured only
393    /// by `query_notes_filtered_count_free` with `offset: 0`.
394    #[serde(default)]
395    pub after_instant: Option<NoteInstantSeekAfter>,
396    /// Restrict `message` rows to the ones this mailbox reader may see, in
397    /// the query itself. Rows of every other kind are unaffected. Applying the
398    /// partition here means a scan window, and any cursor derived from it,
399    /// only ever contains rows the reader is allowed to see.
400    #[serde(default)]
401    pub mailbox: Option<NoteMailboxScope>,
402}
403
404impl NoteFilter {
405    /// Select expiry timestamps at or before `micros` (inclusive, Unix microseconds).
406    /// A note without an expiry does not match this bound.
407    pub fn expires_before(mut self, micros: i64) -> Self {
408        self.max_expires_at = Some(micros);
409        self
410    }
411
412    /// Select creation timestamps at or before `micros` (inclusive, Unix microseconds).
413    pub fn created_before(mut self, micros: i64) -> Self {
414        self.max_created_at = Some(micros);
415        self
416    }
417
418    /// Include tombstones in filtered reads without permitting property patches on them.
419    pub fn include_deleted(mut self) -> Self {
420        self.include_deleted = true;
421        self
422    }
423}
424
425/// The actor partition a mailbox reader may see, as evaluated by the store.
426/// It mirrors the row-level mailbox rule: an inbound message belongs to its
427/// `to_actor`, an outbound message to its `from_actor`, and `legacy_local`
428/// additionally admits the unattributed pre-routing rows.
429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
430pub struct NoteMailboxScope {
431    pub actor_id: String,
432    pub legacy_local: bool,
433}
434
435/// Temporal-referential note CRUD over the notes substrate table.
436#[async_trait]
437pub trait NoteStore: Send + Sync + 'static {
438    async fn get_live_notes_by_key(
439        &self,
440        _namespace: &str,
441        _key: &str,
442        _kind: Option<&str>,
443    ) -> StorageResult<Vec<Note>> {
444        Err(crate::StorageError::Unsupported {
445            capability: crate::StorageCapability::Notes,
446            operation: "get_live_notes_by_key".into(),
447            message: "backend has no keyed note lookup".into(),
448        })
449    }
450
451    async fn query_keyed_notes(
452        &self,
453        _namespace: &str,
454        _filter: &NoteFilter,
455        _prefix: &str,
456        _after: Option<&NoteKeyCursor>,
457        _page: PageRequest,
458    ) -> StorageResult<(Vec<Note>, Option<NoteKeyCursor>)> {
459        Err(crate::StorageError::Unsupported {
460            capability: crate::StorageCapability::Notes,
461            operation: "query_keyed_notes".into(),
462            message: "backend has no keyed note pagination".into(),
463        })
464    }
465
466    /// Insert or update a single note. Updates preserve the stored immutable key.
467    async fn upsert_note(&self, note: Note) -> StorageResult<()>;
468    /// Replace a note only when the persisted row still matches the caller's
469    /// read snapshot.
470    ///
471    /// `expected_updated_at` is the snapshot revision and
472    /// `expected_deleted_at` closes the soft-delete race (legacy soft-delete
473    /// paths may change `deleted_at` without changing `updated_at`). The
474    /// replacement note's `updated_at` must be strictly greater than that
475    /// persisted revision. Returns `false` when the row disappeared, changed,
476    /// or was supplied a non-advancing replacement revision. This is the
477    /// full-note compare-and-swap seam used when a pack hook derives coupled
478    /// fields from that snapshot before persistence. The default returns
479    /// `Unsupported` rather than falling back to an unguarded upsert and
480    /// reintroducing the stale-snapshot race.
481    async fn replace_note_if_unchanged(
482        &self,
483        _note: Note,
484        _expected_updated_at: i64,
485        _expected_deleted_at: Option<i64>,
486    ) -> StorageResult<bool> {
487        Err(crate::StorageError::Unsupported {
488            capability: crate::StorageCapability::Notes,
489            operation: "replace_note_if_unchanged".into(),
490            message: "this backend does not implement guarded note replacement".into(),
491        })
492    }
493    /// Insert a note only if no row already holds its id, reporting whether
494    /// this call is the one that inserted it.
495    ///
496    /// This closes the other half of the read-modify-write race that
497    /// [`NoteStore::replace_note_if_unchanged`] closes. That one protects a
498    /// caller who read an existing row; this one protects a caller who read
499    /// *no* row. Where the id is derived rather than freshly generated — a
500    /// deterministic per-subject id — two callers can both read absence and
501    /// both write, and an upsert resolves that by overwriting, so the first
502    /// caller's write is lost with no error on either side. Returns `false`
503    /// when a row already existed, which the caller maps to a conflict rather
504    /// than to success.
505    ///
506    /// The pre-existing row is left exactly as it is: this must not be
507    /// implemented as an upsert, since overwriting is the behaviour being
508    /// avoided. The default returns `Unsupported` for that reason, rather
509    /// than falling back to `upsert_note` and silently reintroducing the
510    /// race under a name that promises otherwise.
511    async fn insert_note_if_absent(&self, _note: Note) -> StorageResult<bool> {
512        Err(crate::StorageError::Unsupported {
513            capability: crate::StorageCapability::Notes,
514            operation: "insert_note_if_absent".into(),
515            message: "this backend does not implement guarded note insertion".into(),
516        })
517    }
518    /// Insert or update a batch of notes. Updates preserve each stored immutable key.
519    async fn upsert_notes(&self, notes: Vec<Note>) -> StorageResult<BatchWriteSummary>;
520    /// Fetch a note by UUID, returning `None` if absent.
521    async fn get_note(&self, id: Uuid) -> StorageResult<Option<Note>>;
522    /// Fetch a note by UUID regardless of soft-deletion state.
523    ///
524    /// Returns the note row even when `deleted_at` is set. Callers use this
525    /// to distinguish "soft-deleted" from "never existed".
526    async fn get_note_including_deleted(&self, id: Uuid) -> StorageResult<Option<Note>>;
527    /// Delete a note by UUID using the specified delete mode.
528    async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> StorageResult<bool>;
529    /// Patch `properties`/`updated_at` on an existing note in place via a real
530    /// `UPDATE`, leaving every other column (including the row's `rowid`)
531    /// untouched.
532    ///
533    /// Unlike `upsert_note`, which writes the complete note shape, this leaves
534    /// every non-property column untouched. It also never churns the row's
535    /// implicit `rowid`, which is required by callers relying on stable row
536    /// identity (#780).
537    /// Returns `true` when a live (non-soft-deleted) row with this `id` was
538    /// found and updated, `false` otherwise.
539    async fn update_note_properties(
540        &self,
541        id: Uuid,
542        properties: Option<Value>,
543        updated_at: i64,
544    ) -> StorageResult<bool>;
545    /// Atomically set one top-level key in a note's JSON `properties` object.
546    ///
547    /// The backend must perform the read/modify/write as one storage operation
548    /// so concurrent writes to different keys cannot overwrite each other.
549    /// `value` keeps its JSON type, including explicit JSON `null`. A SQL-NULL
550    /// property document is initialized as an empty object. A live row whose
551    /// stored document is a non-object is not modified and returns `false`, as
552    /// do missing and soft-deleted rows. Keys containing U+0000 must be
553    /// rejected: SQLite JSON-path labels cannot address them without risking
554    /// mutation of a shorter sibling key.
555    async fn set_note_property(
556        &self,
557        id: Uuid,
558        key: &str,
559        value: Value,
560        updated_at: i64,
561    ) -> StorageResult<bool>;
562    /// Atomically patch a single `properties` JSON key on a note, but only
563    /// when the row's *current* state (re-evaluated inside this same
564    /// statement, not a snapshot the caller fetched earlier) still satisfies
565    /// `filter`'s namespace/kind/property_filters.
566    ///
567    /// Unlike `set_note_property` (which patches unconditionally once the row
568    /// is live) or `update_note_properties` (which replaces the whole
569    /// `properties` column with a value the caller already computed — safe
570    /// only when nothing else can have written to the row since the caller's
571    /// read), this also rechecks `filter` against the row's live state before
572    /// writing, so a target that stopped matching an eligibility predicate
573    /// between validation and this call is not mutated. Any other property
574    /// written concurrently between the caller's read and this call survives
575    /// untouched either way. A live row whose stored `properties` document is
576    /// a non-object (scalar, array, or otherwise) is not modified and returns
577    /// `false`, mirroring `set_note_property`. Returns `Ok(false)` — not an
578    /// error — when no live row currently matches `filter` (id not found,
579    /// soft-deleted, an eligibility property changed since the caller last
580    /// validated it, or the stored document is not a JSON object); the
581    /// caller degrades that the same way as `update_note_properties`'s
582    /// `Ok(false)`.
583    async fn try_patch_note_property(
584        &self,
585        id: Uuid,
586        namespace: &str,
587        filter: &NoteFilter,
588        json_path: &str,
589        value: Value,
590        updated_at: i64,
591    ) -> StorageResult<bool>;
592    /// Atomically patch one JSON property on every supplied note.
593    ///
594    /// Each target is rechecked against `namespace` and `filter` inside the
595    /// same transaction. The operation commits only when every distinct id
596    /// matches exactly one live object-valued row; a missing, soft-deleted, or
597    /// no-longer-eligible target rolls the entire unit back. Other property
598    /// keys are preserved by the same storage-side `json_set` operation as
599    /// [`Self::try_patch_note_property`]. Backends without a transactional
600    /// multi-note implementation retain the default `Unsupported` result.
601    async fn patch_note_property_atomic(
602        &self,
603        _ids: Vec<Uuid>,
604        _namespace: &str,
605        _filter: &NoteFilter,
606        _json_path: &str,
607        _value: Value,
608        _updated_at: i64,
609    ) -> StorageResult<()> {
610        Err(crate::StorageError::Unsupported {
611            capability: crate::StorageCapability::Notes,
612            operation: "patch_note_property_atomic".into(),
613            message: "this backend does not implement atomic multi-note property patches".into(),
614        })
615    }
616    /// Query notes by namespace and optional kind with pagination.
617    /// The returned total and page items must come from one consistent
618    /// backend snapshot.
619    async fn query_notes(
620        &self,
621        namespace: &str,
622        kind: Option<&str>,
623        page: PageRequest,
624    ) -> StorageResult<Page<Note>>;
625    /// Query notes by namespace and optional kind without computing an exact total.
626    ///
627    /// Implementations must return `total: None`. Backends that cannot
628    /// guarantee a count-free projection must fail closed rather than invoke
629    /// [`Self::query_notes`], whose exact total remains available to callers
630    /// that expose a snapshot-consistent count.
631    async fn query_notes_count_free(
632        &self,
633        _namespace: &str,
634        _kind: Option<&str>,
635        _page: PageRequest,
636    ) -> StorageResult<Page<Note>> {
637        Err(crate::StorageError::Unsupported {
638            capability: crate::StorageCapability::Notes,
639            operation: "query_notes_count_free".into(),
640            message: "this backend does not implement count-free note paging".into(),
641        })
642    }
643    /// Query notes with property-based filtering and custom sort.
644    /// The returned total and page items must come from one consistent
645    /// backend snapshot.
646    async fn query_notes_filtered(
647        &self,
648        namespace: &str,
649        filter: &NoteFilter,
650        page: PageRequest,
651    ) -> StorageResult<Page<Note>>;
652    /// Query a filtered note page without computing an exact total.
653    ///
654    /// Implementations must return `total: None`; callers that need a
655    /// `has_more` bit should request one lookahead row. Backends that cannot
656    /// guarantee a count-free projection must fail closed rather than invoke
657    /// [`Self::query_notes_filtered`], whose exact total is intentionally
658    /// backlog-proportional.
659    async fn query_notes_filtered_count_free(
660        &self,
661        _namespace: &str,
662        _filter: &NoteFilter,
663        _page: PageRequest,
664    ) -> StorageResult<Page<Note>> {
665        Err(crate::StorageError::Unsupported {
666            capability: crate::StorageCapability::Notes,
667            operation: "query_notes_filtered_count_free".into(),
668            message: "this backend does not implement count-free filtered note paging".into(),
669        })
670    }
671    /// Count several filtered note populations in one consistent backend
672    /// snapshot. Backends that cannot provide that guarantee must return
673    /// [`crate::StorageError::Unsupported`] rather than composing independent
674    /// queries or treating an absent page total as zero. SQL backends should
675    /// override this operation so callers can retain separate index-friendly
676    /// predicates without racing between their counts.
677    async fn count_notes_filtered_in_snapshot(
678        &self,
679        _namespace: &str,
680        _filters: &[NoteFilter],
681    ) -> StorageResult<Vec<u64>> {
682        Err(crate::StorageError::Unsupported {
683            capability: crate::StorageCapability::Notes,
684            operation: "count_notes_filtered_in_snapshot".into(),
685            message: "this backend does not implement snapshot-consistent filtered note counts"
686                .into(),
687        })
688    }
689    /// Count several filtered note populations in one backend snapshot, with
690    /// each count bounded by `cap`.
691    ///
692    /// A saturated entry reports `count == cap` and `saturated == true`; an
693    /// unsaturated entry is exact. SQL implementations should count over a
694    /// limited `SELECT 1` subquery so work is proportional to `cap` and the
695    /// caller's own matching population, never an unrelated backlog. That
696    /// bound holds only when every `property_filter` in the given
697    /// `NoteFilter` is servable as an index key column (equality on a bound
698    /// parameter or a plan-time-provable partial-index predicate) all the way
699    /// down to the filter that actually narrows the population — a residual
700    /// filter evaluated after the index seek (a bound parameter the index
701    /// cannot use to skip rows) degrades the bound to work proportional to
702    /// the rows *scanned* before `cap` matches are found, not the rows that
703    /// match. Callers building filters against this bound must ensure the
704    /// full predicate set is indexed, not just the predicate that narrows the
705    /// population most. Backends that cannot preserve the shared snapshot or
706    /// work bound must fail closed.
707    async fn count_notes_filtered_bounded_in_snapshot(
708        &self,
709        _namespace: &str,
710        _filters: &[NoteFilter],
711        _cap: u32,
712    ) -> StorageResult<Vec<BoundedCount>> {
713        Err(crate::StorageError::Unsupported {
714            capability: crate::StorageCapability::Notes,
715            operation: "count_notes_filtered_bounded_in_snapshot".into(),
716            message: "this backend does not implement snapshot-consistent bounded note counts"
717                .into(),
718        })
719    }
720    /// Resolve a note id to its immutable insertion sequence.
721    async fn note_sequence(&self, _id: Uuid) -> StorageResult<Option<i64>> {
722        Err(crate::StorageError::Unsupported {
723            capability: crate::StorageCapability::Notes,
724            operation: "note_sequence".into(),
725            message: "this backend does not implement note insertion sequences".into(),
726        })
727    }
728    /// Query an immutable insertion-sequence keyset page with the same
729    /// predicates as [`Self::query_notes_filtered`].
730    async fn query_notes_filtered_after(
731        &self,
732        _namespace: &str,
733        _filter: &NoteFilter,
734        _after: Option<SeekCursor>,
735        _limit: u32,
736    ) -> StorageResult<SeekPage<Note>> {
737        Err(crate::StorageError::Unsupported {
738            capability: crate::StorageCapability::Notes,
739            operation: "query_notes_filtered_after".into(),
740            message: "this backend does not implement note seek pagination".into(),
741        })
742    }
743    /// Fetch up to `max_rows + 1` notes matching `filter` in a single
744    /// deterministically-ordered SQL statement, with no separate `COUNT(*)`
745    /// and no pagination loop.
746    ///
747    /// A single statement observes one consistent snapshot for its entire
748    /// execution, so the result cannot be split across a concurrent insert
749    /// the way a `COUNT(*)` followed by independent `LIMIT`/`OFFSET` pages
750    /// can. Callers detect the over-bound case by checking whether the
751    /// returned `Vec` has more than `max_rows` items — that means at least
752    /// `max_rows + 1` rows matched and the caller must reject the query
753    /// rather than silently return a truncated, possibly priority-incomplete
754    /// set.
755    async fn query_notes_filtered_bounded(
756        &self,
757        namespace: &str,
758        filter: &NoteFilter,
759        max_rows: u32,
760    ) -> StorageResult<Vec<Note>>;
761    /// Count notes in a namespace, optionally filtered by kind.
762    async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> StorageResult<u64>;
763    /// Count notes across the given namespaces, optionally filtered by kind.
764    /// The default preserves compatibility by summing the existing
765    /// single-namespace operation; SQL backends should override this with one
766    /// `IN` aggregate.
767    async fn count_notes_in_namespaces(
768        &self,
769        namespaces: &[String],
770        kind: Option<&str>,
771    ) -> StorageResult<u64> {
772        let mut total = 0;
773        for namespace in namespaces {
774            total += self.count_notes(namespace, kind).await?;
775        }
776        Ok(total)
777    }
778
779    /// Attempt to insert a note without overwriting an existing row.
780    ///
781    /// Returns `true` when the row was newly written.  Returns `false` only
782    /// when a live note with the same non-empty `external_id` already exists in
783    /// the same namespace and kind (confirmed dedup hit).  Any other constraint
784    /// violation (e.g. a primary key collision) is surfaced as a `StorageError`
785    /// so that callers do not misinterpret unexpected failures as deduplication.
786    async fn try_insert_note(&self, note: Note) -> StorageResult<bool>;
787
788    /// Atomically insert an ingest note and the blob attachments owned by it.
789    /// A deduplicated note writes no attachments; callers must inspect the
790    /// existing note before repairing a legacy missing role.
791    async fn try_insert_note_with_attachments(
792        &self,
793        _note: Note,
794        _attachments: Vec<crate::Attachment>,
795    ) -> StorageResult<bool> {
796        Err(crate::StorageError::Unsupported {
797            capability: crate::StorageCapability::Notes,
798            operation: "try_insert_note_with_attachments".into(),
799            message: "this backend cannot atomically insert note attachments".into(),
800        })
801    }
802
803    /// Fetch multiple notes by UUID in a single call.
804    async fn get_notes_batch(&self, ids: &[Uuid]) -> StorageResult<Vec<Note>> {
805        let mut out = Vec::with_capacity(ids.len());
806        for &id in ids {
807            if let Some(n) = self.get_note(id).await? {
808                out.push(n);
809            }
810        }
811        Ok(out)
812    }
813
814    /// Fetch multiple notes, including soft-deleted rows, for mutation policy checks.
815    /// Missing IDs are omitted; callers correlate rows by ID rather than result order.
816    async fn get_notes_batch_including_deleted(&self, ids: &[Uuid]) -> StorageResult<Vec<Note>> {
817        let mut out = Vec::with_capacity(ids.len());
818        for &id in ids {
819            if let Some(note) = self.get_note_including_deleted(id).await? {
820                out.push(note);
821            }
822        }
823        Ok(out)
824    }
825
826    /// Fetch only the columns needed for note visibility checks by UUID.
827    /// Missing IDs are omitted; soft-deleted rows retain their deletion time.
828    async fn get_note_visibility_batch(&self, ids: &[Uuid]) -> StorageResult<Vec<NoteVisibility>> {
829        let mut out = Vec::with_capacity(ids.len());
830        for &id in ids {
831            if let Some(note) = self.get_note_including_deleted(id).await? {
832                out.push(NoteVisibility {
833                    id: note.id,
834                    namespace: note.namespace,
835                    deleted_at: note.deleted_at,
836                });
837            }
838        }
839        Ok(out)
840    }
841}