khive_storage/
attachment.rs1use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use crate::blob::ContentRef;
8use crate::capability::StorageCapability;
9use crate::error::StorageError;
10use crate::types::StorageResult;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum AttachmentSubstrate {
16 Entity,
17 Note,
18}
19
20impl AttachmentSubstrate {
21 pub const fn as_str(self) -> &'static str {
23 match self {
24 Self::Entity => "entity",
25 Self::Note => "note",
26 }
27 }
28}
29
30impl std::fmt::Display for AttachmentSubstrate {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.write_str(self.as_str())
33 }
34}
35
36impl std::str::FromStr for AttachmentSubstrate {
37 type Err = String;
38
39 fn from_str(value: &str) -> Result<Self, Self::Err> {
40 match value {
41 "entity" => Ok(Self::Entity),
42 "note" => Ok(Self::Note),
43 other => Err(format!(
44 "attachment substrate must be \"entity\" or \"note\", got {other:?}"
45 )),
46 }
47 }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct NewAttachment {
53 pub role: String,
54 pub content_ref: ContentRef,
55 pub media_type: Option<String>,
56 pub size_bytes: Option<u64>,
57}
58
59impl NewAttachment {
60 pub fn validate(&self) -> StorageResult<()> {
62 validate_attachment_role(&self.role)?;
63 validate_attachment_size(self.size_bytes)
64 }
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69pub struct Attachment {
70 pub record_uuid: Uuid,
71 pub substrate: AttachmentSubstrate,
72 pub role: String,
73 pub content_ref: ContentRef,
74 pub media_type: Option<String>,
75 pub size_bytes: Option<u64>,
76 pub created_at: i64,
77}
78
79impl Attachment {
80 pub fn from_new(
82 record_uuid: Uuid,
83 substrate: AttachmentSubstrate,
84 new_attachment: NewAttachment,
85 created_at: i64,
86 ) -> Self {
87 Self {
88 record_uuid,
89 substrate,
90 role: new_attachment.role,
91 content_ref: new_attachment.content_ref,
92 media_type: new_attachment.media_type,
93 size_bytes: new_attachment.size_bytes,
94 created_at,
95 }
96 }
97
98 pub fn validate(&self) -> StorageResult<()> {
100 validate_attachment_role(&self.role)?;
101 validate_attachment_size(self.size_bytes)
102 }
103}
104
105pub fn validate_attachment_role(role: &str) -> StorageResult<()> {
107 if role.is_empty() {
108 return Err(StorageError::InvalidInput {
109 capability: StorageCapability::Attachments,
110 operation: "validate_attachment_role".into(),
111 message: "attachment role must not be empty".to_string(),
112 });
113 }
114 if role.chars().any(char::is_control) {
115 return Err(StorageError::InvalidInput {
116 capability: StorageCapability::Attachments,
117 operation: "validate_attachment_role".into(),
118 message: "attachment role must not contain control characters".to_string(),
119 });
120 }
121 Ok(())
122}
123
124fn validate_attachment_size(size_bytes: Option<u64>) -> StorageResult<()> {
125 if size_bytes.is_some_and(|size| size > i64::MAX as u64) {
126 return Err(StorageError::InvalidInput {
127 capability: StorageCapability::Attachments,
128 operation: "validate_attachment".into(),
129 message: format!(
130 "attachment size_bytes must fit SQLite INTEGER (maximum {} bytes)",
131 i64::MAX
132 ),
133 });
134 }
135 Ok(())
136}
137
138#[async_trait]
143pub trait AttachmentStore: Send + Sync + 'static {
144 async fn upsert_attachment(&self, attachment: Attachment) -> StorageResult<()>;
146 async fn get_attachment(
148 &self,
149 record_uuid: Uuid,
150 role: &str,
151 ) -> StorageResult<Option<Attachment>>;
152 async fn list_attachments(&self, record_uuid: Uuid) -> StorageResult<Vec<Attachment>>;
154 async fn delete_attachment(&self, record_uuid: Uuid, role: &str) -> StorageResult<bool>;
156}