Skip to main content

khive_db/stores/
note.rs

1//! SQL-backed `NoteStore` implementation.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use uuid::Uuid;
7
8use khive_storage::error::StorageError;
9use khive_storage::note::{FilterOp, Note, NoteFilter, SortDir};
10use khive_storage::types::{
11    BatchWriteSummary, DeleteMode, Page, PageRequest, SqlStatement, SqlValue,
12};
13use khive_storage::NoteStore;
14use khive_storage::StorageCapability;
15
16use crate::error::SqliteError;
17use crate::pool::ConnectionPool;
18use crate::sql_bridge::bind_params;
19use crate::writer_task::WriterTaskHandle;
20
21fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
22    StorageError::driver(StorageCapability::Notes, op, e)
23}
24
25fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
26    StorageError::driver(StorageCapability::Notes, op, e)
27}
28
29// ---------------------------------------------------------------------------
30// Pure statement builders (ADR-099 B3 r6 structural cut) — see entity.rs's
31// sibling block for the full rationale. `upsert_note`/`delete_note` below
32// and ADR-099's atomic prepare path (`khive-runtime`) both call these.
33// ---------------------------------------------------------------------------
34
35/// The exact `INSERT OR REPLACE` this store's `upsert_note` issues.
36pub fn note_upsert_statement(note: &Note) -> SqlStatement {
37    let properties_str = note
38        .properties
39        .as_ref()
40        .map(|v| serde_json::to_string(v).unwrap_or_default());
41    SqlStatement {
42        sql: "INSERT OR REPLACE INTO notes \
43              (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
44               properties, created_at, updated_at, deleted_at) \
45              VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"
46            .to_string(),
47        params: vec![
48            SqlValue::Text(note.id.to_string()),
49            SqlValue::Text(note.namespace.clone()),
50            SqlValue::Text(note.kind.to_string()),
51            SqlValue::Text(note.status.clone()),
52            match &note.name {
53                Some(n) => SqlValue::Text(n.clone()),
54                None => SqlValue::Null,
55            },
56            SqlValue::Text(note.content.clone()),
57            match note.salience {
58                Some(s) => SqlValue::Float(s),
59                None => SqlValue::Null,
60            },
61            match note.decay_factor {
62                Some(d) => SqlValue::Float(d),
63                None => SqlValue::Null,
64            },
65            match note.expires_at {
66                Some(e) => SqlValue::Integer(e),
67                None => SqlValue::Null,
68            },
69            match properties_str {
70                Some(p) => SqlValue::Text(p),
71                None => SqlValue::Null,
72            },
73            SqlValue::Integer(note.created_at),
74            SqlValue::Integer(note.updated_at),
75            match note.deleted_at {
76                Some(d) => SqlValue::Integer(d),
77                None => SqlValue::Null,
78            },
79        ],
80        label: Some("note-upsert".to_string()),
81    }
82}
83
84/// The exact `properties`/`updated_at` `UPDATE` this store's
85/// `update_note_properties` issues. A real `UPDATE` never triggers SQLite's
86/// `INSERT OR REPLACE` delete+insert, so the row is patched in place (#780).
87/// The `comm.probe` cursor is keyed on `notes_seq.seq`, which is fixed at
88/// first insert and survives a delete+reinsert of the same note id, so this
89/// is defensive rather than load-bearing for cursor correctness; a metadata
90/// patch should never rewrite the row regardless.
91pub fn note_update_properties_statement(
92    id: Uuid,
93    properties: &Option<serde_json::Value>,
94    updated_at: i64,
95) -> SqlStatement {
96    let properties_str = properties
97        .as_ref()
98        .map(|v| serde_json::to_string(v).unwrap_or_default());
99    SqlStatement {
100        sql: "UPDATE notes SET properties = ?1, updated_at = ?2 \
101              WHERE id = ?3 AND deleted_at IS NULL"
102            .to_string(),
103        params: vec![
104            match properties_str {
105                Some(p) => SqlValue::Text(p),
106                None => SqlValue::Null,
107            },
108            SqlValue::Integer(updated_at),
109            SqlValue::Text(id.to_string()),
110        ],
111        label: Some("note-update-properties".to_string()),
112    }
113}
114
115/// The exact soft-delete `UPDATE` this store's `delete_note(Soft)` issues.
116pub fn note_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
117    SqlStatement {
118        sql: "UPDATE notes SET status = 'deleted', deleted_at = ?1 \
119              WHERE id = ?2 AND deleted_at IS NULL"
120            .to_string(),
121        params: vec![
122            SqlValue::Integer(deleted_at),
123            SqlValue::Text(id.to_string()),
124        ],
125        label: Some("note-delete-soft".to_string()),
126    }
127}
128
129/// The exact hard-delete `DELETE` this store's `delete_note(Hard)` issues.
130pub fn note_hard_delete_statement(id: Uuid) -> SqlStatement {
131    SqlStatement {
132        sql: "DELETE FROM notes WHERE id = ?1".to_string(),
133        params: vec![SqlValue::Text(id.to_string())],
134        label: Some("note-delete-hard".to_string()),
135    }
136}
137
138/// A NoteStore backed by SQLite. Namespace is the caller's responsibility.
139///
140/// UUID is globally unique — get/delete by ID alone. Query/count use the
141/// namespace parameter as passed. The store is just a pool + is_file_backed.
142pub struct SqlNoteStore {
143    pool: Arc<ConnectionPool>,
144    is_file_backed: bool,
145    writer_task: Option<WriterTaskHandle>,
146}
147
148impl SqlNoteStore {
149    /// Create a new store.
150    pub fn new(pool: Arc<ConnectionPool>, is_file_backed: bool) -> Self {
151        // Best-effort opt-in (ADR-067 Component A, mirrors entity.rs slice 1
152        // policy): a missing writer task — flag off, spawn degraded, or no
153        // Tokio runtime available at this first access — degrades to the
154        // legacy pool-mutex path rather than failing construction.
155        let writer_task = pool.writer_task_handle().ok().flatten();
156
157        Self {
158            pool,
159            is_file_backed,
160            writer_task,
161        }
162    }
163
164    fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
165        let config = self.pool.config();
166        let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
167            operation: "note_reader".into(),
168            message: "in-memory databases do not support standalone connections".into(),
169        })?;
170
171        let conn = rusqlite::Connection::open_with_flags(
172            path,
173            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
174                | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
175                | rusqlite::OpenFlags::SQLITE_OPEN_URI,
176        )
177        .map_err(|e| map_err(e, "open_note_reader"))?;
178
179        conn.busy_timeout(config.busy_timeout)
180            .map_err(|e| map_err(e, "open_note_reader"))?;
181        conn.pragma_update(None, "foreign_keys", "ON")
182            .map_err(|e| map_err(e, "open_note_reader"))?;
183        conn.pragma_update(None, "synchronous", "NORMAL")
184            .map_err(|e| map_err(e, "open_note_reader"))?;
185
186        Ok(conn)
187    }
188
189    /// Route a single-row write through the pool-wide `WriterTask` when
190    /// `KHIVE_WRITE_QUEUE=1` and a handle is available; otherwise fall back
191    /// to the legacy pool-mutex path (ADR-067 Component A, Fork C slice 2).
192    ///
193    /// This is the routing point for single-statement `with_writer` callers
194    /// in this store (`update_note_properties`, `delete_note`). `f` must be
195    /// DML-only — on the flag-on path it runs inside the WriterTask's own
196    /// transaction, so a bare `BEGIN IMMEDIATE` would violate SQLite's
197    /// nested-transaction rule. `upsert_notes` (the batch method) does its
198    /// own flag check and returns early on `Some`, so its fallback call
199    /// into this helper only ever executes on the flag-off path
200    /// (`self.writer_task` is `None` by construction whenever that call is
201    /// reached) — no double-routing. Callers whose `f` issues more than one
202    /// DML statement that must land atomically together (`upsert_note`,
203    /// `try_insert_note`) use [`Self::with_writer_tx`] instead — see its doc
204    /// comment (khive #827).
205    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
206    where
207        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
208        R: Send + 'static,
209    {
210        if let Some(writer_task) = &self.writer_task {
211            return writer_task
212                .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
213                .await;
214        }
215
216        let pool = Arc::clone(&self.pool);
217        tokio::task::spawn_blocking(move || {
218            let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
219            f(guard.conn()).map_err(|e| map_err(e, op))
220        })
221        .await
222        .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
223    }
224
225    /// Like [`Self::with_writer`], but for callers whose closure issues more
226    /// than one DML statement that must land atomically together (khive
227    /// #827): a single-note insert immediately followed by
228    /// `assign_note_seq`. On the flag-on path the WriterTask already wraps
229    /// every request in its own `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK`, so `f`
230    /// is sent unwrapped, same as `with_writer`. On the flag-off (pool-mutex)
231    /// path, `with_writer` runs `f` in SQLite's default autocommit mode --
232    /// each statement inside `f` is its own implicit transaction -- so a
233    /// crash or interleaving between the insert and the sequence assignment
234    /// can strand a note that `comm.probe`'s `INNER JOIN notes_seq` will
235    /// never see again. This wraps that path in one explicit transaction,
236    /// matching `upsert_notes`' own flag-off branch.
237    async fn with_writer_tx<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
238    where
239        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
240        R: Send + 'static,
241    {
242        if let Some(writer_task) = &self.writer_task {
243            return writer_task
244                .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
245                .await;
246        }
247
248        let pool = Arc::clone(&self.pool);
249        tokio::task::spawn_blocking(move || {
250            let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
251            let conn = guard.conn();
252            conn.execute_batch("BEGIN IMMEDIATE")
253                .map_err(|e| map_err(e, op))?;
254
255            match f(conn) {
256                Ok(value) => match conn.execute_batch("COMMIT") {
257                    Ok(()) => Ok(value),
258                    Err(e) => {
259                        let _ = conn.execute_batch("ROLLBACK");
260                        Err(map_err(e, op))
261                    }
262                },
263                Err(e) => {
264                    let _ = conn.execute_batch("ROLLBACK");
265                    Err(map_err(e, op))
266                }
267            }
268        })
269        .await
270        .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
271    }
272
273    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
274    where
275        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
276        R: Send + 'static,
277    {
278        if self.is_file_backed {
279            let conn = self.open_standalone_reader()?;
280            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
281                .await
282                .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
283        } else {
284            let pool = Arc::clone(&self.pool);
285            tokio::task::spawn_blocking(move || {
286                let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
287                f(guard.conn()).map_err(|e| map_err(e, op))
288            })
289            .await
290            .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
291        }
292    }
293}
294
295// =============================================================================
296// Helpers
297// =============================================================================
298
299fn read_note(row: &rusqlite::Row<'_>) -> Result<Note, rusqlite::Error> {
300    let id_str: String = row.get(0)?;
301    let namespace: String = row.get(1)?;
302    let kind: String = row.get(2)?;
303    let status: String = row.get(3)?;
304    let name: Option<String> = row.get(4)?;
305    let content: String = row.get(5)?;
306    let salience: Option<f64> = row.get(6)?;
307    let decay_factor: Option<f64> = row.get(7)?;
308    let expires_at: Option<i64> = row.get(8)?;
309    let properties_str: Option<String> = row.get(9)?;
310    let created_at: i64 = row.get(10)?;
311    let updated_at: i64 = row.get(11)?;
312    let deleted_at: Option<i64> = row.get(12)?;
313
314    let id = parse_uuid(&id_str)?;
315
316    let properties = properties_str
317        .map(|s| {
318            serde_json::from_str(&s).map_err(|e| {
319                rusqlite::Error::FromSqlConversionFailure(
320                    9,
321                    rusqlite::types::Type::Text,
322                    Box::new(e),
323                )
324            })
325        })
326        .transpose()?;
327
328    Ok(Note {
329        id,
330        namespace,
331        kind,
332        status,
333        name,
334        content,
335        salience,
336        decay_factor,
337        expires_at,
338        properties,
339        created_at,
340        updated_at,
341        deleted_at,
342    })
343}
344
345fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
346    Uuid::parse_str(s).map_err(|e| {
347        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
348    })
349}
350
351/// DML-only batch upsert loop shared by both the legacy (flag-off) and
352/// WriterTask-routed (flag-on) `upsert_notes` paths (ADR-067 Component A).
353///
354/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
355/// enclosing transaction. Per-row failures are captured into
356/// `BatchWriteSummary::failed`/`first_error` rather than aborting the loop,
357/// matching the existing partial-success contract.
358fn batch_upsert_notes(
359    conn: &rusqlite::Connection,
360    notes: &[Note],
361    attempted: u64,
362) -> Result<BatchWriteSummary, rusqlite::Error> {
363    let mut affected = 0u64;
364    let mut failed = 0u64;
365    let mut first_error = String::new();
366
367    for note in notes {
368        let id_str = note.id.to_string();
369        let kind_str = note.kind.to_string();
370        let status_str = note.status.clone();
371        let properties_str = note
372            .properties
373            .as_ref()
374            .map(|v| serde_json::to_string(v).unwrap_or_default());
375
376        match conn.execute(
377            "INSERT OR REPLACE INTO notes \
378             (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
379              properties, created_at, updated_at, deleted_at) \
380             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
381            rusqlite::params![
382                id_str,
383                &note.namespace,
384                kind_str,
385                status_str,
386                &note.name,
387                note.content,
388                note.salience,
389                note.decay_factor,
390                note.expires_at,
391                properties_str,
392                note.created_at,
393                note.updated_at,
394                note.deleted_at,
395            ],
396        ) {
397            Ok(_) => {
398                assign_note_seq(conn, &id_str)?;
399                affected += 1;
400            }
401            Err(e) => {
402                if first_error.is_empty() {
403                    first_error = e.to_string();
404                }
405                failed += 1;
406            }
407        }
408    }
409
410    Ok(BatchWriteSummary {
411        attempted,
412        affected,
413        failed,
414        first_error,
415    })
416}
417
418/// Assign a note id its durable, non-reusing sequence number the first time
419/// it is inserted (khive #827 — see `sql/007-notes-seq.sql`). `INSERT OR
420/// IGNORE` makes this idempotent across an `INSERT OR REPLACE`
421/// delete+reinsert of the same note id: the sequence value is fixed at the
422/// note's first insert and never reassigned, unlike `notes`' own implicit
423/// rowid.
424fn assign_note_seq(conn: &rusqlite::Connection, note_id: &str) -> Result<(), rusqlite::Error> {
425    conn.execute(
426        "INSERT OR IGNORE INTO notes_seq (note_id) VALUES (?1)",
427        rusqlite::params![note_id],
428    )?;
429    Ok(())
430}
431
432fn build_note_where(
433    namespace: &str,
434    kind: Option<&str>,
435) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
436    let mut conditions: Vec<String> = vec![
437        "namespace = ?1".to_string(),
438        "deleted_at IS NULL".to_string(),
439    ];
440    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(namespace.to_string())];
441
442    if let Some(k) = kind {
443        params.push(Box::new(k.to_string()));
444        conditions.push(format!("kind = ?{}", params.len()));
445    }
446
447    let clause = format!(" WHERE {}", conditions.join(" AND "));
448    (clause, params)
449}
450
451/// Validate that a json_path is safe to interpolate into SQL.
452/// Accepts only `$.field` or `$.field.subfield` paths with alphanumeric/underscore segments.
453fn validate_json_path(path: &str) -> Result<(), StorageError> {
454    let valid = path.starts_with("$.")
455        && path[2..].split('.').all(|part| {
456            !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
457        });
458    if valid {
459        Ok(())
460    } else {
461        Err(StorageError::InvalidInput {
462            capability: StorageCapability::Notes,
463            operation: "query_notes_filtered".into(),
464            message: format!("invalid JSON path for note filter: {path:?}"),
465        })
466    }
467}
468
469fn json_extract_expr(path: &str) -> String {
470    format!("json_extract(properties, '{path}')")
471}
472
473fn json_type_expr(path: &str) -> String {
474    format!("json_type(properties, '{path}')")
475}
476
477fn sql_value_param(value: &SqlValue) -> Result<Box<dyn rusqlite::types::ToSql>, rusqlite::Error> {
478    Ok(match value {
479        SqlValue::Null => Box::new(Option::<String>::None),
480        SqlValue::Bool(v) => Box::new(*v as i64),
481        SqlValue::Integer(v) => Box::new(*v),
482        SqlValue::Float(v) => Box::new(*v),
483        SqlValue::Text(v) => Box::new(v.clone()),
484        SqlValue::Blob(v) => Box::new(v.clone()),
485        SqlValue::Json(v) => Box::new(
486            serde_json::to_string(v)
487                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
488        ),
489        SqlValue::Uuid(v) => Box::new(v.to_string()),
490        SqlValue::Timestamp(v) => Box::new(v.timestamp_micros()),
491    })
492}
493
494fn build_note_filter_where(
495    namespace: &str,
496    filter: &NoteFilter,
497) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
498    // When filter.namespaces is non-empty use `namespace IN (...)` for
499    // multi-namespace read visibility. Otherwise fall back to equality.
500    let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
501        if !filter.namespaces.is_empty() {
502            let placeholders: Vec<String> = (1..=filter.namespaces.len())
503                .map(|i| format!("?{i}"))
504                .collect();
505            let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
506                .namespaces
507                .iter()
508                .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
509                .collect();
510            (
511                format!("namespace IN ({})", placeholders.join(", ")),
512                params,
513            )
514        } else {
515            (
516                "namespace = ?1".to_string(),
517                vec![Box::new(namespace.to_string())],
518            )
519        };
520
521    let mut conditions = vec![ns_condition, "deleted_at IS NULL".to_string()];
522    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
523
524    if let Some(kind) = &filter.kind {
525        params.push(Box::new(kind.clone()));
526        conditions.push(format!("kind = ?{}", params.len()));
527    }
528
529    for pf in &filter.property_filters {
530        match &pf.op {
531            FilterOp::EqOrMissing => {
532                let expr = json_extract_expr(&pf.json_path);
533                params.push(sql_value_param(&pf.value)?);
534                conditions.push(format!(
535                    "({expr} = ?{n} OR {expr} IS NULL)",
536                    n = params.len()
537                ));
538            }
539            FilterOp::JsonTypeEq => {
540                let type_expr = json_type_expr(&pf.json_path);
541                params.push(sql_value_param(&pf.value)?);
542                conditions.push(format!("{type_expr} = ?{}", params.len()));
543            }
544            FilterOp::JsonTypeNeMissing => {
545                let type_expr = json_type_expr(&pf.json_path);
546                params.push(sql_value_param(&pf.value)?);
547                let n = params.len();
548                conditions.push(format!("({type_expr} IS NULL OR {type_expr} != ?{n})"));
549            }
550            FilterOp::In(values) => {
551                let expr = json_extract_expr(&pf.json_path);
552                if values.is_empty() {
553                    // An empty set can never match any row.
554                    conditions.push("0".to_string());
555                    continue;
556                }
557                let mut placeholders = Vec::with_capacity(values.len());
558                for v in values {
559                    params.push(sql_value_param(v)?);
560                    placeholders.push(format!("?{}", params.len()));
561                }
562                conditions.push(format!("{expr} IN ({})", placeholders.join(", ")));
563            }
564            FilterOp::NotInOrMissing(values) => {
565                let expr = json_extract_expr(&pf.json_path);
566                if values.is_empty() {
567                    // Nothing to exclude — every row (including missing) matches.
568                    continue;
569                }
570                let mut placeholders = Vec::with_capacity(values.len());
571                for v in values {
572                    params.push(sql_value_param(v)?);
573                    placeholders.push(format!("?{}", params.len()));
574                }
575                conditions.push(format!(
576                    "({expr} IS NULL OR {expr} NOT IN ({}))",
577                    placeholders.join(", ")
578                ));
579            }
580            _ => {
581                let expr = json_extract_expr(&pf.json_path);
582                let op = match pf.op {
583                    FilterOp::Eq => "=",
584                    FilterOp::Ne => "!=",
585                    FilterOp::Lt => "<",
586                    FilterOp::Lte => "<=",
587                    FilterOp::Gt => ">",
588                    FilterOp::Gte => ">=",
589                    FilterOp::EqOrMissing
590                    | FilterOp::JsonTypeEq
591                    | FilterOp::JsonTypeNeMissing
592                    | FilterOp::In(_)
593                    | FilterOp::NotInOrMissing(_) => {
594                        unreachable!()
595                    }
596                };
597                params.push(sql_value_param(&pf.value)?);
598                conditions.push(format!("{expr} {op} ?{}", params.len()));
599            }
600        }
601    }
602
603    if let Some(min_ts) = filter.min_created_at {
604        params.push(Box::new(min_ts));
605        conditions.push(format!("created_at >= ?{}", params.len()));
606    }
607
608    Ok((format!(" WHERE {}", conditions.join(" AND ")), params))
609}
610
611// =============================================================================
612// NoteStore implementation
613// =============================================================================
614
615#[async_trait]
616impl NoteStore for SqlNoteStore {
617    async fn upsert_note(&self, note: Note) -> Result<(), StorageError> {
618        let id_str = note.id.to_string();
619        let statement = note_upsert_statement(&note);
620        self.with_writer_tx("upsert_note", move |conn| {
621            let mut stmt = conn.prepare(&statement.sql)?;
622            bind_params(&mut stmt, &statement.params)?;
623            stmt.raw_execute()?;
624            assign_note_seq(conn, &id_str)?;
625            Ok(())
626        })
627        .await
628    }
629
630    async fn update_note_properties(
631        &self,
632        id: Uuid,
633        properties: Option<serde_json::Value>,
634        updated_at: i64,
635    ) -> Result<bool, StorageError> {
636        let statement = note_update_properties_statement(id, &properties, updated_at);
637        self.with_writer("update_note_properties", move |conn| {
638            let mut stmt = conn.prepare(&statement.sql)?;
639            bind_params(&mut stmt, &statement.params)?;
640            Ok(stmt.raw_execute()? > 0)
641        })
642        .await
643    }
644
645    async fn try_insert_note(&self, note: Note) -> Result<bool, StorageError> {
646        let namespace = note.namespace.clone();
647        let id_str = note.id.to_string();
648        let kind_str = note.kind.to_string();
649        let status_str = note.status.clone();
650        let properties_str = note
651            .properties
652            .as_ref()
653            .map(|v| serde_json::to_string(v).unwrap_or_default());
654
655        // Extract external_id (if any) for dedup verification after a zero-row insert.
656        let ext_id_opt: Option<String> = note
657            .properties
658            .as_ref()
659            .and_then(|v| v.get("external_id"))
660            .and_then(|v| v.as_str())
661            .filter(|s| !s.is_empty())
662            .map(|s| s.to_string());
663
664        self.with_writer_tx("try_insert_note", move |conn| {
665            let rows = conn.execute(
666                "INSERT OR IGNORE INTO notes \
667                 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
668                  properties, created_at, updated_at, deleted_at) \
669                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
670                rusqlite::params![
671                    id_str,
672                    namespace,
673                    kind_str,
674                    status_str,
675                    note.name,
676                    note.content,
677                    note.salience,
678                    note.decay_factor,
679                    note.expires_at,
680                    properties_str,
681                    note.created_at,
682                    note.updated_at,
683                    note.deleted_at,
684                ],
685            )?;
686
687            if rows > 0 {
688                assign_note_seq(conn, &id_str)?;
689                return Ok(true);
690            }
691
692            // Zero rows: the INSERT was silently skipped by OR IGNORE.
693            // Only treat this as a dedup hit when a live note with the same
694            // non-empty external_id already exists in this namespace and kind.
695            // Any other ignored constraint (e.g. a PRIMARY KEY collision) must
696            // surface as an error rather than being misreported as a duplicate.
697            if let Some(ref ext_id) = ext_id_opt {
698                let is_dedup: bool = conn.query_row(
699                    "SELECT COUNT(*) > 0 FROM notes \
700                     WHERE namespace = ?1 \
701                       AND kind = ?2 \
702                       AND json_extract(properties, '$.external_id') = ?3 \
703                       AND deleted_at IS NULL",
704                    rusqlite::params![namespace, kind_str, ext_id],
705                    |row| row.get(0),
706                )?;
707                if is_dedup {
708                    return Ok(false);
709                }
710            }
711
712            // The INSERT was dropped for a reason other than an external_id
713            // collision.  Surface it as a constraint error.
714            Err(rusqlite::Error::SqliteFailure(
715                rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
716                Some(
717                    "try_insert_note: INSERT ignored for a constraint other than \
718                     external_id dedup; not masking as deduplication"
719                        .to_string(),
720                ),
721            ))
722        })
723        .await
724    }
725
726    async fn upsert_notes(&self, notes: Vec<Note>) -> Result<BatchWriteSummary, StorageError> {
727        let attempted = notes.len() as u64;
728
729        // khive #827: route through `with_writer_tx`
730        // instead of hand-rolling BEGIN IMMEDIATE/COMMIT/ROLLBACK here. The
731        // old flag-off path only rolled back when the final COMMIT failed —
732        // an earlier error from `batch_upsert_notes` (e.g. a failed
733        // `assign_note_seq`) propagated via `?` straight out of the closure,
734        // skipping ROLLBACK entirely and leaving BEGIN IMMEDIATE open on the
735        // shared pool-mutex connection, poisoning every later write on that
736        // connection. `with_writer_tx` rolls back on ANY error from `f`, on
737        // both the flag-on (WriterTask, which wraps its own transaction) and
738        // flag-off (pool-mutex) paths.
739        self.with_writer_tx("upsert_notes", move |conn| {
740            let _tx_handle =
741                khive_storage::tx_registry::register(Some("note_upsert_batch".to_string()));
742            batch_upsert_notes(conn, &notes, attempted)
743        })
744        .await
745    }
746
747    async fn get_note(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
748        let id_str = id.to_string();
749
750        self.with_reader("get_note", move |conn| {
751            let mut stmt = conn.prepare(
752                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
753                 properties, created_at, updated_at, deleted_at \
754                 FROM notes WHERE id = ?1 AND deleted_at IS NULL",
755            )?;
756            let mut rows = stmt.query(rusqlite::params![id_str])?;
757            match rows.next()? {
758                Some(row) => Ok(Some(read_note(row)?)),
759                None => Ok(None),
760            }
761        })
762        .await
763    }
764
765    async fn get_note_including_deleted(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
766        let id_str = id.to_string();
767
768        self.with_reader("get_note_including_deleted", move |conn| {
769            let mut stmt = conn.prepare(
770                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
771                 properties, created_at, updated_at, deleted_at \
772                 FROM notes WHERE id = ?1",
773            )?;
774            let mut rows = stmt.query(rusqlite::params![id_str])?;
775            match rows.next()? {
776                Some(row) => Ok(Some(read_note(row)?)),
777                None => Ok(None),
778            }
779        })
780        .await
781    }
782
783    async fn get_notes_batch(&self, ids: &[Uuid]) -> Result<Vec<Note>, StorageError> {
784        if ids.is_empty() {
785            return Ok(vec![]);
786        }
787        // SQLite SQLITE_MAX_VARIABLE_NUMBER defaults to 999; chunk below that
788        // ceiling so callers can safely hydrate arbitrarily large ID sets.
789        const CHUNK: usize = 900;
790        let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
791
792        let mut result = Vec::with_capacity(ids.len());
793        for chunk in id_strings.chunks(CHUNK) {
794            let chunk_owned = chunk.to_vec();
795            let notes = self
796                .with_reader("get_notes_batch", move |conn| {
797                    let placeholders: String = (1..=chunk_owned.len())
798                        .map(|i| format!("?{i}"))
799                        .collect::<Vec<_>>()
800                        .join(", ");
801                    let sql = format!(
802                        "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
803                         properties, created_at, updated_at, deleted_at \
804                         FROM notes WHERE id IN ({placeholders}) AND deleted_at IS NULL"
805                    );
806                    let mut stmt = conn.prepare(&sql)?;
807                    let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
808                        .iter()
809                        .map(|s| s as &dyn rusqlite::types::ToSql)
810                        .collect();
811                    let rows = stmt.query_map(params.as_slice(), read_note)?;
812                    let mut notes = Vec::new();
813                    for row in rows {
814                        notes.push(row?);
815                    }
816                    Ok(notes)
817                })
818                .await?;
819            result.extend(notes);
820        }
821        Ok(result)
822    }
823
824    async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
825        match mode {
826            DeleteMode::Soft => {
827                let now = chrono::Utc::now().timestamp_micros();
828                let statement = note_soft_delete_statement(id, now);
829                self.with_writer("delete_note_soft", move |conn| {
830                    let mut stmt = conn.prepare(&statement.sql)?;
831                    bind_params(&mut stmt, &statement.params)?;
832                    Ok(stmt.raw_execute()? > 0)
833                })
834                .await
835            }
836            DeleteMode::Hard => {
837                let statement = note_hard_delete_statement(id);
838                self.with_writer("delete_note_hard", move |conn| {
839                    let mut stmt = conn.prepare(&statement.sql)?;
840                    bind_params(&mut stmt, &statement.params)?;
841                    Ok(stmt.raw_execute()? > 0)
842                })
843                .await
844            }
845        }
846    }
847
848    async fn query_notes(
849        &self,
850        namespace: &str,
851        kind: Option<&str>,
852        page: PageRequest,
853    ) -> Result<Page<Note>, StorageError> {
854        let namespace = namespace.to_string();
855        let kind = kind.map(|k| k.to_string());
856        let limit_i64 = i64::from(page.limit);
857        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
858            capability: StorageCapability::Notes,
859            operation: "query_notes".into(),
860            message: format!(
861                "PageRequest: offset must be <= i64::MAX, got {}",
862                page.offset
863            ),
864        })?;
865
866        self.with_reader("query_notes", move |conn| {
867            let (count_sql, count_params) = build_note_where(&namespace, kind.as_deref());
868            let total: i64 = {
869                let sql = format!("SELECT COUNT(*) FROM notes{}", count_sql);
870                let mut stmt = conn.prepare(&sql)?;
871                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
872                    count_params.iter().map(|p| p.as_ref()).collect();
873                stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
874            };
875
876            let (where_sql, mut data_params) = build_note_where(&namespace, kind.as_deref());
877            data_params.push(Box::new(limit_i64));
878            data_params.push(Box::new(offset_i64));
879
880            let limit_idx = data_params.len() - 1;
881            let offset_idx = data_params.len();
882
883            let data_sql = format!(
884                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
885                 properties, created_at, updated_at, deleted_at \
886                 FROM notes{} ORDER BY created_at DESC LIMIT ?{} OFFSET ?{}",
887                where_sql, limit_idx, offset_idx,
888            );
889
890            let mut stmt = conn.prepare(&data_sql)?;
891            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
892                data_params.iter().map(|p| p.as_ref()).collect();
893            let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
894
895            let mut items = Vec::new();
896            for row in rows {
897                items.push(row?);
898            }
899
900            Ok(Page {
901                items,
902                total: Some(total as u64),
903            })
904        })
905        .await
906    }
907
908    async fn query_notes_filtered(
909        &self,
910        namespace: &str,
911        filter: &NoteFilter,
912        page: PageRequest,
913    ) -> Result<Page<Note>, StorageError> {
914        // Validate paths before entering spawn_blocking (closures return rusqlite::Error).
915        for pf in &filter.property_filters {
916            validate_json_path(&pf.json_path)?;
917        }
918        if let Some((path, _)) = &filter.order_by {
919            validate_json_path(path)?;
920        }
921
922        let namespace = namespace.to_string();
923        let filter = filter.clone();
924        let limit_i64 = i64::from(page.limit);
925        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
926            capability: StorageCapability::Notes,
927            operation: "query_notes_filtered".into(),
928            message: format!(
929                "PageRequest: offset must be <= i64::MAX, got {}",
930                page.offset
931            ),
932        })?;
933
934        self.with_reader("query_notes_filtered", move |conn| {
935            let (count_sql, count_params) = build_note_filter_where(&namespace, &filter)?;
936            let total: i64 = {
937                let sql = format!("SELECT COUNT(*) FROM notes{}", count_sql);
938                let mut stmt = conn.prepare(&sql)?;
939                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
940                    count_params.iter().map(|p| p.as_ref()).collect();
941                stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
942            };
943
944            let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
945            data_params.push(Box::new(limit_i64));
946            data_params.push(Box::new(offset_i64));
947
948            let order_clause = match &filter.order_by {
949                Some((path, dir)) => {
950                    let dir_str = match dir {
951                        SortDir::Asc => "ASC",
952                        SortDir::Desc => "DESC",
953                    };
954                    format!(" ORDER BY {} {dir_str}", json_extract_expr(path))
955                }
956                None => " ORDER BY created_at DESC".to_string(),
957            };
958
959            let limit_idx = data_params.len() - 1;
960            let offset_idx = data_params.len();
961            let data_sql = format!(
962                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
963                 expires_at, properties, created_at, updated_at, deleted_at \
964                 FROM notes{}{order_clause} LIMIT ?{} OFFSET ?{}",
965                where_sql, limit_idx, offset_idx,
966            );
967
968            let mut stmt = conn.prepare(&data_sql)?;
969            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
970                data_params.iter().map(|p| p.as_ref()).collect();
971            let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
972
973            let mut items = Vec::new();
974            for row in rows {
975                items.push(row?);
976            }
977
978            Ok(Page {
979                items,
980                total: Some(total as u64),
981            })
982        })
983        .await
984    }
985
986    async fn query_notes_filtered_bounded(
987        &self,
988        namespace: &str,
989        filter: &NoteFilter,
990        max_rows: u32,
991    ) -> Result<Vec<Note>, StorageError> {
992        for pf in &filter.property_filters {
993            validate_json_path(&pf.json_path)?;
994        }
995        if let Some((path, _)) = &filter.order_by {
996            validate_json_path(path)?;
997        }
998
999        let namespace = namespace.to_string();
1000        let filter = filter.clone();
1001        let limit_i64 = i64::from(max_rows) + 1;
1002
1003        self.with_reader("query_notes_filtered_bounded", move |conn| {
1004            let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
1005            data_params.push(Box::new(limit_i64));
1006            let limit_idx = data_params.len();
1007
1008            // Tie-break on `id` in addition to the primary sort key so the
1009            // snapshot ordering is fully deterministic even when many rows
1010            // share the same `created_at` (or the same custom sort value).
1011            let order_clause = match &filter.order_by {
1012                Some((path, dir)) => {
1013                    let dir_str = match dir {
1014                        SortDir::Asc => "ASC",
1015                        SortDir::Desc => "DESC",
1016                    };
1017                    format!(" ORDER BY {} {dir_str}, id ASC", json_extract_expr(path))
1018                }
1019                None => " ORDER BY created_at DESC, id ASC".to_string(),
1020            };
1021
1022            let data_sql = format!(
1023                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1024                 expires_at, properties, created_at, updated_at, deleted_at \
1025                 FROM notes{where_sql}{order_clause} LIMIT ?{limit_idx}",
1026            );
1027
1028            let mut stmt = conn.prepare(&data_sql)?;
1029            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1030                data_params.iter().map(|p| p.as_ref()).collect();
1031            let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1032
1033            let mut items = Vec::new();
1034            for row in rows {
1035                items.push(row?);
1036            }
1037            Ok(items)
1038        })
1039        .await
1040    }
1041
1042    async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> Result<u64, StorageError> {
1043        let namespace = namespace.to_string();
1044        let kind = kind.map(|k| k.to_string());
1045
1046        self.with_reader("count_notes", move |conn| {
1047            let (where_sql, params) = build_note_where(&namespace, kind.as_deref());
1048            let sql = format!("SELECT COUNT(*) FROM notes{}", where_sql);
1049            let mut stmt = conn.prepare(&sql)?;
1050            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1051                params.iter().map(|p| p.as_ref()).collect();
1052            let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1053            Ok(count as u64)
1054        })
1055        .await
1056    }
1057}
1058
1059// =============================================================================
1060// DDL
1061// =============================================================================
1062
1063const NOTES_DDL: &str = include_str!("../../sql/notes-ddl.sql");
1064
1065/// Same anti-join repair as `sql/008-notes-seq-repair.sql` (the V8 forward
1066/// migration) -- shared via `include_str!` from that single source file
1067/// rather than duplicated as SQL text. `INSERT OR IGNORE` targets notes
1068/// still missing a `notes_seq` row specifically, so it is correct to run
1069/// against a fresh ledger, a partially populated one, or an already fully
1070/// repaired one.
1071const NOTES_SEQ_REPAIR_DDL: &str = include_str!("../../sql/008-notes-seq-repair.sql");
1072
1073pub(crate) fn ensure_notes_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1074    conn.execute_batch(NOTES_DDL)
1075}
1076
1077/// Anti-join backfill of `notes_seq` for any note still missing a row
1078/// (khive #827). Scans `notes` in full, so callers MUST gate this to
1079/// run at most once per backend/pool rather than on every store acquisition
1080/// (khive #827) -- see
1081/// `StorageBackend::notes_for_namespace`.
1082pub(crate) fn repair_notes_seq(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1083    conn.execute_batch(NOTES_SEQ_REPAIR_DDL)
1084}
1085
1086#[cfg(test)]
1087#[path = "note_tests.rs"]
1088mod tests;