Skip to main content

aven_core/operations/
task_moves.rs

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}