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.create_project(&source, "Source").await.unwrap();
405 database.create_project(&target, "Target").await.unwrap();
406 (temp, database, source, target)
407 }
408
409 #[tokio::test]
410 async fn move_preserves_task_owned_data_and_import_history() {
411 let (_temp, database, source, target) = setup().await;
412 database.create_label(&source, "important").await.unwrap();
413 let task = database
414 .create_task(
415 &source,
416 draft("Move me", "Source", vec!["important".to_string()], false),
417 )
418 .await
419 .unwrap()
420 .task;
421 database
422 .add_note(&source, &task.id, "keep this note".to_string())
423 .await
424 .unwrap();
425
426 let outcome = database
427 .move_tasks(
428 &source,
429 MoveTasksInput {
430 task_ids: vec![task.id.clone()],
431 target_workspace: target.clone(),
432 target_project: "Target".to_string(),
433 },
434 )
435 .await
436 .unwrap();
437 assert_eq!(outcome.task_ids, vec![task.id.clone()]);
438
439 let mut conn = database.acquire_reader().await.unwrap();
440 let row: (String, String) = sqlx::query_as(
441 "SELECT t.workspace_id, p.key FROM tasks t
442 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
443 WHERE t.id = ?",
444 )
445 .bind(&task.id)
446 .fetch_one(&mut *conn)
447 .await
448 .unwrap();
449 assert_eq!(row, (target.id.to_string(), "target".to_string()));
450 for (table, query) in [
451 (
452 "notes",
453 "SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ?",
454 ),
455 (
456 "task_labels",
457 "SELECT count(*) FROM task_labels WHERE workspace_id = ? AND task_id = ?",
458 ),
459 (
460 "task_metadata",
461 "SELECT count(*) FROM task_metadata WHERE workspace_id = ? AND task_id = ?",
462 ),
463 ] {
464 let count: i64 = sqlx::query_scalar(query)
465 .bind(&target.id)
466 .bind(&task.id)
467 .fetch_one(&mut *conn)
468 .await
469 .unwrap();
470 assert_eq!(count, 1, "{table}");
471 }
472 let protocol = crate::db::get_meta(&mut conn, "sync_established_protocol")
473 .await
474 .unwrap();
475 assert_eq!(protocol, None);
476 let history: i64 =
477 sqlx::query_scalar("SELECT count(*) FROM task_workspace_history WHERE task_id = ?")
478 .bind(&task.id)
479 .fetch_one(&mut *conn)
480 .await
481 .unwrap();
482 assert_eq!(history, 2);
483 drop(conn);
484
485 let export = database.export_data(crate::ids::now()).await.unwrap();
486 let imported_temp = tempfile::tempdir().unwrap();
487 let imported = Database::open(&imported_temp.path().join("import.sqlite"))
488 .await
489 .unwrap();
490 imported.import_data(&export).await.unwrap();
491 let mut imported_conn = imported.acquire_reader().await.unwrap();
492 let imported_workspace: String =
493 sqlx::query_scalar("SELECT workspace_id FROM tasks WHERE id = ?")
494 .bind(&task.id)
495 .fetch_one(&mut *imported_conn)
496 .await
497 .unwrap();
498 assert_eq!(imported_workspace, target.id.to_string());
499 let imported_history: i64 =
500 sqlx::query_scalar("SELECT count(*) FROM task_workspace_history WHERE task_id = ?")
501 .bind(&task.id)
502 .fetch_one(&mut *imported_conn)
503 .await
504 .unwrap();
505 assert_eq!(imported_history, 2);
506 }
507
508 #[tokio::test]
509 async fn boundary_relationship_rejects_the_entire_move() {
510 let (_temp, database, source, target) = setup().await;
511 let first = database
512 .create_task(&source, draft("First", "Source", Vec::new(), false))
513 .await
514 .unwrap()
515 .task;
516 let second = database
517 .create_task(&source, draft("Second", "Source", Vec::new(), false))
518 .await
519 .unwrap()
520 .task;
521 database
522 .add_task_dependency(&source, &first.id, &second.id)
523 .await
524 .unwrap();
525
526 let error = database
527 .move_tasks(
528 &source,
529 MoveTasksInput {
530 task_ids: vec![first.id.clone()],
531 target_workspace: target,
532 target_project: "Target".to_string(),
533 },
534 )
535 .await
536 .unwrap_err();
537 assert!(error.to_string().contains("move-boundary-dependency"));
538 let mut conn = database.acquire_reader().await.unwrap();
539 let workspace_id: String =
540 sqlx::query_scalar("SELECT workspace_id FROM tasks WHERE id = ?")
541 .bind(&first.id)
542 .fetch_one(&mut *conn)
543 .await
544 .unwrap();
545 assert_eq!(workspace_id, source.id.to_string());
546 assert_eq!(
547 crate::db::get_meta(&mut conn, "sync_established_protocol")
548 .await
549 .unwrap(),
550 None
551 );
552 }
553
554 #[tokio::test]
555 async fn moving_an_epic_includes_soft_deleted_descendants() {
556 let (_temp, database, source, target) = setup().await;
557 let epic = database
558 .create_task(&source, draft("Epic", "Source", Vec::new(), true))
559 .await
560 .unwrap()
561 .task;
562 let child = database
563 .create_task(&source, draft("Child", "Source", Vec::new(), false))
564 .await
565 .unwrap()
566 .task;
567 database
568 .add_task_to_epic(&source, &child.id, &epic.id)
569 .await
570 .unwrap();
571 database
572 .update_task(
573 &source,
574 &child.id,
575 TaskUpdate {
576 deleted: Some(true),
577 ..TaskUpdate::default()
578 },
579 )
580 .await
581 .unwrap();
582
583 let outcome = database
584 .move_tasks(
585 &source,
586 MoveTasksInput {
587 task_ids: vec![epic.id.clone()],
588 target_workspace: target.clone(),
589 target_project: "Target".to_string(),
590 },
591 )
592 .await
593 .unwrap();
594 let mut expected = vec![child.id.clone(), epic.id.clone()];
595 expected.sort();
596 assert_eq!(outcome.task_ids, expected);
597 let mut conn = database.acquire_reader().await.unwrap();
598 let moved: Vec<(String, i64)> = sqlx::query_as(
599 "SELECT workspace_id, deleted FROM tasks WHERE id IN (?, ?) ORDER BY id",
600 )
601 .bind(&child.id)
602 .bind(&epic.id)
603 .fetch_all(&mut *conn)
604 .await
605 .unwrap();
606 assert_eq!(moved.len(), 2);
607 assert!(
608 moved
609 .iter()
610 .all(|(workspace_id, _)| workspace_id == target.id.as_str())
611 );
612 assert!(moved.iter().any(|(_, deleted)| *deleted == 1));
613 }
614 #[tokio::test]
615 async fn removed_related_boundary_allows_move_and_internal_link_imports() {
616 let (_temp, db, source, target) = setup().await;
617 let a = db
618 .create_task(&source, draft("A", "Source", vec![], false))
619 .await
620 .unwrap()
621 .task;
622 let b = db
623 .create_task(&source, draft("B", "Source", vec![], false))
624 .await
625 .unwrap()
626 .task;
627 db.add_task_related_link(&source, &a.id, &b.id)
628 .await
629 .unwrap();
630 db.remove_task_related_link(&source, &a.id, &b.id)
631 .await
632 .unwrap();
633 db.move_tasks(
634 &source,
635 MoveTasksInput {
636 task_ids: vec![a.id.clone()],
637 target_workspace: target.clone(),
638 target_project: "Target".into(),
639 },
640 )
641 .await
642 .unwrap();
643 db.move_tasks(
644 &target,
645 MoveTasksInput {
646 task_ids: vec![a.id.clone()],
647 target_workspace: source.clone(),
648 target_project: "Source".into(),
649 },
650 )
651 .await
652 .unwrap();
653 db.add_task_related_link(&source, &a.id, &b.id)
654 .await
655 .unwrap();
656 db.move_tasks(
657 &source,
658 MoveTasksInput {
659 task_ids: vec![a.id, b.id],
660 target_workspace: target,
661 target_project: "Target".into(),
662 },
663 )
664 .await
665 .unwrap();
666 let export = db.export_data("now".into()).await.unwrap();
667 let dir = tempfile::tempdir().unwrap();
668 let imported = Database::open(&dir.path().join("import.sqlite"))
669 .await
670 .unwrap();
671 imported.import_data(&export).await.unwrap();
672 }
673
674 #[tokio::test]
675 async fn oversized_snapshot_refuses_without_creating_destination_fields() {
676 let (_temp, db, source, target) = setup().await;
677 let mut ids = Vec::new();
678 for title in ["A", "B"] {
679 let mut d = draft(title, "Source", vec![], false);
680 d.metadata = (0..8)
681 .map(|n| TaskMetadataInput {
682 expected_field_id: None,
683 key: format!("field-{n}"),
684 value: "x".repeat(4096),
685 })
686 .collect();
687 ids.push(db.create_task(&source, d).await.unwrap().task.id);
688 }
689 let before = db.export_data("before".into()).await.unwrap();
690 let error = db
691 .move_tasks(
692 &source,
693 MoveTasksInput {
694 task_ids: ids,
695 target_workspace: target.clone(),
696 target_project: "Target".into(),
697 },
698 )
699 .await
700 .unwrap_err();
701 assert!(error.to_string().contains("payload-too-large"));
702 let after = db.export_data("after".into()).await.unwrap();
703 assert_eq!(before.tables.changes.len(), after.tables.changes.len());
704 assert_eq!(
705 before.tables.metadata_fields.len(),
706 after.tables.metadata_fields.len()
707 );
708 assert!(
709 after
710 .tables
711 .tasks
712 .iter()
713 .all(|t| t.workspace_id == source.id)
714 );
715 }
716
717 #[tokio::test]
718 async fn removed_metadata_versions_do_not_consume_live_field_capacity() {
719 let (_temp, db, source, target) = setup().await;
720 let mut d = draft("A", "Source", vec![], false);
721 d.metadata.clear();
722 let task = db.create_task(&source, d).await.unwrap().task;
723 for n in 0..129 {
724 let key = format!("field-{n}");
725 db.update_task(
726 &source,
727 &task.id,
728 TaskUpdate {
729 set_metadata: vec![TaskMetadataInput {
730 expected_field_id: None,
731 key: key.clone(),
732 value: "value".into(),
733 }],
734 ..Default::default()
735 },
736 )
737 .await
738 .unwrap();
739 db.update_task(
740 &source,
741 &task.id,
742 TaskUpdate {
743 remove_metadata: vec![key],
744 ..Default::default()
745 },
746 )
747 .await
748 .unwrap();
749 }
750 assert!(
751 db.task_metadata(&source.id, &task.id)
752 .await
753 .unwrap()
754 .is_empty()
755 );
756 db.move_tasks(
757 &source,
758 MoveTasksInput {
759 task_ids: vec![task.id.clone()],
760 target_workspace: target.clone(),
761 target_project: "Target".into(),
762 },
763 )
764 .await
765 .unwrap();
766 let values = db.task_metadata(&target.id, &task.id).await.unwrap();
767 assert!(values.is_empty(), "values={values:?}");
768 let snapshot = db.export_data("now".into()).await.unwrap();
769 let moved = snapshot
770 .tables
771 .changes
772 .iter()
773 .find(|c| c.op_type == "move_tasks")
774 .unwrap();
775 let payload: serde_json::Value = serde_json::from_str(&moved.payload).unwrap();
776 assert_eq!(
777 payload["tasks"][0]["metadata"].as_array().unwrap().len(),
778 129
779 );
780 }
781}