Skip to main content

aven_core/operations/
attachments.rs

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