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