Skip to main content

khive_storage/
attachment.rs

1//! Role-keyed binary attachments for entity and note records.
2
3use 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/// The record substrate that owns an attachment.
13#[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    /// Stable lowercase value stored in SQLite and exposed on the wire.
22    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/// Caller-supplied metadata for one role-keyed attachment.
51#[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    /// Validate values that must be represented in the SQLite attachment row.
61    pub fn validate(&self) -> StorageResult<()> {
62        validate_attachment_role(&self.role)?;
63        validate_attachment_size(self.size_bytes)
64    }
65}
66
67/// A persisted attachment row.
68#[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/// Readable attachment rows and diagnostics for unreadable metadata.
80#[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    /// Bind caller-supplied metadata to one record and timestamp.
89    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    /// Validate values that must be represented in the SQLite attachment row.
107    pub fn validate(&self) -> StorageResult<()> {
108        validate_attachment_role(&self.role)?;
109        validate_attachment_size(self.size_bytes)
110    }
111}
112
113/// Validate a role at every lookup and mutation boundary.
114pub 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/// Role-keyed attachment metadata within one backend.
147///
148/// This trait is placement-blind; runtime/host wiring chooses the canonical
149/// main backend that participates in blob liveness.
150#[async_trait]
151pub trait AttachmentStore: Send + Sync + 'static {
152    /// Insert or replace one attachment role.
153    async fn upsert_attachment(&self, attachment: Attachment) -> StorageResult<()>;
154    /// Insert one role only if it is still absent, without replacing a concurrent writer.
155    /// Returns `true` when inserted and `false` when the role already exists.
156    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    /// Fetch one attachment role for a record.
164    async fn get_attachment(
165        &self,
166        record_uuid: Uuid,
167        role: &str,
168    ) -> StorageResult<Option<Attachment>>;
169    /// List all attachment roles for a record in stable role order.
170    async fn list_attachments(&self, record_uuid: Uuid) -> StorageResult<Vec<Attachment>>;
171    /// Report and skip unreadable rows while preserving whole-lookup errors.
172    /// Backends without row diagnostics retain their strict listing behavior.
173    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    /// Return reports in input order, including repeated records.
183    /// Backends may batch the reads; the default preserves scalar behavior.
184    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    /// Remove one attachment role without touching the referenced blob.
195    async fn delete_attachment(&self, record_uuid: Uuid, role: &str) -> StorageResult<bool>;
196}