1use std::path::Path;
2
3use rusqlite::{Connection, OptionalExtension};
4
5use super::{with_coven_sql_authority, DbError};
6use coven_protocol::blob::{RowBlobAuthority, RowBlobRef};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ExternalBlob {
14 pub path: std::path::PathBuf,
16 pub size: u64,
19}
20
21pub(crate) struct ExternalBlobRecords<'connection> {
22 connection: &'connection Connection,
23}
24
25impl<'connection> ExternalBlobRecords<'connection> {
26 pub(crate) fn new(connection: &'connection Connection) -> Self {
27 Self { connection }
28 }
29
30 pub(crate) fn register(&self, reference: &RowBlobRef, path: &Path) -> Result<(), DbError> {
31 if reference.authority() != &RowBlobAuthority::Local || reference.stored().is_some() {
32 return Err(DbError::Message(
33 "external file requires an exact Local row blob reference".to_string(),
34 ));
35 }
36 let path = path.to_str().ok_or_else(|| {
37 DbError::Message(format!("external blob path is not UTF-8: {path:?}"))
38 })?;
39 let size = i64::try_from(reference.plaintext_size()).map_err(|_| {
40 DbError::Message("external blob plaintext size exceeds SQLite INTEGER".to_string())
41 })?;
42 with_coven_sql_authority(|| {
43 self.connection
44 .execute(
45 "INSERT INTO local_blob_refs
46 (table_name, row_id, column_name, row_stamp, namespace, blob_id,
47 path, plaintext_size, plaintext_hash)
48 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
49 ON CONFLICT(table_name, row_id, column_name, row_stamp) DO UPDATE SET
50 namespace = excluded.namespace,
51 blob_id = excluded.blob_id,
52 path = excluded.path,
53 plaintext_size = excluded.plaintext_size,
54 plaintext_hash = excluded.plaintext_hash",
55 rusqlite::params![
56 reference.table(),
57 reference.row_id(),
58 reference.column(),
59 reference.row_stamp(),
60 &reference.blob().namespace,
61 &reference.blob().id,
62 path,
63 size,
64 reference.plaintext_hash().to_string(),
65 ],
66 )
67 .map(|_| ())
68 .map_err(DbError::from)
69 })
70 }
71
72 pub(crate) fn clear(&self, reference: &RowBlobRef) -> Result<(), DbError> {
73 with_coven_sql_authority(|| {
74 self.connection
75 .execute(
76 "DELETE FROM local_blob_refs WHERE table_name = ?1 AND row_id = ?2
77 AND column_name = ?3 AND row_stamp = ?4",
78 rusqlite::params![
79 reference.table(),
80 reference.row_id(),
81 reference.column(),
82 reference.row_stamp(),
83 ],
84 )
85 .map(|_| ())
86 .map_err(DbError::from)
87 })
88 }
89
90 pub(crate) fn load(&self, reference: &RowBlobRef) -> Result<Option<ExternalBlob>, DbError> {
91 let row = self
92 .connection
93 .query_row(
94 "SELECT path, plaintext_size, plaintext_hash, namespace, blob_id
95 FROM local_blob_refs
96 WHERE table_name = ?1 AND row_id = ?2 AND column_name = ?3
97 AND row_stamp = ?4",
98 rusqlite::params![
99 reference.table(),
100 reference.row_id(),
101 reference.column(),
102 reference.row_stamp()
103 ],
104 |row| {
105 Ok((
106 row.get::<_, String>(0)?,
107 row.get::<_, i64>(1)?,
108 row.get::<_, String>(2)?,
109 row.get::<_, String>(3)?,
110 row.get::<_, String>(4)?,
111 ))
112 },
113 )
114 .optional()
115 .map_err(DbError::from)?;
116 let Some((path, size, hash, stored_namespace, stored_blob_id)) = row else {
117 return Ok(None);
118 };
119 let size = u64::try_from(size).map_err(|_| {
120 DbError::Message(format!(
121 "external blob {} has negative size",
122 reference.blob().id
123 ))
124 })?;
125 let hash: coven_protocol::store_commit::ObjectHash = hash.parse().map_err(|error| {
126 DbError::context(format!("external blob {} hash", reference.blob().id), error)
127 })?;
128 if size != reference.plaintext_size()
129 || hash != reference.plaintext_hash()
130 || stored_namespace != reference.blob().namespace
131 || stored_blob_id != reference.blob().id
132 {
133 return Err(DbError::Message(format!(
134 "external blob row {}/{}/{} differs from its row reference",
135 reference.table(),
136 reference.row_id(),
137 reference.column()
138 )));
139 }
140 Ok(Some(ExternalBlob {
141 path: std::path::PathBuf::from(path),
142 size,
143 }))
144 }
145}