Skip to main content

khive_db/stores/
note.rs

1//! SQL-backed `NoteStore` implementation.
2
3use std::collections::HashSet;
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use rusqlite::OptionalExtension;
8use uuid::Uuid;
9
10use khive_storage::attachment::AttachmentSubstrate;
11use khive_storage::error::{StorageError, WriterTaskRequestState};
12use khive_storage::note::{FilterOp, Note, NoteFilter, SortDir};
13use khive_storage::types::{
14    BatchWriteSummary, DeleteMode, Page, PageRequest, SeekCursor, SeekPage, SqlStatement, SqlValue,
15};
16use khive_storage::NoteStore;
17use khive_storage::StorageCapability;
18
19use crate::error::SqliteError;
20use crate::pool::ConnectionPool;
21use crate::sql_bridge::bind_params;
22use crate::stores::attachment::delete_record_attachments_statement;
23use crate::writer_task::{execute_wrapped_transaction, WriterTaskHandle};
24
25fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
26    StorageError::driver(StorageCapability::Notes, op, e)
27}
28
29fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
30    StorageError::driver(StorageCapability::Notes, op, e)
31}
32
33const NAMESPACE_COUNT_CHUNK_SIZE: usize = 500;
34
35// ---------------------------------------------------------------------------
36// Pure statement builders (ADR-099 B3 r6 structural cut) — see entity.rs's
37// sibling block for the full rationale. `upsert_note`/`delete_note` below
38// and ADR-099's atomic prepare path (`khive-runtime`) both call these.
39// ---------------------------------------------------------------------------
40
41/// The single true UPSERT every note writer (single, batch, and note-merge)
42/// issues against `notes` (ADR-116 memory-ANN-generation-coherence prereq).
43///
44/// `INSERT OR REPLACE` is a SQLite DELETE-then-INSERT on a conflicting `id`:
45/// it fires DELETE-path triggers (spuriously invalidating ANN generation
46/// state once ADR-116's liveness triggers land) and discards the row's
47/// original `created_at`/rowid identity. This form updates in place on a
48/// primary-key conflict instead. `created_at` is deliberately absent from
49/// the `DO UPDATE SET` list — it is bound for the INSERT branch only and
50/// left untouched by the UPDATE branch, so an existing row keeps its
51/// original `created_at` across any number of upserts.
52pub const NOTE_UPSERT_SQL: &str = "INSERT INTO notes \
53     (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
54      properties, created_at, updated_at, deleted_at) \
55     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
56     ON CONFLICT(id) DO UPDATE SET \
57       namespace = excluded.namespace, \
58       kind = excluded.kind, \
59       status = excluded.status, \
60       name = excluded.name, \
61       content = excluded.content, \
62       salience = excluded.salience, \
63       decay_factor = excluded.decay_factor, \
64       expires_at = excluded.expires_at, \
65       properties = excluded.properties, \
66       updated_at = excluded.updated_at, \
67       deleted_at = excluded.deleted_at";
68
69/// Insert-if-absent counterpart to [`NOTE_UPSERT_SQL`], for a caller whose
70/// read found no row.
71///
72/// Identical column list and parameter order, and deliberately
73/// `DO NOTHING` rather than `DO UPDATE`: the point is that a row already
74/// present must survive untouched, so the losing caller can be told it lost
75/// instead of silently overwriting the winner. `changes()` is then the
76/// answer to "did I insert it", which `DO UPDATE` cannot report.
77pub const NOTE_INSERT_IF_ABSENT_SQL: &str = "INSERT INTO notes \
78     (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
79      properties, created_at, updated_at, deleted_at) \
80     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
81     ON CONFLICT(id) DO NOTHING";
82
83/// The exact statement this store's `insert_note_if_absent` issues.
84pub fn note_insert_if_absent_statement(note: &Note) -> SqlStatement {
85    let mut statement = note_upsert_statement(note);
86    statement.sql = NOTE_INSERT_IF_ABSENT_SQL.to_string();
87    statement.label = Some("note-insert-if-absent".to_string());
88    statement
89}
90
91/// The exact true UPSERT this store's `upsert_note` issues.
92pub fn note_upsert_statement(note: &Note) -> SqlStatement {
93    let properties_str = note
94        .properties
95        .as_ref()
96        .map(|v| serde_json::to_string(v).unwrap_or_default());
97    SqlStatement {
98        sql: NOTE_UPSERT_SQL.to_string(),
99        params: vec![
100            SqlValue::Text(note.id.to_string()),
101            SqlValue::Text(note.namespace.clone()),
102            SqlValue::Text(note.kind.to_string()),
103            SqlValue::Text(note.status.clone()),
104            match &note.name {
105                Some(n) => SqlValue::Text(n.clone()),
106                None => SqlValue::Null,
107            },
108            SqlValue::Text(note.content.clone()),
109            match note.salience {
110                Some(s) => SqlValue::Float(s),
111                None => SqlValue::Null,
112            },
113            match note.decay_factor {
114                Some(d) => SqlValue::Float(d),
115                None => SqlValue::Null,
116            },
117            match note.expires_at {
118                Some(e) => SqlValue::Integer(e),
119                None => SqlValue::Null,
120            },
121            match properties_str {
122                Some(p) => SqlValue::Text(p),
123                None => SqlValue::Null,
124            },
125            SqlValue::Integer(note.created_at),
126            SqlValue::Integer(note.updated_at),
127            match note.deleted_at {
128                Some(d) => SqlValue::Integer(d),
129                None => SqlValue::Null,
130            },
131        ],
132        label: Some("note-upsert".to_string()),
133    }
134}
135
136/// Full-note compare-and-swap update used after caller-side normalization was
137/// derived from a read snapshot. Unlike [`note_upsert_statement`], this never
138/// inserts and cannot overwrite a row whose revision or deletion marker moved
139/// after the snapshot was read. The replacement revision must also be strictly
140/// greater than the persisted snapshot revision; equality is a refused CAS,
141/// never a successful write with an unchanged concurrency token.
142pub fn note_replace_if_unchanged_statement(
143    note: &Note,
144    expected_updated_at: i64,
145    expected_deleted_at: Option<i64>,
146) -> SqlStatement {
147    let properties_str = note
148        .properties
149        .as_ref()
150        .map(|v| serde_json::to_string(v).unwrap_or_default());
151    SqlStatement {
152        sql: "UPDATE notes SET \
153                namespace = ?1, kind = ?2, status = ?3, name = ?4, content = ?5, \
154                salience = ?6, decay_factor = ?7, expires_at = ?8, properties = ?9, \
155                updated_at = ?10, deleted_at = ?11 \
156              WHERE id = ?12 AND updated_at = ?13 AND deleted_at IS ?14 \
157                AND ?10 > updated_at"
158            .to_string(),
159        params: vec![
160            SqlValue::Text(note.namespace.clone()),
161            SqlValue::Text(note.kind.to_string()),
162            SqlValue::Text(note.status.clone()),
163            match &note.name {
164                Some(name) => SqlValue::Text(name.clone()),
165                None => SqlValue::Null,
166            },
167            SqlValue::Text(note.content.clone()),
168            match note.salience {
169                Some(value) => SqlValue::Float(value),
170                None => SqlValue::Null,
171            },
172            match note.decay_factor {
173                Some(value) => SqlValue::Float(value),
174                None => SqlValue::Null,
175            },
176            match note.expires_at {
177                Some(value) => SqlValue::Integer(value),
178                None => SqlValue::Null,
179            },
180            match properties_str {
181                Some(value) => SqlValue::Text(value),
182                None => SqlValue::Null,
183            },
184            SqlValue::Integer(note.updated_at),
185            match note.deleted_at {
186                Some(value) => SqlValue::Integer(value),
187                None => SqlValue::Null,
188            },
189            SqlValue::Text(note.id.to_string()),
190            SqlValue::Integer(expected_updated_at),
191            match expected_deleted_at {
192                Some(value) => SqlValue::Integer(value),
193                None => SqlValue::Null,
194            },
195        ],
196        label: Some("note-replace-if-unchanged".to_string()),
197    }
198}
199
200/// The exact `properties`/`updated_at` `UPDATE` this store's
201/// `update_note_properties` issues. The row is patched in place without
202/// rewriting any other note column or its stable row identity (#780).
203/// The `comm.probe` cursor is keyed on `notes_seq.seq`, which is fixed at
204/// first insert and survives a delete+reinsert of the same note id, so this
205/// is defensive rather than load-bearing for cursor correctness; a metadata
206/// patch should never rewrite the row regardless.
207pub fn note_update_properties_statement(
208    id: Uuid,
209    properties: &Option<serde_json::Value>,
210    updated_at: i64,
211) -> SqlStatement {
212    let properties_str = properties
213        .as_ref()
214        .map(|v| serde_json::to_string(v).unwrap_or_default());
215    SqlStatement {
216        sql: "UPDATE notes SET properties = ?1, updated_at = ?2 \
217              WHERE id = ?3 AND deleted_at IS NULL"
218            .to_string(),
219        params: vec![
220            match properties_str {
221                Some(p) => SqlValue::Text(p),
222                None => SqlValue::Null,
223            },
224            SqlValue::Integer(updated_at),
225            SqlValue::Text(id.to_string()),
226        ],
227        label: Some("note-update-properties".to_string()),
228    }
229}
230
231/// The atomic top-level JSON-property `UPDATE` issued by
232/// [`NoteStore::set_note_property`]. The key is encoded as one quoted JSON
233/// path segment, so punctuation is literal rather than interpreted as nested
234/// path syntax. `json(?2)` preserves the bound value's JSON type instead of
235/// storing objects, arrays, booleans, or numbers as JSON strings.
236///
237/// SQL-NULL documents start from `{}`. JSON arrays/scalars/null are not
238/// property objects and are left untouched; the caller receives `false`.
239pub fn note_set_property_statement(
240    id: Uuid,
241    key: &str,
242    value: &serde_json::Value,
243    updated_at: i64,
244) -> Result<SqlStatement, StorageError> {
245    // SQLite JSON-path object labels terminate at U+0000. Passing such a key
246    // through `json_set` can therefore select a shorter sibling key instead
247    // of the requested literal key, so reject it before constructing SQL.
248    if key.contains('\0') {
249        return Err(StorageError::InvalidInput {
250            capability: StorageCapability::Notes,
251            operation: "set_note_property".into(),
252            message: "property key must not contain U+0000".to_string(),
253        });
254    }
255    let path = format!("$.{}", serde_json::Value::String(key.to_string()));
256    Ok(SqlStatement {
257        sql: "UPDATE notes \
258              SET properties = json_set(COALESCE(properties, '{}'), ?1, json(?2)), \
259                  updated_at = ?3 \
260              WHERE id = ?4 AND deleted_at IS NULL \
261                AND (properties IS NULL OR json_type(properties) = 'object')"
262            .to_string(),
263        params: vec![
264            SqlValue::Text(path),
265            SqlValue::Text(value.to_string()),
266            SqlValue::Integer(updated_at),
267            SqlValue::Text(id.to_string()),
268        ],
269        label: Some("note-set-property".to_string()),
270    })
271}
272
273/// The exact soft-delete `UPDATE` this store's `delete_note(Soft)` issues.
274pub fn note_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
275    SqlStatement {
276        sql: "UPDATE notes SET status = 'deleted', deleted_at = ?1 \
277              WHERE id = ?2 AND deleted_at IS NULL"
278            .to_string(),
279        params: vec![
280            SqlValue::Integer(deleted_at),
281            SqlValue::Text(id.to_string()),
282        ],
283        label: Some("note-delete-soft".to_string()),
284    }
285}
286
287/// The exact hard-delete `DELETE` this store's `delete_note(Hard)` issues.
288pub fn note_hard_delete_statement(id: Uuid) -> SqlStatement {
289    SqlStatement {
290        sql: "DELETE FROM notes WHERE id = ?1".to_string(),
291        params: vec![SqlValue::Text(id.to_string())],
292        label: Some("note-delete-hard".to_string()),
293    }
294}
295
296/// A NoteStore backed by SQLite. Namespace is the caller's responsibility.
297///
298/// UUID is globally unique — get/delete by ID alone. Query/count use the
299/// namespace parameter as passed. The store is just a pool + is_file_backed.
300pub struct SqlNoteStore {
301    pool: Arc<ConnectionPool>,
302    is_file_backed: bool,
303    writer_task: Option<WriterTaskHandle>,
304}
305
306impl SqlNoteStore {
307    /// Create a new store.
308    pub fn new(pool: Arc<ConnectionPool>, is_file_backed: bool) -> Self {
309        // Enabled by default for file-backed pools; explicit off/degraded
310        // fallback remains possible (ADR-067 Component A, mirrors
311        // entity.rs policy): a missing writer task — explicitly disabled,
312        // spawn degraded, or no Tokio runtime available at this first
313        // access — is cached without failing construction. Every write
314        // re-resolves it and applies strict/compatibility policy then.
315        let writer_task = pool.writer_task_handle().ok().flatten();
316
317        Self {
318            pool,
319            is_file_backed,
320            writer_task,
321        }
322    }
323
324    fn current_writer_task(
325        &self,
326        operation: &'static str,
327    ) -> Result<Option<WriterTaskHandle>, StorageError> {
328        self.pool
329            .writer_task_for_write(self.writer_task.as_ref(), operation)
330    }
331
332    /// Route a single-row write through the pool-wide `WriterTask` when
333    /// the write queue is enabled and a handle is available. Strict mode
334    /// refuses a missing handle; compatibility mode falls back to the legacy
335    /// pool-mutex path (ADR-067 Component A, Fork C slice 2).
336    ///
337    /// This is the routing point for single-statement `with_writer` callers
338    /// in this store (`update_note_properties`, `set_note_property`,
339    /// `delete_note`). `f` must be DML-only — on the flag-on path it runs
340    /// inside the WriterTask's own transaction, so a bare `BEGIN IMMEDIATE`
341    /// would violate SQLite's nested-transaction rule. `upsert_notes` (the
342    /// batch method) performs the same write-time lookup first; a non-strict
343    /// `None` then falls through this helper, which records the actual
344    /// compatibility fallback. Strict mode returns before the direct-writer
345    /// seam. Callers whose `f` issues more than one
346    /// DML statement that must land atomically together (`upsert_note`,
347    /// `try_insert_note`, `patch_note_property_atomic`) use
348    /// [`Self::with_writer_tx`] instead — see its doc comment (khive #827,
349    /// #1387).
350    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
351    where
352        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
353        R: Send + 'static,
354    {
355        if let Some(writer_task) = self.current_writer_task(op)? {
356            return writer_task
357                .send_bounded(move |conn| f(conn).map_err(|e| map_err(e, op)))
358                .await;
359        }
360
361        self.pool
362            .record_direct_route(crate::timeout_sink::Site::DirectRouteNote);
363        let pool = Arc::clone(&self.pool);
364        tokio::task::spawn_blocking(move || {
365            let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
366            f(guard.conn()).map_err(|e| map_err(e, op))
367        })
368        .await
369        .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
370    }
371
372    /// Like [`Self::with_writer`], but for callers whose closure issues more
373    /// than one DML statement that must land atomically together (khive
374    /// #827, #1387), such as a note insert plus `assign_note_seq` or a guarded
375    /// property patch across several notes. On the flag-on path the
376    /// WriterTask already wraps every request in its own `BEGIN
377    /// IMMEDIATE`/`COMMIT`/`ROLLBACK`, so `f` is sent unwrapped, same as
378    /// `with_writer`. On the flag-off (pool-mutex) path, `with_writer` runs
379    /// `f` in SQLite's default autocommit mode -- each statement inside `f`
380    /// is its own implicit transaction -- so a later failure could leave an
381    /// earlier statement committed. This wraps that path in one explicit
382    /// transaction, matching `upsert_notes`' own flag-off branch.
383    async fn with_writer_tx<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
384    where
385        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
386        R: Send + 'static,
387    {
388        self.with_writer_tx_storage(op, move |conn| f(conn).map_err(|error| map_err(error, op)))
389            .await
390    }
391
392    async fn with_writer_tx_storage<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
393    where
394        F: FnOnce(&rusqlite::Connection) -> Result<R, StorageError> + Send + 'static,
395        R: Send + 'static,
396    {
397        if let Some(writer_task) = self.current_writer_task(op)? {
398            return writer_task.send_bounded(f).await;
399        }
400
401        self.pool
402            .record_direct_route(crate::timeout_sink::Site::DirectRouteNote);
403        let pool = Arc::clone(&self.pool);
404        tokio::task::spawn_blocking(move || {
405            let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
406            let conn = guard.conn();
407            if !conn.is_autocommit() {
408                pool.retire_pooled_writer(conn);
409                return Err(StorageError::WriterTaskTerminated {
410                    request_state: WriterTaskRequestState::SideEffectsUnknown,
411                });
412            }
413            if let Err(begin_error) = conn.execute_batch("BEGIN IMMEDIATE") {
414                if !conn.is_autocommit() {
415                    pool.retire_pooled_writer(conn);
416                    return Err(StorageError::WriterTaskTerminated {
417                        request_state: WriterTaskRequestState::SideEffectsUnknown,
418                    });
419                }
420                return Err(map_err(begin_error, op));
421            }
422
423            let (result, terminal_state) = execute_wrapped_transaction(conn, op, f);
424            if terminal_state.is_some() {
425                pool.retire_pooled_writer(conn);
426            }
427            result
428        })
429        .await
430        .map_err(|e| StorageError::driver(StorageCapability::Notes, op, e))?
431    }
432
433    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
434    where
435        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
436        R: Send + 'static,
437    {
438        if self.is_file_backed {
439            let pool = Arc::clone(&self.pool);
440            crate::read_cancellation::run_declared_interruptible_read(
441                StorageCapability::Notes,
442                op,
443                move |scope| {
444                    scope.ensure_active()?;
445                    let conn = pool
446                        .open_standalone_reader()
447                        .map_err(|error| map_sqlite_err(error, op))?;
448                    scope.run(&conn, || f(&conn).map_err(|e| map_err(e, op)))
449                },
450            )
451            .await
452        } else {
453            let pool = Arc::clone(&self.pool);
454            crate::read_cancellation::run_declared_interruptible_read(
455                StorageCapability::Notes,
456                op,
457                move |scope| {
458                    let mut guard = pool.resolve_reader_checkout(
459                        StorageCapability::Notes,
460                        op,
461                        pool.reader_until(|| scope.should_stop()),
462                    )?;
463                    scope.run_pooled_reader(&mut guard, |conn| f(conn).map_err(|e| map_err(e, op)))
464                },
465            )
466            .await
467        }
468    }
469}
470
471// =============================================================================
472// Helpers
473// =============================================================================
474
475fn read_note(row: &rusqlite::Row<'_>) -> Result<Note, rusqlite::Error> {
476    let id_str: String = row.get(0)?;
477    let namespace: String = row.get(1)?;
478    let kind: String = row.get(2)?;
479    let status: String = row.get(3)?;
480    let name: Option<String> = row.get(4)?;
481    let content: String = row.get(5)?;
482    let salience: Option<f64> = row.get(6)?;
483    let decay_factor: Option<f64> = row.get(7)?;
484    let expires_at: Option<i64> = row.get(8)?;
485    let properties_str: Option<String> = row.get(9)?;
486    let created_at: i64 = row.get(10)?;
487    let updated_at: i64 = row.get(11)?;
488    let deleted_at: Option<i64> = row.get(12)?;
489
490    let id = parse_uuid(&id_str)?;
491
492    let properties = properties_str
493        .map(|s| {
494            serde_json::from_str(&s).map_err(|e| {
495                rusqlite::Error::FromSqlConversionFailure(
496                    9,
497                    rusqlite::types::Type::Text,
498                    Box::new(e),
499                )
500            })
501        })
502        .transpose()?;
503
504    Ok(Note {
505        id,
506        namespace,
507        kind,
508        status,
509        name,
510        content,
511        salience,
512        decay_factor,
513        expires_at,
514        properties,
515        created_at,
516        updated_at,
517        deleted_at,
518    })
519}
520
521fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
522    Uuid::parse_str(s).map_err(|e| {
523        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
524    })
525}
526
527fn query_note_page_snapshot(
528    conn: &rusqlite::Connection,
529    operation: &'static str,
530    namespace: &str,
531    count_sql: &str,
532    count_params: &[Box<dyn rusqlite::types::ToSql>],
533    data_sql: &str,
534    data_params: &[Box<dyn rusqlite::types::ToSql>],
535) -> Result<Page<Note>, rusqlite::Error> {
536    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Deferred)?;
537
538    let total: i64 = {
539        let mut stmt = tx.prepare(count_sql)?;
540        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
541            count_params.iter().map(|param| param.as_ref()).collect();
542        stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
543    };
544
545    #[cfg(test)]
546    tests::page_snapshot_seam::hook(operation, namespace);
547    #[cfg(not(test))]
548    let _ = (operation, namespace);
549
550    let items = {
551        let mut stmt = tx.prepare(data_sql)?;
552        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
553            data_params.iter().map(|param| param.as_ref()).collect();
554        let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
555        rows.collect::<Result<Vec<_>, _>>()?
556    };
557
558    tx.commit()?;
559    Ok(Page {
560        items,
561        total: Some(total as u64),
562    })
563}
564
565/// DML-only batch upsert loop shared by both the legacy (flag-off) and
566/// WriterTask-routed (flag-on) `upsert_notes` paths (ADR-067 Component A).
567///
568/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
569/// enclosing transaction. Per-row failures are captured into
570/// `BatchWriteSummary::failed`/`first_error` rather than aborting the loop,
571/// matching the existing partial-success contract.
572fn batch_upsert_notes(
573    conn: &rusqlite::Connection,
574    notes: &[Note],
575    attempted: u64,
576) -> Result<BatchWriteSummary, rusqlite::Error> {
577    let mut affected = 0u64;
578    let mut failed = 0u64;
579    let mut first_error = String::new();
580
581    // Prepare the UPSERT once for the whole batch — `Connection::execute`
582    // re-parses and re-plans the statement on every call, which dominates
583    // wall time at conflict-heavy batch sizes (measured 536ms vs 260ms at
584    // 50k conflicts; see PR #1082 review).
585    let mut stmt = conn.prepare_cached(NOTE_UPSERT_SQL)?;
586
587    for note in notes {
588        let id_str = note.id.to_string();
589        let kind_str = note.kind.to_string();
590        let status_str = note.status.clone();
591        let properties_str = note
592            .properties
593            .as_ref()
594            .map(|v| serde_json::to_string(v).unwrap_or_default());
595
596        match stmt.execute(rusqlite::params![
597            id_str,
598            &note.namespace,
599            kind_str,
600            status_str,
601            &note.name,
602            note.content,
603            note.salience,
604            note.decay_factor,
605            note.expires_at,
606            properties_str,
607            note.created_at,
608            note.updated_at,
609            note.deleted_at,
610        ]) {
611            Ok(_) => {
612                assign_note_seq(conn, &id_str)?;
613                affected += 1;
614            }
615            Err(e) => {
616                if first_error.is_empty() {
617                    first_error = e.to_string();
618                }
619                failed += 1;
620            }
621        }
622    }
623
624    Ok(BatchWriteSummary {
625        attempted,
626        affected,
627        failed,
628        first_error,
629    })
630}
631
632/// Assign a note id its durable, non-reusing sequence number the first time
633/// it is inserted (khive #827 — see `sql/007-notes-seq.sql`). `INSERT OR
634/// IGNORE` makes this idempotent across repeated upserts of the same note
635/// id: the sequence value is fixed at the note's first insert and never
636/// reassigned, unlike `notes`' own implicit rowid.
637fn assign_note_seq(conn: &rusqlite::Connection, note_id: &str) -> Result<(), rusqlite::Error> {
638    conn.execute(
639        "INSERT OR IGNORE INTO notes_seq (note_id) VALUES (?1)",
640        rusqlite::params![note_id],
641    )?;
642    Ok(())
643}
644
645fn build_note_where(
646    namespace: &str,
647    kind: Option<&str>,
648) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
649    let mut conditions: Vec<String> = vec![
650        "namespace = ?1".to_string(),
651        "deleted_at IS NULL".to_string(),
652    ];
653    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(namespace.to_string())];
654
655    if let Some(k) = kind {
656        params.push(Box::new(k.to_string()));
657        conditions.push(format!("kind = ?{}", params.len()));
658    }
659
660    let clause = format!(" WHERE {}", conditions.join(" AND "));
661    (clause, params)
662}
663
664fn build_note_where_for_namespaces(
665    namespaces: &[String],
666    kind: Option<&str>,
667) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
668    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = namespaces
669        .iter()
670        .map(|namespace| -> Box<dyn rusqlite::types::ToSql> { Box::new(namespace.clone()) })
671        .collect();
672    let namespace_condition = match namespaces.len() {
673        0 => "0".to_string(),
674        1 => "namespace = ?1".to_string(),
675        _ => {
676            let placeholders: Vec<String> =
677                (1..=namespaces.len()).map(|i| format!("?{i}")).collect();
678            format!("namespace IN ({})", placeholders.join(", "))
679        }
680    };
681    let mut conditions = vec![namespace_condition, "deleted_at IS NULL".to_string()];
682
683    if let Some(kind) = kind {
684        params.push(Box::new(kind.to_string()));
685        conditions.push(format!("kind = ?{}", params.len()));
686    }
687
688    let clause = format!(" WHERE {}", conditions.join(" AND "));
689    (clause, params)
690}
691
692/// Validate that a json_path is safe to interpolate into SQL.
693/// Accepts only `$.field` or `$.field.subfield` paths with alphanumeric/underscore segments.
694fn validate_json_path(path: &str) -> Result<(), StorageError> {
695    let valid = path.starts_with("$.")
696        && path[2..].split('.').all(|part| {
697            !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
698        });
699    if valid {
700        Ok(())
701    } else {
702        Err(StorageError::InvalidInput {
703            capability: StorageCapability::Notes,
704            operation: "query_notes_filtered".into(),
705            message: format!("invalid JSON path for note filter: {path:?}"),
706        })
707    }
708}
709
710fn json_extract_expr(path: &str) -> String {
711    format!("json_extract(properties, '{path}')")
712}
713
714fn json_type_expr(path: &str) -> String {
715    format!("json_type(properties, '{path}')")
716}
717
718fn sql_value_param(value: &SqlValue) -> Result<Box<dyn rusqlite::types::ToSql>, rusqlite::Error> {
719    Ok(match value {
720        SqlValue::Null => Box::new(Option::<String>::None),
721        SqlValue::Bool(v) => Box::new(*v as i64),
722        SqlValue::Integer(v) => Box::new(*v),
723        SqlValue::Float(v) => Box::new(*v),
724        SqlValue::Text(v) => Box::new(v.clone()),
725        SqlValue::Blob(v) => Box::new(v.clone()),
726        SqlValue::Json(v) => Box::new(
727            serde_json::to_string(v)
728                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
729        ),
730        SqlValue::Uuid(v) => Box::new(v.to_string()),
731        SqlValue::Timestamp(v) => Box::new(v.timestamp_micros()),
732    })
733}
734
735fn build_note_filter_where(
736    namespace: &str,
737    filter: &NoteFilter,
738) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
739    // When filter.namespaces is non-empty use `namespace IN (...)` for
740    // multi-namespace read visibility. Otherwise fall back to equality.
741    let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
742        if !filter.namespaces.is_empty() {
743            let placeholders: Vec<String> = (1..=filter.namespaces.len())
744                .map(|i| format!("?{i}"))
745                .collect();
746            let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
747                .namespaces
748                .iter()
749                .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
750                .collect();
751            (
752                format!("namespace IN ({})", placeholders.join(", ")),
753                params,
754            )
755        } else {
756            (
757                "namespace = ?1".to_string(),
758                vec![Box::new(namespace.to_string())],
759            )
760        };
761
762    let mut conditions = vec![ns_condition, "deleted_at IS NULL".to_string()];
763    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
764
765    if let Some(kind) = &filter.kind {
766        params.push(Box::new(kind.clone()));
767        conditions.push(format!("kind = ?{}", params.len()));
768    }
769
770    for pf in &filter.property_filters {
771        match &pf.op {
772            FilterOp::EqOrMissing => {
773                let expr = json_extract_expr(&pf.json_path);
774                params.push(sql_value_param(&pf.value)?);
775                conditions.push(format!(
776                    "({expr} = ?{n} OR {expr} IS NULL)",
777                    n = params.len()
778                ));
779            }
780            FilterOp::TextEqOrNonText => {
781                let expr = json_extract_expr(&pf.json_path);
782                let type_expr = json_type_expr(&pf.json_path);
783                params.push(sql_value_param(&pf.value)?);
784                let n = params.len();
785                conditions.push(format!(
786                    "CASE WHEN {type_expr} = 'text' THEN {expr} ELSE ?{n} END = ?{n}"
787                ));
788            }
789            FilterOp::JsonTypeEq => {
790                let type_expr = json_type_expr(&pf.json_path);
791                params.push(sql_value_param(&pf.value)?);
792                conditions.push(format!("{type_expr} = ?{}", params.len()));
793            }
794            FilterOp::JsonTypeNeMissing => {
795                let type_expr = json_type_expr(&pf.json_path);
796                params.push(sql_value_param(&pf.value)?);
797                let n = params.len();
798                conditions.push(format!("({type_expr} IS NULL OR {type_expr} != ?{n})"));
799            }
800            FilterOp::In(values) => {
801                let expr = json_extract_expr(&pf.json_path);
802                if values.is_empty() {
803                    // An empty set can never match any row.
804                    conditions.push("0".to_string());
805                    continue;
806                }
807                let mut placeholders = Vec::with_capacity(values.len());
808                for v in values {
809                    params.push(sql_value_param(v)?);
810                    placeholders.push(format!("?{}", params.len()));
811                }
812                conditions.push(format!("{expr} IN ({})", placeholders.join(", ")));
813            }
814            FilterOp::NotInOrMissing(values) => {
815                let expr = json_extract_expr(&pf.json_path);
816                if values.is_empty() {
817                    // Nothing to exclude — every row (including missing) matches.
818                    continue;
819                }
820                let mut placeholders = Vec::with_capacity(values.len());
821                for v in values {
822                    params.push(sql_value_param(v)?);
823                    placeholders.push(format!("?{}", params.len()));
824                }
825                conditions.push(format!(
826                    "({expr} IS NULL OR {expr} NOT IN ({}))",
827                    placeholders.join(", ")
828                ));
829            }
830            _ => {
831                let expr = json_extract_expr(&pf.json_path);
832                let op = match pf.op {
833                    FilterOp::Eq => "=",
834                    FilterOp::Ne => "!=",
835                    FilterOp::Lt => "<",
836                    FilterOp::Lte => "<=",
837                    FilterOp::Gt => ">",
838                    FilterOp::Gte => ">=",
839                    FilterOp::EqOrMissing
840                    | FilterOp::TextEqOrNonText
841                    | FilterOp::JsonTypeEq
842                    | FilterOp::JsonTypeNeMissing
843                    | FilterOp::In(_)
844                    | FilterOp::NotInOrMissing(_) => {
845                        unreachable!()
846                    }
847                };
848                params.push(sql_value_param(&pf.value)?);
849                conditions.push(format!("{expr} {op} ?{}", params.len()));
850            }
851        }
852    }
853
854    if let Some(min_ts) = filter.min_created_at {
855        params.push(Box::new(min_ts));
856        conditions.push(format!("created_at >= ?{}", params.len()));
857    }
858
859    Ok((format!(" WHERE {}", conditions.join(" AND ")), params))
860}
861
862fn execute_filtered_note_property_patch(
863    conn: &rusqlite::Connection,
864    id: Uuid,
865    namespace: &str,
866    filter: &NoteFilter,
867    json_path: &str,
868    value_json: &str,
869    updated_at: i64,
870) -> Result<usize, rusqlite::Error> {
871    let (where_clause, mut params) = build_note_filter_where(namespace, filter)?;
872
873    let base = params.len();
874    let sql = format!(
875        "UPDATE notes SET properties = json_set(COALESCE(properties, '{{}}'), ?{p1}, json(?{p2})), \
876         updated_at = ?{p3} {where_clause} \
877         AND (properties IS NULL OR json_type(properties) = 'object') AND id = ?{p4}",
878        p1 = base + 1,
879        p2 = base + 2,
880        p3 = base + 3,
881        p4 = base + 4,
882    );
883    params.push(Box::new(json_path.to_string()));
884    params.push(Box::new(value_json.to_string()));
885    params.push(Box::new(updated_at));
886    params.push(Box::new(id.to_string()));
887
888    let mut stmt = conn.prepare_cached(&sql)?;
889    let param_refs: Vec<&dyn rusqlite::types::ToSql> =
890        params.iter().map(|param| param.as_ref()).collect();
891    stmt.execute(param_refs.as_slice())
892}
893
894// =============================================================================
895// NoteStore implementation
896// =============================================================================
897
898#[async_trait]
899impl NoteStore for SqlNoteStore {
900    async fn upsert_note(&self, note: Note) -> Result<(), StorageError> {
901        let id_str = note.id.to_string();
902        let statement = note_upsert_statement(&note);
903        self.with_writer_tx("upsert_note", move |conn| {
904            let mut stmt = conn.prepare_cached(&statement.sql)?;
905            bind_params(&mut stmt, &statement.params)?;
906            stmt.raw_execute()?;
907            assign_note_seq(conn, &id_str)?;
908            Ok(())
909        })
910        .await
911    }
912
913    async fn insert_note_if_absent(&self, note: Note) -> Result<bool, StorageError> {
914        let id_str = note.id.to_string();
915        let statement = note_insert_if_absent_statement(&note);
916        self.with_writer_tx("insert_note_if_absent", move |conn| {
917            let mut stmt = conn.prepare_cached(&statement.sql)?;
918            bind_params(&mut stmt, &statement.params)?;
919            let inserted = stmt.raw_execute()? > 0;
920            // Only a real insert needs a sequence number. Assigning one on the
921            // no-op path would write to a row this call did not create, which
922            // is the overwrite this primitive exists to avoid.
923            if inserted {
924                assign_note_seq(conn, &id_str)?;
925            }
926            Ok(inserted)
927        })
928        .await
929    }
930
931    async fn replace_note_if_unchanged(
932        &self,
933        note: Note,
934        expected_updated_at: i64,
935        expected_deleted_at: Option<i64>,
936    ) -> Result<bool, StorageError> {
937        let statement =
938            note_replace_if_unchanged_statement(&note, expected_updated_at, expected_deleted_at);
939        self.with_writer("replace_note_if_unchanged", move |conn| {
940            let mut stmt = conn.prepare(&statement.sql)?;
941            bind_params(&mut stmt, &statement.params)?;
942            Ok(stmt.raw_execute()? > 0)
943        })
944        .await
945    }
946
947    async fn update_note_properties(
948        &self,
949        id: Uuid,
950        properties: Option<serde_json::Value>,
951        updated_at: i64,
952    ) -> Result<bool, StorageError> {
953        let statement = note_update_properties_statement(id, &properties, updated_at);
954        self.with_writer("update_note_properties", move |conn| {
955            let mut stmt = conn.prepare(&statement.sql)?;
956            bind_params(&mut stmt, &statement.params)?;
957            Ok(stmt.raw_execute()? > 0)
958        })
959        .await
960    }
961
962    async fn set_note_property(
963        &self,
964        id: Uuid,
965        key: &str,
966        value: serde_json::Value,
967        updated_at: i64,
968    ) -> Result<bool, StorageError> {
969        let statement = note_set_property_statement(id, key, &value, updated_at)?;
970        self.with_writer("set_note_property", move |conn| {
971            let mut stmt = conn.prepare(&statement.sql)?;
972            bind_params(&mut stmt, &statement.params)?;
973            Ok(stmt.raw_execute()? > 0)
974        })
975        .await
976    }
977
978    async fn try_patch_note_property(
979        &self,
980        id: Uuid,
981        namespace: &str,
982        filter: &NoteFilter,
983        json_path: &str,
984        value: serde_json::Value,
985        updated_at: i64,
986    ) -> Result<bool, StorageError> {
987        let namespace = namespace.to_string();
988        let filter = filter.clone();
989        let value_json = serde_json::to_string(&value).map_err(|e| {
990            StorageError::driver(StorageCapability::Notes, "try_patch_note_property", e)
991        })?;
992        let json_path = json_path.to_string();
993
994        self.with_writer("try_patch_note_property", move |conn| {
995            execute_filtered_note_property_patch(
996                conn,
997                id,
998                &namespace,
999                &filter,
1000                &json_path,
1001                &value_json,
1002                updated_at,
1003            )
1004            .map(|rows| rows > 0)
1005        })
1006        .await
1007    }
1008
1009    async fn patch_note_property_atomic(
1010        &self,
1011        mut ids: Vec<Uuid>,
1012        namespace: &str,
1013        filter: &NoteFilter,
1014        json_path: &str,
1015        value: serde_json::Value,
1016        updated_at: i64,
1017    ) -> Result<(), StorageError> {
1018        let mut seen = HashSet::with_capacity(ids.len());
1019        ids.retain(|id| seen.insert(*id));
1020        if ids.is_empty() {
1021            return Err(StorageError::InvalidInput {
1022                capability: StorageCapability::Notes,
1023                operation: "patch_note_property_atomic".into(),
1024                message: "at least one note id is required".to_string(),
1025            });
1026        }
1027
1028        let namespace = namespace.to_string();
1029        let filter = filter.clone();
1030        let value_json = serde_json::to_string(&value).map_err(|e| {
1031            StorageError::driver(StorageCapability::Notes, "patch_note_property_atomic", e)
1032        })?;
1033        let json_path = json_path.to_string();
1034
1035        self.with_writer_tx_storage("patch_note_property_atomic", move |conn| {
1036            for id in ids {
1037                let rows = execute_filtered_note_property_patch(
1038                    conn,
1039                    id,
1040                    &namespace,
1041                    &filter,
1042                    &json_path,
1043                    &value_json,
1044                    updated_at,
1045                )
1046                .map_err(|error| map_err(error, "patch_note_property_atomic"))?;
1047                if rows != 1 {
1048                    return Err(StorageError::Conflict {
1049                        capability: StorageCapability::Notes,
1050                        operation: "patch_note_property_atomic".into(),
1051                        message: format!(
1052                            "precondition failed for note {id}: guarded update changed {rows} rows; expected 1"
1053                        ),
1054                    });
1055                }
1056            }
1057            Ok(())
1058        })
1059        .await
1060    }
1061
1062    async fn try_insert_note(&self, note: Note) -> Result<bool, StorageError> {
1063        let namespace = note.namespace.clone();
1064        let id_str = note.id.to_string();
1065        let kind_str = note.kind.to_string();
1066        let status_str = note.status.clone();
1067        let properties_str = note
1068            .properties
1069            .as_ref()
1070            .map(|v| serde_json::to_string(v).unwrap_or_default());
1071
1072        // Extract external_id (if any) for dedup verification after a zero-row insert.
1073        let ext_id_opt: Option<String> = note
1074            .properties
1075            .as_ref()
1076            .and_then(|v| v.get("external_id"))
1077            .and_then(|v| v.as_str())
1078            .filter(|s| !s.is_empty())
1079            .map(|s| s.to_string());
1080
1081        self.with_writer_tx("try_insert_note", move |conn| {
1082            let rows = conn.execute(
1083                "INSERT OR IGNORE INTO notes \
1084                 (id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
1085                  properties, created_at, updated_at, deleted_at) \
1086                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
1087                rusqlite::params![
1088                    id_str,
1089                    namespace,
1090                    kind_str,
1091                    status_str,
1092                    note.name,
1093                    note.content,
1094                    note.salience,
1095                    note.decay_factor,
1096                    note.expires_at,
1097                    properties_str,
1098                    note.created_at,
1099                    note.updated_at,
1100                    note.deleted_at,
1101                ],
1102            )?;
1103
1104            if rows > 0 {
1105                assign_note_seq(conn, &id_str)?;
1106                return Ok(true);
1107            }
1108
1109            // Zero rows: the INSERT was silently skipped by OR IGNORE.
1110            // Only treat this as a dedup hit when a live note with the same
1111            // non-empty external_id already exists in this namespace and kind.
1112            // Any other ignored constraint (e.g. a PRIMARY KEY collision) must
1113            // surface as an error rather than being misreported as a duplicate.
1114            if let Some(ref ext_id) = ext_id_opt {
1115                let is_dedup: bool = conn.query_row(
1116                    "SELECT COUNT(*) > 0 FROM notes \
1117                     WHERE namespace = ?1 \
1118                       AND kind = ?2 \
1119                       AND json_extract(properties, '$.external_id') = ?3 \
1120                       AND deleted_at IS NULL",
1121                    rusqlite::params![namespace, kind_str, ext_id],
1122                    |row| row.get(0),
1123                )?;
1124                if is_dedup {
1125                    return Ok(false);
1126                }
1127            }
1128
1129            // The INSERT was dropped for a reason other than an external_id
1130            // collision.  Surface it as a constraint error.
1131            Err(rusqlite::Error::SqliteFailure(
1132                rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
1133                Some(
1134                    "try_insert_note: INSERT ignored for a constraint other than \
1135                     external_id dedup; not masking as deduplication"
1136                        .to_string(),
1137                ),
1138            ))
1139        })
1140        .await
1141    }
1142
1143    async fn upsert_notes(&self, notes: Vec<Note>) -> Result<BatchWriteSummary, StorageError> {
1144        let attempted = notes.len() as u64;
1145
1146        // khive #827: route through `with_writer_tx`
1147        // instead of hand-rolling BEGIN IMMEDIATE/COMMIT/ROLLBACK here. The
1148        // old flag-off path only rolled back when the final COMMIT failed —
1149        // an earlier error from `batch_upsert_notes` (e.g. a failed
1150        // `assign_note_seq`) propagated via `?` straight out of the closure,
1151        // skipping ROLLBACK entirely and leaving BEGIN IMMEDIATE open on the
1152        // shared pool-mutex connection, poisoning every later write on that
1153        // connection. `with_writer_tx` rolls back on ANY error from `f`, on
1154        // both the flag-on (WriterTask, which wraps its own transaction) and
1155        // flag-off (pool-mutex) paths.
1156        let origin = self.pool.origin();
1157        self.with_writer_tx("upsert_notes", move |conn| {
1158            let _tx_handle = khive_storage::tx_registry::register_scoped(
1159                Some("note_upsert_batch".to_string()),
1160                origin,
1161            );
1162            batch_upsert_notes(conn, &notes, attempted)
1163        })
1164        .await
1165    }
1166
1167    async fn get_note(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
1168        let id_str = id.to_string();
1169
1170        self.with_reader("get_note", move |conn| {
1171            let mut stmt = conn.prepare(
1172                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
1173                 properties, created_at, updated_at, deleted_at \
1174                 FROM notes WHERE id = ?1 AND deleted_at IS NULL",
1175            )?;
1176            let mut rows = stmt.query(rusqlite::params![id_str])?;
1177            match rows.next()? {
1178                Some(row) => Ok(Some(read_note(row)?)),
1179                None => Ok(None),
1180            }
1181        })
1182        .await
1183    }
1184
1185    async fn get_note_including_deleted(&self, id: Uuid) -> Result<Option<Note>, StorageError> {
1186        let id_str = id.to_string();
1187
1188        self.with_reader("get_note_including_deleted", move |conn| {
1189            let mut stmt = conn.prepare(
1190                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
1191                 properties, created_at, updated_at, deleted_at \
1192                 FROM notes WHERE id = ?1",
1193            )?;
1194            let mut rows = stmt.query(rusqlite::params![id_str])?;
1195            match rows.next()? {
1196                Some(row) => Ok(Some(read_note(row)?)),
1197                None => Ok(None),
1198            }
1199        })
1200        .await
1201    }
1202
1203    async fn note_sequence(&self, id: Uuid) -> Result<Option<i64>, StorageError> {
1204        let id = id.to_string();
1205        self.with_reader("note_sequence", move |conn| {
1206            conn.query_row(
1207                "SELECT seq FROM notes_seq WHERE note_id = ?1",
1208                rusqlite::params![id],
1209                |row| row.get(0),
1210            )
1211            .optional()
1212        })
1213        .await
1214    }
1215
1216    async fn get_notes_batch(&self, ids: &[Uuid]) -> Result<Vec<Note>, StorageError> {
1217        if ids.is_empty() {
1218            return Ok(vec![]);
1219        }
1220        // SQLite SQLITE_MAX_VARIABLE_NUMBER defaults to 999; chunk below that
1221        // ceiling so callers can safely hydrate arbitrarily large ID sets.
1222        const CHUNK: usize = 900;
1223        let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
1224
1225        let mut result = Vec::with_capacity(ids.len());
1226        for chunk in id_strings.chunks(CHUNK) {
1227            let chunk_owned = chunk.to_vec();
1228            let notes = self
1229                .with_reader("get_notes_batch", move |conn| {
1230                    let placeholders: String = (1..=chunk_owned.len())
1231                        .map(|i| format!("?{i}"))
1232                        .collect::<Vec<_>>()
1233                        .join(", ");
1234                    let sql = format!(
1235                        "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
1236                         properties, created_at, updated_at, deleted_at \
1237                         FROM notes WHERE id IN ({placeholders}) AND deleted_at IS NULL"
1238                    );
1239                    let mut stmt = conn.prepare(&sql)?;
1240                    let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
1241                        .iter()
1242                        .map(|s| s as &dyn rusqlite::types::ToSql)
1243                        .collect();
1244                    let rows = stmt.query_map(params.as_slice(), read_note)?;
1245                    let mut notes = Vec::new();
1246                    for row in rows {
1247                        notes.push(row?);
1248                    }
1249                    Ok(notes)
1250                })
1251                .await?;
1252            result.extend(notes);
1253        }
1254        Ok(result)
1255    }
1256
1257    async fn delete_note(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
1258        match mode {
1259            DeleteMode::Soft => {
1260                let now = chrono::Utc::now().timestamp_micros();
1261                let statement = note_soft_delete_statement(id, now);
1262                self.with_writer("delete_note_soft", move |conn| {
1263                    let mut stmt = conn.prepare(&statement.sql)?;
1264                    bind_params(&mut stmt, &statement.params)?;
1265                    Ok(stmt.raw_execute()? > 0)
1266                })
1267                .await
1268            }
1269            DeleteMode::Hard => {
1270                let note_statement = note_hard_delete_statement(id);
1271                let attachment_statement =
1272                    delete_record_attachments_statement(id, AttachmentSubstrate::Note);
1273                self.with_writer_tx("delete_note_hard", move |conn| {
1274                    let mut note_stmt = conn.prepare(&note_statement.sql)?;
1275                    bind_params(&mut note_stmt, &note_statement.params)?;
1276                    let deleted = note_stmt.raw_execute()? > 0;
1277                    drop(note_stmt);
1278                    if deleted {
1279                        let mut attachment_stmt = conn.prepare(&attachment_statement.sql)?;
1280                        bind_params(&mut attachment_stmt, &attachment_statement.params)?;
1281                        attachment_stmt.raw_execute()?;
1282                    }
1283                    Ok(deleted)
1284                })
1285                .await
1286            }
1287        }
1288    }
1289
1290    async fn query_notes(
1291        &self,
1292        namespace: &str,
1293        kind: Option<&str>,
1294        page: PageRequest,
1295    ) -> Result<Page<Note>, StorageError> {
1296        let namespace = namespace.to_string();
1297        let kind = kind.map(|k| k.to_string());
1298        let limit_i64 = i64::from(page.limit);
1299        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1300            capability: StorageCapability::Notes,
1301            operation: "query_notes".into(),
1302            message: format!(
1303                "PageRequest: offset must be <= i64::MAX, got {}",
1304                page.offset
1305            ),
1306        })?;
1307
1308        self.with_reader("query_notes", move |conn| {
1309            let (count_sql, count_params) = build_note_where(&namespace, kind.as_deref());
1310            let count_sql = format!("SELECT COUNT(*) FROM notes{count_sql}");
1311
1312            let (where_sql, mut data_params) = build_note_where(&namespace, kind.as_deref());
1313            data_params.push(Box::new(limit_i64));
1314            data_params.push(Box::new(offset_i64));
1315
1316            let limit_idx = data_params.len() - 1;
1317            let offset_idx = data_params.len();
1318
1319            let data_sql = format!(
1320                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, expires_at, \
1321                 properties, created_at, updated_at, deleted_at \
1322                 FROM notes{} ORDER BY created_at DESC, id ASC LIMIT ?{} OFFSET ?{}",
1323                where_sql, limit_idx, offset_idx,
1324            );
1325
1326            query_note_page_snapshot(
1327                conn,
1328                "query_notes",
1329                &namespace,
1330                &count_sql,
1331                &count_params,
1332                &data_sql,
1333                &data_params,
1334            )
1335        })
1336        .await
1337    }
1338
1339    async fn query_notes_filtered(
1340        &self,
1341        namespace: &str,
1342        filter: &NoteFilter,
1343        page: PageRequest,
1344    ) -> Result<Page<Note>, StorageError> {
1345        // Validate paths before entering spawn_blocking (closures return rusqlite::Error).
1346        for pf in &filter.property_filters {
1347            validate_json_path(&pf.json_path)?;
1348        }
1349        if let Some((path, _)) = &filter.order_by {
1350            validate_json_path(path)?;
1351        }
1352
1353        let namespace = namespace.to_string();
1354        let filter = filter.clone();
1355        let limit_i64 = i64::from(page.limit);
1356        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1357            capability: StorageCapability::Notes,
1358            operation: "query_notes_filtered".into(),
1359            message: format!(
1360                "PageRequest: offset must be <= i64::MAX, got {}",
1361                page.offset
1362            ),
1363        })?;
1364
1365        self.with_reader("query_notes_filtered", move |conn| {
1366            let (count_sql, count_params) = build_note_filter_where(&namespace, &filter)?;
1367            let count_sql = format!("SELECT COUNT(*) FROM notes{count_sql}");
1368
1369            let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
1370            data_params.push(Box::new(limit_i64));
1371            data_params.push(Box::new(offset_i64));
1372
1373            let order_clause = match &filter.order_by {
1374                Some((path, dir)) => {
1375                    let dir_str = match dir {
1376                        SortDir::Asc => "ASC",
1377                        SortDir::Desc => "DESC",
1378                    };
1379                    // #1671: append `id` as the final tiebreak in the sort
1380                    // field's direction so offset pages form a deterministic
1381                    // total order even when the JSON sort value repeats. The
1382                    // total order removes tie-order instability only — offset
1383                    // paging can still duplicate or skip rows under concurrent
1384                    // inserts/deletes or sort-key updates (that would need
1385                    // snapshot isolation or keyset pagination).
1386                    format!(
1387                        " ORDER BY {} {dir_str}, id {dir_str}",
1388                        json_extract_expr(path)
1389                    )
1390                }
1391                // #1671: intentionally left unchanged — `id ASC` over the
1392                // primary key already makes this clause a deterministic total
1393                // order; flipping the direction would change the observable
1394                // default order for existing consumers without fixing
1395                // anything.
1396                None => " ORDER BY created_at DESC, id ASC".to_string(),
1397            };
1398
1399            let limit_idx = data_params.len() - 1;
1400            let offset_idx = data_params.len();
1401            let data_sql = format!(
1402                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1403                 expires_at, properties, created_at, updated_at, deleted_at \
1404                 FROM notes{}{order_clause} LIMIT ?{} OFFSET ?{}",
1405                where_sql, limit_idx, offset_idx,
1406            );
1407
1408            query_note_page_snapshot(
1409                conn,
1410                "query_notes_filtered",
1411                &namespace,
1412                &count_sql,
1413                &count_params,
1414                &data_sql,
1415                &data_params,
1416            )
1417        })
1418        .await
1419    }
1420
1421    async fn query_notes_filtered_after(
1422        &self,
1423        namespace: &str,
1424        filter: &NoteFilter,
1425        after: Option<SeekCursor>,
1426        limit: u32,
1427    ) -> Result<SeekPage<Note>, StorageError> {
1428        if limit == 0 {
1429            return Ok(SeekPage::default());
1430        }
1431        if filter.order_by.is_some() {
1432            return Err(StorageError::InvalidInput {
1433                capability: StorageCapability::Notes,
1434                operation: "query_notes_filtered_after".into(),
1435                message: "custom order_by is not compatible with insertion-sequence pagination"
1436                    .into(),
1437            });
1438        }
1439        for property_filter in &filter.property_filters {
1440            validate_json_path(&property_filter.json_path)?;
1441        }
1442
1443        let namespace = namespace.to_string();
1444        let filter = filter.clone();
1445        let limit_usize = limit as usize;
1446        let probe_limit_i64 = i64::from(limit) + 1;
1447        self.with_reader("query_notes_filtered_after", move |conn| {
1448            let (mut where_sql, mut params) = build_note_filter_where(&namespace, &filter)?;
1449            if let Some(cursor) = after {
1450                params.push(Box::new(cursor.sequence));
1451                where_sql.push_str(&format!(" AND notes_seq.seq > ?{}", params.len()));
1452            }
1453            params.push(Box::new(probe_limit_i64));
1454            let limit_idx = params.len();
1455            // CROSS JOIN fixes the ledger as the outer loop, preserving an
1456            // indexed `seq > boundary` scan with no full-match sort.
1457            let sql = format!(
1458                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1459                 expires_at, properties, created_at, updated_at, deleted_at, notes_seq.seq \
1460                 FROM notes_seq CROSS JOIN notes ON notes.id = notes_seq.note_id{where_sql} \
1461                 ORDER BY notes_seq.seq ASC LIMIT ?{limit_idx}"
1462            );
1463            let mut stmt = conn.prepare(&sql)?;
1464            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1465                params.iter().map(|param| param.as_ref()).collect();
1466            let rows = stmt.query_map(param_refs.as_slice(), |row| {
1467                Ok((read_note(row)?, row.get::<_, i64>(13)?))
1468            })?;
1469            let mut entries = rows.collect::<Result<Vec<_>, _>>()?;
1470            let has_more = entries.len() > limit_usize;
1471            if has_more {
1472                entries.truncate(limit_usize);
1473            }
1474            let next_after = if has_more {
1475                entries.last().map(|(note, sequence)| SeekCursor {
1476                    sequence: *sequence,
1477                    id: note.id,
1478                })
1479            } else {
1480                None
1481            };
1482            let items = entries.into_iter().map(|(note, _)| note).collect();
1483            Ok(SeekPage { items, next_after })
1484        })
1485        .await
1486    }
1487
1488    async fn query_notes_filtered_bounded(
1489        &self,
1490        namespace: &str,
1491        filter: &NoteFilter,
1492        max_rows: u32,
1493    ) -> Result<Vec<Note>, StorageError> {
1494        for pf in &filter.property_filters {
1495            validate_json_path(&pf.json_path)?;
1496        }
1497        if let Some((path, _)) = &filter.order_by {
1498            validate_json_path(path)?;
1499        }
1500
1501        let namespace = namespace.to_string();
1502        let filter = filter.clone();
1503        let limit_i64 = i64::from(max_rows) + 1;
1504
1505        self.with_reader("query_notes_filtered_bounded", move |conn| {
1506            let (where_sql, mut data_params) = build_note_filter_where(&namespace, &filter)?;
1507            data_params.push(Box::new(limit_i64));
1508            let limit_idx = data_params.len();
1509
1510            // Tie-break on `id` in addition to the primary sort key so the
1511            // snapshot ordering is fully deterministic even when many rows
1512            // share the same `created_at` (or the same custom sort value).
1513            let order_clause = match &filter.order_by {
1514                Some((path, dir)) => {
1515                    let dir_str = match dir {
1516                        SortDir::Asc => "ASC",
1517                        SortDir::Desc => "DESC",
1518                    };
1519                    format!(" ORDER BY {} {dir_str}, id ASC", json_extract_expr(path))
1520                }
1521                None => " ORDER BY created_at DESC, id ASC".to_string(),
1522            };
1523
1524            let data_sql = format!(
1525                "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1526                 expires_at, properties, created_at, updated_at, deleted_at \
1527                 FROM notes{where_sql}{order_clause} LIMIT ?{limit_idx}",
1528            );
1529
1530            let mut stmt = conn.prepare(&data_sql)?;
1531            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1532                data_params.iter().map(|p| p.as_ref()).collect();
1533            let rows = stmt.query_map(param_refs.as_slice(), read_note)?;
1534
1535            let mut items = Vec::new();
1536            for row in rows {
1537                items.push(row?);
1538            }
1539            Ok(items)
1540        })
1541        .await
1542    }
1543
1544    async fn count_notes(&self, namespace: &str, kind: Option<&str>) -> Result<u64, StorageError> {
1545        let namespace = namespace.to_string();
1546        let kind = kind.map(|k| k.to_string());
1547
1548        self.with_reader("count_notes", move |conn| {
1549            let (where_sql, params) = build_note_where(&namespace, kind.as_deref());
1550            let sql = format!("SELECT COUNT(*) FROM notes{}", where_sql);
1551            let mut stmt = conn.prepare(&sql)?;
1552            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1553                params.iter().map(|p| p.as_ref()).collect();
1554            let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1555            Ok(count as u64)
1556        })
1557        .await
1558    }
1559
1560    async fn count_notes_in_namespaces(
1561        &self,
1562        namespaces: &[String],
1563        kind: Option<&str>,
1564    ) -> Result<u64, StorageError> {
1565        let namespaces: Vec<String> = namespaces
1566            .iter()
1567            .cloned()
1568            .collect::<HashSet<_>>()
1569            .into_iter()
1570            .collect();
1571        let kind = kind.map(str::to_string);
1572
1573        self.with_reader("count_notes_in_namespaces", move |conn| {
1574            let mut total = 0;
1575            for chunk in namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
1576                let (where_sql, params) = build_note_where_for_namespaces(chunk, kind.as_deref());
1577                let sql = format!("SELECT COUNT(*) FROM notes{where_sql}");
1578                let mut stmt = conn.prepare(&sql)?;
1579                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1580                    params.iter().map(|p| p.as_ref()).collect();
1581                let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1582                total += count as u64;
1583            }
1584            Ok(total)
1585        })
1586        .await
1587    }
1588}
1589
1590// =============================================================================
1591// DDL
1592// =============================================================================
1593
1594const NOTES_DDL: &str = include_str!("../../sql/notes-ddl.sql");
1595
1596/// Same anti-join repair as `sql/008-notes-seq-repair.sql` (the V8 forward
1597/// migration) -- shared via `include_str!` from that single source file
1598/// rather than duplicated as SQL text. `INSERT OR IGNORE` targets notes
1599/// still missing a `notes_seq` row specifically, so it is correct to run
1600/// against a fresh ledger, a partially populated one, or an already fully
1601/// repaired one.
1602const NOTES_SEQ_REPAIR_DDL: &str = include_str!("../../sql/008-notes-seq-repair.sql");
1603
1604pub(crate) fn ensure_notes_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1605    conn.execute_batch(NOTES_DDL)
1606}
1607
1608/// Anti-join backfill of `notes_seq` for any note still missing a row
1609/// (khive #827). Scans `notes` in full, so callers MUST gate this to
1610/// run at most once per backend/pool rather than on every store acquisition
1611/// (khive #827) -- see
1612/// `StorageBackend::notes_for_namespace`.
1613pub(crate) fn repair_notes_seq(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1614    conn.execute_batch(NOTES_SEQ_REPAIR_DDL)
1615}
1616
1617#[cfg(test)]
1618#[path = "note_tests.rs"]
1619mod tests;