Skip to main content

aven_core/
metadata.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4use crate::ids::{MetadataFieldId, WorkspaceId};
5
6mod fields;
7mod validation;
8mod values;
9
10pub(crate) const MAX_METADATA_VALUES: usize = 128;
11pub(crate) const MAX_METADATA_VALUE_BYTES: usize = 4 * 1024;
12pub(crate) const MAX_METADATA_TOTAL_BYTES: usize = 32 * 1024;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct MetadataField {
16    pub id: MetadataFieldId,
17    pub workspace_id: WorkspaceId,
18    pub key: String,
19    pub created_at: String,
20    pub updated_at: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct TaskMetadataValue {
25    pub field_id: MetadataFieldId,
26    pub key: String,
27    pub value: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct TaskMetadataInput {
32    /// Require this workspace field identity and key instead of defining a key.
33    pub expected_field_id: Option<MetadataFieldId>,
34    pub key: String,
35    pub value: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct MetadataFieldUsage {
40    pub field: MetadataField,
41    pub task_count: usize,
42    pub series_count: usize,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub(crate) struct ResolvedMetadataValue {
47    pub(crate) field_id: MetadataFieldId,
48    pub(crate) key: String,
49    pub(crate) value: String,
50}
51
52#[derive(Serialize, Deserialize)]
53struct MetadataConflictValue {
54    present: bool,
55    value: String,
56}
57
58pub(crate) fn encode_metadata_conflict_value(value: Option<&str>) -> Result<String> {
59    Ok(serde_json::to_string(&MetadataConflictValue {
60        present: value.is_some(),
61        value: value.unwrap_or_default().to_string(),
62    })?)
63}
64
65pub(crate) fn decode_metadata_conflict_value(value: &str) -> Result<Option<String>> {
66    let value: MetadataConflictValue = serde_json::from_str(value)?;
67    Ok(value.present.then_some(value.value))
68}
69
70pub use fields::normalize_metadata_key;
71
72pub(crate) use fields::{
73    find_metadata_field_in_workspace, metadata_field_by_id, metadata_field_by_key,
74    require_metadata_field,
75};
76pub(crate) use validation::{
77    validate_metadata_update, validate_recurrence_metadata_result, validate_task_metadata_result,
78};
79pub(crate) use values::{
80    insert_initial_task_metadata, metadata_by_task_ids, remove_recurrence_metadata,
81    remove_task_metadata, resolve_metadata_inputs, set_recurrence_metadata, set_task_metadata,
82};
83
84#[cfg(test)]
85pub(crate) use fields::{rename_metadata_field, resolve_or_create_metadata_field};
86#[cfg(test)]
87use validation::validate_metadata_result_limits;
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::db::begin_immediate;
93    use crate::test_support::{ensure_default_workspace, test_conn};
94
95    #[tokio::test]
96    async fn existing_field_identity_rejects_rename_and_other_workspace() {
97        let (_temp, mut conn) = test_conn().await;
98        let workspace = ensure_default_workspace(&mut conn).await.unwrap();
99        let mut tx = begin_immediate(&mut conn).await.unwrap();
100        let field = resolve_or_create_metadata_field(&mut tx, &workspace, "review")
101            .await
102            .unwrap();
103        let input = TaskMetadataInput {
104            expected_field_id: Some(field.id.clone()),
105            key: field.key.clone(),
106            value: String::new(),
107        };
108        assert_eq!(
109            resolve_metadata_inputs(&mut tx, &workspace, std::slice::from_ref(&input))
110                .await
111                .unwrap()[0]
112                .value,
113            ""
114        );
115        rename_metadata_field(&mut tx, &workspace, "review", "review-state")
116            .await
117            .unwrap();
118        assert!(
119            resolve_metadata_inputs(&mut tx, &workspace, &[input])
120                .await
121                .unwrap_err()
122                .to_string()
123                .contains("metadata-field-changed")
124        );
125        assert!(
126            metadata_field_by_key(&mut tx, &workspace.id, "review")
127                .await
128                .unwrap()
129                .is_none()
130        );
131        assert!(
132            require_metadata_field(&mut tx, &WorkspaceId::new(), &field.id, "review-state")
133                .await
134                .is_err()
135        );
136    }
137
138    #[test]
139    fn metadata_keys_have_stable_normalization() {
140        assert_eq!(normalize_metadata_key(" Max_Turns ").unwrap(), "max_turns");
141        assert_eq!(normalize_metadata_key("source.id").unwrap(), "source.id");
142        assert!(normalize_metadata_key("1source").is_err());
143        assert!(normalize_metadata_key("source id").is_err());
144        assert!(normalize_metadata_key("aven.internal").is_err());
145    }
146
147    #[test]
148    fn metadata_result_limits_include_existing_value_bytes() {
149        let mut existing = (0..7)
150            .map(|index| (format!("key_{index}"), "x".repeat(MAX_METADATA_VALUE_BYTES)))
151            .collect::<Vec<_>>();
152        existing.push((
153            "key_7".to_string(),
154            "x".repeat(MAX_METADATA_VALUE_BYTES - 1),
155        ));
156        let set = [TaskMetadataInput {
157            expected_field_id: None,
158            key: "key_8".to_string(),
159            value: "é".to_string(),
160        }];
161
162        let error = validate_metadata_result_limits(existing, &set, &[]).unwrap_err();
163
164        assert_eq!(
165            error.to_string(),
166            format!("error metadata-values-too-large limit={MAX_METADATA_TOTAL_BYTES}")
167        );
168    }
169
170    #[test]
171    fn metadata_result_limits_apply_removals_before_sets() {
172        let existing = (0..8)
173            .map(|index| (format!("key_{index}"), "x".repeat(MAX_METADATA_VALUE_BYTES)))
174            .collect::<Vec<_>>();
175        let set = [TaskMetadataInput {
176            expected_field_id: None,
177            key: "replacement".to_string(),
178            value: "y".repeat(MAX_METADATA_VALUE_BYTES),
179        }];
180
181        validate_metadata_result_limits(existing, &set, &[" KEY_0 ".to_string()]).unwrap();
182    }
183
184    #[test]
185    fn metadata_result_limits_distinguish_replacement_from_insertion_at_count_limit() {
186        let existing = (0..MAX_METADATA_VALUES)
187            .map(|index| {
188                (
189                    format!("key_{index}"),
190                    "x".repeat(MAX_METADATA_TOTAL_BYTES / MAX_METADATA_VALUES),
191                )
192            })
193            .collect::<Vec<_>>();
194        let replacement = [TaskMetadataInput {
195            expected_field_id: None,
196            key: "KEY_0".to_string(),
197            value: "replacement".to_string(),
198        }];
199        validate_metadata_result_limits(existing.clone(), &replacement, &[]).unwrap();
200
201        let insertion = [TaskMetadataInput {
202            expected_field_id: None,
203            key: "extra".to_string(),
204            value: "x".to_string(),
205        }];
206        let error = validate_metadata_result_limits(existing, &insertion, &[]).unwrap_err();
207
208        assert_eq!(
209            error.to_string(),
210            format!("error too-many-metadata-values limit={MAX_METADATA_VALUES}")
211        );
212    }
213
214    #[tokio::test]
215    async fn repeated_key_resolution_reuses_stable_field_identity() {
216        let (_temp, mut conn) = test_conn().await;
217        let workspace = ensure_default_workspace(&mut conn).await.unwrap();
218        let mut tx = begin_immediate(&mut conn).await.unwrap();
219        let first = resolve_or_create_metadata_field(&mut tx, &workspace, "max_turns")
220            .await
221            .unwrap();
222        let second = resolve_or_create_metadata_field(&mut tx, &workspace, "MAX_TURNS")
223            .await
224            .unwrap();
225        tx.commit().await.unwrap();
226
227        assert_eq!(first.id, second.id);
228        let changes: i64 = sqlx::query_scalar(
229            "SELECT count(*) FROM changes WHERE op_type = 'create_metadata_field'",
230        )
231        .fetch_one(&mut *conn)
232        .await
233        .unwrap();
234        assert_eq!(changes, 1);
235    }
236}