// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Releases: tag-based release notes and their uploaded assets. The asset bytes //! live on disk (under the data directory); this layer tracks the metadata. use sqlx::any::AnyRow; use sqlx::{AssertSqlSafe, Row}; use crate::{Store, StoreError, new_id, now_ms}; /// A release: notes attached to a git tag, optionally a draft or pre-release. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Release { /// ULID primary key. pub id: String, /// The repository this release belongs to. pub repo_id: String, /// The git tag the release is cut from. pub tag: String, /// The release title. pub name: String, /// The markdown release notes. pub body: Option, /// Whether this is a pre-release (not the "latest"). pub is_prerelease: bool, /// Whether this is an unpublished draft. pub is_draft: bool, /// The author's user id, if still present. pub author_id: Option, /// Creation time. pub created_at: i64, /// Last edit time. pub updated_at: i64, } /// Fields to create a release. #[derive(Debug, Clone)] pub struct NewRelease { /// The repository. pub repo_id: String, /// The git tag. pub tag: String, /// The release title. pub name: String, /// The markdown body. pub body: Option, /// Whether it is a pre-release. pub is_prerelease: bool, /// Whether it is a draft. pub is_draft: bool, /// The author's user id. pub author_id: String, } /// An uploaded release asset. The bytes are stored on disk keyed by `id`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReleaseAsset { /// ULID primary key (and on-disk file stem). pub id: String, /// The owning release. pub release_id: String, /// The original file name. pub name: String, /// Size in bytes. pub size: i64, /// The MIME content type. pub content_type: String, /// How many times it has been downloaded. pub download_count: i64, /// Upload time. pub created_at: i64, } const RELEASE_COLUMNS: &str = "id, repo_id, tag, name, body, is_prerelease, is_draft, \ author_id, created_at, updated_at"; const ASSET_COLUMNS: &str = "id, release_id, name, size, content_type, download_count, created_at"; impl Store { fn map_release(&self, row: &AnyRow) -> Result { Ok(Release { id: row.try_get("id")?, repo_id: row.try_get("repo_id")?, tag: row.try_get("tag")?, name: row.try_get("name")?, body: row.try_get("body")?, is_prerelease: self.get_bool(row, "is_prerelease")?, is_draft: self.get_bool(row, "is_draft")?, author_id: row.try_get("author_id")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, }) } /// Create a release. /// /// # Errors /// /// [`StoreError::Conflict`] if a release already exists for the tag, else /// [`StoreError::Query`]. pub async fn create_release(&self, new: NewRelease) -> Result { let now = now_ms(); let release = Release { id: new_id(), repo_id: new.repo_id, tag: new.tag, name: new.name, body: new.body, is_prerelease: new.is_prerelease, is_draft: new.is_draft, author_id: Some(new.author_id), created_at: now, updated_at: now, }; sqlx::query( "INSERT INTO releases \ (id, repo_id, tag, name, body, is_prerelease, is_draft, author_id, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", ) .bind(&release.id) .bind(&release.repo_id) .bind(&release.tag) .bind(&release.name) .bind(&release.body) .bind(release.is_prerelease) .bind(release.is_draft) .bind(&release.author_id) .bind(release.created_at) .bind(release.updated_at) .execute(&self.pool) .await .map_err(|e| crate::map_conflict(e, "release", &[("tag", "tag")]))?; Ok(release) } /// List a repository's releases, newest first (drafts included). /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn list_releases(&self, repo_id: &str) -> Result, StoreError> { let sql = format!( "SELECT {RELEASE_COLUMNS} FROM releases WHERE repo_id = $1 ORDER BY created_at DESC" ); let rows = sqlx::query(AssertSqlSafe(sql)) .bind(repo_id) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| self.map_release(r)) .collect::>() .map_err(Into::into) } /// Fetch a release by id. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn release_by_id(&self, id: &str) -> Result, StoreError> { let sql = format!("SELECT {RELEASE_COLUMNS} FROM releases WHERE id = $1"); let row = sqlx::query(AssertSqlSafe(sql)) .bind(id) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(|r| self.map_release(r)).transpose()?) } /// Fetch a repository's release for a given tag. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn release_by_tag( &self, repo_id: &str, tag: &str, ) -> Result, StoreError> { let sql = format!("SELECT {RELEASE_COLUMNS} FROM releases WHERE repo_id = $1 AND tag = $2"); let row = sqlx::query(AssertSqlSafe(sql)) .bind(repo_id) .bind(tag) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(|r| self.map_release(r)).transpose()?) } /// Update a release's editable fields. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn update_release( &self, id: &str, name: &str, body: Option<&str>, is_prerelease: bool, is_draft: bool, ) -> Result<(), StoreError> { sqlx::query( "UPDATE releases SET name = $1, body = $2, is_prerelease = $3, is_draft = $4, \ updated_at = $5 WHERE id = $6", ) .bind(name) .bind(body) .bind(is_prerelease) .bind(is_draft) .bind(now_ms()) .bind(id) .execute(&self.pool) .await?; Ok(()) } /// Delete a release (its assets cascade). /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_release(&self, id: &str) -> Result { let n = sqlx::query("DELETE FROM releases WHERE id = $1") .bind(id) .execute(&self.pool) .await? .rows_affected(); Ok(n > 0) } // ---- Assets ---- fn map_asset(row: &AnyRow) -> Result { Ok(ReleaseAsset { id: row.try_get("id")?, release_id: row.try_get("release_id")?, name: row.try_get("name")?, size: row.try_get("size")?, content_type: row.try_get("content_type")?, download_count: row.try_get("download_count")?, created_at: row.try_get("created_at")?, }) } /// Record an uploaded asset, returning its id (the on-disk file stem). /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn add_release_asset( &self, release_id: &str, name: &str, size: i64, content_type: &str, ) -> Result { let asset = ReleaseAsset { id: new_id(), release_id: release_id.to_string(), name: name.to_string(), size, content_type: content_type.to_string(), download_count: 0, created_at: now_ms(), }; sqlx::query( "INSERT INTO release_assets \ (id, release_id, name, size, content_type, download_count, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7)", ) .bind(&asset.id) .bind(&asset.release_id) .bind(&asset.name) .bind(asset.size) .bind(&asset.content_type) .bind(asset.download_count) .bind(asset.created_at) .execute(&self.pool) .await?; Ok(asset) } /// List a release's assets, oldest first. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn list_release_assets( &self, release_id: &str, ) -> Result, StoreError> { let sql = format!( "SELECT {ASSET_COLUMNS} FROM release_assets WHERE release_id = $1 ORDER BY created_at ASC" ); let rows = sqlx::query(AssertSqlSafe(sql)) .bind(release_id) .fetch_all(&self.pool) .await?; rows.iter() .map(Self::map_asset) .collect::>() .map_err(Into::into) } /// Fetch an asset by id. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn release_asset_by_id(&self, id: &str) -> Result, StoreError> { let sql = format!("SELECT {ASSET_COLUMNS} FROM release_assets WHERE id = $1"); let row = sqlx::query(AssertSqlSafe(sql)) .bind(id) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(Self::map_asset).transpose()?) } /// Delete an asset record (the caller removes the on-disk file). /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_release_asset(&self, id: &str) -> Result { let n = sqlx::query("DELETE FROM release_assets WHERE id = $1") .bind(id) .execute(&self.pool) .await? .rows_affected(); Ok(n > 0) } /// Increment an asset's download counter (best-effort). /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn bump_asset_download(&self, id: &str) -> Result<(), StoreError> { sqlx::query("UPDATE release_assets SET download_count = download_count + 1 WHERE id = $1") .bind(id) .execute(&self.pool) .await?; Ok(()) } }