about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rwxr-xr-xscripts/mkdb.sh5
-rw-r--r--yt/src/app.rs18
-rw-r--r--yt/src/storage/migrate/mod.rs178
-rw-r--r--yt/src/storage/migrate/sql/00_empty_to_zero.sql (renamed from yt/src/storage/video_database/schema.sql)20
-rw-r--r--yt/src/storage/mod.rs1
5 files changed, 210 insertions, 12 deletions
diff --git a/scripts/mkdb.sh b/scripts/mkdb.sh
index afd94b3..6bcebaf 100755
--- a/scripts/mkdb.sh
+++ b/scripts/mkdb.sh
@@ -16,6 +16,9 @@ db="$root/target/database.sqlx"
 [ -f "$db" ] && rm "$db"
 [ -d "$root/target" ] || mkdir "$root/target"
 
-sqlite3 "$db" <"$root/yt/src/storage/video_database/schema.sql"
+fd . "$root/yt/src/storage/migrate/sql" | while read -r sql_file; do
+    echo "Applying sql migration file: $(basename "$sql_file").."
+    sqlite3 "$db" <"$sql_file"
+done
 
 # vim: ft=sh
diff --git a/yt/src/app.rs b/yt/src/app.rs
index b9338af..937e58f 100644
--- a/yt/src/app.rs
+++ b/yt/src/app.rs
@@ -9,9 +9,9 @@
 // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
 
 use anyhow::{Context, Result};
-use sqlx::{SqlitePool, query, sqlite::SqliteConnectOptions};
+use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
 
-use crate::config::Config;
+use crate::{config::Config, storage::migrate::migrate_db};
 
 #[derive(Debug)]
 pub struct App {
@@ -30,13 +30,15 @@ impl App {
             .await
             .context("Failed to connect to database!")?;
 
-        query(include_str!("storage/video_database/schema.sql"))
-            .execute(&pool)
-            .await?;
-
-        Ok(App {
+        let app = App {
             database: pool,
             config,
-        })
+        };
+
+        migrate_db(&app)
+            .await
+            .context("Failed to migrate db to new version")?;
+
+        Ok(app)
     }
 }
