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}