1use std::collections::{BTreeSet, HashMap};
2
3use anyhow::{Result, bail};
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use sqlx::{QueryBuilder, Row, Sqlite, SqliteConnection};
7
8use crate::db::{
9 Database, begin_immediate, conflict_exists, entity_conflict_exists, entity_field_version,
10 field_version, insert_change, set_entity_field_version, set_field_version,
11};
12use crate::ids::{MetadataFieldId, TaskId, WorkspaceId, now};
13use crate::recurrence::RecurrenceSeriesId;
14use crate::types::MutableEntityType;
15use crate::workspaces::Workspace;
16
17pub(crate) const MAX_METADATA_VALUES: usize = 128;
18pub(crate) const MAX_METADATA_VALUE_BYTES: usize = 4 * 1024;
19pub(crate) const MAX_METADATA_TOTAL_BYTES: usize = 32 * 1024;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct MetadataField {
23 pub id: MetadataFieldId,
24 pub workspace_id: WorkspaceId,
25 pub key: String,
26 pub created_at: String,
27 pub updated_at: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct TaskMetadataValue {
32 pub field_id: MetadataFieldId,
33 pub key: String,
34 pub value: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct TaskMetadataInput {
39 pub key: String,
40 pub value: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct MetadataFieldUsage {
45 pub field: MetadataField,
46 pub task_count: usize,
47 pub series_count: usize,
48}
49
50#[derive(Debug, Clone, Serialize)]
51pub(crate) struct ResolvedMetadataValue {
52 pub(crate) field_id: MetadataFieldId,
53 pub(crate) key: String,
54 pub(crate) value: String,
55}
56
57#[derive(Serialize, Deserialize)]
58struct MetadataConflictValue {
59 present: bool,
60 value: String,
61}
62
63pub(crate) fn encode_metadata_conflict_value(value: Option<&str>) -> Result<String> {
64 Ok(serde_json::to_string(&MetadataConflictValue {
65 present: value.is_some(),
66 value: value.unwrap_or_default().to_string(),
67 })?)
68}
69
70pub(crate) fn decode_metadata_conflict_value(value: &str) -> Result<Option<String>> {
71 let value: MetadataConflictValue = serde_json::from_str(value)?;
72 Ok(value.present.then_some(value.value))
73}
74
75impl Database {
76 pub async fn list_metadata_fields(
77 &self,
78 workspace_id: &WorkspaceId,
79 ) -> Result<Vec<MetadataFieldUsage>> {
80 let mut conn = self.acquire_reader().await?;
81 list_metadata_fields_in_workspace(&mut conn, workspace_id).await
82 }
83
84 pub async fn find_metadata_field(
85 &self,
86 workspace_id: &WorkspaceId,
87 key: &str,
88 ) -> Result<Option<MetadataField>> {
89 let mut conn = self.acquire_reader().await?;
90 find_metadata_field_in_workspace(&mut conn, workspace_id, key).await
91 }
92
93 pub async fn task_metadata(
94 &self,
95 workspace_id: &WorkspaceId,
96 task_id: &TaskId,
97 ) -> Result<Vec<TaskMetadataValue>> {
98 let mut conn = self.acquire_reader().await?;
99 task_metadata_in_workspace(&mut conn, workspace_id, task_id).await
100 }
101
102 pub async fn rename_metadata_field(
103 &self,
104 workspace: &Workspace,
105 key: &str,
106 new_key: &str,
107 ) -> Result<MetadataField> {
108 let mut conn = self.acquire_writer().await?;
109 let mut tx = begin_immediate(&mut conn).await?;
110 let field = rename_metadata_field(&mut tx, workspace, key, new_key).await?;
111 tx.commit().await?;
112 Ok(field)
113 }
114}
115
116pub fn normalize_metadata_key(input: &str) -> Result<String> {
117 let key = input.trim().to_ascii_lowercase();
118 let mut bytes = key.bytes();
119 let Some(first) = bytes.next() else {
120 bail!("error invalid-metadata-key");
121 };
122 if !first.is_ascii_lowercase()
123 || key.len() > 64
124 || !bytes.all(|byte| {
125 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
126 })
127 || key.starts_with("aven.")
128 {
129 bail!("error invalid-metadata-key");
130 }
131 Ok(key)
132}
133
134pub(crate) async fn find_metadata_field_in_workspace(
135 conn: &mut SqliteConnection,
136 workspace_id: &WorkspaceId,
137 input: &str,
138) -> Result<Option<MetadataField>> {
139 let key = normalize_metadata_key(input)?;
140 metadata_field_by_key(conn, workspace_id, &key).await
141}
142
143pub(crate) async fn metadata_field_by_key(
144 conn: &mut SqliteConnection,
145 workspace_id: &WorkspaceId,
146 key: &str,
147) -> Result<Option<MetadataField>> {
148 let row = sqlx::query(
149 "SELECT id, workspace_id, key, created_at, updated_at
150 FROM metadata_fields WHERE workspace_id = ? AND key = ?",
151 )
152 .bind(workspace_id)
153 .bind(key)
154 .fetch_optional(&mut *conn)
155 .await?;
156 Ok(row.map(metadata_field_from_row))
157}
158
159pub(crate) async fn metadata_field_by_id(
160 conn: &mut SqliteConnection,
161 workspace_id: &WorkspaceId,
162 field_id: &MetadataFieldId,
163) -> Result<Option<MetadataField>> {
164 let row = sqlx::query(
165 "SELECT id, workspace_id, key, created_at, updated_at
166 FROM metadata_fields WHERE workspace_id = ? AND id = ?",
167 )
168 .bind(workspace_id)
169 .bind(field_id)
170 .fetch_optional(&mut *conn)
171 .await?;
172 Ok(row.map(metadata_field_from_row))
173}
174
175pub(crate) async fn resolve_or_create_metadata_field(
176 conn: &mut SqliteConnection,
177 workspace: &Workspace,
178 input: &str,
179) -> Result<MetadataField> {
180 let key = normalize_metadata_key(input)?;
181 if let Some(field) = metadata_field_by_key(conn, &workspace.id, &key).await? {
182 return Ok(field);
183 }
184
185 let id = MetadataFieldId::new();
186 let timestamp = now();
187 sqlx::query(
188 "INSERT INTO metadata_fields(id, workspace_id, key, created_at, updated_at)
189 VALUES (?, ?, ?, ?, ?)",
190 )
191 .bind(&id)
192 .bind(&workspace.id)
193 .bind(&key)
194 .bind(×tamp)
195 .bind(×tamp)
196 .execute(&mut *conn)
197 .await?;
198 let change_id = insert_change(
199 conn,
200 "metadata_field",
201 id.as_str(),
202 None,
203 "create_metadata_field",
204 json!({
205 "workspace_id": &workspace.id,
206 "workspace_key": &workspace.key,
207 "key": &key,
208 "created_at": ×tamp,
209 }),
210 None,
211 )
212 .await?;
213 set_entity_field_version(
214 conn,
215 &workspace.id,
216 MutableEntityType::MetadataField,
217 id.as_str(),
218 "key",
219 &change_id,
220 )
221 .await?;
222 Ok(MetadataField {
223 id,
224 workspace_id: workspace.id.clone(),
225 key,
226 created_at: timestamp.clone(),
227 updated_at: timestamp,
228 })
229}
230
231pub(crate) async fn rename_metadata_field(
232 conn: &mut SqliteConnection,
233 workspace: &Workspace,
234 input: &str,
235 new_input: &str,
236) -> Result<MetadataField> {
237 let key = normalize_metadata_key(input)?;
238 let new_key = normalize_metadata_key(new_input)?;
239 let mut field = metadata_field_by_key(conn, &workspace.id, &key)
240 .await?
241 .ok_or_else(|| anyhow::anyhow!("error unknown-metadata-field"))?;
242 let resolving = entity_conflict_exists(
243 conn,
244 &workspace.id,
245 MutableEntityType::MetadataField,
246 field.id.as_str(),
247 "key",
248 )
249 .await?;
250 if key == new_key && !resolving {
251 return Ok(field);
252 }
253 if key != new_key
254 && metadata_field_by_key(conn, &workspace.id, &new_key)
255 .await?
256 .is_some()
257 {
258 bail!("error metadata-field-exists");
259 }
260 let base = if resolving {
261 None
262 } else {
263 entity_field_version(
264 conn,
265 &workspace.id,
266 MutableEntityType::MetadataField,
267 field.id.as_str(),
268 "key",
269 )
270 .await?
271 };
272 let timestamp = now();
273 sqlx::query(
274 "UPDATE metadata_fields SET key = ?, updated_at = ?
275 WHERE workspace_id = ? AND id = ?",
276 )
277 .bind(&new_key)
278 .bind(×tamp)
279 .bind(&workspace.id)
280 .bind(&field.id)
281 .execute(&mut *conn)
282 .await?;
283 let change_id = insert_change(
284 conn,
285 "metadata_field",
286 field.id.as_str(),
287 Some("key"),
288 "set_metadata_field",
289 json!({
290 "workspace_id": &workspace.id,
291 "workspace_key": &workspace.key,
292 "key": &new_key,
293 "conflict_resolution": resolving,
294 }),
295 base.as_deref(),
296 )
297 .await?;
298 set_entity_field_version(
299 conn,
300 &workspace.id,
301 MutableEntityType::MetadataField,
302 field.id.as_str(),
303 "key",
304 &change_id,
305 )
306 .await?;
307 if resolving {
308 sqlx::query(
309 "UPDATE conflicts SET resolved = 1
310 WHERE workspace_id = ? AND entity_type = 'metadata_field'
311 AND entity_id = ? AND field = 'key' AND resolved = 0",
312 )
313 .bind(&workspace.id)
314 .bind(&field.id)
315 .execute(&mut *conn)
316 .await?;
317 }
318 field.key = new_key;
319 field.updated_at = timestamp;
320 Ok(field)
321}
322
323pub(crate) async fn insert_initial_task_metadata(
324 conn: &mut SqliteConnection,
325 workspace: &Workspace,
326 task_id: &TaskId,
327 inputs: &[TaskMetadataInput],
328 timestamp: &str,
329) -> Result<Vec<ResolvedMetadataValue>> {
330 let values = resolve_metadata_inputs(conn, workspace, inputs).await?;
331 for value in &values {
332 sqlx::query(
333 "INSERT INTO task_metadata(
334 workspace_id, task_id, field_id, value, created_at, updated_at
335 ) VALUES (?, ?, ?, ?, ?, ?)",
336 )
337 .bind(&workspace.id)
338 .bind(task_id)
339 .bind(&value.field_id)
340 .bind(&value.value)
341 .bind(timestamp)
342 .bind(timestamp)
343 .execute(&mut *conn)
344 .await?;
345 }
346 Ok(values)
347}
348
349pub(crate) async fn set_task_metadata(
350 conn: &mut SqliteConnection,
351 workspace: &Workspace,
352 task_id: &TaskId,
353 input: &TaskMetadataInput,
354) -> Result<bool> {
355 let field = resolve_or_create_metadata_field(conn, workspace, &input.key).await?;
356 let identity = format!("metadata:{}", field.id);
357 let current = sqlx::query_scalar::<_, String>(
358 "SELECT value FROM task_metadata
359 WHERE workspace_id = ? AND task_id = ? AND field_id = ?",
360 )
361 .bind(&workspace.id)
362 .bind(task_id)
363 .bind(&field.id)
364 .fetch_optional(&mut *conn)
365 .await?;
366 if current.as_deref() == Some(input.value.as_str()) {
367 return Ok(false);
368 }
369 if conflict_exists(conn, &workspace.id, task_id, &identity).await? {
370 bail!("error conflicted-field ref={task_id} field={identity}");
371 }
372 let base = field_version(conn, task_id, &identity).await?;
373 let timestamp = now();
374 sqlx::query(
375 "INSERT INTO task_metadata(
376 workspace_id, task_id, field_id, value, created_at, updated_at
377 ) VALUES (?, ?, ?, ?, ?, ?)
378 ON CONFLICT(workspace_id, task_id, field_id)
379 DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
380 )
381 .bind(&workspace.id)
382 .bind(task_id)
383 .bind(&field.id)
384 .bind(&input.value)
385 .bind(×tamp)
386 .bind(×tamp)
387 .execute(&mut *conn)
388 .await?;
389 touch_task(conn, &workspace.id, task_id, ×tamp).await?;
390 let change_id = insert_change(
391 conn,
392 "task",
393 task_id,
394 Some(&identity),
395 "set_task_metadata",
396 json!({
397 "workspace_id": &workspace.id,
398 "workspace_key": &workspace.key,
399 "field_id": &field.id,
400 "key": &field.key,
401 "value": &input.value,
402 }),
403 base.as_deref(),
404 )
405 .await?;
406 set_field_version(conn, task_id, &identity, &change_id).await?;
407 Ok(true)
408}
409
410pub(crate) async fn remove_task_metadata(
411 conn: &mut SqliteConnection,
412 workspace: &Workspace,
413 task_id: &TaskId,
414 input: &str,
415) -> Result<bool> {
416 let key = normalize_metadata_key(input)?;
417 let Some(field) = metadata_field_by_key(conn, &workspace.id, &key).await? else {
418 bail!("error unknown-metadata-field");
419 };
420 let identity = format!("metadata:{}", field.id);
421 if conflict_exists(conn, &workspace.id, task_id, &identity).await? {
422 bail!("error conflicted-field ref={task_id} field={identity}");
423 }
424 let base = field_version(conn, task_id, &identity).await?;
425 let deleted = sqlx::query(
426 "DELETE FROM task_metadata
427 WHERE workspace_id = ? AND task_id = ? AND field_id = ?",
428 )
429 .bind(&workspace.id)
430 .bind(task_id)
431 .bind(&field.id)
432 .execute(&mut *conn)
433 .await?
434 .rows_affected();
435 if deleted == 0 {
436 return Ok(false);
437 }
438 let timestamp = now();
439 touch_task(conn, &workspace.id, task_id, ×tamp).await?;
440 let change_id = insert_change(
441 conn,
442 "task",
443 task_id,
444 Some(&identity),
445 "remove_task_metadata",
446 json!({
447 "workspace_id": &workspace.id,
448 "workspace_key": &workspace.key,
449 "field_id": &field.id,
450 "key": &field.key,
451 }),
452 base.as_deref(),
453 )
454 .await?;
455 set_field_version(conn, task_id, &identity, &change_id).await?;
456 Ok(true)
457}
458
459pub(crate) async fn set_recurrence_metadata(
460 conn: &mut SqliteConnection,
461 workspace: &Workspace,
462 series_id: &RecurrenceSeriesId,
463 input: &TaskMetadataInput,
464) -> Result<bool> {
465 let field = resolve_or_create_metadata_field(conn, workspace, &input.key).await?;
466 let identity = format!("metadata:{}", field.id);
467 let current = sqlx::query_scalar::<_, String>(
468 "SELECT value FROM recurrence_series_metadata
469 WHERE workspace_id = ? AND series_id = ? AND field_id = ?",
470 )
471 .bind(&workspace.id)
472 .bind(series_id)
473 .bind(&field.id)
474 .fetch_optional(&mut *conn)
475 .await?;
476 if current.as_deref() == Some(input.value.as_str()) {
477 return Ok(false);
478 }
479 if entity_conflict_exists(
480 conn,
481 &workspace.id,
482 MutableEntityType::RecurrenceSeries,
483 series_id.as_str(),
484 &identity,
485 )
486 .await?
487 {
488 bail!("error conflicted-recurrence-field field={identity}");
489 }
490 let base = entity_field_version(
491 conn,
492 &workspace.id,
493 MutableEntityType::RecurrenceSeries,
494 series_id.as_str(),
495 &identity,
496 )
497 .await?;
498 let timestamp = now();
499 sqlx::query(
500 "INSERT INTO recurrence_series_metadata(
501 workspace_id, series_id, field_id, value, created_at, updated_at
502 ) VALUES (?, ?, ?, ?, ?, ?)
503 ON CONFLICT(workspace_id, series_id, field_id)
504 DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
505 )
506 .bind(&workspace.id)
507 .bind(series_id)
508 .bind(&field.id)
509 .bind(&input.value)
510 .bind(×tamp)
511 .bind(×tamp)
512 .execute(&mut *conn)
513 .await?;
514 sqlx::query("UPDATE recurrence_series SET updated_at = ? WHERE workspace_id = ? AND id = ?")
515 .bind(×tamp)
516 .bind(&workspace.id)
517 .bind(series_id)
518 .execute(&mut *conn)
519 .await?;
520 let change_id = insert_change(
521 conn,
522 "recurrence_series",
523 series_id.as_str(),
524 Some(&identity),
525 "set_recurrence_metadata",
526 json!({
527 "workspace_id": &workspace.id,
528 "workspace_key": &workspace.key,
529 "field_id": &field.id,
530 "key": &field.key,
531 "value": &input.value,
532 }),
533 base.as_deref(),
534 )
535 .await?;
536 set_entity_field_version(
537 conn,
538 &workspace.id,
539 MutableEntityType::RecurrenceSeries,
540 series_id.as_str(),
541 &identity,
542 &change_id,
543 )
544 .await?;
545 Ok(true)
546}
547
548pub(crate) async fn remove_recurrence_metadata(
549 conn: &mut SqliteConnection,
550 workspace: &Workspace,
551 series_id: &RecurrenceSeriesId,
552 key: &str,
553) -> Result<bool> {
554 let Some(field) =
555 metadata_field_by_key(conn, &workspace.id, &normalize_metadata_key(key)?).await?
556 else {
557 return Ok(false);
558 };
559 let identity = format!("metadata:{}", field.id);
560 if entity_conflict_exists(
561 conn,
562 &workspace.id,
563 MutableEntityType::RecurrenceSeries,
564 series_id.as_str(),
565 &identity,
566 )
567 .await?
568 {
569 bail!("error conflicted-recurrence-field field={identity}");
570 }
571 let removed = sqlx::query(
572 "DELETE FROM recurrence_series_metadata
573 WHERE workspace_id = ? AND series_id = ? AND field_id = ?",
574 )
575 .bind(&workspace.id)
576 .bind(series_id)
577 .bind(&field.id)
578 .execute(&mut *conn)
579 .await?
580 .rows_affected();
581 if removed == 0 {
582 return Ok(false);
583 }
584 let base = entity_field_version(
585 conn,
586 &workspace.id,
587 MutableEntityType::RecurrenceSeries,
588 series_id.as_str(),
589 &identity,
590 )
591 .await?;
592 let timestamp = now();
593 sqlx::query("UPDATE recurrence_series SET updated_at = ? WHERE workspace_id = ? AND id = ?")
594 .bind(×tamp)
595 .bind(&workspace.id)
596 .bind(series_id)
597 .execute(&mut *conn)
598 .await?;
599 let change_id = insert_change(
600 conn,
601 "recurrence_series",
602 series_id.as_str(),
603 Some(&identity),
604 "remove_recurrence_metadata",
605 json!({
606 "workspace_id": &workspace.id,
607 "workspace_key": &workspace.key,
608 "field_id": &field.id,
609 "key": &field.key,
610 }),
611 base.as_deref(),
612 )
613 .await?;
614 set_entity_field_version(
615 conn,
616 &workspace.id,
617 MutableEntityType::RecurrenceSeries,
618 series_id.as_str(),
619 &identity,
620 &change_id,
621 )
622 .await?;
623 Ok(true)
624}
625
626pub(crate) fn validate_metadata_update(set: &[TaskMetadataInput], remove: &[String]) -> Result<()> {
627 if set.len() > MAX_METADATA_VALUES {
628 bail!("error too-many-metadata-values limit={MAX_METADATA_VALUES}");
629 }
630 let mut keys = BTreeSet::new();
631 let mut total_bytes = 0usize;
632 for input in set {
633 let key = normalize_metadata_key(&input.key)?;
634 if !keys.insert(key) {
635 bail!("error duplicate-metadata-key");
636 }
637 if input.value.len() > MAX_METADATA_VALUE_BYTES {
638 bail!("error metadata-value-too-large limit={MAX_METADATA_VALUE_BYTES}");
639 }
640 total_bytes = total_bytes.saturating_add(input.value.len());
641 }
642 if total_bytes > MAX_METADATA_TOTAL_BYTES {
643 bail!("error metadata-values-too-large limit={MAX_METADATA_TOTAL_BYTES}");
644 }
645 for input in remove {
646 let key = normalize_metadata_key(input)?;
647 if !keys.insert(key) {
648 bail!("error overlapping-metadata-key");
649 }
650 }
651 Ok(())
652}
653
654fn validate_metadata_result_limits(
655 existing: impl IntoIterator<Item = (String, String)>,
656 set: &[TaskMetadataInput],
657 remove: &[String],
658) -> Result<()> {
659 let mut values = existing.into_iter().collect::<HashMap<_, _>>();
660 for key in remove {
661 values.remove(&normalize_metadata_key(key)?);
662 }
663 for input in set {
664 values.insert(normalize_metadata_key(&input.key)?, input.value.clone());
665 }
666 if values.len() > MAX_METADATA_VALUES {
667 bail!("error too-many-metadata-values limit={MAX_METADATA_VALUES}");
668 }
669 let total_bytes = values.values().map(String::len).sum::<usize>();
670 if total_bytes > MAX_METADATA_TOTAL_BYTES {
671 bail!("error metadata-values-too-large limit={MAX_METADATA_TOTAL_BYTES}");
672 }
673 Ok(())
674}
675
676pub(crate) async fn validate_recurrence_metadata_result(
677 conn: &mut SqliteConnection,
678 workspace_id: &WorkspaceId,
679 series_id: &RecurrenceSeriesId,
680 set: &[TaskMetadataInput],
681 remove: &[String],
682) -> Result<()> {
683 validate_metadata_update(set, remove)?;
684 let rows = sqlx::query(
685 "SELECT f.key, m.value FROM recurrence_series_metadata m
686 JOIN metadata_fields f ON f.workspace_id = m.workspace_id AND f.id = m.field_id
687 WHERE m.workspace_id = ? AND m.series_id = ?",
688 )
689 .bind(workspace_id)
690 .bind(series_id)
691 .fetch_all(&mut *conn)
692 .await?;
693 validate_metadata_result_limits(
694 rows.into_iter()
695 .map(|row| (row.get::<String, _>("key"), row.get::<String, _>("value"))),
696 set,
697 remove,
698 )
699}
700
701pub(crate) async fn validate_task_metadata_result(
702 conn: &mut SqliteConnection,
703 workspace_id: &WorkspaceId,
704 task_id: &TaskId,
705 set: &[TaskMetadataInput],
706 remove: &[String],
707) -> Result<()> {
708 validate_metadata_update(set, remove)?;
709 let rows = sqlx::query(
710 "SELECT f.key, m.value FROM task_metadata m
711 JOIN metadata_fields f ON f.workspace_id = m.workspace_id AND f.id = m.field_id
712 WHERE m.workspace_id = ? AND m.task_id = ?",
713 )
714 .bind(workspace_id)
715 .bind(task_id)
716 .fetch_all(&mut *conn)
717 .await?;
718 validate_metadata_result_limits(
719 rows.into_iter()
720 .map(|row| (row.get::<String, _>("key"), row.get::<String, _>("value"))),
721 set,
722 remove,
723 )
724}
725
726pub(crate) async fn resolve_metadata_inputs(
727 conn: &mut SqliteConnection,
728 workspace: &Workspace,
729 inputs: &[TaskMetadataInput],
730) -> Result<Vec<ResolvedMetadataValue>> {
731 validate_metadata_update(inputs, &[])?;
732 let mut values = Vec::with_capacity(inputs.len());
733 for input in inputs {
734 let field = resolve_or_create_metadata_field(conn, workspace, &input.key).await?;
735 values.push(ResolvedMetadataValue {
736 field_id: field.id,
737 key: field.key,
738 value: input.value.clone(),
739 });
740 }
741 Ok(values)
742}
743
744async fn touch_task(
745 conn: &mut SqliteConnection,
746 workspace_id: &WorkspaceId,
747 task_id: &TaskId,
748 timestamp: &str,
749) -> Result<()> {
750 let affected = sqlx::query("UPDATE tasks SET updated_at = ? WHERE workspace_id = ? AND id = ?")
751 .bind(timestamp)
752 .bind(workspace_id)
753 .bind(task_id)
754 .execute(&mut *conn)
755 .await?
756 .rows_affected();
757 if affected != 1 {
758 bail!("error task-not-found task_id={task_id}");
759 }
760 Ok(())
761}
762
763pub(crate) async fn task_metadata_in_workspace(
764 conn: &mut SqliteConnection,
765 workspace_id: &WorkspaceId,
766 task_id: &TaskId,
767) -> Result<Vec<TaskMetadataValue>> {
768 let rows = sqlx::query(
769 "SELECT m.field_id, f.key, m.value
770 FROM task_metadata m
771 JOIN metadata_fields f ON f.workspace_id = m.workspace_id AND f.id = m.field_id
772 WHERE m.workspace_id = ? AND m.task_id = ?
773 ORDER BY f.key",
774 )
775 .bind(workspace_id)
776 .bind(task_id)
777 .fetch_all(&mut *conn)
778 .await?;
779 Ok(rows
780 .into_iter()
781 .map(|row| TaskMetadataValue {
782 field_id: row.get("field_id"),
783 key: row.get("key"),
784 value: row.get("value"),
785 })
786 .collect())
787}
788
789pub(crate) async fn metadata_by_task_ids(
790 conn: &mut SqliteConnection,
791 workspace_id: &WorkspaceId,
792 task_ids: &[TaskId],
793) -> Result<HashMap<TaskId, Vec<TaskMetadataValue>>> {
794 let mut values = HashMap::new();
795 for chunk in task_ids.chunks(900) {
796 let mut query = QueryBuilder::<Sqlite>::new(
797 "SELECT m.task_id, m.field_id, f.key, m.value
798 FROM task_metadata m
799 JOIN metadata_fields f
800 ON f.workspace_id = m.workspace_id AND f.id = m.field_id
801 WHERE m.workspace_id = ",
802 );
803 query.push_bind(workspace_id);
804 query.push(" AND m.task_id IN (");
805 {
806 let mut separated = query.separated(", ");
807 for task_id in chunk {
808 separated.push_bind(task_id);
809 }
810 }
811 query.push(") ORDER BY m.task_id, f.key");
812 for row in query.build().fetch_all(&mut *conn).await? {
813 let task_id: TaskId = row.get("task_id");
814 values
815 .entry(task_id)
816 .or_insert_with(Vec::new)
817 .push(TaskMetadataValue {
818 field_id: row.get("field_id"),
819 key: row.get("key"),
820 value: row.get("value"),
821 });
822 }
823 }
824 Ok(values)
825}
826
827async fn list_metadata_fields_in_workspace(
828 conn: &mut SqliteConnection,
829 workspace_id: &WorkspaceId,
830) -> Result<Vec<MetadataFieldUsage>> {
831 let rows = sqlx::query(
832 "SELECT f.id, f.workspace_id, f.key, f.created_at, f.updated_at,
833 (SELECT count(*) FROM task_metadata m
834 WHERE m.workspace_id = f.workspace_id AND m.field_id = f.id) AS task_count,
835 (SELECT count(*) FROM recurrence_series_metadata m
836 WHERE m.workspace_id = f.workspace_id AND m.field_id = f.id) AS series_count
837 FROM metadata_fields f
838 WHERE f.workspace_id = ?
839 ORDER BY f.key",
840 )
841 .bind(workspace_id)
842 .fetch_all(&mut *conn)
843 .await?;
844 Ok(rows
845 .into_iter()
846 .map(|row| MetadataFieldUsage {
847 field: MetadataField {
848 id: row.get("id"),
849 workspace_id: row.get("workspace_id"),
850 key: row.get("key"),
851 created_at: row.get("created_at"),
852 updated_at: row.get("updated_at"),
853 },
854 task_count: usize::try_from(row.get::<i64, _>("task_count")).unwrap_or(usize::MAX),
855 series_count: usize::try_from(row.get::<i64, _>("series_count")).unwrap_or(usize::MAX),
856 })
857 .collect())
858}
859
860fn metadata_field_from_row(row: sqlx::sqlite::SqliteRow) -> MetadataField {
861 MetadataField {
862 id: row.get("id"),
863 workspace_id: row.get("workspace_id"),
864 key: row.get("key"),
865 created_at: row.get("created_at"),
866 updated_at: row.get("updated_at"),
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use crate::db::begin_immediate;
874 use crate::test_support::{ensure_default_workspace, test_conn};
875
876 #[test]
877 fn metadata_keys_have_stable_normalization() {
878 assert_eq!(normalize_metadata_key(" Max_Turns ").unwrap(), "max_turns");
879 assert_eq!(normalize_metadata_key("source.id").unwrap(), "source.id");
880 assert!(normalize_metadata_key("1source").is_err());
881 assert!(normalize_metadata_key("source id").is_err());
882 assert!(normalize_metadata_key("aven.internal").is_err());
883 }
884
885 #[test]
886 fn metadata_result_limits_include_existing_value_bytes() {
887 let mut existing = (0..7)
888 .map(|index| (format!("key_{index}"), "x".repeat(MAX_METADATA_VALUE_BYTES)))
889 .collect::<Vec<_>>();
890 existing.push((
891 "key_7".to_string(),
892 "x".repeat(MAX_METADATA_VALUE_BYTES - 1),
893 ));
894 let set = [TaskMetadataInput {
895 key: "key_8".to_string(),
896 value: "é".to_string(),
897 }];
898
899 let error = validate_metadata_result_limits(existing, &set, &[]).unwrap_err();
900
901 assert_eq!(
902 error.to_string(),
903 format!("error metadata-values-too-large limit={MAX_METADATA_TOTAL_BYTES}")
904 );
905 }
906
907 #[test]
908 fn metadata_result_limits_apply_removals_before_sets() {
909 let existing = (0..8)
910 .map(|index| (format!("key_{index}"), "x".repeat(MAX_METADATA_VALUE_BYTES)))
911 .collect::<Vec<_>>();
912 let set = [TaskMetadataInput {
913 key: "replacement".to_string(),
914 value: "y".repeat(MAX_METADATA_VALUE_BYTES),
915 }];
916
917 validate_metadata_result_limits(existing, &set, &[" KEY_0 ".to_string()]).unwrap();
918 }
919
920 #[test]
921 fn metadata_result_limits_distinguish_replacement_from_insertion_at_count_limit() {
922 let existing = (0..MAX_METADATA_VALUES)
923 .map(|index| {
924 (
925 format!("key_{index}"),
926 "x".repeat(MAX_METADATA_TOTAL_BYTES / MAX_METADATA_VALUES),
927 )
928 })
929 .collect::<Vec<_>>();
930 let replacement = [TaskMetadataInput {
931 key: "KEY_0".to_string(),
932 value: "replacement".to_string(),
933 }];
934 validate_metadata_result_limits(existing.clone(), &replacement, &[]).unwrap();
935
936 let insertion = [TaskMetadataInput {
937 key: "extra".to_string(),
938 value: "x".to_string(),
939 }];
940 let error = validate_metadata_result_limits(existing, &insertion, &[]).unwrap_err();
941
942 assert_eq!(
943 error.to_string(),
944 format!("error too-many-metadata-values limit={MAX_METADATA_VALUES}")
945 );
946 }
947
948 #[tokio::test]
949 async fn repeated_key_resolution_reuses_stable_field_identity() {
950 let (_temp, mut conn) = test_conn().await;
951 let workspace = ensure_default_workspace(&mut conn).await.unwrap();
952 let mut tx = begin_immediate(&mut conn).await.unwrap();
953 let first = resolve_or_create_metadata_field(&mut tx, &workspace, "max_turns")
954 .await
955 .unwrap();
956 let second = resolve_or_create_metadata_field(&mut tx, &workspace, "MAX_TURNS")
957 .await
958 .unwrap();
959 tx.commit().await.unwrap();
960
961 assert_eq!(first.id, second.id);
962 let changes: i64 = sqlx::query_scalar(
963 "SELECT count(*) FROM changes WHERE op_type = 'create_metadata_field'",
964 )
965 .fetch_one(&mut *conn)
966 .await
967 .unwrap();
968 assert_eq!(changes, 1);
969 }
970}