Skip to main content

aven_core/operations/
attachments.rs

1use std::path::Path;
2
3use anyhow::{Result, bail};
4use sqlx::SqliteConnection;
5use sqlx::{AssertSqlSafe, Row};
6
7use crate::attachments::AttachmentBytesState;
8use crate::attachments::decode::{ImageFacts, ValidatedImage, validate_image};
9use crate::attachments::optimization::{ImageOptimizationPolicy, optimize_image_bytes};
10use crate::attachments::storage::{blob_inventory_row, sha256_hex, store_validated_blob};
11use crate::attachments::validation::{
12    validate_alt_text, validate_attachment_id, validate_filename,
13};
14use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
15use crate::db::{Database, begin_immediate};
16use crate::ids::{TaskId, WorkspaceId, new_id, now};
17use crate::types::TaskAttachment;
18use crate::workspaces::Workspace;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct AttachmentAddInput {
22    pub filename: Option<String>,
23    pub alt_text: Option<String>,
24    pub declared_media_type: Option<String>,
25    pub bytes: Vec<u8>,
26    pub optimization_policy: ImageOptimizationPolicy,
27    pub dedupe_existing: bool,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct TaskAttachmentAddInput {
32    pub attachment_id: String,
33    pub input: AttachmentAddInput,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct PreparedAttachment {
38    pub attachment_id: String,
39    pub filename: Option<String>,
40    pub alt_text: Option<String>,
41    pub sha256: String,
42    pub byte_size: i64,
43    pub facts: ImageFacts,
44    pub bytes: Vec<u8>,
45    pub optimized: bool,
46}
47
48pub struct AttachmentAddOutcome {
49    pub outcome: AttachmentOutcome,
50    pub created: bool,
51    pub optimized: bool,
52}
53
54pub struct AttachmentOutcome {
55    pub attachment: TaskAttachment,
56    pub has_blob: bool,
57}
58
59pub struct AttachmentReadLease {
60    pub sha256: String,
61    pub media_type: String,
62    pub lease_id: String,
63}
64
65#[derive(Debug, Clone)]
66pub struct AttachmentReadItem {
67    pub attachment: TaskAttachment,
68    pub bytes_state: AttachmentBytesState,
69    pub has_blob: bool,
70}
71
72const ATTACHMENT_COLUMNS: &str =
73    "ta.workspace_id, ta.attachment_id, ta.task_id, ta.sha256, ta.byte_size,
74    ta.media_type, ta.filename, ta.alt_text, ta.width, ta.height,
75    ta.created_at, ta.created_by_change_id, ta.deleted, ta.deleted_at, ta.deleted_by_change_id";
76
77fn attachment_from_row(row: &sqlx::sqlite::SqliteRow) -> TaskAttachment {
78    TaskAttachment {
79        workspace_id: row.get("workspace_id"),
80        attachment_id: row.get("attachment_id"),
81        task_id: row.get("task_id"),
82        sha256: row.get("sha256"),
83        byte_size: row.get("byte_size"),
84        media_type: row.get("media_type"),
85        filename: row.get("filename"),
86        alt_text: row.get("alt_text"),
87        width: row.get("width"),
88        height: row.get("height"),
89        created_at: row.get("created_at"),
90        created_by_change_id: row.get("created_by_change_id"),
91        deleted: row.get::<i64, _>("deleted") != 0,
92        deleted_at: row.get("deleted_at"),
93        deleted_by_change_id: row.get("deleted_by_change_id"),
94    }
95}
96
97async fn require_attachment(
98    conn: &mut SqliteConnection,
99    workspace_id: &WorkspaceId,
100    attachment_id: &str,
101) -> Result<TaskAttachment> {
102    let row = sqlx::query(AssertSqlSafe(format!(
103        "SELECT {ATTACHMENT_COLUMNS}
104         FROM task_attachments ta
105         WHERE ta.workspace_id = ? AND ta.attachment_id = ?"
106    )))
107    .bind(workspace_id)
108    .bind(attachment_id)
109    .fetch_optional(&mut *conn)
110    .await?;
111
112    let Some(row) = row else {
113        bail!("error attachment-not-found id={}", attachment_id);
114    };
115    Ok(attachment_from_row(&row))
116}
117
118async fn attachment_bytes_state(
119    conn: &mut SqliteConnection,
120    sha256: &str,
121) -> Result<AttachmentBytesState> {
122    Ok(match blob_inventory_row(conn, sha256).await? {
123        Some(row) if row.available => AttachmentBytesState::Present,
124        Some(_) => AttachmentBytesState::Unavailable,
125        None => AttachmentBytesState::PendingDownload,
126    })
127}
128
129async fn attachment_has_blob(conn: &mut SqliteConnection, sha256: &str) -> Result<bool> {
130    Ok(attachment_bytes_state(conn, sha256).await? == AttachmentBytesState::Present)
131}
132
133async fn existing_live_attachments_by_sha(
134    conn: &mut SqliteConnection,
135    workspace_id: &crate::ids::WorkspaceId,
136    task_id: &TaskId,
137    sha256: &str,
138) -> Result<Vec<TaskAttachment>> {
139    let rows = sqlx::query(AssertSqlSafe(format!(
140        "SELECT {ATTACHMENT_COLUMNS}
141         FROM task_attachments ta
142         WHERE ta.workspace_id = ? AND ta.task_id = ? AND ta.sha256 = ? AND ta.deleted = 0
143         ORDER BY ta.created_at, ta.attachment_id"
144    )))
145    .bind(workspace_id)
146    .bind(task_id)
147    .bind(sha256)
148    .fetch_all(&mut *conn)
149    .await?;
150
151    Ok(rows.iter().map(attachment_from_row).collect())
152}
153
154pub async fn prepare_task_attachment(input: TaskAttachmentAddInput) -> Result<PreparedAttachment> {
155    validate_attachment_id(&input.attachment_id)?;
156    validate_filename(input.input.filename.as_deref())?;
157    validate_alt_text(input.input.alt_text.as_deref())?;
158
159    let source = validate_image(input.input.bytes, input.input.declared_media_type).await?;
160    let source_facts = source.facts.clone();
161    let optimized = optimize_image_bytes(
162        &source_facts.media_type,
163        source.bytes,
164        input.input.optimization_policy,
165    )
166    .await?;
167    let optimized_flag = optimized.optimized;
168    let stored_image = if optimized_flag {
169        validate_image(optimized.bytes, None).await?
170    } else {
171        ValidatedImage {
172            bytes: optimized.bytes,
173            facts: source_facts,
174        }
175    };
176    let sha256 = sha256_hex(&stored_image.bytes);
177    let byte_size = i64::try_from(stored_image.bytes.len())?;
178    Ok(PreparedAttachment {
179        attachment_id: input.attachment_id,
180        filename: input.input.filename,
181        alt_text: input.input.alt_text,
182        sha256,
183        byte_size,
184        facts: stored_image.facts,
185        bytes: stored_image.bytes,
186        optimized: optimized_flag,
187    })
188}
189
190struct AttachmentRecordDraft<'a> {
191    attachment_id: &'a str,
192    sha256: &'a str,
193    byte_size: i64,
194    media_type: &'a str,
195    filename: Option<&'a str>,
196    alt_text: Option<&'a str>,
197    width: i64,
198    height: i64,
199    created_at: &'a str,
200}
201
202async fn record_attachment_add(
203    conn: &mut SqliteConnection,
204    workspace: &Workspace,
205    task_id: &TaskId,
206    draft: AttachmentRecordDraft<'_>,
207) -> Result<String> {
208    let change_id = append_change(
209        conn,
210        ChangeEntity::Task,
211        task_id,
212        Some("attachments"),
213        op_type::ATTACHMENT_ADD,
214        ChangePayload::workspace(workspace)
215            .set("attachment_id", draft.attachment_id)
216            .set("sha256", draft.sha256)
217            .set("byte_size", draft.byte_size)
218            .set("media_type", draft.media_type)
219            .set("filename", draft.filename)
220            .set("alt_text", draft.alt_text)
221            .set("width", draft.width)
222            .set("height", draft.height)
223            .set("created_at", draft.created_at),
224    )
225    .await?;
226    sqlx::query(
227        "INSERT INTO task_attachments(workspace_id, attachment_id, task_id, sha256, byte_size, media_type, filename, alt_text, width, height, created_at, created_by_change_id)
228         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
229    )
230    .bind(&workspace.id)
231    .bind(draft.attachment_id)
232    .bind(task_id)
233    .bind(draft.sha256)
234    .bind(draft.byte_size)
235    .bind(draft.media_type)
236    .bind(draft.filename)
237    .bind(draft.alt_text)
238    .bind(draft.width)
239    .bind(draft.height)
240    .bind(draft.created_at)
241    .bind(&change_id)
242    .execute(&mut *conn)
243    .await?;
244    Ok(change_id)
245}
246
247pub(super) async fn insert_prepared_attachment(
248    conn: &mut SqliteConnection,
249    workspace: &Workspace,
250    task_id: &TaskId,
251    prepared: &PreparedAttachment,
252    created_at: &str,
253) -> Result<String> {
254    record_attachment_add(
255        conn,
256        workspace,
257        task_id,
258        AttachmentRecordDraft {
259            attachment_id: &prepared.attachment_id,
260            sha256: &prepared.sha256,
261            byte_size: prepared.byte_size,
262            media_type: &prepared.facts.media_type,
263            filename: prepared.filename.as_deref(),
264            alt_text: prepared.alt_text.as_deref(),
265            width: prepared.facts.width,
266            height: prepared.facts.height,
267            created_at,
268        },
269    )
270    .await
271}
272
273struct AttachmentCommitIdentity {
274    attachment_id: Option<String>,
275    created_at: Option<String>,
276}
277
278pub async fn add_task_attachment(
279    conn: &mut SqliteConnection,
280    workspace: &Workspace,
281    blob_dir: &Path,
282    policy: crate::attachments::lifecycle::LifecyclePolicy,
283    task_id: &TaskId,
284    input: AttachmentAddInput,
285) -> Result<AttachmentAddOutcome> {
286    add_task_attachment_inner(
287        conn,
288        workspace,
289        blob_dir,
290        policy,
291        task_id,
292        AttachmentCommitIdentity {
293            attachment_id: None,
294            created_at: None,
295        },
296        input,
297    )
298    .await
299}
300
301pub(crate) async fn add_ordered_task_attachment(
302    conn: &mut SqliteConnection,
303    workspace: &Workspace,
304    blob_dir: &Path,
305    policy: crate::attachments::lifecycle::LifecyclePolicy,
306    task_id: &TaskId,
307    created_at: String,
308    input: TaskAttachmentAddInput,
309) -> Result<AttachmentAddOutcome> {
310    add_task_attachment_inner(
311        conn,
312        workspace,
313        blob_dir,
314        policy,
315        task_id,
316        AttachmentCommitIdentity {
317            attachment_id: Some(input.attachment_id),
318            created_at: Some(created_at),
319        },
320        input.input,
321    )
322    .await
323}
324
325async fn add_task_attachment_inner(
326    conn: &mut SqliteConnection,
327    workspace: &Workspace,
328    blob_dir: &Path,
329    policy: crate::attachments::lifecycle::LifecyclePolicy,
330    task_id: &TaskId,
331    identity: AttachmentCommitIdentity,
332    input: AttachmentAddInput,
333) -> Result<AttachmentAddOutcome> {
334    validate_filename(input.filename.as_deref())?;
335    validate_alt_text(input.alt_text.as_deref())?;
336
337    let source = validate_image(input.bytes, input.declared_media_type).await?;
338    let source_facts = source.facts.clone();
339    let optimized = optimize_image_bytes(
340        &source_facts.media_type,
341        source.bytes,
342        input.optimization_policy,
343    )
344    .await?;
345    let optimized_flag = optimized.optimized;
346    let stored_image = if optimized_flag {
347        validate_image(optimized.bytes, Some(source_facts.media_type.clone())).await?
348    } else {
349        ValidatedImage {
350            bytes: optimized.bytes,
351            facts: source_facts,
352        }
353    };
354    let sha256 = sha256_hex(&stored_image.bytes);
355    let byte_size = i64::try_from(stored_image.bytes.len())?;
356    let capacity_reservation = crate::attachments::lifecycle::ensure_local_capacity(
357        conn,
358        blob_dir,
359        &sha256,
360        byte_size,
361        policy,
362        &crate::attachments::lifecycle::SystemClock,
363    )
364    .await?;
365    let staging_lease = crate::attachments::lifecycle::acquire_lease(
366        conn,
367        &sha256,
368        "staging",
369        &crate::attachments::lifecycle::SystemClock,
370    )
371    .await?;
372    let stored = match store_validated_blob(conn, blob_dir, stored_image).await {
373        Ok(stored) => stored,
374        Err(error) => {
375            let _ = crate::attachments::lifecycle::release_lease(conn, &staging_lease).await;
376            if let Some(reservation_id) = capacity_reservation.as_deref() {
377                let _ =
378                    crate::attachments::lifecycle::release_reservation(conn, reservation_id).await;
379            }
380            return Err(error);
381        }
382    };
383    if let Some(reservation_id) = capacity_reservation {
384        crate::attachments::lifecycle::release_reservation(conn, &reservation_id).await?;
385    }
386    let database_result = async {
387        let mut tx = begin_immediate(conn).await?;
388        crate::operations::route_recurrence_task_field(
389            &mut tx,
390            workspace,
391            task_id,
392            "attachments",
393            "",
394        )
395        .await?;
396        let task_exists = sqlx::query_scalar::<_, bool>(
397            "SELECT EXISTS(SELECT 1 FROM tasks WHERE workspace_id = ? AND id = ?)",
398        )
399        .bind(&workspace.id)
400        .bind(task_id)
401        .fetch_one(&mut *tx)
402        .await?;
403        if !task_exists {
404            bail!("error task-not-found task_id={task_id}");
405        }
406
407        if input.dedupe_existing
408            && let Some(existing) =
409                existing_live_attachments_by_sha(&mut tx, &workspace.id, task_id, &stored.sha256)
410                    .await?
411                    .first()
412        {
413            let attachment_id = existing.attachment_id.clone();
414            crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
415                &mut tx,
416                std::slice::from_ref(&stored.sha256),
417                &crate::attachments::lifecycle::SystemClock,
418            )
419            .await?;
420            tx.commit().await?;
421            return Ok::<_, anyhow::Error>((attachment_id, false));
422        }
423
424        let attachment_id = identity.attachment_id.unwrap_or_else(new_id);
425        let created_at = identity.created_at.unwrap_or_else(now);
426        record_attachment_add(
427            &mut tx,
428            workspace,
429            task_id,
430            AttachmentRecordDraft {
431                attachment_id: &attachment_id,
432                sha256: &stored.sha256,
433                byte_size: stored.byte_size,
434                media_type: &stored.facts.media_type,
435                filename: input.filename.as_deref(),
436                alt_text: input.alt_text.as_deref(),
437                width: stored.facts.width,
438                height: stored.facts.height,
439                created_at: &created_at,
440            },
441        )
442        .await?;
443        crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
444            &mut tx,
445            std::slice::from_ref(&stored.sha256),
446            &crate::attachments::lifecycle::SystemClock,
447        )
448        .await?;
449        tx.commit().await?;
450        Ok((attachment_id, true))
451    }
452    .await;
453    let release_result = crate::attachments::lifecycle::release_lease(conn, &staging_lease).await;
454    let (attachment_id, created) = match database_result {
455        Ok(result) => result,
456        Err(error) => {
457            crate::attachments::storage::remove_staged_blob_if_unreferenced(
458                conn,
459                blob_dir,
460                &stored.sha256,
461            )
462            .await;
463            return Err(error);
464        }
465    };
466    release_result?;
467    crate::attachments::lifecycle::reconcile_liveness(
468        conn,
469        &crate::attachments::lifecycle::SystemClock,
470    )
471    .await?;
472    let outcome = attachment_by_id(conn, workspace, &attachment_id).await?;
473    Ok(AttachmentAddOutcome {
474        outcome,
475        created,
476        optimized: optimized_flag,
477    })
478}
479
480pub async fn attachment_by_id(
481    conn: &mut SqliteConnection,
482    workspace: &Workspace,
483    attachment_id: &str,
484) -> Result<AttachmentOutcome> {
485    let attachment = require_attachment(conn, &workspace.id, attachment_id).await?;
486    let has_blob = attachment_has_blob(conn, &attachment.sha256).await?;
487    Ok(AttachmentOutcome {
488        attachment,
489        has_blob,
490    })
491}
492
493pub async fn delete_task_attachment(
494    conn: &mut SqliteConnection,
495    workspace: &Workspace,
496    attachment_id: &str,
497) -> Result<AttachmentOutcome> {
498    let attachment = require_attachment(conn, &workspace.id, attachment_id).await?;
499    if attachment.deleted {
500        let has_blob = attachment_has_blob(conn, &attachment.sha256).await?;
501        return Ok(AttachmentOutcome {
502            attachment,
503            has_blob,
504        });
505    }
506
507    let task_id = attachment.task_id.clone();
508    let deleted_at = now();
509
510    let mut tx = begin_immediate(conn).await?;
511    crate::operations::route_recurrence_task_field(&mut tx, workspace, &task_id, "attachments", "")
512        .await?;
513
514    let change_id = append_change(
515        &mut tx,
516        ChangeEntity::Task,
517        &task_id,
518        Some("attachments"),
519        op_type::ATTACHMENT_DELETE,
520        ChangePayload::workspace(workspace)
521            .set("attachment_id", attachment_id)
522            .set("filename", attachment.filename.as_deref())
523            .set("media_type", &attachment.media_type)
524            .set("deleted_at", &deleted_at),
525    )
526    .await?;
527
528    sqlx::query(
529        "UPDATE task_attachments SET deleted = 1, deleted_at = ?, deleted_by_change_id = ? WHERE workspace_id = ? AND attachment_id = ?",
530    )
531    .bind(&deleted_at)
532    .bind(&change_id)
533    .bind(&workspace.id)
534    .bind(attachment_id)
535    .execute(&mut *tx)
536    .await?;
537
538    crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
539        &mut tx,
540        std::slice::from_ref(&attachment.sha256),
541        &crate::attachments::lifecycle::SystemClock,
542    )
543    .await?;
544    tx.commit().await?;
545
546    attachment_by_id(conn, workspace, attachment_id).await
547}
548
549pub async fn attachment_read_items_by_task(
550    conn: &mut SqliteConnection,
551    workspace_id: &str,
552    task_id: &str,
553    include_deleted: bool,
554) -> Result<Vec<AttachmentReadItem>> {
555    let attachments = attachments_by_task(conn, workspace_id, task_id, include_deleted).await?;
556    let mut items = Vec::with_capacity(attachments.len());
557    for attachment in attachments {
558        let bytes_state = attachment_bytes_state(conn, &attachment.sha256).await?;
559        items.push(AttachmentReadItem {
560            attachment,
561            has_blob: bytes_state == AttachmentBytesState::Present,
562            bytes_state,
563        });
564    }
565    Ok(items)
566}
567
568pub async fn attachments_by_task(
569    conn: &mut SqliteConnection,
570    workspace_id: &str,
571    task_id: &str,
572    include_deleted: bool,
573) -> Result<Vec<TaskAttachment>> {
574    let deleted_predicate = if include_deleted {
575        ""
576    } else {
577        " AND ta.deleted = 0"
578    };
579    let rows = sqlx::query(AssertSqlSafe(format!(
580        "SELECT {ATTACHMENT_COLUMNS}
581         FROM task_attachments ta
582         WHERE ta.workspace_id = ? AND ta.task_id = ?{deleted_predicate}
583         ORDER BY ta.created_at, ta.attachment_id"
584    )))
585    .bind(workspace_id)
586    .bind(task_id)
587    .fetch_all(&mut *conn)
588    .await?;
589
590    let mut attachments = Vec::with_capacity(rows.len());
591    for row in &rows {
592        attachments.push(attachment_from_row(row));
593    }
594    Ok(attachments)
595}
596
597impl Database {
598    pub async fn add_task_attachment(
599        &self,
600        workspace: &Workspace,
601        blob_dir: &Path,
602        policy: crate::attachments::lifecycle::LifecyclePolicy,
603        task_id: &TaskId,
604        input: AttachmentAddInput,
605    ) -> Result<AttachmentAddOutcome> {
606        let mut conn = self.acquire_writer().await?;
607        add_task_attachment(&mut conn, workspace, blob_dir, policy, task_id, input).await
608    }
609
610    pub async fn add_ordered_task_attachment(
611        &self,
612        workspace: &Workspace,
613        blob_dir: &Path,
614        policy: crate::attachments::lifecycle::LifecyclePolicy,
615        task_id: &TaskId,
616        created_at: String,
617        input: TaskAttachmentAddInput,
618    ) -> Result<AttachmentAddOutcome> {
619        let mut conn = self.acquire_writer().await?;
620        add_ordered_task_attachment(
621            &mut conn, workspace, blob_dir, policy, task_id, created_at, input,
622        )
623        .await
624    }
625
626    pub async fn attachment_by_id(
627        &self,
628        workspace: &Workspace,
629        attachment_id: &str,
630    ) -> Result<AttachmentOutcome> {
631        let mut conn = self.acquire_reader().await?;
632        attachment_by_id(&mut conn, workspace, attachment_id).await
633    }
634
635    pub async fn delete_task_attachment(
636        &self,
637        workspace: &Workspace,
638        attachment_id: &str,
639    ) -> Result<AttachmentOutcome> {
640        let mut conn = self.acquire_writer().await?;
641        delete_task_attachment(&mut conn, workspace, attachment_id).await
642    }
643
644    pub async fn attachment_read_items_by_task(
645        &self,
646        workspace_id: &WorkspaceId,
647        task_id: &TaskId,
648        include_deleted: bool,
649    ) -> Result<Vec<AttachmentReadItem>> {
650        let mut conn = self.acquire_reader().await?;
651        attachment_read_items_by_task(
652            &mut conn,
653            workspace_id.as_str(),
654            task_id.as_str(),
655            include_deleted,
656        )
657        .await
658    }
659
660    pub async fn prune_attachments(
661        &self,
662        blob_dir: &Path,
663        policy: crate::attachments::lifecycle::LifecyclePolicy,
664        apply: bool,
665    ) -> Result<crate::attachments::lifecycle::PruneSummary> {
666        let mut conn = self.acquire_writer().await?;
667        crate::attachments::lifecycle::prune(
668            &mut conn,
669            blob_dir,
670            policy,
671            apply,
672            &crate::attachments::lifecycle::SystemClock,
673        )
674        .await
675    }
676
677    pub async fn acquire_attachment_lease(&self, sha256: &str, purpose: &str) -> Result<String> {
678        let mut conn = self.acquire_writer().await?;
679        crate::attachments::lifecycle::acquire_lease(
680            &mut conn,
681            sha256,
682            purpose,
683            &crate::attachments::lifecycle::SystemClock,
684        )
685        .await
686    }
687
688    pub async fn acquire_live_attachment_read_lease(
689        &self,
690        workspace: &Workspace,
691        attachment_id: &str,
692    ) -> Result<AttachmentReadLease> {
693        let mut conn = self.acquire_writer().await?;
694        let row = sqlx::query(
695            "SELECT ta.sha256, ta.media_type, bi.available
696             FROM task_attachments ta
697             JOIN tasks t ON t.workspace_id = ta.workspace_id AND t.id = ta.task_id
698             LEFT JOIN blob_inventory bi ON bi.sha256 = ta.sha256
699             WHERE ta.workspace_id = ? AND ta.attachment_id = ?
700               AND ta.deleted = 0 AND t.deleted = 0",
701        )
702        .bind(&workspace.id)
703        .bind(attachment_id)
704        .fetch_optional(&mut *conn)
705        .await?
706        .ok_or_else(|| anyhow::anyhow!("error attachment-invalidated"))?;
707        if !row.try_get::<bool, _>("available").unwrap_or(false) {
708            bail!("error attachment-blob-unavailable");
709        }
710        let sha256: String = row.get("sha256");
711        let media_type: String = row.get("media_type");
712        let lease_id = crate::attachments::lifecycle::acquire_lease(
713            &mut conn,
714            &sha256,
715            "read",
716            &crate::attachments::lifecycle::SystemClock,
717        )
718        .await?;
719        Ok(AttachmentReadLease {
720            sha256,
721            media_type,
722            lease_id,
723        })
724    }
725
726    pub async fn release_attachment_lease(&self, lease_id: &str) -> Result<()> {
727        let mut conn = self.acquire_writer().await?;
728        crate::attachments::lifecycle::release_lease(&mut conn, lease_id).await
729    }
730
731    pub async fn attachment_lifecycle_report(
732        &self,
733        blob_dir: &Path,
734        policy: crate::attachments::lifecycle::LifecyclePolicy,
735    ) -> Result<crate::attachments::lifecycle::LifecycleReport> {
736        let mut conn = self.acquire_writer().await?;
737        crate::attachments::lifecycle::lifecycle_report(
738            &mut conn,
739            blob_dir,
740            policy,
741            &crate::attachments::lifecycle::SystemClock,
742        )
743        .await
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use std::time::Duration;
750
751    use super::*;
752
753    #[tokio::test]
754    async fn lifecycle_report_waits_for_guarded_writer() {
755        let temp = tempfile::tempdir().unwrap();
756        let database = Database::open(&temp.path().join("lifecycle.sqlite"))
757            .await
758            .unwrap();
759        let writer = database.acquire_writer().await.unwrap();
760
761        let reporting_database = database.clone();
762        let blob_dir = temp.path().join("blobs");
763        let mut report = tokio::spawn(async move {
764            reporting_database
765                .attachment_lifecycle_report(
766                    &blob_dir,
767                    crate::attachments::lifecycle::LifecyclePolicy::default(),
768                )
769                .await
770        });
771
772        assert!(
773            tokio::time::timeout(Duration::from_millis(100), &mut report)
774                .await
775                .is_err(),
776            "lifecycle reporting should wait for the guarded writer"
777        );
778
779        drop(writer);
780        tokio::time::timeout(Duration::from_secs(1), report)
781            .await
782            .expect("lifecycle reporting should proceed after the writer is released")
783            .unwrap()
784            .unwrap();
785    }
786}