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