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