Skip to main content

coven_database/
file_moves.rs

1//! Moves of this device's files to the cloud (§17.3): the files one write
2//! stages for the cloud, or the files the app moves. Each upload belongs to
3//! one move. No file of a move switches to the cloud until every one of its
4//! uploads is stored; then one write switches them all. A move that fails
5//! or is cancelled switches none. Each moving or failed move lists its
6//! files, for the app to see in `coven_moves`.
7
8use crate::{sqlite::DatabaseConnection, DbError};
9use coven_foundation::id_source::FileId;
10
11/// One move's identity on this device, never reused (§17.3, E3).
12#[derive(
13    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
14)]
15pub struct MoveId(pub i64);
16
17/// A move id bound and read as an SQL integer, as `coven_uploads.move`
18/// and `coven_pending.move` hold it for the app's `cancel_move` (E4).
19impl rusqlite::ToSql for MoveId {
20    fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
21        Ok(self.0.into())
22    }
23}
24
25impl rusqlite::types::FromSql for MoveId {
26    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
27        i64::column_result(value).map(Self)
28    }
29}
30
31/// Where a move stands.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub(crate) enum MoveState {
34    /// Its uploads are queued; none of its files has switched.
35    Moving,
36    /// A user's original changed before its upload: none of its files
37    /// switched or waits for an upload, and its record says which original
38    /// and its list which files, until the app cancels it.
39    Failed,
40    /// The app cancelled it: none of its files switched, and it lists none.
41    Cancelled,
42    /// Its files switched to the cloud together, and it lists none.
43    Finished,
44}
45
46impl MoveState {
47    fn text(self) -> &'static str {
48        match self {
49            Self::Moving => "moving",
50            Self::Failed => "failed",
51            Self::Cancelled => "cancelled",
52            Self::Finished => "finished",
53        }
54    }
55}
56
57/// Record a new move, moving.
58pub(crate) fn create(db: &DatabaseConnection) -> Result<MoveId, DbError> {
59    db.query_row("INSERT INTO _coven_moves(state) VALUES('moving') RETURNING id", [], |r| r.get(0))
60}
61
62/// The move's state, or `None` for a move this device never made.
63pub(crate) fn state(db: &DatabaseConnection, id: MoveId) -> Result<Option<MoveState>, DbError> {
64    db.query("SELECT state FROM _coven_moves WHERE id=?1", [id], |r| r.get::<_, String>(0))?
65        .pop()
66        .map(|state| match state.as_str() {
67            "moving" => Ok(MoveState::Moving),
68            "failed" => Ok(MoveState::Failed),
69            "cancelled" => Ok(MoveState::Cancelled),
70            "finished" => Ok(MoveState::Finished),
71            _ => Err(DbError::DamagedDatabase),
72        })
73        .transpose()
74}
75
76/// Whether the move is a bound file's (§17.4): its upload holds a write
77/// until the file is stored, so the app can't cancel it.
78pub(crate) fn bound(db: &DatabaseConnection, id: MoveId) -> Result<bool, DbError> {
79    db.query_row(
80        "SELECT EXISTS(SELECT 1 FROM _coven_file_uploads WHERE move=?1 AND holds IS NOT NULL)",
81        [id],
82        |r| r.get(0),
83    )
84}
85
86/// The move an upload belongs to.
87pub(crate) fn of_upload(db: &DatabaseConnection, upload: i64) -> Result<MoveId, DbError> {
88    db.query_row("SELECT move FROM _coven_file_uploads WHERE id=?1", [upload], |r| r.get(0))
89        .map_err(|error| match error {
90            DbError::Sqlite(rusqlite::Error::QueryReturnedNoRows) => {
91                DbError::Invariant { what: "a queued upload belongs to a move" }
92            }
93            error => error,
94        })
95}
96
97/// The move that lists `file`, moving or failed, if any: a file is in at
98/// most one move (§17.3).
99pub(crate) fn of_file(db: &DatabaseConnection, file: FileId) -> Result<Option<MoveId>, DbError> {
100    Ok(db.query("SELECT move FROM _coven_move_files WHERE file=?1", [file], |r| r.get(0))?.pop())
101}
102
103/// List each file as one of its move's, as the uploads of the moves come to
104/// store them (§17.3), as one statement for each chunk.
105pub(crate) fn list_all(db: &DatabaseConnection, files: &[(MoveId, FileId)]) -> Result<(), DbError> {
106    let rows: Vec<_> = files
107        .iter()
108        .map(|(id, file)| {
109            Ok(vec![crate::insert_rows::value(id)?, crate::insert_rows::value(file)?])
110        })
111        .collect::<Result<_, DbError>>()?;
112    crate::insert_rows::insert_rows(
113        db,
114        2,
115        &rows,
116        |values| format!("INSERT INTO _coven_move_files(move,file) VALUES{values}"),
117        |_| Ok(()),
118    )?;
119    Ok(())
120}
121
122/// The new file `to` over `from`'s bytes, now waiting for the upload `from`
123/// waits for, joins that upload's move (§17.2, §17.3).
124pub(crate) fn join(db: &DatabaseConnection, from: FileId, to: FileId) -> Result<(), DbError> {
125    db.internal_execute(
126        "INSERT INTO _coven_move_files(move,file)
127         SELECT u.move,?2 FROM _coven_upload_files AS l
128         JOIN _coven_file_uploads AS u ON u.id=l.upload WHERE l.file=?1",
129        rusqlite::params![from, to],
130    )?;
131    Ok(())
132}
133
134/// `file`'s record is gone from this device: no move lists it.
135pub(crate) fn forget(db: &DatabaseConnection, file: FileId) -> Result<(), DbError> {
136    db.internal_execute("DELETE FROM _coven_move_files WHERE file=?1", [file])?;
137    Ok(())
138}
139
140/// Move `id` now stands at `state`. A switch or a cancel ends its list with
141/// it; a failure keeps the list, for the app to cancel (§17.3).
142fn set(db: &DatabaseConnection, id: MoveId, state: MoveState) -> Result<(), DbError> {
143    match state {
144        MoveState::Cancelled | MoveState::Finished => {
145            db.internal_execute("DELETE FROM _coven_move_files WHERE move=?1", [id])?;
146        }
147        MoveState::Moving | MoveState::Failed => {}
148    }
149    db.internal_execute(
150        "UPDATE _coven_moves SET state=?2 WHERE id=?1",
151        rusqlite::params![id, state.text()],
152    )?;
153    Ok(())
154}
155
156/// End a move switching none of its files: every file stays on this device
157/// only, keeping its bytes here, and no upload of the move stores a file any
158/// more, so the upload worker retires them, an attempt in flight once it
159/// ends, and their stored bytes become deletion candidates (§17.10). A
160/// failed move keeps its list; a cancelled one ends it. The move's pending
161/// record is replaced or cleared by the caller.
162pub(crate) fn end_unswitched(
163    db: &DatabaseConnection,
164    id: MoveId,
165    state: MoveState,
166) -> Result<(), DbError> {
167    crate::file_queue::unswitch_move(db, id)?;
168    set(db, id, state)
169}
170
171/// The moving moves none of whose uploads that still store a file waits to
172/// be stored, each with those uploads: what one write switches now. A move
173/// whose files all dropped out has none.
174pub(crate) fn complete(db: &DatabaseConnection) -> Result<Vec<(MoveId, Vec<i64>)>, DbError> {
175    let moves = db.query(
176        "SELECT m.id FROM _coven_moves AS m WHERE m.state='moving'
177         AND NOT EXISTS(SELECT 1 FROM _coven_file_uploads AS u
178             WHERE u.move=m.id AND u.stored=0
179             AND EXISTS(SELECT 1 FROM _coven_upload_files AS l WHERE l.upload=u.id))",
180        [],
181        |r| r.get(0),
182    )?;
183    moves
184        .into_iter()
185        .map(|id| {
186            let uploads = db.query(
187                "SELECT u.id FROM _coven_file_uploads AS u WHERE u.move=?1
188                 AND EXISTS(SELECT 1 FROM _coven_upload_files AS l WHERE l.upload=u.id)
189                 ORDER BY u.id",
190                [id],
191                |r| r.get(0),
192            )?;
193            Ok((id, uploads))
194        })
195        .collect()
196}
197
198/// A move ended with its files switched: whatever its record said ends.
199pub(crate) fn finish(db: &DatabaseConnection, id: MoveId) -> Result<(), DbError> {
200    set(db, id, MoveState::Finished)?;
201    crate::pending::clear_move(db, id)
202}
203
204#[cfg(test)]
205#[path = "file_moves_tests.rs"]
206pub(crate) mod tests;