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
79#[derive(Clone, Debug, Default, PartialEq, Eq)]
81pub struct AttachmentReadReport {
82 pub attachments: Vec<Attachment>,
83 pub unreadable_count: u64,
84 pub unreadable_reason: Option<String>,
85}
86
87impl Attachment {
88 pub fn from_new(
90 record_uuid: Uuid,
91 substrate: AttachmentSubstrate,
92 new_attachment: NewAttachment,
93 created_at: i64,
94 ) -> Self {
95 Self {
96 record_uuid,
97 substrate,
98 role: new_attachment.role,
99 content_ref: new_attachment.content_ref,
100 media_type: new_attachment.media_type,
101 size_bytes: new_attachment.size_bytes,
102 created_at,
103 }
104 }
105
106 pub fn validate(&self) -> StorageResult<()> {
108 validate_attachment_role(&self.role)?;
109 validate_attachment_size(self.size_bytes)
110 }
111}
112
113pub fn validate_attachment_role(role: &str) -> StorageResult<()> {
115 if role.is_empty() {
116 return Err(StorageError::InvalidInput {
117 capability: StorageCapability::Attachments,
118 operation: "validate_attachment_role".into(),
119 message: "attachment role must not be empty".to_string(),
120 });
121 }
122 if role.chars().any(char::is_control) {
123 return Err(StorageError::InvalidInput {
124 capability: StorageCapability::Attachments,
125 operation: "validate_attachment_role".into(),
126 message: "attachment role must not contain control characters".to_string(),
127 });
128 }
129 Ok(())
130}
131
132fn validate_attachment_size(size_bytes: Option<u64>) -> StorageResult<()> {
133 if size_bytes.is_some_and(|size| size > i64::MAX as u64) {
134 return Err(StorageError::InvalidInput {
135 capability: StorageCapability::Attachments,
136 operation: "validate_attachment".into(),
137 message: format!(
138 "attachment size_bytes must fit SQLite INTEGER (maximum {} bytes)",
139 i64::MAX
140 ),
141 });
142 }
143 Ok(())
144}
145
146#[async_trait]
151pub trait AttachmentStore: Send + Sync + 'static {
152 async fn upsert_attachment(&self, attachment: Attachment) -> StorageResult<()>;
154 async fn try_insert_attachment(&self, _attachment: Attachment) -> StorageResult<bool> {
157 Err(StorageError::Unsupported {
158 capability: StorageCapability::Attachments,
159 operation: "try_insert_attachment".into(),
160 message: "the attachment backend does not support conditional insert".to_string(),
161 })
162 }
163 async fn get_attachment(
165 &self,
166 record_uuid: Uuid,
167 role: &str,
168 ) -> StorageResult<Option<Attachment>>;
169 async fn list_attachments(&self, record_uuid: Uuid) -> StorageResult<Vec<Attachment>>;
171 async fn list_attachments_report(
174 &self,
175 record_uuid: Uuid,
176 ) -> StorageResult<AttachmentReadReport> {
177 Ok(AttachmentReadReport {
178 attachments: self.list_attachments(record_uuid).await?,
179 ..AttachmentReadReport::default()
180 })
181 }
182 async fn list_attachments_reports(
185 &self,
186 record_uuids: &[Uuid],
187 ) -> StorageResult<Vec<AttachmentReadReport>> {
188 let mut reports = Vec::with_capacity(record_uuids.len());
189 for record_uuid in record_uuids {
190 reports.push(self.list_attachments_report(*record_uuid).await?);
191 }
192 Ok(reports)
193 }
194 async fn delete_attachment(&self, record_uuid: Uuid, role: &str) -> StorageResult<bool>;
196}