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 Finding 2).
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 Finding 2): 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 Finding 2 (round 3): 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        let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
788
789        self.with_reader("get_notes_batch", move |conn| {
790            let placeholders: String = (1..=id_strings.len())
791                .map(|i| format!("?{i}"))
792                .collect::<Vec<_>>()
793                .join(", ");
794            let sql = format!(
795                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
796                 properties, created_at, updated_at, deleted_at \
797                 FROM notes WHERE id IN ({placeholders}) AND deleted_at IS NULL"
798            );
799            let mut stmt = conn.prepare(&sql)?;
800            let params: Vec<&dyn rusqlite::types::ToSql> = id_strings
801                .iter()
802                .map(|s| s as &dyn rusqlite::types::ToSql)
803                .collect();
804            let rows = stmt.query_map(params.as_slice(), read_note)?;
805            let mut out = Vec::new();
806            for row in rows {
807                out.push(row?);
808            }
809            Ok(out)
810        })
811        .await
812    }
813
814    async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
815        match mode {
816            DeleteMode::Soft => {
817                let now = chrono::Utc::now().timestamp_micros();
818                let statement = note_soft_delete_statement(id, now);
819                self.with_writer("delete_note_soft", move |conn| {
820                    let mut stmt = conn.prepare(&statement.sql)?;
821                    bind_params(&mut stmt, &statement.params)?;
822                    Ok(stmt.raw_execute()? > 0)
823                })
824                .await
825            }
826            DeleteMode::Hard => {
827                let statement = note_hard_delete_statement(id);
828                self.with_writer("delete_note_hard", move |conn| {
829                    let mut stmt = conn.prepare(&statement.sql)?;
830                    bind_params(&mut stmt, &statement.params)?;
831                    Ok(stmt.raw_execute()? > 0)
832                })
833                .await
834            }
835        }
836    }
837
838    async fn query_notes(
839        &self,
840        namespace: &str,
841        kind: Option<&str>,
842        page: PageRequest,
843    ) -> Result<Page<Note>, StorageError> {
844        let namespace = namespace.to_string();
845        let kind = kind.map(|k| k.to_string());
846        let limit_i64 = i64::from(page.limit);
847        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
848            capability: StorageCapability::Notes,
849            operation: "query_notes".into(),
850            message: format!(
851                "PageRequest: offset must be <= i64::MAX, got {}",
852                page.offset
853            ),
854        })?;
855
856        self.with_reader("query_notes", move |conn| {
857            let (count_sql, count_params) = build_note_where(&namespace, kind.as_deref());
858            let total: i64 = {
859                let sql = format!("SELECT COUNT(*) FROM notes{}", count_sql);
860                let mut stmt = conn.prepare(&sql)?;
861                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
862                    count_params.iter().map(|p| p.as_ref()).collect();
863                stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
864            };
865
866            let (where_sql, mut data_params) = build_note_where(&namespace, kind.as_deref());
867            data_params.push(Box::new(limit_i64));
868            data_params.push(Box::new(offset_i64));
869
870            let limit_idx = data_params.len() - 1;
871            let offset_idx = data_params.len();
872
873            let data_sql = format!(
874                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
875                 properties, created_at, updated_at, deleted_at \
876                 FROM notes{} ORDER BY created_at DESC LIMIT ?{} OFFSET ?{}",
877                where_sql, limit_idx, offset_idx,
878            );
879
880            let mut stmt = conn.prepare(&data_sql)?;
881            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
882                data_params.iter().map(|p| p.as_ref()).collect();
883            let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
884
885            let mut items = Vec::new();
886            for row in rows {
887                items.push(row?);
888            }
889
890            Ok(Page {
891                items,
892                total: Some(total as u64),
893            })
894        })
895        .await
896    }
897
898    async fn query_notes_filtered(
899        &self,
900        namespace: &str,
901        filter: &NoteFilter,
902        page: PageRequest,
903    ) -> Result<Page<Note>, StorageError> {
904        // Validate paths before entering spawn_blocking (closures return rusqlite::Error).
905        for pf in &filter.property_filters {
906            validate_json_path(&pf.json_path)?;
907        }
908        if let Some((path, _)) = &filter.order_by {
909            validate_json_path(path)?;
910        }
911
912        let namespace = namespace.to_string();
913        let filter = filter.clone();
914        let limit_i64 = i64::from(page.limit);
915        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
916            capability: StorageCapability::Notes,
917            operation: "query_notes_filtered".into(),
918            message: format!(
919                "PageRequest: offset must be <= i64::MAX, got {}",
920                page.offset
921            ),
922        })?;
923
924        self.with_reader("query_notes_filtered", move |conn| {
925            let (count_sql, count_params) = build_note_filter_where(&namespace, &filter)?;
926            let total: i64 = {
927                let sql = format!("SELECT COUNT(*) FROM notes{}", count_sql);
928                let mut stmt = conn.prepare(&sql)?;
929                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
930                    count_params.iter().map(|p| p.as_ref()).collect();
931                stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
932            };
933
934            let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
935            data_params.push(Box::new(limit_i64));
936            data_params.push(Box::new(offset_i64));
937
938            let order_clause = match &filter.order_by {
939                Some((path, dir)) => {
940                    let dir_str = match dir {
941                        SortDir::Asc => "ASC",
942                        SortDir::Desc => "DESC",
943                    };
944                    format!(" ORDER BY {} {dir_str}", json_extract_expr(path))
945                }
946                None => " ORDER BY created_at DESC".to_string(),
947            };
948
949            let limit_idx = data_params.len() - 1;
950            let offset_idx = data_params.len();
951            let data_sql = format!(
952                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
953                 expires_at, properties, created_at, updated_at, deleted_at \
954                 FROM notes{}{order_clause} LIMIT ?{} OFFSET ?{}",
955                where_sql, limit_idx, offset_idx,
956            );
957
958            let mut stmt = conn.prepare(&data_sql)?;
959            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
960                data_params.iter().map(|p| p.as_ref()).collect();
961            let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
962
963            let mut items = Vec::new();
964            for row in rows {
965                items.push(row?);
966            }
967
968            Ok(Page {
969                items,
970                total: Some(total as u64),
971            })
972        })
973        .await
974    }
975
976    async fn query_notes_filtered_bounded(
977        &self,
978        namespace: &str,
979        filter: &NoteFilter,
980        max_rows: u32,
981    ) -> Result<Vec<Note>, StorageError> {
982        for pf in &filter.property_filters {
983            validate_json_path(&pf.json_path)?;
984        }
985        if let Some((path, _)) = &filter.order_by {
986            validate_json_path(path)?;
987        }
988
989        let namespace = namespace.to_string();
990        let filter = filter.clone();
991        let limit_i64 = i64::from(max_rows) + 1;
992
993        self.with_reader("query_notes_filtered_bounded", move |conn| {
994            let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
995            data_params.push(Box::new(limit_i64));
996            let limit_idx = data_params.len();
997
998            // Tie-break on `id` in addition to the primary sort key so the
999            // snapshot ordering is fully deterministic even when many rows
1000            // share the same `created_at` (or the same custom sort value).
1001            let order_clause = match &filter.order_by {
1002                Some((path, dir)) => {
1003                    let dir_str = match dir {
1004                        SortDir::Asc => "ASC",
1005                        SortDir::Desc => "DESC",
1006                    };
1007                    format!(" ORDER BY {} {dir_str}, id ASC", json_extract_expr(path))
1008                }
1009                None => " ORDER BY created_at DESC, id ASC".to_string(),
1010            };
1011
1012            let data_sql = format!(
1013                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1014                 expires_at, properties, created_at, updated_at, deleted_at \
1015                 FROM notes{where_sql}{order_clause} LIMIT ?{limit_idx}",
1016            );
1017
1018            let mut stmt = conn.prepare(&data_sql)?;
1019            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1020                data_params.iter().map(|p| p.as_ref()).collect();
1021            let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1022
1023            let mut items = Vec::new();
1024            for row in rows {
1025                items.push(row?);
1026            }
1027            Ok(items)
1028        })
1029        .await
1030    }
1031
1032    async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> Result<u64, StorageError> {
1033        let namespace = namespace.to_string();
1034        let kind = kind.map(|k| k.to_string());
1035
1036        self.with_reader("count_notes", move |conn| {
1037            let (where_sql, params) = build_note_where(&namespace, kind.as_deref());
1038            let sql = format!("SELECT COUNT(*) FROM notes{}", where_sql);
1039            let mut stmt = conn.prepare(&sql)?;
1040            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1041                params.iter().map(|p| p.as_ref()).collect();
1042            let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1043            Ok(count as u64)
1044        })
1045        .await
1046    }
1047}
1048
1049// =============================================================================
1050// DDL
1051// =============================================================================
1052
1053const NOTES_DDL: &str = include_str!("../../sql/notes-ddl.sql");
1054
1055/// Same anti-join repair as `sql/008-notes-seq-repair.sql` (the V8 forward
1056/// migration) -- shared via `include_str!` from that single source file
1057/// rather than duplicated as SQL text. `INSERT OR IGNORE` targets notes
1058/// still missing a `notes_seq` row specifically, so it is correct to run
1059/// against a fresh ledger, a partially populated one, or an already fully
1060/// repaired one.
1061const NOTES_SEQ_REPAIR_DDL: &str = include_str!("../../sql/008-notes-seq-repair.sql");
1062
1063pub(crate) fn ensure_notes_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1064    conn.execute_batch(NOTES_DDL)
1065}
1066
1067/// Anti-join backfill of `notes_seq` for any note still missing a row
1068/// (khive #827 round 3). Scans `notes` in full, so callers MUST gate this to
1069/// run at most once per backend/pool rather than on every store acquisition
1070/// (khive #827 round 4 perf finding) -- see
1071/// `StorageBackend::notes_for_namespace`.
1072pub(crate) fn repair_notes_seq(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1073    conn.execute_batch(NOTES_SEQ_REPAIR_DDL)
1074}
1075
1076#[cfg(test)]
1077#[path = "note_tests.rs"]
1078mod tests;