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 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}