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