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