1use std::collections::HashSet;
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use rusqlite::OptionalExtension;
8use uuid::Uuid;
9
10use khive_storage::attachment::{Attachment, AttachmentSubstrate};
11use khive_storage::entity::{Entity, EntityFilter};
12use khive_storage::error::{StorageError, WriterTaskRequestState};
13use khive_storage::types::{
14 BatchWriteSummary, DeleteMode, Page, PageRequest, SeekCursor, SeekPage, SqlStatement, SqlValue,
15};
16use khive_storage::EntityStore;
17use khive_storage::StorageCapability;
18
19use crate::error::SqliteError;
20use crate::pool::ConnectionPool;
21use crate::sql_bridge::bind_params;
22use crate::stores::attachment::{attachment_upsert_statement, 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::Entities, op, e)
27}
28
29fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
30 StorageError::driver(StorageCapability::Entities, op, e)
31}
32
33const NAMESPACE_COUNT_CHUNK_SIZE: usize = 500;
34
35const ENTITY_SELECT_COLUMNS: &str =
36 "entities.id, entities.namespace, entities.kind, entities.entity_type, entities.name, \
37 entities.description, entities.properties, entities.tags, entities.created_at, \
38 entities.updated_at, entities.deleted_at, entities.merged_into, entities.merge_event_id, \
39 (SELECT attachment.content_ref FROM attachments AS attachment \
40 WHERE attachment.record_uuid = entities.id \
41 AND attachment.substrate = 'entity' AND attachment.role = 'content') AS content_ref, entities.version";
42
43pub fn entity_upsert_statement(entity: &Entity) -> SqlStatement {
59 let mut statement = entity_write_statement(entity, "INSERT", "entity-upsert");
60 statement.sql.push_str(
61 " ON CONFLICT(id) DO UPDATE SET namespace=excluded.namespace, kind=excluded.kind, \
62 entity_type=excluded.entity_type, name=excluded.name, description=excluded.description, \
63 properties=excluded.properties, tags=excluded.tags, created_at=excluded.created_at, \
64 updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, \
65 merged_into=excluded.merged_into, merge_event_id=excluded.merge_event_id, \
66 version=entities.version+1",
67 );
68 statement
69}
70
71pub fn entity_insert_statement(entity: &Entity) -> SqlStatement {
75 entity_write_statement(entity, "INSERT", "entity-insert")
76}
77
78fn entity_write_statement(entity: &Entity, insert: &str, label: &str) -> SqlStatement {
79 let properties_str = entity
80 .properties
81 .as_ref()
82 .map(|v| serde_json::to_string(v).unwrap_or_default());
83 let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
84 SqlStatement {
85 sql: format!(
86 "{insert} INTO entities \
87 (id, namespace, kind, entity_type, name, description, properties, tags, \
88 created_at, updated_at, deleted_at, merged_into, merge_event_id) \
89 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"
90 ),
91 params: vec![
92 SqlValue::Text(entity.id.to_string()),
93 SqlValue::Text(entity.namespace.clone()),
94 SqlValue::Text(entity.kind.clone()),
95 match &entity.entity_type {
96 Some(t) => SqlValue::Text(t.clone()),
97 None => SqlValue::Null,
98 },
99 SqlValue::Text(entity.name.clone()),
100 match &entity.description {
101 Some(d) => SqlValue::Text(d.clone()),
102 None => SqlValue::Null,
103 },
104 match properties_str {
105 Some(p) => SqlValue::Text(p),
106 None => SqlValue::Null,
107 },
108 SqlValue::Text(tags_str),
109 SqlValue::Integer(entity.created_at),
110 SqlValue::Integer(entity.updated_at),
111 match entity.deleted_at {
112 Some(d) => SqlValue::Integer(d),
113 None => SqlValue::Null,
114 },
115 match entity.merged_into {
116 Some(u) => SqlValue::Text(u.to_string()),
117 None => SqlValue::Null,
118 },
119 match entity.merge_event_id {
120 Some(u) => SqlValue::Text(u.to_string()),
121 None => SqlValue::Null,
122 },
123 ],
124 label: Some(label.to_string()),
125 }
126}
127
128pub fn entity_insert_if_absent_statement(entity: &Entity) -> SqlStatement {
132 let mut statement = entity_upsert_statement(entity);
133 statement.sql = "INSERT INTO entities \
134 (id, namespace, kind, entity_type, name, description, properties, tags, \
135 created_at, updated_at, deleted_at, merged_into, merge_event_id) \
136 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
137 ON CONFLICT DO NOTHING"
138 .to_string();
139 statement.label = Some("entity-insert-if-absent".to_string());
140 statement
141}
142
143pub fn entity_replace_if_unchanged_statement(
154 entity: &Entity,
155 expected_updated_at: i64,
156 expected_deleted_at: Option<i64>,
157) -> SqlStatement {
158 let properties_str = entity
159 .properties
160 .as_ref()
161 .map(|v| serde_json::to_string(v).unwrap_or_default());
162 let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
163 SqlStatement {
164 sql: "UPDATE entities SET \
165 namespace = ?1, kind = ?2, entity_type = ?3, name = ?4, description = ?5, \
166 properties = ?6, tags = ?7, updated_at = ?8, deleted_at = ?9, \
167 merged_into = ?10, merge_event_id = ?11, version = version + 1 \
168 WHERE id = ?12 AND updated_at = ?13 AND deleted_at IS ?14 \
169 AND ?8 > updated_at AND version = ?15"
170 .to_string(),
171 params: vec![
172 SqlValue::Text(entity.namespace.clone()),
173 SqlValue::Text(entity.kind.clone()),
174 match &entity.entity_type {
175 Some(t) => SqlValue::Text(t.clone()),
176 None => SqlValue::Null,
177 },
178 SqlValue::Text(entity.name.clone()),
179 match &entity.description {
180 Some(d) => SqlValue::Text(d.clone()),
181 None => SqlValue::Null,
182 },
183 match properties_str {
184 Some(p) => SqlValue::Text(p),
185 None => SqlValue::Null,
186 },
187 SqlValue::Text(tags_str),
188 SqlValue::Integer(entity.updated_at),
189 match entity.deleted_at {
190 Some(d) => SqlValue::Integer(d),
191 None => SqlValue::Null,
192 },
193 match entity.merged_into {
194 Some(u) => SqlValue::Text(u.to_string()),
195 None => SqlValue::Null,
196 },
197 match entity.merge_event_id {
198 Some(u) => SqlValue::Text(u.to_string()),
199 None => SqlValue::Null,
200 },
201 SqlValue::Text(entity.id.to_string()),
202 SqlValue::Integer(expected_updated_at),
203 match expected_deleted_at {
204 Some(value) => SqlValue::Integer(value),
205 None => SqlValue::Null,
206 },
207 SqlValue::Integer(entity.version),
208 ],
209 label: Some("entity-replace-if-unchanged".to_string()),
210 }
211}
212
213pub fn entity_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
215 SqlStatement {
216 sql: "UPDATE entities SET deleted_at = ?1, version = version + 1 WHERE id = ?2 AND deleted_at IS NULL".to_string(),
217 params: vec![
218 SqlValue::Integer(deleted_at),
219 SqlValue::Text(id.to_string()),
220 ],
221 label: Some("entity-delete-soft".to_string()),
222 }
223}
224
225pub fn entity_hard_delete_statement(id: Uuid) -> SqlStatement {
228 SqlStatement {
229 sql: "DELETE FROM entities WHERE id = ?1".to_string(),
230 params: vec![SqlValue::Text(id.to_string())],
231 label: Some("entity-delete-hard".to_string()),
232 }
233}
234
235pub struct SqlEntityStore {
241 pool: Arc<ConnectionPool>,
242 writer_task: Option<WriterTaskHandle>,
243}
244
245impl SqlEntityStore {
246 pub fn new(pool: Arc<ConnectionPool>, _is_file_backed: bool) -> Self {
264 let writer_task = pool.writer_task_handle().ok().flatten();
272
273 Self { pool, writer_task }
274 }
275
276 fn current_writer_task(
277 &self,
278 operation: &'static str,
279 ) -> Result<Option<WriterTaskHandle>, StorageError> {
280 self.pool
281 .writer_task_for_write(self.writer_task.as_ref(), operation)
282 }
283
284 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
301 where
302 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
303 R: Send + 'static,
304 {
305 if let Some(writer_task) = self.current_writer_task(op)? {
306 return writer_task
307 .send_bounded(move |conn| f(conn).map_err(|e| map_err(e, op)))
308 .await;
309 }
310
311 self.pool
312 .record_direct_route(crate::timeout_sink::Site::DirectRouteEntity);
313 let pool = Arc::clone(&self.pool);
314 tokio::task::spawn_blocking(move || {
315 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
316 f(guard.conn()).map_err(|e| map_err(e, op))
317 })
318 .await
319 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
320 }
321
322 async fn with_writer_tx<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
326 where
327 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
328 R: Send + 'static,
329 {
330 if let Some(writer_task) = self.current_writer_task(op)? {
331 return writer_task
332 .send_bounded(move |conn| f(conn).map_err(|error| map_err(error, op)))
333 .await;
334 }
335
336 self.pool
337 .record_direct_route(crate::timeout_sink::Site::DirectRouteEntity);
338 let pool = Arc::clone(&self.pool);
339 tokio::task::spawn_blocking(move || {
340 let guard = pool
341 .try_writer()
342 .map_err(|error| map_sqlite_err(error, op))?;
343 let conn = guard.conn();
344 if !conn.is_autocommit() {
345 pool.retire_pooled_writer(conn);
346 return Err(StorageError::WriterTaskTerminated {
347 request_state: WriterTaskRequestState::SideEffectsUnknown,
348 });
349 }
350 if let Err(begin_error) = conn.execute_batch("BEGIN IMMEDIATE") {
351 if !conn.is_autocommit() {
352 pool.retire_pooled_writer(conn);
353 return Err(StorageError::WriterTaskTerminated {
354 request_state: WriterTaskRequestState::SideEffectsUnknown,
355 });
356 }
357 return Err(map_err(begin_error, op));
358 }
359
360 let (result, terminal_state) = execute_wrapped_transaction(conn, op, move |conn| {
361 f(conn).map_err(|error| map_err(error, op))
362 });
363 if terminal_state.is_some() {
364 pool.retire_pooled_writer(conn);
365 }
366 result
367 })
368 .await
369 .map_err(|error| StorageError::driver(StorageCapability::Entities, op, error))?
370 }
371
372 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
373 where
374 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
375 R: Send + 'static,
376 {
377 super::run_pooled_store_read(
378 Arc::clone(&self.pool),
379 StorageCapability::Entities,
380 op,
381 move |conn| f(conn).map_err(|error| map_err(error, op)),
382 )
383 .await
384 }
385}
386
387fn read_entity(row: &rusqlite::Row<'_>) -> Result<Entity, rusqlite::Error> {
392 let id_str: String = row.get(0)?;
393 let namespace: String = row.get(1)?;
394 let kind: String = row.get(2)?;
395 let entity_type: Option<String> = row.get(3)?;
396 let name: String = row.get(4)?;
397 let description: Option<String> = row.get(5)?;
398 let properties_str: Option<String> = row.get(6)?;
399 let tags_str: String = row.get(7)?;
400 let created_at: i64 = row.get(8)?;
401 let updated_at: i64 = row.get(9)?;
402 let deleted_at: Option<i64> = row.get(10)?;
403 let merged_into_str: Option<String> = row.get(11)?;
404 let merge_event_id_str: Option<String> = row.get(12)?;
405 let content_ref: Option<String> = row.get(13)?;
406 let version: i64 = row.get(14)?;
407
408 let id = parse_uuid(&id_str)?;
409
410 let properties = properties_str
411 .map(|s| {
412 serde_json::from_str(&s).map_err(|e| {
413 rusqlite::Error::FromSqlConversionFailure(
414 6,
415 rusqlite::types::Type::Text,
416 Box::new(e),
417 )
418 })
419 })
420 .transpose()?;
421
422 let tags: Vec<String> = serde_json::from_str(&tags_str).map_err(|e| {
423 rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
424 })?;
425
426 let merged_into = merged_into_str
427 .as_deref()
428 .map(Uuid::parse_str)
429 .transpose()
430 .map_err(|e| {
431 rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
432 })?;
433
434 let merge_event_id = merge_event_id_str
435 .as_deref()
436 .map(Uuid::parse_str)
437 .transpose()
438 .map_err(|e| {
439 rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
440 })?;
441
442 Ok(Entity {
443 id,
444 namespace,
445 kind,
446 entity_type,
447 name,
448 description,
449 properties,
450 tags,
451 created_at,
452 updated_at,
453 version,
454 deleted_at,
455 merged_into,
456 merge_event_id,
457 content_ref,
458 })
459}
460
461fn batch_upsert_entities(
471 conn: &rusqlite::Connection,
472 entities: &[Entity],
473 attempted: u64,
474) -> Result<BatchWriteSummary, rusqlite::Error> {
475 let mut summary = BatchWriteSummary {
476 attempted,
477 ..BatchWriteSummary::default()
478 };
479
480 for (index, entity) in entities.iter().enumerate() {
481 let id_str = entity.id.to_string();
482 let statement = entity_upsert_statement(entity);
483 let result = (|| {
484 let mut prepared = conn.prepare(&statement.sql)?;
485 bind_params(&mut prepared, &statement.params)?;
486 prepared.raw_execute()
487 })();
488 match result {
489 Ok(_) => summary.affected = summary.affected.saturating_add(1),
490 Err(e) => {
491 let (class, retryability) = super::classify_batch_sqlite_error(&e);
492 summary.record_failure(index, Some(id_str), class, retryability, e.to_string());
493 }
494 }
495 }
496
497 Ok(summary)
498}
499
500fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
501 Uuid::parse_str(s).map_err(|e| {
502 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
503 })
504}
505
506fn escape_like(input: &str) -> String {
512 let mut out = String::with_capacity(input.len());
513 for c in input.chars() {
514 if matches!(c, '\\' | '%' | '_') {
515 out.push('\\');
516 }
517 out.push(c);
518 }
519 out
520}
521
522fn build_entity_where(
523 namespace: &str,
524 filter: &EntityFilter,
525) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
526 let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
530 if !filter.namespaces.is_empty() {
531 let placeholders: Vec<String> = (1..=filter.namespaces.len())
532 .map(|i| format!("?{i}"))
533 .collect();
534 let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
535 .namespaces
536 .iter()
537 .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
538 .collect();
539 (
540 format!("namespace IN ({})", placeholders.join(", ")),
541 params,
542 )
543 } else {
544 (
545 "namespace = ?1".to_string(),
546 vec![Box::new(namespace.to_string())],
547 )
548 };
549
550 let mut conditions: Vec<String> = vec![ns_condition, "deleted_at IS NULL".to_string()];
551 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
552
553 if !filter.ids.is_empty() {
554 let placeholders: Vec<String> = filter
555 .ids
556 .iter()
557 .map(|id| {
558 params.push(Box::new(id.to_string()));
559 format!("?{}", params.len())
560 })
561 .collect();
562 conditions.push(format!("id IN ({})", placeholders.join(", ")));
563 }
564
565 if !filter.kinds.is_empty() {
566 let placeholders: Vec<String> = filter
567 .kinds
568 .iter()
569 .map(|k| {
570 params.push(Box::new(k.clone()));
571 format!("?{}", params.len())
572 })
573 .collect();
574 conditions.push(format!("kind IN ({})", placeholders.join(", ")));
575 }
576
577 let type_scope = conditions.join(" AND ");
578 let type_predicate = |scope: &str, placeholders: &str| {
579 if filter.legacy_entity_type_fallback {
580 format!(
585 "id IN (SELECT id FROM entities WHERE {scope} \
586 AND entity_type IN ({placeholders}) \
587 UNION ALL SELECT id FROM entities WHERE {scope} \
588 AND entity_type IS NULL AND json_valid(properties) \
589 AND json_type(properties, '$.type') = 'text' \
590 AND json_extract(properties, '$.type') IN ({placeholders}))"
591 )
592 } else {
593 format!("entity_type IN ({placeholders})")
594 }
595 };
596 if !filter.entity_types.is_empty() {
597 let placeholders: Vec<String> = filter
598 .entity_types
599 .iter()
600 .map(|t| {
601 params.push(Box::new(t.clone()));
602 format!("?{}", params.len())
603 })
604 .collect();
605 conditions.push(type_predicate(&type_scope, &placeholders.join(", ")));
606 }
607
608 if !filter.entity_types_by_kind.is_empty() {
609 let mut groups = Vec::new();
610 for (kind, types) in &filter.entity_types_by_kind {
611 if types.is_empty() {
612 continue;
613 }
614 params.push(Box::new(kind.clone()));
615 let kind_param = params.len();
616 let placeholders = types
617 .iter()
618 .map(|value| {
619 params.push(Box::new(value.clone()));
620 format!("?{}", params.len())
621 })
622 .collect::<Vec<_>>()
623 .join(", ");
624 let scope = format!("{type_scope} AND kind = ?{kind_param}");
625 let predicate = type_predicate(&scope, &placeholders);
626 groups.push(format!("(kind = ?{kind_param} AND {predicate})"));
627 }
628 conditions.push(if groups.is_empty() {
629 "0".to_string()
630 } else {
631 format!("({})", groups.join(" OR "))
632 });
633 }
634
635 if let Some(ref prefix) = filter.name_prefix {
636 params.push(Box::new(format!("{}%", escape_like(prefix))));
637 conditions.push(format!("name LIKE ?{} ESCAPE '\\'", params.len()));
638 }
639
640 if let Some(ref exact) = filter.name_exact {
641 params.push(Box::new(exact.clone()));
642 conditions.push(format!("name = ?{} COLLATE BINARY", params.len()));
647 }
648
649 if !filter.names_ci.is_empty() {
650 let placeholders: Vec<String> = filter
653 .names_ci
654 .iter()
655 .map(|n| {
656 params.push(Box::new(n.to_ascii_lowercase()));
657 format!("?{}", params.len())
658 })
659 .collect();
660 conditions.push(format!("LOWER(name) IN ({})", placeholders.join(", ")));
661 }
662
663 if !filter.tags_any.is_empty() {
664 let placeholders: Vec<String> = filter
665 .tags_any
666 .iter()
667 .map(|t| {
668 params.push(Box::new(t.to_lowercase()));
671 format!("?{}", params.len())
672 })
673 .collect();
674 conditions.push(format!(
675 "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) IN ({}))",
676 placeholders.join(", ")
677 ));
678 }
679
680 let clause = format!(" WHERE {}", conditions.join(" AND "));
681 (clause, params)
682}
683
684fn build_candidate_entity_query(
685 columns: &str,
686 where_sql: &str,
687 candidate_param_indices: &[usize],
688 order_by: &str,
689 limit_idx: usize,
690 offset_idx: usize,
691) -> String {
692 let candidate_rows = candidate_param_indices
693 .iter()
694 .map(|idx| format!("(?{idx})"))
695 .collect::<Vec<_>>()
696 .join(", ");
697
698 format!(
699 "WITH candidates(folded_name) AS (VALUES {candidate_rows}), \
700 matched_entities(entity_id) AS (\
701 SELECT (\
702 SELECT id FROM entities{where_sql} \
703 AND LOWER(name) = candidates.folded_name LIMIT 1\
704 ) FROM candidates\
705 ) \
706 SELECT {columns} FROM entities \
707 JOIN matched_entities ON entities.id = matched_entities.entity_id \
708 ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
709 )
710}
711
712fn is_complete_id_lookup(filter: &EntityFilter, page: &PageRequest) -> bool {
713 !filter.ids.is_empty()
714 && filter.kinds.is_empty()
715 && filter.entity_types.is_empty()
716 && filter.entity_types_by_kind.is_empty()
717 && filter.name_prefix.is_none()
718 && filter.name_exact.is_none()
719 && filter.tags_any.is_empty()
720 && filter.names_ci.is_empty()
721 && page.offset == 0
722 && usize::try_from(page.limit).ok() == Some(filter.ids.len())
723}
724
725#[async_trait]
730impl EntityStore for SqlEntityStore {
731 async fn upsert_entity(&self, entity: Entity) -> Result<(), StorageError> {
732 let statement = entity_upsert_statement(&entity);
733 self.with_writer("upsert_entity", move |conn| {
734 let mut stmt = conn.prepare(&statement.sql)?;
735 bind_params(&mut stmt, &statement.params)?;
736 stmt.raw_execute()?;
737 Ok(())
738 })
739 .await
740 }
741
742 async fn insert_entity_if_absent(&self, entity: Entity) -> Result<bool, StorageError> {
743 let statement = entity_insert_if_absent_statement(&entity);
744 self.with_writer("insert_entity_if_absent", move |conn| {
745 let mut stmt = conn.prepare(&statement.sql)?;
746 bind_params(&mut stmt, &statement.params)?;
747 Ok(stmt.raw_execute()? > 0)
748 })
749 .await
750 }
751
752 async fn upsert_entity_with_attachments(
753 &self,
754 entity: Entity,
755 attachments: Vec<Attachment>,
756 ) -> Result<(), StorageError> {
757 let entity_id = entity.id;
758 let entity_statement = entity_upsert_statement(&entity);
759 let mut attachment_statements = Vec::with_capacity(attachments.len());
760 for attachment in attachments {
761 attachment.validate()?;
762 if attachment.record_uuid != entity_id
763 || attachment.substrate != AttachmentSubstrate::Entity
764 {
765 return Err(StorageError::InvalidInput {
766 capability: StorageCapability::Attachments,
767 operation: "upsert_entity_with_attachments".into(),
768 message: format!(
769 "attachment {} must target entity {entity_id}",
770 attachment.role
771 ),
772 });
773 }
774 attachment_statements.push(attachment_upsert_statement(&attachment)?);
775 }
776
777 self.with_writer_tx("upsert_entity_with_attachments", move |conn| {
778 let mut entity_stmt = conn.prepare(&entity_statement.sql)?;
779 bind_params(&mut entity_stmt, &entity_statement.params)?;
780 entity_stmt.raw_execute()?;
781 drop(entity_stmt);
782
783 for statement in attachment_statements {
784 let mut stmt = conn.prepare(&statement.sql)?;
785 bind_params(&mut stmt, &statement.params)?;
786 stmt.raw_execute()?;
787 }
788 Ok(())
789 })
790 .await
791 }
792
793 async fn upsert_entities(
794 &self,
795 entities: Vec<Entity>,
796 ) -> Result<BatchWriteSummary, StorageError> {
797 let attempted = entities.len() as u64;
798
799 if let Some(writer_task) = self.current_writer_task("upsert_entities")? {
806 return writer_task
807 .send_bounded(move |conn| {
808 batch_upsert_entities(conn, &entities, attempted)
809 .map_err(|e| map_err(e, "upsert_entities"))
810 })
811 .await;
812 }
813
814 let origin = self.pool.origin();
818 self.with_writer("upsert_entities", move |conn| {
819 conn.execute_batch("BEGIN IMMEDIATE")?;
820 let _tx_handle = khive_storage::tx_registry::register_scoped(
821 Some("entity_upsert_batch".to_string()),
822 origin,
823 );
824
825 let summary = batch_upsert_entities(conn, &entities, attempted)?;
826
827 if let Err(e) = conn.execute_batch("COMMIT") {
828 let _ = conn.execute_batch("ROLLBACK");
829 return Err(e);
830 }
831 Ok(summary)
832 })
833 .await
834 }
835
836 async fn replace_entity_if_unchanged(
837 &self,
838 entity: Entity,
839 expected_updated_at: i64,
840 expected_deleted_at: Option<i64>,
841 ) -> Result<bool, StorageError> {
842 let statement = entity_replace_if_unchanged_statement(
843 &entity,
844 expected_updated_at,
845 expected_deleted_at,
846 );
847 self.with_writer("replace_entity_if_unchanged", move |conn| {
848 let mut stmt = conn.prepare(&statement.sql)?;
849 bind_params(&mut stmt, &statement.params)?;
850 Ok(stmt.raw_execute()? > 0)
851 })
852 .await
853 }
854
855 async fn get_entity(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
856 let id_str = id.to_string();
857
858 self.with_reader("get_entity", move |conn| {
859 let sql = format!(
860 "SELECT {ENTITY_SELECT_COLUMNS} FROM entities \
861 WHERE entities.id = ?1 AND entities.deleted_at IS NULL"
862 );
863 let mut stmt = conn.prepare(&sql)?;
864 let mut rows = stmt.query(rusqlite::params![id_str])?;
865 match rows.next()? {
866 Some(row) => Ok(Some(read_entity(row)?)),
867 None => Ok(None),
868 }
869 })
870 .await
871 }
872
873 async fn entity_sequence(&self, id: Uuid) -> Result<Option<i64>, StorageError> {
874 let id = id.to_string();
875 self.with_reader("entity_sequence", move |conn| {
876 conn.query_row(
877 "SELECT seq FROM entities_seq WHERE entity_id = ?1",
878 rusqlite::params![id],
879 |row| row.get(0),
880 )
881 .optional()
882 })
883 .await
884 }
885
886 async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
887 match mode {
888 DeleteMode::Soft => {
889 let now = chrono::Utc::now().timestamp_micros();
890 let statement = entity_soft_delete_statement(id, now);
891 self.with_writer("delete_entity_soft", move |conn| {
892 let mut stmt = conn.prepare(&statement.sql)?;
893 bind_params(&mut stmt, &statement.params)?;
894 Ok(stmt.raw_execute()? > 0)
895 })
896 .await
897 }
898 DeleteMode::Hard => {
899 let entity_statement = entity_hard_delete_statement(id);
900 let attachment_statement =
901 delete_record_attachments_statement(id, AttachmentSubstrate::Entity);
902 self.with_writer_tx("delete_entity_hard", move |conn| {
903 let mut entity_stmt = conn.prepare(&entity_statement.sql)?;
904 bind_params(&mut entity_stmt, &entity_statement.params)?;
905 let deleted = entity_stmt.raw_execute()? > 0;
906 drop(entity_stmt);
907 if deleted {
908 let mut attachment_stmt = conn.prepare(&attachment_statement.sql)?;
909 bind_params(&mut attachment_stmt, &attachment_statement.params)?;
910 attachment_stmt.raw_execute()?;
911 }
912 Ok(deleted)
913 })
914 .await
915 }
916 }
917 }
918
919 async fn query_entities(
920 &self,
921 namespace: &str,
922 filter: EntityFilter,
923 page: PageRequest,
924 ) -> Result<Page<Entity>, StorageError> {
925 let namespace = namespace.to_string();
926 let skip_total = is_complete_id_lookup(&filter, &page);
927 let limit_i64 = i64::from(page.limit);
928 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
929 capability: StorageCapability::Entities,
930 operation: "query_entities".into(),
931 message: format!(
932 "PageRequest: offset must be <= i64::MAX, got {}",
933 page.offset
934 ),
935 })?;
936
937 self.with_reader("query_entities", move |conn| {
938 let total = if filter.names_ci.is_empty() && !skip_total {
939 let (count_sql, count_params) = build_entity_where(&namespace, &filter);
940 let sql = format!("SELECT COUNT(*) FROM entities{count_sql}");
941 let mut stmt = conn.prepare(&sql)?;
942 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
943 count_params.iter().map(|p| p.as_ref()).collect();
944 Some(stmt.query_row(param_refs.as_slice(), |row| row.get::<_, i64>(0))? as u64)
945 } else {
946 None
947 };
948
949 let mut lookup_filter = filter.clone();
950 lookup_filter.names_ci.clear();
951 let effective_filter = if filter.names_ci.is_empty() {
952 &filter
953 } else {
954 &lookup_filter
955 };
956 let (where_sql, mut data_params) = build_entity_where(&namespace, effective_filter);
957
958 let candidate_param_indices = if filter.names_ci.is_empty() {
959 Vec::new()
960 } else {
961 let mut candidates: Vec<String> = filter
962 .names_ci
963 .iter()
964 .map(|name| name.to_ascii_lowercase())
965 .collect();
966 candidates.sort_unstable();
967 candidates.dedup();
968 candidates
969 .into_iter()
970 .map(|candidate| {
971 data_params.push(Box::new(candidate));
972 data_params.len()
973 })
974 .collect()
975 };
976
977 let order_by = if let Some(ref prefix) = filter.name_prefix {
983 data_params.push(Box::new(prefix.to_ascii_lowercase()));
984 format!(
985 "CASE WHEN LOWER(name) = ?{} THEN 0 ELSE 1 END, created_at DESC, id DESC",
986 data_params.len()
987 )
988 } else {
989 "created_at DESC, id DESC".to_string()
997 };
998
999 data_params.push(Box::new(limit_i64));
1000 data_params.push(Box::new(offset_i64));
1001
1002 let limit_idx = data_params.len() - 1;
1003 let offset_idx = data_params.len();
1004
1005 let columns = ENTITY_SELECT_COLUMNS;
1006 let data_sql = if filter.names_ci.is_empty() {
1007 format!(
1008 "SELECT {columns} FROM entities{where_sql} \
1009 ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
1010 )
1011 } else {
1012 build_candidate_entity_query(
1013 columns,
1014 &where_sql,
1015 &candidate_param_indices,
1016 &order_by,
1017 limit_idx,
1018 offset_idx,
1019 )
1020 };
1021
1022 let mut stmt = conn.prepare(&data_sql)?;
1023 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1024 data_params.iter().map(|p| p.as_ref()).collect();
1025 let rows = stmt.query_map(param_refs.as_slice(), read_entity)?;
1026
1027 let mut items = Vec::new();
1028 for row in rows {
1029 items.push(row?);
1030 }
1031
1032 Ok(Page { items, total })
1033 })
1034 .await
1035 }
1036
1037 async fn query_entities_after(
1038 &self,
1039 namespace: &str,
1040 filter: EntityFilter,
1041 after: Option<SeekCursor>,
1042 limit: u32,
1043 ) -> Result<SeekPage<Entity>, StorageError> {
1044 if limit == 0 {
1045 return Ok(SeekPage::default());
1046 }
1047 if !filter.names_ci.is_empty() {
1048 return Err(StorageError::InvalidInput {
1049 capability: StorageCapability::Entities,
1050 operation: "query_entities_after".into(),
1051 message: "names_ci candidate folding is not compatible with seek pagination".into(),
1052 });
1053 }
1054
1055 let namespace = namespace.to_string();
1056 let limit_usize = limit as usize;
1057 let probe_limit_i64 = i64::from(limit) + 1;
1058 self.with_reader("query_entities_after", move |conn| {
1059 let (mut where_sql, mut params) = build_entity_where(&namespace, &filter);
1060 if let Some(cursor) = after {
1061 params.push(Box::new(cursor.sequence));
1062 where_sql.push_str(&format!(" AND entities_seq.seq > ?{}", params.len()));
1063 }
1064 params.push(Box::new(probe_limit_i64));
1065 let limit_idx = params.len();
1066
1067 let columns = ENTITY_SELECT_COLUMNS;
1068 let join_kind = if filter.kinds.is_empty() {
1077 "CROSS JOIN"
1078 } else {
1079 "JOIN"
1080 };
1081 let sql = format!(
1082 "SELECT {columns}, entities_seq.seq FROM entities_seq \
1083 {join_kind} entities ON entities.id = entities_seq.entity_id{where_sql} \
1084 ORDER BY entities_seq.seq ASC LIMIT ?{limit_idx}"
1085 );
1086 let mut stmt = conn.prepare(&sql)?;
1087 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1088 params.iter().map(|param| param.as_ref()).collect();
1089 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1090 Ok((read_entity(row)?, row.get::<_, i64>(15)?))
1091 })?;
1092 let mut entries = rows.collect::<Result<Vec<_>, _>>()?;
1093 let has_more = entries.len() > limit_usize;
1094 if has_more {
1095 entries.truncate(limit_usize);
1096 }
1097 let next_after = if has_more {
1098 entries.last().map(|(entity, sequence)| SeekCursor {
1099 sequence: *sequence,
1100 id: entity.id,
1101 })
1102 } else {
1103 None
1104 };
1105 let items = entries.into_iter().map(|(entity, _)| entity).collect();
1106 Ok(SeekPage { items, next_after })
1107 })
1108 .await
1109 }
1110
1111 async fn get_entity_including_deleted(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
1112 let id_str = id.to_string();
1113
1114 self.with_reader("get_entity_including_deleted", move |conn| {
1115 let sql =
1116 format!("SELECT {ENTITY_SELECT_COLUMNS} FROM entities WHERE entities.id = ?1");
1117 let mut stmt = conn.prepare(&sql)?;
1118 let mut rows = stmt.query(rusqlite::params![id_str])?;
1119 match rows.next()? {
1120 Some(row) => Ok(Some(read_entity(row)?)),
1121 None => Ok(None),
1122 }
1123 })
1124 .await
1125 }
1126
1127 async fn count_entities(
1128 &self,
1129 namespace: &str,
1130 filter: EntityFilter,
1131 ) -> Result<u64, StorageError> {
1132 let namespace = namespace.to_string();
1133
1134 self.with_reader("count_entities", move |conn| {
1135 if filter.namespaces.is_empty() {
1136 let (where_sql, params) = build_entity_where(&namespace, &filter);
1137 let sql = format!("SELECT COUNT(*) FROM entities{}", where_sql);
1138 let mut stmt = conn.prepare(&sql)?;
1139 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1140 params.iter().map(|p| p.as_ref()).collect();
1141 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1142 return Ok(count as u64);
1143 }
1144
1145 let deduped_namespaces: Vec<String> = filter
1146 .namespaces
1147 .iter()
1148 .cloned()
1149 .collect::<HashSet<_>>()
1150 .into_iter()
1151 .collect();
1152
1153 let mut total = 0;
1154 for chunk in deduped_namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
1155 let chunk_filter = EntityFilter {
1156 namespaces: chunk.to_vec(),
1157 ..filter.clone()
1158 };
1159 let (where_sql, params) = build_entity_where(&namespace, &chunk_filter);
1160 let sql = format!("SELECT COUNT(*) FROM entities{}", where_sql);
1161 let mut stmt = conn.prepare(&sql)?;
1162 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1163 params.iter().map(|p| p.as_ref()).collect();
1164 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1165 total += count as u64;
1166 }
1167 Ok(total)
1168 })
1169 .await
1170 }
1171}
1172
1173const ENTITIES_DDL: &str = include_str!("../../sql/entities-ddl.sql");
1178
1179pub(crate) fn ensure_entities_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1180 conn.execute_batch(ENTITIES_DDL)
1181}
1182
1183#[cfg(test)]
1184#[path = "entity_tests.rs"]
1185mod tests;