Skip to main content

khive_db/stores/
note.rs

1//! SQL-backed `NoteStore` implementation.
2
3pub mod recipient;
4pub mod transport;
5
6use std::collections::HashSet;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use rusqlite::OptionalExtension;
11use uuid::Uuid;
12
13use khive_storage::attachment::{Attachment, AttachmentSubstrate};
14use khive_storage::error::StorageError;
15use khive_storage::note::{
16    FilterOp, Note, NoteFilter, NoteInstantSeekAfter, NoteKeyCursor, NoteSeekAfter, NoteTagMode,
17    NoteTimeOrder, NoteVisibility, SortDir,
18};
19use khive_storage::types::{
20    BatchWriteSummary, BoundedCount, DeleteMode, Page, PageRequest, SeekCursor, SeekPage,
21    SqlStatement, SqlValue,
22};
23use khive_storage::NoteStore;
24use khive_storage::{StorageCapability, StorageResult};
25
26use crate::error::SqliteError;
27use crate::pool::ConnectionPool;
28use crate::sql_bridge::bind_params;
29use crate::stores::attachment::{attachment_upsert_statement, delete_record_attachments_statement};
30use crate::writer_task::{execute_wrapped_transaction, WriterTaskHandle};
31
32fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
33    StorageError::driver(StorageCapability::Notes, op, e)
34}
35
36fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
37    e.into_storage_error(StorageCapability::Notes, op)
38}
39
40const NAMESPACE_COUNT_CHUNK_SIZE: usize = 500;
41
42pub fn note_key_prefix_successor(prefix: &str) -> Option<String> {
43    let mut chars: Vec<char> = prefix.chars().collect();
44    while let Some(last) = chars.pop() {
45        if last == char::MAX {
46            continue;
47        }
48        let next = if last == '\u{d7ff}' {
49            '\u{e000}'
50        } else {
51            char::from_u32(u32::from(last) + 1).expect("incremented non-max scalar")
52        };
53        chars.push(next);
54        return Some(chars.into_iter().collect());
55    }
56    None
57}
58
59// ---------------------------------------------------------------------------
60// Pure statement builders (ADR-099 B3 r6 structural cut) — see entity.rs's
61// sibling block for the full rationale. `upsert_note`/`delete_note` below
62// and ADR-099's atomic prepare path (`khive-runtime`) both call these.
63// ---------------------------------------------------------------------------
64
65/// The single true UPSERT every note writer (single, batch, and note-merge)
66/// issues against `notes` (ADR-116 memory-ANN-generation-coherence prereq).
67///
68/// `INSERT OR REPLACE` is a SQLite DELETE-then-INSERT on a conflicting `id`:
69/// it fires DELETE-path triggers (spuriously invalidating ANN generation
70/// state once ADR-116's liveness triggers land) and discards the row's
71/// original `created_at`/rowid identity. This form updates in place on a
72/// primary-key conflict instead. `created_at` is deliberately absent from
73/// the `DO UPDATE SET` list — it is bound for the INSERT branch only and
74/// left untouched by the UPDATE branch, so an existing row keeps its
75/// original `created_at` across any number of upserts. The caller-chosen
76/// `key` is likewise insert-only: updates cannot replace or clear its identity.
77pub const NOTE_UPSERT_SQL: &str = "INSERT INTO notes \
78     (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
79      properties, created_at, updated_at, deleted_at, key, strict_due_key, due_source) \
80     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) \
81     ON CONFLICT(id) DO UPDATE SET \
82       namespace = excluded.namespace, \
83       kind = excluded.kind, \
84       status = excluded.status, \
85       name = excluded.name, \
86       content = excluded.content, \
87       salience = excluded.salience, \
88       decay_factor = excluded.decay_factor, \
89       expires_at = excluded.expires_at, \
90       properties = excluded.properties, \
91       strict_due_key = excluded.strict_due_key, \
92       due_source = excluded.due_source, \
93       updated_at = excluded.updated_at, \
94       deleted_at = excluded.deleted_at";
95
96/// Insert-if-absent counterpart to [`NOTE_UPSERT_SQL`], for a caller whose
97/// read found no row.
98///
99/// Identical column list and parameter order, and deliberately
100/// `DO NOTHING` rather than `DO UPDATE`: the point is that a row already
101/// present must survive untouched, so the losing caller can be told it lost
102/// instead of silently overwriting the winner. `changes()` is then the
103/// answer to "did I insert it", which `DO UPDATE` cannot report.
104pub const NOTE_INSERT_IF_ABSENT_SQL: &str = "INSERT INTO notes \
105     (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
106      properties, created_at, updated_at, deleted_at, key, strict_due_key, due_source) \
107     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) \
108     ON CONFLICT(id) DO NOTHING";
109
110/// The exact statement this store's `insert_note_if_absent` issues.
111pub fn note_insert_if_absent_statement(note: &Note) -> SqlStatement {
112    let mut statement = note_upsert_statement(note);
113    statement.sql = NOTE_INSERT_IF_ABSENT_SQL.to_string();
114    statement.label = Some("note-insert-if-absent".to_string());
115    statement
116}
117
118/// The live key index is the arbiter; an occupied key changes no row.
119pub fn note_insert_keyed_statement(note: &Note) -> SqlStatement {
120    let mut statement = note_upsert_statement(note);
121    statement.sql = "INSERT INTO notes \
122        (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
123         properties, created_at, updated_at, deleted_at, key, strict_due_key, due_source) \
124        VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16) \
125        ON CONFLICT(namespace,kind,key) WHERE key IS NOT NULL AND deleted_at IS NULL DO NOTHING"
126        .into();
127    statement.label = Some("note-keyed-create".into());
128    statement
129}
130
131/// Compute a strict, order-preserving deadline key for supported note writers.
132/// Missing, non-text, and malformed values remain NULL and enter the index as
133/// conservative due candidates, where the strict read residual decides.
134pub fn note_due_key_values(
135    properties: &Option<serde_json::Value>,
136) -> (Option<Vec<u8>>, Option<String>) {
137    let source = properties
138        .as_ref()
139        .and_then(|value| value.get("next_attempt_at"))
140        .and_then(|value| value.as_str());
141    match source.and_then(|text| crate::pool::strict_rfc3339_key(text).map(|key| (key, text))) {
142        Some((key, source)) => (Some(key), Some(source.to_string())),
143        None => (None, None),
144    }
145}
146
147/// The exact true UPSERT this store's `upsert_note` issues.
148pub fn note_upsert_statement(note: &Note) -> SqlStatement {
149    let (due_key, due_source) = note_due_key_values(&note.properties);
150    let properties_str = note
151        .properties
152        .as_ref()
153        .map(|v| serde_json::to_string(v).unwrap_or_default());
154    SqlStatement {
155        sql: NOTE_UPSERT_SQL.to_string(),
156        params: vec![
157            SqlValue::Text(note.id.to_string()),
158            SqlValue::Text(note.namespace.clone()),
159            SqlValue::Text(note.kind.to_string()),
160            SqlValue::Text(note.status.clone()),
161            match &note.name {
162                Some(n) => SqlValue::Text(n.clone()),
163                None => SqlValue::Null,
164            },
165            SqlValue::Text(note.content.clone()),
166            match note.salience {
167                Some(s) => SqlValue::Float(s),
168                None => SqlValue::Null,
169            },
170            match note.decay_factor {
171                Some(d) => SqlValue::Float(d),
172                None => SqlValue::Null,
173            },
174            match note.expires_at {
175                Some(e) => SqlValue::Integer(e),
176                None => SqlValue::Null,
177            },
178            match properties_str {
179                Some(p) => SqlValue::Text(p),
180                None => SqlValue::Null,
181            },
182            SqlValue::Integer(note.created_at),
183            SqlValue::Integer(note.updated_at),
184            match note.deleted_at {
185                Some(d) => SqlValue::Integer(d),
186                None => SqlValue::Null,
187            },
188            match &note.key {
189                Some(key) => SqlValue::Text(key.clone()),
190                None => SqlValue::Null,
191            },
192            due_key.map_or(SqlValue::Null, SqlValue::Blob),
193            due_source.map_or(SqlValue::Null, SqlValue::Text),
194        ],
195        label: Some("note-upsert".to_string()),
196    }
197}
198
199/// Full-note compare-and-swap update used after caller-side normalization was
200/// derived from a read snapshot. Unlike [`note_upsert_statement`], this never
201/// inserts and cannot overwrite a row whose revision or deletion marker moved
202/// after the snapshot was read. The replacement revision must also be strictly
203/// greater than the persisted snapshot revision; equality is a refused CAS,
204/// never a successful write with an unchanged concurrency token.
205pub fn note_replace_if_unchanged_statement(
206    note: &Note,
207    expected_updated_at: i64,
208    expected_deleted_at: Option<i64>,
209) -> SqlStatement {
210    let (due_key, due_source) = note_due_key_values(&note.properties);
211    let properties_str = note
212        .properties
213        .as_ref()
214        .map(|v| serde_json::to_string(v).unwrap_or_default());
215    SqlStatement {
216        sql: "UPDATE notes SET \
217                namespace = ?1, kind = ?2, status = ?3, name = ?4, content = ?5, \
218                salience = ?6, decay_factor = ?7, expires_at = ?8, properties = ?9, \
219                updated_at = ?10, deleted_at = ?11, strict_due_key = ?15, due_source = ?16 \
220              WHERE id = ?12 AND updated_at = ?13 AND deleted_at IS ?14 \
221                AND ?10 > updated_at"
222            .to_string(),
223        params: vec![
224            SqlValue::Text(note.namespace.clone()),
225            SqlValue::Text(note.kind.to_string()),
226            SqlValue::Text(note.status.clone()),
227            match &note.name {
228                Some(name) => SqlValue::Text(name.clone()),
229                None => SqlValue::Null,
230            },
231            SqlValue::Text(note.content.clone()),
232            match note.salience {
233                Some(value) => SqlValue::Float(value),
234                None => SqlValue::Null,
235            },
236            match note.decay_factor {
237                Some(value) => SqlValue::Float(value),
238                None => SqlValue::Null,
239            },
240            match note.expires_at {
241                Some(value) => SqlValue::Integer(value),
242                None => SqlValue::Null,
243            },
244            match properties_str {
245                Some(value) => SqlValue::Text(value),
246                None => SqlValue::Null,
247            },
248            SqlValue::Integer(note.updated_at),
249            match note.deleted_at {
250                Some(value) => SqlValue::Integer(value),
251                None => SqlValue::Null,
252            },
253            SqlValue::Text(note.id.to_string()),
254            SqlValue::Integer(expected_updated_at),
255            match expected_deleted_at {
256                Some(value) => SqlValue::Integer(value),
257                None => SqlValue::Null,
258            },
259            due_key.map_or(SqlValue::Null, SqlValue::Blob),
260            due_source.map_or(SqlValue::Null, SqlValue::Text),
261        ],
262        label: Some("note-replace-if-unchanged".to_string()),
263    }
264}
265
266/// Metadata-only CAS for an immutable stream entry. Protected fields are
267/// predicates, never SET targets: SQLite UPDATE OF triggers fire even when a
268/// value is assigned to itself. Keep the full snapshot and revision guards.
269pub fn note_metadata_replace_if_unchanged_statement(
270    note: &Note,
271    expected_updated_at: i64,
272    expected_deleted_at: Option<i64>,
273) -> SqlStatement {
274    let mut statement =
275        note_replace_if_unchanged_statement(note, expected_updated_at, expected_deleted_at);
276    statement.sql = "UPDATE notes SET status=?3, name=?4, salience=?6, decay_factor=?7, expires_at=?8, updated_at=?10 \
277                     WHERE id=?12 AND updated_at=?13 AND deleted_at IS ?14 AND ?10 > updated_at \
278                       AND namespace=?1 AND kind=?2 AND content=?5 AND properties IS ?9 AND deleted_at IS ?11".into();
279    statement.params.truncate(14);
280    statement.label = Some("stream-note-metadata-cas".into());
281    statement
282}
283
284/// The exact `properties`/`updated_at` `UPDATE` this store's
285/// `update_note_properties` issues. The row is patched in place without
286/// rewriting any other note column or its stable row identity (#780).
287/// The `comm.probe` cursor is keyed on `notes_seq.seq`, which is fixed at
288/// first insert and survives a delete+reinsert of the same note id, so this
289/// is defensive rather than load-bearing for cursor correctness; a metadata
290/// patch should never rewrite the row regardless.
291pub fn note_update_properties_statement(
292    id: Uuid,
293    properties: &Option<serde_json::Value>,
294    updated_at: i64,
295) -> SqlStatement {
296    let (due_key, due_source) = note_due_key_values(properties);
297    let properties_str = properties
298        .as_ref()
299        .map(|v| serde_json::to_string(v).unwrap_or_default());
300    SqlStatement {
301        sql: "UPDATE notes SET properties = ?1, updated_at = ?2, \
302              strict_due_key = ?4, due_source = ?5 \
303              WHERE id = ?3 AND deleted_at IS NULL"
304            .to_string(),
305        params: vec![
306            match properties_str {
307                Some(p) => SqlValue::Text(p),
308                None => SqlValue::Null,
309            },
310            SqlValue::Integer(updated_at),
311            SqlValue::Text(id.to_string()),
312            due_key.map_or(SqlValue::Null, SqlValue::Blob),
313            due_source.map_or(SqlValue::Null, SqlValue::Text),
314        ],
315        label: Some("note-update-properties".to_string()),
316    }
317}
318
319/// The atomic top-level JSON-property `UPDATE` issued by
320/// [`NoteStore::set_note_property`]. The key is encoded as one quoted JSON
321/// path segment, so punctuation is literal rather than interpreted as nested
322/// path syntax. `json(?2)` preserves the bound value's JSON type instead of
323/// storing objects, arrays, booleans, or numbers as JSON strings.
324///
325/// SQL-NULL documents start from `{}`. JSON arrays/scalars/null are not
326/// property objects and are left untouched; the caller receives `false`.
327pub fn note_set_property_statement(
328    id: Uuid,
329    key: &str,
330    value: &serde_json::Value,
331    updated_at: i64,
332) -> Result<SqlStatement, StorageError> {
333    // SQLite JSON-path object labels terminate at U+0000. Passing such a key
334    // through `json_set` can therefore select a shorter sibling key instead
335    // of the requested literal key, so reject it before constructing SQL.
336    if key.contains('\0') {
337        return Err(StorageError::InvalidInput {
338            capability: StorageCapability::Notes,
339            operation: "set_note_property".into(),
340            message: "property key must not contain U+0000".to_string(),
341        });
342    }
343    let path = format!("$.{}", serde_json::Value::String(key.to_string()));
344    let updating_due = key == "next_attempt_at";
345    let (due_key, due_source) = if updating_due {
346        note_due_key_values(&Some(serde_json::json!({"next_attempt_at": value})))
347    } else {
348        (None, None)
349    };
350    Ok(SqlStatement {
351        sql: "UPDATE notes \
352              SET properties = json_set(COALESCE(properties, '{}'), ?1, json(?2)), \
353                  updated_at = ?3, \
354                  strict_due_key = CASE WHEN ?5 = 1 THEN ?6 ELSE strict_due_key END, \
355                  due_source = CASE WHEN ?5 = 1 THEN ?7 ELSE due_source END \
356              WHERE id = ?4 AND deleted_at IS NULL \
357                AND (properties IS NULL OR json_type(properties) = 'object')"
358            .to_string(),
359        params: vec![
360            SqlValue::Text(path),
361            SqlValue::Text(value.to_string()),
362            SqlValue::Integer(updated_at),
363            SqlValue::Text(id.to_string()),
364            SqlValue::Integer(i64::from(updating_due)),
365            due_key.map_or(SqlValue::Null, SqlValue::Blob),
366            due_source.map_or(SqlValue::Null, SqlValue::Text),
367        ],
368        label: Some("note-set-property".to_string()),
369    })
370}
371
372/// The exact soft-delete `UPDATE` this store's `delete_note(Soft)` issues.
373pub fn note_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
374    SqlStatement {
375        sql: "UPDATE notes SET status = 'deleted', deleted_at = ?1 \
376              WHERE id = ?2 AND deleted_at IS NULL"
377            .to_string(),
378        params: vec![
379            SqlValue::Integer(deleted_at),
380            SqlValue::Text(id.to_string()),
381        ],
382        label: Some("note-delete-soft".to_string()),
383    }
384}
385
386/// The exact hard-delete `DELETE` this store's `delete_note(Hard)` issues.
387pub fn note_hard_delete_statement(id: Uuid) -> SqlStatement {
388    SqlStatement {
389        sql: "DELETE FROM notes WHERE id = ?1".to_string(),
390        params: vec![SqlValue::Text(id.to_string())],
391        label: Some("note-delete-hard".to_string()),
392    }
393}
394
395/// A NoteStore backed by SQLite. Namespace is the caller's responsibility.
396///
397/// UUID is globally unique — get/delete by ID alone. Query/count use the
398/// namespace parameter as passed. Read routing is always pool-backed; the
399/// constructor's legacy file-backed flag is retained for API compatibility.
400pub struct SqlNoteStore {
401    pool: Arc<ConnectionPool>,
402    index_repair: Option<super::index_repair::IndexRepairContext>,
403    writer_task: Option<WriterTaskHandle>,
404}
405
406impl SqlNoteStore {
407    /// Create a new store.
408    pub fn new(pool: Arc<ConnectionPool>, _is_file_backed: bool) -> Self {
409        // Enabled by default for file-backed pools; explicit off/degraded
410        // fallback remains possible (ADR-067 Component A, mirrors
411        // entity.rs policy): a missing writer task — explicitly disabled,
412        // spawn degraded, or no Tokio runtime available at this first
413        // access — is cached without failing construction. Every write
414        // re-resolves it and applies strict/compatibility policy then.
415        let writer_task = pool.writer_task_handle().ok().flatten();
416
417        Self {
418            pool,
419            writer_task,
420            index_repair: None,
421        }
422    }
423
424    pub(crate) fn with_index_repair(
425        mut self,
426        repair: super::index_repair::IndexRepairContext,
427    ) -> Self {
428        self.index_repair = Some(repair);
429        self
430    }
431
432    async fn with_indexed_reader<F, R>(&self, op: &'static str, read: F) -> Result<R, StorageError>
433    where
434        F: FnMut(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
435        R: Send + 'static,
436    {
437        super::index_repair::run_indexed_read(
438            Arc::clone(&self.pool),
439            self.index_repair.clone(),
440            StorageCapability::Notes,
441            op,
442            read,
443        )
444        .await
445    }
446
447    fn current_writer_task(
448        &self,
449        operation: &'static str,
450    ) -> Result<Option<WriterTaskHandle>, StorageError> {
451        self.pool
452            .writer_task_for_write(self.writer_task.as_ref(), operation)
453    }
454
455    /// Route a single-row write through the pool-wide `WriterTask` when
456    /// the write queue is enabled and a handle is available. Strict mode
457    /// refuses a missing handle; compatibility mode falls back to the legacy
458    /// pool-mutex path (ADR-067 Component A, Fork C slice 2).
459    ///
460    /// This is the routing point for single-statement `with_writer` callers
461    /// in this store (`update_note_properties`, `set_note_property`,
462    /// `delete_note`). `f` must be DML-only — on the flag-on path it runs
463    /// inside the WriterTask's own transaction, so a bare `BEGIN IMMEDIATE`
464    /// would violate SQLite's nested-transaction rule. `upsert_notes` (the
465    /// batch method) performs the same write-time lookup first; a non-strict
466    /// `None` then falls through this helper, which records the actual
467    /// compatibility fallback. Strict mode returns before the direct-writer
468    /// seam. Callers whose `f` issues more than one
469    /// DML statement that must land atomically together (`upsert_note`,
470    /// `try_insert_note`, `patch_note_property_atomic`) use
471    /// [`Self::with_writer_tx`] instead — see its doc comment (khive #827,
472    /// #1387).
473    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
474    where
475        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
476        R: Send + 'static,
477    {
478        if let Some(writer_task) = self.current_writer_task(op)? {
479            return writer_task
480                .send_bounded_at(move |conn| f(conn).map_err(|e| map_err(e, op)), op)
481                .await;
482        }
483
484        self.pool
485            .record_direct_route(crate::timeout_sink::Site::DirectRouteNote);
486        let pool = Arc::clone(&self.pool);
487        tokio::task::spawn_blocking(move || {
488            let guard = pool
489                .autocommit_write_unit()
490                .map_err(|e| map_sqlite_err(e, op))?;
491            f(guard.conn())
492                .map_err(|e| map_err(e, op))
493                .map_err(crate::error::statement_failure)
494                .inspect_err(|error| pool.record_direct_writer_error(error))
495        })
496        .await
497        .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
498    }
499
500    /// Like [`Self::with_writer`], but for callers whose closure issues more
501    /// than one DML statement that must land atomically together (khive
502    /// #827, #1387), such as a note insert plus `assign_note_seq` or a guarded
503    /// property patch across several notes. On the flag-on path the
504    /// WriterTask already wraps every request in its own `BEGIN
505    /// IMMEDIATE`/`COMMIT`/`ROLLBACK`, so `f` is sent unwrapped, same as
506    /// `with_writer`. On the flag-off (pool-mutex) path, `with_writer` runs
507    /// `f` in SQLite's default autocommit mode -- each statement inside `f`
508    /// is its own implicit transaction -- so a later failure could leave an
509    /// earlier statement committed. This wraps that path in one explicit
510    /// transaction, matching `upsert_notes`' own flag-off branch.
511    async fn with_writer_tx<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
512    where
513        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
514        R: Send + 'static,
515    {
516        self.with_writer_tx_storage(op, move |conn| f(conn).map_err(|error| map_err(error, op)))
517            .await
518    }
519
520    async fn with_writer_tx_storage<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
521    where
522        F: FnOnce(&rusqlite::Connection) -> Result<R, StorageError> + Send + 'static,
523        R: Send + 'static,
524    {
525        if let Some(writer_task) = self.current_writer_task(op)? {
526            return writer_task.send_bounded_at(f, op).await;
527        }
528
529        self.pool
530            .record_direct_route(crate::timeout_sink::Site::DirectRouteNote);
531        let pool = Arc::clone(&self.pool);
532        tokio::task::spawn_blocking(move || {
533            let guard = pool
534                .transaction_write_unit()
535                .map_err(|e| map_sqlite_err(e, op))
536                .inspect_err(|error| pool.record_direct_writer_error(error))?;
537            let conn = guard.conn();
538            let (result, terminal_state) = execute_wrapped_transaction(conn, op, f);
539            if terminal_state.is_some() {
540                pool.retire_pooled_writer(conn);
541            }
542            result.inspect_err(|error| pool.record_direct_writer_error(error))
543        })
544        .await
545        .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
546    }
547
548    async fn get_notes_batch_inner(
549        &self,
550        ids: &[Uuid],
551        include_deleted: bool,
552    ) -> Result<Vec<Note>, StorageError> {
553        if ids.is_empty() {
554            return Ok(vec![]);
555        }
556        let operation = if include_deleted {
557            "get_notes_batch_including_deleted"
558        } else {
559            "get_notes_batch"
560        };
561        let deletion_filter = if include_deleted {
562            ""
563        } else {
564            " AND deleted_at IS NULL"
565        };
566        // SQLite SQLITE_MAX_VARIABLE_NUMBER defaults to 999; chunk below that
567        // ceiling so callers can safely hydrate arbitrarily large ID sets.
568        const CHUNK: usize = 900;
569        let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
570
571        let mut result = Vec::with_capacity(ids.len());
572        for chunk in id_strings.chunks(CHUNK) {
573            let chunk_owned = chunk.to_vec();
574            let notes = self
575                .with_reader(operation, move |conn| {
576                    let placeholders: String = (1..=chunk_owned.len())
577                        .map(|i| format!("?{i}"))
578                        .collect::<Vec<_>>()
579                        .join(", ");
580                    let sql = format!(
581                        "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
582                         properties, created_at, updated_at, deleted_at, key, version \
583                         FROM notes WHERE id IN ({placeholders}){deletion_filter}"
584                    );
585                    let mut stmt = conn.prepare(&sql)?;
586                    let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
587                        .iter()
588                        .map(|s| s as &dyn rusqlite::types::ToSql)
589                        .collect();
590                    let rows = stmt.query_map(params.as_slice(), read_note)?;
591                    let mut notes = Vec::new();
592                    for row in rows {
593                        notes.push(row?);
594                    }
595                    Ok(notes)
596                })
597                .await?;
598            result.extend(notes);
599        }
600        Ok(result)
601    }
602
603    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
604    where
605        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
606        R: Send + 'static,
607    {
608        super::run_pooled_store_read(
609            Arc::clone(&self.pool),
610            StorageCapability::Notes,
611            op,
612            move |conn| f(conn).map_err(|error| map_err(error, op)),
613        )
614        .await
615    }
616}
617
618// =============================================================================
619// Helpers
620// =============================================================================
621
622fn read_note(row: &rusqlite::Row<'_>) -> Result<Note, rusqlite::Error> {
623    let id_str: String = row.get(0)?;
624    let namespace: String = row.get(1)?;
625    let kind: String = row.get(2)?;
626    let status: String = row.get(3)?;
627    let name: Option<String> = row.get(4)?;
628    let content: String = row.get(5)?;
629    let salience: Option<f64> = row.get(6)?;
630    let decay_factor: Option<f64> = row.get(7)?;
631    let expires_at: Option<i64> = row.get(8)?;
632    let properties_str: Option<String> = row.get(9)?;
633    let created_at: i64 = row.get(10)?;
634    let updated_at: i64 = row.get(11)?;
635    let deleted_at: Option<i64> = row.get(12)?;
636    let key: Option<String> = row.get(13)?;
637    let version: i64 = row.get(14)?;
638
639    let id = parse_uuid(&id_str)?;
640
641    let properties = properties_str
642        .map(|s| {
643            serde_json::from_str(&s).map_err(|e| {
644                rusqlite::Error::FromSqlConversionFailure(
645                    9,
646                    rusqlite::types::Type::Text,
647                    Box::new(e),
648                )
649            })
650        })
651        .transpose()?;
652
653    Ok(Note {
654        id,
655        namespace,
656        kind,
657        status,
658        name,
659        content,
660        salience,
661        decay_factor,
662        expires_at,
663        properties,
664        created_at,
665        updated_at,
666        deleted_at,
667        key,
668        version,
669    })
670}
671
672fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
673    Uuid::parse_str(s).map_err(|e| {
674        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
675    })
676}
677
678fn query_note_page_snapshot(
679    conn: &rusqlite::Connection,
680    operation: &'static str,
681    namespace: &str,
682    count_sql: &str,
683    count_params: &[Box<dyn rusqlite::types::ToSql>],
684    data_sql: &str,
685    data_params: &[Box<dyn rusqlite::types::ToSql>],
686) -> Result<Page<Note>, rusqlite::Error> {
687    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Deferred)?;
688
689    let total: i64 = {
690        let mut stmt = tx.prepare(count_sql)?;
691        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
692            count_params.iter().map(|param| param.as_ref()).collect();
693        stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
694    };
695
696    #[cfg(test)]
697    tests::page_snapshot_seam::hook(operation, namespace);
698    #[cfg(not(test))]
699    let _ = (operation, namespace);
700
701    let items = {
702        let mut stmt = tx.prepare(data_sql)?;
703        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
704            data_params.iter().map(|param| param.as_ref()).collect();
705        let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
706        rows.collect::<Result<Vec<_>, _>>()?
707    };
708
709    tx.commit()?;
710    Ok(Page {
711        items,
712        total: Some(total as u64),
713    })
714}
715
716/// DML-only batch upsert loop shared by both the legacy (flag-off) and
717/// WriterTask-routed (flag-on) `upsert_notes` paths (ADR-067 Component A).
718///
719/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
720/// enclosing transaction. Per-row failures are captured into
721/// `BatchWriteSummary::failed`/`first_error` rather than aborting the loop,
722/// matching the existing partial-success contract.
723fn batch_upsert_notes(
724    conn: &rusqlite::Connection,
725    notes: &[Note],
726    attempted: u64,
727) -> Result<BatchWriteSummary, rusqlite::Error> {
728    let mut summary = BatchWriteSummary {
729        attempted,
730        ..BatchWriteSummary::default()
731    };
732
733    // Prepare the UPSERT once for the whole batch — `Connection::execute`
734    // re-parses and re-plans the statement on every call, which dominates
735    // wall time at conflict-heavy batch sizes (measured 536ms vs 260ms at
736    // 50k conflicts; see PR #1082 review).
737    let mut stmt = conn.prepare_cached(NOTE_UPSERT_SQL)?;
738
739    for (index, note) in notes.iter().enumerate() {
740        let id_str = note.id.to_string();
741        let kind_str = note.kind.to_string();
742        let status_str = note.status.clone();
743        let properties_str = note
744            .properties
745            .as_ref()
746            .map(|v| serde_json::to_string(v).unwrap_or_default());
747        let (due_key, due_source) = note_due_key_values(&note.properties);
748
749        match stmt.execute(rusqlite::params![
750            id_str,
751            &note.namespace,
752            kind_str,
753            status_str,
754            &note.name,
755            note.content,
756            note.salience,
757            note.decay_factor,
758            note.expires_at,
759            properties_str,
760            note.created_at,
761            note.updated_at,
762            note.deleted_at,
763            note.key,
764            due_key,
765            due_source,
766        ]) {
767            Ok(_) => {
768                assign_note_seq(conn, &id_str)?;
769                summary.affected = summary.affected.saturating_add(1);
770            }
771            Err(e) => {
772                let (class, retryability) = super::classify_batch_sqlite_error(&e);
773                summary.record_failure(index, Some(id_str), class, retryability, e.to_string());
774            }
775        }
776    }
777
778    Ok(summary)
779}
780
781/// Assign a note id its durable, non-reusing sequence number the first time
782/// it is inserted (khive #827 — see `sql/007-notes-seq.sql`). `INSERT OR
783/// IGNORE` makes this idempotent across repeated upserts of the same note
784/// id: the sequence value is fixed at the note's first insert and never
785/// reassigned, unlike `notes`' own implicit rowid.
786fn assign_note_seq(conn: &rusqlite::Connection, note_id: &str) -> Result<(), rusqlite::Error> {
787    conn.execute(
788        "INSERT OR IGNORE INTO notes_seq (note_id) VALUES (?1)",
789        rusqlite::params![note_id],
790    )?;
791    Ok(())
792}
793
794fn build_note_where(
795    namespace: &str,
796    kind: Option<&str>,
797) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
798    let mut conditions: Vec<String> = vec![
799        "namespace = ?1".to_string(),
800        "deleted_at IS NULL".to_string(),
801    ];
802    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(namespace.to_string())];
803
804    if let Some(k) = kind {
805        params.push(Box::new(k.to_string()));
806        conditions.push(format!("kind = ?{}", params.len()));
807    }
808
809    let clause = format!(" WHERE {}", conditions.join(" AND "));
810    (clause, params)
811}
812
813fn build_note_where_for_namespaces(
814    namespaces: &[String],
815    kind: Option<&str>,
816) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
817    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = namespaces
818        .iter()
819        .map(|namespace| -> Box<dyn rusqlite::types::ToSql> { Box::new(namespace.clone()) })
820        .collect();
821    let namespace_condition = match namespaces.len() {
822        0 => "0".to_string(),
823        1 => "namespace = ?1".to_string(),
824        _ => {
825            let placeholders: Vec<String> =
826                (1..=namespaces.len()).map(|i| format!("?{i}")).collect();
827            format!("namespace IN ({})", placeholders.join(", "))
828        }
829    };
830    let mut conditions = vec![namespace_condition, "deleted_at IS NULL".to_string()];
831
832    if let Some(kind) = kind {
833        params.push(Box::new(kind.to_string()));
834        conditions.push(format!("kind = ?{}", params.len()));
835    }
836
837    let clause = format!(" WHERE {}", conditions.join(" AND "));
838    (clause, params)
839}
840
841/// Validate that a json_path is safe to interpolate into SQL.
842/// Accepts only `$.field` or `$.field.subfield` paths with alphanumeric/underscore segments.
843fn validate_json_path(path: &str) -> Result<(), StorageError> {
844    let valid = path.starts_with("$.")
845        && path[2..].split('.').all(|part| {
846            !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
847        });
848    if valid {
849        Ok(())
850    } else {
851        Err(StorageError::InvalidInput {
852            capability: StorageCapability::Notes,
853            operation: "query_notes_filtered".into(),
854            message: format!("invalid JSON path for note filter: {path:?}"),
855        })
856    }
857}
858
859fn json_extract_expr(path: &str) -> String {
860    format!("json_extract(properties, '{path}')")
861}
862
863fn json_type_expr(path: &str) -> String {
864    format!("json_type(properties, '{path}')")
865}
866
867fn validate_note_time_order(
868    filter: &NoteFilter,
869    operation: &'static str,
870    supports_time_order: bool,
871) -> StorageResult<()> {
872    if filter.time_order.is_some()
873        && (!supports_time_order
874            || filter.order_by.is_some()
875            || filter.order_by_instant
876            || filter.unordered
877            || filter.after.is_some()
878            || filter.after_instant.is_some())
879    {
880        return Err(StorageError::InvalidInput {
881            capability: StorageCapability::Notes,
882            operation: operation.into(),
883            message: "time_order requires a native-order page without property order, unordered mode or cursors; fixed-order reads and filtered mutations do not support it".into(),
884        });
885    }
886    Ok(())
887}
888
889fn note_time_order_clause(order: NoteTimeOrder) -> &'static str {
890    match order {
891        NoteTimeOrder::ExpiresAt => " ORDER BY expires_at ASC, id ASC",
892        NoteTimeOrder::ExpiresAtOrCreatedAt => {
893            " ORDER BY COALESCE(expires_at, created_at) ASC, id ASC"
894        }
895    }
896}
897
898/// Deterministic total order shared by exact-count and count-free filtered
899/// pages. Keeping the clause in one helper prevents the cheaper projection
900/// from drifting into a different offset sequence.
901fn note_filter_page_order_clause(filter: &NoteFilter) -> String {
902    if let Some(order) = filter.time_order {
903        return note_time_order_clause(order).to_string();
904    }
905    if filter.unordered {
906        return String::new();
907    }
908    match &filter.order_by {
909        Some((path, dir)) => {
910            if filter.order_by_instant {
911                let expr = json_extract_expr(path);
912                return format!(" ORDER BY khive_rfc3339_key({expr}) ASC, {expr} ASC, id ASC");
913            }
914            let dir_str = match dir {
915                SortDir::Asc => "ASC",
916                SortDir::Desc => "DESC",
917            };
918            // #1671: append `id` as the final tiebreak in the sort field's
919            // direction so equal JSON sort values still form a total order.
920            format!(
921                " ORDER BY {} {dir_str}, id {dir_str}",
922                json_extract_expr(path)
923            )
924        }
925        // After ANALYZE, SQLite may choose the creation-order index to fill
926        // LIMIT and scan every future retry. The unary plus preserves the
927        // INTEGER timestamp order while making SQLite sort only due rows
928        // reached through the channel/deadline index.
929        None if filter
930            .property_filters
931            .iter()
932            .any(|property| matches!(&property.op, FilterOp::TextColonPrefixBucketIndexed))
933            && filter
934                .property_filters
935                .iter()
936                .any(|property| matches!(&property.op, FilterOp::Rfc3339LteOrInvalid)) =>
937        {
938            " ORDER BY +created_at DESC, id ASC".to_string()
939        }
940        // `id ASC` over the primary key is already the stable tiebreak for
941        // notes sharing a creation timestamp.
942        None => DEFAULT_NOTE_PAGE_ORDER.to_string(),
943    }
944}
945
946/// Validate a value destined for inline comparison against `json_type()`.
947/// The only admissible values are SQLite's own json_type result strings —
948/// a closed vocabulary — so a validated value can be inlined into SQL text
949/// without any injection surface. Anything else is a caller bug, rejected
950/// rather than parameterized.
951fn json_type_literal(value: &SqlValue) -> Result<&str, rusqlite::Error> {
952    const JSON_TYPES: [&str; 8] = [
953        "true", "false", "integer", "real", "text", "array", "object", "null",
954    ];
955    match value {
956        SqlValue::Text(s) if JSON_TYPES.contains(&s.as_str()) => Ok(s.as_str()),
957        other => Err(rusqlite::Error::InvalidParameterName(format!(
958            "json_type comparison value must be one of SQLite's json_type strings \
959             ({JSON_TYPES:?}), got {other:?}"
960        ))),
961    }
962}
963
964/// Exclusive upper bound of the set of strings starting with `prefix`, in
965/// SQLite's BINARY (UTF-8 byte) order: the prefix with its last code point
966/// incremented, after dropping any trailing `char::MAX` (which has no
967/// successor). `None` when every string starting with `prefix` is also
968/// `>= prefix` without an upper bound (empty prefix, or all `char::MAX`).
969/// UTF-8 preserves code point order byte-wise, so every string with the
970/// prefix sorts strictly below the returned bound.
971fn text_prefix_upper_bound(prefix: &str) -> Option<String> {
972    let mut chars: Vec<char> = prefix.chars().collect();
973    while let Some(last) = chars.pop() {
974        let mut next = u32::from(last) + 1;
975        if (0xD800..=0xDFFF).contains(&next) {
976            next = 0xE000;
977        }
978        if let Some(next) = char::from_u32(next) {
979            chars.push(next);
980            return Some(chars.into_iter().collect());
981        }
982    }
983    None
984}
985
986fn sql_value_param(value: &SqlValue) -> Result<Box<dyn rusqlite::types::ToSql>, rusqlite::Error> {
987    Ok(match value {
988        SqlValue::Null => Box::new(Option::<String>::None),
989        SqlValue::Bool(v) => Box::new(*v as i64),
990        SqlValue::Integer(v) => Box::new(*v),
991        SqlValue::Float(v) => Box::new(*v),
992        SqlValue::Text(v) => Box::new(v.clone()),
993        SqlValue::Blob(v) => Box::new(v.clone()),
994        SqlValue::Json(v) => Box::new(
995            serde_json::to_string(v)
996                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
997        ),
998        SqlValue::Uuid(v) => Box::new(v.to_string()),
999        SqlValue::Timestamp(v) => Box::new(v.timestamp_micros()),
1000    })
1001}
1002
1003fn build_note_filter_where(
1004    namespace: &str,
1005    filter: &NoteFilter,
1006) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
1007    // When filter.namespaces is non-empty use `namespace IN (...)` for
1008    // multi-namespace read visibility. Otherwise fall back to equality.
1009    let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
1010        if !filter.namespaces.is_empty() {
1011            let placeholders: Vec<String> = (1..=filter.namespaces.len())
1012                .map(|i| format!("?{i}"))
1013                .collect();
1014            let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
1015                .namespaces
1016                .iter()
1017                .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
1018                .collect();
1019            (
1020                format!("namespace IN ({})", placeholders.join(", ")),
1021                params,
1022            )
1023        } else {
1024            (
1025                "namespace = ?1".to_string(),
1026                vec![Box::new(namespace.to_string())],
1027            )
1028        };
1029
1030    let mut conditions = vec![ns_condition];
1031    if !filter.include_deleted {
1032        conditions.push("deleted_at IS NULL".to_string());
1033    }
1034    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
1035
1036    if let Some(kind) = &filter.kind {
1037        params.push(Box::new(kind.clone()));
1038        conditions.push(format!("kind = ?{}", params.len()));
1039    }
1040
1041    if let Some(since) = filter.min_updated_at {
1042        params.push(Box::new(since));
1043        conditions.push(format!("updated_at >= ?{}", params.len()));
1044    }
1045    if !filter.tags.is_empty() {
1046        let mut tag_predicates = Vec::new();
1047        for tag in &filter.tags {
1048            params.push(Box::new(tag.clone()));
1049            tag_predicates.push(format!(
1050                "EXISTS (SELECT 1 FROM json_each(CASE WHEN json_type(properties,'$.tags')='array' \
1051                 THEN json_extract(properties,'$.tags') ELSE '[]' END) AS tag \
1052                 WHERE tag.type='text' AND tag.value = ?{} COLLATE NOCASE)",
1053                params.len()
1054            ));
1055        }
1056        let join = match filter.tag_mode {
1057            NoteTagMode::Any => " OR ",
1058            NoteTagMode::All => " AND ",
1059        };
1060        conditions.push(format!("({})", tag_predicates.join(join)));
1061    }
1062
1063    for pf in &filter.property_filters {
1064        match &pf.op {
1065            FilterOp::Rfc3339Valid => {
1066                let expr = json_extract_expr(&pf.json_path);
1067                conditions.push(format!("khive_rfc3339_key({expr}) IS NOT NULL"));
1068            }
1069            FilterOp::Rfc3339Gte | FilterOp::Rfc3339Lte | FilterOp::Rfc3339LteOrInvalid => {
1070                let instant = match &pf.value {
1071                    SqlValue::Timestamp(instant) => *instant,
1072                    SqlValue::Text(text) => {
1073                        text.parse::<chrono::DateTime<chrono::Utc>>()
1074                            .map_err(|error| {
1075                                rusqlite::Error::ToSqlConversionFailure(Box::new(error))
1076                            })?
1077                    }
1078                    _ => {
1079                        return Err(rusqlite::Error::ToSqlConversionFailure(
1080                            "RFC 3339 filters require a timestamp or text value".into(),
1081                        ));
1082                    }
1083                };
1084                let expr = json_extract_expr(&pf.json_path);
1085                let op = if matches!(&pf.op, FilterOp::Rfc3339Gte) {
1086                    ">="
1087                } else {
1088                    "<="
1089                };
1090                params.push(Box::new(crate::pool::rfc3339_instant_key(instant)));
1091                if matches!(&pf.op, FilterOp::Rfc3339LteOrInvalid) {
1092                    // Byte-for-byte match the built-in-only V44 expression
1093                    // index. A raw properties edit invalidates due_source and
1094                    // enters the empty-key candidate range; the strict read
1095                    // residual below rejects its future/malformed distinction.
1096                    conditions.push(format!(
1097                        "CASE WHEN due_source = {expr} \
1098                         THEN ifnull(strict_due_key, x'') ELSE x'' END <= ?{n} \
1099                         AND ifnull(khive_rfc3339_strict_key({expr}), x'') <= ?{n}",
1100                        n = params.len()
1101                    ));
1102                } else {
1103                    conditions.push(format!("khive_rfc3339_key({expr}) {op} ?{}", params.len()));
1104                }
1105            }
1106            FilterOp::EqOrMissing => {
1107                let expr = json_extract_expr(&pf.json_path);
1108                params.push(sql_value_param(&pf.value)?);
1109                conditions.push(format!(
1110                    "({expr} = ?{n} OR {expr} IS NULL)",
1111                    n = params.len()
1112                ));
1113            }
1114            FilterOp::EqOrMissingIndexed => {
1115                let expr = json_extract_expr(&pf.json_path);
1116                params.push(sql_value_param(&pf.value)?);
1117                conditions.push(format!("ifnull({expr}, '') = ?{}", params.len()));
1118            }
1119            FilterOp::TextEqOrNonText => {
1120                let expr = json_extract_expr(&pf.json_path);
1121                let type_expr = json_type_expr(&pf.json_path);
1122                params.push(sql_value_param(&pf.value)?);
1123                let n = params.len();
1124                conditions.push(format!(
1125                    "CASE WHEN {type_expr} = 'text' THEN {expr} ELSE ?{n} END = ?{n}"
1126                ));
1127            }
1128            FilterOp::TextInOrNonText(values) => {
1129                let expr = json_extract_expr(&pf.json_path);
1130                let type_expr = json_type_expr(&pf.json_path);
1131                let mut placeholders = Vec::with_capacity(values.len());
1132                for value in values {
1133                    params.push(sql_value_param(value)?);
1134                    placeholders.push(format!("?{}", params.len()));
1135                }
1136                let text_match = if placeholders.is_empty() {
1137                    "0".to_string()
1138                } else {
1139                    format!("{expr} IN ({})", placeholders.join(", "))
1140                };
1141                conditions.push(format!(
1142                    "CASE WHEN {type_expr} = 'text' THEN {text_match} ELSE 1 END"
1143                ));
1144            }
1145            FilterOp::MissingNullOrSpaceEmptyText => {
1146                let expr = json_extract_expr(&pf.json_path);
1147                let ty = json_type_expr(&pf.json_path);
1148                conditions.push(format!(
1149                    "({ty} IS NULL OR {ty} = 'null' OR ({ty} = 'text' AND trim({expr}) = ''))"
1150                ));
1151            }
1152            FilterOp::TrueOrTextTrue => {
1153                let expr = json_extract_expr(&pf.json_path);
1154                let ty = json_type_expr(&pf.json_path);
1155                conditions.push(format!("({expr} = 'true' OR {ty} = 'true')"));
1156            }
1157            FilterOp::JsonTypeEq => {
1158                let type_expr = json_type_expr(&pf.json_path);
1159                params.push(sql_value_param(&pf.value)?);
1160                conditions.push(format!("{type_expr} = ?{}", params.len()));
1161            }
1162            FilterOp::JsonTypeMissing => {
1163                let type_expr = json_type_expr(&pf.json_path);
1164                conditions.push(format!("{type_expr} IS NULL"));
1165            }
1166            FilterOp::JsonTypeMissingOrNullIndexed => {
1167                let expr = json_extract_expr(&pf.json_path);
1168                let type_expr = json_type_expr(&pf.json_path);
1169                conditions.push(format!(
1170                    "ifnull({expr}, '') = '' AND ({type_expr} IS NULL OR {type_expr} = 'null')"
1171                ));
1172            }
1173            FilterOp::EqOrLegacyIndexed => {
1174                let expr = json_extract_expr(&pf.json_path);
1175                let type_expr = json_type_expr(&pf.json_path);
1176                params.push(sql_value_param(&pf.value)?);
1177                let n = params.len();
1178                conditions.push(format!(
1179                    "ifnull({expr}, '') IN (?{n}, '') AND \
1180                     ({type_expr} IS NULL OR {type_expr} = 'null' OR ifnull({expr}, '') != '')"
1181                ));
1182            }
1183            FilterOp::JsonTypeNeMissing => {
1184                let type_expr = json_type_expr(&pf.json_path);
1185                // Inlined as a validated literal, NOT a parameter: the
1186                // partial unread index (`idx_notes_unread_probe_recipient_direction`)
1187                // carries this exact predicate in its WHERE clause, and SQLite can
1188                // only prove a query implies an index predicate when the
1189                // compared value is known at plan time — a bound parameter
1190                // defeats the index and the scan degrades to
1191                // mailbox-proportional work. The value domain is SQLite's
1192                // closed json_type vocabulary, so inlining is injection-safe
1193                // by construction.
1194                let literal = json_type_literal(&pf.value)?;
1195                conditions.push(format!(
1196                    "({type_expr} IS NULL OR {type_expr} != '{literal}')"
1197                ));
1198            }
1199            FilterOp::In(values) => {
1200                let expr = json_extract_expr(&pf.json_path);
1201                if values.is_empty() {
1202                    // An empty set can never match any row.
1203                    conditions.push("0".to_string());
1204                    continue;
1205                }
1206                let mut placeholders = Vec::with_capacity(values.len());
1207                for v in values {
1208                    params.push(sql_value_param(v)?);
1209                    placeholders.push(format!("?{}", params.len()));
1210                }
1211                conditions.push(format!("{expr} IN ({})", placeholders.join(", ")));
1212            }
1213            FilterOp::TextStartsWithIndexed => {
1214                let expr = json_extract_expr(&pf.json_path);
1215                let SqlValue::Text(prefix) = &pf.value else {
1216                    return Err(rusqlite::Error::ToSqlConversionFailure(
1217                        "TextStartsWithIndexed takes a text prefix in PropertyFilter.value".into(),
1218                    ));
1219                };
1220                params.push(Box::new(prefix.clone()));
1221                let lower = params.len();
1222                match text_prefix_upper_bound(prefix) {
1223                    Some(upper) => {
1224                        params.push(Box::new(upper));
1225                        conditions.push(format!(
1226                            "({expr} >= ?{lower} AND {expr} < ?{})",
1227                            params.len()
1228                        ));
1229                    }
1230                    None => {
1231                        let type_expr = json_type_expr(&pf.json_path);
1232                        conditions.push(format!("({type_expr} = 'text' AND {expr} >= ?{lower})"));
1233                    }
1234                }
1235            }
1236            FilterOp::TextColonPrefixBucketIndexed => {
1237                let SqlValue::Text(prefix) = &pf.value else {
1238                    return Err(rusqlite::Error::ToSqlConversionFailure(
1239                        "TextColonPrefixBucketIndexed takes a text prefix".into(),
1240                    ));
1241                };
1242                if prefix
1243                    .strip_suffix(':')
1244                    .is_none_or(|head| head.is_empty() || head.contains(':'))
1245                {
1246                    return Err(rusqlite::Error::ToSqlConversionFailure(
1247                        "TextColonPrefixBucketIndexed requires one trailing colon".into(),
1248                    ));
1249                }
1250                let expr = json_extract_expr(&pf.json_path);
1251                params.push(Box::new(prefix.clone()));
1252                conditions.push(format!(
1253                    "substr({expr}, 1, instr({expr}, ':')) = ?{}",
1254                    params.len()
1255                ));
1256            }
1257            FilterOp::NotInOrMissing(values) => {
1258                let expr = json_extract_expr(&pf.json_path);
1259                if values.is_empty() {
1260                    // Nothing to exclude — every row (including missing) matches.
1261                    continue;
1262                }
1263                let mut placeholders = Vec::with_capacity(values.len());
1264                for v in values {
1265                    params.push(sql_value_param(v)?);
1266                    placeholders.push(format!("?{}", params.len()));
1267                }
1268                conditions.push(format!(
1269                    "({expr} IS NULL OR {expr} NOT IN ({}))",
1270                    placeholders.join(", ")
1271                ));
1272            }
1273            _ => {
1274                let expr = json_extract_expr(&pf.json_path);
1275                let op = match pf.op {
1276                    FilterOp::Eq => "=",
1277                    FilterOp::Ne => "!=",
1278                    FilterOp::Lt => "<",
1279                    FilterOp::Lte => "<=",
1280                    FilterOp::Gt => ">",
1281                    FilterOp::Gte => ">=",
1282                    FilterOp::EqOrMissing
1283                    | FilterOp::EqOrMissingIndexed
1284                    | FilterOp::TextEqOrNonText
1285                    | FilterOp::TextInOrNonText(_)
1286                    | FilterOp::JsonTypeEq
1287                    | FilterOp::JsonTypeMissing
1288                    | FilterOp::MissingNullOrSpaceEmptyText
1289                    | FilterOp::TrueOrTextTrue
1290                    | FilterOp::JsonTypeMissingOrNullIndexed
1291                    | FilterOp::EqOrLegacyIndexed
1292                    | FilterOp::JsonTypeNeMissing
1293                    | FilterOp::In(_)
1294                    | FilterOp::NotInOrMissing(_)
1295                    | FilterOp::TextStartsWithIndexed
1296                    | FilterOp::TextColonPrefixBucketIndexed => {
1297                        unreachable!()
1298                    }
1299                    FilterOp::Rfc3339Valid
1300                    | FilterOp::Rfc3339Gte
1301                    | FilterOp::Rfc3339Lte
1302                    | FilterOp::Rfc3339LteOrInvalid => {
1303                        unreachable!()
1304                    }
1305                };
1306                params.push(sql_value_param(&pf.value)?);
1307                conditions.push(format!("{expr} {op} ?{}", params.len()));
1308            }
1309        }
1310    }
1311
1312    if let Some(min_ts) = filter.min_created_at {
1313        params.push(Box::new(min_ts));
1314        conditions.push(format!("created_at >= ?{}", params.len()));
1315    }
1316
1317    if let Some(max_ts) = filter.max_created_at {
1318        params.push(Box::new(max_ts));
1319        conditions.push(format!("created_at <= ?{}", params.len()));
1320    }
1321    if let Some(max_ts) = filter.max_expires_at {
1322        params.push(Box::new(max_ts));
1323        conditions.push(format!("expires_at <= ?{}", params.len()));
1324    }
1325    if let Some(cutoff) = filter.expiry_fallback {
1326        params.push(Box::new(cutoff.expires_at_or_before));
1327        let expiry = params.len();
1328        params.push(Box::new(cutoff.created_at_or_before));
1329        let creation = params.len();
1330        conditions.push(format!(
1331            "((expires_at IS NOT NULL AND expires_at <= ?{expiry}) OR (expires_at IS NULL AND created_at <= ?{creation}))"
1332        ));
1333    }
1334    if let Some(scope) = &filter.mailbox {
1335        conditions.push(mailbox_condition(scope, &mut params));
1336    }
1337
1338    Ok((format!(" WHERE {}", conditions.join(" AND ")), params))
1339}
1340
1341/// SQL form of the row-level mailbox rule for `message` rows. Every other kind
1342/// passes. JSON `null`, a missing key and a missing `properties` blob all read
1343/// as "absent", and a non-text routing field never equals the actor id: the
1344/// row-level rule reads routing fields as strings, while `json_extract` renders
1345/// an array or object as its JSON text, which an actor id can spell.
1346fn mailbox_condition(
1347    scope: &khive_storage::note::NoteMailboxScope,
1348    params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
1349) -> String {
1350    params.push(Box::new(scope.actor_id.clone()));
1351    let actor = format!("?{}", params.len());
1352    let absent = |path: &str| {
1353        let ty = json_type_expr(path);
1354        format!("({ty} IS NULL OR {ty} = 'null')")
1355    };
1356    let not_text = |path: &str| format!("ifnull({}, '') != 'text'", json_type_expr(path));
1357    let direction = json_extract_expr("$.direction");
1358    let text_is_actor = |path: &str| {
1359        format!(
1360            "({} = 'text' AND {} = {actor})",
1361            json_type_expr(path),
1362            json_extract_expr(path)
1363        )
1364    };
1365    let to = text_is_actor("$.to_actor");
1366    let from = text_is_actor("$.from_actor");
1367    let (inbound_legacy, outbound_legacy, unrouted) = if scope.legacy_local {
1368        (
1369            format!(" OR {}", absent("$.to_actor")),
1370            format!(" OR {}", absent("$.from_actor")),
1371            format!(
1372                " OR ({} AND {} AND {})",
1373                absent("$.direction"),
1374                not_text("$.from_actor"),
1375                not_text("$.to_actor")
1376            ),
1377        )
1378    } else {
1379        Default::default()
1380    };
1381    format!(
1382        "(kind != 'message' OR ({direction} = 'inbound' AND ({to}{inbound_legacy})) \
1383         OR ({direction} = 'outbound' AND ({from}{outbound_legacy})){unrouted})"
1384    )
1385}
1386
1387// ADR-187: match only compiler-emitted equality/IN terms that constrain every
1388// comm seek key. The unread pin additionally requires its exact partial predicate.
1389// Inspect whole AND terms, never substrings that might occur inside an OR branch.
1390fn comm_filter_index_clause(filter: &NoteFilter, where_sql: &str) -> &'static str {
1391    if filter.kind.as_deref() != Some("message") {
1392        return "";
1393    }
1394    let Some(predicate) = where_sql.strip_prefix(" WHERE ") else {
1395        return "";
1396    };
1397    let terms: Vec<_> = predicate.split(" AND ").collect();
1398    let numbered_param = |value: &str| {
1399        value.strip_prefix('?').is_some_and(|number| {
1400            !number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit())
1401        })
1402    };
1403    let equality = |prefix: &str| {
1404        terms
1405            .iter()
1406            .any(|term| term.strip_prefix(prefix).is_some_and(numbered_param))
1407    };
1408    let namespace = equality("namespace = ")
1409        || terms.iter().any(|term| {
1410            term.strip_prefix("namespace IN (")
1411                .and_then(|term| term.strip_suffix(')'))
1412                .is_some_and(|values| values.split(", ").all(numbered_param))
1413        });
1414    let recipient = equality("ifnull(json_extract(properties, '$.to_actor'), '') = ")
1415        || terms.contains(&"ifnull(json_extract(properties, '$.to_actor'), '') = ''")
1416        || terms.iter().any(|term| {
1417            term.strip_prefix("ifnull(json_extract(properties, '$.to_actor'), '') IN (")
1418                .and_then(|term| term.strip_suffix(", '')"))
1419                .is_some_and(numbered_param)
1420        });
1421    if !namespace || !terms.contains(&"deleted_at IS NULL") || !equality("kind = ") {
1422        return "";
1423    }
1424    if !equality("json_extract(properties, '$.direction') = ") || !recipient {
1425        // A thread-keyed read selects the thread's own rows. Left to the
1426        // planner, `LIMIT` under a `created_at` order picks the creation-order
1427        // index and evaluates `$.thread_id` on every message in the namespace,
1428        // and a direction term ties the thread index with
1429        // `idx_comm_message_outbound_ref`, which walks every outbound message.
1430        let thread = equality("json_extract(properties, '$.thread_id') = ")
1431            || terms.iter().any(|term| {
1432                term.strip_prefix("json_extract(properties, '$.thread_id') IN (")
1433                    .and_then(|term| term.strip_suffix(')'))
1434                    .is_some_and(|values| values.split(", ").all(numbered_param))
1435            });
1436        return if thread {
1437            " INDEXED BY idx_comm_message_thread"
1438        } else {
1439            ""
1440        };
1441    }
1442    if terms.contains(
1443        &"(json_type(properties, '$.read') IS NULL OR json_type(properties, '$.read') != 'true')",
1444    ) {
1445        // Delegated reads require an exact, string-typed recipient. Match both
1446        // whole compiler-emitted terms and the bound type value; legacy/own
1447        // shapes retain the existing ADR-187 pin.
1448        let typed_exact_recipient =
1449            equality("ifnull(json_extract(properties, '$.to_actor'), '') = ")
1450                && equality("json_type(properties, '$.to_actor') = ")
1451                && filter.property_filters.iter().any(|property| {
1452                    property.json_path == "$.to_actor"
1453                        && matches!(property.op, FilterOp::JsonTypeEq)
1454                        && matches!(&property.value, SqlValue::Text(value) if value == "text")
1455                });
1456        if typed_exact_recipient {
1457            " INDEXED BY idx_notes_unread_probe_recipient_type_direction"
1458        } else {
1459            " INDEXED BY idx_notes_unread_probe_recipient_direction"
1460        }
1461    } else {
1462        " INDEXED BY idx_notes_message_recipient_direction"
1463    }
1464}
1465
1466fn build_note_filter_read_clause(
1467    namespace: &str,
1468    filter: &NoteFilter,
1469) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
1470    let (where_sql, params) = build_note_filter_where(namespace, filter)?;
1471    let index_clause = comm_filter_index_clause(filter, &where_sql);
1472    Ok((format!("{index_clause}{where_sql}"), params))
1473}
1474
1475/// `SELECT` column list for a plain note-row projection. Used by
1476/// [`fetch_notes_after`] and `query_notes_filtered_count_free`; the other
1477/// note-row projection queries in this file (`query_notes`,
1478/// `query_notes_count_free`, `query_notes_filtered`,
1479/// `query_notes_filtered_after`, `query_notes_filtered_bounded`) still spell
1480/// the same column list out inline.
1481const NOTE_COLUMNS: &str = "id, namespace, kind, status, name, content, salience, decay_factor, \
1482     expires_at, properties, created_at, updated_at, deleted_at, key, version";
1483
1484/// The default note page order, served for one namespace by
1485/// `idx_notes_namespace_created`.
1486const DEFAULT_NOTE_PAGE_ORDER: &str = " ORDER BY created_at DESC, id ASC";
1487
1488/// Most namespaces a page read splits into per-namespace arms; a wider visible
1489/// set keeps the single `namespace IN (...)` statement.
1490const NAMESPACE_ARMS_MAX: usize = 32;
1491
1492/// The statement and parameters for one `query_notes_filtered_count_free` page
1493/// read by `LIMIT`/`OFFSET`.
1494///
1495/// Several visible namespaces filter as `namespace IN (...)`, and SQLite cannot
1496/// take `created_at DESC, id ASC` from `idx_notes_namespace_created` across an
1497/// `IN` list: it reads every visible live row and sorts them all before taking
1498/// the page. In that case the page is built from one arm per namespace. Each arm
1499/// is the same filter with `namespace = ?i`, so it can walk the order index and
1500/// stop after `offset + limit` rows, and the outer order takes the page from at
1501/// most that many rows per namespace. A row lives in exactly one namespace and
1502/// `id` makes the order total, so the page is the one the `IN` form returns.
1503///
1504/// Every other read keeps the single `IN` statement: an order other than the
1505/// default, an index pin chosen for a message read, deleted rows included (the
1506/// order index is partial on `deleted_at IS NULL`), and a visible set wider than
1507/// [`NAMESPACE_ARMS_MAX`].
1508fn note_filter_page_sql(
1509    namespace: &str,
1510    filter: &NoteFilter,
1511    limit: i64,
1512    offset: i64,
1513) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
1514    let mut filter = filter.clone();
1515    let mut seen = HashSet::new();
1516    filter.namespaces.retain(|ns| seen.insert(ns.clone()));
1517    let (read_clause, mut params) = build_note_filter_read_clause(namespace, &filter)?;
1518    let order_clause = note_filter_page_order_clause(&filter);
1519    let arms = filter.namespaces.len();
1520    let in_term = format!(
1521        " WHERE namespace IN ({})",
1522        (1..=arms)
1523            .map(|i| format!("?{i}"))
1524            .collect::<Vec<_>>()
1525            .join(", ")
1526    );
1527    let arm_rest = read_clause.strip_prefix(in_term.as_str()).filter(|_| {
1528        (2..=NAMESPACE_ARMS_MAX).contains(&arms)
1529            && !filter.include_deleted
1530            && order_clause == DEFAULT_NOTE_PAGE_ORDER
1531    });
1532    let Some(rest) = arm_rest else {
1533        params.push(Box::new(limit));
1534        params.push(Box::new(offset));
1535        let limit_idx = params.len() - 1;
1536        let offset_idx = params.len();
1537        return Ok((
1538            format!(
1539                "SELECT {NOTE_COLUMNS} FROM notes{read_clause}{order_clause} \
1540                 LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
1541            ),
1542            params,
1543        ));
1544    };
1545    params.push(Box::new(offset.saturating_add(limit)));
1546    let arm_limit_idx = params.len();
1547    let arm_sql: Vec<String> = (1..=arms)
1548        .map(|i| {
1549            format!(
1550                "SELECT * FROM (SELECT {NOTE_COLUMNS} FROM notes WHERE namespace = ?{i}{rest}\
1551                 {order_clause} LIMIT ?{arm_limit_idx})"
1552            )
1553        })
1554        .collect();
1555    params.push(Box::new(limit));
1556    params.push(Box::new(offset));
1557    let limit_idx = params.len() - 1;
1558    let offset_idx = params.len();
1559    Ok((
1560        format!(
1561            "{}{order_clause} LIMIT ?{limit_idx} OFFSET ?{offset_idx}",
1562            arm_sql.join(" UNION ALL ")
1563        ),
1564        params,
1565    ))
1566}
1567
1568fn fetch_notes_after_instant(
1569    conn: &rusqlite::Connection,
1570    namespace: &str,
1571    base_filter: &NoteFilter,
1572    after: &NoteInstantSeekAfter,
1573    limit: i64,
1574) -> Result<Vec<Note>, rusqlite::Error> {
1575    let (where_sql, mut params) = build_note_filter_read_clause(namespace, base_filter)?;
1576    params.push(Box::new(after.value.clone()));
1577    let value_idx = params.len();
1578    params.push(Box::new(after.id.to_string()));
1579    let id_idx = params.len();
1580    params.push(Box::new(limit));
1581    let limit_idx = params.len();
1582    let (path, _) = base_filter
1583        .order_by
1584        .as_ref()
1585        .expect("instant cursor requires order_by");
1586    let expr = json_extract_expr(path);
1587    let order_clause = note_filter_page_order_clause(base_filter);
1588    let sql = format!(
1589        "SELECT {NOTE_COLUMNS} FROM notes{where_sql} \
1590         AND (khive_rfc3339_key({expr}), {expr}, id) > \
1591             (khive_rfc3339_key(?{value_idx}), ?{value_idx}, ?{id_idx}) \
1592         {order_clause} LIMIT ?{limit_idx}"
1593    );
1594    let mut stmt = conn.prepare_cached(&sql)?;
1595    let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1596        params.iter().map(|param| param.as_ref()).collect();
1597    let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1598    rows.collect()
1599}
1600
1601/// Fetch up to `limit` rows strictly after `after` in the notes store's
1602/// default `created_at DESC, id ASC` total order, for `NoteFilter.after`
1603/// keyset pagination.
1604///
1605/// Deliberately NOT a single `WHERE ... (created_at, id) < (?, ?)` (row
1606/// value) or `WHERE ... (created_at < ?1 OR (created_at = ?1 AND id > ?2))`
1607/// predicate: `created_at` sorts DESC while `id` sorts ASC, and neither form
1608/// gets index-seek treatment from SQLite for a mixed-direction boundary on
1609/// this build (`EXPLAIN QUERY PLAN` showed the same full ordered index scan
1610/// as no boundary at all, i.e. exactly the `OFFSET` cost this exists to
1611/// avoid — confirmed empirically, not assumed). Splitting into two
1612/// single-direction queries keeps each one a plain equality/range AND-chain,
1613/// which SQLite reliably turns into an index seek: the first grabs any tied
1614/// rows at the exact boundary timestamp (`id ASC` order matches the index
1615/// order within that tie group), the second grabs the (strictly smaller)
1616/// timestamps that follow. Concatenating the two batches in that order
1617/// reproduces `created_at DESC, id ASC` exactly with no merge step, since a
1618/// tie-group's rows all sort before every following (smaller) timestamp.
1619fn fetch_notes_after(
1620    conn: &rusqlite::Connection,
1621    namespace: &str,
1622    base_filter: &NoteFilter,
1623    after: &NoteSeekAfter,
1624    limit: i64,
1625) -> Result<Vec<Note>, rusqlite::Error> {
1626    let mut items = Vec::new();
1627    if limit <= 0 {
1628        return Ok(items);
1629    }
1630
1631    {
1632        let (where_sql, mut params) = build_note_filter_read_clause(namespace, base_filter)?;
1633        params.push(Box::new(after.created_at));
1634        let ts_idx = params.len();
1635        params.push(Box::new(after.id.to_string()));
1636        let id_idx = params.len();
1637        params.push(Box::new(limit));
1638        let limit_idx = params.len();
1639        let sql = format!(
1640            "SELECT {NOTE_COLUMNS} FROM notes{where_sql} AND created_at = ?{ts_idx} \
1641             AND id > ?{id_idx} ORDER BY id ASC LIMIT ?{limit_idx}"
1642        );
1643        let mut stmt = conn.prepare_cached(&sql)?;
1644        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1645            params.iter().map(|p| p.as_ref()).collect();
1646        let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1647        for row in rows {
1648            items.push(row?);
1649        }
1650    }
1651
1652    let remaining = limit - items.len() as i64;
1653    if remaining > 0 {
1654        let (where_sql, mut params) = build_note_filter_read_clause(namespace, base_filter)?;
1655        params.push(Box::new(after.created_at));
1656        let ts_idx = params.len();
1657        params.push(Box::new(remaining));
1658        let limit_idx = params.len();
1659        let sql = format!(
1660            "SELECT {NOTE_COLUMNS} FROM notes{where_sql} AND created_at < ?{ts_idx} \
1661             ORDER BY created_at DESC, id ASC LIMIT ?{limit_idx}"
1662        );
1663        let mut stmt = conn.prepare_cached(&sql)?;
1664        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1665            params.iter().map(|p| p.as_ref()).collect();
1666        let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1667        for row in rows {
1668            items.push(row?);
1669        }
1670    }
1671
1672    Ok(items)
1673}
1674
1675fn execute_filtered_note_property_patch(
1676    conn: &rusqlite::Connection,
1677    id: Uuid,
1678    namespace: &str,
1679    filter: &NoteFilter,
1680    json_path: &str,
1681    value_json: &str,
1682    updated_at: i64,
1683) -> Result<usize, rusqlite::Error> {
1684    let (mut where_clause, mut params) = build_note_filter_where(namespace, filter)?;
1685    // A read selector cannot relax either property-patch method's live-row guard.
1686    if filter.include_deleted {
1687        where_clause.push_str(" AND deleted_at IS NULL");
1688    }
1689    let updating_due = json_path == "$.next_attempt_at";
1690    let due_source = if updating_due {
1691        serde_json::from_str::<serde_json::Value>(value_json)
1692            .ok()
1693            .and_then(|value| value.as_str().map(str::to_owned))
1694    } else {
1695        None
1696    };
1697    let due_key = due_source
1698        .as_deref()
1699        .and_then(crate::pool::strict_rfc3339_key);
1700
1701    let base = params.len();
1702    let sql = format!(
1703        "UPDATE notes SET properties = json_set(COALESCE(properties, '{{}}'), ?{p1}, json(?{p2})), \
1704         updated_at = ?{p3}, \
1705         strict_due_key = CASE WHEN ?{p5} = 1 THEN ?{p6} ELSE strict_due_key END, \
1706         due_source = CASE WHEN ?{p5} = 1 THEN ?{p7} ELSE due_source END \
1707         {where_clause} \
1708         AND (properties IS NULL OR json_type(properties) = 'object') AND id = ?{p4}",
1709        p1 = base + 1,
1710        p2 = base + 2,
1711        p3 = base + 3,
1712        p4 = base + 4,
1713        p5 = base + 5,
1714        p6 = base + 6,
1715        p7 = base + 7,
1716    );
1717    params.push(Box::new(json_path.to_string()));
1718    params.push(Box::new(value_json.to_string()));
1719    params.push(Box::new(updated_at));
1720    params.push(Box::new(id.to_string()));
1721    params.push(Box::new(i64::from(updating_due)));
1722    params.push(Box::new(due_key));
1723    params.push(Box::new(due_source));
1724
1725    let mut stmt = conn.prepare_cached(&sql)?;
1726    let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1727        params.iter().map(|param| param.as_ref()).collect();
1728    stmt.execute(param_refs.as_slice())
1729}
1730
1731// =============================================================================
1732// NoteStore implementation
1733// =============================================================================
1734
1735#[async_trait]
1736impl NoteStore for SqlNoteStore {
1737    async fn get_live_notes_by_key(
1738        &self,
1739        namespace: &str,
1740        key: &str,
1741        kind: Option<&str>,
1742    ) -> StorageResult<Vec<Note>> {
1743        let namespace = namespace.to_owned();
1744        let key = key.to_owned();
1745        let kind = kind.map(str::to_owned);
1746        self.with_reader("get_live_notes_by_key", move |conn| {
1747            let (mut clause, mut params) = build_note_where(&namespace, kind.as_deref());
1748            params.push(Box::new(key));
1749            clause.push_str(&format!(" AND key = ?{}", params.len()));
1750            let sql = format!("SELECT {NOTE_COLUMNS} FROM notes{clause} ORDER BY kind ASC");
1751            let mut stmt = conn.prepare(&sql)?;
1752            let params: Vec<&dyn rusqlite::types::ToSql> =
1753                params.iter().map(|p| p.as_ref()).collect();
1754            let rows = stmt.query_map(params.as_slice(), read_note)?.collect();
1755            rows
1756        })
1757        .await
1758    }
1759
1760    async fn query_keyed_notes(
1761        &self,
1762        namespace: &str,
1763        filter: &NoteFilter,
1764        prefix: &str,
1765        after: Option<&NoteKeyCursor>,
1766        page: PageRequest,
1767    ) -> StorageResult<(Vec<Note>, Option<NoteKeyCursor>)> {
1768        validate_note_time_order(filter, "query_keyed_notes", false)?;
1769        if !filter.namespaces.is_empty()
1770            || filter.order_by.is_some()
1771            || filter.after.is_some()
1772            || (after.is_some() && page.offset != 0)
1773            || page.limit == 0
1774        {
1775            return Err(StorageError::InvalidInput { capability: StorageCapability::Notes,
1776                operation: "query_keyed_notes".into(), message: "keyed paging requires primary namespace, keyed order and a positive limit; cursor excludes offset".into() });
1777        }
1778        for property in &filter.property_filters {
1779            validate_json_path(&property.json_path)?;
1780        }
1781        let offset = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1782            capability: StorageCapability::Notes,
1783            operation: "query_keyed_notes".into(),
1784            message: "offset exceeds the supported integer range".into(),
1785        })?;
1786        let namespace = namespace.to_owned();
1787        let filter = filter.clone();
1788        let prefix = prefix.to_owned();
1789        let after = after.cloned();
1790        self.with_reader("query_keyed_notes", move |conn| {
1791            let (mut clause, mut params) = build_note_filter_where(&namespace, &filter)?;
1792            params.push(Box::new(prefix.clone()));
1793            clause.push_str(&format!(" AND key IS NOT NULL AND key >= ?{}", params.len()));
1794            if let Some(upper) = note_key_prefix_successor(&prefix) {
1795                params.push(Box::new(upper));
1796                clause.push_str(&format!(" AND key < ?{}", params.len()));
1797            }
1798            if let Some(after) = after {
1799                params.push(Box::new(after.updated_at)); let u = params.len();
1800                params.push(Box::new(after.key)); let k = params.len();
1801                params.push(Box::new(after.id.to_string())); let id = params.len();
1802                clause.push_str(&format!(" AND (updated_at < ?{u} OR (updated_at = ?{u} AND key < ?{k}) \
1803                    OR (updated_at = ?{u} AND key = ?{k} AND id > ?{id}))"));
1804            }
1805            params.push(Box::new(i64::from(page.limit) + 1)); let limit = params.len();
1806            params.push(Box::new(offset)); let offset = params.len();
1807            let sql = format!("SELECT {NOTE_COLUMNS} FROM notes{clause} ORDER BY updated_at DESC, key DESC, id ASC LIMIT ?{limit} OFFSET ?{offset}");
1808            let mut stmt = conn.prepare(&sql)?;
1809            let params: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1810            let mut notes = stmt.query_map(params.as_slice(), read_note)?.collect::<Result<Vec<_>, _>>()?;
1811            let has_more = notes.len() > page.limit as usize;
1812            notes.truncate(page.limit as usize);
1813            let next = if has_more { notes.last().map(NoteKeyCursor::from) } else { None };
1814            Ok((notes, next))
1815        }).await
1816    }
1817
1818    async fn upsert_note(&self, note: Note) -> Result<(), StorageError> {
1819        let id_str = note.id.to_string();
1820        let statement = note_upsert_statement(&note);
1821        self.with_writer_tx("upsert_note", move |conn| {
1822            let mut stmt = conn.prepare_cached(&statement.sql)?;
1823            bind_params(&mut stmt, &statement.params)?;
1824            stmt.raw_execute()?;
1825            assign_note_seq(conn, &id_str)?;
1826            Ok(())
1827        })
1828        .await
1829    }
1830
1831    async fn insert_note_if_absent(&self, note: Note) -> Result<bool, StorageError> {
1832        let id_str = note.id.to_string();
1833        let statement = note_insert_if_absent_statement(&note);
1834        self.with_writer_tx("insert_note_if_absent", move |conn| {
1835            let mut stmt = conn.prepare_cached(&statement.sql)?;
1836            bind_params(&mut stmt, &statement.params)?;
1837            let inserted = stmt.raw_execute()? > 0;
1838            // Only a real insert needs a sequence number. Assigning one on the
1839            // no-op path would write to a row this call did not create, which
1840            // is the overwrite this primitive exists to avoid.
1841            if inserted {
1842                assign_note_seq(conn, &id_str)?;
1843            }
1844            Ok(inserted)
1845        })
1846        .await
1847    }
1848
1849    async fn replace_note_if_unchanged(
1850        &self,
1851        note: Note,
1852        expected_updated_at: i64,
1853        expected_deleted_at: Option<i64>,
1854    ) -> Result<bool, StorageError> {
1855        let statement =
1856            note_replace_if_unchanged_statement(&note, expected_updated_at, expected_deleted_at);
1857        self.with_writer("replace_note_if_unchanged", move |conn| {
1858            let mut stmt = conn.prepare(&statement.sql)?;
1859            bind_params(&mut stmt, &statement.params)?;
1860            Ok(stmt.raw_execute()? > 0)
1861        })
1862        .await
1863    }
1864
1865    async fn update_note_properties(
1866        &self,
1867        id: Uuid,
1868        properties: Option<serde_json::Value>,
1869        updated_at: i64,
1870    ) -> Result<bool, StorageError> {
1871        let statement = note_update_properties_statement(id, &properties, updated_at);
1872        self.with_writer("update_note_properties", move |conn| {
1873            let mut stmt = conn.prepare(&statement.sql)?;
1874            bind_params(&mut stmt, &statement.params)?;
1875            Ok(stmt.raw_execute()? > 0)
1876        })
1877        .await
1878    }
1879
1880    async fn set_note_property(
1881        &self,
1882        id: Uuid,
1883        key: &str,
1884        value: serde_json::Value,
1885        updated_at: i64,
1886    ) -> Result<bool, StorageError> {
1887        let statement = note_set_property_statement(id, key, &value, updated_at)?;
1888        self.with_writer("set_note_property", move |conn| {
1889            let mut stmt = conn.prepare(&statement.sql)?;
1890            bind_params(&mut stmt, &statement.params)?;
1891            Ok(stmt.raw_execute()? > 0)
1892        })
1893        .await
1894    }
1895
1896    async fn try_patch_note_property(
1897        &self,
1898        id: Uuid,
1899        namespace: &str,
1900        filter: &NoteFilter,
1901        json_path: &str,
1902        value: serde_json::Value,
1903        updated_at: i64,
1904    ) -> Result<bool, StorageError> {
1905        validate_note_time_order(filter, "try_patch_note_property", false)?;
1906        for property in &filter.property_filters {
1907            validate_json_path(&property.json_path)?;
1908        }
1909        let namespace = namespace.to_string();
1910        let filter = filter.clone();
1911        let value_json = serde_json::to_string(&value).map_err(|e| {
1912            StorageError::driver(StorageCapability::Notes, "try_patch_note_property", e)
1913        })?;
1914        let json_path = json_path.to_string();
1915
1916        self.with_writer("try_patch_note_property", move |conn| {
1917            execute_filtered_note_property_patch(
1918                conn,
1919                id,
1920                &namespace,
1921                &filter,
1922                &json_path,
1923                &value_json,
1924                updated_at,
1925            )
1926            .map(|rows| rows > 0)
1927        })
1928        .await
1929    }
1930
1931    async fn patch_note_property_atomic(
1932        &self,
1933        mut ids: Vec<Uuid>,
1934        namespace: &str,
1935        filter: &NoteFilter,
1936        json_path: &str,
1937        value: serde_json::Value,
1938        updated_at: i64,
1939    ) -> Result<(), StorageError> {
1940        validate_note_time_order(filter, "patch_note_property_atomic", false)?;
1941        for property in &filter.property_filters {
1942            validate_json_path(&property.json_path)?;
1943        }
1944        let mut seen = HashSet::with_capacity(ids.len());
1945        ids.retain(|id| seen.insert(*id));
1946        if ids.is_empty() {
1947            return Err(StorageError::InvalidInput {
1948                capability: StorageCapability::Notes,
1949                operation: "patch_note_property_atomic".into(),
1950                message: "at least one note id is required".to_string(),
1951            });
1952        }
1953
1954        let namespace = namespace.to_string();
1955        let filter = filter.clone();
1956        let value_json = serde_json::to_string(&value).map_err(|e| {
1957            StorageError::driver(StorageCapability::Notes, "patch_note_property_atomic", e)
1958        })?;
1959        let json_path = json_path.to_string();
1960
1961        self.with_writer_tx_storage("patch_note_property_atomic", move |conn| {
1962            for id in ids {
1963                let rows = execute_filtered_note_property_patch(
1964                    conn,
1965                    id,
1966                    &namespace,
1967                    &filter,
1968                    &json_path,
1969                    &value_json,
1970                    updated_at,
1971                )
1972                .map_err(|error| map_err(error, "patch_note_property_atomic"))?;
1973                if rows != 1 {
1974                    return Err(StorageError::Conflict {
1975                        capability: StorageCapability::Notes,
1976                        operation: "patch_note_property_atomic".into(),
1977                        message: format!(
1978                            "precondition failed for note {id}: guarded update changed {rows} rows; expected 1"
1979                        ),
1980                    });
1981                }
1982            }
1983            Ok(())
1984        })
1985        .await
1986    }
1987
1988    async fn try_insert_note(&self, note: Note) -> Result<bool, StorageError> {
1989        self.try_insert_note_with_attachments(note, Vec::new())
1990            .await
1991    }
1992
1993    async fn try_insert_note_with_attachments(
1994        &self,
1995        note: Note,
1996        attachments: Vec<Attachment>,
1997    ) -> Result<bool, StorageError> {
1998        let mut attachment_statements = Vec::with_capacity(attachments.len());
1999        for attachment in attachments {
2000            attachment.validate()?;
2001            if attachment.record_uuid != note.id
2002                || attachment.substrate != AttachmentSubstrate::Note
2003            {
2004                return Err(StorageError::InvalidInput {
2005                    capability: StorageCapability::Attachments,
2006                    operation: "try_insert_note_with_attachments".into(),
2007                    message: format!(
2008                        "attachment {} must target note {}",
2009                        attachment.role, note.id
2010                    ),
2011                });
2012            }
2013            attachment_statements.push(attachment_upsert_statement(&attachment)?);
2014        }
2015        let namespace = note.namespace.clone();
2016        let id_str = note.id.to_string();
2017        let kind_str = note.kind.to_string();
2018        let status_str = note.status.clone();
2019        let properties_str = note
2020            .properties
2021            .as_ref()
2022            .map(|v| serde_json::to_string(v).unwrap_or_default());
2023        let (due_key, due_source) = note_due_key_values(&note.properties);
2024
2025        // Capture the durable dedup key for verification after a zero-row insert.
2026        let ext_id_opt: Option<String> = note
2027            .properties
2028            .as_ref()
2029            .and_then(|v| v.get("external_id"))
2030            .and_then(|v| v.as_str())
2031            .filter(|s| !s.is_empty())
2032            .map(|s| s.to_string());
2033        let channel_kind = note
2034            .properties
2035            .as_ref()
2036            .and_then(|v| v.get("channel_kind"))
2037            .and_then(|v| v.as_str())
2038            .unwrap_or_default()
2039            .to_string();
2040        let channel_slug = note
2041            .properties
2042            .as_ref()
2043            .and_then(|v| v.get("channel_slug"))
2044            .and_then(|v| v.as_str())
2045            .unwrap_or_default()
2046            .to_string();
2047
2048        self.with_writer_tx("try_insert_note", move |conn| {
2049            let rows = conn.execute(
2050                "INSERT OR IGNORE INTO notes \
2051                 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
2052                  properties, created_at, updated_at, deleted_at, key, strict_due_key, due_source) \
2053                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
2054                rusqlite::params![
2055                    id_str,
2056                    namespace,
2057                    kind_str,
2058                    status_str,
2059                    note.name,
2060                    note.content,
2061                    note.salience,
2062                    note.decay_factor,
2063                    note.expires_at,
2064                    properties_str,
2065                    note.created_at,
2066                    note.updated_at,
2067                    note.deleted_at,
2068                    note.key,
2069                    due_key,
2070                    due_source,
2071                ],
2072            )?;
2073
2074            if rows > 0 {
2075                assign_note_seq(conn, &id_str)?;
2076                for statement in attachment_statements {
2077                    let mut stmt = conn.prepare(&statement.sql)?;
2078                    bind_params(&mut stmt, &statement.params)?;
2079                    stmt.raw_execute()?;
2080                }
2081                return Ok(true);
2082            }
2083
2084            // Zero rows: the INSERT was silently skipped by OR IGNORE.
2085            // Only treat this as a dedup hit when a live note with the same
2086            // non-empty external_id and exact channel provenance already exists
2087            // in this namespace and kind.
2088            // Any other ignored constraint (e.g. a PRIMARY KEY collision) must
2089            // surface as an error rather than being misreported as a duplicate.
2090            if let Some(ref ext_id) = ext_id_opt {
2091                let is_dedup: bool = conn.query_row(
2092                    "SELECT COUNT(*) > 0 FROM notes \
2093                     WHERE namespace = ?1 \
2094                       AND kind = ?2 \
2095                       AND json_extract(properties, '$.external_id') = ?3 \
2096                       AND ifnull(json_extract(properties, '$.channel_kind'), '') = ?4 \
2097                       AND ifnull(json_extract(properties, '$.channel_slug'), '') = ?5 \
2098                       AND deleted_at IS NULL",
2099                    rusqlite::params![namespace, kind_str, ext_id, channel_kind, channel_slug],
2100                    |row| row.get(0),
2101                )?;
2102                if is_dedup {
2103                    return Ok(false);
2104                }
2105            }
2106
2107            // The INSERT was dropped for a reason other than an external_id
2108            // collision.  Surface it as a constraint error.
2109            Err(rusqlite::Error::SqliteFailure(
2110                rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
2111                Some(
2112                    "try_insert_note: INSERT ignored for a constraint other than \
2113                     external_id dedup; not masking as deduplication"
2114                        .to_string(),
2115                ),
2116            ))
2117        })
2118        .await
2119    }
2120
2121    async fn upsert_notes(&self, notes: Vec<Note>) -> Result<BatchWriteSummary, StorageError> {
2122        let attempted = notes.len() as u64;
2123
2124        // khive #827: route through `with_writer_tx`
2125        // instead of hand-rolling BEGIN IMMEDIATE/COMMIT/ROLLBACK here. The
2126        // old flag-off path only rolled back when the final COMMIT failed —
2127        // an earlier error from `batch_upsert_notes` (e.g. a failed
2128        // `assign_note_seq`) propagated via `?` straight out of the closure,
2129        // skipping ROLLBACK entirely and leaving BEGIN IMMEDIATE open on the
2130        // shared pool-mutex connection, poisoning every later write on that
2131        // connection. `with_writer_tx` rolls back on ANY error from `f`, on
2132        // both the flag-on (WriterTask, which wraps its own transaction) and
2133        // flag-off (pool-mutex) paths.
2134        let origin = self.pool.origin();
2135        self.with_writer_tx("upsert_notes", move |conn| {
2136            let _tx_handle = khive_storage::tx_registry::register_scoped(
2137                Some("note_upsert_batch".to_string()),
2138                origin,
2139            );
2140            batch_upsert_notes(conn, &notes, attempted)
2141        })
2142        .await
2143    }
2144
2145    async fn get_note(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
2146        let id_str = id.to_string();
2147
2148        self.with_reader("get_note", move |conn| {
2149            let mut stmt = conn.prepare(
2150                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
2151                 properties, created_at, updated_at, deleted_at, key, version \
2152                 FROM notes WHERE id = ?1 AND deleted_at IS NULL",
2153            )?;
2154            let mut rows = stmt.query(rusqlite::params![id_str])?;
2155            match rows.next()? {
2156                Some(row) => Ok(Some(read_note(row)?)),
2157                None => Ok(None),
2158            }
2159        })
2160        .await
2161    }
2162
2163    async fn get_note_including_deleted(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
2164        let id_str = id.to_string();
2165
2166        self.with_reader("get_note_including_deleted", move |conn| {
2167            let mut stmt = conn.prepare(
2168                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
2169                 properties, created_at, updated_at, deleted_at, key, version \
2170                 FROM notes WHERE id = ?1",
2171            )?;
2172            let mut rows = stmt.query(rusqlite::params![id_str])?;
2173            match rows.next()? {
2174                Some(row) => Ok(Some(read_note(row)?)),
2175                None => Ok(None),
2176            }
2177        })
2178        .await
2179    }
2180
2181    async fn note_sequence(&self, id: Uuid) -> Result<Option<i64>, StorageError> {
2182        let id = id.to_string();
2183        self.with_reader("note_sequence", move |conn| {
2184            conn.query_row(
2185                "SELECT seq FROM notes_seq WHERE note_id = ?1",
2186                rusqlite::params![id],
2187                |row| row.get(0),
2188            )
2189            .optional()
2190        })
2191        .await
2192    }
2193
2194    async fn get_notes_batch(&self, ids: &[Uuid]) -> Result<Vec<Note>, StorageError> {
2195        self.get_notes_batch_inner(ids, false).await
2196    }
2197
2198    async fn get_notes_batch_including_deleted(
2199        &self,
2200        ids: &[Uuid],
2201    ) -> Result<Vec<Note>, StorageError> {
2202        self.get_notes_batch_inner(ids, true).await
2203    }
2204
2205    async fn get_note_visibility_batch(
2206        &self,
2207        ids: &[Uuid],
2208    ) -> Result<Vec<NoteVisibility>, StorageError> {
2209        if ids.is_empty() {
2210            return Ok(vec![]);
2211        }
2212        // Stay below SQLite's baseline 999-parameter limit for large ANN candidate sets.
2213        const CHUNK: usize = 900;
2214        let id_strings: Vec<String> = ids.iter().map(Uuid::to_string).collect();
2215        let mut result = Vec::with_capacity(ids.len());
2216        for chunk in id_strings.chunks(CHUNK) {
2217            let chunk_owned = chunk.to_vec();
2218            let rows = self
2219                .with_reader("get_note_visibility_batch", move |conn| {
2220                    let placeholders = (1..=chunk_owned.len())
2221                        .map(|i| format!("?{i}"))
2222                        .collect::<Vec<_>>()
2223                        .join(", ");
2224                    let sql = format!(
2225                        "SELECT id, namespace, deleted_at FROM notes WHERE id IN ({placeholders})"
2226                    );
2227                    let mut stmt = conn.prepare(&sql)?;
2228                    let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
2229                        .iter()
2230                        .map(|s| s as &dyn rusqlite::types::ToSql)
2231                        .collect();
2232                    let rows = stmt.query_map(params.as_slice(), |row| {
2233                        let id: String = row.get(0)?;
2234                        Ok(NoteVisibility {
2235                            id: parse_uuid(&id)?,
2236                            namespace: row.get(1)?,
2237                            deleted_at: row.get(2)?,
2238                        })
2239                    })?;
2240                    rows.collect::<rusqlite::Result<Vec<_>>>()
2241                })
2242                .await?;
2243            result.extend(rows);
2244        }
2245        Ok(result)
2246    }
2247
2248    async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
2249        match mode {
2250            DeleteMode::Soft => {
2251                let now = chrono::Utc::now().timestamp_micros();
2252                let statement = note_soft_delete_statement(id, now);
2253                self.with_writer("delete_note_soft", move |conn| {
2254                    let mut stmt = conn.prepare(&statement.sql)?;
2255                    bind_params(&mut stmt, &statement.params)?;
2256                    Ok(stmt.raw_execute()? > 0)
2257                })
2258                .await
2259            }
2260            DeleteMode::Hard => {
2261                let note_statement = note_hard_delete_statement(id);
2262                let attachment_statement =
2263                    delete_record_attachments_statement(id, AttachmentSubstrate::Note);
2264                self.with_writer_tx("delete_note_hard", move |conn| {
2265                    let mut note_stmt = conn.prepare(&note_statement.sql)?;
2266                    bind_params(&mut note_stmt, &note_statement.params)?;
2267                    let deleted = note_stmt.raw_execute()? > 0;
2268                    drop(note_stmt);
2269                    if deleted {
2270                        let mut attachment_stmt = conn.prepare(&attachment_statement.sql)?;
2271                        bind_params(&mut attachment_stmt, &attachment_statement.params)?;
2272                        attachment_stmt.raw_execute()?;
2273                    }
2274                    Ok(deleted)
2275                })
2276                .await
2277            }
2278        }
2279    }
2280
2281    async fn query_notes(
2282        &self,
2283        namespace: &str,
2284        kind: Option<&str>,
2285        page: PageRequest,
2286    ) -> Result<Page<Note>, StorageError> {
2287        let namespace = namespace.to_string();
2288        let kind = kind.map(|k| k.to_string());
2289        let limit_i64 = i64::from(page.limit);
2290        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
2291            capability: StorageCapability::Notes,
2292            operation: "query_notes".into(),
2293            message: format!(
2294                "PageRequest: offset must be <= i64::MAX, got {}",
2295                page.offset
2296            ),
2297        })?;
2298
2299        self.with_reader("query_notes", move |conn| {
2300            let (count_sql, count_params) = build_note_where(&namespace, kind.as_deref());
2301            let count_sql = format!("SELECT COUNT(*) FROM notes{count_sql}");
2302
2303            let (where_sql, mut data_params) = build_note_where(&namespace, kind.as_deref());
2304            data_params.push(Box::new(limit_i64));
2305            data_params.push(Box::new(offset_i64));
2306
2307            let limit_idx = data_params.len() - 1;
2308            let offset_idx = data_params.len();
2309
2310            let data_sql = format!(
2311                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
2312                 properties, created_at, updated_at, deleted_at, key, version \
2313                 FROM notes{} ORDER BY created_at DESC, id ASC LIMIT ?{} OFFSET ?{}",
2314                where_sql, limit_idx, offset_idx,
2315            );
2316
2317            query_note_page_snapshot(
2318                conn,
2319                "query_notes",
2320                &namespace,
2321                &count_sql,
2322                &count_params,
2323                &data_sql,
2324                &data_params,
2325            )
2326        })
2327        .await
2328    }
2329
2330    async fn query_notes_count_free(
2331        &self,
2332        namespace: &str,
2333        kind: Option<&str>,
2334        page: PageRequest,
2335    ) -> Result<Page<Note>, StorageError> {
2336        let namespace = namespace.to_string();
2337        let kind = kind.map(str::to_string);
2338        let limit_i64 = i64::from(page.limit);
2339        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
2340            capability: StorageCapability::Notes,
2341            operation: "query_notes_count_free".into(),
2342            message: format!(
2343                "PageRequest: offset must be <= i64::MAX, got {}",
2344                page.offset
2345            ),
2346        })?;
2347
2348        self.with_reader("query_notes_count_free", move |conn| {
2349            let (where_sql, mut params) = build_note_where(&namespace, kind.as_deref());
2350            params.push(Box::new(limit_i64));
2351            params.push(Box::new(offset_i64));
2352            let limit_idx = params.len() - 1;
2353            let offset_idx = params.len();
2354            let sql = format!(
2355                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
2356                 expires_at, properties, created_at, updated_at, deleted_at, key, version \
2357                 FROM notes{where_sql} ORDER BY created_at DESC, id ASC \
2358                 LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
2359            );
2360
2361            let mut stmt = conn.prepare(&sql)?;
2362            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2363                params.iter().map(|param| param.as_ref()).collect();
2364            let mut rows = stmt.query(param_refs.as_slice())?;
2365            let mut items = Vec::new();
2366            while let Some(row) = rows.next()? {
2367                items.push(read_note(row)?);
2368            }
2369
2370            Ok(Page { items, total: None })
2371        })
2372        .await
2373    }
2374
2375    async fn query_notes_filtered(
2376        &self,
2377        namespace: &str,
2378        filter: &NoteFilter,
2379        page: PageRequest,
2380    ) -> Result<Page<Note>, StorageError> {
2381        validate_note_time_order(filter, "query_notes_filtered", true)?;
2382        if filter.unordered {
2383            return Err(StorageError::InvalidInput {
2384                capability: StorageCapability::Notes,
2385                operation: "query_notes_filtered".into(),
2386                message: "NoteFilter.unordered is supported only by \
2387                          query_notes_filtered_count_free"
2388                    .into(),
2389            });
2390        }
2391        // Validate paths before entering spawn_blocking (closures return rusqlite::Error).
2392        for pf in &filter.property_filters {
2393            validate_json_path(&pf.json_path)?;
2394        }
2395        if let Some((path, _)) = &filter.order_by {
2396            validate_json_path(path)?;
2397        }
2398        if filter.order_by_instant && !matches!(filter.order_by.as_ref(), Some((_, SortDir::Asc))) {
2399            return Err(StorageError::InvalidInput {
2400                capability: StorageCapability::Notes,
2401                operation: "query_notes_filtered".into(),
2402                message: "order_by_instant requires an ascending property order".into(),
2403            });
2404        }
2405        if filter.after.is_some() || filter.after_instant.is_some() {
2406            return Err(StorageError::InvalidInput {
2407                capability: StorageCapability::Notes,
2408                operation: "query_notes_filtered".into(),
2409                message: "NoteFilter.after or after_instant (keyset pagination) is not supported by this \
2410                          method: it computes an exact COUNT(*) total over the whole \
2411                          matching set, which has no defined meaning paired with a seek \
2412                          boundary; use query_notes_filtered_count_free instead, which \
2413                          seeks and returns total: None"
2414                    .into(),
2415            });
2416        }
2417
2418        let namespace = namespace.to_string();
2419        let filter = filter.clone();
2420        let limit_i64 = i64::from(page.limit);
2421        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
2422            capability: StorageCapability::Notes,
2423            operation: "query_notes_filtered".into(),
2424            message: format!(
2425                "PageRequest: offset must be <= i64::MAX, got {}",
2426                page.offset
2427            ),
2428        })?;
2429
2430        self.with_indexed_reader("query_notes_filtered", move |conn| {
2431            let (count_sql, count_params) = build_note_filter_read_clause(&namespace, &filter)?;
2432            let count_sql = format!("SELECT COUNT(*) FROM notes{count_sql}");
2433
2434            let (where_sql, mut data_params) = build_note_filter_read_clause(&namespace, &filter)?;
2435            data_params.push(Box::new(limit_i64));
2436            data_params.push(Box::new(offset_i64));
2437
2438            // The total order removes tie-order instability only — offset
2439            // paging can still duplicate or skip rows across separate requests
2440            // under concurrent inserts/deletes or sort-key updates.
2441            let order_clause = note_filter_page_order_clause(&filter);
2442
2443            let limit_idx = data_params.len() - 1;
2444            let offset_idx = data_params.len();
2445            let data_sql = format!(
2446                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
2447                 expires_at, properties, created_at, updated_at, deleted_at, key, version \
2448                 FROM notes{}{order_clause} LIMIT ?{} OFFSET ?{}",
2449                where_sql, limit_idx, offset_idx,
2450            );
2451
2452            query_note_page_snapshot(
2453                conn,
2454                "query_notes_filtered",
2455                &namespace,
2456                &count_sql,
2457                &count_params,
2458                &data_sql,
2459                &data_params,
2460            )
2461        })
2462        .await
2463    }
2464
2465    async fn query_notes_filtered_count_free(
2466        &self,
2467        namespace: &str,
2468        filter: &NoteFilter,
2469        page: PageRequest,
2470    ) -> Result<Page<Note>, StorageError> {
2471        validate_note_time_order(filter, "query_notes_filtered_count_free", true)?;
2472        for property_filter in &filter.property_filters {
2473            validate_json_path(&property_filter.json_path)?;
2474        }
2475        if let Some((path, _)) = &filter.order_by {
2476            validate_json_path(path)?;
2477        }
2478        if filter.order_by_instant && !matches!(filter.order_by.as_ref(), Some((_, SortDir::Asc))) {
2479            return Err(StorageError::InvalidInput {
2480                capability: StorageCapability::Notes,
2481                operation: "query_notes_filtered_count_free".into(),
2482                message: "order_by_instant requires an ascending property order".into(),
2483            });
2484        }
2485        if filter.order_by_instant && filter.unordered {
2486            return Err(StorageError::InvalidInput {
2487                capability: StorageCapability::Notes,
2488                operation: "query_notes_filtered_count_free".into(),
2489                message: "order_by_instant is incompatible with unordered pages".into(),
2490            });
2491        }
2492        if filter.after_instant.is_some() && !filter.order_by_instant {
2493            return Err(StorageError::InvalidInput {
2494                capability: StorageCapability::Notes,
2495                operation: "query_notes_filtered_count_free".into(),
2496                message: "after_instant requires order_by_instant".into(),
2497            });
2498        }
2499        if filter.after.is_some() && filter.after_instant.is_some() {
2500            return Err(StorageError::InvalidInput {
2501                capability: StorageCapability::Notes,
2502                operation: "query_notes_filtered_count_free".into(),
2503                message: "after and after_instant are mutually exclusive".into(),
2504            });
2505        }
2506        if filter.after.is_some() && filter.order_by.is_some() {
2507            return Err(StorageError::InvalidInput {
2508                capability: StorageCapability::Notes,
2509                operation: "query_notes_filtered_count_free".into(),
2510                message: "NoteFilter.after is incompatible with a custom order_by; it is \
2511                          defined only over the default created_at DESC, id ASC order"
2512                    .into(),
2513            });
2514        }
2515        if (filter.after.is_some() || filter.after_instant.is_some()) && page.offset != 0 {
2516            return Err(StorageError::InvalidInput {
2517                capability: StorageCapability::Notes,
2518                operation: "query_notes_filtered_count_free".into(),
2519                message: "NoteFilter.after or after_instant and a non-zero PageRequest.offset \
2520                          are mutually exclusive pagination strategies; pass offset: 0"
2521                    .into(),
2522            });
2523        }
2524
2525        let namespace = namespace.to_string();
2526        let filter = filter.clone();
2527        let limit_i64 = i64::from(page.limit);
2528        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
2529            capability: StorageCapability::Notes,
2530            operation: "query_notes_filtered_count_free".into(),
2531            message: format!(
2532                "PageRequest: offset must be <= i64::MAX, got {}",
2533                page.offset
2534            ),
2535        })?;
2536
2537        self.with_indexed_reader("query_notes_filtered_count_free", move |conn| {
2538            if let Some(after) = &filter.after_instant {
2539                let mut base_filter = filter.clone();
2540                base_filter.after_instant = None;
2541                let items =
2542                    fetch_notes_after_instant(conn, &namespace, &base_filter, after, limit_i64)?;
2543                return Ok(Page { items, total: None });
2544            }
2545            if let Some(after) = &filter.after {
2546                let mut base_filter = filter.clone();
2547                base_filter.after = None;
2548                let items = fetch_notes_after(conn, &namespace, &base_filter, after, limit_i64)?;
2549                return Ok(Page { items, total: None });
2550            }
2551
2552            let (sql, params) = note_filter_page_sql(&namespace, &filter, limit_i64, offset_i64)?;
2553
2554            let mut stmt = conn.prepare(&sql)?;
2555            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2556                params.iter().map(|param| param.as_ref()).collect();
2557            let mut rows = stmt.query(param_refs.as_slice())?;
2558            let mut items = Vec::new();
2559            while let Some(row) = rows.next()? {
2560                items.push(read_note(row)?);
2561                // A single SQLite statement pins its read snapshot on the
2562                // first step. The test seam pauses there so a concurrent WAL
2563                // commit can prove the remainder stays on that snapshot.
2564                #[cfg(test)]
2565                if items.len() == 1 {
2566                    tests::page_snapshot_seam::hook("query_notes_filtered_count_free", &namespace);
2567                }
2568            }
2569
2570            Ok(Page { items, total: None })
2571        })
2572        .await
2573    }
2574
2575    async fn count_notes_filtered_in_snapshot(
2576        &self,
2577        namespace: &str,
2578        filters: &[NoteFilter],
2579    ) -> Result<Vec<u64>, StorageError> {
2580        for filter in filters {
2581            for pf in &filter.property_filters {
2582                validate_json_path(&pf.json_path)?;
2583            }
2584        }
2585
2586        let namespace = namespace.to_string();
2587        let filters = filters.to_vec();
2588        self.with_indexed_reader("count_notes_filtered_in_snapshot", move |conn| {
2589            let tx = rusqlite::Transaction::new_unchecked(
2590                conn,
2591                rusqlite::TransactionBehavior::Deferred,
2592            )?;
2593            let mut counts = Vec::with_capacity(filters.len());
2594            for filter in filters.iter() {
2595                #[cfg(test)]
2596                if !counts.is_empty() {
2597                    tests::page_snapshot_seam::hook("count_notes_filtered_in_snapshot", &namespace);
2598                }
2599
2600                let (where_sql, params) = build_note_filter_read_clause(&namespace, filter)?;
2601                let sql = format!("SELECT COUNT(*) FROM notes{where_sql}");
2602                let mut stmt = tx.prepare(&sql)?;
2603                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2604                    params.iter().map(|param| param.as_ref()).collect();
2605                let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
2606                counts.push(count as u64);
2607            }
2608            tx.commit()?;
2609            Ok(counts)
2610        })
2611        .await
2612    }
2613
2614    async fn count_notes_filtered_bounded_in_snapshot(
2615        &self,
2616        namespace: &str,
2617        filters: &[NoteFilter],
2618        cap: u32,
2619    ) -> Result<Vec<BoundedCount>, StorageError> {
2620        for filter in filters {
2621            for property_filter in &filter.property_filters {
2622                validate_json_path(&property_filter.json_path)?;
2623            }
2624        }
2625
2626        let namespace = namespace.to_string();
2627        let filters = filters.to_vec();
2628        let cap_u64 = u64::from(cap);
2629        let probe_limit_i64 = i64::from(cap) + 1;
2630        self.with_indexed_reader("count_notes_filtered_bounded_in_snapshot", move |conn| {
2631            let tx = rusqlite::Transaction::new_unchecked(
2632                conn,
2633                rusqlite::TransactionBehavior::Deferred,
2634            )?;
2635            let mut counts = Vec::with_capacity(filters.len());
2636            for filter in &filters {
2637                #[cfg(test)]
2638                if !counts.is_empty() {
2639                    tests::page_snapshot_seam::hook(
2640                        "count_notes_filtered_bounded_in_snapshot",
2641                        &namespace,
2642                    );
2643                }
2644
2645                let (where_sql, mut params) = build_note_filter_read_clause(&namespace, filter)?;
2646                params.push(Box::new(probe_limit_i64));
2647                let limit_idx = params.len();
2648                // The inner LIMIT is the work bound. Selecting a constant
2649                // and omitting ORDER BY lets SQLite stop after cap + 1
2650                // matching index entries instead of hydrating or sorting
2651                // the complete population.
2652                let sql = format!(
2653                    "SELECT COUNT(*) FROM (SELECT 1 FROM notes{where_sql} LIMIT ?{limit_idx})"
2654                );
2655                let mut stmt = tx.prepare(&sql)?;
2656                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2657                    params.iter().map(|param| param.as_ref()).collect();
2658                let observed: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
2659                let observed = observed as u64;
2660                counts.push(BoundedCount {
2661                    count: observed.min(cap_u64),
2662                    cap: cap_u64,
2663                    saturated: observed > cap_u64,
2664                });
2665            }
2666            tx.commit()?;
2667            Ok(counts)
2668        })
2669        .await
2670    }
2671
2672    async fn query_notes_filtered_after(
2673        &self,
2674        namespace: &str,
2675        filter: &NoteFilter,
2676        after: Option<SeekCursor>,
2677        limit: u32,
2678    ) -> Result<SeekPage<Note>, StorageError> {
2679        validate_note_time_order(filter, "query_notes_filtered_after", false)?;
2680        if limit == 0 {
2681            return Ok(SeekPage::default());
2682        }
2683        if filter.order_by.is_some() {
2684            return Err(StorageError::InvalidInput {
2685                capability: StorageCapability::Notes,
2686                operation: "query_notes_filtered_after".into(),
2687                message: "custom order_by is not compatible with insertion-sequence pagination"
2688                    .into(),
2689            });
2690        }
2691        for property_filter in &filter.property_filters {
2692            validate_json_path(&property_filter.json_path)?;
2693        }
2694
2695        let namespace = namespace.to_string();
2696        let filter = filter.clone();
2697        let limit_usize = limit as usize;
2698        let probe_limit_i64 = i64::from(limit) + 1;
2699        self.with_reader("query_notes_filtered_after", move |conn| {
2700            let (mut where_sql, mut params) = build_note_filter_where(&namespace, &filter)?;
2701            if let Some(cursor) = after {
2702                params.push(Box::new(cursor.sequence));
2703                where_sql.push_str(&format!(" AND notes_seq.seq > ?{}", params.len()));
2704            }
2705            params.push(Box::new(probe_limit_i64));
2706            let limit_idx = params.len();
2707            // CROSS JOIN fixes the ledger as the outer loop, preserving an
2708            // indexed `seq > boundary` scan with no full-match sort.
2709            let sql = format!(
2710                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
2711                 expires_at, properties, created_at, updated_at, deleted_at, key, version, notes_seq.seq \
2712                 FROM notes_seq CROSS JOIN notes ON notes.id = notes_seq.note_id{where_sql} \
2713                 ORDER BY notes_seq.seq ASC LIMIT ?{limit_idx}"
2714            );
2715            let mut stmt = conn.prepare(&sql)?;
2716            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2717                params.iter().map(|param| param.as_ref()).collect();
2718            let rows = stmt.query_map(param_refs.as_slice(), |row| {
2719                Ok((read_note(row)?, row.get::<_, i64>(15)?))
2720            })?;
2721            let mut entries = rows.collect::<Result<Vec<_>, _>>()?;
2722            let has_more = entries.len() > limit_usize;
2723            if has_more {
2724                entries.truncate(limit_usize);
2725            }
2726            let next_after = if has_more {
2727                entries.last().map(|(note, sequence)| SeekCursor {
2728                    sequence: *sequence,
2729                    id: note.id,
2730                })
2731            } else {
2732                None
2733            };
2734            let items = entries.into_iter().map(|(note, _)| note).collect();
2735            Ok(SeekPage { items, next_after })
2736        })
2737        .await
2738    }
2739
2740    async fn query_notes_filtered_bounded(
2741        &self,
2742        namespace: &str,
2743        filter: &NoteFilter,
2744        max_rows: u32,
2745    ) -> Result<Vec<Note>, StorageError> {
2746        validate_note_time_order(filter, "query_notes_filtered_bounded", true)?;
2747        if filter.unordered {
2748            return Err(StorageError::InvalidInput {
2749                capability: StorageCapability::Notes,
2750                operation: "query_notes_filtered_bounded".into(),
2751                message: "NoteFilter.unordered is supported only by \
2752                          query_notes_filtered_count_free"
2753                    .into(),
2754            });
2755        }
2756        for pf in &filter.property_filters {
2757            validate_json_path(&pf.json_path)?;
2758        }
2759        if let Some((path, _)) = &filter.order_by {
2760            validate_json_path(path)?;
2761        }
2762
2763        let namespace = namespace.to_string();
2764        let filter = filter.clone();
2765        let limit_i64 = i64::from(max_rows) + 1;
2766
2767        self.with_indexed_reader("query_notes_filtered_bounded", move |conn| {
2768            let (where_sql, mut data_params) = build_note_filter_read_clause(&namespace, &filter)?;
2769            data_params.push(Box::new(limit_i64));
2770            let limit_idx = data_params.len();
2771
2772            // Tie-break on `id` in addition to the primary sort key so the
2773            // snapshot ordering is fully deterministic even when many rows
2774            // share the same `created_at` (or the same custom sort value).
2775            let order_clause = match (filter.time_order, &filter.order_by) {
2776                (Some(order), _) => note_time_order_clause(order).to_string(),
2777                (None, Some((path, dir))) => {
2778                    let dir_str = match dir {
2779                        SortDir::Asc => "ASC",
2780                        SortDir::Desc => "DESC",
2781                    };
2782                    format!(" ORDER BY {} {dir_str}, id ASC", json_extract_expr(path))
2783                }
2784                (None, None) => " ORDER BY created_at DESC, id ASC".to_string(),
2785            };
2786
2787            let data_sql = format!(
2788                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
2789                 expires_at, properties, created_at, updated_at, deleted_at, key, version \
2790                 FROM notes{where_sql}{order_clause} LIMIT ?{limit_idx}",
2791            );
2792
2793            let mut stmt = conn.prepare(&data_sql)?;
2794            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2795                data_params.iter().map(|p| p.as_ref()).collect();
2796            let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
2797
2798            let mut items = Vec::new();
2799            for row in rows {
2800                items.push(row?);
2801            }
2802            Ok(items)
2803        })
2804        .await
2805    }
2806
2807    async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> Result<u64, StorageError> {
2808        let namespace = namespace.to_string();
2809        let kind = kind.map(|k| k.to_string());
2810
2811        self.with_reader("count_notes", move |conn| {
2812            let (where_sql, params) = build_note_where(&namespace, kind.as_deref());
2813            let sql = format!("SELECT COUNT(*) FROM notes{}", where_sql);
2814            let mut stmt = conn.prepare(&sql)?;
2815            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2816                params.iter().map(|p| p.as_ref()).collect();
2817            let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
2818            Ok(count as u64)
2819        })
2820        .await
2821    }
2822
2823    async fn count_notes_in_namespaces(
2824        &self,
2825        namespaces: &[String],
2826        kind: Option<&str>,
2827    ) -> Result<u64, StorageError> {
2828        let namespaces: Vec<String> = namespaces
2829            .iter()
2830            .cloned()
2831            .collect::<HashSet<_>>()
2832            .into_iter()
2833            .collect();
2834        let kind = kind.map(str::to_string);
2835
2836        self.with_reader("count_notes_in_namespaces", move |conn| {
2837            let mut total = 0;
2838            for chunk in namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
2839                let (where_sql, params) = build_note_where_for_namespaces(chunk, kind.as_deref());
2840                let sql = format!("SELECT COUNT(*) FROM notes{where_sql}");
2841                let mut stmt = conn.prepare(&sql)?;
2842                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2843                    params.iter().map(|p| p.as_ref()).collect();
2844                let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
2845                total += count as u64;
2846            }
2847            Ok(total)
2848        })
2849        .await
2850    }
2851}
2852
2853// =============================================================================
2854// DDL
2855// =============================================================================
2856
2857const NOTES_DDL: &str = include_str!("../../sql/notes-ddl.sql");
2858
2859/// Same anti-join repair as `sql/008-notes-seq-repair.sql` (the V8 forward
2860/// migration) -- shared via `include_str!` from that single source file
2861/// rather than duplicated as SQL text. `INSERT OR IGNORE` targets notes
2862/// still missing a `notes_seq` row specifically, so it is correct to run
2863/// against a fresh ledger, a partially populated one, or an already fully
2864/// repaired one.
2865const NOTES_SEQ_REPAIR_DDL: &str = include_str!("../../sql/008-notes-seq-repair.sql");
2866
2867pub(crate) fn ensure_notes_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
2868    conn.execute_batch(NOTES_DDL)
2869}
2870
2871/// Anti-join backfill of `notes_seq` for any note still missing a row
2872/// (khive #827). Scans `notes` in full, so callers MUST gate this to
2873/// run at most once per backend/pool rather than on every store acquisition
2874/// (khive #827) -- see
2875/// `StorageBackend::notes_for_namespace`.
2876pub(crate) fn repair_notes_seq(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
2877    conn.execute_batch(NOTES_SEQ_REPAIR_DDL)
2878}
2879
2880#[cfg(test)]
2881#[path = "note_tests.rs"]
2882mod tests;
2883
2884#[cfg(test)]
2885#[path = "comm_filter_plan_tests.rs"]
2886mod comm_filter_plan_tests;
2887
2888#[cfg(test)]
2889#[path = "note_list_plan_tests.rs"]
2890mod note_list_plan_tests;
2891
2892#[cfg(test)]
2893#[path = "note_busy_tests.rs"]
2894mod direct_busy_tests;