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