Skip to main content

coven_database/
file_database_partial_pins.rs

1//! A pin's download that stopped (§17.8): the first bytes of a file's whole
2//! encrypted copy, synced and kept apart from the cache, so the next prepare
3//! of the file continues from them. The cache neither counts nor evicts them.
4//! They go when the app unpins the file, when the pin they became publishes,
5//! or when the stored bytes they are of leave this device.
6
7use super::{cache, BytesWriting, CacheReservation, FileDatabase};
8use crate::{sqlite::DatabaseConnection, DbError, FileRef};
9use coven_foundation::files::FileName;
10
11impl FileDatabase {
12    /// The cache file a prepare of `file` writes its whole copy to, and how
13    /// many of its bytes are already kept: the partial copy an earlier
14    /// prepare left, reserved again, or else `fresh`, reserved as a new
15    /// file, with none. The bytes kept are whole chunks after the header,
16    /// synced.
17    #[expect(clippy::map_err_ignore, reason = "a stored name that won't decode is DamagedDatabase")]
18    pub async fn reserve_pin_download(
19        &self,
20        file: &FileRef,
21        fresh: FileName,
22    ) -> Result<(CacheReservation, u64), DbError> {
23        let database = &self.database;
24        let writing = BytesWriting::of(database).await;
25        let held = {
26            let file = file.clone();
27            database
28                .call(move |inner| {
29                    let found = inner
30                        .access
31                        .with_writer(|writer| writer.read_transaction(|| partial(writer, &file)))?;
32                    let Some((path, size)) = found else {
33                        return Ok(None);
34                    };
35                    let name = FileName::new(&path).map_err(|_| DbError::DamagedDatabase)?;
36                    inner.staging.reserve([crate::file_removals::OwnedFile::Cached(name.clone())]);
37                    Ok::<_, DbError>(Some((
38                        CacheReservation {
39                            reserved: inner.staging.clone(),
40                            file,
41                            name,
42                            writing: Some(writing),
43                            partial: true,
44                        },
45                        size,
46                    )))
47                })
48                .await?
49        };
50        match held {
51            Some(held) => Ok(held),
52            None => Ok((self.reserve_cache_file(file, fresh).await?, 0)),
53        }
54    }
55
56    /// Keep the first `size` bytes of the reservation's cache file, which
57    /// are synced, as the partial copy the next prepare continues from.
58    /// `NoLongerStored` when no record here names the file's stored bytes
59    /// any more, and `PinChanged` when the app unpinned the file since the
60    /// copy was reserved, which ended the partial (§17.8).
61    #[expect(
62        clippy::map_err_ignore,
63        reason = "a size past SQLite's integer is TooLarge, which names the field and size"
64    )]
65    pub async fn keep_pin_download(
66        &self,
67        reservation: &mut CacheReservation,
68        size: u64,
69    ) -> Result<(), DbError> {
70        let (file, name, kept) =
71            (reservation.file.clone(), reservation.name.clone(), reservation.partial);
72        let size = i64::try_from(size).map_err(|_| DbError::TooLarge {
73            field: "partial pin download",
74            actual: size,
75            maximum: i64::MAX as u64,
76        })?;
77        self.run(move |db, _, _, _| db.transaction(|db| record(db, &file, &name, size, kept)))
78            .await?;
79        reservation.partial = true;
80        Ok(())
81    }
82
83    /// The cache file of the partial download kept for `file`, and how many
84    /// of its bytes are kept, for a test to look at.
85    #[cfg(any(test, feature = "test-utils"))]
86    #[expect(clippy::map_err_ignore, reason = "a stored name that won't decode is DamagedDatabase")]
87    pub async fn pin_download_kept(
88        &self,
89        file: &FileRef,
90    ) -> Result<Option<(FileName, u64)>, DbError> {
91        let file = file.clone();
92        let found = self.read(move |db| partial(db, &file)).await?;
93        found
94            .map(|(path, size)| {
95                Ok((FileName::new(&path).map_err(|_| DbError::DamagedDatabase)?, size))
96            })
97            .transpose()
98    }
99
100    /// End the partial copy of `file`, whose bytes are not the file's:
101    /// they go once the writer is free.
102    pub async fn discard_pin_download(&self, file: &FileRef) -> Result<(), DbError> {
103        let file = file.clone();
104        let chosen = self
105            .run(move |db, _, _, _| {
106                let object = cache::object_path_of(&file)?;
107                let paths = remove(db, file.namespace(), &object)?;
108                Ok::<_, DbError>(crate::file_removals::choose_cache(&paths))
109            })
110            .await?;
111        self.removed(chosen).await
112    }
113}
114
115/// The partial copy kept of `file`: its cache file and how many bytes of
116/// it are kept.
117#[expect(clippy::map_err_ignore, reason = "a stored size that won't convert is DamagedDatabase")]
118fn partial(db: &DatabaseConnection, file: &FileRef) -> Result<Option<(String, u64)>, DbError> {
119    let found = db
120        .query(
121            "SELECT path,size FROM _coven_cache_partials WHERE namespace=?1 AND object_path=?2",
122            (file.namespace(), cache::object_path_of(file)?),
123            |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
124        )?
125        .pop();
126    found
127        .map(|(path, size)| Ok((path, u64::try_from(size).map_err(|_| DbError::DamagedDatabase)?)))
128        .transpose()
129}
130
131/// Record that `name` holds `size` synced bytes of `file`'s copy: its row
132/// updated when `kept`, which an unpin may have taken, and else made, naming
133/// the file's copy as a partial for the first time.
134fn record(
135    db: &DatabaseConnection,
136    file: &FileRef,
137    name: &FileName,
138    size: i64,
139    kept: bool,
140) -> Result<(), DbError> {
141    if !cache::named(db, file)? {
142        return Err(DbError::NoLongerStored { file: file.id() });
143    }
144    let object = cache::object_path_of(file)?;
145    if kept {
146        let updated = db.internal_execute(
147            "UPDATE _coven_cache_partials SET size=?4
148             WHERE namespace=?1 AND object_path=?2 AND path=?3",
149            rusqlite::params![file.namespace(), object, name.as_str(), size],
150        )?;
151        return if updated == 1 { Ok(()) } else { Err(DbError::PinChanged { file: file.id() }) };
152    }
153    // The reservation's cleanup record goes before the row names its bytes.
154    crate::file_removals::keep(db, "cache", name.as_str())?;
155    db.internal_execute(
156        "INSERT INTO _coven_cache_partials(namespace,object_path,path,size) VALUES(?1,?2,?3,?4)",
157        rusqlite::params![file.namespace(), object, name.as_str(), size],
158    )?;
159    Ok(())
160}
161
162/// Delete the partial copy of the stored bytes at `object` and record its
163/// cache file for removal; returns the name.
164pub(crate) fn remove(
165    db: &DatabaseConnection,
166    namespace: &str,
167    object: &str,
168) -> Result<Vec<String>, DbError> {
169    let paths = db.query(
170        "DELETE FROM _coven_cache_partials WHERE namespace=?1 AND object_path=?2 RETURNING path",
171        (namespace, object),
172        |r| r.get::<_, String>(0),
173    )?;
174    for path in &paths {
175        crate::file_removals::record_cached(db, path)?;
176    }
177    Ok(paths)
178}