Skip to main content

khive_db/stores/
entity.rs

1//! SQL-backed `EntityStore` implementation.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use uuid::Uuid;
7
8use khive_storage::entity::{Entity, EntityFilter};
9use khive_storage::error::StorageError;
10use khive_storage::types::{
11    BatchWriteSummary, DeleteMode, Page, PageRequest, SqlStatement, SqlValue,
12};
13use khive_storage::EntityStore;
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::Entities, op, e)
23}
24
25fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
26    StorageError::driver(StorageCapability::Entities, op, e)
27}
28
29// ---------------------------------------------------------------------------
30// Pure statement builders (ADR-099 B3 r6 structural cut)
31//
32// These carry NO I/O — they turn an already-computed `Entity` (or a bare id)
33// into the exact `SqlStatement` this store executes. `upsert_entity` and
34// `delete_entity` below call them and execute the result; ADR-099's atomic
35// prepare path (`khive-runtime`) calls them too, to build the same statement
36// for its own guarded, synchronous apply. One statement generator, two
37// execution mechanisms (async trait dispatch vs. synchronous atomic unit) —
38// per ADR-099's accepted "handler-logic-duplication objection" text, the
39// bulk-apply path reuses the handler's existing statement generation instead
40// of re-deriving it.
41// ---------------------------------------------------------------------------
42
43/// The exact `INSERT OR REPLACE` this store's `upsert_entity` issues.
44pub fn entity_upsert_statement(entity: &Entity) -> SqlStatement {
45    let properties_str = entity
46        .properties
47        .as_ref()
48        .map(|v| serde_json::to_string(v).unwrap_or_default());
49    let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
50    SqlStatement {
51        sql: "INSERT OR REPLACE INTO entities \
52              (id, namespace, kind, entity_type, name, description, properties, tags, \
53               created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref) \
54              VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"
55            .to_string(),
56        params: vec![
57            SqlValue::Text(entity.id.to_string()),
58            SqlValue::Text(entity.namespace.clone()),
59            SqlValue::Text(entity.kind.clone()),
60            match &entity.entity_type {
61                Some(t) => SqlValue::Text(t.clone()),
62                None => SqlValue::Null,
63            },
64            SqlValue::Text(entity.name.clone()),
65            match &entity.description {
66                Some(d) => SqlValue::Text(d.clone()),
67                None => SqlValue::Null,
68            },
69            match properties_str {
70                Some(p) => SqlValue::Text(p),
71                None => SqlValue::Null,
72            },
73            SqlValue::Text(tags_str),
74            SqlValue::Integer(entity.created_at),
75            SqlValue::Integer(entity.updated_at),
76            match entity.deleted_at {
77                Some(d) => SqlValue::Integer(d),
78                None => SqlValue::Null,
79            },
80            match entity.merged_into {
81                Some(u) => SqlValue::Text(u.to_string()),
82                None => SqlValue::Null,
83            },
84            match entity.merge_event_id {
85                Some(u) => SqlValue::Text(u.to_string()),
86                None => SqlValue::Null,
87            },
88            match &entity.content_ref {
89                Some(c) => SqlValue::Text(c.clone()),
90                None => SqlValue::Null,
91            },
92        ],
93        label: Some("entity-upsert".to_string()),
94    }
95}
96
97/// The exact soft-delete `UPDATE` this store's `delete_entity(Soft)` issues.
98pub fn entity_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
99    SqlStatement {
100        sql: "UPDATE entities SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL".to_string(),
101        params: vec![
102            SqlValue::Integer(deleted_at),
103            SqlValue::Text(id.to_string()),
104        ],
105        label: Some("entity-delete-soft".to_string()),
106    }
107}
108
109/// The exact hard-delete `DELETE` this store's `delete_entity(Hard)` issues
110/// (no `deleted_at` predicate — purges live and already-tombstoned rows).
111pub fn entity_hard_delete_statement(id: Uuid) -> SqlStatement {
112    SqlStatement {
113        sql: "DELETE FROM entities WHERE id = ?1".to_string(),
114        params: vec![SqlValue::Text(id.to_string())],
115        label: Some("entity-delete-hard".to_string()),
116    }
117}
118
119/// An EntityStore backed by SQLite. Namespace is the caller's responsibility.
120///
121/// UUID is globally unique — get/delete by ID alone. Query/count use the
122/// namespace parameter as passed. The store is just a pool + is_file_backed.
123pub struct SqlEntityStore {
124    pool: Arc<ConnectionPool>,
125    is_file_backed: bool,
126    writer_task: Option<WriterTaskHandle>,
127}
128
129impl SqlEntityStore {
130    /// Create a new store.
131    ///
132    /// When `KHIVE_WRITE_QUEUE=1` (`PoolConfig::write_queue_enabled`), every
133    /// write path on this store — the batch `upsert_entities` (its own
134    /// explicit flag check) AND every single-row write routed through the
135    /// shared `with_writer` helper (`upsert_entity`, `delete_entity`) —
136    /// routes through the pool-wide `WriterTask`
137    /// (`ConnectionPool::writer_task_handle`) instead of the legacy
138    /// pool-mutex path. The handle is a clone of the ONE writer task owned
139    /// by `pool` — constructing multiple stores (or multiple namespaces)
140    /// over the same pool never spawns more than one writer task; see
141    /// `ConnectionPool::writer_task_handle`'s doc comment for why that
142    /// matters. `None` (falling back to the legacy path for every write)
143    /// if the flag is off, or if the writer task failed to spawn (for
144    /// example, an in-memory pool, which has no standalone-connection
145    /// support) — the flag is a best-effort opt-in, not a hard requirement.
146    pub fn new(pool: Arc<ConnectionPool>, is_file_backed: bool) -> Self {
147        // Best-effort opt-in (slice 1 policy, unchanged): a missing writer
148        // task — whether the flag is off, spawn degraded (e.g. in-memory
149        // pool), or no Tokio runtime was available at this first access
150        // (ADR-067 Component A runtime-handle guard) — degrades to the
151        // legacy pool-mutex path rather than failing construction.
152        let writer_task = pool.writer_task_handle().ok().flatten();
153
154        Self {
155            pool,
156            is_file_backed,
157            writer_task,
158        }
159    }
160
161    fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
162        let config = self.pool.config();
163        let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
164            operation: "entity_reader".into(),
165            message: "in-memory databases do not support standalone connections".into(),
166        })?;
167
168        let conn = rusqlite::Connection::open_with_flags(
169            path,
170            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
171                | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
172                | rusqlite::OpenFlags::SQLITE_OPEN_URI,
173        )
174        .map_err(|e| map_err(e, "open_entity_reader"))?;
175
176        conn.busy_timeout(config.busy_timeout)
177            .map_err(|e| map_err(e, "open_entity_reader"))?;
178        conn.pragma_update(None, "foreign_keys", "ON")
179            .map_err(|e| map_err(e, "open_entity_reader"))?;
180        conn.pragma_update(None, "synchronous", "NORMAL")
181            .map_err(|e| map_err(e, "open_entity_reader"))?;
182
183        Ok(conn)
184    }
185
186    /// Route a single-row write through the pool-wide `WriterTask` when
187    /// `KHIVE_WRITE_QUEUE=1` and a handle is available; otherwise fall back
188    /// to the legacy pool-mutex path.
189    ///
190    /// ADR-067 Component A (Fork C slice 2): this is the ONE routing point
191    /// for every `with_writer` caller in this store — `upsert_entity`,
192    /// `delete_entity` (soft/hard) all reach the WriterTask through this
193    /// helper rather than each duplicating the flag check. `f` must be
194    /// DML-only (a single statement, no bare `BEGIN IMMEDIATE`): on the
195    /// flag-on path it runs inside the WriterTask's own transaction, and a
196    /// nested `BEGIN IMMEDIATE` would violate SQLite's nested-transaction
197    /// rule. `upsert_entities` (the batch method) does its OWN flag check
198    /// and returns early on `Some`, so its fallback call into this helper
199    /// only ever executes on the flag-off path (`self.writer_task` is
200    /// `None` by construction whenever that call is reached) — no
201    /// double-routing.
202    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
203    where
204        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
205        R: Send + 'static,
206    {
207        if let Some(writer_task) = &self.writer_task {
208            return writer_task
209                .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
210                .await;
211        }
212
213        let pool = Arc::clone(&self.pool);
214        tokio::task::spawn_blocking(move || {
215            let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
216            f(guard.conn()).map_err(|e| map_err(e, op))
217        })
218        .await
219        .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
220    }
221
222    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
223    where
224        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
225        R: Send + 'static,
226    {
227        if self.is_file_backed {
228            let conn = self.open_standalone_reader()?;
229            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
230                .await
231                .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
232        } else {
233            let pool = Arc::clone(&self.pool);
234            tokio::task::spawn_blocking(move || {
235                let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
236                f(guard.conn()).map_err(|e| map_err(e, op))
237            })
238            .await
239            .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
240        }
241    }
242}
243
244// =============================================================================
245// Helpers
246// =============================================================================
247
248fn read_entity(row: &rusqlite::Row<'_>) -> Result<Entity, rusqlite::Error> {
249    let id_str: String = row.get(0)?;
250    let namespace: String = row.get(1)?;
251    let kind: String = row.get(2)?;
252    let entity_type: Option<String> = row.get(3)?;
253    let name: String = row.get(4)?;
254    let description: Option<String> = row.get(5)?;
255    let properties_str: Option<String> = row.get(6)?;
256    let tags_str: String = row.get(7)?;
257    let created_at: i64 = row.get(8)?;
258    let updated_at: i64 = row.get(9)?;
259    let deleted_at: Option<i64> = row.get(10)?;
260    let merged_into_str: Option<String> = row.get(11)?;
261    let merge_event_id_str: Option<String> = row.get(12)?;
262    let content_ref: Option<String> = row.get(13)?;
263
264    let id = parse_uuid(&id_str)?;
265
266    let properties = properties_str
267        .map(|s| {
268            serde_json::from_str(&s).map_err(|e| {
269                rusqlite::Error::FromSqlConversionFailure(
270                    6,
271                    rusqlite::types::Type::Text,
272                    Box::new(e),
273                )
274            })
275        })
276        .transpose()?;
277
278    let tags: Vec<String> = serde_json::from_str(&tags_str).map_err(|e| {
279        rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
280    })?;
281
282    let merged_into = merged_into_str
283        .as_deref()
284        .map(Uuid::parse_str)
285        .transpose()
286        .map_err(|e| {
287            rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
288        })?;
289
290    let merge_event_id = merge_event_id_str
291        .as_deref()
292        .map(Uuid::parse_str)
293        .transpose()
294        .map_err(|e| {
295            rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
296        })?;
297
298    Ok(Entity {
299        id,
300        namespace,
301        kind,
302        entity_type,
303        name,
304        description,
305        properties,
306        tags,
307        created_at,
308        updated_at,
309        deleted_at,
310        merged_into,
311        merge_event_id,
312        content_ref,
313    })
314}
315
316/// DML-only batch upsert loop shared by both the legacy (flag-off) and
317/// WriterTask-routed (flag-on) `upsert_entities` paths (ADR-067 slice 1).
318///
319/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
320/// enclosing transaction. Per-row failures are captured into
321/// `BatchWriteSummary::failed`/`first_error` rather than aborting the loop,
322/// matching the existing partial-success contract: this function's own
323/// `Result` is `Ok` unless a caller bug is present, since no branch here
324/// returns `Err`.
325fn batch_upsert_entities(
326    conn: &rusqlite::Connection,
327    entities: &[Entity],
328    attempted: u64,
329) -> Result<BatchWriteSummary, rusqlite::Error> {
330    let mut affected = 0u64;
331    let mut failed = 0u64;
332    let mut first_error = String::new();
333
334    for entity in entities {
335        let id_str = entity.id.to_string();
336        let properties_str = entity
337            .properties
338            .as_ref()
339            .map(|v| serde_json::to_string(v).unwrap_or_default());
340        let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
341
342        let merged_into_str = entity.merged_into.map(|u| u.to_string());
343        let merge_event_id_str = entity.merge_event_id.map(|u| u.to_string());
344        match conn.execute(
345            "INSERT OR REPLACE INTO entities \
346             (id, namespace, kind, entity_type, name, description, properties, tags, \
347              created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref) \
348             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
349            rusqlite::params![
350                id_str,
351                &entity.namespace,
352                entity.kind,
353                entity.entity_type,
354                entity.name,
355                entity.description,
356                properties_str,
357                tags_str,
358                entity.created_at,
359                entity.updated_at,
360                entity.deleted_at,
361                merged_into_str,
362                merge_event_id_str,
363                entity.content_ref,
364            ],
365        ) {
366            Ok(_) => affected += 1,
367            Err(e) => {
368                if first_error.is_empty() {
369                    first_error = e.to_string();
370                }
371                failed += 1;
372            }
373        }
374    }
375
376    Ok(BatchWriteSummary {
377        attempted,
378        affected,
379        failed,
380        first_error,
381    })
382}
383
384fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
385    Uuid::parse_str(s).map_err(|e| {
386        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
387    })
388}
389
390/// Escape SQLite `LIKE` wildcard characters (`%`, `_`) and the escape
391/// character itself (`\`) so a caller-supplied name is matched literally
392/// under `LIKE ... ESCAPE '\'` rather than as a pattern (#818: an
393/// entity named e.g. `a_b` must not also match `aXb`, and a name containing
394/// `%` must not silently widen into a broad substring scan).
395fn escape_like(input: &str) -> String {
396    let mut out = String::with_capacity(input.len());
397    for c in input.chars() {
398        if matches!(c, '\\' | '%' | '_') {
399            out.push('\\');
400        }
401        out.push(c);
402    }
403    out
404}
405
406fn build_entity_where(
407    namespace: &str,
408    filter: &EntityFilter,
409) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
410    // When filter.namespaces is non-empty use `namespace IN (...)` so that
411    // multi-namespace read visibility works.  Otherwise fall back to the
412    // single-namespace equality check for backward compatibility.
413    let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
414        if !filter.namespaces.is_empty() {
415            let placeholders: Vec<String> = (1..=filter.namespaces.len())
416                .map(|i| format!("?{i}"))
417                .collect();
418            let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
419                .namespaces
420                .iter()
421                .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
422                .collect();
423            (
424                format!("namespace IN ({})", placeholders.join(", ")),
425                params,
426            )
427        } else {
428            (
429                "namespace = ?1".to_string(),
430                vec![Box::new(namespace.to_string())],
431            )
432        };
433
434    let mut conditions: Vec<String> = vec![ns_condition, "deleted_at IS NULL".to_string()];
435    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
436
437    if !filter.ids.is_empty() {
438        let placeholders: Vec<String> = filter
439            .ids
440            .iter()
441            .map(|id| {
442                params.push(Box::new(id.to_string()));
443                format!("?{}", params.len())
444            })
445            .collect();
446        conditions.push(format!("id IN ({})", placeholders.join(", ")));
447    }
448
449    if !filter.kinds.is_empty() {
450        let placeholders: Vec<String> = filter
451            .kinds
452            .iter()
453            .map(|k| {
454                params.push(Box::new(k.clone()));
455                format!("?{}", params.len())
456            })
457            .collect();
458        conditions.push(format!("kind IN ({})", placeholders.join(", ")));
459    }
460
461    if !filter.entity_types.is_empty() {
462        let placeholders: Vec<String> = filter
463            .entity_types
464            .iter()
465            .map(|t| {
466                params.push(Box::new(t.clone()));
467                format!("?{}", params.len())
468            })
469            .collect();
470        conditions.push(format!("entity_type IN ({})", placeholders.join(", ")));
471    }
472
473    if let Some(ref prefix) = filter.name_prefix {
474        params.push(Box::new(format!("{}%", escape_like(prefix))));
475        conditions.push(format!("name LIKE ?{} ESCAPE '\\'", params.len()));
476    }
477
478    if let Some(ref exact) = filter.name_exact {
479        params.push(Box::new(exact.clone()));
480        // `entities.name` has no `COLLATE NOCASE` (see sql/schema.sql), so
481        // `=` is already SQLite's default case-sensitive BINARY comparison.
482        // `COLLATE BINARY` is spelled out here so this predicate stays
483        // correct even if the column's default collation ever changes.
484        conditions.push(format!("name = ?{} COLLATE BINARY", params.len()));
485    }
486
487    if !filter.names_ci.is_empty() {
488        // ADR-104 Stage C, R1: one batched `LOWER(name) IN (...)` predicate,
489        // served by `idx_entities_namespace_name_ci (namespace, LOWER(name))`.
490        let placeholders: Vec<String> = filter
491            .names_ci
492            .iter()
493            .map(|n| {
494                params.push(Box::new(n.to_ascii_lowercase()));
495                format!("?{}", params.len())
496            })
497            .collect();
498        conditions.push(format!("LOWER(name) IN ({})", placeholders.join(", ")));
499    }
500
501    if !filter.tags_any.is_empty() {
502        let placeholders: Vec<String> = filter
503            .tags_any
504            .iter()
505            .map(|t| {
506                // Normalise to lowercase so the comparison is case-insensitive
507                // domain filter must be case-insensitive.
508                params.push(Box::new(t.to_lowercase()));
509                format!("?{}", params.len())
510            })
511            .collect();
512        conditions.push(format!(
513            "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) IN ({}))",
514            placeholders.join(", ")
515        ));
516    }
517
518    let clause = format!(" WHERE {}", conditions.join(" AND "));
519    (clause, params)
520}
521
522fn build_candidate_entity_query(
523    columns: &str,
524    where_sql: &str,
525    candidate_param_indices: &[usize],
526    order_by: &str,
527    limit_idx: usize,
528    offset_idx: usize,
529) -> String {
530    let candidate_rows = candidate_param_indices
531        .iter()
532        .map(|idx| format!("(?{idx})"))
533        .collect::<Vec<_>>()
534        .join(", ");
535
536    format!(
537        "WITH candidates(folded_name) AS (VALUES {candidate_rows}), \
538         matched_entities(entity_id) AS (\
539             SELECT (\
540                 SELECT id FROM entities{where_sql} \
541                 AND LOWER(name) = candidates.folded_name LIMIT 1\
542             ) FROM candidates\
543         ) \
544         SELECT {columns} FROM entities \
545         JOIN matched_entities ON entities.id = matched_entities.entity_id \
546         ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
547    )
548}
549
550// =============================================================================
551// EntityStore implementation
552// =============================================================================
553
554#[async_trait]
555impl EntityStore for SqlEntityStore {
556    async fn upsert_entity(&self, entity: Entity) -> Result<(), StorageError> {
557        let statement = entity_upsert_statement(&entity);
558        self.with_writer("upsert_entity", move |conn| {
559            let mut stmt = conn.prepare(&statement.sql)?;
560            bind_params(&mut stmt, &statement.params)?;
561            stmt.raw_execute()?;
562            Ok(())
563        })
564        .await
565    }
566
567    async fn upsert_entities(
568        &self,
569        entities: Vec<Entity>,
570    ) -> Result<BatchWriteSummary, StorageError> {
571        let attempted = entities.len() as u64;
572
573        // ADR-067 slice 1: when the write queue is enabled, route through
574        // the WriterTask channel. The closure is DML-only — no BEGIN
575        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
576        // owns the transaction and `WriteRequest::execute_and_reply` owns
577        // the commit/rollback decision (a bare BEGIN IMMEDIATE inside this
578        // closure would violate SQLite's nested-transaction rule).
579        if let Some(writer_task) = &self.writer_task {
580            return writer_task
581                .send(move |conn| {
582                    batch_upsert_entities(conn, &entities, attempted)
583                        .map_err(|e| map_err(e, "upsert_entities"))
584                })
585                .await;
586        }
587
588        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
589        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT/ROLLBACK
590        // via the pool-mutex writer.
591        self.with_writer("upsert_entities", move |conn| {
592            conn.execute_batch("BEGIN IMMEDIATE")?;
593            let _tx_handle =
594                khive_storage::tx_registry::register(Some("entity_upsert_batch".to_string()));
595
596            let summary = batch_upsert_entities(conn, &entities, attempted)?;
597
598            if let Err(e) = conn.execute_batch("COMMIT") {
599                let _ = conn.execute_batch("ROLLBACK");
600                return Err(e);
601            }
602            Ok(summary)
603        })
604        .await
605    }
606
607    async fn get_entity(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
608        let id_str = id.to_string();
609
610        self.with_reader("get_entity", move |conn| {
611            let mut stmt = conn.prepare(
612                "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
613                 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref \
614                 FROM entities WHERE id = ?1 AND deleted_at IS NULL",
615            )?;
616            let mut rows = stmt.query(rusqlite::params![id_str])?;
617            match rows.next()? {
618                Some(row) => Ok(Some(read_entity(row)?)),
619                None => Ok(None),
620            }
621        })
622        .await
623    }
624
625    async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
626        match mode {
627            DeleteMode::Soft => {
628                let now = chrono::Utc::now().timestamp_micros();
629                let statement = entity_soft_delete_statement(id, now);
630                self.with_writer("delete_entity_soft", move |conn| {
631                    let mut stmt = conn.prepare(&statement.sql)?;
632                    bind_params(&mut stmt, &statement.params)?;
633                    Ok(stmt.raw_execute()? > 0)
634                })
635                .await
636            }
637            DeleteMode::Hard => {
638                let statement = entity_hard_delete_statement(id);
639                self.with_writer("delete_entity_hard", move |conn| {
640                    let mut stmt = conn.prepare(&statement.sql)?;
641                    bind_params(&mut stmt, &statement.params)?;
642                    Ok(stmt.raw_execute()? > 0)
643                })
644                .await
645            }
646        }
647    }
648
649    async fn query_entities(
650        &self,
651        namespace: &str,
652        filter: EntityFilter,
653        page: PageRequest,
654    ) -> Result<Page<Entity>, StorageError> {
655        let namespace = namespace.to_string();
656        let limit_i64 = i64::from(page.limit);
657        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
658            capability: StorageCapability::Entities,
659            operation: "query_entities".into(),
660            message: format!(
661                "PageRequest: offset must be <= i64::MAX, got {}",
662                page.offset
663            ),
664        })?;
665
666        self.with_reader("query_entities", move |conn| {
667            let total = if filter.names_ci.is_empty() {
668                let (count_sql, count_params) = build_entity_where(&namespace, &filter);
669                let sql = format!("SELECT COUNT(*) FROM entities{count_sql}");
670                let mut stmt = conn.prepare(&sql)?;
671                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
672                    count_params.iter().map(|p| p.as_ref()).collect();
673                Some(stmt.query_row(param_refs.as_slice(), |row| row.get::<_, i64>(0))? as u64)
674            } else {
675                None
676            };
677
678            let mut lookup_filter = filter.clone();
679            lookup_filter.names_ci.clear();
680            let effective_filter = if filter.names_ci.is_empty() {
681                &filter
682            } else {
683                &lookup_filter
684            };
685            let (where_sql, mut data_params) = build_entity_where(&namespace, effective_filter);
686
687            let candidate_param_indices = if filter.names_ci.is_empty() {
688                Vec::new()
689            } else {
690                let mut candidates: Vec<String> = filter
691                    .names_ci
692                    .iter()
693                    .map(|name| name.to_ascii_lowercase())
694                    .collect();
695                candidates.sort_unstable();
696                candidates.dedup();
697                candidates
698                    .into_iter()
699                    .map(|candidate| {
700                        data_params.push(Box::new(candidate));
701                        data_params.len()
702                    })
703                    .collect()
704            };
705
706            // #818: when a name_prefix filter is active, an exact
707            // ASCII-case-insensitive match must never be pushed out of the page by
708            // pattern candidates that merely share the prefix. Rank exact
709            // matches first (deterministic tiebreak via created_at) so page
710            // truncation can never hide the record a caller resolved by name.
711            let order_by = if let Some(ref prefix) = filter.name_prefix {
712                data_params.push(Box::new(prefix.to_ascii_lowercase()));
713                format!(
714                    "CASE WHEN LOWER(name) = ?{} THEN 0 ELSE 1 END, created_at DESC",
715                    data_params.len()
716                )
717            } else {
718                "created_at DESC".to_string()
719            };
720
721            data_params.push(Box::new(limit_i64));
722            data_params.push(Box::new(offset_i64));
723
724            let limit_idx = data_params.len() - 1;
725            let offset_idx = data_params.len();
726
727            let columns = "id, namespace, kind, entity_type, name, description, properties, tags, \
728                           created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref";
729            let data_sql = if filter.names_ci.is_empty() {
730                format!(
731                    "SELECT {columns} FROM entities{where_sql} \
732                     ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
733                )
734            } else {
735                build_candidate_entity_query(
736                    columns,
737                    &where_sql,
738                    &candidate_param_indices,
739                    &order_by,
740                    limit_idx,
741                    offset_idx,
742                )
743            };
744
745            let mut stmt = conn.prepare(&data_sql)?;
746            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
747                data_params.iter().map(|p| p.as_ref()).collect();
748            let rows = stmt.query_map(param_refs.as_slice(), read_entity)?;
749
750            let mut items = Vec::new();
751            for row in rows {
752                items.push(row?);
753            }
754
755            Ok(Page { items, total })
756        })
757        .await
758    }
759
760    async fn get_entity_including_deleted(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
761        let id_str = id.to_string();
762
763        self.with_reader("get_entity_including_deleted", move |conn| {
764            let mut stmt = conn.prepare(
765                "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
766                 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref \
767                 FROM entities WHERE id = ?1",
768            )?;
769            let mut rows = stmt.query(rusqlite::params![id_str])?;
770            match rows.next()? {
771                Some(row) => Ok(Some(read_entity(row)?)),
772                None => Ok(None),
773            }
774        })
775        .await
776    }
777
778    async fn count_entities(
779        &self,
780        namespace: &str,
781        filter: EntityFilter,
782    ) -> Result<u64, StorageError> {
783        let namespace = namespace.to_string();
784
785        self.with_reader("count_entities", move |conn| {
786            let (where_sql, params) = build_entity_where(&namespace, &filter);
787            let sql = format!("SELECT COUNT(*) FROM entities{}", where_sql);
788            let mut stmt = conn.prepare(&sql)?;
789            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
790                params.iter().map(|p| p.as_ref()).collect();
791            let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
792            Ok(count as u64)
793        })
794        .await
795    }
796}
797
798// =============================================================================
799// DDL
800// =============================================================================
801
802const ENTITIES_DDL: &str = include_str!("../../sql/entities-ddl.sql");
803
804pub(crate) fn ensure_entities_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
805    conn.execute_batch(ENTITIES_DDL)
806}
807
808#[cfg(test)]
809#[path = "entity_tests.rs"]
810mod tests;