diff --git a/yt/src/storage/migrate/mod.rs b/yt/src/storage/migrate/mod.rs
new file mode 100644
index 0000000..4e956de
--- /dev/null
+++ b/yt/src/storage/migrate/mod.rs
@@ -0,0 +1,178 @@
+use std::{
+    fmt::Display,
+    time::{SystemTime, UNIX_EPOCH},
+};
+
+use anyhow::{Context, Result, bail};
+use chrono::TimeDelta;
+use log::{debug, info};
+use sqlx::{Sqlite, Transaction, query};
+
+use crate::app::App;
+
+#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
+pub enum DbVersion {
+    /// The database is not yet initialized.
+    Empty,
+
+    /// The first database version.
+    /// Introduced: 2025-02-16.
+    Zero,
+}
+const CURRENT_VERSION: DbVersion = DbVersion::Zero;
+
+async fn set_db_version(
+    tx: &mut Transaction<'_, Sqlite>,
+    old_version: Option<DbVersion>,
+    new_version: DbVersion,
+) -> Result<()> {
+    let valid_from = get_current_date();
+
+    if let Some(old_version) = old_version {
+        let valid_to = valid_from + 1;
+        let old_version = old_version.as_sql_integer();
+
+        query!(
+            "UPDATE version SET valid_to = ? WHERE namespace = 'yt' AND number = ?;",
+            valid_to,
+            old_version
+        )
+        .execute(&mut *(*tx))
+        .await?;
+    }
+
+    let version = new_version.as_sql_integer();
+
+    query!(
+        "INSERT INTO version (namespace, number, valid_from, valid_to) VALUES ('yt', ?, ?, NULL);",
+        version,
+        valid_from
+    )
+    .execute(&mut *(*tx))
+    .await?;
+
+    Ok(())
+}
+
+impl DbVersion {
+    fn as_sql_integer(self) -> i32 {
+        match self {
+            DbVersion::Empty => unreachable!("A empty version does not have an associated integer"),
+            DbVersion::Zero => 0,
+        }
+    }
+    fn from_db(number: i64, namespace: &str) -> Result<Self> {
+        match (number, namespace) {
+            (0, "yt") => Ok(DbVersion::Zero),
+
+            (0, other) => bail!("Db version is Zero, but got unknown namespace: '{other}'"),
+
+            (other, "yt") => bail!("Got unkown version for 'yt' namespace: {other}"),
+            (num, nasp) => bail!("Got unkown version number ({num}) and namespace ('{nasp}')"),
+        }
+    }
+
+    /// Try to update the database from version [`self`] to the [`CURRENT_VERSION`].
+    ///
+    /// Each update is atomic, so if this function fails you are still guaranteed to have a
+    /// database at version `get_version`.
+    async fn update(self, app: &App) -> Result<()> {
+        match self {
+            DbVersion::Empty => {
+                let mut tx = app.database.begin().await?;
+                debug!("Migrate: Empty -> Zero");
+
+                sqlx::raw_sql(include_str!("./sql/00_empty_to_zero.sql"))
+                    .execute(&mut *tx)
+                    .await?;
+
+                set_db_version(&mut tx, None, DbVersion::Zero).await?;
+
+                tx.commit().await?;
+                Box::pin(Self::Zero.update(app)).await
+            }
+
+            // This is the current version
+            DbVersion::Zero => {
+                debug!("Migrate: Zero -> One");
+
+                assert_eq!(self, CURRENT_VERSION);
+                assert_eq!(self, get_version(app).await?);
+                Ok(())
+            }
+        }
+    }
+}
+impl Display for DbVersion {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        // It is a unit only enum, thus we can simply use the Debug formatting
+        <Self as std::fmt::Debug>::fmt(self, f)
+    }
+}
+
+/// Returns the current data as UNIX time stamp.
+fn get_current_date() -> i64 {
+    let start = SystemTime::now();
+    let seconds_since_epoch: TimeDelta = TimeDelta::from_std(
+        start
+            .duration_since(UNIX_EPOCH)
+            .expect("Time went backwards"),
+    )
+    .expect("Time does not go backwards");
+
+    debug!(
+        "Adding a date with timestamp: {}",
+        seconds_since_epoch.num_seconds()
+    );
+
+    // All database dates should be after the UNIX_EPOCH (and thus positiv)
+    seconds_since_epoch.num_seconds()
+}
+
+/// Return the current database version.
+///
+/// # Panics
+/// Only if internal assertions fail.
+pub async fn get_version(app: &App) -> Result<DbVersion> {
+    let version_table_exists = {
+        let query = query!(
+            "SELECT 1 as result FROM sqlite_master WHERE type = 'table' AND name = 'version'"
+        )
+        .fetch_optional(&app.database)
+        .await?;
+        if let Some(output) = query {
+            assert_eq!(output.result, 1);
+            true
+        } else {
+            false
+        }
+    };
+    if !version_table_exists {
+        return Ok(DbVersion::Empty);
+    }
+
+    let current_version = query!(
+        "
+        SELECT namespace, number FROM version WHERE valid_to IS NULL;
+        "
+    )
+    .fetch_one(&app.database)
+    .await
+    .context("Failed to fetch version number")?;
+
+    DbVersion::from_db(current_version.number, current_version.namespace.as_str())
+}
+
+pub async fn migrate_db(app: &App) -> Result<()> {
+    let current_version = get_version(app).await?;
+
+    if current_version == CURRENT_VERSION {
+        return Ok(());
+    }
+
+    info!("Migrate database from version '{current_version}' to version '{CURRENT_VERSION}'");
+
+    current_version.update(app).await?;
+
+    Ok(())
+}
diff --git a/yt/src/storage/video_database/schema.sql b/yt/src/storage/migrate/sql/00_empty_to_zero.sql
index 3afd091..2e890e5 100644
--- a/yt/src/storage/video_database/schema.sql
+++ b/yt/src/storage/migrate/sql/00_empty_to_zero.sql
@@ -11,8 +11,22 @@
 -- All tables should be declared STRICT, as I actually like to have types checking (and a
 -- db that doesn't lie to me).
 
+-- Keep this table in sync with the `DbVersion` enumeration.
+CREATE TABLE version (
+    -- The `namespace` is only useful, if other tools ever build on this database
+    namespace   TEXT           NOT NULL,
+
+    -- The version.
+    number      INTEGER UNIQUE NOT NULL PRIMARY KEY,
+
+    -- The validity of this version as UNIX time stamp
+    valid_from  INTEGER        NOT NULL CHECK (valid_from < valid_to),
+    -- If set to `NULL`, represents the current version
+    valid_to    INTEGER UNIQUE          CHECK (valid_to > valid_from)
+) STRICT;
+
 -- Keep this table in sync with the `Video` structure
-CREATE TABLE IF NOT EXISTS videos (
+CREATE TABLE videos (
     cache_path                  TEXT UNIQUE                    CHECK (CASE WHEN cache_path IS NOT NULL THEN
                                                                             status == 2
                                                                       ELSE
@@ -43,7 +57,7 @@ CREATE TABLE IF NOT EXISTS videos (
 ) STRICT;
 
 -- Store additional metadata for the videos marked to be watched
-CREATE TABLE IF NOT EXISTS video_options (
+CREATE TABLE video_options (
     extractor_hash              TEXT UNIQUE NOT NULL PRIMARY KEY,
     subtitle_langs              TEXT        NOT NULL,
     playback_speed              REAL        NOT NULL,
@@ -51,7 +65,7 @@ CREATE TABLE IF NOT EXISTS video_options (
 ) STRICT;
 
 -- Store subscriptions
-CREATE TABLE IF NOT EXISTS subscriptions (
+CREATE TABLE subscriptions (
     name              TEXT UNIQUE NOT NULL PRIMARY KEY,
     url               TEXT        NOT NULL
 ) STRICT;
diff --git a/yt/src/storage/mod.rs b/yt/src/storage/mod.rs
index 6a12d8b..d945d0d 100644
--- a/yt/src/storage/mod.rs
+++ b/yt/src/storage/mod.rs
@@ -10,3 +10,4 @@
 
 pub mod subscriptions;
 pub mod video_database;
+pub mod migrate;