1use std::collections::{BTreeMap, BTreeSet};
2
3use anyhow::{Result, bail, ensure};
4use serde_json::to_value;
5use sqlx::{Row, SqliteConnection};
6
7use crate::change_log::op_type;
8use crate::db::{Database, IdentifiedChange, begin_immediate, insert_change_with_identity};
9use crate::ids::{TaskId, new_id, now};
10use crate::metadata::TaskMetadataInput;
11use crate::sync::wire::{
12 ChangeWire, MAX_MOVE_TASKS, MoveDependencySnapshot, MoveEpicSnapshot, MoveRelatedSnapshot,
13 MoveTaskMetadataSnapshot, MoveTaskSnapshot, MoveTasksPayload,
14};
15use crate::workspaces::Workspace;
16
17#[derive(Debug, Clone)]
18pub struct MoveTasksInput {
19 pub task_ids: Vec<TaskId>,
20 pub target_workspace: Workspace,
21 pub target_project: String,
22}
23
24#[derive(Debug, Clone)]
25pub struct MoveTasksOutcome {
26 pub task_ids: Vec<TaskId>,
27 pub target_project_key: String,
28}
29
30impl Database {
31 pub async fn move_tasks(
32 &self,
33 source_workspace: &Workspace,
34 input: MoveTasksInput,
35 ) -> Result<MoveTasksOutcome> {
36 let mut conn = self.acquire_writer().await?;
37 let mut tx = begin_immediate(&mut conn).await?;
38 let outcome = move_tasks_in_transaction(&mut tx, source_workspace, input).await;
39 match outcome {
40 Ok(outcome) => {
41 tx.commit().await?;
42 Ok(outcome)
43 }
44 Err(error) => {
45 tx.rollback().await?;
46 Err(error)
47 }
48 }
49 }
50}
51
52async fn move_tasks_in_transaction(
53 conn: &mut SqliteConnection,
54 source_workspace: &Workspace,
55 input: MoveTasksInput,
56) -> Result<MoveTasksOutcome> {
57 ensure!(
58 source_workspace.id != input.target_workspace.id,
59 "error move-same-workspace"
60 );
61 let target_project = crate::projects::resolve_existing_project_in_workspace(
62 conn,
63 &input.target_workspace.id,
64 &input.target_project,
65 )
66 .await?;
67 let task_ids = expand_and_validate_members(conn, source_workspace, input.task_ids).await?;
68 let (dependencies, related, epics) =
69 relationship_snapshots(conn, source_workspace, &task_ids).await?;
70 let task_snapshots =
71 task_snapshots(conn, source_workspace, &input.target_workspace, &task_ids).await?;
72 let payload = MoveTasksPayload {
73 source_workspace_id: source_workspace.id.clone(),
74 target_workspace_id: input.target_workspace.id.clone(),
75 project_id: target_project.id.clone(),
76 project_key: target_project.key.clone(),
77 project_name: target_project.name.clone(),
78 project_prefix: target_project.prefix.clone(),
79 tasks: task_snapshots,
80 dependencies,
81 related,
82 epics,
83 };
84 let payload_value = to_value(&payload)?;
85 let group_id = new_id();
86 let change_id = new_id();
87 let created_at = now();
88 let validation_change = ChangeWire {
89 change_id: change_id.clone(),
90 client_id: "local".to_string(),
91 local_seq: 1,
92 entity_type: "task_move".to_string(),
93 entity_id: group_id.clone(),
94 field: None,
95 op_type: op_type::MOVE_TASKS.to_string(),
96 payload: payload_value.clone(),
97 base_version: None,
98 created_at: created_at.clone(),
99 server_seq: None,
100 };
101 crate::sync::wire::validate_local_change_shape(&validation_change)?;
102
103 insert_change_with_identity(
104 conn,
105 IdentifiedChange {
106 change_id: &change_id,
107 entity_type: "task_move",
108 entity_id: &group_id,
109 field: None,
110 op_type: op_type::MOVE_TASKS,
111 payload: payload_value,
112 base_version: None,
113 created_at: &created_at,
114 },
115 )
116 .await?;
117 crate::sync::apply::apply_remote_change(conn, &validation_change).await?;
118
119 Ok(MoveTasksOutcome {
120 task_ids,
121 target_project_key: target_project.key,
122 })
123}
124
125async fn expand_and_validate_members(
126 conn: &mut SqliteConnection,
127 source_workspace: &Workspace,
128 requested: Vec<TaskId>,
129) -> Result<Vec<TaskId>> {
130 if requested.is_empty() {
131 bail!("error move-task-required");
132 }
133 let mut members = requested.into_iter().collect::<BTreeSet<_>>();
134 loop {
135 let mut added = false;
136 for task_id in members.iter().cloned().collect::<Vec<_>>() {
137 let is_epic: Option<i64> =
138 sqlx::query_scalar("SELECT is_epic FROM tasks WHERE workspace_id = ? AND id = ?")
139 .bind(&source_workspace.id)
140 .bind(&task_id)
141 .fetch_optional(&mut *conn)
142 .await?;
143 let Some(is_epic) = is_epic else {
144 bail!("error task-not-found task_id={task_id}");
145 };
146 if is_epic != 0 {
147 let children: Vec<TaskId> = sqlx::query_scalar(
148 "SELECT child_task_id FROM task_epic_links
149 WHERE workspace_id = ? AND epic_task_id = ?",
150 )
151 .bind(&source_workspace.id)
152 .bind(&task_id)
153 .fetch_all(&mut *conn)
154 .await?;
155 for child in children {
156 added |= members.insert(child);
157 }
158 }
159 }
160 if !added {
161 break;
162 }
163 }
164 if members.len() > MAX_MOVE_TASKS {
165 bail!("error move-too-many-tasks limit={MAX_MOVE_TASKS}");
166 }
167 for task_id in &members {
168 let recurrence: bool = sqlx::query_scalar(
169 "SELECT EXISTS(SELECT 1 FROM recurrence_occurrences WHERE workspace_id = ? AND task_id = ?)",
170 )
171 .bind(&source_workspace.id)
172 .bind(task_id)
173 .fetch_one(&mut *conn)
174 .await?;
175 if recurrence {
176 bail!("error move-recurring-task task_id={task_id}");
177 }
178 let conflicted: bool = sqlx::query_scalar(
179 "SELECT EXISTS(SELECT 1 FROM conflicts
180 WHERE workspace_id = ? AND entity_type = 'task' AND entity_id = ? AND resolved = 0)",
181 )
182 .bind(&source_workspace.id)
183 .bind(task_id)
184 .fetch_one(&mut *conn)
185 .await?;
186 if conflicted {
187 bail!("error move-conflicted-task task_id={task_id}");
188 }
189 }
190 Ok(members.into_iter().collect())
191}
192
193async fn task_snapshots(
194 conn: &mut SqliteConnection,
195 source_workspace: &Workspace,
196 target_workspace: &Workspace,
197 task_ids: &[TaskId],
198) -> Result<Vec<MoveTaskSnapshot>> {
199 let mut labels_by_task =
200 crate::task_enrichment::labels_for_tasks(conn, &source_workspace.id, task_ids).await?;
201 let mut metadata_by_task =
202 crate::metadata::metadata_by_task_ids(conn, &source_workspace.id, task_ids).await?;
203 let mut snapshots = Vec::with_capacity(task_ids.len());
204 for task_id in task_ids {
205 let labels = labels_by_task.remove(task_id).unwrap_or_default();
206 for label in &labels {
207 crate::operations::create_label_operation(conn, target_workspace, label).await?;
208 }
209 let source_metadata = metadata_by_task.remove(task_id).unwrap_or_default();
210 let mut metadata = Vec::with_capacity(source_metadata.len());
211 for value in source_metadata {
212 let version =
213 crate::db::field_version(conn, task_id, &format!("metadata:{}", value.field_id))
214 .await?;
215 let inputs = [TaskMetadataInput {
216 expected_field_id: None,
217 key: value.key,
218 value: value.value,
219 }];
220 let resolved =
221 crate::metadata::resolve_metadata_inputs(conn, target_workspace, &inputs).await?;
222 let value = resolved.into_iter().next().expect("one metadata input");
223 metadata.push(MoveTaskMetadataSnapshot {
224 field_id: value.field_id,
225 key: value.key,
226 value: Some(value.value),
227 version,
228 });
229 }
230 let versions: Vec<(String, String, String)> = sqlx::query_as(
231 "SELECT m.id, m.key, f.version FROM field_versions f
232 JOIN metadata_fields m ON m.workspace_id = f.workspace_id AND f.field = 'metadata:' || m.id
233 WHERE f.entity_type = 'task' AND f.entity_id = ? AND f.workspace_id = ?")
234 .bind(task_id).bind(&source_workspace.id).fetch_all(&mut *conn).await?;
235 for (_, key, version) in versions {
236 if metadata.iter().any(|value| value.key == key) {
237 continue;
238 }
239 let resolved = crate::metadata::resolve_metadata_inputs(
240 conn,
241 target_workspace,
242 &[TaskMetadataInput {
243 expected_field_id: None,
244 key,
245 value: String::new(),
246 }],
247 )
248 .await?;
249 let value = resolved.into_iter().next().expect("one metadata input");
250 metadata.push(MoveTaskMetadataSnapshot {
251 field_id: value.field_id,
252 key: value.key,
253 value: None,
254 version: Some(version),
255 });
256 }
257 metadata.sort_by(|a, b| a.key.cmp(&b.key));
258 snapshots.push(MoveTaskSnapshot {
259 task_id: task_id.clone(),
260 labels,
261 metadata,
262 });
263 }
264 Ok(snapshots)
265}
266
267type RelationshipSnapshots = (
268 Vec<MoveDependencySnapshot>,
269 Vec<MoveRelatedSnapshot>,
270 Vec<MoveEpicSnapshot>,
271);
272
273async fn relationship_snapshots(
274 conn: &mut SqliteConnection,
275 workspace: &Workspace,
276 task_ids: &[TaskId],
277) -> Result<RelationshipSnapshots> {
278 let members = task_ids.iter().cloned().collect::<BTreeSet<_>>();
279 let mut dependencies = BTreeMap::new();
280 let mut related = BTreeMap::new();
281 let mut epics = BTreeMap::new();
282 for task_id in task_ids {
283 let rows = sqlx::query(
284 "SELECT task_id, depends_on_task_id, created_at FROM task_dependencies
285 WHERE workspace_id = ? AND (task_id = ? OR depends_on_task_id = ?)",
286 )
287 .bind(&workspace.id)
288 .bind(task_id)
289 .bind(task_id)
290 .fetch_all(&mut *conn)
291 .await?;
292 for row in rows {
293 let task_id: TaskId = row.get("task_id");
294 let depends_on_task_id: TaskId = row.get("depends_on_task_id");
295 if !members.contains(&task_id) || !members.contains(&depends_on_task_id) {
296 bail!(
297 "error move-boundary-dependency task_id={task_id} depends_on_task_id={depends_on_task_id} hint=remove-link-or-include-task"
298 );
299 }
300 dependencies
301 .entry((task_id.clone(), depends_on_task_id.clone()))
302 .or_insert(MoveDependencySnapshot {
303 task_id,
304 depends_on_task_id,
305 created_at: row.get("created_at"),
306 });
307 }
308 let rows = sqlx::query(
309 "SELECT task_a_id, task_b_id, linked, last_change_id FROM task_related_links
310 WHERE workspace_id = ? AND (task_a_id = ? OR task_b_id = ?)",
311 )
312 .bind(&workspace.id)
313 .bind(task_id)
314 .bind(task_id)
315 .fetch_all(&mut *conn)
316 .await?;
317 for row in rows {
318 let task_a_id: TaskId = row.get("task_a_id");
319 let task_b_id: TaskId = row.get("task_b_id");
320 if !members.contains(&task_a_id) || !members.contains(&task_b_id) {
321 if row.get::<i64, _>("linked") == 0 {
322 continue;
323 }
324 bail!(
325 "error move-boundary-related task_id={task_a_id} related_task_id={task_b_id} hint=remove-link-or-include-task"
326 );
327 }
328 related
329 .entry((task_a_id.clone(), task_b_id.clone()))
330 .or_insert(MoveRelatedSnapshot {
331 task_a_id,
332 task_b_id,
333 linked: row.get::<i64, _>("linked") != 0,
334 last_change_id: row.get("last_change_id"),
335 });
336 }
337 let rows = sqlx::query(
338 "SELECT child_task_id, epic_task_id, created_at FROM task_epic_links
339 WHERE workspace_id = ? AND (child_task_id = ? OR epic_task_id = ?)",
340 )
341 .bind(&workspace.id)
342 .bind(task_id)
343 .bind(task_id)
344 .fetch_all(&mut *conn)
345 .await?;
346 for row in rows {
347 let child_task_id: TaskId = row.get("child_task_id");
348 let epic_task_id: TaskId = row.get("epic_task_id");
349 if !members.contains(&child_task_id) || !members.contains(&epic_task_id) {
350 bail!(
351 "error move-boundary-epic child_task_id={child_task_id} epic_task_id={epic_task_id} hint=move-the-epic-or-remove-link"
352 );
353 }
354 epics
355 .entry((child_task_id.clone(), epic_task_id.clone()))
356 .or_insert(MoveEpicSnapshot {
357 child_task_id,
358 epic_task_id,
359 created_at: row.get("created_at"),
360 });
361 }
362 }
363 Ok((
364 dependencies.into_values().collect(),
365 related.into_values().collect(),
366 epics.into_values().collect(),
367 ))
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::choices::TaskSource;
374 use crate::metadata::TaskMetadataInput;
375 use crate::operations::{TaskDraft, TaskUpdate};
376
377 fn draft(title: &str, project: &str, labels: Vec<String>, is_epic: bool) -> TaskDraft {
378 TaskDraft {
379 title: title.to_string(),
380 description: "description".to_string(),
381 project: Some(project.to_string()),
382 status: "todo".to_string(),
383 priority: "none".to_string(),
384 source: TaskSource::Cli,
385 labels,
386 metadata: vec![TaskMetadataInput {
387 expected_field_id: None,
388 key: "owner".to_string(),
389 value: "Raine".to_string(),
390 }],
391 available_at: None,
392 due_on: None,
393 is_epic,
394 }
395 }
396
397 async fn setup() -> (tempfile::TempDir, Database, Workspace, Workspace) {
398 let temp = tempfile::tempdir().unwrap();
399 let database = Database::open(&temp.path().join("aven.sqlite"))
400 .await
401 .unwrap();
402 let source = database.list_workspaces().await.unwrap().remove(0);
403 let target = database.create_workspace("Target").await.unwrap();
404 database
405 .create_project(&source, "Source", None)
406 .await
407 .unwrap();
408 database
409 .create_project(&target, "Target", None)
410 .await
411 .unwrap();
412 (temp, database, source, target)
413 }
414
415 #[tokio::test]
416 async fn move_preserves_task_owned_data_and_import_history() {
417 let (_temp, database, source, target) = setup().await;
418 database.create_label(&source, "important").await.unwrap();
419 let task = database
420 .create_task(
421 &source,
422 draft("Move me", "Source", vec!["important".to_string()], false),
423 )
424 .await
425 .unwrap()
426 .task;
427 database
428 .add_note(&source, &task.id, "keep this note".to_string())
429 .await
430 .unwrap();
431
432 let outcome = database
433 .move_tasks(
434 &source,
435 MoveTasksInput {
436 task_ids: vec![task.id.clone()],
437 target_workspace: target.clone(),
438 target_project: "Target".to_string(),
439 },
440 )
441 .await
442 .unwrap();
443 assert_eq!(outcome.task_ids, vec![task.id.clone()]);
444
445 let mut conn = database.acquire_reader().await.unwrap();
446 let row: (String, String) = sqlx::query_as(
447 "SELECT t.workspace_id, p.key FROM tasks t
448 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
449 WHERE t.id = ?",
450 )
451 .bind(&task.id)
452 .fetch_one(&mut *conn)
453 .await
454 .unwrap();
455 assert_eq!(row, (target.id.to_string(), "target".to_string()));
456 for (table, query) in [
457 (
458 "notes",
459 "SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ?",
460 ),
461 (
462 "task_labels",
463 "SELECT count(*) FROM task_labels WHERE workspace_id = ? AND task_id = ?",
464 ),
465 (
466 "task_metadata",
467 "SELECT count(*) FROM task_metadata WHERE workspace_id = ? AND task_id = ?",
468 ),
469 ] {
470 let count: i64 = sqlx::query_scalar(query)
471 .bind(&target.id)
472 .bind(&task.id)
473 .fetch_one(&mut *conn)
474 .await
475 .unwrap();
476 assert_eq!(count, 1, "{table}");
477 }
478 let protocol = crate::db::get_meta(&mut conn, "sync_established_protocol")
479 .await
480 .unwrap();
481 assert_eq!(protocol, None);
482 let history: i64 =
483 sqlx::query_scalar("SELECT count(*) FROM task_workspace_history WHERE task_id = ?")
484 .bind(&task.id)
485 .fetch_one(&mut *conn)
486 .await
487 .unwrap();
488 assert_eq!(history, 2);
489 drop(conn);
490
491 let export = database.export_data(crate::ids::now()).await.unwrap();
492 let imported_temp = tempfile::tempdir().unwrap();
493 let imported = Database::open(&imported_temp.path().join("import.sqlite"))
494 .await
495 .unwrap();
496 imported.import_data(&export).await.unwrap();
497 let mut imported_conn = imported.acquire_reader().await.unwrap();
498 let imported_workspace: String =
499 sqlx::query_scalar("SELECT workspace_id FROM tasks WHERE id = ?")
500 .bind(&task.id)
501 .fetch_one(&mut *imported_conn)
502 .await
503 .unwrap();
504 assert_eq!(imported_workspace, target.id.to_string());
505 let imported_history: i64 =
506 sqlx::query_scalar("SELECT count(*) FROM task_workspace_history WHERE task_id = ?")
507 .bind(&task.id)
508 .fetch_one(&mut *imported_conn)
509 .await
510 .unwrap();
511 assert_eq!(imported_history, 2);
512 }
513
514 #[tokio::test]
515 async fn boundary_relationship_rejects_the_entire_move() {
516 let (_temp, database, source, target) = setup().await;
517 let first = database
518 .create_task(&source, draft("First", "Source", Vec::new(), false))
519 .await
520 .unwrap()
521 .task;
522 let second = database
523 .create_task(&source, draft("Second", "Source", Vec::new(), false))
524 .await
525 .unwrap()
526 .task;
527 database
528 .add_task_dependency(&source, &first.id, &second.id)
529 .await
530 .unwrap();
531
532 let error = database
533 .move_tasks(
534 &source,
535 MoveTasksInput {
536 task_ids: vec![first.id.clone()],
537 target_workspace: target,
538 target_project: "Target".to_string(),
539 },
540 )
541 .await
542 .unwrap_err();
543 assert!(error.to_string().contains("move-boundary-dependency"));
544 let mut conn = database.acquire_reader().await.unwrap();
545 let workspace_id: String =
546 sqlx::query_scalar("SELECT workspace_id FROM tasks WHERE id = ?")
547 .bind(&first.id)
548 .fetch_one(&mut *conn)
549 .await
550 .unwrap();
551 assert_eq!(workspace_id, source.id.to_string());
552 assert_eq!(
553 crate::db::get_meta(&mut conn, "sync_established_protocol")
554 .await
555 .unwrap(),
556 None
557 );
558 }
559
560 #[tokio::test]
561 async fn moving_an_epic_includes_soft_deleted_descendants() {
562 let (_temp, database, source, target) = setup().await;
563 let epic = database
564 .create_task(&source, draft("Epic", "Source", Vec::new(), true))
565 .await
566 .unwrap()
567 .task;
568 let child = database
569 .create_task(&source, draft("Child", "Source", Vec::new(), false))
570 .await
571 .unwrap()
572 .task;
573 database
574 .add_task_to_epic(&source, &child.id, &epic.id)
575 .await
576 .unwrap();
577 database
578 .update_task(
579 &source,
580 &child.id,
581 TaskUpdate {
582 deleted: Some(true),
583 ..TaskUpdate::default()
584 },
585 )
586 .await
587 .unwrap();
588
589 let outcome = database
590 .move_tasks(
591 &source,
592 MoveTasksInput {
593 task_ids: vec![epic.id.clone()],
594 target_workspace: target.clone(),
595 target_project: "Target".to_string(),
596 },
597 )
598 .await
599 .unwrap();
600 let mut expected = vec![child.id.clone(), epic.id.clone()];
601 expected.sort();
602 assert_eq!(outcome.task_ids, expected);
603 let mut conn = database.acquire_reader().await.unwrap();
604 let moved: Vec<(String, i64)> = sqlx::query_as(
605 "SELECT workspace_id, deleted FROM tasks WHERE id IN (?, ?) ORDER BY id",
606 )
607 .bind(&child.id)
608 .bind(&epic.id)
609 .fetch_all(&mut *conn)
610 .await
611 .unwrap();
612 assert_eq!(moved.len(), 2);
613 assert!(
614 moved
615 .iter()
616 .all(|(workspace_id, _)| workspace_id == target.id.as_str())
617 );
618 assert!(moved.iter().any(|(_, deleted)| *deleted == 1));
619 }
620 #[tokio::test]
621 async fn removed_related_boundary_allows_move_and_internal_link_imports() {
622 let (_temp, db, source, target) = setup().await;
623 let a = db
624 .create_task(&source, draft("A", "Source", vec![], false))
625 .await
626 .unwrap()
627 .task;
628 let b = db
629 .create_task(&source, draft("B", "Source", vec![], false))
630 .await
631 .unwrap()
632 .task;
633 db.add_task_related_link(&source, &a.id, &b.id)
634 .await
635 .unwrap();
636 db.remove_task_related_link(&source, &a.id, &b.id)
637 .await
638 .unwrap();
639 db.move_tasks(
640 &source,
641 MoveTasksInput {
642 task_ids: vec![a.id.clone()],
643 target_workspace: target.clone(),
644 target_project: "Target".into(),
645 },
646 )
647 .await
648 .unwrap();
649 db.move_tasks(
650 &target,
651 MoveTasksInput {
652 task_ids: vec![a.id.clone()],
653 target_workspace: source.clone(),
654 target_project: "Source".into(),
655 },
656 )
657 .await
658 .unwrap();
659 db.add_task_related_link(&source, &a.id, &b.id)
660 .await
661 .unwrap();
662 db.move_tasks(
663 &source,
664 MoveTasksInput {
665 task_ids: vec![a.id, b.id],
666 target_workspace: target,
667 target_project: "Target".into(),
668 },
669 )
670 .await
671 .unwrap();
672 let export = db.export_data("now".into()).await.unwrap();
673 let dir = tempfile::tempdir().unwrap();
674 let imported = Database::open(&dir.path().join("import.sqlite"))
675 .await
676 .unwrap();
677 imported.import_data(&export).await.unwrap();
678 }
679
680 #[tokio::test]
681 async fn oversized_snapshot_refuses_without_creating_destination_fields() {
682 let (_temp, db, source, target) = setup().await;
683 let mut ids = Vec::new();
684 for title in ["A", "B"] {
685 let mut d = draft(title, "Source", vec![], false);
686 d.metadata = (0..8)
687 .map(|n| TaskMetadataInput {
688 expected_field_id: None,
689 key: format!("field-{n}"),
690 value: "x".repeat(4096),
691 })
692 .collect();
693 ids.push(db.create_task(&source, d).await.unwrap().task.id);
694 }
695 let before = db.export_data("before".into()).await.unwrap();
696 let error = db
697 .move_tasks(
698 &source,
699 MoveTasksInput {
700 task_ids: ids,
701 target_workspace: target.clone(),
702 target_project: "Target".into(),
703 },
704 )
705 .await
706 .unwrap_err();
707 assert!(error.to_string().contains("payload-too-large"));
708 let after = db.export_data("after".into()).await.unwrap();
709 assert_eq!(before.tables.changes.len(), after.tables.changes.len());
710 assert_eq!(
711 before.tables.metadata_fields.len(),
712 after.tables.metadata_fields.len()
713 );
714 assert!(
715 after
716 .tables
717 .tasks
718 .iter()
719 .all(|t| t.workspace_id == source.id)
720 );
721 }
722
723 #[tokio::test]
724 async fn removed_metadata_versions_do_not_consume_live_field_capacity() {
725 let (_temp, db, source, target) = setup().await;
726 let mut d = draft("A", "Source", vec![], false);
727 d.metadata.clear();
728 let task = db.create_task(&source, d).await.unwrap().task;
729 for n in 0..129 {
730 let key = format!("field-{n}");
731 db.update_task(
732 &source,
733 &task.id,
734 TaskUpdate {
735 set_metadata: vec![TaskMetadataInput {
736 expected_field_id: None,
737 key: key.clone(),
738 value: "value".into(),
739 }],
740 ..Default::default()
741 },
742 )
743 .await
744 .unwrap();
745 db.update_task(
746 &source,
747 &task.id,
748 TaskUpdate {
749 remove_metadata: vec![key],
750 ..Default::default()
751 },
752 )
753 .await
754 .unwrap();
755 }
756 assert!(
757 db.task_metadata(&source.id, &task.id)
758 .await
759 .unwrap()
760 .is_empty()
761 );
762 db.move_tasks(
763 &source,
764 MoveTasksInput {
765 task_ids: vec![task.id.clone()],
766 target_workspace: target.clone(),
767 target_project: "Target".into(),
768 },
769 )
770 .await
771 .unwrap();
772 let values = db.task_metadata(&target.id, &task.id).await.unwrap();
773 assert!(values.is_empty(), "values={values:?}");
774 let snapshot = db.export_data("now".into()).await.unwrap();
775 let moved = snapshot
776 .tables
777 .changes
778 .iter()
779 .find(|c| c.op_type == "move_tasks")
780 .unwrap();
781 let payload: serde_json::Value = serde_json::from_str(&moved.payload).unwrap();
782 assert_eq!(
783 payload["tasks"][0]["metadata"].as_array().unwrap().len(),
784 129
785 );
786 }
787}