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