Skip to main content

khive_storage/
entity.rs

1//! Entity storage capability — graph node CRUD.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use uuid::Uuid;
7
8use crate::types::{BatchWriteSummary, DeleteMode, Page, PageRequest, StorageResult};
9
10/// Storage-level entity record. Flat SQL-friendly representation.
11/// Maps to the `entities` substrate table.
12#[derive(Clone, Debug, Serialize, Deserialize)]
13pub struct Entity {
14    pub id: Uuid,
15    pub namespace: String,
16    pub kind: String,
17    /// Pack-governed subtype token. Maps to `entities.entity_type` column.
18    pub entity_type: Option<String>,
19    pub name: String,
20    pub description: Option<String>,
21    pub properties: Option<Value>,
22    pub tags: Vec<String>,
23    pub created_at: i64,
24    pub updated_at: i64,
25    pub deleted_at: Option<i64>,
26    /// When this entity was tombstoned by a merge, the `into` entity's ID.
27    pub merged_into: Option<Uuid>,
28    /// Opaque event ID for the merge that tombstoned this entity.
29    pub merge_event_id: Option<Uuid>,
30    /// Content-addressed reference into a `BlobStore` (khive#292), stored as
31    /// the raw hex digest string. `None` when this entity has no attached
32    /// binary payload. Storage does not validate that the referenced blob
33    /// actually exists — callers publish the blob before setting this field
34    /// (see `docs/adr` BlobStore ADR "publish-then-reference" ordering).
35    pub content_ref: Option<String>,
36}
37
38impl Entity {
39    /// Create a new entity with a generated UUID and current timestamp.
40    pub fn new(
41        namespace: impl Into<String>,
42        kind: impl Into<String>,
43        name: impl Into<String>,
44    ) -> Self {
45        let now = chrono::Utc::now().timestamp_micros();
46        Self {
47            id: Uuid::new_v4(),
48            namespace: namespace.into(),
49            kind: kind.into(),
50            entity_type: None,
51            name: name.into(),
52            description: None,
53            properties: None,
54            tags: Vec::new(),
55            created_at: now,
56            updated_at: now,
57            deleted_at: None,
58            merged_into: None,
59            merge_event_id: None,
60            content_ref: None,
61        }
62    }
63
64    /// Set the content-addressed blob reference (khive#292).
65    pub fn with_content_ref(mut self, content_ref: impl Into<String>) -> Self {
66        self.content_ref = Some(content_ref.into());
67        self
68    }
69
70    /// Set the pack-governed entity subtype token.
71    pub fn with_entity_type(mut self, t: Option<impl Into<String>>) -> Self {
72        self.entity_type = t.map(Into::into);
73        self
74    }
75
76    /// Set the entity description.
77    pub fn with_description(mut self, d: impl Into<String>) -> Self {
78        self.description = Some(d.into());
79        self
80    }
81
82    /// Set the entity properties JSON blob.
83    pub fn with_properties(mut self, p: Value) -> Self {
84        self.properties = Some(p);
85        self
86    }
87
88    /// Set the entity tags.
89    pub fn with_tags(mut self, t: Vec<String>) -> Self {
90        self.tags = t;
91        self
92    }
93}
94
95/// Entity filter for query operations.
96#[derive(Clone, Debug, Default, Serialize, Deserialize)]
97pub struct EntityFilter {
98    pub ids: Vec<Uuid>,
99    pub kinds: Vec<String>,
100    /// Filter by exact `entity_type` value. Multiple values are ORed.
101    pub entity_types: Vec<String>,
102    pub name_prefix: Option<String>,
103    /// Deterministic, case-sensitive equality on `entities.name` (binary
104    /// comparison — SQLite's default collation for `=` on a `TEXT` column
105    /// without an explicit `COLLATE NOCASE`). Distinct from `name_prefix`:
106    /// that stage's `LIKE` is inherently prefix-shaped and, with SQLite's
107    /// default `NOCASE`-free `LIKE` on ASCII, still ranks a page by
108    /// `created_at DESC` — a match that is exact but not the newest can be
109    /// paged out. `name_exact` skips paging risk entirely by filtering to
110    /// only rows that equal `name` at the SQL layer.
111    pub name_exact: Option<String>,
112    pub tags_any: Vec<String>,
113    /// When non-empty, restricts results to any of these namespaces using
114    /// `namespace IN (...)`. Takes precedence over the `namespace` string
115    /// parameter passed to `query_entities` / `count_entities`. When empty the
116    /// caller-supplied `namespace` parameter is used (single-namespace path,
117    /// backward-compatible default).
118    #[serde(default)]
119    pub namespaces: Vec<String>,
120    /// ASCII-case-insensitive batched exact-name match (ADR-104 Stage C).
121    /// Compares a caller-bounded set of raw and ASCII-lowercased candidate
122    /// strings to `LOWER(name)`. Cased non-ASCII characters require exact form.
123    /// Distinct from single-value, case-sensitive `name_exact`. Results contain
124    /// at most one representative row per folded candidate before page limits
125    /// and offsets are applied.
126    /// Implementations may omit the page total to keep this lookup page-limited
127    /// instead of issuing a separate count.
128    #[serde(default)]
129    pub names_ci: Vec<String>,
130}
131
132/// Entity CRUD operations over the entities substrate table.
133#[async_trait]
134pub trait EntityStore: Send + Sync + 'static {
135    /// Insert or update a single entity.
136    async fn upsert_entity(&self, entity: Entity) -> StorageResult<()>;
137    /// Insert or update a batch of entities.
138    async fn upsert_entities(&self, entities: Vec<Entity>) -> StorageResult<BatchWriteSummary>;
139    /// Fetch an entity by UUID, returning `None` if absent.
140    async fn get_entity(&self, id: Uuid) -> StorageResult<Option<Entity>>;
141    /// Delete an entity by UUID using the specified delete mode.
142    async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> StorageResult<bool>;
143    /// Query entities by namespace with filter and pagination.
144    async fn query_entities(
145        &self,
146        namespace: &str,
147        filter: EntityFilter,
148        page: PageRequest,
149    ) -> StorageResult<Page<Entity>>;
150    /// Count entities in a namespace matching the given filter.
151    async fn count_entities(&self, namespace: &str, filter: EntityFilter) -> StorageResult<u64>;
152    /// Fetch an entity by UUID regardless of soft-deletion state.
153    ///
154    /// Returns the entity row even when `deleted_at` is set. Callers use this
155    /// to distinguish "soft-deleted" from "never existed".
156    async fn get_entity_including_deleted(&self, id: Uuid) -> StorageResult<Option<Entity>>;
157}