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::{BatchWriteSummary, DeleteMode, Page, PageRequest};
11use khive_storage::EntityStore;
12use khive_storage::StorageCapability;
13
14use crate::error::SqliteError;
15use crate::pool::ConnectionPool;
16
17fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
18    StorageError::driver(StorageCapability::Entities, op, e)
19}
20
21fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
22    StorageError::driver(StorageCapability::Entities, op, e)
23}
24
25/// An EntityStore backed by SQLite. Namespace is the caller's responsibility.
26///
27/// UUID is globally unique — get/delete by ID alone. Query/count use the
28/// namespace parameter as passed. The store is just a pool + is_file_backed.
29pub struct SqlEntityStore {
30    pool: Arc<ConnectionPool>,
31    is_file_backed: bool,
32}
33
34impl SqlEntityStore {
35    /// Create a new store.
36    pub fn new(pool: Arc<ConnectionPool>, is_file_backed: bool) -> Self {
37        Self {
38            pool,
39            is_file_backed,
40        }
41    }
42
43    fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
44        let config = self.pool.config();
45        let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
46            operation: "entity_reader".into(),
47            message: "in-memory databases do not support standalone connections".into(),
48        })?;
49
50        let conn = rusqlite::Connection::open_with_flags(
51            path,
52            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
53                | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
54                | rusqlite::OpenFlags::SQLITE_OPEN_URI,
55        )
56        .map_err(|e| map_err(e, "open_entity_reader"))?;
57
58        conn.busy_timeout(config.busy_timeout)
59            .map_err(|e| map_err(e, "open_entity_reader"))?;
60        conn.pragma_update(None, "foreign_keys", "ON")
61            .map_err(|e| map_err(e, "open_entity_reader"))?;
62        conn.pragma_update(None, "synchronous", "NORMAL")
63            .map_err(|e| map_err(e, "open_entity_reader"))?;
64
65        Ok(conn)
66    }
67
68    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
69    where
70        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
71        R: Send + 'static,
72    {
73        let pool = Arc::clone(&self.pool);
74        tokio::task::spawn_blocking(move || {
75            let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
76            f(guard.conn()).map_err(|e| map_err(e, op))
77        })
78        .await
79        .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
80    }
81
82    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
83    where
84        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
85        R: Send + 'static,
86    {
87        if self.is_file_backed {
88            let conn = self.open_standalone_reader()?;
89            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
90                .await
91                .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
92        } else {
93            let pool = Arc::clone(&self.pool);
94            tokio::task::spawn_blocking(move || {
95                let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
96                f(guard.conn()).map_err(|e| map_err(e, op))
97            })
98            .await
99            .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
100        }
101    }
102}
103
104// =============================================================================
105// Helpers
106// =============================================================================
107
108fn read_entity(row: &rusqlite::Row<'_>) -> Result<Entity, rusqlite::Error> {
109    let id_str: String = row.get(0)?;
110    let namespace: String = row.get(1)?;
111    let kind: String = row.get(2)?;
112    let entity_type: Option<String> = row.get(3)?;
113    let name: String = row.get(4)?;
114    let description: Option<String> = row.get(5)?;
115    let properties_str: Option<String> = row.get(6)?;
116    let tags_str: String = row.get(7)?;
117    let created_at: i64 = row.get(8)?;
118    let updated_at: i64 = row.get(9)?;
119    let deleted_at: Option<i64> = row.get(10)?;
120    let merged_into_str: Option<String> = row.get(11)?;
121    let merge_event_id_str: Option<String> = row.get(12)?;
122
123    let id = parse_uuid(&id_str)?;
124
125    let properties = properties_str
126        .map(|s| {
127            serde_json::from_str(&s).map_err(|e| {
128                rusqlite::Error::FromSqlConversionFailure(
129                    6,
130                    rusqlite::types::Type::Text,
131                    Box::new(e),
132                )
133            })
134        })
135        .transpose()?;
136
137    let tags: Vec<String> = serde_json::from_str(&tags_str).map_err(|e| {
138        rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
139    })?;
140
141    let merged_into = merged_into_str
142        .as_deref()
143        .map(Uuid::parse_str)
144        .transpose()
145        .map_err(|e| {
146            rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
147        })?;
148
149    let merge_event_id = merge_event_id_str
150        .as_deref()
151        .map(Uuid::parse_str)
152        .transpose()
153        .map_err(|e| {
154            rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
155        })?;
156
157    Ok(Entity {
158        id,
159        namespace,
160        kind,
161        entity_type,
162        name,
163        description,
164        properties,
165        tags,
166        created_at,
167        updated_at,
168        deleted_at,
169        merged_into,
170        merge_event_id,
171    })
172}
173
174fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
175    Uuid::parse_str(s).map_err(|e| {
176        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
177    })
178}
179
180fn build_entity_where(
181    namespace: &str,
182    filter: &EntityFilter,
183) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
184    // When filter.namespaces is non-empty use `namespace IN (...)` so that
185    // multi-namespace read visibility works.  Otherwise fall back to the
186    // single-namespace equality check for backward compatibility.
187    let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
188        if !filter.namespaces.is_empty() {
189            let placeholders: Vec<String> = (1..=filter.namespaces.len())
190                .map(|i| format!("?{i}"))
191                .collect();
192            let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
193                .namespaces
194                .iter()
195                .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
196                .collect();
197            (
198                format!("namespace IN ({})", placeholders.join(", ")),
199                params,
200            )
201        } else {
202            (
203                "namespace = ?1".to_string(),
204                vec![Box::new(namespace.to_string())],
205            )
206        };
207
208    let mut conditions: Vec<String> = vec![ns_condition, "deleted_at IS NULL".to_string()];
209    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
210
211    if !filter.ids.is_empty() {
212        let placeholders: Vec<String> = filter
213            .ids
214            .iter()
215            .map(|id| {
216                params.push(Box::new(id.to_string()));
217                format!("?{}", params.len())
218            })
219            .collect();
220        conditions.push(format!("id IN ({})", placeholders.join(", ")));
221    }
222
223    if !filter.kinds.is_empty() {
224        let placeholders: Vec<String> = filter
225            .kinds
226            .iter()
227            .map(|k| {
228                params.push(Box::new(k.clone()));
229                format!("?{}", params.len())
230            })
231            .collect();
232        conditions.push(format!("kind IN ({})", placeholders.join(", ")));
233    }
234
235    if !filter.entity_types.is_empty() {
236        let placeholders: Vec<String> = filter
237            .entity_types
238            .iter()
239            .map(|t| {
240                params.push(Box::new(t.clone()));
241                format!("?{}", params.len())
242            })
243            .collect();
244        conditions.push(format!("entity_type IN ({})", placeholders.join(", ")));
245    }
246
247    if let Some(ref prefix) = filter.name_prefix {
248        params.push(Box::new(format!("{}%", prefix)));
249        conditions.push(format!("name LIKE ?{}", params.len()));
250    }
251
252    if !filter.tags_any.is_empty() {
253        let placeholders: Vec<String> = filter
254            .tags_any
255            .iter()
256            .map(|t| {
257                // Normalise to lowercase so the comparison is case-insensitive
258                // domain filter must be case-insensitive.
259                params.push(Box::new(t.to_lowercase()));
260                format!("?{}", params.len())
261            })
262            .collect();
263        conditions.push(format!(
264            "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) IN ({}))",
265            placeholders.join(", ")
266        ));
267    }
268
269    let clause = format!(" WHERE {}", conditions.join(" AND "));
270    (clause, params)
271}
272
273// =============================================================================
274// EntityStore implementation
275// =============================================================================
276
277#[async_trait]
278impl EntityStore for SqlEntityStore {
279    async fn upsert_entity(&self, entity: Entity) -> Result<(), StorageError> {
280        let namespace = entity.namespace.clone();
281        let id_str = entity.id.to_string();
282        let properties_str = entity
283            .properties
284            .as_ref()
285            .map(|v| serde_json::to_string(v).unwrap_or_default());
286        let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
287
288        let merged_into_str = entity.merged_into.map(|u| u.to_string());
289        let merge_event_id_str = entity.merge_event_id.map(|u| u.to_string());
290
291        self.with_writer("upsert_entity", move |conn| {
292            conn.execute(
293                "INSERT OR REPLACE INTO entities \
294                 (id, namespace, kind, entity_type, name, description, properties, tags, \
295                  created_at, updated_at, deleted_at, merged_into, merge_event_id) \
296                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
297                rusqlite::params![
298                    id_str,
299                    namespace,
300                    entity.kind,
301                    entity.entity_type,
302                    entity.name,
303                    entity.description,
304                    properties_str,
305                    tags_str,
306                    entity.created_at,
307                    entity.updated_at,
308                    entity.deleted_at,
309                    merged_into_str,
310                    merge_event_id_str,
311                ],
312            )?;
313            Ok(())
314        })
315        .await
316    }
317
318    async fn upsert_entities(
319        &self,
320        entities: Vec<Entity>,
321    ) -> Result<BatchWriteSummary, StorageError> {
322        let attempted = entities.len() as u64;
323
324        self.with_writer("upsert_entities", move |conn| {
325            conn.execute_batch("BEGIN IMMEDIATE")?;
326            let mut affected = 0u64;
327            let mut failed = 0u64;
328            let mut first_error = String::new();
329
330            for entity in &entities {
331                let id_str = entity.id.to_string();
332                let properties_str = entity
333                    .properties
334                    .as_ref()
335                    .map(|v| serde_json::to_string(v).unwrap_or_default());
336                let tags_str =
337                    serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
338
339                let merged_into_str = entity.merged_into.map(|u| u.to_string());
340                let merge_event_id_str = entity.merge_event_id.map(|u| u.to_string());
341                match conn.execute(
342                    "INSERT OR REPLACE INTO entities \
343                     (id, namespace, kind, entity_type, name, description, properties, tags, \
344                      created_at, updated_at, deleted_at, merged_into, merge_event_id) \
345                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
346                    rusqlite::params![
347                        id_str,
348                        &entity.namespace,
349                        entity.kind,
350                        entity.entity_type,
351                        entity.name,
352                        entity.description,
353                        properties_str,
354                        tags_str,
355                        entity.created_at,
356                        entity.updated_at,
357                        entity.deleted_at,
358                        merged_into_str,
359                        merge_event_id_str,
360                    ],
361                ) {
362                    Ok(_) => affected += 1,
363                    Err(e) => {
364                        if first_error.is_empty() {
365                            first_error = e.to_string();
366                        }
367                        failed += 1;
368                    }
369                }
370            }
371
372            if let Err(e) = conn.execute_batch("COMMIT") {
373                let _ = conn.execute_batch("ROLLBACK");
374                return Err(e);
375            }
376            Ok(BatchWriteSummary {
377                attempted,
378                affected,
379                failed,
380                first_error,
381            })
382        })
383        .await
384    }
385
386    async fn get_entity(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
387        let id_str = id.to_string();
388
389        self.with_reader("get_entity", move |conn| {
390            let mut stmt = conn.prepare(
391                "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
392                 created_at, updated_at, deleted_at, merged_into, merge_event_id \
393                 FROM entities WHERE id = ?1 AND deleted_at IS NULL",
394            )?;
395            let mut rows = stmt.query(rusqlite::params![id_str])?;
396            match rows.next()? {
397                Some(row) => Ok(Some(read_entity(row)?)),
398                None => Ok(None),
399            }
400        })
401        .await
402    }
403
404    async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
405        let id_str = id.to_string();
406
407        match mode {
408            DeleteMode::Soft => {
409                self.with_writer("delete_entity_soft", move |conn| {
410                    let now = chrono::Utc::now().timestamp_micros();
411                    let deleted = conn.execute(
412                        "UPDATE entities SET deleted_at = ?1 \
413                         WHERE id = ?2 AND deleted_at IS NULL",
414                        rusqlite::params![now, id_str],
415                    )?;
416                    Ok(deleted > 0)
417                })
418                .await
419            }
420            DeleteMode::Hard => {
421                self.with_writer("delete_entity_hard", move |conn| {
422                    let deleted = conn.execute(
423                        "DELETE FROM entities WHERE id = ?1",
424                        rusqlite::params![id_str],
425                    )?;
426                    Ok(deleted > 0)
427                })
428                .await
429            }
430        }
431    }
432
433    async fn query_entities(
434        &self,
435        namespace: &str,
436        filter: EntityFilter,
437        page: PageRequest,
438    ) -> Result<Page<Entity>, StorageError> {
439        let namespace = namespace.to_string();
440
441        self.with_reader("query_entities", move |conn| {
442            let (count_sql, count_params) = build_entity_where(&namespace, &filter);
443            let total: i64 = {
444                let sql = format!("SELECT COUNT(*) FROM entities{}", count_sql);
445                let mut stmt = conn.prepare(&sql)?;
446                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
447                    count_params.iter().map(|p| p.as_ref()).collect();
448                stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
449            };
450
451            let (where_sql, mut data_params) = build_entity_where(&namespace, &filter);
452            data_params.push(Box::new(page.limit as i64));
453            data_params.push(Box::new(page.offset as i64));
454
455            let limit_idx = data_params.len() - 1;
456            let offset_idx = data_params.len();
457
458            let data_sql = format!(
459                "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
460                 created_at, updated_at, deleted_at, merged_into, merge_event_id \
461                 FROM entities{} ORDER BY created_at DESC LIMIT ?{} OFFSET ?{}",
462                where_sql, limit_idx, offset_idx,
463            );
464
465            let mut stmt = conn.prepare(&data_sql)?;
466            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
467                data_params.iter().map(|p| p.as_ref()).collect();
468            let rows = stmt.query_map(param_refs.as_slice(), read_entity)?;
469
470            let mut items = Vec::new();
471            for row in rows {
472                items.push(row?);
473            }
474
475            Ok(Page {
476                items,
477                total: Some(total as u64),
478            })
479        })
480        .await
481    }
482
483    async fn get_entity_including_deleted(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
484        let id_str = id.to_string();
485
486        self.with_reader("get_entity_including_deleted", move |conn| {
487            let mut stmt = conn.prepare(
488                "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
489                 created_at, updated_at, deleted_at, merged_into, merge_event_id \
490                 FROM entities WHERE id = ?1",
491            )?;
492            let mut rows = stmt.query(rusqlite::params![id_str])?;
493            match rows.next()? {
494                Some(row) => Ok(Some(read_entity(row)?)),
495                None => Ok(None),
496            }
497        })
498        .await
499    }
500
501    async fn count_entities(
502        &self,
503        namespace: &str,
504        filter: EntityFilter,
505    ) -> Result<u64, StorageError> {
506        let namespace = namespace.to_string();
507
508        self.with_reader("count_entities", move |conn| {
509            let (where_sql, params) = build_entity_where(&namespace, &filter);
510            let sql = format!("SELECT COUNT(*) FROM entities{}", where_sql);
511            let mut stmt = conn.prepare(&sql)?;
512            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
513                params.iter().map(|p| p.as_ref()).collect();
514            let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
515            Ok(count as u64)
516        })
517        .await
518    }
519}
520
521// =============================================================================
522// DDL
523// =============================================================================
524
525const ENTITIES_DDL: &str = include_str!("../../sql/entities-ddl.sql");
526
527pub(crate) fn ensure_entities_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
528    conn.execute_batch(ENTITIES_DDL)
529}
530
531#[cfg(test)]
532#[path = "entity_tests.rs"]
533mod tests;