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}