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