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
79impl Attachment {
80    /// Bind caller-supplied metadata to one record and timestamp.
81    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    /// Validate values that must be represented in the SQLite attachment row.
99    pub fn validate(&self) -> StorageResult<()> {
100        validate_attachment_role(&self.role)?;
101        validate_attachment_size(self.size_bytes)
102    }
103}
104
105/// Validate a role at every lookup and mutation boundary.
106pub 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/// Role-keyed attachment metadata within one backend.
139///
140/// This trait is placement-blind; runtime/host wiring chooses the canonical
141/// main backend that participates in blob liveness.
142#[async_trait]
143pub trait AttachmentStore: Send + Sync + 'static {
144    /// Insert or replace one attachment role.
145    async fn upsert_attachment(&self, attachment: Attachment) -> StorageResult<()>;
146    /// Fetch one attachment role for a record.
147    async fn get_attachment(
148        &self,
149        record_uuid: Uuid,
150        role: &str,
151    ) -> StorageResult<Option<Attachment>>;
152    /// List all attachment roles for a record in stable role order.
153    async fn list_attachments(&self, record_uuid: Uuid) -> StorageResult<Vec<Attachment>>;
154    /// Remove one attachment role without touching the referenced blob.
155    async fn delete_attachment(&self, record_uuid: Uuid, role: &str) -> StorageResult<bool>;
156}